From f41241255b7fc37cf6192e81a206e614e42fad38 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Fri, 23 Dec 2022 12:43:03 +0530 Subject: [PATCH 001/499] FOGL-7217: initial fix: do reconf in a separate thread, so that service's management interface is not stalled for long in case of long plugin_poll calls Signed-off-by: Amandeep Singh Arora --- C/services/south/include/south_plugin.h | 10 ++++ C/services/south/south_plugin.cpp | 65 +++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/C/services/south/include/south_plugin.h b/C/services/south/include/south_plugin.h index 2c88261b8a..264a4d4341 100644 --- a/C/services/south/include/south_plugin.h +++ b/C/services/south/include/south_plugin.h @@ -10,6 +10,10 @@ * Author: Mark Riddoch */ +#include +#include +#include + #include #include #include @@ -50,7 +54,13 @@ class SouthPlugin : public Plugin { std::string shutdownSaveData(); bool write(const std::string& name, const std::string& value); bool operation(const std::string& name, std::vector& ); + void callPluginReconf(std::string& newConfig); private: + + std::thread *reconfThread; + std::deque pendingNewConfig; + std::atomic reconfOngoing; + PLUGIN_HANDLE instance; void (*pluginStartPtr)(PLUGIN_HANDLE); Reading (*pluginPollPtr)(PLUGIN_HANDLE); diff --git a/C/services/south/south_plugin.cpp b/C/services/south/south_plugin.cpp index 5f931ec83e..c63d3f61a5 100755 --- a/C/services/south/south_plugin.cpp +++ b/C/services/south/south_plugin.cpp @@ -36,6 +36,8 @@ SouthPlugin::SouthPlugin(PLUGIN_HANDLE handle, const ConfigCategory& category) : manager->resolveSymbol(handle, "plugin_init"); instance = (*pluginInit)(&category); + reconfOngoing.store(false); + if (!instance) { Logger::getLogger()->error("plugin_init returned NULL, cannot proceed"); @@ -175,9 +177,15 @@ Reading SouthPlugin::poll() */ ReadingSet* SouthPlugin::pollV2() { - lock_guard guard(mtx2); - try { - std::vector *vec = this->pluginPollPtrV2(instance); + std::vector *vec = NULL; + + try + { + { + lock_guard guard(mtx2); + PRINT_FUNC; + vec = this->pluginPollPtrV2(instance); + } if(vec) { ReadingSet *set = new ReadingSet(vec); @@ -198,14 +206,61 @@ ReadingSet* SouthPlugin::pollV2() } } +static void reconfThreadMain(void *arg, std::string newConfig) +{ + SouthPlugin *sp = (SouthPlugin *)arg; + Logger::getLogger()->info("reconfThreadMain(): CALLING sp->callPluginReconf()"); + sp->callPluginReconf(newConfig); + Logger::getLogger()->info("reconfThreadMain(): DONE sp->callPluginReconf()"); +} + +void SouthPlugin::callPluginReconf(std::string& newConfig) +{ + Logger::getLogger()->info("SouthPlugin::callPluginReconf START"); + this->pluginReconfigurePtr(&instance, newConfig); + Logger::getLogger()->info("SouthPlugin::callPluginReconf DONE"); +} + + /** * Call the reconfigure method in the plugin */ void SouthPlugin::reconfigure(const string& newConfig) { - lock_guard guard(mtx2); + std::ostringstream ss; + ss << std::this_thread::get_id(); + Logger::getLogger()->info("SouthPlugin::reconfigure(): threadid=%s", ss.str().c_str()); + try { - this->pluginReconfigurePtr(&instance, newConfig); + PRINT_FUNC; + bool reconfNow = true; + if (reconfOngoing.load()) + { + Logger::getLogger()->info("SouthPlugin::reconfigure(): reconfOngoing is TRUE"); + if (reconfThread->joinable()) + { + Logger::getLogger()->info("SouthPlugin::reconfigure(): reconfThread is JOINABLE"); + reconfThread->join(); + reconfNow = true; + } + else + { + Logger::getLogger()->info("SouthPlugin::reconfigure(): reconfThread is NOT JOINABLE"); + reconfNow = false; + } + } + else + Logger::getLogger()->info("SouthPlugin::reconfigure(): reconfOngoing is FALSE"); + + pendingNewConfig.emplace_back(newConfig); + + if (reconfNow) + { + std::string reconfStr = pendingNewConfig.front(); + pendingNewConfig.pop_front(); + Logger::getLogger()->info("SouthPlugin::reconfigure(): Creating new reconfThread for reconfStr=%s", reconfStr.c_str()); + reconfThread = new std::thread(reconfThreadMain, this, reconfStr); + } if (!instance) { Logger::getLogger()->error("plugin_reconfigure returned NULL, cannot proceed"); From ef77a86e62d893b98e66127b77d1832f44e5958c Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Fri, 23 Dec 2022 16:48:24 +0530 Subject: [PATCH 002/499] FOGL-7217: Fix updated Signed-off-by: Amandeep Singh Arora --- C/services/south/include/south_plugin.h | 10 ---- C/services/south/include/south_service.h | 5 ++ C/services/south/south.cpp | 51 +++++++++++++++++-- C/services/south/south_plugin.cpp | 65 ++---------------------- 4 files changed, 57 insertions(+), 74 deletions(-) diff --git a/C/services/south/include/south_plugin.h b/C/services/south/include/south_plugin.h index 264a4d4341..2c88261b8a 100644 --- a/C/services/south/include/south_plugin.h +++ b/C/services/south/include/south_plugin.h @@ -10,10 +10,6 @@ * Author: Mark Riddoch */ -#include -#include -#include - #include #include #include @@ -54,13 +50,7 @@ class SouthPlugin : public Plugin { std::string shutdownSaveData(); bool write(const std::string& name, const std::string& value); bool operation(const std::string& name, std::vector& ); - void callPluginReconf(std::string& newConfig); private: - - std::thread *reconfThread; - std::deque pendingNewConfig; - std::atomic reconfOngoing; - PLUGIN_HANDLE instance; void (*pluginStartPtr)(PLUGIN_HANDLE); Reading (*pluginPollPtr)(PLUGIN_HANDLE); diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index 7233043de0..1853258446 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -61,6 +61,7 @@ class SouthService : public ServiceAuthHandler { bool setPoint(const std::string& name, const std::string& value); bool operation(const std::string& name, std::vector& ); void setDryRun() { m_dryRun = true; }; + void handleReconf(const std::string& categoryName, const std::string& category); private: void addConfigDefaults(DefaultConfigCategory& defaults); bool loadPlugin(); @@ -70,6 +71,10 @@ class SouthService : public ServiceAuthHandler { std::string current_name); void throttlePoll(); private: + std::thread *m_reconfThread; + std::deque> m_pendingNewConfig; + std::atomic m_reconfOngoing; + SouthPlugin *southPlugin; Logger *logger; AssetTracker *m_assetTracker; diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 811b457575..02ee0c9230 100755 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -241,6 +241,8 @@ SouthService::SouthService(const string& myName, const string& token) : logger = new Logger(myName); logger->setMinLevel("warning"); + + m_reconfOngoing.store(false); } /** @@ -812,10 +814,15 @@ void SouthService::restart() logger->info("South service shutdown for restart in progress."); } -/** - * Configuration change notification - */ -void SouthService::configChange(const string& categoryName, const string& category) +static void reconfThreadMain(void *arg, std::pair newConfig) +{ + SouthService *ss = (SouthService *)arg; + Logger::getLogger()->debug("reconfThreadMain(): Spawned new thread for calling sp->callPluginReconf()"); + ss->handleReconf(newConfig.first, newConfig.second); + Logger::getLogger()->debug("reconfThreadMain(): DONE ss->callPluginReconf()"); +} + +void SouthService::handleReconf(const string& categoryName, const string& category) { logger->info("Configuration change in category %s: %s", categoryName.c_str(), category.c_str()); @@ -940,6 +947,42 @@ void SouthService::configChange(const string& categoryName, const string& catego } } +/** + * Configuration change notification + */ +void SouthService::configChange(const string& categoryName, const string& category) +{ + bool reconfNow = true; + if (m_reconfOngoing.load()) + { + Logger::getLogger()->debug("SouthPlugin::reconfigure(): m_reconfOngoing is TRUE"); + if (m_reconfThread->joinable()) + { + Logger::getLogger()->debug("SouthPlugin::reconfigure(): reconfThread is JOINABLE"); + m_reconfThread->join(); + reconfNow = true; + } + else + { + Logger::getLogger()->debug("SouthPlugin::reconfigure(): reconfThread is NOT JOINABLE"); + reconfNow = false; + } + } + else + Logger::getLogger()->debug("SouthPlugin::reconfigure(): m_reconfOngoing is FALSE"); + + m_pendingNewConfig.emplace_back(std::make_pair(categoryName, category)); + + if (reconfNow) + { + auto reconfValues = m_pendingNewConfig.front(); + m_pendingNewConfig.pop_front(); + Logger::getLogger()->debug("SouthService::configChange(): Creating new reconfThread for reconfStr: categoryName=%s, category=%s", + reconfValues.first.c_str(), reconfValues.second.c_str()); + m_reconfThread = new std::thread(reconfThreadMain, this, reconfValues); + } +} + /** * Add the generic south service configuration options to the advanced * category diff --git a/C/services/south/south_plugin.cpp b/C/services/south/south_plugin.cpp index c63d3f61a5..5f931ec83e 100755 --- a/C/services/south/south_plugin.cpp +++ b/C/services/south/south_plugin.cpp @@ -36,8 +36,6 @@ SouthPlugin::SouthPlugin(PLUGIN_HANDLE handle, const ConfigCategory& category) : manager->resolveSymbol(handle, "plugin_init"); instance = (*pluginInit)(&category); - reconfOngoing.store(false); - if (!instance) { Logger::getLogger()->error("plugin_init returned NULL, cannot proceed"); @@ -177,15 +175,9 @@ Reading SouthPlugin::poll() */ ReadingSet* SouthPlugin::pollV2() { - std::vector *vec = NULL; - - try - { - { - lock_guard guard(mtx2); - PRINT_FUNC; - vec = this->pluginPollPtrV2(instance); - } + lock_guard guard(mtx2); + try { + std::vector *vec = this->pluginPollPtrV2(instance); if(vec) { ReadingSet *set = new ReadingSet(vec); @@ -206,61 +198,14 @@ ReadingSet* SouthPlugin::pollV2() } } -static void reconfThreadMain(void *arg, std::string newConfig) -{ - SouthPlugin *sp = (SouthPlugin *)arg; - Logger::getLogger()->info("reconfThreadMain(): CALLING sp->callPluginReconf()"); - sp->callPluginReconf(newConfig); - Logger::getLogger()->info("reconfThreadMain(): DONE sp->callPluginReconf()"); -} - -void SouthPlugin::callPluginReconf(std::string& newConfig) -{ - Logger::getLogger()->info("SouthPlugin::callPluginReconf START"); - this->pluginReconfigurePtr(&instance, newConfig); - Logger::getLogger()->info("SouthPlugin::callPluginReconf DONE"); -} - - /** * Call the reconfigure method in the plugin */ void SouthPlugin::reconfigure(const string& newConfig) { - std::ostringstream ss; - ss << std::this_thread::get_id(); - Logger::getLogger()->info("SouthPlugin::reconfigure(): threadid=%s", ss.str().c_str()); - + lock_guard guard(mtx2); try { - PRINT_FUNC; - bool reconfNow = true; - if (reconfOngoing.load()) - { - Logger::getLogger()->info("SouthPlugin::reconfigure(): reconfOngoing is TRUE"); - if (reconfThread->joinable()) - { - Logger::getLogger()->info("SouthPlugin::reconfigure(): reconfThread is JOINABLE"); - reconfThread->join(); - reconfNow = true; - } - else - { - Logger::getLogger()->info("SouthPlugin::reconfigure(): reconfThread is NOT JOINABLE"); - reconfNow = false; - } - } - else - Logger::getLogger()->info("SouthPlugin::reconfigure(): reconfOngoing is FALSE"); - - pendingNewConfig.emplace_back(newConfig); - - if (reconfNow) - { - std::string reconfStr = pendingNewConfig.front(); - pendingNewConfig.pop_front(); - Logger::getLogger()->info("SouthPlugin::reconfigure(): Creating new reconfThread for reconfStr=%s", reconfStr.c_str()); - reconfThread = new std::thread(reconfThreadMain, this, reconfStr); - } + this->pluginReconfigurePtr(&instance, newConfig); if (!instance) { Logger::getLogger()->error("plugin_reconfigure returned NULL, cannot proceed"); From 7163252c323b6575712117a1242c2b5dfa1cba52 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 15:32:28 +0530 Subject: [PATCH 003/499] Common utility function added to get OS platform details & also added function to check OS ID is of debian type or Not Signed-off-by: ashish-jabble --- python/fledge/common/utils.py | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/python/fledge/common/utils.py b/python/fledge/common/utils.py index e8d77f317a..cd9babfa73 100644 --- a/python/fledge/common/utils.py +++ b/python/fledge/common/utils.py @@ -85,3 +85,42 @@ def decorator(Class): def eprint(*args, **kwargs): """ eprintf -- convenience print function that prints to stderr """ print(*args, *kwargs, file=sys.stderr) + + +def read_os_release(): + """ General information to identifying the operating system """ + import ast + import re + os_details = {} + try: + filename = '/etc/os-release' + f = open(filename) + except FileNotFoundError: + filename = '/usr/lib/os-release' + f = open(filename) + + for line_number, line in enumerate(f, start=1): + line = line.rstrip() + if not line or line.startswith('#'): + continue + m = re.match(r'([A-Z][A-Z_0-9]+)=(.*)', line) + if m: + name, val = m.groups() + if val and val[0] in '"\'': + val = ast.literal_eval(val) + os_details.update({name: val}) + return os_details + + +def is_debian(): + """ + To check if the Operating system is of Debian type or Not + Examples: + a) For an operating system with "ID=centos", an assignment of "ID_LIKE="rhel fedora"" is appropriate + b) For an operating system with "ID=ubuntu/raspbian", an assignment of "ID_LIKE=debian" is appropriate. + """ + os_release = read_os_release() + id_like = os_release.get('ID_LIKE') + if id_like is not None and any(x in id_like.lower() for x in ['centos', 'rhel', 'redhat', 'fedora']): + return False + return True From 7c91ff007f78f14ed038f5ce67ae950597aab9c0 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 15:34:34 +0530 Subject: [PATCH 004/499] syslog API updated as per common util method for identifying an OS Signed-off-by: ashish-jabble --- python/fledge/services/core/api/support.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/python/fledge/services/core/api/support.py b/python/fledge/services/core/api/support.py index d95b279e37..d9d53487c2 100644 --- a/python/fledge/services/core/api/support.py +++ b/python/fledge/services/core/api/support.py @@ -5,7 +5,6 @@ # FLEDGE_END import os -import platform import subprocess import json import logging @@ -15,11 +14,10 @@ from pathlib import Path from aiohttp import web -from fledge.common import logger +from fledge.common import logger, utils from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA from fledge.services.core.support import SupportBuilder -from fledge.common.common import _FLEDGE_ROOT __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -28,12 +26,8 @@ _logger = logger.setup(__name__, level=logging.INFO) -_SYSLOG_FILE = '/var/log/syslog' -if any(x in platform.platform() for x in ['centos', 'redhat']): - _SYSLOG_FILE = '/var/log/messages' - +_SYSLOG_FILE = '/var/log/syslog' if utils.is_debian() else '/var/log/messages' _SCRIPTS_DIR = "{}/scripts".format(_FLEDGE_ROOT) - __DEFAULT_LIMIT = 20 __DEFAULT_OFFSET = 0 __DEFAULT_LOG_SOURCE = 'Fledge' From f55e522c7bd80275a967376f0ac98cb339321a42 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 16:29:05 +0530 Subject: [PATCH 005/499] support bundle updated as per common util method for identifying an OS Signed-off-by: ashish-jabble --- python/fledge/services/core/support.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index 7b84d2ca3f..01b84b4f61 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -9,7 +9,6 @@ import logging import datetime -import platform import os from os.path import basename import glob @@ -19,13 +18,14 @@ import tarfile import fnmatch import subprocess -from fledge.services.core.connect import * -from fledge.common import logger + +from fledge.common import logger, utils from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA from fledge.common.configuration_manager import ConfigurationManager from fledge.common.plugin_discovery import PluginDiscovery from fledge.common.storage_client import payload_builder from fledge.services.core.api.service import get_service_records, get_service_installed +from fledge.services.core.connect import * __author__ = "Amarendra K Sinha" @@ -35,12 +35,9 @@ _LOGGER = logger.setup(__name__, level=logging.INFO) _NO_OF_FILES_TO_RETAIN = 3 -_SYSLOG_FILE = '/var/log/syslog' +_SYSLOG_FILE = '/var/log/syslog' if utils.is_debian() else '/var/log/messages' _PATH = _FLEDGE_DATA if _FLEDGE_DATA else _FLEDGE_ROOT + '/data' -if ('centos' in platform.platform()) or ('redhat' in platform.platform()): - _SYSLOG_FILE = '/var/log/messages' - class SupportBuilder: From 084f70eef8ffc9272793f6e6c0bdbe0cb335f95d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 16:39:33 +0530 Subject: [PATCH 006/499] service API updated as per common util method for identifying debian Signed-off-by: ashish-jabble --- python/fledge/services/core/api/service.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index 9a58eac121..0554c06699 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -9,7 +9,6 @@ import os import datetime import uuid -import platform import json import multiprocessing from aiohttp import web @@ -263,9 +262,7 @@ async def add_service(request): delimiter = '.' if str(version).count(delimiter) != 2: raise ValueError('Service semantic version is incorrect; it should be like X.Y.Z') - - _platform = platform.platform() - pkg_mgt = 'yum' if 'centos' in _platform or 'redhat' in _platform else 'apt' + pkg_mgt = 'apt' if utils.is_debian() else 'yum' # Check Pre-conditions from Packages table # if status is -1 (Already in progress) then return as rejected request storage = connect.get_storage_async() @@ -684,13 +681,11 @@ async def _put_schedule(protocol: str, host: str, port: int, sch_id: uuid, is_en def do_update(http_enabled: bool, host: str, port: int, storage: connect, pkg_name: str, uid: str, schedules: list) -> None: _logger.info("{} service update started...".format(pkg_name)) - _platform = platform.platform() stdout_file_path = common.create_log_file("update", pkg_name) - pkg_mgt = 'apt' + pkg_mgt = 'apt' if utils.is_debian() else 'yum' cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) protocol = "HTTP" if http_enabled else "HTTPS" - if 'centos' in _platform or 'redhat' in _platform: - pkg_mgt = 'yum' + if pkg_mgt == 'yum': cmd = "sudo {} check-update > {} 2>&1".format(pkg_mgt, stdout_file_path) ret_code = os.system(cmd) # sudo apt/yum -y install only happens when update is without any error From 84e66648e7df3eb93cd6fa532d530e092c2a32f6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 16:50:52 +0530 Subject: [PATCH 007/499] Package Updater API updated as per common util method for identifying debian Signed-off-by: ashish-jabble --- python/fledge/services/core/api/update.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/python/fledge/services/core/api/update.py b/python/fledge/services/core/api/update.py index 0fde626928..7777e20d60 100644 --- a/python/fledge/services/core/api/update.py +++ b/python/fledge/services/core/api/update.py @@ -13,10 +13,9 @@ import os import asyncio import re -import platform +from fledge.common import logger, utils from fledge.services.core import server -from fledge.common import logger from fledge.services.core.scheduler.entities import ManualSchedule _LOG_LEVEL = 20 @@ -126,10 +125,10 @@ async def get_updates(request: web.Request) -> web.Response: Example curl -sX GET http://localhost:8081/fledge/update |jq """ - _platform = platform.platform() - update_cmd = "sudo apt update" - upgradable_pkgs_check_cmd = "apt list --upgradable | grep \^fledge" - if "centos" in _platform or "redhat" in _platform: + if utils.is_debian(): + update_cmd = "sudo apt update" + upgradable_pkgs_check_cmd = "apt list --upgradable | grep \^fledge" + else: update_cmd = "sudo yum check-update" upgradable_pkgs_check_cmd = "yum list updates | grep \^fledge" From 8c5d9738157dbc4e93e0942437594ec55e7ec77c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 16:56:48 +0530 Subject: [PATCH 008/499] Plugins common updated as per common util method for identifying debian Signed-off-by: ashish-jabble --- python/fledge/services/core/api/plugins/common.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/python/fledge/services/core/api/plugins/common.py b/python/fledge/services/core/api/plugins/common.py index ff3e3c8c16..48fcb9f202 100644 --- a/python/fledge/services/core/api/plugins/common.py +++ b/python/fledge/services/core/api/plugins/common.py @@ -9,7 +9,6 @@ import types import logging import os -import platform import json import glob import importlib.util @@ -17,7 +16,7 @@ from datetime import datetime from functools import lru_cache -from fledge.common import logger +from fledge.common import logger, utils as common_utils from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA, _FLEDGE_PLUGIN_PATH from fledge.services.core.api import utils from fledge.services.core.api.plugins.exceptions import * @@ -171,9 +170,8 @@ async def fetch_available_packages(package_type: str = "") -> tuple: stdout_file_path = create_log_file(action="list") tmp_log_output_fp = stdout_file_path.split('logs/')[:1][0] + "logs/output.txt" - _platform = platform.platform() pkg_type = "" if package_type is None else package_type - pkg_mgt = 'yum' if 'centos' in _platform or 'redhat' in _platform else 'apt' + pkg_mgt = 'apt' if common_utils.is_debian() else 'yum' category = await server.Server._configuration_manager.get_category_all_items("Installation") max_update_cat_item = category['maxUpdate'] pkg_cache_mgr = server.Server._package_cache_manager @@ -186,8 +184,7 @@ async def fetch_available_packages(package_type: str = "") -> tuple: if duration_in_sec > (24 / int(max_update_cat_item['value'])) * 60 * 60 or not last_accessed_time: _logger.info("Attempting update on {}".format(now)) cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) - if 'centos' in _platform or 'redhat' in _platform: - pkg_mgt = 'yum' + if pkg_mgt == 'yum': cmd = "sudo {} check-update > {} 2>&1".format(pkg_mgt, stdout_file_path) # Execute command os.system(cmd) From 6819cd97cda2e80affa400f857878bf76f255dd7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 17:01:48 +0530 Subject: [PATCH 009/499] Plugins install API updated as per common util method for identifying debian Signed-off-by: ashish-jabble --- .../services/core/api/plugins/install.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/python/fledge/services/core/api/plugins/install.py b/python/fledge/services/core/api/plugins/install.py index 29cfa88303..571b9a994b 100644 --- a/python/fledge/services/core/api/plugins/install.py +++ b/python/fledge/services/core/api/plugins/install.py @@ -5,7 +5,6 @@ # FLEDGE_END import os -import platform import subprocess import logging import asyncio @@ -21,17 +20,18 @@ from typing import Dict from datetime import datetime +from fledge.common import logger, utils from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA -from fledge.services.core.api.plugins import common -from fledge.common import logger -from fledge.services.core.api.plugins.exceptions import * -from fledge.services.core import connect -from fledge.common.configuration_manager import ConfigurationManager from fledge.common.audit_logger import AuditLogger -from fledge.services.core import server +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.plugin_discovery import PluginDiscovery from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError -from fledge.common.plugin_discovery import PluginDiscovery +from fledge.services.core import connect +from fledge.services.core import server +from fledge.services.core.api.plugins import common +from fledge.services.core.api.plugins.exceptions import * + __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2019 Dianomic Systems Inc." @@ -118,8 +118,7 @@ async def add_plugin(request: web.Request) -> web.Response: if name not in plugins: raise KeyError('{} plugin is not available for the configured repository'.format(name)) - _platform = platform.platform() - pkg_mgt = 'yum' if 'centos' in _platform or 'redhat' in _platform else 'apt' + pkg_mgt = 'apt' if utils.is_debian() else 'yum' # Insert record into Packages table insert_payload = PayloadBuilder().INSERT(id=str(uuid.uuid4()), name=name, action=action, status=-1, log_file_uri="").payload() From 0177908ead47da27ba720df27833eed49f1e8a47 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 17:07:02 +0530 Subject: [PATCH 010/499] Plugins remove API updated as per common util method for identifying debian Signed-off-by: ashish-jabble --- .../services/core/api/plugins/remove.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index 36dae1e74f..e11b61e2bf 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -5,7 +5,6 @@ # FLEDGE_END import aiohttp -import platform import os import logging import json @@ -14,16 +13,17 @@ import multiprocessing from aiohttp import web -from fledge.common import logger +from fledge.common import logger, utils +from fledge.common.audit_logger import AuditLogger +from fledge.common.common import _FLEDGE_ROOT +from fledge.common.configuration_manager import ConfigurationManager from fledge.common.plugin_discovery import PluginDiscovery +from fledge.common.storage_client.exceptions import StorageServerError +from fledge.common.storage_client.payload_builder import PayloadBuilder +from fledge.services.core import connect from fledge.services.core.api.plugins import common from fledge.services.core.api.plugins.exceptions import * -from fledge.services.core import connect -from fledge.common.storage_client.payload_builder import PayloadBuilder -from fledge.common.configuration_manager import ConfigurationManager -from fledge.common.common import _FLEDGE_ROOT -from fledge.common.audit_logger import AuditLogger -from fledge.common.storage_client.exceptions import StorageServerError + __author__ = "Rajesh Kumar" __copyright__ = "Copyright (c) 2020, Dianomic Systems Inc." @@ -240,9 +240,8 @@ def purge_plugin(plugin_type: str, name: str, uid: uuid, storage: connect) -> tu name = name.replace('_', '-').lower() plugin_name = 'fledge-{}-{}'.format(plugin_type, name) - get_platform = platform.platform() try: - if 'centos' in get_platform or 'redhat' in get_platform: + if not utils.is_debian(): rpm_list = os.popen('rpm -qa | grep fledge*').read() _logger.debug("rpm list : {}".format(rpm_list)) if len(rpm_list): From 56a532195802261dab6b0904e02bd5e7d6a24d5d Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Mon, 26 Dec 2022 17:07:50 +0530 Subject: [PATCH 011/499] FOGL-7217: Fix updated to handle multiple waiting reconf requests Signed-off-by: Amandeep Singh Arora --- C/services/south/include/south_service.h | 8 +- C/services/south/south.cpp | 320 +++++++++++++---------- 2 files changed, 191 insertions(+), 137 deletions(-) diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index 1853258446..d71f6bd070 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -61,7 +61,8 @@ class SouthService : public ServiceAuthHandler { bool setPoint(const std::string& name, const std::string& value); bool operation(const std::string& name, std::vector& ); void setDryRun() { m_dryRun = true; }; - void handleReconf(const std::string& categoryName, const std::string& category); + void handlePendingReconf(); + private: void addConfigDefaults(DefaultConfigCategory& defaults); bool loadPlugin(); @@ -71,9 +72,10 @@ class SouthService : public ServiceAuthHandler { std::string current_name); void throttlePoll(); private: - std::thread *m_reconfThread; + std::thread *m_reconfThread; std::deque> m_pendingNewConfig; - std::atomic m_reconfOngoing; + std::mutex m_pendingNewConfigMutex; + std::condition_variable m_cvNewReconf; SouthPlugin *southPlugin; Logger *logger; diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 02ee0c9230..bdb1851d9f 100755 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -223,6 +223,9 @@ void doIngestV2(Ingest *ingest, ReadingSet *set) } +static void reconfThreadMain(void *arg); + + /** * Constructor for the south service */ @@ -242,7 +245,7 @@ SouthService::SouthService(const string& myName, const string& token) : logger = new Logger(myName); logger->setMinLevel("warning"); - m_reconfOngoing.store(false); + m_reconfThread = new std::thread(reconfThreadMain, this); } /** @@ -549,6 +552,22 @@ void SouthService::start(string& coreAddress, unsigned short corePort) } else // V2 poll method { + PRINT_FUNC; + while(1) + { + unsigned int numPendingReconfs; + { + lock_guard guard(m_pendingNewConfigMutex); + numPendingReconfs = m_pendingNewConfig.size(); + } + if (numPendingReconfs) + { + Logger::getLogger()->debug("SouthService::start(): %d entries in m_pendingNewConfig, poll thread yielding CPU", numPendingReconfs); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + else + break; + } ReadingSet *set = southPlugin->pollV2(); if (set) { @@ -814,172 +833,205 @@ void SouthService::restart() logger->info("South service shutdown for restart in progress."); } -static void reconfThreadMain(void *arg, std::pair newConfig) +/** + * Separate thread to run plugin_reconf, to avoid blocking + * service's management interface due to long plugin_poll calls + */ +static void reconfThreadMain(void *arg) { SouthService *ss = (SouthService *)arg; - Logger::getLogger()->debug("reconfThreadMain(): Spawned new thread for calling sp->callPluginReconf()"); - ss->handleReconf(newConfig.first, newConfig.second); - Logger::getLogger()->debug("reconfThreadMain(): DONE ss->callPluginReconf()"); + Logger::getLogger()->info("reconfThreadMain(): Spawned new thread for plugin reconf"); + while(1) + { + ss->handlePendingReconf(); + } + Logger::getLogger()->info("reconfThreadMain(): plugin reconf thread exiting"); } -void SouthService::handleReconf(const string& categoryName, const string& category) +/** + * Handle configuration change notification + */ +void SouthService::handlePendingReconf() { - logger->info("Configuration change in category %s: %s", categoryName.c_str(), - category.c_str()); - if (categoryName.compare(m_name) == 0) - { - m_config = ConfigCategory(m_name, category); - try { - southPlugin->reconfigure(category); - } - catch (...) { - logger->fatal("Unrecoverable failure during South plugin reconfigure, south service exiting..."); - shutdown(); - } - // Let ingest class check for changes to filter pipeline - m_ingest->configChange(categoryName, category); - } - if (categoryName.compare(m_name+"Advanced") == 0) + Logger::getLogger()->info("SouthService::handlePendingReconf: Going into cv wait"); + mutex mtx; + unique_lock lck(mtx); + m_cvNewReconf.wait(lck); + Logger::getLogger()->info("SouthService::handlePendingReconf: cv wait has completed"); + + while(1) { - m_configAdvanced = ConfigCategory(m_name+"Advanced", category); - if (m_configAdvanced.itemExists("statistics")) + unsigned int numPendingReconfs = 0; { - m_ingest->setStatistics(m_configAdvanced.getValue("statistics")); + lock_guard guard(m_pendingNewConfigMutex); + numPendingReconfs = m_pendingNewConfig.size(); + if (numPendingReconfs) + Logger::getLogger()->info("SouthService::handlePendingReconf(): will process %d entries in m_pendingNewConfig", numPendingReconfs); + else + { + Logger::getLogger()->info("SouthService::handlePendingReconf DONE"); + break; + } } - if (! southPlugin->isAsync()) + + for (unsigned int i=0; iinfo("SouthService::handlePendingReconf(): Handling Configuration change #%d", i); + std::pair *reconfValue = NULL; + { + lock_guard guard(m_pendingNewConfigMutex); + reconfValue = &m_pendingNewConfig[i]; + } + std::string categoryName = reconfValue->first; + std::string category = reconfValue->second; + + logger->info("Configuration change in category %s: %s", categoryName.c_str(), category.c_str()); + if (categoryName.compare(m_name) == 0) + { + m_config = ConfigCategory(m_name, category); + try { + PRINT_FUNC; + southPlugin->reconfigure(category); + PRINT_FUNC; + } + catch (...) { + logger->fatal("Unrecoverable failure during South plugin reconfigure, south service exiting..."); + shutdown(); + } + // Let ingest class check for changes to filter pipeline + m_ingest->configChange(categoryName, category); + } + PRINT_FUNC; + if (categoryName.compare(m_name+"Advanced") == 0) + { + m_configAdvanced = ConfigCategory(m_name+"Advanced", category); + if (m_configAdvanced.itemExists("statistics")) { - logger->warn("Invalid setting of reading rate, defaulting to 1"); - m_readingsPerSec = 1; + m_ingest->setStatistics(m_configAdvanced.getValue("statistics")); } - string units = m_configAdvanced.getValue("units"); - unsigned long dividend = 1000000; - if (units.compare("second") == 0) - dividend = 1000000; - else if (units.compare("minute") == 0) - dividend = 60000000; - else if (units.compare("hour") == 0) - dividend = 3600000000; - if (newval != m_readingsPerSec || m_rateUnits.compare(units) != 0) + if (! southPlugin->isAsync()) + { + try { + unsigned long newval = (unsigned long)strtol(m_configAdvanced.getValue("readingsPerSec").c_str(), NULL, 10); + if (newval < 1) + { + logger->warn("Invalid setting of reading rate, defaulting to 1"); + m_readingsPerSec = 1; + } + string units = m_configAdvanced.getValue("units"); + unsigned long dividend = 1000000; + if (units.compare("second") == 0) + dividend = 1000000; + else if (units.compare("minute") == 0) + dividend = 60000000; + else if (units.compare("hour") == 0) + dividend = 3600000000; + if (newval != m_readingsPerSec || m_rateUnits.compare(units) != 0) + { + m_readingsPerSec = newval; + m_rateUnits = units; + close(m_timerfd); + unsigned long usecs = dividend / m_readingsPerSec; + if (usecs > MAX_SLEEP * 1000000) + { + double x = usecs / (MAX_SLEEP * 1000000); + m_repeatCnt = ceil(x); + usecs /= m_repeatCnt; + } + else + { + m_repeatCnt = 1; + } + m_desiredRate.tv_sec = (int)(usecs / 1000000); + m_desiredRate.tv_usec = (int)(usecs % 1000000); + m_currentRate = m_desiredRate; + m_timerfd = createTimerFd(m_desiredRate); // interval to be passed is in usecs + } + } catch (ConfigItemNotFound e) { + logger->error("Failed to update poll interval following configuration change"); + } + } + unsigned long threshold = 5000; // This should never be used + if (m_configAdvanced.itemExists("bufferThreshold")) + { + threshold = (unsigned int)strtol(m_configAdvanced.getValue("bufferThreshold").c_str(), NULL, 10); + m_ingest->setThreshold(threshold); + } + if (m_configAdvanced.itemExists("maxSendLatency")) + { + m_ingest->setTimeout(strtol(m_configAdvanced.getValue("maxSendLatency").c_str(), NULL, 10)); + } + if (m_configAdvanced.itemExists("logLevel")) + { + string prevLogLevel = logger->getMinLevel(); + logger->setMinLevel(m_configAdvanced.getValue("logLevel")); + + PluginManager *manager = PluginManager::getInstance(); + PLUGIN_TYPE type = manager->getPluginImplType(southPlugin->getHandle()); + logger->debug("%s:%d: South plugin type = %s", __FUNCTION__, __LINE__, (type==PYTHON_PLUGIN)?"PYTHON_PLUGIN":"BINARY_PLUGIN"); + + // propagate loglevel change to filter irrespective whether the host plugin is python/binary + m_ingest->configChange(categoryName, "logLevel"); + + if (type == PYTHON_PLUGIN) + { + // propagate loglevel changes to python filters/plugins, if present + logger->debug("prevLogLevel=%s, m_configAdvanced.getValue(\"logLevel\")=%s", prevLogLevel.c_str(), m_configAdvanced.getValue("logLevel").c_str()); + if (prevLogLevel.compare(m_configAdvanced.getValue("logLevel")) != 0) + { + logger->debug("%s:%d: calling southPlugin->reconfigure() for updating loglevel", __FUNCTION__, __LINE__); + southPlugin->reconfigure("logLevel"); + } + } + } + if (m_configAdvanced.itemExists("throttle")) { - m_readingsPerSec = newval; - m_rateUnits = units; - close(m_timerfd); - unsigned long usecs = dividend / m_readingsPerSec; - if (usecs > MAX_SLEEP * 1000000) + string throt = m_configAdvanced.getValue("throttle"); + if (throt[0] == 't' || throt[0] == 'T') { - double x = usecs / (MAX_SLEEP * 1000000); - m_repeatCnt = ceil(x); - usecs /= m_repeatCnt; + m_throttle = true; + m_highWater = threshold + + (((float)threshold * SOUTH_THROTTLE_HIGH_PERCENT) / 100.0); + m_lowWater = threshold + + (((float)threshold * SOUTH_THROTTLE_LOW_PERCENT) / 100.0); + logger->info("Throttling is enabled, high water mark is set to %ld", m_highWater); } else { - m_repeatCnt = 1; + m_throttle = false; } - m_desiredRate.tv_sec = (int)(usecs / 1000000); - m_desiredRate.tv_usec = (int)(usecs % 1000000); - m_currentRate = m_desiredRate; - m_timerfd = createTimerFd(m_desiredRate); // interval to be passed is in usecs } - } catch (ConfigItemNotFound e) { - logger->error("Failed to update poll interval following configuration change"); } - } - unsigned long threshold = 5000; // This should never be used - if (m_configAdvanced.itemExists("bufferThreshold")) - { - threshold = (unsigned int)strtol(m_configAdvanced.getValue("bufferThreshold").c_str(), NULL, 10); - m_ingest->setThreshold(threshold); - } - if (m_configAdvanced.itemExists("maxSendLatency")) - { - m_ingest->setTimeout(strtol(m_configAdvanced.getValue("maxSendLatency").c_str(), NULL, 10)); - } - if (m_configAdvanced.itemExists("logLevel")) - { - string prevLogLevel = logger->getMinLevel(); - logger->setMinLevel(m_configAdvanced.getValue("logLevel")); - - PluginManager *manager = PluginManager::getInstance(); - PLUGIN_TYPE type = manager->getPluginImplType(southPlugin->getHandle()); - logger->debug("%s:%d: South plugin type = %s", __FUNCTION__, __LINE__, (type==PYTHON_PLUGIN)?"PYTHON_PLUGIN":"BINARY_PLUGIN"); - // propagate loglevel change to filter irrespective whether the host plugin is python/binary - m_ingest->configChange(categoryName, "logLevel"); - - if (type == PYTHON_PLUGIN) + // Update the Security category + if (categoryName.compare(m_name+"Security") == 0) { - // propagate loglevel changes to python filters/plugins, if present - logger->debug("prevLogLevel=%s, m_configAdvanced.getValue(\"logLevel\")=%s", prevLogLevel.c_str(), m_configAdvanced.getValue("logLevel").c_str()); - if (prevLogLevel.compare(m_configAdvanced.getValue("logLevel")) != 0) - { - logger->debug("%s:%d: calling southPlugin->reconfigure() for updating loglevel", __FUNCTION__, __LINE__); - southPlugin->reconfigure("logLevel"); - } + this->updateSecurityCategory(category); } + logger->info("SouthService::handlePendingReconf(): Handling of configuration change #%d done", i); } - if (m_configAdvanced.itemExists("throttle")) + { - string throt = m_configAdvanced.getValue("throttle"); - if (throt[0] == 't' || throt[0] == 'T') - { - m_throttle = true; - m_highWater = threshold - + (((float)threshold * SOUTH_THROTTLE_HIGH_PERCENT) / 100.0); - m_lowWater = threshold - + (((float)threshold * SOUTH_THROTTLE_LOW_PERCENT) / 100.0); - logger->info("Throttling is enabled, high water mark is set to %ld", m_highWater); - } - else - { - m_throttle = false; - } + lock_guard guard(m_pendingNewConfigMutex); + for (unsigned int i=0; iinfo("SouthService::handlePendingReconf DONE: first %d entry(ies) removed, m_pendingNewConfig new size=%d", numPendingReconfs, m_pendingNewConfig.size()); } } - - // Update the Security category - if (categoryName.compare(m_name+"Security") == 0) - { - this->updateSecurityCategory(category); - } } /** - * Configuration change notification + * Configuration change notification using a separate thread */ void SouthService::configChange(const string& categoryName, const string& category) { - bool reconfNow = true; - if (m_reconfOngoing.load()) + PRINT_FUNC; { - Logger::getLogger()->debug("SouthPlugin::reconfigure(): m_reconfOngoing is TRUE"); - if (m_reconfThread->joinable()) - { - Logger::getLogger()->debug("SouthPlugin::reconfigure(): reconfThread is JOINABLE"); - m_reconfThread->join(); - reconfNow = true; - } - else - { - Logger::getLogger()->debug("SouthPlugin::reconfigure(): reconfThread is NOT JOINABLE"); - reconfNow = false; - } - } - else - Logger::getLogger()->debug("SouthPlugin::reconfigure(): m_reconfOngoing is FALSE"); - - m_pendingNewConfig.emplace_back(std::make_pair(categoryName, category)); - - if (reconfNow) - { - auto reconfValues = m_pendingNewConfig.front(); - m_pendingNewConfig.pop_front(); - Logger::getLogger()->debug("SouthService::configChange(): Creating new reconfThread for reconfStr: categoryName=%s, category=%s", - reconfValues.first.c_str(), reconfValues.second.c_str()); - m_reconfThread = new std::thread(reconfThreadMain, this, reconfValues); + lock_guard guard(m_pendingNewConfigMutex); + m_pendingNewConfig.emplace_back(std::make_pair(categoryName, category)); + Logger::getLogger()->info("SouthService::reconfigure(): After adding new entry, m_pendingNewConfig.size()=%d", m_pendingNewConfig.size()); + + m_cvNewReconf.notify_all(); } } From da522e7a43b49fa045aa4161783a2c8cc7390ffe Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 17:11:51 +0530 Subject: [PATCH 012/499] Plugins update API updated as per common util method for identifying debian Signed-off-by: ashish-jabble --- .../services/core/api/plugins/update.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index 58e3a04dd7..271cc6a0fd 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -9,20 +9,19 @@ import os import logging import uuid -import platform import multiprocessing import json from aiohttp import web -from fledge.common import logger -from fledge.services.core import connect +from fledge.common import logger, utils +from fledge.common.audit_logger import AuditLogger +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.plugin_discovery import PluginDiscovery +from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.payload_builder import PayloadBuilder +from fledge.services.core import connect from fledge.services.core import server -from fledge.common.plugin_discovery import PluginDiscovery from fledge.services.core.api.plugins import common -from fledge.common.configuration_manager import ConfigurationManager -from fledge.common.audit_logger import AuditLogger -from fledge.common.storage_client.exceptions import StorageServerError __author__ = "Ashish Jabble" @@ -32,7 +31,7 @@ _help = """ ------------------------------------------------------------------------------- - | PUT | /fledge/plugin/{type}/{name}/update | + | PUT | /fledge/plugin/{type}/{name}/update | ------------------------------------------------------------------------------- """ _logger = logger.setup(__name__, level=logging.INFO) @@ -217,11 +216,11 @@ def _update_repo_sources_and_plugin(_type: str, name: str) -> tuple: # sudo apt list command internal so package name always returns in lowercase, # irrespective of package name defined in the configured repo. name = "fledge-{}-{}".format(_type, name.lower()) - _platform = platform.platform() stdout_file_path = common.create_log_file(action="update", plugin_name=name) - pkg_mgt = 'apt' - cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) - if 'centos' in _platform or 'redhat' in _platform: + if utils.is_debian(): + pkg_mgt = 'apt' + cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) + else: pkg_mgt = 'yum' cmd = "sudo {} check-update > {} 2>&1".format(pkg_mgt, stdout_file_path) ret_code = os.system(cmd) From eb96afcb503609394d7931a03b6ee42983b0e633 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Mon, 26 Dec 2022 18:33:54 +0530 Subject: [PATCH 013/499] FOGL-7217: Code re-org Signed-off-by: Amandeep Singh Arora --- C/services/south/include/south_service.h | 1 + C/services/south/south.cpp | 276 ++++++++++++----------- 2 files changed, 141 insertions(+), 136 deletions(-) diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index d71f6bd070..516fe1ed2a 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -52,6 +52,7 @@ class SouthService : public ServiceAuthHandler { void shutdown(); void restart(); void configChange(const std::string&, const std::string&); + void configChangeReal(const std::string&, const std::string&); void configChildCreate(const std::string&, const std::string&, const std::string&){}; diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index bdb1851d9f..e2867ad41b 100755 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -39,6 +39,8 @@ extern int makeDaemon(void); extern void handler(int sig); +static void reconfThreadMain(void *arg); + using namespace std; /** @@ -222,10 +224,6 @@ void doIngestV2(Ingest *ingest, ReadingSet *set) delete set; } - -static void reconfThreadMain(void *arg); - - /** * Constructor for the south service */ @@ -478,7 +476,7 @@ void SouthService::start(string& coreAddress, unsigned short corePort) logger->info("pollInterfaceV2=%s", pollInterfaceV2?"true":"false"); /* - * Start the plugin. If it fails with an excpetion retry the start with a delay + * Start the plugin. If it fails with an exception, retry the start with a delay * That delay starts at 500mS and will backoff to 1 minute * * We will continue to retry the start until the service is shutdown @@ -833,6 +831,134 @@ void SouthService::restart() logger->info("South service shutdown for restart in progress."); } +/** + * Configuration change notification + */ +void SouthService::configChangeReal(const string& categoryName, const string& category) +{ + logger->info("Configuration change in category %s: %s", categoryName.c_str(), + category.c_str()); + if (categoryName.compare(m_name) == 0) + { + m_config = ConfigCategory(m_name, category); + try { + southPlugin->reconfigure(category); + } + catch (...) { + logger->fatal("Unrecoverable failure during South plugin reconfigure, south service exiting..."); + shutdown(); + } + // Let ingest class check for changes to filter pipeline + m_ingest->configChange(categoryName, category); + } + if (categoryName.compare(m_name+"Advanced") == 0) + { + m_configAdvanced = ConfigCategory(m_name+"Advanced", category); + if (m_configAdvanced.itemExists("statistics")) + { + m_ingest->setStatistics(m_configAdvanced.getValue("statistics")); + } + if (! southPlugin->isAsync()) + { + try { + unsigned long newval = (unsigned long)strtol(m_configAdvanced.getValue("readingsPerSec").c_str(), NULL, 10); + if (newval < 1) + { + logger->warn("Invalid setting of reading rate, defaulting to 1"); + m_readingsPerSec = 1; + } + string units = m_configAdvanced.getValue("units"); + unsigned long dividend = 1000000; + if (units.compare("second") == 0) + dividend = 1000000; + else if (units.compare("minute") == 0) + dividend = 60000000; + else if (units.compare("hour") == 0) + dividend = 3600000000; + if (newval != m_readingsPerSec || m_rateUnits.compare(units) != 0) + { + m_readingsPerSec = newval; + m_rateUnits = units; + close(m_timerfd); + unsigned long usecs = dividend / m_readingsPerSec; + if (usecs > MAX_SLEEP * 1000000) + { + double x = usecs / (MAX_SLEEP * 1000000); + m_repeatCnt = ceil(x); + usecs /= m_repeatCnt; + } + else + { + m_repeatCnt = 1; + } + m_desiredRate.tv_sec = (int)(usecs / 1000000); + m_desiredRate.tv_usec = (int)(usecs % 1000000); + m_currentRate = m_desiredRate; + m_timerfd = createTimerFd(m_desiredRate); // interval to be passed is in usecs + } + } catch (ConfigItemNotFound e) { + logger->error("Failed to update poll interval following configuration change"); + } + } + unsigned long threshold = 5000; // This should never be used + if (m_configAdvanced.itemExists("bufferThreshold")) + { + threshold = (unsigned int)strtol(m_configAdvanced.getValue("bufferThreshold").c_str(), NULL, 10); + m_ingest->setThreshold(threshold); + } + if (m_configAdvanced.itemExists("maxSendLatency")) + { + m_ingest->setTimeout(strtol(m_configAdvanced.getValue("maxSendLatency").c_str(), NULL, 10)); + } + if (m_configAdvanced.itemExists("logLevel")) + { + string prevLogLevel = logger->getMinLevel(); + logger->setMinLevel(m_configAdvanced.getValue("logLevel")); + + PluginManager *manager = PluginManager::getInstance(); + PLUGIN_TYPE type = manager->getPluginImplType(southPlugin->getHandle()); + logger->debug("%s:%d: South plugin type = %s", __FUNCTION__, __LINE__, (type==PYTHON_PLUGIN)?"PYTHON_PLUGIN":"BINARY_PLUGIN"); + + // propagate loglevel change to filter irrespective whether the host plugin is python/binary + m_ingest->configChange(categoryName, "logLevel"); + + if (type == PYTHON_PLUGIN) + { + // propagate loglevel changes to python filters/plugins, if present + logger->debug("prevLogLevel=%s, m_configAdvanced.getValue(\"logLevel\")=%s", prevLogLevel.c_str(), m_configAdvanced.getValue("logLevel").c_str()); + if (prevLogLevel.compare(m_configAdvanced.getValue("logLevel")) != 0) + { + logger->debug("%s:%d: calling southPlugin->reconfigure() for updating loglevel", __FUNCTION__, __LINE__); + southPlugin->reconfigure("logLevel"); + } + } + } + if (m_configAdvanced.itemExists("throttle")) + { + string throt = m_configAdvanced.getValue("throttle"); + if (throt[0] == 't' || throt[0] == 'T') + { + m_throttle = true; + m_highWater = threshold + + (((float)threshold * SOUTH_THROTTLE_HIGH_PERCENT) / 100.0); + m_lowWater = threshold + + (((float)threshold * SOUTH_THROTTLE_LOW_PERCENT) / 100.0); + logger->info("Throttling is enabled, high water mark is set to %ld", m_highWater); + } + else + { + m_throttle = false; + } + } + } + + // Update the Security category + if (categoryName.compare(m_name+"Security") == 0) + { + this->updateSecurityCategory(category); + } +} + /** * Separate thread to run plugin_reconf, to avoid blocking * service's management interface due to long plugin_poll calls @@ -853,11 +979,11 @@ static void reconfThreadMain(void *arg) */ void SouthService::handlePendingReconf() { - Logger::getLogger()->info("SouthService::handlePendingReconf: Going into cv wait"); + Logger::getLogger()->debug("SouthService::handlePendingReconf: Going into cv wait"); mutex mtx; unique_lock lck(mtx); m_cvNewReconf.wait(lck); - Logger::getLogger()->info("SouthService::handlePendingReconf: cv wait has completed"); + Logger::getLogger()->debug("SouthService::handlePendingReconf: cv wait has completed"); while(1) { @@ -866,17 +992,17 @@ void SouthService::handlePendingReconf() lock_guard guard(m_pendingNewConfigMutex); numPendingReconfs = m_pendingNewConfig.size(); if (numPendingReconfs) - Logger::getLogger()->info("SouthService::handlePendingReconf(): will process %d entries in m_pendingNewConfig", numPendingReconfs); + Logger::getLogger()->debug("SouthService::handlePendingReconf(): will process %d entries in m_pendingNewConfig", numPendingReconfs); else { - Logger::getLogger()->info("SouthService::handlePendingReconf DONE"); + Logger::getLogger()->debug("SouthService::handlePendingReconf DONE"); break; } } for (unsigned int i=0; iinfo("SouthService::handlePendingReconf(): Handling Configuration change #%d", i); + logger->debug("SouthService::handlePendingReconf(): Handling Configuration change #%d", i); std::pair *reconfValue = NULL; { lock_guard guard(m_pendingNewConfigMutex); @@ -884,138 +1010,16 @@ void SouthService::handlePendingReconf() } std::string categoryName = reconfValue->first; std::string category = reconfValue->second; - - logger->info("Configuration change in category %s: %s", categoryName.c_str(), category.c_str()); - if (categoryName.compare(m_name) == 0) - { - m_config = ConfigCategory(m_name, category); - try { - PRINT_FUNC; - southPlugin->reconfigure(category); - PRINT_FUNC; - } - catch (...) { - logger->fatal("Unrecoverable failure during South plugin reconfigure, south service exiting..."); - shutdown(); - } - // Let ingest class check for changes to filter pipeline - m_ingest->configChange(categoryName, category); - } - PRINT_FUNC; - if (categoryName.compare(m_name+"Advanced") == 0) - { - m_configAdvanced = ConfigCategory(m_name+"Advanced", category); - if (m_configAdvanced.itemExists("statistics")) - { - m_ingest->setStatistics(m_configAdvanced.getValue("statistics")); - } - if (! southPlugin->isAsync()) - { - try { - unsigned long newval = (unsigned long)strtol(m_configAdvanced.getValue("readingsPerSec").c_str(), NULL, 10); - if (newval < 1) - { - logger->warn("Invalid setting of reading rate, defaulting to 1"); - m_readingsPerSec = 1; - } - string units = m_configAdvanced.getValue("units"); - unsigned long dividend = 1000000; - if (units.compare("second") == 0) - dividend = 1000000; - else if (units.compare("minute") == 0) - dividend = 60000000; - else if (units.compare("hour") == 0) - dividend = 3600000000; - if (newval != m_readingsPerSec || m_rateUnits.compare(units) != 0) - { - m_readingsPerSec = newval; - m_rateUnits = units; - close(m_timerfd); - unsigned long usecs = dividend / m_readingsPerSec; - if (usecs > MAX_SLEEP * 1000000) - { - double x = usecs / (MAX_SLEEP * 1000000); - m_repeatCnt = ceil(x); - usecs /= m_repeatCnt; - } - else - { - m_repeatCnt = 1; - } - m_desiredRate.tv_sec = (int)(usecs / 1000000); - m_desiredRate.tv_usec = (int)(usecs % 1000000); - m_currentRate = m_desiredRate; - m_timerfd = createTimerFd(m_desiredRate); // interval to be passed is in usecs - } - } catch (ConfigItemNotFound e) { - logger->error("Failed to update poll interval following configuration change"); - } - } - unsigned long threshold = 5000; // This should never be used - if (m_configAdvanced.itemExists("bufferThreshold")) - { - threshold = (unsigned int)strtol(m_configAdvanced.getValue("bufferThreshold").c_str(), NULL, 10); - m_ingest->setThreshold(threshold); - } - if (m_configAdvanced.itemExists("maxSendLatency")) - { - m_ingest->setTimeout(strtol(m_configAdvanced.getValue("maxSendLatency").c_str(), NULL, 10)); - } - if (m_configAdvanced.itemExists("logLevel")) - { - string prevLogLevel = logger->getMinLevel(); - logger->setMinLevel(m_configAdvanced.getValue("logLevel")); + configChangeReal(categoryName, category); - PluginManager *manager = PluginManager::getInstance(); - PLUGIN_TYPE type = manager->getPluginImplType(southPlugin->getHandle()); - logger->debug("%s:%d: South plugin type = %s", __FUNCTION__, __LINE__, (type==PYTHON_PLUGIN)?"PYTHON_PLUGIN":"BINARY_PLUGIN"); - - // propagate loglevel change to filter irrespective whether the host plugin is python/binary - m_ingest->configChange(categoryName, "logLevel"); - - if (type == PYTHON_PLUGIN) - { - // propagate loglevel changes to python filters/plugins, if present - logger->debug("prevLogLevel=%s, m_configAdvanced.getValue(\"logLevel\")=%s", prevLogLevel.c_str(), m_configAdvanced.getValue("logLevel").c_str()); - if (prevLogLevel.compare(m_configAdvanced.getValue("logLevel")) != 0) - { - logger->debug("%s:%d: calling southPlugin->reconfigure() for updating loglevel", __FUNCTION__, __LINE__); - southPlugin->reconfigure("logLevel"); - } - } - } - if (m_configAdvanced.itemExists("throttle")) - { - string throt = m_configAdvanced.getValue("throttle"); - if (throt[0] == 't' || throt[0] == 'T') - { - m_throttle = true; - m_highWater = threshold - + (((float)threshold * SOUTH_THROTTLE_HIGH_PERCENT) / 100.0); - m_lowWater = threshold - + (((float)threshold * SOUTH_THROTTLE_LOW_PERCENT) / 100.0); - logger->info("Throttling is enabled, high water mark is set to %ld", m_highWater); - } - else - { - m_throttle = false; - } - } - } - - // Update the Security category - if (categoryName.compare(m_name+"Security") == 0) - { - this->updateSecurityCategory(category); - } - logger->info("SouthService::handlePendingReconf(): Handling of configuration change #%d done", i); + logger->debug("SouthService::handlePendingReconf(): Handling of configuration change #%d done", i); } { lock_guard guard(m_pendingNewConfigMutex); for (unsigned int i=0; iinfo("SouthService::handlePendingReconf DONE: first %d entry(ies) removed, m_pendingNewConfig new size=%d", numPendingReconfs, m_pendingNewConfig.size()); + logger->debug("SouthService::handlePendingReconf DONE: first %d entry(ies) removed, m_pendingNewConfig new size=%d", numPendingReconfs, m_pendingNewConfig.size()); } } } @@ -1029,7 +1033,7 @@ void SouthService::configChange(const string& categoryName, const string& catego { lock_guard guard(m_pendingNewConfigMutex); m_pendingNewConfig.emplace_back(std::make_pair(categoryName, category)); - Logger::getLogger()->info("SouthService::reconfigure(): After adding new entry, m_pendingNewConfig.size()=%d", m_pendingNewConfig.size()); + Logger::getLogger()->debug("SouthService::reconfigure(): After adding new entry, m_pendingNewConfig.size()=%d", m_pendingNewConfig.size()); m_cvNewReconf.notify_all(); } From fde50d27df0743dadbae5fd39e175864ef4c4050 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Dec 2022 19:21:47 +0530 Subject: [PATCH 014/499] tests directory updated as per new utility to get OS details & some reordering fixes of imports used in tests Signed-off-by: ashish-jabble --- tests/system/python/conftest.py | 19 ++++++++++--------- .../python/packages/test_authentication.py | 9 ++++----- tests/system/python/packages/test_eds.py | 9 ++++----- .../python/packages/test_gcp_gateway.py | 11 +++++------ .../python/packages/test_multiple_assets.py | 6 +++--- tests/system/python/packages/test_opcua.py | 10 +++++----- 6 files changed, 31 insertions(+), 33 deletions(-) diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index 19a0d4f94c..5010b2a4fa 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -8,7 +8,6 @@ """ import subprocess import os -import platform import sys import fnmatch import http.client @@ -16,10 +15,10 @@ import base64 import ssl import shutil -import pytest from urllib.parse import quote from pathlib import Path -import sys +import pytest +from fledge.common import utils __author__ = "Vaibhav Singhal" @@ -133,8 +132,7 @@ def clone_make_install(): clone_make_install() elif installation_type == 'package': try: - os_platform = platform.platform() - pkg_mgr = 'yum' if 'centos' in os_platform or 'redhat' in os_platform else 'apt' + pkg_mgr = 'apt' if pytest.IS_DEBIAN else 'yum' subprocess.run(["sudo {} install -y fledge-south-{}".format(pkg_mgr, south_plugin)], shell=True, check=True) except subprocess.CalledProcessError: @@ -186,8 +184,7 @@ def clone_make_install(): clone_make_install() elif installation_type == 'package': try: - os_platform = platform.platform() - pkg_mgr = 'yum' if 'centos' in os_platform or 'redhat' in os_platform else 'apt' + pkg_mgr = 'apt' if pytest.IS_DEBIAN else 'yum' subprocess.run(["sudo {} install -y fledge-north-{}".format(pkg_mgr, north_plugin)], shell=True, check=True) except subprocess.CalledProcessError: @@ -548,8 +545,7 @@ def _add_filter(filter_plugin, filter_plugin_branch, filter_name, filter_config, assert False, "{} filter plugin installation failed".format(filter_plugin) elif installation_type == 'package': try: - os_platform = platform.platform() - pkg_mgr = 'yum' if 'centos' in os_platform or 'redhat' in os_platform else 'apt' + pkg_mgr = 'apt' if pytest.IS_DEBIAN else 'yum' subprocess.run(["sudo {} install -y fledge-filter-{}".format(pkg_mgr, filter_plugin)], shell=True, check=True) except subprocess.CalledProcessError: @@ -1010,3 +1006,8 @@ def throttled_network_config(request): @pytest.fixture def start_north_as_service(request): return request.config.getoption("--start-north-as-service") + + +def pytest_configure(): + pytest.OS_PLATFORM_DETAILS = utils.read_os_release() + pytest.IS_DEBIAN = utils.is_debian() diff --git a/tests/system/python/packages/test_authentication.py b/tests/system/python/packages/test_authentication.py index a20d77e0ed..8159f0b0e9 100644 --- a/tests/system/python/packages/test_authentication.py +++ b/tests/system/python/packages/test_authentication.py @@ -11,10 +11,10 @@ import http.client import json import time -import pytest -from pathlib import Path import ssl -import platform +from pathlib import Path +import pytest +from pytest import IS_DEBIAN __author__ = "Yash Tatkondawar" __copyright__ = "Copyright (c) 2019 Dianomic Systems" @@ -172,8 +172,7 @@ def remove_and_add_fledge_pkgs(package_build_version): assert False, "setup package script failed" try: - os_platform = platform.platform() - pkg_mgr = 'yum' if 'centos' in os_platform or 'redhat' in os_platform else 'apt' + pkg_mgr = 'apt' if IS_DEBIAN else 'yum' subprocess.run(["sudo {} install -y fledge-south-http-south".format(pkg_mgr)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "installation of http-south package failed" diff --git a/tests/system/python/packages/test_eds.py b/tests/system/python/packages/test_eds.py index dc71b43de6..3ef900767e 100644 --- a/tests/system/python/packages/test_eds.py +++ b/tests/system/python/packages/test_eds.py @@ -16,11 +16,11 @@ import http.client import json import time -import pytest from pathlib import Path -import utils from datetime import datetime -import platform +import pytest +import utils +from pytest import IS_DEBIAN __author__ = "Yash Tatkondawar" __copyright__ = "Copyright (c) 2020 Dianomic Systems, Inc." @@ -72,8 +72,7 @@ def remove_and_add_pkgs(package_build_version): assert False, "setup package script failed" try: - os_platform = platform.platform() - pkg_mgr = 'yum' if 'centos' in os_platform or 'redhat' in os_platform else 'apt' + pkg_mgr = 'apt' if IS_DEBIAN else 'yum' subprocess.run(["sudo {} install -y fledge-south-sinusoid".format(pkg_mgr)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "installation of sinusoid package failed" diff --git a/tests/system/python/packages/test_gcp_gateway.py b/tests/system/python/packages/test_gcp_gateway.py index 5fcd6b67c1..0adce10784 100644 --- a/tests/system/python/packages/test_gcp_gateway.py +++ b/tests/system/python/packages/test_gcp_gateway.py @@ -13,12 +13,12 @@ import http.client import json import time -import pytest from pathlib import Path -import utils from datetime import timezone, datetime -import itertools -import platform +import utils +import pytest +from pytest import IS_DEBIAN + __author__ = "Yash Tatkondawar" __copyright__ = "Copyright (c) 2020 Dianomic Systems Inc." @@ -67,8 +67,7 @@ def remove_and_add_pkgs(package_build_version): assert False, "setup package script failed" try: - os_platform = platform.platform() - pkg_mgr = 'yum' if 'centos' in os_platform or 'redhat' in os_platform else 'apt' + pkg_mgr = 'apt' if IS_DEBIAN else 'yum' subprocess.run(["sudo {} install -y fledge-north-gcp fledge-south-sinusoid".format(pkg_mgr)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "installation of gcp-gateway and sinusoid packages failed" diff --git a/tests/system/python/packages/test_multiple_assets.py b/tests/system/python/packages/test_multiple_assets.py index f6a966baa9..37fcc19e8c 100644 --- a/tests/system/python/packages/test_multiple_assets.py +++ b/tests/system/python/packages/test_multiple_assets.py @@ -13,7 +13,6 @@ import http.client import json import os -import platform import ssl import subprocess import time @@ -22,6 +21,8 @@ import pytest import utils +from pytest import IS_DEBIAN + # This gives the path of directory where fledge is cloned. test_file < packages < python < system < tests < ROOT PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent @@ -60,8 +61,7 @@ def remove_and_add_pkgs(package_build_version): assert False, "setup package script failed" try: - os_platform = platform.platform() - pkg_mgr = 'yum' if 'centos' in os_platform or 'redhat' in os_platform else 'apt' + pkg_mgr = 'apt' if IS_DEBIAN else 'yum' subprocess.run(["sudo {} install -y fledge-south-benchmark".format(pkg_mgr)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "installation of benchmark package failed" diff --git a/tests/system/python/packages/test_opcua.py b/tests/system/python/packages/test_opcua.py index b292aca1b7..4928a81fb4 100644 --- a/tests/system/python/packages/test_opcua.py +++ b/tests/system/python/packages/test_opcua.py @@ -31,11 +31,12 @@ import subprocess import time -import utils -import pytest -import platform import urllib.parse from typing import Tuple +import utils +import pytest +from pytest import IS_DEBIAN + """ First FL instance IP Address """ FL1_INSTANCE_IP = "192.168.1.8" @@ -140,8 +141,7 @@ def install_pkg(): """ Fixture used for to install packages and only used in First FL instance """ try: - os_platform = platform.platform() - pkg_mgr = 'yum' if 'centos' in os_platform or 'redhat' in os_platform else 'apt' + pkg_mgr = 'apt' if IS_DEBIAN else 'yum' subprocess.run(["sudo {} install -y {}".format(pkg_mgr, PKG_LIST)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "{} one of installation package failed".format(PKG_LIST) From 8be1eb1d00e4d62c3c85e0b9ad74e1f7dbb11aff Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 27 Dec 2022 14:52:44 +0530 Subject: [PATCH 015/499] service and plugin discovery API system tests fixes on CentOS9 stream Signed-off-by: ashish-jabble --- tests/system/python/api/test_plugin_discovery.py | 9 ++++++--- tests/system/python/api/test_service.py | 5 +++-- tests/system/python/scripts/install_c_plugin | 3 ++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/system/python/api/test_plugin_discovery.py b/tests/system/python/api/test_plugin_discovery.py index b3e28b4c04..864a9630e3 100644 --- a/tests/system/python/api/test_plugin_discovery.py +++ b/tests/system/python/api/test_plugin_discovery.py @@ -115,7 +115,8 @@ def test_south_plugins_installed(self, fledge_url, _type='south'): assert 'fledge-south-sinusoid' == plugins[0]['packageName'] # install one more south plugin (C version) - install_plugin(_type, plugin='random', plugin_lang='C') + # FIXME: BRANCH when testing is done in main FOGL-7260 branch + install_plugin(_type, plugin='random', plugin_lang='C', branch='compilation-fixes-centos9-stream') conn.request("GET", '/fledge/plugins/installed?type={}'.format(_type)) r = conn.getresponse() assert 200 == r.status @@ -179,7 +180,8 @@ def test_filter_plugins_installed(self, fledge_url, _type='filter'): def test_delivery_plugins_installed(self, fledge_url, _type='notify'): # install slack delivery plugin - install_plugin(_type, plugin='slack', plugin_lang='C') + # FIXME: BRANCH when testing is done in main FOGL-7260 branch + install_plugin(_type, plugin='slack', plugin_lang='C', branch='compilation-fixes-centos9-stream') conn = http.client.HTTPConnection(fledge_url) conn.request("GET", '/fledge/plugins/installed?type={}'.format(_type)) r = conn.getresponse() @@ -196,7 +198,8 @@ def test_delivery_plugins_installed(self, fledge_url, _type='notify'): def test_rule_plugins_installed(self, fledge_url, _type='rule'): # install OutOfBound rule plugin - install_plugin(_type, plugin='outofbound', plugin_lang='C') + # FIXME: BRANCH when testing is done in main FOGL-7260 branch + install_plugin(_type, plugin='outofbound', plugin_lang='C', branch='compilation-fixes-centos9-stream') conn = http.client.HTTPConnection(fledge_url) conn.request("GET", '/fledge/plugins/installed?type={}'.format(_type)) r = conn.getresponse() diff --git a/tests/system/python/api/test_service.py b/tests/system/python/api/test_service.py index 9eb38d1898..c431988730 100644 --- a/tests/system/python/api/test_service.py +++ b/tests/system/python/api/test_service.py @@ -40,8 +40,9 @@ def install_plugins(): plugin_and_service.install('south', plugin='randomwalk') plugin_and_service.install('south', plugin='http') - plugin_and_service.install('south', plugin='benchmark', plugin_lang='C') - plugin_and_service.install('south', plugin='random', plugin_lang='C') + # FIXME: BRANCH when testing is done in main FOGL-7260 branch + plugin_and_service.install('south', plugin='benchmark', plugin_lang='C', branch='compilation-fixes-centos9-stream') + plugin_and_service.install('south', plugin='random', plugin_lang='C', branch='compilation-fixes-centos9-stream') plugin_and_service.install('south', plugin='csv-async', plugin_lang='C') diff --git a/tests/system/python/scripts/install_c_plugin b/tests/system/python/scripts/install_c_plugin index fdedbff529..7e94f62496 100755 --- a/tests/system/python/scripts/install_c_plugin +++ b/tests/system/python/scripts/install_c_plugin @@ -43,7 +43,8 @@ install_binary_file () { then # fledge-service-notification repo is required to build notificationRule Plugins service_repo_name='fledge-service-notification' - git clone -b ${BRANCH_NAME} --single-branch https://github.com/fledge-iot/${service_repo_name}.git /tmp/${service_repo_name} + # FIXME: BRANCH_NAME when testing is done in main FOGL-7260 branch + git clone -b develop --single-branch https://github.com/fledge-iot/${service_repo_name}.git /tmp/${service_repo_name} export NOTIFICATION_SERVICE_INCLUDE_DIRS=/tmp/${service_repo_name}/C/services/common/include fi From 7998c4f38d26bd8099a77aa8e48ebdb09b188b4d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 1 Dec 2022 17:32:59 +0530 Subject: [PATCH 016/499] empty pid file handling added in fledge script; so that fledge start can move ahead with default rest api url Signed-off-by: ashish-jabble Signed-off-by: nandan --- scripts/fledge | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/fledge b/scripts/fledge index e3f3a875cd..12b4fbed19 100755 --- a/scripts/fledge +++ b/scripts/fledge @@ -587,17 +587,15 @@ get_fledge_version() { ## Get Fledge rest API URL ## get_rest_api_url() { - pid_file=${FLEDGE_DATA}/var/run/fledge.core.pid - export PYTHONPATH=${FLEDGE_ROOT} - - if [[ -f ${pid_file} ]]; then - REST_API_URL=`cat ${pid_file} | python3 -m scripts.common.json_parse get_rest_api_url_from_pid` + # Check whether pid_file exists and its contents are not empty + if [[ -s ${pid_file} ]]; then + REST_API_URL=$(cat ${pid_file} | python3 -m scripts.common.json_parse get_rest_api_url_from_pid) fi # Sets a default value if it not possible to determine the proper value using the pid file if [ ! "${REST_API_URL}" ]; then - export REST_API_URL=http://localhost:8081 + export REST_API_URL="http://localhost:8081" fi } From 9488219b88f489ff9562c8ce63d212177b1d38c8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 6 Dec 2022 18:38:57 +0530 Subject: [PATCH 017/499] restored PYTHONPATH variable in fledge script Signed-off-by: ashish-jabble Signed-off-by: nandan --- scripts/fledge | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/fledge b/scripts/fledge index 12b4fbed19..74af95db0f 100755 --- a/scripts/fledge +++ b/scripts/fledge @@ -588,6 +588,8 @@ get_fledge_version() { ## get_rest_api_url() { pid_file=${FLEDGE_DATA}/var/run/fledge.core.pid + export PYTHONPATH=${FLEDGE_ROOT} + # Check whether pid_file exists and its contents are not empty if [[ -s ${pid_file} ]]; then REST_API_URL=$(cat ${pid_file} | python3 -m scripts.common.json_parse get_rest_api_url_from_pid) From dc6de195aacd59f5b6a31f27e693e382cfa80f42 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 7 Dec 2022 19:21:52 +0530 Subject: [PATCH 018/499] pip3 occurrences is replaced with python3 -m pip Signed-off-by: ashish-jabble Signed-off-by: nandan --- Makefile | 6 +++--- docs/building_fledge/06_testing.rst | 2 +- tests/README.rst | 2 +- tests/system/python/README.rst | 2 +- tests/system/python/iprpc/README.rst | 2 +- tests/system/python/scripts/install_python_plugin | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 98821a2bdb..c1c80d666b 100644 --- a/Makefile +++ b/Makefile @@ -14,16 +14,16 @@ $(if $(PLATFORM_RH), $(info Platform is $(PLATFORM_RH) $(OS_VERSION))) ifneq ("$(PLATFORM_RH)","") ifeq ("$(OS_VERSION_PREFIX)", "7") # CentOS we need rh-python36 and devtoolset-7 - PIP_INSTALL_REQUIREMENTS := source scl_source enable rh-python36 && pip3 install -Ir + PIP_INSTALL_REQUIREMENTS := source scl_source enable rh-python36 && python3 -m pip install -Ir PYTHON_BUILD_PACKAGE = source scl_source enable rh-python36 && python3 setup.py build -b ../$(PYTHON_BUILD_DIR) CMAKE := source scl_source enable rh-python36 && source scl_source enable devtoolset-7 && cmake else - PIP_INSTALL_REQUIREMENTS := pip3 install -Ir + PIP_INSTALL_REQUIREMENTS := python3 -m pip install -Ir PYTHON_BUILD_PACKAGE = python3 setup.py build -b ../$(PYTHON_BUILD_DIR) CMAKE := cmake endif else - PIP_INSTALL_REQUIREMENTS := pip3 install -Ir + PIP_INSTALL_REQUIREMENTS := python3 -m pip install -Ir PYTHON_BUILD_PACKAGE = python3 setup.py build -b ../$(PYTHON_BUILD_DIR) CMAKE := cmake endif diff --git a/docs/building_fledge/06_testing.rst b/docs/building_fledge/06_testing.rst index 72de797bf3..9c40cd7e71 100644 --- a/docs/building_fledge/06_testing.rst +++ b/docs/building_fledge/06_testing.rst @@ -192,7 +192,7 @@ Note: This following instructions assume you have downloaded and installed the C $ cd fledge-south-coap $ sudo cp -r python/fledge/plugins/south/coap /usr/local/fledge/python/fledge/plugins/south/ $ sudo cp python/requirements-coap.txt /usr/local/fledge/python/ - $ sudo pip3 install -r /usr/local/fledge/python/requirements-coap.txt + $ sudo python3 -m pip install -r /usr/local/fledge/python/requirements-coap.txt $ sudo chown -R root:root /usr/local/fledge/python/fledge/plugins/south/coap $ curl -sX POST http://localhost:8081/fledge/service -d '{"name": "CoAP", "type": "south", "plugin": "coap", "enabled": true}' diff --git a/tests/README.rst b/tests/README.rst index 10f504d816..47c9db148e 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -43,7 +43,7 @@ You can test Fledge from your development environment or after installing Fledge To install the dependencies required to run python tests, run the following command from FLEDGE_ROOT :: - pip3 install -r python/requirements-test.txt --user + python3 -m pip install -r python/requirements-test.txt --user sudo apt install jq libxslt-dev diff --git a/tests/system/python/README.rst b/tests/system/python/README.rst index aa60fcda7b..ee39edbb67 100644 --- a/tests/system/python/README.rst +++ b/tests/system/python/README.rst @@ -56,7 +56,7 @@ Test Prerequisites Install the following prerequisites to run a System test :: - pip3 install pytest + python3 -m pip install pytest Also, Fledge must have: diff --git a/tests/system/python/iprpc/README.rst b/tests/system/python/iprpc/README.rst index 4de6571e46..bee9a917ba 100644 --- a/tests/system/python/iprpc/README.rst +++ b/tests/system/python/iprpc/README.rst @@ -79,7 +79,7 @@ To install the dependencies required to run python tests, run the following two :: cd $FLEDGE_ROOT/tests/system/python/iprpc - pip3 install -r requirements-iprpc-test.txt --user + python3 -m pip install -r requirements-iprpc-test.txt --user Test Execution diff --git a/tests/system/python/scripts/install_python_plugin b/tests/system/python/scripts/install_python_plugin index b220d92f50..2d26435806 100755 --- a/tests/system/python/scripts/install_python_plugin +++ b/tests/system/python/scripts/install_python_plugin @@ -47,7 +47,7 @@ copy_file_and_requirement () { cp -r /tmp/${REPO_NAME}/python/fledge/plugins/${PLUGIN_TYPE}/${PLUGIN_NAME} $FLEDGE_ROOT/python/fledge/plugins/${PLUGIN_TYPE}/ fi req_file=$(find /tmp/${REPO_NAME} -name requirement*.txt) - [ ! -z "${req_file}" ] && pip3 install --user -Ir ${req_file} ${USE_PIP_CACHE} || echo "No such external dependency needed for ${PLUGIN_NAME} plugin." + [ ! -z "${req_file}" ] && python3 -m pip install --user -Ir ${req_file} ${USE_PIP_CACHE} || echo "No such external dependency needed for ${PLUGIN_NAME} plugin." } clean From 050517b825503e0a620a20a7b16928f28c4f33e8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 12 Dec 2022 13:46:59 +0530 Subject: [PATCH 019/499] enable more endpoints for RBAC user Signed-off-by: ashish-jabble Signed-off-by: nandan --- python/fledge/common/web/middleware.py | 30 ++++++++++++++++++++------ 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/python/fledge/common/web/middleware.py b/python/fledge/common/web/middleware.py index e939c265e2..a436a9a66f 100644 --- a/python/fledge/common/web/middleware.py +++ b/python/fledge/common/web/middleware.py @@ -159,19 +159,35 @@ def handle_api_exception(ex, _class=None, if_trace=0): async def validate_requests(request): - # With "view" based user role only read access operation is allowed - # logout is also allowed + """ + a) With "view" based user role id=3 only + - read access operations (GET calls) + - change password (PUT call) + - logout (PUT call) + b) With "data-view" based user role id=4 only + - ping (GET call) + - browser asset read operation (GET call) + - service (GET call) + - statistics, statistics history, statistics rate (GET call) + - user profile (GET call) + - change password (PUT call) + - logout (PUT call) + """ + user_id = request.user['id'] if int(request.user["role_id"]) == 3 and request.method != 'GET': - if not str(request.path).endswith('/logout'): + supported_endpoints = ['/fledge/user/{}/password'.format(user_id), '/logout'] + if not str(request.rel_url).endswith(tuple(supported_endpoints)): raise web.HTTPForbidden - # With "data-view" based user role only browser asset read operation is allowed - # logout is also allowed elif int(request.user["role_id"]) == 4: if request.method == 'GET': - if not str(request.path).startswith('/fledge/asset'): + supported_endpoints = ['/fledge/asset', '/fledge/ping', '/fledge/statistics', + '/fledge/user?id={}'.format(user_id)] + if not (str(request.rel_url).startswith(tuple(supported_endpoints) + ) or str(request.rel_url).endswith('/fledge/service')): raise web.HTTPForbidden elif request.method == 'PUT': - if not str(request.path).endswith('/logout'): + supported_endpoints = ['/fledge/user/{}/password'.format(user_id), '/logout'] + if not str(request.rel_url).endswith(tuple(supported_endpoints)): raise web.HTTPForbidden else: raise web.HTTPForbidden From bdf1c1b0da59dd36d92fb3c2df27f00aa2192e19 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 13 Dec 2022 16:28:15 +0530 Subject: [PATCH 020/499] system API tests updated Signed-off-by: ashish-jabble Signed-off-by: nandan --- ...est_endpoints_with_different_user_types.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/system/python/api/test_endpoints_with_different_user_types.py b/tests/system/python/api/test_endpoints_with_different_user_types.py index 1e1453643b..0075fa4222 100644 --- a/tests/system/python/api/test_endpoints_with_different_user_types.py +++ b/tests/system/python/api/test_endpoints_with_different_user_types.py @@ -101,7 +101,7 @@ def test_login(self, fledge_url, wait_time): ("GET", "/fledge/health/storage", 200), ("GET", "/fledge/health/logging", 200), # user & roles ("GET", "/fledge/user", 200), ("PUT", "/fledge/user", 403), ("PUT", "/fledge/user/1/password", 403), - ("GET", "/fledge/user/role", 200), + ("PUT", "/fledge/user/3/password", 500), ("GET", "/fledge/user/role", 200), # auth ("POST", "/fledge/login", 403), ("PUT", "/fledge/31/logout", 401), ("GET", "/fledge/auth/ott", 200), @@ -207,11 +207,9 @@ def test_login(self, fledge_url, wait_time): ("DELETE", "/fledge/notification/N1/delivery/C1", 403) ]) def test_endpoints(self, fledge_url, method, route_path, http_status_code, storage_plugin): - # FIXME: Once below JIRA's are resolved + # FIXME: Once below JIRA is resolved if storage_plugin == 'postgres': - if route_path == '/fledge/structure/asset': - pytest.skip('Due to FOGL-7098') - elif route_path == '/fledge/statistics/rate?periods=1&statistics=FOO': + if route_path == '/fledge/statistics/rate?periods=1&statistics=FOO': pytest.skip('Due to FOGL-7097') conn = http.client.HTTPConnection(fledge_url) conn.request(method, route_path, headers={"authorization": TOKEN}) @@ -246,12 +244,12 @@ def test_login(self, fledge_url, wait_time): @pytest.mark.parametrize(("method", "route_path", "http_status_code"), [ # common - ("GET", "/fledge/ping", 403), ("PUT", "/fledge/shutdown", 403), ("PUT", "/fledge/restart", 403), + ("GET", "/fledge/ping", 200), ("PUT", "/fledge/shutdown", 403), ("PUT", "/fledge/restart", 403), # health ("GET", "/fledge/health/storage", 403), ("GET", "/fledge/health/logging", 403), # user & roles ("GET", "/fledge/user", 403), ("PUT", "/fledge/user", 403), ("PUT", "/fledge/user/1/password", 403), - ("GET", "/fledge/user/role", 403), + ("PUT", "/fledge/user/4/password", 500), ("GET", "/fledge/user/role", 403), # auth ("POST", "/fledge/login", 403), ("PUT", "/fledge/31/logout", 401), ("GET", "/fledge/auth/ott", 403), @@ -283,7 +281,7 @@ def test_login(self, fledge_url, wait_time): ("GET", "/fledge/task/123", 403), ("PUT", "/fledge/task/123/cancel", 403), ("POST", "/fledge/scheduled/task", 403), ("DELETE", "/fledge/scheduled/task/blah", 403), # service - ("POST", "/fledge/service", 403), ("GET", "/fledge/service", 403), ("DELETE", "/fledge/service/blah", 403), + ("POST", "/fledge/service", 403), ("GET", "/fledge/service", 200), ("DELETE", "/fledge/service/blah", 403), ("GET", "/fledge/service/available", 403), ("GET", "/fledge/service/installed", 403), ("PUT", "/fledge/service/Southbound/blah/update", 403), ("POST", "/fledge/service/blah/otp", 403), # south & north @@ -300,8 +298,8 @@ def test_login(self, fledge_url, wait_time): ("GET", "/fledge/track", 403), ("GET", "/fledge/track/storage/assets", 403), ("PUT", "/fledge/track/service/foo/asset/bar/event/Ingest", 403), # statistics - ("GET", "/fledge/statistics", 403), ("GET", "/fledge/statistics/history", 403), - ("GET", "/fledge/statistics/rate?periods=1&statistics=FOO", 403), + ("GET", "/fledge/statistics", 200), ("GET", "/fledge/statistics/history", 200), + ("GET", "/fledge/statistics/rate?periods=1&statistics=FOO", 200), # audit trail ("POST", "/fledge/audit", 403), ("GET", "/fledge/audit", 403), ("GET", "/fledge/audit/logcode", 403), ("GET", "/fledge/audit/severity", 403), @@ -354,7 +352,11 @@ def test_login(self, fledge_url, wait_time): ("POST", "/fledge/notification/N1/delivery", 403), ("GET", "/fledge/notification/N1/delivery/C1", 403), ("DELETE", "/fledge/notification/N1/delivery/C1", 403) ]) - def test_endpoints(self, fledge_url, method, route_path, http_status_code): + def test_endpoints(self, fledge_url, method, route_path, http_status_code, storage_plugin): + # FIXME: Once below JIRA is resolved + if storage_plugin == 'postgres': + if route_path == '/fledge/statistics/rate?periods=1&statistics=FOO': + pytest.skip('Due to FOGL-7097') conn = http.client.HTTPConnection(fledge_url) conn.request(method, route_path, headers={"authorization": TOKEN}) r = conn.getresponse() From af460b14f668d0daa18e083b84df567f632cb4d8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 14 Dec 2022 18:40:15 +0530 Subject: [PATCH 021/499] GET user role call enabled for data view user Signed-off-by: ashish-jabble Signed-off-by: nandan --- python/fledge/common/web/middleware.py | 3 ++- .../python/api/test_endpoints_with_different_user_types.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/python/fledge/common/web/middleware.py b/python/fledge/common/web/middleware.py index a436a9a66f..37101850f7 100644 --- a/python/fledge/common/web/middleware.py +++ b/python/fledge/common/web/middleware.py @@ -170,6 +170,7 @@ async def validate_requests(request): - service (GET call) - statistics, statistics history, statistics rate (GET call) - user profile (GET call) + - roles (GET call) - change password (PUT call) - logout (PUT call) """ @@ -181,7 +182,7 @@ async def validate_requests(request): elif int(request.user["role_id"]) == 4: if request.method == 'GET': supported_endpoints = ['/fledge/asset', '/fledge/ping', '/fledge/statistics', - '/fledge/user?id={}'.format(user_id)] + '/fledge/user?id={}'.format(user_id), '/fledge/user/role'] if not (str(request.rel_url).startswith(tuple(supported_endpoints) ) or str(request.rel_url).endswith('/fledge/service')): raise web.HTTPForbidden diff --git a/tests/system/python/api/test_endpoints_with_different_user_types.py b/tests/system/python/api/test_endpoints_with_different_user_types.py index 0075fa4222..6b17f7b029 100644 --- a/tests/system/python/api/test_endpoints_with_different_user_types.py +++ b/tests/system/python/api/test_endpoints_with_different_user_types.py @@ -249,7 +249,7 @@ def test_login(self, fledge_url, wait_time): ("GET", "/fledge/health/storage", 403), ("GET", "/fledge/health/logging", 403), # user & roles ("GET", "/fledge/user", 403), ("PUT", "/fledge/user", 403), ("PUT", "/fledge/user/1/password", 403), - ("PUT", "/fledge/user/4/password", 500), ("GET", "/fledge/user/role", 403), + ("PUT", "/fledge/user/4/password", 500), ("GET", "/fledge/user/role", 200), # auth ("POST", "/fledge/login", 403), ("PUT", "/fledge/31/logout", 401), ("GET", "/fledge/auth/ott", 403), From dbd8eedf37e97f0b4459a13c23ed96ad6e41eb1c Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Wed, 14 Dec 2022 19:09:58 +0530 Subject: [PATCH 022/499] FOGL-7179: Set session level time zone for Postgres DB connection so that all time-aware timestamps are returned in 'UTC' rather than configured time zone Signed-off-by: Amandeep Singh Arora Signed-off-by: nandan --- C/plugins/storage/postgres/connection.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index 0c8e335ad2..c2831b235c 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -350,6 +350,14 @@ Connection::Connection() connectErrorTime = time(0); } } + + logSQL("Set", "session time zone 'UTC' "); + PGresult *res = PQexec(dbConnection, " set session time zone 'UTC' "); + if (PQresultStatus(res) != PGRES_COMMAND_OK) + { + Logger::getLogger()->error("set session time zone failed: %s", PQerrorMessage(dbConnection)); + } + PQclear(res); } /** From e5006f479c792a28fa9285754b5264ac64bcb611 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 15 Dec 2022 17:51:27 +0000 Subject: [PATCH 023/499] FOGL-7227 Run ourge operstions on readings plugins if different from the (#901) storage plugin. Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch Signed-off-by: nandan --- C/services/storage/storage.cpp | 30 +++++++++++++++++++- scripts/common/get_engine_management.sh | 8 +++++- scripts/common/get_readings_plugin.sh | 32 +++++++++++++++++++++ scripts/services/storage | 15 ++++++++++ scripts/storage | 37 +++++++++++++++++++++++-- 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100755 scripts/common/get_readings_plugin.sh diff --git a/C/services/storage/storage.cpp b/C/services/storage/storage.cpp index 8e1361536e..4f3439b937 100644 --- a/C/services/storage/storage.cpp +++ b/C/services/storage/storage.cpp @@ -92,6 +92,7 @@ string coreAddress = "localhost"; bool daemonMode = true; string myName = SERVICE_NAME; bool returnPlugin = false; +bool returnReadingsPlugin = false; string logLevel = "warning"; for (int i = 1; i < argc; i++) @@ -116,24 +117,38 @@ string logLevel = "warning"; { returnPlugin = true; } + else if (!strncmp(argv[i], "--readingsplugin", 8)) + { + returnReadingsPlugin = true; + } else if (!strncmp(argv[i], "--logLevel=", 11)) { logLevel = &argv[i][11]; } } - if (returnPlugin == false && daemonMode && makeDaemon() == -1) + if (returnPlugin == false && returnReadingsPlugin == false && daemonMode && makeDaemon() == -1) { // Failed to run in daemon mode cout << "Failed to run as deamon - proceeding in interactive mode." << endl; } + if (returnPlugin && returnReadingsPlugin) + { + cout << "You can not specify --plugin and --readingsplugin together"; + exit(1); + } + StorageService *service = new StorageService(myName); Logger::getLogger()->setMinLevel(logLevel); if (returnPlugin) { cout << service->getPluginName() << " " << service->getPluginManagedStatus() << endl; } + else if (returnReadingsPlugin) + { + cout << service->getReadingPluginName() << " " << service->getPluginManagedStatus() << endl; + } else { service->start(coreAddress, corePort); @@ -522,3 +537,16 @@ string StorageService::getPluginManagedStatus() { return string(config->getValue("managedStatus")); } + +/** + * Return the name of the configured reading plugin + */ +string StorageService::getReadingPluginName() +{ + string rval = config->getValue("readingPlugin"); + if (rval.empty()) + { + rval = config->getValue("plugin"); + } + return rval; +} diff --git a/scripts/common/get_engine_management.sh b/scripts/common/get_engine_management.sh index a67c9820c7..a724270101 100755 --- a/scripts/common/get_engine_management.sh +++ b/scripts/common/get_engine_management.sh @@ -25,7 +25,13 @@ get_engine_management() { storage_info=( $($FLEDGE_ROOT/scripts/services/storage --plugin) ) if [ "${storage_info[0]}" != "$1" ]; then - echo "" + # Not the storage plugin, maybe beign used for readings + storage_info=( $($FLEDGE_ROOT/scripts/services/storage --readingplugin) ) + if [ "${storage_info[0]}" != "$1" ]; then + echo "" + else + echo "${storage_info[1]}" + fi else echo "${storage_info[1]}" fi diff --git a/scripts/common/get_readings_plugin.sh b/scripts/common/get_readings_plugin.sh new file mode 100755 index 0000000000..49b98ee030 --- /dev/null +++ b/scripts/common/get_readings_plugin.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +##-------------------------------------------------------------------- +## Copyright (c) 2022 OSIsoft, LLC +## +## Licensed under the Apache License, Version 2.0 (the "License"); +## you may not use this file except in compliance with the License. +## You may obtain a copy of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, +## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +## See the License for the specific language governing permissions and +## limitations under the License. +##-------------------------------------------------------------------- + +__author__="Massimiliano Pinto" +__version__="1.0" + +# Get the storage database plugin from the Storage microservice cache file +get_readings_plugin() { +if [ "${FLEDGE_ROOT}" ]; then + $FLEDGE_ROOT/scripts/services/storage --readingsPlugin | cut -d' ' -f1 +elif [ -x scripts/services/storage ]; then + scripts/services/storage --readingsPlugin | cut -d' ' -f1 +else + logger "Unable to find Fledge storage script." + exit 1 +fi +} diff --git a/scripts/services/storage b/scripts/services/storage index de290c4774..dcea1179e9 100755 --- a/scripts/services/storage +++ b/scripts/services/storage @@ -41,6 +41,9 @@ else if [[ "$1" != "--plugin" ]]; then write_log "" "scripts.services.storage" "info" "Fledge storage microservice found in FLEDGE_ROOT location: ${FLEDGE_ROOT}" "logonly" "" fi + if [[ "$1" != "--readingsPlugin" ]]; then + write_log "" "scripts.services.storage" "info" "Fledge storage microservice found in FLEDGE_ROOT location: ${FLEDGE_ROOT}" "logonly" "" + fi fi fi @@ -56,6 +59,18 @@ if [[ "$1" != "--plugin" ]]; then ${pluginScriptPath}/${storagePlugin}.sh init ${FLEDGE_SCHEMA} ${managedEngine} fi +if [[ "$1" != "--readingsPlugin" ]]; then + # Get db schema + FLEDGE_VERSION_FILE="${FLEDGE_ROOT}/VERSION" + FLEDGE_SCHEMA=`cat ${FLEDGE_VERSION_FILE} | tr -d ' ' | grep -i "FLEDGE_SCHEMA=" | sed -e 's/\(.*\)=\(.*\)/\2/g'` + # Get storage engine + res=(`${storageExec} --readingsPlugin`) + storagePlugin=${res[0]} + managedEngine=${res[1]} + # Call plugin check: this will create database if not set yet + ${pluginScriptPath}/${storagePlugin}.sh init ${FLEDGE_SCHEMA} ${managedEngine} +fi + # Run storage service ${storageExec} "$@" exit 0 diff --git a/scripts/storage b/scripts/storage index b7908cc791..1c2f8c955e 100755 --- a/scripts/storage +++ b/scripts/storage @@ -26,6 +26,7 @@ # Include common code source "${FLEDGE_ROOT}/scripts/common/get_storage_plugin.sh" +source "${FLEDGE_ROOT}/scripts/common/get_readings_plugin.sh" PLUGIN_TO_USE="" @@ -40,6 +41,7 @@ storage_log() { PLUGIN_TO_USE=`get_storage_plugin` +READINGS_PLUGIN_TO_USE=`get_readings_plugin` if [[ "${#PLUGIN_TO_USE}" -eq 0 ]]; then storage_log "err" "Missing plugin from Fledge storage service" "all" "pretty" fi @@ -54,8 +56,39 @@ if [[ ! -x "$PLUGIN_SCRIPT" ]]; then fi -# Pass action in $1 and FLEDGE_VERSION in $2 -source "$PLUGIN_SCRIPT" $1 $2 +# The reset must be executed on both the storage and readings plugins, if the +# readings are stored in a different plugin. On the readings plugin this becomes +# a purge operation. +# +# The purge action is only executed via the readings plugin if defined, or +# the main storage plugin is not defined. + +if [[ "$1" == "reset" ]] ; then + # Pass action in $1 and FLEDGE_VERSION in $2 + source "$PLUGIN_SCRIPT" $1 $2 + + if [[ "$PLUGIN_TO_USE" != "$READINGS_PLUGIN_TO_USE" ]]; then + READINGS_SCRIPT="$FLEDGE_ROOT/scripts/plugins/storage/$READINGS_PLUGIN_TO_USE.sh" + if [[ -x "$READINGS_SCRIPT" ]]; then + source "$READINGS_SCRIPT" purge $2 + fi + fi +elif [[ "$1" == "purge" ]]; then + # Pass action in $1 and FLEDGE_VERSION in $2 + + if [[ "$PLUGIN_TO_USE" != "$READINGS_PLUGIN_TO_USE" ]]; then + READINGS_SCRIPT="$FLEDGE_ROOT/scripts/plugins/storage/$READINGS_PLUGIN_TO_USE.sh" + # Soem readings plugins, notably sqlitememory, do not have a script + if [[ -x "$READINGS_SCRIPT" ]]; then + source "$READINGS_SCRIPT" $1 $2 + fi + else + source "$PLUGIN_SCRIPT" $1 $2 + fi +else + # Pass any other operation to the storage plugin + source "$PLUGIN_SCRIPT" $1 $2 +fi # exit cannot be used because the script is sourced. #exit $? From 80f23b7638bf565434af015a7593a612a5a568fd Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 16 Dec 2022 18:00:13 +0530 Subject: [PATCH 024/499] FOGL-7128: Search empty readings table before creating new database if reading tables are not available Signed-off-by: nandan --- .../common/include/readings_catalogue.h | 7 +- .../sqlite/common/readings_catalogue.cpp | 182 +++++++++++------- 2 files changed, 114 insertions(+), 75 deletions(-) diff --git a/C/plugins/storage/sqlite/common/include/readings_catalogue.h b/C/plugins/storage/sqlite/common/include/readings_catalogue.h index 0bddc55863..15eb07d06e 100644 --- a/C/plugins/storage/sqlite/common/include/readings_catalogue.h +++ b/C/plugins/storage/sqlite/common/include/readings_catalogue.h @@ -131,10 +131,11 @@ class ReadingsCatalogue { void preallocateReadingsTables(int dbId); bool loadAssetReadingCatalogue(); + bool loadEmptyAssetReadingCatalogue(); bool latestDbUpdate(sqlite3 *dbHandle, int newDbId); void preallocateNewDbsRange(int dbIdStart, int dbIdEnd); - bool getEmptyReadingTableReference(tyReadingReference& emptyTableReference); + tyReadingReference getEmptyReadingTableReference(std::string& asset); tyReadingReference getReadingReference(Connection *connection, const char *asset_code); bool attachDbsToAllConnections(); std::string sqlConstructMultiDb(std::string &sqlCmdBase, std::vector &assetCodes, bool considerExclusion=false); @@ -223,6 +224,10 @@ class ReadingsCatalogue { // asset_code - reading Table Id, Db Id // {"", ,{1 ,1 }} }; + std::map > m_EmptyAssetReadingCatalogue={ // In memory structure to identify in which database/table an asset is empty + // asset_code - reading Table Id, Db Id + // {"", ,{1 ,1 }} + }; public: TransactionBoundary m_tx; diff --git a/C/plugins/storage/sqlite/common/readings_catalogue.cpp b/C/plugins/storage/sqlite/common/readings_catalogue.cpp index 61952824d7..3e614fc655 100644 --- a/C/plugins/storage/sqlite/common/readings_catalogue.cpp +++ b/C/plugins/storage/sqlite/common/readings_catalogue.cpp @@ -845,6 +845,7 @@ void ReadingsCatalogue::multipleReadingsInit(STORAGE_CONFIGURATION &storageConfi preallocateReadingsTables(0); // on the last database evaluateGlobalId(); + loadEmptyAssetReadingCatalogue(); } catch (exception& e) { @@ -1835,8 +1836,9 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co AttachDbSync *attachSync = AttachDbSync::getInstance(); attachSync->lock(); + ReadingsCatalogue::tyReadingReference emptyTableReference = {-1, -1}; - + std::string emptyAsset = {}; auto item = m_AssetReadingCatalogue.find(asset_code); if (item != m_AssetReadingCatalogue.end()) { @@ -1845,53 +1847,60 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co } else { - //# Allocate a new block of readings table + if (! isReadingAvailable () ) { - Logger::getLogger()->debug("getReadingReference - allocate a new db, dbNAvailable :%d:", m_dbNAvailable); - - if (m_dbNAvailable > 0) + //No Readding table available... Get empty reading table + emptyTableReference = getEmptyReadingTableReference(emptyAsset); + if ( !emptyAsset.empty() ) { - // DBs already created are available - m_dbIdCurrent++; - m_dbNAvailable--; - m_nReadingsAvailable = getNReadingsAllocate(); - - Logger::getLogger()->debug("getReadingReference - allocate a new db, db already available - dbIdCurrent :%d: dbIdLast :%d: dbNAvailable :%d: nReadingsAvailable :%d: ", m_dbIdCurrent, m_dbIdLast, m_dbNAvailable, m_nReadingsAvailable); + ref = emptyTableReference; } - else + else { - // Allocates new DBs - int dbId, dbIdStart, dbIdEnd; - - dbIdStart = m_dbIdLast +1; - dbIdEnd = m_dbIdLast + m_storageConfigCurrent.nDbToAllocate; + //# Allocate a new block of readings table + Logger::getLogger()->debug("getReadingReference - allocate a new db, dbNAvailable :%d:", m_dbNAvailable); - Logger::getLogger()->debug("getReadingReference - allocate a new db - create new db - dbIdCurrent :%d: dbIdStart :%d: dbIdEnd :%d:", m_dbIdCurrent, dbIdStart, dbIdEnd); + if (m_dbNAvailable > 0) + { + // DBs already created are available + m_dbIdCurrent++; + m_dbNAvailable--; + m_nReadingsAvailable = getNReadingsAllocate(); - for (dbId = dbIdStart; dbId <= dbIdEnd; dbId++) + Logger::getLogger()->debug("getReadingReference - allocate a new db, db already available - dbIdCurrent :%d: dbIdLast :%d: dbNAvailable :%d: nReadingsAvailable :%d: ", m_dbIdCurrent, m_dbIdLast, m_dbNAvailable, m_nReadingsAvailable); + } + else { - readingsAvailable = evaluateLastReadingAvailable(dbHandle, dbId - 1); + // Allocates new DBs + int dbId, dbIdStart, dbIdEnd; + + dbIdStart = m_dbIdLast +1; + dbIdEnd = m_dbIdLast + m_storageConfigCurrent.nDbToAllocate; - startReadingsId = 1; + Logger::getLogger()->debug("getReadingReference - allocate a new db - create new db - dbIdCurrent :%d: dbIdStart :%d: dbIdEnd :%d:", m_dbIdCurrent, dbIdStart, dbIdEnd); - if (!getEmptyReadingTableReference(emptyTableReference)) + for (dbId = dbIdStart; dbId <= dbIdEnd; dbId++) { + readingsAvailable = evaluateLastReadingAvailable(dbHandle, dbId - 1); + + startReadingsId = 1; + success = createNewDB(dbHandle, dbId, startReadingsId, NEW_DB_ATTACH_REQUEST); if (success) { Logger::getLogger()->debug("getReadingReference - allocate a new db - create new dbs - dbId :%d: startReadingsIdOnDB :%d:", dbId, startReadingsId); } } - + m_dbIdLast = dbIdEnd; + m_dbIdCurrent++; + m_dbNAvailable = (m_dbIdLast - m_dbIdCurrent) - m_storageConfigCurrent.nDbLeftFreeBeforeAllocate; } - m_dbIdLast = dbIdEnd; - m_dbIdCurrent++; - m_dbNAvailable = (m_dbIdLast - m_dbIdCurrent) - m_storageConfigCurrent.nDbLeftFreeBeforeAllocate; - } - ref.tableId = -1; - ref.dbId = -1; + ref.tableId = -1; + ref.dbId = -1; + } + } if (success) @@ -1899,49 +1908,53 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co // Associate a reading table to the asset { // Associate the asset to the reading_id + if (emptyAsset.empty()) { - if (emptyTableReference.tableId > 0) - { - ref.tableId = emptyTableReference.tableId; - ref.dbId = emptyTableReference.dbId; - } - else - { - ref.tableId = getMaxReadingsId(m_dbIdCurrent) + 1; - ref.dbId = m_dbIdCurrent; - } - + ref.tableId = getMaxReadingsId(m_dbIdCurrent) + 1; + ref.dbId = m_dbIdCurrent; + } + + { + m_EmptyAssetReadingCatalogue.erase(emptyAsset); + m_AssetReadingCatalogue.erase(emptyAsset); auto newItem = make_pair(ref.tableId, ref.dbId); auto newMapValue = make_pair(asset_code, newItem); m_AssetReadingCatalogue.insert(newMapValue); } - Logger::getLogger()->debug("getReadingReference - allocate a new reading table for the asset :%s: db Id :%d: readings Id :%d: ", asset_code, ref.dbId, ref.tableId); - // Allocate the table in the reading catalogue + if (emptyAsset.empty()) { - if (emptyTableReference.tableId > 0) - { - - sql_cmd = " UPDATE " READINGS_DB ".asset_reading_catalogue SET asset_code ='" + string(asset_code) + "'" + - " WHERE db_id = " + to_string(emptyTableReference.dbId) + " AND table_id = " + to_string(emptyTableReference.tableId) + ";"; - } - else - { - sql_cmd = + sql_cmd = "INSERT INTO " READINGS_DB ".asset_reading_catalogue (table_id, db_id, asset_code) VALUES (" + to_string(ref.tableId) + "," + to_string(ref.dbId) + "," + "\"" + asset_code + "\")"; - } + + Logger::getLogger()->debug("getReadingReference - allocate a new reading table for the asset :%s: db Id :%d: readings Id :%d: ", asset_code, ref.dbId, ref.tableId); + + } + else + { + sql_cmd = " UPDATE " READINGS_DB ".asset_reading_catalogue SET asset_code ='" + string(asset_code) + "'" + + " WHERE db_id = " + to_string(ref.dbId) + " AND table_id = " + to_string(ref.tableId) + ";"; + Logger::getLogger()->debug("getReadingReference - Use empty table %readings_%d_%d: ",ref.dbId,ref.tableId); + } + + { rc = SQLExec(dbHandle, sql_cmd.c_str()); if (rc != SQLITE_OK) { msg = string(sqlite3_errmsg(dbHandle)) + " asset :" + asset_code + ":"; raiseError("asset_reading_catalogue update", msg.c_str()); } - allocateReadingAvailable(); + + if (emptyAsset.empty()) + { + allocateReadingAvailable(); + } + } } @@ -1956,35 +1969,29 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co } /** - * Get Empty Reading Table - * - * @param emptyTableReference An empty reading table reference to be used for the given asset_code - * @return True of success, false on any error + * Loads the empty reading table catalogue * */ -bool ReadingsCatalogue::getEmptyReadingTableReference(tyReadingReference &emptyTableReference) +bool ReadingsCatalogue::loadEmptyAssetReadingCatalogue() { - bool isEmptyTableAvailable = false; sqlite3 *dbHandle; string sql_cmd; sqlite3_stmt *stmt; - - // Disable functionality temporarily to avoid regression - return false; - ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); dbHandle = connection->getDbHandle(); - + m_EmptyAssetReadingCatalogue.clear(); + for (auto &item : m_AssetReadingCatalogue) { - int tableId = item.second.first; - int dbId = item.second.second; - sql_cmd = "SELECT COUNT(*) FROM (SELECT 0 FROM readings_" + to_string(dbId) + ".readings_" + to_string(dbId) + "_" + to_string(tableId) + " LIMIT 1)"; - + string asset_name = item.first; // Asset + int tableId = item.second.first; // tableId; + int dbId = item.second.second; // dbId; + + sql_cmd = "SELECT COUNT(*) FROM readings_" + to_string(dbId) + ".readings_" + to_string(dbId) + "_" + to_string(tableId) + " ;"; if (sqlite3_prepare_v2(dbHandle, sql_cmd.c_str(), -1, &stmt, NULL) != SQLITE_OK) { - raiseError("getEmptyReadingTableReference", sqlite3_errmsg(dbHandle)); + raiseError("loadEmptyAssetReadingCatalogue", sqlite3_errmsg(dbHandle)); return false; } @@ -1992,16 +1999,43 @@ bool ReadingsCatalogue::getEmptyReadingTableReference(tyReadingReference &emptyT { if (sqlite3_column_int(stmt, 0) == 0) { - isEmptyTableAvailable = true; - emptyTableReference.dbId = dbId; - emptyTableReference.tableId = tableId; + auto newItem = make_pair(tableId,dbId); + auto newMapValue = make_pair(asset_name,newItem); + m_EmptyAssetReadingCatalogue.insert(newMapValue); + } } sqlite3_finalize(stmt); + } - manager->release(connection); - return isEmptyTableAvailable; + return true; +} + +/** + * Get Empty Reading Table + * + * @param asset emptyAsset, copies value of asset for which empty table is found + * @return the reading id associated to the provided empty table + */ +ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getEmptyReadingTableReference(std::string& asset) +{ + ReadingsCatalogue::tyReadingReference emptyTableReference = {-1, -1}; + if (m_EmptyAssetReadingCatalogue.size() == 0) + { + loadEmptyAssetReadingCatalogue(); + } + + auto it = m_EmptyAssetReadingCatalogue.begin(); + if (it != m_EmptyAssetReadingCatalogue.end()) + { + asset = it->first; + emptyTableReference.tableId = it->second.first; + emptyTableReference.dbId = it->second.second; + + } + + return emptyTableReference; } /** From 73ddaf08f1de3f0b8b6ae54c96c77df6feb66804 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Sun, 18 Dec 2022 20:25:15 +0000 Subject: [PATCH 025/499] FOGL-6962 Add support for linked data types (#898) * FOGL-6962 Add support for linked data types Signed-off-by: Mark Riddoch * Addition of format and hint support Signed-off-by: Mark Riddoch * Update documentation Signed-off-by: Mark Riddoch * FOGL-7213 and FOGL-7212 change delimiter and send AF structure when usign linked data only Signed-off-by: Mark Riddoch * Update with review comments Signed-off-by: Mark Riddoch * Fix issue with unit test building Signed-off-by: Mark Riddoch * Make AVEVA all capitals in all places Signed-off-by: Mark Riddoch * Fix typo in comment Signed-off-by: Mark Riddoch * Addition of more OMF hints Signed-off-by: Mark Riddoch * Add new overrides Signed-off-by: Mark Riddoch * Add new hints for single datapoint hints Signed-off-by: Mark Riddoch * Fix JSON example Signed-off-by: Mark Riddoch * Fix typos Signed-off-by: Mark Riddoch * duplicate label & typo's fixes in OMF doc (#903) Signed-off-by: ashish-jabble Signed-off-by: ashish-jabble Signed-off-by: Mark Riddoch Signed-off-by: ashish-jabble Co-authored-by: Ashish Jabble Signed-off-by: nandan --- C/plugins/north/OMF/include/OMFHint.h | 61 +++ C/plugins/north/OMF/include/basetypes.h | 171 +++++++ C/plugins/north/OMF/include/omf.h | 61 ++- C/plugins/north/OMF/include/omflinkeddata.h | 104 ++++ C/plugins/north/OMF/linkdata.cpp | 385 +++++++++++++++ C/plugins/north/OMF/omf.cpp | 466 +++++++++++++----- C/plugins/north/OMF/omfhints.cpp | 78 +++ C/plugins/north/OMF/plugin.cpp | 112 ++++- docs/OMF.rst | 95 +++- docs/scripts/plugin_and_service_documentation | 2 +- tests/unit/C/CMakeLists.txt | 3 +- 11 files changed, 1380 insertions(+), 158 deletions(-) create mode 100644 C/plugins/north/OMF/include/basetypes.h create mode 100644 C/plugins/north/OMF/include/omflinkeddata.h create mode 100644 C/plugins/north/OMF/linkdata.cpp diff --git a/C/plugins/north/OMF/include/OMFHint.h b/C/plugins/north/OMF/include/OMFHint.h index 85e7f598d3..a78fa7997b 100644 --- a/C/plugins/north/OMF/include/OMFHint.h +++ b/C/plugins/north/OMF/include/OMFHint.h @@ -79,6 +79,67 @@ class OMFAFLocationHint : public OMFHint ~OMFAFLocationHint() {}; }; +/** + * A Legacy type hint, tells the OMF plugin to send complex types for this asset + */ +class OMFLegacyTypeHint : public OMFHint +{ +public: + OMFLegacyTypeHint(const std::string& name) { m_hint = name; }; + ~OMFLegacyTypeHint() {}; +}; + +/** + * A Source hint, defines the data source for the asset or datapoint + */ +class OMFSourceHint : public OMFHint +{ +public: + OMFSourceHint(const std::string& name) { m_hint = name; }; + ~OMFSourceHint() {}; +}; + +/** + * A unit of measurement hint, defines the unit of measurement for a datapoint + */ +class OMFUOMHint : public OMFHint +{ +public: + OMFUOMHint(const std::string& name) { m_hint = name; }; + ~OMFUOMHint() {}; +}; + + +/** + * A minimum hint, defines the minimum value for a property + */ +class OMFMinimumHint : public OMFHint +{ +public: + OMFMinimumHint(const std::string& name) { m_hint = name; }; + ~OMFMinimumHint() {}; +}; + + +/** + * A maximum hint, defines the maximum value for a property + */ +class OMFMaximumHint : public OMFHint +{ +public: + OMFMaximumHint(const std::string& name) { m_hint = name; }; + ~OMFMaximumHint() {}; +}; + +/** + * A interpolation hint, defines the interpolation value for a property + */ +class OMFInterpolationHint : public OMFHint +{ +public: + OMFInterpolationHint(const std::string& name) { m_hint = name; }; + ~OMFInterpolationHint() {}; +}; /** * A set of hints for a reading * diff --git a/C/plugins/north/OMF/include/basetypes.h b/C/plugins/north/OMF/include/basetypes.h new file mode 100644 index 0000000000..df6e33ee6a --- /dev/null +++ b/C/plugins/north/OMF/include/basetypes.h @@ -0,0 +1,171 @@ +#ifndef _BASETYPES_H +#define _BASETYPES_H +/* + * Fledge OSIsoft OMF interface to PI Server. + * + * Copyright (c) 2022 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ +#include + +static const char *baseOMFTypes = QUOTE( + [ + { + "id":"Double64", + "type":"object", + "classification":"dynamic", + "properties":{ + "Double64":{ + "type":["number", "null"], + "format":"float64" + }, + "Time":{ + "type":"string", + "format":"date-time", + "isindex":true + } + } + }, + { + "id":"Double32", + "type":"object", + "classification":"dynamic", + "properties":{ + "Double32":{ + "type":["number", "null"], + "format":"float32" + }, + "Time":{ + "type":"string", + "format":"date-time", + "isindex":true + } + } + }, + { + "id":"Integer16", + "type":"object", + "classification":"dynamic", + "properties":{ + "Integer16":{ + "type":["integer","null"], + "format":"int16", + }, + "Time":{ + "type":"string", + "format":"date-time", + "isindex":true + } + } + }, + { + "id":"Integer32", + "type":"object", + "classification":"dynamic", + "properties":{ + "Integer32":{ + "type":["integer","null"], + "format":"int32", + }, + "Time":{ + "type":"string", + "format":"date-time", + "isindex":true + } + } + }, + { + "id":"Integer64", + "type":"object", + "classification":"dynamic", + "properties":{ + "Integer64":{ + "type":["integer","null"], + "format":"int64", + }, + "Time":{ + "type":"string", + "format":"date-time", + "isindex":true + } + } + }, + { + "id":"UInteger16", + "type":"object", + "classification":"dynamic", + "properties":{ + "UInteger16":{ + "type":["integer","null"], + "format":"uint16", + }, + "Time":{ + "type":"string", + "format":"date-time", + "isindex":true + } + } + }, + { + "id":"UInteger32", + "type":"object", + "classification":"dynamic", + "properties":{ + "UInteger32":{ + "type":["integer","null"], + "format":"uint32", + }, + "Time":{ + "type":"string", + "format":"date-time", + "isindex":true + } + } + }, + { + "id":"UInteger64", + "type":"object", + "classification":"dynamic", + "properties":{ + "UInteger64":{ + "type":["integer","null"], + "format":"uint64", + }, + "Time":{ + "type":"string", + "format":"date-time", + "isindex":true + } + } + }, + { + "id":"String", + "type":"object", + "classification":"dynamic", + "properties":{ + "String":{ + "type":["string","null"] + }, + "Time":{ + "type":"string", + "format":"date-time", + "isindex":true + } + } + }, + { + "id":"FledgeAsset", + "type":"object", + "classification":"static", + "properties":{ + "AssetId": {"type": "string", "isindex": true }, + "Name" : { "type": "string", "isname": true } + } + + } + ]); + +#endif diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index 8872ec0fd7..537d8465f6 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -101,6 +101,20 @@ class OMF // Destructor ~OMF(); + void setOMFVersion(std::string& omfversion) + { + m_OMFVersion = omfversion; + if (omfversion.compare("1.0") == 0 + || omfversion.compare("1.1") == 0) + { + m_linkedProperties = false; + } + else + { + m_linkedProperties = true; + } + }; + /** * Send data to PI Server passing a vector of readings. * @@ -204,6 +218,8 @@ class OMF bool getConnected() const { return m_connected; }; void setConnected(const bool connectionStatus) { m_connected = connectionStatus; }; + void setLegacyMode(bool legacy) { m_legacy = legacy; }; + static std::string ApplyPIServerNamingRulesObj(const std::string &objName, bool *changed); static std::string ApplyPIServerNamingRulesPath(const std::string &objName, bool *changed); static std::string ApplyPIServerNamingRulesInvalidChars(const std::string &objName, bool *changed); @@ -230,7 +246,7 @@ class OMF const std::string createStaticData(const Reading& reading); // Create data Link message, with 'Data', for current row - std::string createLinkData(const Reading& reading, std::string& AFHierarchyLevel, std::string& prefix, std::string& objectPrefix, OMFHints *hints); + std::string createLinkData(const Reading& reading, std::string& AFHierarchyLevel, std::string& prefix, std::string& objectPrefix, OMFHints *hints, bool legacy); /** * Creata data for readings data content, with 'Data', for one row @@ -321,6 +337,12 @@ class OMF bool HandleAFMapNames(Document& JSon); bool HandleAFMapMetedata(Document& JSon); + // Start of support for using linked containers + bool sendBaseTypes(); + // End of support for using linked containers + // + string createAFLinks(Reading &reading, OMFHints *hints); + private: // Use for the evaluation of the OMFDataTypes.typesShort union t_typeCount { @@ -435,12 +457,47 @@ class OMF std::vector> *m_staticData; + /** + * The version of OMF we are talking + */ + std::string m_OMFVersion; + + /** + * Support sending properties via links + */ + bool m_linkedProperties; + /** + * The container for this asset and data point has been sent in + * this session. + */ + std::map + m_containerSent; + + /** + * The data message for this asset and data point has been sent in + * this session. + */ + std::map + m_assetSent; + + /** + * The link for this asset and data point has been sent in + * this session. + */ + std::map + m_linkSent; + + /** + * Force the data to be sent using the legacy, complex OMF types + */ + bool m_legacy; }; /** * The OMFData class. - * A reading is formatted with OMF specifications + * A reading is formatted with OMF specifications using the original + * type creation scheme implemented by the OMF plugin */ class OMFData { diff --git a/C/plugins/north/OMF/include/omflinkeddata.h b/C/plugins/north/OMF/include/omflinkeddata.h new file mode 100644 index 0000000000..c2e174a962 --- /dev/null +++ b/C/plugins/north/OMF/include/omflinkeddata.h @@ -0,0 +1,104 @@ +#ifndef OMFLINKEDDATA_H +#define OMFLINKEDDATA_H +/* + * Fledge OSIsoft OMF interface to PI Server. + * + * Copyright (c) 2022 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ + +#include +#include +#include + +/** + * The OMFLinkedData class. + * A reading is formatted with OMF specifications using the linked + * type creation scheme supported for OMF Version 1.2 onwards. + * + * This is based on the new mechanism discussed at AVEVA World 2022 and + * the mechanism is detailed in the Google Doc, + * https://docs.google.com/document/d/1w0e7VRqX7xzc0lEBLq-sYhgaHE0ABasOa6EC9dJMrMs/edit + * + * The principle is to use links to containers in OMF with each container being a single + * data point in the asset. There are no specific types for the assets, they share a set + * of base types via these links. This should allow for readings that have different sets + * of datapoints for each asset. + * + * It is also a goal of this mechanism to move away from the need to persist state data + * between invocations and make the process more robust. + */ +class OMFLinkedData +{ + public: + OMFLinkedData( std::map *containerSent, + std::map *assetSent, + std::map *linkSent, + const OMF_ENDPOINT PIServerEndpoint = ENDPOINT_CR) : + m_containerSent(containerSent), + m_assetSent(assetSent), + m_linkSent(linkSent), + m_endpoint(PIServerEndpoint), + m_doubleFormat("float64"), + m_integerFormat("int64") + {}; + std::string processReading(const Reading& reading, + const std::string& DefaultAFLocation = std::string(), + OMFHints *hints = NULL); + bool flushContainers(HttpSender& sender, const std::string& path, std::vector >& header); + void setFormats(const std::string& doubleFormat, const std::string& integerFormat) + { + m_doubleFormat = doubleFormat; + m_integerFormat = integerFormat; + }; + private: + std::string sendContainer(std::string& link, Datapoint *dp, const std::string& format, OMFHints * hints); + bool isTypeSupported(DatapointValue& dataPoint) + { + switch (dataPoint.getType()) + { + case DatapointValue::DatapointTag::T_FLOAT: + case DatapointValue::DatapointTag::T_INTEGER: + case DatapointValue::DatapointTag::T_STRING: + return true; + default: + return false; + } + }; + + private: + /** + * The container for this asset and data point has been sent in + * this session. + */ + std::map *m_containerSent; + + /** + * The data message for this asset and data point has been sent in + * this session. + */ + std::map *m_assetSent; + + /** + * The link for this asset and data point has been sent in + * this session. + */ + std::map *m_linkSent; + + /** + * The endpoint to which we are sending data + */ + OMF_ENDPOINT m_endpoint; + + + /** + * The set of containers to flush + */ + std::string m_containers; + std::string m_doubleFormat; + std::string m_integerFormat; +}; +#endif diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp new file mode 100644 index 0000000000..0fbb6bc439 --- /dev/null +++ b/C/plugins/north/OMF/linkdata.cpp @@ -0,0 +1,385 @@ +/* + * Fledge OSIsoft OMF interface to PI Server. + * + * Copyright (c) 2022 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ + +#include +#include +#include +#include +#include +#include +#include +#include "string_utils.h" +#include + +#include + +#include + +using namespace std; +/** + * OMFLinkedData constructor, generates the OMF message containing the data + * + * @param reading Reading for which the OMF message must be generated + * @param AFHierarchyPrefix Unused at the current stage + * @param hints OMF hints for the specific reading for changing the behaviour of the operation + * + */ +string OMFLinkedData::processReading(const Reading& reading, const string& AFHierarchyPrefix, OMFHints *hints) +{ + string outData; + bool changed; + + + string assetName = reading.getAssetName(); + // Apply any TagName hints to modify the containerid + if (hints) + { + const std::vector omfHints = hints->getHints(); + for (auto it = omfHints.cbegin(); it != omfHints.cend(); it++) + { + if (typeid(**it) == typeid(OMFTagNameHint)) + { + assetName = (*it)->getHint(); + Logger::getLogger()->info("Using OMF TagName hint: %s", assetName.c_str()); + } + if (typeid(**it) == typeid(OMFTagHint)) + { + assetName = (*it)->getHint(); + Logger::getLogger()->info("Using OMF Tag hint: %s", assetName.c_str()); + } + } + } + + + // Get reading data + const vector data = reading.getReadingData(); + unsigned long skipDatapoints = 0; + + Logger::getLogger()->info("Processing %s with new OMF method", assetName.c_str()); + + bool needDelim = false; + if (m_assetSent->find(assetName) == m_assetSent->end()) + { + // Send the data message to create the asset instance + outData.append("{ \"typeid\":\"FledgeAsset\", \"values\":[ { \"AssetId\":\""); + outData.append(assetName + "\",\"Name\":\""); + outData.append(assetName + "\""); + outData.append("} ] }"); + needDelim = true; + m_assetSent->insert(pair(assetName, true)); + } + + /** + * This loop creates the data values for each of the datapoints in the + * reading. + */ + for (vector::const_iterator it = data.begin(); it != data.end(); ++it) + { + string dpName = (*it)->getName(); + if (dpName.compare(OMF_HINT) == 0) + { + // Don't send the OMF Hint to the PI Server + continue; + } + if (!isTypeSupported((*it)->getData())) + { + skipDatapoints++;; + continue; + } + else + { + if (needDelim) + { + outData.append(","); + } + else + { + needDelim = true; + } + string format; + if (hints) + { + const vector omfHints = hints->getHints(dpName); + for (auto hit = omfHints.cbegin(); hit != omfHints.cend(); hit++) + { + if (typeid(**hit) == typeid(OMFNumberHint)) + { + format = (*hit)->getHint(); + break; + } + if (typeid(**hit) == typeid(OMFIntegerHint)) + { + format = (*hit)->getHint(); + break; + } + + } + } + + // Create the link for the asset if not already created + string link = assetName + "." + dpName; + string baseType; + auto container = m_containerSent->find(link); + if (container == m_containerSent->end()) + { + baseType = sendContainer(link, *it, format, hints); + m_containerSent->insert(pair(link, baseType)); + } + else + { + baseType = container->second; + } + if (baseType.empty()) + { + // Type is not supported, skip the datapoint + continue; + } + if (m_linkSent->find(link) == m_linkSent->end()) + { + outData.append("{ \"typeid\":\"__Link\","); + outData.append("\"values\":[ { \"source\" : {"); + outData.append("\"typeid\": \"FledgeAsset\","); + outData.append("\"index\":\"" + assetName); + outData.append("\" }, \"target\" : {"); + outData.append("\"containerid\" : \""); + outData.append(link); + outData.append("\" } } ] },"); + + m_linkSent->insert(pair(link, true)); + } + + // Convert reading data into the OMF JSON string + outData.append("{\"containerid\": \"" + link); + outData.append("\", \"values\": [{"); + + // Base type we are using for this data point + outData.append("\"" + baseType + "\": "); + // Add datapoint Value + outData.append((*it)->getData().toString()); + outData.append(", "); + // Append Z to getAssetDateTime(FMT_STANDARD) + outData.append("\"Time\": \"" + reading.getAssetDateUserTime(Reading::FMT_STANDARD) + "Z" + "\""); + outData.append("} ] }"); + } + } + Logger::getLogger()->debug("Created data messasges %s", outData.c_str()); + return outData; +} + +/** + * Send the container message for the linked datapoint + * + * @param linkName The name to use for the container + * @param dp The datapoint to process + * @param format The format to use based on a hint, this may be empty + * @param hints Hints related to this asset + * @return The base type linked in the container + */ +string OMFLinkedData::sendContainer(string& linkName, Datapoint *dp, const string& format, OMFHints * hints) +{ + string baseType; + switch (dp->getData().getType()) + { + case DatapointValue::T_STRING: + baseType = "String"; + break; + case DatapointValue::T_INTEGER: + { + string intFormat; + if (!format.empty()) + intFormat = format; + else + intFormat = m_integerFormat; + if (intFormat.compare("int64") == 0) + baseType = "Integer64"; + else if (intFormat.compare("int32") == 0) + baseType = "Integer32"; + else if (intFormat.compare("int16") == 0) + baseType = "Integer16"; + else if (intFormat.compare("uint64") == 0) + baseType = "UInteger64"; + else if (intFormat.compare("uint32") == 0) + baseType = "UInteger32"; + else if (intFormat.compare("uint16") == 0) + baseType = "UInteger16"; + break; + } + case DatapointValue::T_FLOAT: + { + string doubleFormat; + if (!format.empty()) + doubleFormat = format; + else + doubleFormat = m_doubleFormat; + if (doubleFormat.compare("float64") == 0) + baseType = "Double64"; + else if (doubleFormat.compare("float32") == 0) + baseType = "Double32"; + break; + } + default: + Logger::getLogger()->error("Unsupported type %s", dp->getData().getTypeStr()); + // Not supported + return baseType; + } + + string dataSource = "Fledge"; + string uom, minimum, maximum, interpolation; + bool propertyOverrides = false; + + + if (hints) + { + const vector omfHints = hints->getHints(); + for (auto it = omfHints.cbegin(); it != omfHints.end(); it++) + { + if (typeid(**it) == typeid(OMFSourceHint)) + { + dataSource = (*it)->getHint(); + } + } + + const vector dpHints = hints->getHints(dp->getName()); + for (auto it = dpHints.cbegin(); it != dpHints.end(); it++) + { + if (typeid(**it) == typeid(OMFSourceHint)) + { + dataSource = (*it)->getHint(); + } + if (typeid(**it) == typeid(OMFUOMHint)) + { + uom = (*it)->getHint(); + propertyOverrides = true; + } + if (typeid(**it) == typeid(OMFMinimumHint)) + { + minimum = (*it)->getHint(); + propertyOverrides = true; + } + if (typeid(**it) == typeid(OMFMaximumHint)) + { + maximum = (*it)->getHint(); + propertyOverrides = true; + } + if (typeid(**it) == typeid(OMFInterpolationHint)) + { + interpolation = (*it)->getHint(); + propertyOverrides = true; + } + } + } + + string container = "{ \"id\" : \"" + linkName; + container += "\", \"typeid\" : \""; + container += baseType; + container += "\", \"name\" : \""; + container += dp->getName(); + container += "\", \"datasource\" : \"" + dataSource + "\""; + + if (propertyOverrides) + { + container += ", \"propertyoverrides\" : { \""; + container += baseType; + container += "\" : {"; + string delim = ""; + if (!uom.empty()) + { + delim = ","; + container += "\"uom\" : \""; + container += uom; + container += "\""; + } + if (!minimum.empty()) + { + container += delim; + delim = ","; + container += "\"minimum\" : "; + container += minimum; + } + if (!maximum.empty()) + { + container += delim; + delim = ","; + container += "\"maximum\" : "; + container += maximum; + } + if (!interpolation.empty()) + { + container += delim; + delim = ","; + container += "\"interpolation\" : \""; + container += interpolation; + container += "\""; + } + container += "} }"; + } + container += "}"; + + Logger::getLogger()->debug("Built container: %s", container.c_str()); + + if (! m_containers.empty()) + m_containers += ","; + m_containers.append(container); + + return baseType; +} + +/** + * Flush the container definitions that have been built up + * + * @return true if the containers where succesfully flushed + */ +bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vector >& header) +{ + if (m_containers.empty()) + return true; // Nothing to flush + string payload = "[" + m_containers + "]"; + m_containers = ""; + + Logger::getLogger()->debug("Flush container information: %s", payload.c_str()); + + // Write to OMF endpoint + try + { + int res = sender.sendRequest("POST", + path, + header, + payload); + if ( ! (res >= 200 && res <= 299) ) + { + Logger::getLogger()->error("Sending containers, HTTP code %d - %s %s", + res, + sender.getHostPort().c_str(), + path.c_str()); + return false; + } + } + // Exception raised for HTTP 400 Bad Request + catch (const BadRequest& e) + { + + Logger::getLogger()->warn("Sending containers, not blocking issue: %s - %s %s", + e.what(), + sender.getHostPort().c_str(), + path.c_str()); + + return false; + } + catch (const std::exception& e) + { + + Logger::getLogger()->error("Sending containers, %s - %s %s", + e.what(), + sender.getHostPort().c_str(), + path.c_str()); + return false; + } + return true; +} diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index c29ef5d86d..f24c4251c7 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -30,6 +30,9 @@ #include #include +#include +#include + using namespace std; using namespace rapidjson; @@ -227,11 +230,13 @@ OMF::OMF(HttpSender& sender, m_path(path), m_typeId(id), m_producerToken(token), - m_sender(sender) + m_sender(sender), + m_legacy(false) { m_lastError = false; m_changeTypeId = false; m_OMFDataTypes = NULL; + m_OMFVersion = "1.0"; } /** @@ -543,7 +548,8 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) , AFHierarchyLevel.c_str() ); // Create data for Static Data message - string typeLinkData = OMF::createLinkData(row, AFHierarchyLevel, prefix, objectPrefix, hints); + string typeLinkData = OMF::createLinkData(row, AFHierarchyLevel, prefix, objectPrefix, hints, true); + string payload = "[" + typeLinkData + "]"; // Build an HTTPS POST with 'resLinkData' headers // and 'typeLinkData' JSON payload @@ -553,7 +559,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) res = m_sender.sendRequest("POST", m_path, resLinkData, - typeLinkData); + payload); if (!(res >= 200 && res <= 299)) { Logger::getLogger()->error("Sending JSON dataType message 'Data' (lynk) - error: HTTP code |%d| - %s %s", @@ -1031,9 +1037,19 @@ uint32_t OMF::sendToServer(const vector& readings, gettimeofday(&start, NULL); #endif - // Create a superset of all found datapoints for each assetName - // the superset[assetName] is then passed to routines which handle - // creation of OMF data types + if (m_linkedProperties) + { + if (!sendBaseTypes()) + { + Logger::getLogger()->error("Unable to send base types, linked assets will not be sent"); + m_linkedProperties = false; + } + } + + // Create a superset of all the datapoints for each assetName + // the superset[assetName] is then passed to routines which handles + // creation of OMF data types. This is used for the initial type + // handling of complex data types. OMF::setMapObjectTypes(readings, m_SuperSetDataPoints); #if INSTRUMENT @@ -1067,6 +1083,12 @@ uint32_t OMF::sendToServer(const vector& readings, string OMFHintAFHierarchyTmp; string OMFHintAFHierarchy; + bool legacyType = m_legacy; + + // Create the class that deals with the linked data generation + OMFLinkedData linkedData(&m_containerSent, &m_assetSent, &m_linkSent, m_PIServerEndpoint); + linkedData.setFormats(getFormatType(OMF_TYPE_FLOAT), getFormatType(OMF_TYPE_INTEGER)); + bool pendingSeparator = false; ostringstream jsonData; jsonData << "["; @@ -1112,6 +1134,15 @@ uint32_t OMF::sendToServer(const vector& readings, ,OMFHintAFHierarchy.c_str() ); } } + for (auto it = omfHints.cbegin(); it != omfHints.cend(); it++) + { + if (typeid(**it) == typeid(OMFLegacyTypeHint)) + { + Logger::getLogger()->info("Using OMF Legacy Type hint: %s", (*it)->getHint().c_str()); + legacyType = true; + break; + } + } } // Applies the PI-Server naming rules to the AssetName @@ -1166,118 +1197,152 @@ uint32_t OMF::sendToServer(const vector& readings, } } - if (! usingTagHint) + if (! AFHierarchySent) { - /* - * Check the OMFHints, if there are any, to see if we have a - * type name that should be used for this asset. - * We will still create the type, but the name will be fixed - * as the value of this hint. - */ - bool usingTypeNameHint = false; - if (hints) + setAFHierarchy(); + } + + string outData; + if (legacyType || m_OMFDataTypes->find(keyComplete) != m_OMFDataTypes->end()) + { + // Legacy type support + if (! usingTagHint) { - const vector omfHints = hints->getHints(); - for (auto it = omfHints.cbegin(); it != omfHints.cend(); it++) + /* + * Check the OMFHints, if there are any, to see if we have a + * type name that should be used for this asset. + * We will still create the type, but the name will be fixed + * as the value of this hint. + */ + bool usingTypeNameHint = false; + if (hints) { - if (typeid(**it) == typeid(OMFTypeNameHint)) + const vector omfHints = hints->getHints(); + for (auto it = omfHints.cbegin(); it != omfHints.cend(); it++) { - Logger::getLogger()->info("Using OMF TypeName hint: %s", (*it)->getHint().c_str()); - keyComplete.append("_" + (*it)->getHint()); - usingTypeNameHint = true; - break; + if (typeid(**it) == typeid(OMFTypeNameHint)) + { + Logger::getLogger()->info("Using OMF TypeName hint: %s", (*it)->getHint().c_str()); + keyComplete.append("_" + (*it)->getHint()); + usingTypeNameHint = true; + break; + } } } - } - - if (! AFHierarchySent) - { - setAFHierarchy(); - } - auto it = m_SuperSetDataPoints.find(m_assetName); - if (it == m_SuperSetDataPoints.end()) { - // The asset has only unsupported properties, so it is ignored - continue; - } - sendDataTypes = (m_lastError == false && skipSentDataTypes == true) ? - // Send if not already sent - !OMF::getCreatedTypes(keyComplete, *reading, hints) : - // Always send types - true; + auto it = m_SuperSetDataPoints.find(m_assetName); + if (it == m_SuperSetDataPoints.end()) { + // The asset has only unsupported properties, so it is ignored + continue; + } - Reading* datatypeStructure = NULL; - if (sendDataTypes && !usingTypeNameHint) - { - // Increment type-id of assetName in in memory cache - OMF::incrementAssetTypeIdOnly(keyComplete); - // Remove data and keep type-id - OMF::clearCreatedTypes(keyComplete); + sendDataTypes = (m_lastError == false && skipSentDataTypes == true) ? + // Send if not already sent + !OMF::getCreatedTypes(keyComplete, *reading, hints) : + // Always send types + true; - // Get the supersetDataPoints for current assetName - auto it = m_SuperSetDataPoints.find(m_assetName); - if (it != m_SuperSetDataPoints.end()) + Reading* datatypeStructure = NULL; + if (sendDataTypes && !usingTypeNameHint) { - datatypeStructure = (*it).second; + // Increment type-id of assetName in in memory cache + OMF::incrementAssetTypeIdOnly(keyComplete); + // Remove data and keep type-id + OMF::clearCreatedTypes(keyComplete); + + // Get the supersetDataPoints for current assetName + auto it = m_SuperSetDataPoints.find(m_assetName); + if (it != m_SuperSetDataPoints.end()) + { + datatypeStructure = (*it).second; + } } - } - if (m_sendFullStructure) { + if (m_sendFullStructure) { + + // The AF hierarchy is created/recreated if an OMF type message is sent + // it sends the hierarchy once + if (sendDataTypes and ! AFHierarchySent) + { + if (!handleAFHierarchy()) + { + m_lastError = true; + return 0; + } - // The AF hierarchy is created/recreated if an OMF type message is sent - // it sends the hierarchy once - if (sendDataTypes and ! AFHierarchySent) + AFHierarchySent = true; + } + } + + if (usingTypeNameHint) { - if (!handleAFHierarchy()) + if (sendDataTypes && !OMF::handleDataTypes(keyComplete, + *reading, skipSentDataTypes, hints)) { + // Failure m_lastError = true; return 0; } + } + else + { + // Check first we have supersetDataPoints for the current reading + if ((sendDataTypes && datatypeStructure == NULL) || + // Handle the data types of the current reading + (sendDataTypes && + // Send data type + !OMF::handleDataTypes(keyComplete, *datatypeStructure, skipSentDataTypes, hints) && + // Data type not sent: + (!m_changeTypeId || + // Increment type-id and re-send data types + !OMF::handleTypeErrors(keyComplete, *datatypeStructure, hints)))) + { + // Remove all assets supersetDataPoints + OMF::unsetMapObjectTypes(m_SuperSetDataPoints); - AFHierarchySent = true; + // Failure + m_lastError = true; + return 0; + } } + + // Create the key for dataTypes sending once + typeId = OMF::getAssetTypeId(m_assetName); } - if (usingTypeNameHint) + measurementId = generateMeasurementId(m_assetName); + + outData = OMFData(*reading, measurementId, m_PIServerEndpoint, AFHierarchyPrefix, hints ).OMFdataVal(); + } + else + { + // We do this before the send so we know if it was sent for the first time + // in the processReading call + auto asset_sent = m_assetSent.find(m_assetName); + // Send data for this reading using the new mechanism + outData = linkedData.processReading(*reading, AFHierarchyPrefix, hints); + if (asset_sent == m_assetSent.end()) { - if (sendDataTypes && !OMF::handleDataTypes(keyComplete, - *reading, skipSentDataTypes, hints)) + // If the hierarchy has not already been sent then send it + if (! AFHierarchySent) { - // Failure - m_lastError = true; - return 0; + if (!handleAFHierarchy()) + { + m_lastError = true; + return 0; + } + AFHierarchySent = true; } - } - else - { - // Check first we have supersetDataPoints for the current reading - if ((sendDataTypes && datatypeStructure == NULL) || - // Handle the data types of the current reading - (sendDataTypes && - // Send data type - !OMF::handleDataTypes(keyComplete, *datatypeStructure, skipSentDataTypes, hints) && - // Data type not sent: - (!m_changeTypeId || - // Increment type-id and re-send data types - !OMF::handleTypeErrors(keyComplete, *datatypeStructure, hints)))) - { - // Remove all assets supersetDataPoints - OMF::unsetMapObjectTypes(m_SuperSetDataPoints); - // Failure - m_lastError = true; - return 0; + string af = createAFLinks(*reading, hints); + if (! af.empty()) + { + outData.append(","); + outData.append(af); } } - - // Create the key for dataTypes sending once - typeId = OMF::getAssetTypeId(m_assetName); } - - measurementId = generateMeasurementId(m_assetName); - - string outData = OMFData(*reading, measurementId, m_PIServerEndpoint, AFHierarchyPrefix, hints ).OMFdataVal(); if (!outData.empty()) { jsonData << (pendingSeparator ? ", " : "") << outData; @@ -1311,6 +1376,9 @@ uint32_t OMF::sendToServer(const vector& readings, gettimeofday(&t3, NULL); #endif + vector> containerHeader = OMF::createMessageHeader("Container"); + linkedData.flushContainers(m_sender, m_path, containerHeader); + /** * Types messages sent, now transform each reading to OMF format. * @@ -1706,7 +1774,7 @@ const vector> OMF::createMessageHeader(const std::string& t res.push_back(pair("messagetype", type)); res.push_back(pair("producertoken", m_producerToken)); - res.push_back(pair("omfversion", "1.0")); + res.push_back(pair("omfversion", m_OMFVersion)); res.push_back(pair("messageformat", "JSON")); res.push_back(pair("action", action)); @@ -1884,6 +1952,7 @@ const std::string OMF::createTypeData(const Reading& reading, OMFHints *hints) } } + /** * Creates the Container message for data type definition * @@ -2115,9 +2184,13 @@ const std::string OMF::createStaticData(const Reading& reading) * Note: type is 'Data' * * @param reading A reading data + * @param AFHierarchyLevel The AF eleemnt we are placing the reading in + * @param AFHierarchyPrefix The prefix we use for thr AF Eleement + * @param objectPrefix The object prefix we are using for this asset + * @param legacy We are using legacy, complex types for this reading * @return Type JSON message as string */ -std::string OMF::createLinkData(const Reading& reading, std::string& AFHierarchyLevel, std::string& AFHierarchyPrefix, std::string& objectPrefix, OMFHints *hints) +std::string OMF::createLinkData(const Reading& reading, std::string& AFHierarchyLevel, std::string& AFHierarchyPrefix, std::string& objectPrefix, OMFHints *hints, bool legacy) { string targetTypeId; @@ -2129,7 +2202,7 @@ std::string OMF::createLinkData(const Reading& reading, std::string& AFHierarch long typeId = getAssetTypeId(assetName); - string lData = "[{\"typeid\": \"__Link\", \"values\": ["; + string lData = "{\"typeid\": \"__Link\", \"values\": ["; // Handles the structure for the Connector Relay // not supported by PI Web API @@ -2160,7 +2233,7 @@ std::string OMF::createLinkData(const Reading& reading, std::string& AFHierarch // Add asset_name lData.append(assetName); - lData.append("\"}},"); + lData.append("\"}}"); } else if (m_PIServerEndpoint == ENDPOINT_PIWEB_API) { @@ -2172,60 +2245,73 @@ std::string OMF::createLinkData(const Reading& reading, std::string& AFHierarch StringReplace(tmpStr, "_placeholder_src_type_", AFHierarchyPrefix + "_" + AFHierarchyLevel + "_typeid"); StringReplace(tmpStr, "_placeholder_src_idx_", AFHierarchyPrefix + "_" + AFHierarchyLevel ); - StringReplace(tmpStr, "_placeholder_tgt_type_", targetTypeId); - StringReplace(tmpStr, "_placeholder_tgt_idx_", "A_" + objectPrefix + "_" + assetName + - generateSuffixType(assetName, typeId) ); + + if (legacy) + { + StringReplace(tmpStr, "_placeholder_tgt_type_", targetTypeId); + StringReplace(tmpStr, "_placeholder_tgt_idx_", "A_" + objectPrefix + "_" + assetName + + generateSuffixType(assetName, typeId) ); + } + else + { + StringReplace(tmpStr, "_placeholder_tgt_type_", "FledgeAsset"); + StringReplace(tmpStr, "_placeholder_tgt_idx_", assetName); + } lData.append(tmpStr); - lData.append(","); } - lData.append("{\"source\": {\"typeid\": \""); - // Add type_id + '_' + asset_name + '__typename_sensor' - OMF::setAssetTypeTag(assetName, - "typename_sensor", - lData); + if (legacy) + { + lData.append(",{\"source\": {\"typeid\": \""); - lData.append("\", \"index\": \""); + // Add type_id + '_' + asset_name + '__typename_sensor' + OMF::setAssetTypeTag(assetName, + "typename_sensor", + lData); - if (m_PIServerEndpoint == ENDPOINT_CR) - { - // Add asset_name - lData.append(assetName); - } - else if (m_PIServerEndpoint == ENDPOINT_OCS || - m_PIServerEndpoint == ENDPOINT_ADH || - m_PIServerEndpoint == ENDPOINT_EDS) - { - // Add asset_name - lData.append(assetName); - } - else if (m_PIServerEndpoint == ENDPOINT_PIWEB_API) - { - lData.append("A_" + objectPrefix + "_" + assetName + generateSuffixType(assetName, typeId) ); - } + lData.append("\", \"index\": \""); - measurementId = generateMeasurementId(assetName); + if (m_PIServerEndpoint == ENDPOINT_CR) + { + // Add asset_name + lData.append(assetName); + } + else if (m_PIServerEndpoint == ENDPOINT_OCS || + m_PIServerEndpoint == ENDPOINT_ADH || + m_PIServerEndpoint == ENDPOINT_EDS) + { + // Add asset_name + lData.append(assetName); + } + else if (m_PIServerEndpoint == ENDPOINT_PIWEB_API) + { + lData.append("A_" + objectPrefix + "_" + assetName + generateSuffixType(assetName, typeId) ); + } + + measurementId = generateMeasurementId(assetName); - // Apply any TagName hints to modify the containerid - if (hints) - { - const std::vector omfHints = hints->getHints(); - for (auto it = omfHints.cbegin(); it != omfHints.cend(); it++) + // Apply any TagName hints to modify the containerid + if (hints) { - if (typeid(**it) == typeid(OMFTagNameHint)) + const std::vector omfHints = hints->getHints(); + for (auto it = omfHints.cbegin(); it != omfHints.cend(); it++) { - measurementId = (*it)->getHint(); - Logger::getLogger()->info("Using OMF TagName hint: %s", measurementId.c_str()); - break; + if (typeid(**it) == typeid(OMFTagNameHint)) + { + measurementId = (*it)->getHint(); + Logger::getLogger()->info("Using OMF TagName hint: %s", measurementId.c_str()); + break; + } } } - } - lData.append("\"}, \"target\": {\"containerid\": \"" + measurementId); + lData.append("\"}, \"target\": {\"containerid\": \"" + measurementId); - lData.append("\"}}]}]"); + lData.append("\"}}"); + } + lData.append("]}"); // Return JSON string return lData; @@ -4525,3 +4611,121 @@ std::string OMF::ApplyPIServerNamingRulesPath(const std::string &objName, bool * return (nameFixed); } + +/** + * Send the base types that we use to define all the data point values + * + * @return true If the data types were sent correctly. Otherwsie false. + */ +bool OMF::sendBaseTypes() +{ + vector> resType = OMF::createMessageHeader("Type"); + + // Build an HTTPS POST with 'resType' headers + // and 'typeData' JSON payload + // Then get HTTPS POST ret code and return 0 to client on error + try + { + int res = m_sender.sendRequest("POST", + m_path, + resType, + baseOMFTypes); + if ( ! (res >= 200 && res <= 299) ) + { + Logger::getLogger()->error("Sending base data types message 'Type', HTTP code %d - %s %s", + res, + m_sender.getHostPort().c_str(), + m_path.c_str()); + return false; + } + } + // Exception raised for HTTP 400 Bad Request + catch (const BadRequest& e) + { + if (OMF::isDataTypeError(e.what())) + { + // Data type error: force type-id change + m_changeTypeId = true; + } + string errorMsg = errorMessageHandler(e.what()); + + Logger::getLogger()->warn("Sending dataType message 'Type', not blocking issue: %s %s - %s %s", + (m_changeTypeId ? "Data Type " : "" ), + errorMsg.c_str(), + m_sender.getHostPort().c_str(), + m_path.c_str()); + + return false; + } + catch (const std::exception& e) + { + string errorMsg = errorMessageHandler(e.what()); + + Logger::getLogger()->error("Sending dataType message 'Type', %s - %s %s", + errorMsg.c_str(), + m_sender.getHostPort().c_str(), + m_path.c_str()); + m_connected = false; + return false; + } + Logger::getLogger()->info("Base types successully sent"); + return true; +} + +/** + * Create the messages to link the asset into the right place in the AF structure + * + * @param reading The reading beign sent + * @param hints OMF Hints for this reading + */ +string OMF::createAFLinks(Reading& reading, OMFHints *hints) +{ +string AFDataMessage; + + if (m_sendFullStructure) + { + string assetName = m_assetName; + string AFHierarchyLevel; + string prefix; + string objectPrefix; + + auto rule = m_AssetNamePrefix.find(assetName); + if (rule != m_AssetNamePrefix.end()) + { + auto itemArray = rule->second; + objectPrefix = ""; + + for (auto &item : itemArray) + { + string AFHierarchy; + string prefix; + + AFHierarchy = std::get<0>(item); + generateAFHierarchyPrefixLevel(AFHierarchy, prefix, AFHierarchyLevel); + + prefix = std::get<1>(item); + + if (objectPrefix.empty()) + { + objectPrefix = prefix; + } + + Logger::getLogger()->debug("%s - assetName :%s: AFHierarchy :%s: prefix :%s: objectPrefix :%s: AFHierarchyLevel :%s: ", __FUNCTION__ + ,assetName.c_str() + , AFHierarchy.c_str() + , prefix.c_str() + , objectPrefix.c_str() + , AFHierarchyLevel.c_str() ); + + // Create data for Static Data message + AFDataMessage = OMF::createLinkData(reading, AFHierarchyLevel, prefix, objectPrefix, hints, false); + + } + } + else + { + Logger::getLogger()->error("AF hiererachy is not defined for the asset Name |%s|", assetName.c_str()); + } + } + return AFDataMessage; +} diff --git a/C/plugins/north/OMF/omfhints.cpp b/C/plugins/north/OMF/omfhints.cpp index d1500a3933..38846115e0 100644 --- a/C/plugins/north/OMF/omfhints.cpp +++ b/C/plugins/north/OMF/omfhints.cpp @@ -142,6 +142,14 @@ OMFHints::OMFHints(const string& hints) { m_hints.push_back(new OMFAFLocationHint(itr->value.GetString())); } + else if (strcmp(name, "LegacyType") == 0) + { + m_hints.push_back(new OMFLegacyTypeHint(itr->value.GetString())); + } + else if (strcmp(name, "source") == 0) + { + m_hints.push_back(new OMFSourceHint(itr->value.GetString())); + } else if (strcmp(name, "datapoint") == 0) { const Value &child = itr->value; @@ -176,6 +184,41 @@ OMFHints::OMFHints(const string& hints) { hints.push_back(new OMFTagHint(dpitr->value.GetString())); } + else if (strcmp(name, "uom") == 0) + { + hints.push_back(new OMFUOMHint(dpitr->value.GetString())); + } + else if (strcmp(name, "source") == 0) + { + hints.push_back(new OMFSourceHint(dpitr->value.GetString())); + } + else if (strcmp(name, "minimum") == 0) + { + hints.push_back(new OMFMinimumHint(dpitr->value.GetString())); + } + else if (strcmp(name, "maximum") == 0) + { + hints.push_back(new OMFMaximumHint(dpitr->value.GetString())); + } + else if (strcmp(name, "interpolation") == 0) + { + string interpolation = dpitr->value.GetString(); + if (interpolation.compare("continuous") + && interpolation.compare("discrete") + && interpolation.compare("stepwisecontinuousleading") + && interpolation.compare("stepwisecontinuousfollowing")) + { + Logger::getLogger()->warn("Invalid value for interpolation hint for %s, only continuous, discrete, stepwisecontinuousleading, and stepwisecontinuousfollowing are supported", dpname.c_str()); + } + else + { + hints.push_back(new OMFInterpolationHint(interpolation)); + } + } + else if (strcmp(name, "name")) // Ignore the name + { + Logger::getLogger()->warn("Invalid OMF hint '%s'", name); + } } m_datapointHints.insert(std::pair>(dpname, hints)); } @@ -211,6 +254,41 @@ OMFHints::OMFHints(const string& hints) { hints.push_back(new OMFTagHint(dpitr->value.GetString())); } + else if (strcmp(name, "uom") == 0) + { + hints.push_back(new OMFUOMHint(dpitr->value.GetString())); + } + else if (strcmp(name, "source") == 0) + { + hints.push_back(new OMFSourceHint(dpitr->value.GetString())); + } + else if (strcmp(name, "minimum") == 0) + { + hints.push_back(new OMFMinimumHint(dpitr->value.GetString())); + } + else if (strcmp(name, "maximum") == 0) + { + hints.push_back(new OMFMaximumHint(dpitr->value.GetString())); + } + else if (strcmp(name, "interpolation") == 0) + { + string interpolation = dpitr->value.GetString(); + if (interpolation.compare("continuous") + && interpolation.compare("discrete") + && interpolation.compare("stepwisecontinuousleading") + && interpolation.compare("stepwisecontinuousfollowing")) + { + Logger::getLogger()->warn("Invalid value for interpolation hint for %s, only continuous, discrete, stepwisecontinuousleading, and stepwisecontinuousfollowing are supported", dpname.c_str()); + } + else + { + hints.push_back(new OMFInterpolationHint(interpolation)); + } + } + else if (strcmp(name, "name")) // Ignore the name + { + Logger::getLogger()->warn("Invalid OMF hint '%s'", name); + } } m_datapointHints.insert(std::pair>(dpname, hints)); } diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 77c26ffc21..634e204f11 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -131,6 +131,20 @@ enum OMF_ENDPOINT_PORT { } \ ) +/* + * Note that the properties "group" is used to group related items, these will appear in different tabs, + * using the group name, in the GUI. + * + * This GUI functionality has yet to be implemented. + * + * Current groups used are + * "Authentication" Items relating to authentication with the endpoint + * "Connection" Connection tuning items + * "Formats & Types" Controls for the way formats and tyoes are defined + * "Asset Framework" Asset framework configuration items + * "Cloud" Things related to OCS or ADH only + * "Advanced" Adds to the Advanced tab that already exists + */ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( { "plugin": { @@ -194,6 +208,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "default": "omf_north_0001", "order": "7", "displayName": "Producer Token", + "group" : "Authentication", "validity" : "PIServerEndpoint == \"Connector Relay\"" }, "source": { @@ -216,6 +231,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type": "integer", "default": "1", "order": "10", + "group": "Connection", "displayName": "Sleep Time Retry" }, "OMFMaxRetry": { @@ -223,6 +239,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type": "integer", "default": "3", "order": "11", + "group": "Connection", "displayName": "Maximum Retry" }, "OMFHttpTimeout": { @@ -230,20 +247,25 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type": "integer", "default": "10", "order": "12", + "group": "Connection", "displayName": "HTTP Timeout" }, "formatInteger": { "description": "OMF format property to apply to the type Integer", - "type": "string", + "type": "enumeration", "default": "int64", + "options": ["int64", "int32", "int16", "uint64", "uint32", "uint16"], "order": "13", + "group": "Formats & Types", "displayName": "Integer Format" }, "formatNumber": { "description": "OMF format property to apply to the type Number", - "type": "string", + "type": "enumeration", "default": "float64", + "options": ["float64", "float32"], "order": "14", + "group": "Formats & Types", "displayName": "Number Format" }, "compression": { @@ -251,6 +273,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type": "boolean", "default": "true", "order": "15", + "group": "Connection", "displayName": "Compression" }, "DefaultAFLocation": { @@ -259,6 +282,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "default": "/fledge/data_piwebapi/default", "order": "16", "displayName": "Default Asset Framework Location", + "group" : "Asset Framework", "validity" : "PIServerEndpoint == \"PI Web API\"" }, "AFMap": { @@ -266,6 +290,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type": "JSON", "default": AF_HIERARCHY_RULES, "order": "17", + "group" : "Asset Framework", "displayName": "Asset Framework hierarchy rules", "validity" : "PIServerEndpoint == \"PI Web API\"" @@ -291,6 +316,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "options":["anonymous", "basic", "kerberos"], "default": "anonymous", "order": "20", + "group": "Authentication", "displayName": "PI Web API Authentication Method", "validity" : "PIServerEndpoint == \"PI Web API\"" }, @@ -299,6 +325,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type": "string", "default": "user_id", "order": "21", + "group": "Authentication", "displayName": "PI Web API User Id", "validity" : "PIServerEndpoint == \"PI Web API\" && PIWebAPIAuthenticationMethod == \"basic\"" }, @@ -307,6 +334,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type": "password", "default": "password", "order": "22" , + "group": "Authentication", "displayName": "PI Web API Password", "validity" : "PIServerEndpoint == \"PI Web API\" && PIWebAPIAuthenticationMethod == \"basic\"" }, @@ -315,6 +343,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type": "string", "default": "piwebapi_kerberos_https.keytab", "order": "23" , + "group": "Authentication", "displayName": "PI Web API Kerberos keytab file", "validity" : "PIServerEndpoint == \"PI Web API\" && PIWebAPIAuthenticationMethod == \"kerberos\"" }, @@ -323,6 +352,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type" : "string", "default": "name_space", "order": "24", + "group" : "Cloud", "displayName" : "Namespace", "validity" : "PIServerEndpoint == \"OSIsoft Cloud Services\" || PIServerEndpoint == \"AVEVA Data Hub\"" }, @@ -331,6 +361,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type" : "string", "default": "ocs_tenant_id", "order": "25", + "group" : "Cloud", "displayName" : "Tenant ID", "validity" : "PIServerEndpoint == \"OSIsoft Cloud Services\" || PIServerEndpoint == \"AVEVA Data Hub\"" }, @@ -339,6 +370,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type" : "string", "default": "ocs_client_id", "order": "26", + "group" : "Cloud", "displayName" : "Client ID", "validity" : "PIServerEndpoint == \"OSIsoft Cloud Services\" || PIServerEndpoint == \"AVEVA Data Hub\"" }, @@ -347,6 +379,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "type" : "password", "default": "ocs_client_secret", "order": "27", + "group" : "Cloud", "displayName" : "Client Secret", "validity" : "PIServerEndpoint == \"OSIsoft Cloud Services\" || PIServerEndpoint == \"AVEVA Data Hub\"" }, @@ -356,6 +389,14 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "default": NOT_BLOCKING_ERRORS_DEFAULT_PI_WEB_API, "order": "28" , "readonly": "true" + }, + "Legacy": { + "description": "Force all data to be sent using complex OMF types", + "type": "boolean", + "default": "false", + "order": "29", + "group": "Formats & Types", + "displayName": "Complex Types" } } ); @@ -415,6 +456,8 @@ typedef struct // Per asset DataTypes std::map assetsDataTypes; + string omfversion; + bool legacy; } CONNECTOR_INFO; unsigned long calcTypeShort (const string& dataTypes); @@ -427,7 +470,7 @@ void AuthKerberosSetup (string& keytabFile, string& keytabFi string OCSRetrieveAuthToken (CONNECTOR_INFO* connInfo); int PIWebAPIGetVersion (CONNECTOR_INFO* connInfo, std::string &version, bool logMessage = true); double GetElapsedTime (struct timeval *startTime); -bool IsPIWebAPIConnected (CONNECTOR_INFO* connInfo); +bool IsPIWebAPIConnected (CONNECTOR_INFO* connInfo, std::string& version); /** @@ -700,6 +743,13 @@ PLUGIN_HANDLE plugin_init(ConfigCategory* configData) } + // Fetch legacy OMF type option + string legacy = configData->getValue("Legacy"); + if (legacy == "True" || legacy == "true" || legacy == "TRUE") + connInfo->legacy = true; + else + connInfo->legacy = false; + #if VERBOSE_LOG // Log plugin configuration Logger::getLogger()->info("%s plugin configured: URL=%s, " @@ -750,7 +800,7 @@ void plugin_start(const PLUGIN_HANDLE handle, PLUGIN_NAME, storedData.c_str()); } - else if(JSONData.HasMember(TYPE_ID_KEY) && + else if (JSONData.HasMember(TYPE_ID_KEY) && (JSONData[TYPE_ID_KEY].IsString() || JSONData[TYPE_ID_KEY].IsNumber())) { @@ -819,20 +869,37 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, gettimeofday(&startTime, NULL); #endif CONNECTOR_INFO* connInfo = (CONNECTOR_INFO *)handle; + string version; // Check if the endpoint is PI Web API and if the PI Web API server is available - if (!IsPIWebAPIConnected(connInfo)) + if (!IsPIWebAPIConnected(connInfo, version)) { + Logger::getLogger()->fatal("OMF Endpoint is not available"); return 0; } + // Until we know better assume OMF 1.2 + connInfo->omfversion = "1.2"; + if (version.find("2019") != std::string::npos) + { + connInfo->omfversion = "1.0"; + } + else if (version.find("2021") != std::string::npos) + { + connInfo->omfversion = "1.2"; + } + /** - * Select the proper library in relation to the need, - * LibcurlHttps is needed to integrate Kerberos as the SimpleHttp does not support it - * the Libcurl integration implements only HTTPS not HTTP at the current stage + * Select the transport library based on the authentication method and transport encryption + * requirements. + * + * LibcurlHttps is used to integrate Kerberos as the SimpleHttp does not support it + * the Libcurl integration implements only HTTPS not HTTP currently. We use SimpleHttp or + * SimpleHttps, as appropriate for the URL given, if not using Kerberos + * * * The handler is allocated using "Hostname : port", connect_timeout and request_timeout. - * Default is no timeout at all + * Default is no timeout */ if (connInfo->PIWebAPIAuthMethod.compare("k") == 0) { @@ -847,18 +914,18 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, if (connInfo->protocol.compare("http") == 0) { connInfo->sender = new SimpleHttp(connInfo->hostAndPort, - connInfo->timeout, - connInfo->timeout, - connInfo->retrySleepTime, - connInfo->maxRetry); + connInfo->timeout, + connInfo->timeout, + connInfo->retrySleepTime, + connInfo->maxRetry); } else { connInfo->sender = new SimpleHttps(connInfo->hostAndPort, - connInfo->timeout, - connInfo->timeout, - connInfo->retrySleepTime, - connInfo->maxRetry); + connInfo->timeout, + connInfo->timeout, + connInfo->retrySleepTime, + connInfo->maxRetry); } } @@ -879,7 +946,7 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, connInfo->sender->setOCSToken (connInfo->OCSToken); } - // Allocate the PI Server data protocol + // Allocate the OMF class that implements the PI Server data protocol connInfo->omf = new OMF(*connInfo->sender, connInfo->path, connInfo->assetsDataTypes, @@ -893,6 +960,7 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, connInfo->omf->setPIServerEndpoint(connInfo->PIServerEndpoint); connInfo->omf->setDefaultAFLocation(connInfo->DefaultAFLocation); connInfo->omf->setAFMap(connInfo->AFMap); + connInfo->omf->setOMFVersion(connInfo->omfversion); // Generates the prefix to have unique asset_id across different levels of hierarchies string AFHierarchyLevel; @@ -909,7 +977,9 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, connInfo->omf->setStaticData(&connInfo->staticData); connInfo->omf->setNotBlockingErrors(connInfo->notBlockingErrors); - // Send data + connInfo->omf->setLegacyMode(connInfo->legacy); + + // Send the readings data to the PI Server uint32_t ret = connInfo->omf->sendToServer(readings, connInfo->compression); @@ -1591,9 +1661,10 @@ double GetElapsedTime(struct timeval *startTime) * Check if the PI Web API server is available by reading the product version * * @param connInfo The CONNECTOR_INFO data structure + * @param version Returned version string * @return Connection status */ -bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo) +bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) { static std::chrono::steady_clock::time_point nextCheck; @@ -1603,7 +1674,6 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo) if (now >= nextCheck) { - std::string version; int httpCode = PIWebAPIGetVersion(connInfo, version, false); if (httpCode >= 500) { diff --git a/docs/OMF.rst b/docs/OMF.rst index 9a49ebc333..657f9c256f 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -43,7 +43,7 @@ Select PI Web API from the Endpoint options. - **Asset Framework Hierarchies Rules:** A set of rules that allow specific readings to be placed elsewhere in the Asset Framework. These rules can be based on the name of the asset itself or some metadata associated with the asset. See `Asset Framework Hierarchy Rules`_. - PI Web API authentication - **PI Web API Authentication Method:** The authentication method to be used: anonymous, basic or kerberos. - Anonymous equates to no authentication, basic authentication requires a user name and password, and Kerberos allows integration with your single signon environment. + Anonymous equates to no authentication, basic authentication requires a user name and password, and Kerberos allows integration with your Single Sign-On environment. - **PI Web API User Id:** For Basic authentication, the user name to authenticate with the PI Web API. - **PI Web API Password:** For Basic authentication, the password of the user we are using to authenticate. - **PI Web API Kerberos keytab file:** The Kerberos keytab file used to authenticate. @@ -56,6 +56,7 @@ Select PI Web API from the Endpoint options. - **Number Format:** Used to match Fledge data types to the data type configured in PI. The default is float64 but may be set to any OMF datatype that supports floating point values. - **Compression:** Compress the readings data before sending them to the PI Web API OMF endpoint. This setting is not related to data compression in the PI Data Archive. + - **Complex Types:** Used to force the plugin to send OMF data types as complex types rather than the newer linked types. Linked types are the default way to send data and allows assets to have different sets of data points in different readings. See :ref:`Linked_Types`. Edge Data Store OMF Endpoint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -97,7 +98,7 @@ The second screen will request the following information: | |omf_plugin_adh_config| | +-------------------------+ -Select AVEVA Data Hubfrom the Endpoint options. +Select AVEVA Data Hub from the Endpoint options. - Basic Information - **Endpoint:** This is the type of OMF endpoint. In this case, choose AVEVA Data Hub. @@ -464,6 +465,10 @@ that adds this hint to ensure this is the case. "OMFHint" : { "type" : "pump" } +.. note:: + + This hint only has meaning when using the complex type legacy mode with this plugin. + Tag Name Hint ~~~~~~~~~~~~~ @@ -473,6 +478,27 @@ Specifies that a specific tag name should be used when storing data in the PI Se "OMFHint" : { "tagName" : "AC1246" } +Legacy Type Hint +~~~~~~~~~~~~~~~~ + +Use legacy style complex types for this reading rather that the newer linked data types. + +.. code-block:: console + + "OMFHint" : { "LegacyType" : "true" } + +The allows the older mechanism to be forced for a single asset. See :ref:`Linked_Types`. + +Source Hint +~~~~~~~~~~~ + +The default data source that is associated with tags in the PI Server is Fledge, however this can be overridden using the data source hint. This hint may be applied to the entire asset or to specific datapoints within the asset. + +.. code-block:: console + + "OMFHint" : { "source" : "Fledge23" } + + Datapoint Specific Hint ~~~~~~~~~~~~~~~~~~~~~~~ @@ -488,6 +514,22 @@ to apply. The above hint applies to the datapoint *voltage* in the asset and applies a *number format* hint to that datapoint. +If more than one datapoint within a reading is required to have OMF hints +attached to them this may be done by using an array as a child of the +datapoint item. + +.. code-block:: console + + "OMFHint" : { "datapoint" : [ + { "name" : "voltage:, "number" : "float32", "uom" : "volt" }, + { "name" : "current:, "number" : "uint32", "uom" : "milliampere } + ] + } + +The example above attaches a number hint to both the voltage and current +datapoints and to the current datapoint. It assigns a unit of measure +of milliampere. The unit of measure for the voltage is set to be volts. + Asset Framework Location Hint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -517,6 +559,45 @@ Note the following when defining an *AFLocation* hint: - If you move a Container, OMF North will not recreate it. If you then edit the AF Location hint, the Container will appear in the new location. +Unit Of Measure Hint +~~~~~~~~~~~~~~~~~~~~ + +A unit of measure, or uom hint is used to associate one of the units of +measurement defined within your PI Server with a particular data point +within an asset. + +.. code-block:: console + + "OMFHint" : { "datapoint" : { "name" : "height:, "uom" : "meter" } } + +Minimum Hint +~~~~~~~~~~~~ + +A minimum hint is used to associate a minimum value in the PI Point created for a data point. + +.. code-block:: console + + "OMFHint" : { "datapoint" : { "name" : "height:, "minimum" : "0" } } + +Maximum Hint +~~~~~~~~~~~~ + +A maximum hint is used to associate a maximum value in the PI Point created for a data point. + +.. code-block:: console + + "OMFHint" : { "datapoint" : { "name" : "height:, "maximum" : "100000" } } + +Interpolation +~~~~~~~~~~~~~ + +The interpolation hint sets the interpolation value used within the PI Server, interpolation values supported are continuous, discrete, stepwisecontinuousleading, and stepwisecontinuousfollowing. + +.. code-block:: console + + "OMFHint" : { "datapoint" : { "name" : "height:, "interpolation" : "continuous" } } + + Adding OMF Hints ~~~~~~~~~~~~~~~~ @@ -524,3 +605,13 @@ An OMF Hint is implemented as a string data point on a reading with the data point name of *OMFHint*. It can be added at any point in the processing of the data, however a specific plugin is available for adding the hints, the |OMFHint filter plugin|. + +.. _Linked_Types: + +Linked Types +------------ + +Versions of this plugin prior to 2.1.0 created a complex type within OMF for each asset that included all of the data points within that asset. This suffered from a limitation in that readings had to contain values for all of the data points of an asset in order to be accepted by the OMF end point. Following the introduction of OMF version 1.2 it was possible to use the linking features of OMF to avoid the need to create complex types for an asset and instead create empty assets and link the data points to this shell asset. This allows readings to only contain a subset of datapoints and still be successfully sent to the PI Server, or other end points. + +As of version 2.1.0 this linking approach is used for all new assets created, if assets exist within the PI Server from versions of the plugin prior to 2.1.0 then the older, complex types will be used. It is possible to force the plugin to use complex types for all assets, both old and new, using the configuration option. It is also to force a particular asset to use the complex type mechanism using an OMFHint. + diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index a9a270c17a..89e11cdc2a 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -148,7 +148,7 @@ echo '.. include:: ../../fledge-north-OMF.rst' > plugins/fledge-north-OMF/index. # Append OMF.rst to the end of the file rather than including it so that we may # edit the links to prevent duplicates cat OMF.rst >> plugins/fledge-north-OMF/index.rst -sed -i -e 's/Naming_Scheme/Naming_Scheme_plugin/' plugins/fledge-north-OMF/index.rst +sed -i -e 's/Naming_Scheme/Naming_Scheme_plugin/' -e 's/Linked_Types/Linked_Types_Plugin/' plugins/fledge-north-OMF/index.rst # Create the Threshold rule documentation mkdir plugins/fledge-rule-Threshold ln -s $(pwd)/fledge-rule-Threshold/images plugins/fledge-rule-Threshold/images diff --git a/tests/unit/C/CMakeLists.txt b/tests/unit/C/CMakeLists.txt index 99482eac35..4460dd7a2c 100644 --- a/tests/unit/C/CMakeLists.txt +++ b/tests/unit/C/CMakeLists.txt @@ -95,7 +95,8 @@ set_target_properties(plugins-common-lib PROPERTIES SOVERSION 1) set(LIB_NAME OMF) file(GLOB OMF_LIB_SOURCES ../../../C/plugins/north/OMF/omf.cpp - ../../../C/plugins/north/OMF/omfhints.cpp) + ../../../C/plugins/north/OMF/omfhints.cpp + ../../../C/plugins/north/OMF/linkdata.cpp) add_library(${LIB_NAME} SHARED ${OMF_LIB_SOURCES}) target_link_libraries(${LIB_NAME} From 10b20220d6c20b0f1c3e0d23e6eba993189d2f13 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 19 Dec 2022 17:41:46 +0530 Subject: [PATCH 026/499] Task always added with C-based script Signed-off-by: ashish-jabble Signed-off-by: nandan --- python/fledge/services/core/api/task.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/python/fledge/services/core/api/task.py b/python/fledge/services/core/api/task.py index 819518d736..18860e549e 100644 --- a/python/fledge/services/core/api/task.py +++ b/python/fledge/services/core/api/task.py @@ -159,11 +159,8 @@ async def add_task(request): plugin_module_path = "{}/python/fledge/plugins/{}/{}".format(_FLEDGE_ROOT, task_type, plugin) plugin_info = common.load_and_fetch_python_plugin_info(plugin_module_path, plugin, task_type) plugin_config = plugin_info['config'] - script = '["tasks/north"]' - process_name = 'north' except FileNotFoundError as ex: # Checking for C-type plugins - script = '["tasks/north_c"]' plugin_info = apiutils.get_plugin_info(plugin, dir=task_type) if not plugin_info: msg = "Plugin {} does not appear to be a valid plugin".format(plugin) @@ -181,10 +178,10 @@ async def add_task(request): _logger.error(msg) return web.HTTPBadRequest(reason=msg) plugin_config = plugin_info['config'] - process_name = 'north_c' if not plugin_config: _logger.exception("Plugin %s import problem from path %s. %s", plugin, plugin_module_path, str(ex)) - raise web.HTTPNotFound(reason='Plugin "{}" import problem from path "{}"'.format(plugin, plugin_module_path)) + raise web.HTTPNotFound(reason='Plugin "{}" import problem from path "{}"'.format(plugin, + plugin_module_path)) except TypeError as ex: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: @@ -208,7 +205,6 @@ async def add_task(request): _logger.exception(msg) raise web.HTTPBadRequest(reason=msg) - # Check whether category name already exists category_info = await config_mgr.get_category_all_items(category_name=name) if category_info is not None: @@ -219,6 +215,9 @@ async def add_task(request): if count != 0: raise web.HTTPBadRequest(reason='A north instance with this name already exists') + # Always run with C based sending process task + process_name = 'north_c' + script = '["tasks/north_c"]' # Check that the process name is not already registered count = await check_scheduled_processes(storage, process_name) if count == 0: # Create the scheduled process entry for the new task From c9cf9cdbf4be6259b7bb39ba48c4c6e7cc4c536d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 19 Dec 2022 17:42:18 +0530 Subject: [PATCH 027/499] unit tests updated as per task always with C based Signed-off-by: ashish-jabble Signed-off-by: nandan --- .../fledge/services/core/api/test_task.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/test_task.py b/tests/unit/python/fledge/services/core/api/test_task.py index f30a4a904a..5e765cf217 100644 --- a/tests/unit/python/fledge/services/core/api/test_task.py +++ b/tests/unit/python/fledge/services/core/api/test_task.py @@ -72,7 +72,7 @@ def q_result(*arg): if table == 'scheduled_processes': assert {'return': ['name'], - 'where': {'column': 'name', 'condition': '=', 'value': 'north'}} == json.loads(payload) + 'where': {'column': 'name', 'condition': '=', 'value': 'north_c'}} == json.loads(payload) return {'count': 0, 'rows': []} if table == 'schedules': assert {'return': ['schedule_name'], @@ -217,7 +217,7 @@ async def q_result(*arg): if table == 'scheduled_processes': assert {'return': ['name'], 'where': {'column': 'name', 'condition': '=', - 'value': 'north'}} == json.loads(payload) + 'value': 'north_c'}} == json.loads(payload) return {'count': 0, 'rows': []} if table == 'schedules': assert {'return': ['schedule_name'], 'where': {'column': 'schedule_name', 'condition': '=', @@ -268,7 +268,6 @@ async def q_result(*arg): _rv3 = asyncio.ensure_future(self.async_mock("")) _rv4 = asyncio.ensure_future(async_mock_get_schedule()) - with patch.object(common, 'load_and_fetch_python_plugin_info', return_value=mock_plugin_info): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(c_mgr, 'get_category_all_items', return_value=_rv1) as patch_get_cat_info: @@ -293,15 +292,16 @@ async def q_result(*arg): patch_save_schedule.assert_called_once() patch_create_child_cat.assert_called_once_with('North', ['north bound']) calls = [call(category_description='North OMF plugin', category_name='north bound', - category_value={'plugin': {'description': 'North OMF plugin', 'default': 'omf', - 'type': 'string'}}, keep_original_items=True), + category_value={ + 'plugin': {'description': 'North OMF plugin', 'default': 'omf', + 'type': 'string'}}, keep_original_items=True), call('North', {}, 'North tasks', True)] patch_create_cat.assert_has_calls(calls) args, kwargs = insert_table_patch.call_args assert 'scheduled_processes' == args[0] p = json.loads(args[1]) - assert p['name'] == 'north' - assert p['script'] == '["tasks/north"]' + assert p['name'] == 'north_c' + assert p['script'] == '["tasks/north_c"]' patch_get_cat_info.assert_called_once_with(category_name=data['name']) @pytest.mark.parametrize( @@ -372,7 +372,7 @@ async def q_result(*arg): payload = arg[1] if table == 'scheduled_processes': assert {'return': ['name'], 'where': {'column': 'name', 'condition': '=', - 'value': 'north'}} == json.loads(payload) + 'value': 'north_c'}} == json.loads(payload) return {'count': 0, 'rows': []} if table == 'schedules': assert {'return': ['schedule_name'], 'where': {'column': 'schedule_name', 'condition': '=', @@ -459,7 +459,8 @@ async def q_result(*arg): return_value=_rv3) as patch_save_schedule: with patch.object(server.Server.scheduler, 'get_schedule_by_name', return_value=_rv4) as patch_get_schedule: - resp = await client.post('/fledge/scheduled/task', data=json.dumps(data)) + resp = await client.post('/fledge/scheduled/task', + data=json.dumps(data)) server.Server.scheduler = None assert 200 == resp.status result = await resp.text() @@ -476,8 +477,8 @@ async def q_result(*arg): args, kwargs = insert_table_patch.call_args assert 'scheduled_processes' == args[0] p = json.loads(args[1]) - assert p['name'] == 'north' - assert p['script'] == '["tasks/north"]' + assert p['name'] == 'north_c' + assert p['script'] == '["tasks/north_c"]' patch_get_cat_info.assert_called_once_with(category_name=data['name']) async def test_delete_task(self, mocker, client): From 7feb3fb87ae7c3a511d2b6603ce49ec00a60c2a8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 22 Dec 2022 12:22:30 +0530 Subject: [PATCH 028/499] PUT user profile update is also allowed for view and data view role based users Signed-off-by: ashish-jabble Signed-off-by: nandan --- python/fledge/common/web/middleware.py | 10 +++++----- .../api/test_endpoints_with_different_user_types.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/python/fledge/common/web/middleware.py b/python/fledge/common/web/middleware.py index 37101850f7..24e5570c80 100644 --- a/python/fledge/common/web/middleware.py +++ b/python/fledge/common/web/middleware.py @@ -162,7 +162,7 @@ async def validate_requests(request): """ a) With "view" based user role id=3 only - read access operations (GET calls) - - change password (PUT call) + - change profile (PUT call) - logout (PUT call) b) With "data-view" based user role id=4 only - ping (GET call) @@ -170,13 +170,13 @@ async def validate_requests(request): - service (GET call) - statistics, statistics history, statistics rate (GET call) - user profile (GET call) - - roles (GET call) - - change password (PUT call) + - user roles (GET call) + - change profile (PUT call) - logout (PUT call) """ user_id = request.user['id'] if int(request.user["role_id"]) == 3 and request.method != 'GET': - supported_endpoints = ['/fledge/user/{}/password'.format(user_id), '/logout'] + supported_endpoints = ['/fledge/user', '/fledge/user/{}/password'.format(user_id), '/logout'] if not str(request.rel_url).endswith(tuple(supported_endpoints)): raise web.HTTPForbidden elif int(request.user["role_id"]) == 4: @@ -187,7 +187,7 @@ async def validate_requests(request): ) or str(request.rel_url).endswith('/fledge/service')): raise web.HTTPForbidden elif request.method == 'PUT': - supported_endpoints = ['/fledge/user/{}/password'.format(user_id), '/logout'] + supported_endpoints = ['/fledge/user', '/fledge/user/{}/password'.format(user_id), '/logout'] if not str(request.rel_url).endswith(tuple(supported_endpoints)): raise web.HTTPForbidden else: diff --git a/tests/system/python/api/test_endpoints_with_different_user_types.py b/tests/system/python/api/test_endpoints_with_different_user_types.py index 6b17f7b029..b04d6b008e 100644 --- a/tests/system/python/api/test_endpoints_with_different_user_types.py +++ b/tests/system/python/api/test_endpoints_with_different_user_types.py @@ -100,7 +100,7 @@ def test_login(self, fledge_url, wait_time): # health ("GET", "/fledge/health/storage", 200), ("GET", "/fledge/health/logging", 200), # user & roles - ("GET", "/fledge/user", 200), ("PUT", "/fledge/user", 403), ("PUT", "/fledge/user/1/password", 403), + ("GET", "/fledge/user", 200), ("PUT", "/fledge/user", 500), ("PUT", "/fledge/user/1/password", 403), ("PUT", "/fledge/user/3/password", 500), ("GET", "/fledge/user/role", 200), # auth ("POST", "/fledge/login", 403), ("PUT", "/fledge/31/logout", 401), @@ -248,7 +248,7 @@ def test_login(self, fledge_url, wait_time): # health ("GET", "/fledge/health/storage", 403), ("GET", "/fledge/health/logging", 403), # user & roles - ("GET", "/fledge/user", 403), ("PUT", "/fledge/user", 403), ("PUT", "/fledge/user/1/password", 403), + ("GET", "/fledge/user", 403), ("PUT", "/fledge/user", 500), ("PUT", "/fledge/user/1/password", 403), ("PUT", "/fledge/user/4/password", 500), ("GET", "/fledge/user/role", 200), # auth ("POST", "/fledge/login", 403), ("PUT", "/fledge/31/logout", 401), From d77848efd7299d6b25250b6112145eba9ea8fd7a Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 22 Dec 2022 11:05:50 +0000 Subject: [PATCH 029/499] FOGL-7138 Fix for missing configuration item properties (#909) * FOGL-7138 Fix for missing configuration item properties Signed-off-by: Mark Riddoch * Escape the validity item as it often contians quotes and restore default to false for itemsToJSON Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch Signed-off-by: nandan --- C/common/config_category.cpp | 73 +++++++++++++++++++++++++----- C/common/include/config_category.h | 6 ++- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/C/common/config_category.cpp b/C/common/config_category.cpp index 0ff76b10e7..3b14e60885 100644 --- a/C/common/config_category.cpp +++ b/C/common/config_category.cpp @@ -464,6 +464,10 @@ string ConfigCategory::getItemAttribute(const string& itemName, return m_items[i]->m_mandatory; case FILE_ATTR: return m_items[i]->m_file; + case VALIDITY_ATTR: + return m_items[i]->m_validity; + case GROUP_ATTR: + return m_items[i]->m_group; default: throw new ConfigItemAttributeNotFound(); } @@ -514,6 +518,12 @@ bool ConfigCategory::setItemAttribute(const string& itemName, case LENGTH_ATTR: m_items[i]->m_length = value; return true; + case VALIDITY_ATTR: + m_items[i]->m_validity = value; + return true; + case GROUP_ATTR: + m_items[i]->m_group = value; + return true; default: return false; } @@ -946,7 +956,7 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, m_order = ""; } - if (item.HasMember("length")) + if (item.HasMember("length")) { m_length = item["length"].GetString(); } @@ -1030,6 +1040,23 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, m_displayName = ""; } + if (item.HasMember("validity")) + { + m_validity = item["validity"].GetString(); + } + else + { + m_validity = ""; + } + if (item.HasMember("group")) + { + m_group = item["group"].GetString(); + } + else + { + m_group = ""; + } + if (item.HasMember("options")) { const Value& options = item["options"]; @@ -1310,12 +1337,14 @@ ConfigCategory::CategoryItem::CategoryItem(const CategoryItem& rhs) } m_file = rhs.m_file; m_itemType = rhs.m_itemType; + m_validity = rhs.m_validity; + m_group = rhs.m_group; } /** * Create a JSON representation of the configuration item * - * @param full false is the deafult, true evaluates all the members of the CategoryItem + * @param full false is the default, true evaluates all the members of the CategoryItem * */ string ConfigCategory::CategoryItem::toJSON(const bool full) const @@ -1365,7 +1394,7 @@ ostringstream convert; convert << ", \"order\" : \"" << m_order << "\""; } - if (!m_length.empty()) + if (!m_length.empty()) { convert << ", \"length\" : \"" << m_length << "\""; } @@ -1385,10 +1414,20 @@ ostringstream convert; convert << ", \"readonly\" : \"" << m_readonly << "\""; } - if (!m_mandatory.empty()) - { - convert << ", \"mandatory\" : \"" << m_mandatory << "\""; - } + if (!m_mandatory.empty()) + { + convert << ", \"mandatory\" : \"" << m_mandatory << "\""; + } + + if (!m_validity.empty()) + { + convert << ", \"validity\" : \"" << JSONescape(m_validity) << "\""; + } + + if (!m_group.empty()) + { + convert << ", \"group\" : \"" << m_group << "\""; + } if (!m_file.empty()) { @@ -1432,7 +1471,7 @@ ostringstream convert; convert << ", \"displayName\" : \"" << m_displayName << "\""; } - if (!m_length.empty()) + if (!m_length.empty()) { convert << ", \"length\" : \"" << m_length << "\""; } @@ -1452,10 +1491,20 @@ ostringstream convert; convert << ", \"readonly\" : \"" << m_readonly << "\""; } - if (!m_mandatory.empty()) - { - convert << ", \"mandatory\" : \"" << m_mandatory << "\""; - } + if (!m_mandatory.empty()) + { + convert << ", \"mandatory\" : \"" << m_mandatory << "\""; + } + + if (!m_validity.empty()) + { + convert << ", \"validity\" : \"" << JSONescape(m_validity) << "\""; + } + + if (!m_group.empty()) + { + convert << ", \"group\" : \"" << m_group << "\""; + } if (!m_file.empty()) { diff --git a/C/common/include/config_category.h b/C/common/include/config_category.h index 76803e0f77..24aee86af1 100644 --- a/C/common/include/config_category.h +++ b/C/common/include/config_category.h @@ -124,7 +124,9 @@ class ConfigCategory { FILE_ATTR, MINIMUM_ATTR, MAXIMUM_ATTR, - LENGTH_ATTR}; + LENGTH_ATTR, + VALIDITY_ATTR, + GROUP_ATTR}; std::string getItemAttribute(const std::string& itemName, ItemAttribute itemAttribute) const; @@ -166,6 +168,8 @@ class ConfigCategory { m_options; std::string m_file; ItemType m_itemType; + std::string m_validity; + std::string m_group; }; std::vector m_items; std::string m_name; From 5c10fa0d3b9c066bcaf20748133e84d35da1437b Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 22 Dec 2022 12:48:11 +0000 Subject: [PATCH 030/499] FOGL-6979 - Allow for 0 rows updated in an update query to be treated as a normal condition (#908) * Add update modifier to the storage plugin and C storage client to allow no rows being updated to be treated as a normal condidtion. Signed-off-by: Mark Riddoch * iterator variable corrected to check type Signed-off-by: ashish-jabble * MODIFIER support added in python payload builder Signed-off-by: ashish-jabble * unit tests updated for modifier payload builder Signed-off-by: ashish-jabble * MODIFIER added in refresh token expiry case only as asked in FOGL-7267 JIRA Signed-off-by: ashish-jabble * Fix issue raused in review comments Signed-off-by: Mark Riddoch * Add changes to sqlitelb also Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch Signed-off-by: ashish-jabble Co-authored-by: ashish-jabble Signed-off-by: nandan --- C/common/include/storage_client.h | 35 ++++--- C/common/include/update_modifier.h | 32 +++++++ C/common/storage_client.cpp | 94 +++++++++++++------ C/plugins/storage/postgres/connection.cpp | 19 +++- .../storage/sqlite/common/connection.cpp | 18 +++- .../storage/sqlitelb/common/connection.cpp | 18 +++- .../common/storage_client/payload_builder.py | 11 +++ python/fledge/services/core/user_model.py | 4 +- .../data/payload_modifier_set_where.json | 11 +++ .../storage_client/test_payload_builder.py | 8 ++ 10 files changed, 205 insertions(+), 45 deletions(-) create mode 100644 C/common/include/update_modifier.h create mode 100644 tests/unit/python/fledge/common/storage_client/data/payload_modifier_set_where.json diff --git a/C/common/include/storage_client.h b/C/common/include/storage_client.h index ea10cb72be..4df4b8a701 100644 --- a/C/common/include/storage_client.h +++ b/C/common/include/storage_client.h @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -48,20 +49,30 @@ class StorageClient { ResultSet *queryTable(const std::string& tablename, const Query& query); ReadingSet *queryTableToReadings(const std::string& tableName, const Query& query); int insertTable(const std::string& schema, const std::string& tableName, const InsertValues& values); - int updateTable(const std::string& schema, const std::string& tableName, const InsertValues& values, const Where& where); - int updateTable(const std::string& schema, const std::string& tableName, const JSONProperties& json, const Where& where); - int updateTable(const std::string& schema, const std::string& tableName, const InsertValues& values, const JSONProperties& json, const Where& where); - int updateTable(const std::string& schema, const std::string& tableName, const ExpressionValues& values, const Where& where); - int updateTable(const std::string& schema, const std::string& tableName, std::vector>& updates); - int updateTable(const std::string& schema, const std::string& tableName, const InsertValues& values, const ExpressionValues& expressoins, const Where& where); + int updateTable(const std::string& schema, const std::string& tableName, const InsertValues& values, + const Where& where, const UpdateModifier *modifier = NULL); + int updateTable(const std::string& schema, const std::string& tableName, const JSONProperties& json, + const Where& where, const UpdateModifier *modifier = NULL); + int updateTable(const std::string& schema, const std::string& tableName, const InsertValues& values, + const JSONProperties& json, const Where& where, const UpdateModifier *modifier = NULL); + int updateTable(const std::string& schema, const std::string& tableName, const ExpressionValues& values, + const Where& where, const UpdateModifier *modifier = NULL); + int updateTable(const std::string& schema, const std::string& tableName, + std::vector>& updates, const UpdateModifier *modifier = NULL); + int updateTable(const std::string& schema, const std::string& tableName, const InsertValues& values, + const ExpressionValues& expressoins, const Where& where, const UpdateModifier *modifier = NULL); int deleteTable(const std::string& schema, const std::string& tableName, const Query& query); int insertTable(const std::string& tableName, const InsertValues& values); - int updateTable(const std::string& tableName, const InsertValues& values, const Where& where); - int updateTable(const std::string& tableName, const JSONProperties& json, const Where& where); - int updateTable(const std::string& tableName, const InsertValues& values, const JSONProperties& json, const Where& where); - int updateTable(const std::string& tableName, const ExpressionValues& values, const Where& where); - int updateTable(const std::string& tableName, std::vector>& updates); - int updateTable(const std::string& tableName, const InsertValues& values, const ExpressionValues& expressoins, const Where& where); + int updateTable(const std::string& tableName, const InsertValues& values, const Where& where, const UpdateModifier *modifier = NULL); + int updateTable(const std::string& tableName, const JSONProperties& json, const Where& where, const UpdateModifier *modifier = NULL); + int updateTable(const std::string& tableName, const InsertValues& values, const JSONProperties& json, + const Where& where, const UpdateModifier *modifier = NULL); + int updateTable(const std::string& tableName, const ExpressionValues& values, const Where& where, + const UpdateModifier *modifier = NULL); + int updateTable(const std::string& tableName, std::vector>& updates, + const UpdateModifier *modifier = NULL); + int updateTable(const std::string& tableName, const InsertValues& values, const ExpressionValues& expressions, + const Where& where, const UpdateModifier *modifier = NULL); int deleteTable(const std::string& tableName, const Query& query); bool readingAppend(Reading& reading); bool readingAppend(const std::vector & readings); diff --git a/C/common/include/update_modifier.h b/C/common/include/update_modifier.h new file mode 100644 index 0000000000..6d3745921c --- /dev/null +++ b/C/common/include/update_modifier.h @@ -0,0 +1,32 @@ +#ifndef _UPDATE_MODIFIER_H +#define _UPDATE_MODIFIER_H +/* + * Fledge storage client. + * + * Copyright (c) 2022 Dianonic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ +#include + + +/** + * Update modifier + */ +class UpdateModifier { + public: + UpdateModifier(const std::string& modifier) : + m_modifier(modifier) + { + }; + ~UpdateModifier(); + const std::string toJSON() const { return m_modifier; }; + private: + UpdateModifier(const UpdateModifier&); + UpdateModifier& operator=(UpdateModifier const&); + const std::string m_modifier; +}; +#endif + diff --git a/C/common/storage_client.cpp b/C/common/storage_client.cpp index 811f1770aa..28732a4fe0 100755 --- a/C/common/storage_client.cpp +++ b/C/common/storage_client.cpp @@ -583,11 +583,12 @@ int StorageClient::insertTable(const string& schema, const string& tableName, co * @param tableName The name of the table into which data will be added * @param values The values to insert into the table * @param where The conditions to match the updated rows + * @param modifier Optional storage modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& tableName, const InsertValues& values, const Where& where) +int StorageClient::updateTable(const string& tableName, const InsertValues& values, const Where& where, const UpdateModifier *modifier) { - return updateTable(DEFAULT_SCHEMA, tableName, values, where); + return updateTable(DEFAULT_SCHEMA, tableName, values, where, modifier); } /** @@ -597,9 +598,10 @@ int StorageClient::updateTable(const string& tableName, const InsertValues& valu * @param tableName The name of the table into which data will be added * @param values The values to insert into the table * @param where The conditions to match the updated rows + * @param modifier Optional storage modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& schema, const string& tableName, const InsertValues& values, const Where& where) +int StorageClient::updateTable(const string& schema, const string& tableName, const InsertValues& values, const Where& where, const UpdateModifier *modifier) { static HttpClient *httpClient = this->getHttpClient(); // to initialize m_seqnum_map[thread_id] for this thread try { @@ -614,8 +616,12 @@ int StorageClient::updateTable(const string& schema, const string& tableName, co ostringstream convert; - convert << "{ \"updates\" : [ "; - convert << "{ \"where\" : "; + convert << "{ \"updates\" : [ {"; + if (modifier) + { + convert << "\"modifiers\" : [ \"" << modifier->toJSON() << "\" ], "; + } + convert << "\"where\" : "; convert << where.toJSON(); convert << ", \"values\" : "; convert << values.toJSON(); @@ -662,11 +668,12 @@ int StorageClient::updateTable(const string& schema, const string& tableName, co * @param tableName The name of the table into which data will be added * @param values The expressions to update into the table * @param where The conditions to match the updated rows + * @param modifier Optional update modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& tableName, const ExpressionValues& values, const Where& where) +int StorageClient::updateTable(const string& tableName, const ExpressionValues& values, const Where& where, const UpdateModifier *modifier) { - return updateTable(DEFAULT_SCHEMA, tableName, values, where); + return updateTable(DEFAULT_SCHEMA, tableName, values, where, modifier); } /** @@ -676,9 +683,10 @@ int StorageClient::updateTable(const string& tableName, const ExpressionValues& * @param tableName The name of the table into which data will be added * @param values The expressions to update into the table * @param where The conditions to match the updated rows + * @param modifier Optional update modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& schema, const string& tableName, const ExpressionValues& values, const Where& where) +int StorageClient::updateTable(const string& schema, const string& tableName, const ExpressionValues& values, const Where& where, const UpdateModifier *modifier) { static HttpClient *httpClient = this->getHttpClient(); // to initialize m_seqnum_map[thread_id] for this thread try { @@ -693,8 +701,12 @@ int StorageClient::updateTable(const string& schema, const string& tableName, co ostringstream convert; - convert << "{ \"updates\" : [ "; - convert << "{ \"where\" : "; + convert << "{ \"updates\" : [ {"; + if (modifier) + { + convert << "\"modifiers\" : [ \"" << modifier->toJSON() << "\" ], "; + } + convert << "\"where\" : "; convert << where.toJSON(); convert << ", \"expressions\" : "; convert << values.toJSON(); @@ -740,11 +752,12 @@ int StorageClient::updateTable(const string& schema, const string& tableName, co * * @param tableName The name of the table into which data will be added * @param updates The expressions and condition pairs to update in the table + * @param modifier Optional update modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& tableName, vector>& updates) +int StorageClient::updateTable(const string& tableName, vector>& updates, const UpdateModifier *modifier) { - return updateTable(DEFAULT_SCHEMA, tableName, updates); + return updateTable(DEFAULT_SCHEMA, tableName, updates, modifier); } /** @@ -753,9 +766,10 @@ int StorageClient::updateTable(const string& tableName, vector>& updates) +int StorageClient::updateTable(const string& schema, const string& tableName, vector>& updates, const UpdateModifier *modifier) { static HttpClient *httpClient = this->getHttpClient(); // to initialize m_seqnum_map[thread_id] for this thread try { @@ -777,7 +791,12 @@ int StorageClient::updateTable(const string& schema, const string& tableName, ve { convert << ", "; } - convert << "{ \"where\" : "; + convert << "{ "; + if (modifier) + { + convert << "\"modifiers\" : [ \"" << modifier->toJSON() << "\" ], "; + } + convert << "\"where\" : "; convert << it->second->toJSON(); convert << ", \"expressions\" : "; convert << it->first->toJSON(); @@ -827,11 +846,12 @@ int StorageClient::updateTable(const string& schema, const string& tableName, ve * @param values The values to insert into the table * @param expressions The expression to update inthe table * @param where The conditions to match the updated rows + * @param modifier Optional update modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& tableName, const InsertValues& values, const ExpressionValues& expressions, const Where& where) +int StorageClient::updateTable(const string& tableName, const InsertValues& values, const ExpressionValues& expressions, const Where& where, const UpdateModifier *modifier) { - return updateTable(DEFAULT_SCHEMA, tableName, values, expressions, where); + return updateTable(DEFAULT_SCHEMA, tableName, values, expressions, where, modifier); } /** @@ -842,15 +862,20 @@ int StorageClient::updateTable(const string& tableName, const InsertValues& valu * @param values The values to insert into the table * @param expressions The expression to update inthe table * @param where The conditions to match the updated rows + * @param modifier Optional update modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& schema, const string& tableName, const InsertValues& values, const ExpressionValues& expressions, const Where& where) +int StorageClient::updateTable(const string& schema, const string& tableName, const InsertValues& values, const ExpressionValues& expressions, const Where& where, const UpdateModifier *modifier) { try { ostringstream convert; - convert << "{ \"updates\" : [ "; - convert << "{ \"where\" : "; + convert << "{ \"updates\" : [ { "; + if (modifier) + { + convert << "\"modifiers\" : [ \"" << modifier->toJSON() << "\" ], "; + } + convert << "\"where\" : "; convert << where.toJSON(); convert << ", \"values\" : "; convert << values.toJSON(); @@ -899,11 +924,12 @@ int StorageClient::updateTable(const string& schema, const string& tableName, co * @param tableName The name of the table into which data will be added * @param json The values to insert into the table * @param where The conditions to match the updated rows + * @param modifier Optional update modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& tableName, const JSONProperties& values, const Where& where) +int StorageClient::updateTable(const string& tableName, const JSONProperties& values, const Where& where, const UpdateModifier *modifier) { - return updateTable(DEFAULT_SCHEMA, tableName, values, where); + return updateTable(DEFAULT_SCHEMA, tableName, values, where, modifier); } /** @@ -915,13 +941,17 @@ int StorageClient::updateTable(const string& tableName, const JSONProperties& va * @param where The conditions to match the updated rows * @return int The number of rows updated */ -int StorageClient::updateTable(const string& schema, const string& tableName, const JSONProperties& values, const Where& where) +int StorageClient::updateTable(const string& schema, const string& tableName, const JSONProperties& values, const Where& where, const UpdateModifier *modifier) { try { ostringstream convert; - convert << "{ \"updates\" : [ "; - convert << "{ \"where\" : "; + convert << "{ \"updates\" : [ {"; + if (modifier) + { + convert << "\"modifiers\" : [ \"" << modifier->toJSON() << "\" ]"; + } + convert << "\"where\" : "; convert << where.toJSON(); convert << ", "; convert << values.toJSON(); @@ -969,11 +999,12 @@ int StorageClient::updateTable(const string& schema, const string& tableName, co * @param values The values to insert into the table * @param jsonProp The JSON Properties to update * @param where The conditions to match the updated rows + * @param modifier Optional update modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& tableName, const InsertValues& values, const JSONProperties& jsonProp, const Where& where) +int StorageClient::updateTable(const string& tableName, const InsertValues& values, const JSONProperties& jsonProp, const Where& where, const UpdateModifier *modifier) { - return updateTable(DEFAULT_SCHEMA, tableName, values, jsonProp, where); + return updateTable(DEFAULT_SCHEMA, tableName, values, jsonProp, where, modifier); } /** @@ -984,15 +1015,20 @@ int StorageClient::updateTable(const string& tableName, const InsertValues& valu * @param values The values to insert into the table * @param jsonProp The JSON Properties to update * @param where The conditions to match the updated rows + * @param modifier Optional update modifier * @return int The number of rows updated */ -int StorageClient::updateTable(const string& schema, const string& tableName, const InsertValues& values, const JSONProperties& jsonProp, const Where& where) +int StorageClient::updateTable(const string& schema, const string& tableName, const InsertValues& values, const JSONProperties& jsonProp, const Where& where, const UpdateModifier *modifier) { try { ostringstream convert; - convert << "{ \"updates\" : [ "; - convert << "{ \"where\" : "; + convert << "{ \"updates\" : [ {"; + if (modifier) + { + convert << "\"modifiers\" : [ \"" << modifier->toJSON() << "\", "; + } + convert << "\"where\" : "; convert << where.toJSON(); convert << ", \"values\" : "; convert << values.toJSON(); diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index c2831b235c..baa6656e93 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -975,6 +975,7 @@ SQLBuffer sql; int row = 0; ostringstream convert; + bool allowZero = false; std::size_t arr = payload.find("updates"); bool changeReqd = (arr == std::string::npos || arr > 8); @@ -1286,6 +1287,21 @@ SQLBuffer sql; return false; } } + if (iter->HasMember("modifier") && (*iter)["modifier"].IsArray()) + { + const Value& modifier = (*iter)["modifier"]; + for (Value::ConstValueIterator modifiers = modifier.Begin(); modifiers != modifier.End(); ++modifiers) + { + if (modifiers->IsString()) + { + string mod = modifiers->GetString(); + if (mod.compare("allowzero") == 0) + { + allowZero = true; + } + } + } + } sql.append(';'); } } @@ -1296,7 +1312,8 @@ SQLBuffer sql; delete[] query; if (PQresultStatus(res) == PGRES_COMMAND_OK) { - if (atoi(PQcmdTuples(res)) == 0) + int rowsUpdated = atoi(PQcmdTuples(res)); + if (rowsUpdated == 0 && allowZero == false) { raiseError("update", "No rows where updated"); return -1; diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index 33f2283a97..21f9b61de5 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -1384,6 +1384,7 @@ int Connection::update(const string& schema, const string& table, const string& Document document; SQLBuffer sql; vector asset_codes; +bool allowZero = false; int row = 0; ostringstream convert; @@ -1681,6 +1682,21 @@ vector asset_codes; col++; } } + if (iter->HasMember("modifier") && (*iter)["modifier"].IsArray()) + { + const Value& modifier = (*iter)["modifier"]; + for (Value::ConstValueIterator modifiers = modifier.Begin(); modifiers != modifier.End(); ++modifiers) + { + if (modifiers->IsString()) + { + string mod = modifiers->GetString(); + if (mod.compare("allowzero") == 0) + { + allowZero = true; + } + } + } + } if (col == 0) { raiseError("update", @@ -1757,7 +1773,7 @@ vector asset_codes; int return_value=0; - if (update == 0) + if (update == 0 && allowZero == false) { char buf[100]; snprintf(buf, sizeof(buf), diff --git a/C/plugins/storage/sqlitelb/common/connection.cpp b/C/plugins/storage/sqlitelb/common/connection.cpp index 4f7874e530..b5848f16cc 100644 --- a/C/plugins/storage/sqlitelb/common/connection.cpp +++ b/C/plugins/storage/sqlitelb/common/connection.cpp @@ -1309,6 +1309,7 @@ int Connection::update(const string& schema, // Default template parameter uses UTF8 and MemoryPoolAllocator. Document document; SQLBuffer sql; +bool allowZero = false; int row = 0; ostringstream convert; @@ -1606,6 +1607,21 @@ SQLBuffer sql; col++; } } + if (iter->HasMember("modifier") && (*iter)["modifier"].IsArray()) + { + const Value& modifier = (*iter)["modifier"]; + for (Value::ConstValueIterator modifiers = modifier.Begin(); modifiers != modifier.End(); ++modifiers) + { + if (modifiers->IsString()) + { + string mod = modifiers->GetString(); + if (mod.compare("allowzero") == 0) + { + allowZero = true; + } + } + } + } if (col == 0) { raiseError("update", @@ -1682,7 +1698,7 @@ SQLBuffer sql; int return_value=0; - if (update == 0) + if (update == 0 && allowZero == false) { char buf[100]; snprintf(buf, sizeof(buf), diff --git a/python/fledge/common/storage_client/payload_builder.py b/python/fledge/common/storage_client/payload_builder.py index 0aa74cae69..6fb1f025df 100644 --- a/python/fledge/common/storage_client/payload_builder.py +++ b/python/fledge/common/storage_client/payload_builder.py @@ -337,6 +337,17 @@ def DISTINCT(cls, cols): cls.query_payload["return"] = cols return cls + @classmethod + def MODIFIER(cls, arg): + if arg is None: + return cls + if not isinstance(arg, list): + return cls + if len(arg) == 0: + return cls + cls.query_payload["modifier"] = arg + return cls + @classmethod def UPDATE_TABLE(cls, tbl_name): return cls.FROM(tbl_name) diff --git a/python/fledge/services/core/user_model.py b/python/fledge/services/core/user_model.py index 4f13750377..289526766c 100644 --- a/python/fledge/services/core/user_model.py +++ b/python/fledge/services/core/user_model.py @@ -259,7 +259,9 @@ async def get(cls, uid=None, username=None): async def refresh_token_expiry(cls, token): storage_client = connect.get_storage_async() exp = datetime.now() + timedelta(seconds=JWT_EXP_DELTA_SECONDS) - payload = PayloadBuilder().SET(token_expiration=str(exp)).WHERE(['token', '=', token]).payload() + """ MODIFIER with allowzero is passed in payload so that storage returns rows_affected 0 in any case """ + payload = PayloadBuilder().SET(token_expiration=str(exp)).WHERE(['token', '=', token] + ).MODIFIER(["allowzero"]).payload() await storage_client.update_tbl("user_logins", payload) @classmethod diff --git a/tests/unit/python/fledge/common/storage_client/data/payload_modifier_set_where.json b/tests/unit/python/fledge/common/storage_client/data/payload_modifier_set_where.json new file mode 100644 index 0000000000..b2cc594ce7 --- /dev/null +++ b/tests/unit/python/fledge/common/storage_client/data/payload_modifier_set_where.json @@ -0,0 +1,11 @@ +{ + "values": { + "value": "token_expiration" + }, + "where": { + "column": "token", + "condition": "=", + "value": "TOKEN" + }, + "modifier": ["allowzero"] +} \ No newline at end of file diff --git a/tests/unit/python/fledge/common/storage_client/test_payload_builder.py b/tests/unit/python/fledge/common/storage_client/test_payload_builder.py index b1c01498ce..49a831f682 100644 --- a/tests/unit/python/fledge/common/storage_client/test_payload_builder.py +++ b/tests/unit/python/fledge/common/storage_client/test_payload_builder.py @@ -516,6 +516,14 @@ def test_update_set_where_payload(self, input_set, input_where, input_table, exp res = PayloadBuilder().SET(value=input_set).WHERE(input_where).UPDATE_TABLE(input_table).payload() assert expected == json.loads(res) + @pytest.mark.parametrize("input_set, input_where, input_modifier, expected", [ + ("token_expiration", ["token", "=", "TOKEN"], ["allowzero"], + _payload("data/payload_modifier_set_where.json")), + ]) + def test_modifier_with_set_where_payload(self, input_set, input_where, input_modifier, expected): + res = PayloadBuilder().SET(value=input_set).WHERE(input_where).MODIFIER(input_modifier).payload() + assert expected == json.loads(res) + @pytest.allure.feature("unit") @pytest.allure.story("payload_builder") From 276d480ac4c39abacf008d8b2062c2f23f4208a0 Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 23 Dec 2022 06:06:45 +0000 Subject: [PATCH 031/499] FOGL-7221: Updated CMakeLists.txt file to generate correct link.txt file Signed-off-by: nandan --- tests/unit/C/plugins/storage/common/CMakeLists.txt | 4 ++++ tests/unit/C/plugins/storage/postgres/CMakeLists.txt | 3 +++ tests/unit/C/plugins/storage/sqlite/CMakeLists.txt | 3 +++ tests/unit/C/plugins/storage/sqlitelb/CMakeLists.txt | 3 +++ tests/unit/C/plugins/storage/sqlitememory/CMakeLists.txt | 3 +++ tests/unit/C/services/storage/postgres/CMakeLists.txt | 3 +++ .../C/services/storage/postgres/plugins/common/CMakeLists.txt | 3 +++ 7 files changed, 22 insertions(+) diff --git a/tests/unit/C/plugins/storage/common/CMakeLists.txt b/tests/unit/C/plugins/storage/common/CMakeLists.txt index 1a1e62d70c..e752201cb6 100644 --- a/tests/unit/C/plugins/storage/common/CMakeLists.txt +++ b/tests/unit/C/plugins/storage/common/CMakeLists.txt @@ -23,6 +23,10 @@ set(common_sources "../../../../../../C/common/string_utils.cpp") # Link runTests with what we want to test and the GTest and pthread library add_executable(RunTests ${test_sources} ${common_sources} tests.cpp) + +#setting BOOST_COMPONENTS to use pthread library only +set(BOOST_COMPONENTS thread) +find_package(Boost 1.53.0 COMPONENTS ${BOOST_COMPONENTS} REQUIRED) target_link_libraries(RunTests ${GTEST_LIBRARIES} pthread ${COMMONLIB}) setup_target_for_coverage_gcovr_html( diff --git a/tests/unit/C/plugins/storage/postgres/CMakeLists.txt b/tests/unit/C/plugins/storage/postgres/CMakeLists.txt index 3bcde16789..fdb34f1896 100644 --- a/tests/unit/C/plugins/storage/postgres/CMakeLists.txt +++ b/tests/unit/C/plugins/storage/postgres/CMakeLists.txt @@ -86,6 +86,9 @@ target_link_libraries(${PROJECT_NAME} ${STORAGE_COMMON_LIB}) target_link_libraries(${PROJECT_NAME} ${PG_LIB}) target_link_libraries(${PROJECT_NAME} ${LIBCURL_LIB}) +#setting BOOST_COMPONENTS to use pthread library only +set(BOOST_COMPONENTS thread) +find_package(Boost 1.53.0 COMPONENTS ${BOOST_COMPONENTS} REQUIRED) target_link_libraries(${PROJECT_NAME} ${GTEST_LIBRARIES} pthread) # Add Python 3.x library if(${CMAKE_VERSION} VERSION_LESS "3.12.0") diff --git a/tests/unit/C/plugins/storage/sqlite/CMakeLists.txt b/tests/unit/C/plugins/storage/sqlite/CMakeLists.txt index b793408c84..2197d96779 100644 --- a/tests/unit/C/plugins/storage/sqlite/CMakeLists.txt +++ b/tests/unit/C/plugins/storage/sqlite/CMakeLists.txt @@ -78,6 +78,9 @@ target_link_libraries(${PROJECT_NAME} ${PLUGIN_SQLITE}) target_link_libraries(${PROJECT_NAME} ${STORAGE_COMMON_LIB}) target_link_libraries(${PROJECT_NAME} ${LIBCURL_LIB}) +#setting BOOST_COMPONENTS to use pthread library only +set(BOOST_COMPONENTS thread) +find_package(Boost 1.53.0 COMPONENTS ${BOOST_COMPONENTS} REQUIRED) target_link_libraries(${PROJECT_NAME} ${GTEST_LIBRARIES} pthread) # Add Python 3.x library if(${CMAKE_VERSION} VERSION_LESS "3.12.0") diff --git a/tests/unit/C/plugins/storage/sqlitelb/CMakeLists.txt b/tests/unit/C/plugins/storage/sqlitelb/CMakeLists.txt index 1b6eb2fb2c..1039fffc78 100644 --- a/tests/unit/C/plugins/storage/sqlitelb/CMakeLists.txt +++ b/tests/unit/C/plugins/storage/sqlitelb/CMakeLists.txt @@ -83,6 +83,9 @@ target_link_libraries(${PROJECT_NAME} ${PLUGIN_SQLITELB}) target_link_libraries(${PROJECT_NAME} ${STORAGE_COMMON_LIB}) target_link_libraries(${PROJECT_NAME} ${LIBCURL_LIB}) +#setting BOOST_COMPONENTS to use pthread library only +set(BOOST_COMPONENTS thread) +find_package(Boost 1.53.0 COMPONENTS ${BOOST_COMPONENTS} REQUIRED) target_link_libraries(${PROJECT_NAME} ${GTEST_LIBRARIES} pthread) # Add Python 3.x library if(${CMAKE_VERSION} VERSION_LESS "3.12.0") diff --git a/tests/unit/C/plugins/storage/sqlitememory/CMakeLists.txt b/tests/unit/C/plugins/storage/sqlitememory/CMakeLists.txt index b0e85de31e..5739768c83 100644 --- a/tests/unit/C/plugins/storage/sqlitememory/CMakeLists.txt +++ b/tests/unit/C/plugins/storage/sqlitememory/CMakeLists.txt @@ -84,6 +84,9 @@ target_link_libraries(${PROJECT_NAME} ${PLUGIN_SQLITEMEMORY}) target_link_libraries(${PROJECT_NAME} ${STORAGE_COMMON_LIB}) target_link_libraries(${PROJECT_NAME} ${LIBCURL_LIB}) +#setting BOOST_COMPONENTS to use pthread library only +set(BOOST_COMPONENTS thread) +find_package(Boost 1.53.0 COMPONENTS ${BOOST_COMPONENTS} REQUIRED) target_link_libraries(${PROJECT_NAME} ${GTEST_LIBRARIES} pthread) # Add Python 3.x library diff --git a/tests/unit/C/services/storage/postgres/CMakeLists.txt b/tests/unit/C/services/storage/postgres/CMakeLists.txt index c94b65091a..2a218ee9cb 100644 --- a/tests/unit/C/services/storage/postgres/CMakeLists.txt +++ b/tests/unit/C/services/storage/postgres/CMakeLists.txt @@ -26,6 +26,9 @@ file(GLOB utils_sources "../../../../../../C/common/json_utils.cpp") # Link runTests with what we want to test and the GTest and pthread library add_executable(RunTests ${test_sources} ${logger_sources} ${config_sources} ${utils_sources} tests.cpp) +#setting BOOST_COMPONENTS to use pthread library only +set(BOOST_COMPONENTS thread) +find_package(Boost 1.53.0 COMPONENTS ${BOOST_COMPONENTS} REQUIRED) target_link_libraries(RunTests ${GTEST_LIBRARIES} pthread) setup_target_for_coverage_gcovr_html( diff --git a/tests/unit/C/services/storage/postgres/plugins/common/CMakeLists.txt b/tests/unit/C/services/storage/postgres/plugins/common/CMakeLists.txt index 299ea45828..cd2447a805 100644 --- a/tests/unit/C/services/storage/postgres/plugins/common/CMakeLists.txt +++ b/tests/unit/C/services/storage/postgres/plugins/common/CMakeLists.txt @@ -23,6 +23,9 @@ set(common_sources "../../../../../../../../C/common/string_utils.cpp") # Link runTests with what we want to test and the GTest and pthread library add_executable(RunTests ${test_sources} ${common_sources} tests.cpp) +#setting BOOST_COMPONENTS to use pthread library only +set(BOOST_COMPONENTS thread) +find_package(Boost 1.53.0 COMPONENTS ${BOOST_COMPONENTS} REQUIRED) target_link_libraries(RunTests ${GTEST_LIBRARIES} pthread) setup_target_for_coverage_gcovr_html( From f8e562374b86d2534b9e444a87429b57bca9883b Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 23 Dec 2022 09:24:38 +0000 Subject: [PATCH 032/499] FOGL-7265 Add include of typeinfo (#912) Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch Signed-off-by: nandan --- C/plugins/north/OMF/linkdata.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index 0fbb6bc439..f3f8d0d9ec 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -19,6 +19,7 @@ #include #include +#include #include From e104aea1ad07c5813c364310cfbb140548f36b93 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 23 Dec 2022 15:44:52 +0530 Subject: [PATCH 033/499] wait time increased in authentication based system tests when wait for fledge restart Signed-off-by: ashish-jabble Signed-off-by: nandan --- tests/system/python/api/test_authentication.py | 2 +- .../python/api/test_endpoints_with_different_user_types.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/system/python/api/test_authentication.py b/tests/system/python/api/test_authentication.py index cc2331362f..3eedb9d5bd 100644 --- a/tests/system/python/api/test_authentication.py +++ b/tests/system/python/api/test_authentication.py @@ -46,7 +46,7 @@ def change_to_auth_mandatory(reset_and_start_fledge, fledge_url, wait_time): class TestAuthenticationAPI: def test_login_username_regular_user(self, change_to_auth_mandatory, fledge_url, wait_time): - time.sleep(wait_time * 2) + time.sleep(wait_time * 3) conn = http.client.HTTPConnection(fledge_url) conn.request("POST", "/fledge/login", json.dumps({"username": "user", "password": "fledge"})) r = conn.getresponse() diff --git a/tests/system/python/api/test_endpoints_with_different_user_types.py b/tests/system/python/api/test_endpoints_with_different_user_types.py index b04d6b008e..084c6fe79d 100644 --- a/tests/system/python/api/test_endpoints_with_different_user_types.py +++ b/tests/system/python/api/test_endpoints_with_different_user_types.py @@ -45,7 +45,7 @@ def change_to_auth_mandatory(fledge_url, wait_time): def test_setup(reset_and_start_fledge, change_to_auth_mandatory, fledge_url, wait_time): - time.sleep(wait_time * 2) + time.sleep(wait_time * 3) conn = http.client.HTTPConnection(fledge_url) # Admin login conn.request("POST", "/fledge/login", json.dumps({"username": "admin", "password": "fledge"})) From 21139e03a5b1792b60ae4b3f2971d7a5be26ff65 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 23 Dec 2022 13:17:54 +0000 Subject: [PATCH 034/499] FOGL-7288 Populate version in all cases to get correct OMF version set (#915) Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch Co-authored-by: pronoob Signed-off-by: nandan --- C/plugins/north/OMF/plugin.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 634e204f11..69df45f355 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -878,12 +878,25 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, return 0; } - // Until we know better assume OMF 1.2 + // Above call does not always populate version + if (version.empty()) + { + PIWebAPIGetVersion(connInfo, version, false); + } + + Logger::getLogger()->info("Version is '%s'", version.c_str()); + + // Until we know better assume OMF 1.2 as this is the base base point + // to give us the flexible type support we need connInfo->omfversion = "1.2"; if (version.find("2019") != std::string::npos) { connInfo->omfversion = "1.0"; } + else if (version.find("2020") != std::string::npos) + { + connInfo->omfversion = "1.1"; + } else if (version.find("2021") != std::string::npos) { connInfo->omfversion = "1.2"; @@ -1505,7 +1518,6 @@ int PIWebAPIGetVersion(CONNECTOR_INFO* connInfo, std::string &version, bool logM _PIWebAPI->setAuthBasicCredentials(connInfo->PIWebAPICredentials); int httpCode = _PIWebAPI->GetVersion(connInfo->hostAndPort, version, logMessage); - delete _PIWebAPI; return httpCode; From 8e3d1fc145df950fa73c7314cfdf6f442d992d37 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 23 Dec 2022 13:38:26 +0000 Subject: [PATCH 035/499] FOGL-7278 Use old OMF types for EDS (#914) * Test EDS Signed-off-by: Mark Riddoch * FOGL-7278 Fore EDS to use OMF Version 1.0 until we fidn soem way to determien the OMF version iused in EDS Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch Signed-off-by: nandan --- C/plugins/north/OMF/include/omf.h | 5 +++++ C/plugins/north/OMF/omf.cpp | 6 +++++- C/plugins/north/OMF/plugin.cpp | 7 +++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index 537d8465f6..835c0a1a0c 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -19,6 +19,11 @@ #define OMF_HINT "OMFHint" +// The following will force the OMF version for EDs endpoints +// Remove or comment out the line below to prevent the forcing +// of the version +#define EDS_OMF_VERSION "1.0" + #define TYPE_ID_DEFAULT 1 #define FAKE_ASSET_KEY "_default_start_id_" #define OMF_TYPE_STRING "string" diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index f24c4251c7..83a17f2ada 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -1203,7 +1203,11 @@ uint32_t OMF::sendToServer(const vector& readings, } string outData; - if (legacyType || m_OMFDataTypes->find(keyComplete) != m_OMFDataTypes->end()) + // Use old style complex types if the user has forced it via configuration, + // we are running against an EDS endpoint or we have types defined for this + // asset already + if (legacyType || m_PIServerEndpoint == ENDPOINT_EDS || + m_OMFDataTypes->find(keyComplete) != m_OMFDataTypes->end()) { // Legacy type support if (! usingTagHint) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 69df45f355..30c791754f 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -877,6 +877,7 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, Logger::getLogger()->fatal("OMF Endpoint is not available"); return 0; } +Logger::getLogger()->fatal("FIXME: version is %s", version.c_str()); // Above call does not always populate version if (version.empty()) @@ -973,6 +974,12 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, connInfo->omf->setPIServerEndpoint(connInfo->PIServerEndpoint); connInfo->omf->setDefaultAFLocation(connInfo->DefaultAFLocation); connInfo->omf->setAFMap(connInfo->AFMap); +#ifdef EDS_OMF_VERSION + if (connInfo->PIServerEndpoint == ENDPOINT_EDS) + { + connInfo->omfversion = EDS_OMF_VERSION; + } +#endif connInfo->omf->setOMFVersion(connInfo->omfversion); // Generates the prefix to have unique asset_id across different levels of hierarchies From ad2d05d41b9cf8b7a7c60a3d29127bba4aa61bf1 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 23 Dec 2022 14:44:03 +0000 Subject: [PATCH 036/499] Add missing packages table to init.sql (#917) Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch Signed-off-by: nandan --- scripts/plugins/storage/sqlitelb/init.sql | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 15ab6e671f..8da2ee2443 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -1,5 +1,5 @@ ---------------------------------------------------------------------- --- Copyright (c) 2021 OSIsoft, LLC +-- Copyright (c) 2022 OSIsoft, LLC -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -519,7 +519,7 @@ CREATE INDEX tasks_ix1 CREATE TABLE fledge.omf_created_objects ( configuration_key character varying(255) NOT NULL, -- FK to fledge.configuration type_id integer NOT NULL, -- Identifies the specific PI Server type - asset_code character varying(50) NOT NULL, + asset_code character varying(255) NOT NULL, CONSTRAINT omf_created_objects_pkey PRIMARY KEY (configuration_key,type_id, asset_code), CONSTRAINT omf_created_objects_fk1 FOREIGN KEY (configuration_key) REFERENCES configuration (key) MATCH SIMPLE @@ -580,6 +580,22 @@ CREATE TABLE fledge.plugin_data ( data JSON NOT NULL DEFAULT '{}', CONSTRAINT plugin_data_pkey PRIMARY KEY (key) ); +-- Create packages table +CREATE TABLE fledge.packages ( + id uuid NOT NULL, -- PK + name character varying(255) NOT NULL, -- Package name + action character varying(10) NOT NULL, -- APT actions: + -- list + -- install + -- purge + -- update + status INTEGER NOT NULL, -- exit code + -- -1 - in-progress + -- 0 - success + -- Non-Zero - failed + log_file_uri character varying(255) NOT NULL, -- Package Log file relative path + CONSTRAINT packages_pkey PRIMARY KEY ( id ) ); + -- Create filters table CREATE TABLE fledge.filters ( name character varying(255) NOT NULL, From 4e17e0d3c10e89a43b1bb6d513895bd64f6a67b8 Mon Sep 17 00:00:00 2001 From: pronoob Date: Mon, 26 Dec 2022 19:01:10 +0530 Subject: [PATCH 037/499] Removed some fatal statement and improved logs (#920) Removed some fatal statement and improved logs (#920) Signed-off-by: nandan --- C/plugins/north/OMF/plugin.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 30c791754f..c0c3737af5 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -877,7 +877,7 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, Logger::getLogger()->fatal("OMF Endpoint is not available"); return 0; } -Logger::getLogger()->fatal("FIXME: version is %s", version.c_str()); + // FIXME - The above call is not working. Investigate why? FOGL-7293 // Above call does not always populate version if (version.empty()) @@ -902,7 +902,7 @@ Logger::getLogger()->fatal("FIXME: version is %s", version.c_str()); { connInfo->omfversion = "1.2"; } - + Logger::getLogger()->info("Using OMF Version '%s'", connInfo->omfversion.c_str()); /** * Select the transport library based on the authentication method and transport encryption * requirements. From 4465db514f16c9cac560c2703a3aa17d0999ca15 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Mon, 26 Dec 2022 20:24:06 +0530 Subject: [PATCH 038/499] FOGL-7285 System tests for PIWebAPI are failing with PIWebAPI 2021 sp2. (#919) FOGL-7285 .Resolved System tests for new changes done for linked data type support. Signed-off-by: nandan --- tests/system/lab/test | 6 + tests/system/python/conftest.py | 154 +++++++----------- .../python/packages/test_multiple_assets.py | 103 +----------- .../python/packages/test_omf_naming_scheme.py | 12 +- .../python/packages/test_omf_north_service.py | 6 +- .../system/python/packages/test_pi_webapi.py | 8 +- .../python/pair/test_c_north_service_pair.py | 6 +- .../pair/test_pyton_north_service_pair.py | 6 +- 8 files changed, 91 insertions(+), 210 deletions(-) diff --git a/tests/system/lab/test b/tests/system/lab/test index 39e53af2d9..137630d483 100755 --- a/tests/system/lab/test +++ b/tests/system/lab/test @@ -134,6 +134,9 @@ setup_north_pi_egress () { }, "DefaultAFLocation": { "value": "/PIlabSinelvl1/PIlabSinelvl2/PIlabSinelvl3" + }, + "Legacy": { + "value": "false" } } }' @@ -174,6 +177,9 @@ setup_north_pi_egress () { }, "DefaultAFLocation": { "value": "/PIlabSinelvl1/PIlabSinelvl2/PIlabSinelvl3" + }, + "Legacy": { + "value": "false" } } }' diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index 19a0d4f94c..eca0c587ef 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -222,7 +222,8 @@ def clone_make_install(): @pytest.fixture def start_north_pi_v2(): def _start_north_pi_server_c(fledge_url, pi_host, pi_port, pi_token, north_plugin="OMF", - taskname="NorthReadingsToPI", start_task=True, naming_scheme="Backward compatibility"): + taskname="NorthReadingsToPI", start_task=True, naming_scheme="Backward compatibility", + pi_use_legacy="true"): """Start north task""" _enabled = "true" if start_task else "false" @@ -239,7 +240,8 @@ def _start_north_pi_server_c(fledge_url, pi_host, pi_port, pi_token, north_plugi "producerToken": {"value": pi_token}, "ServerHostname": {"value": pi_host}, "ServerPort": {"value": str(pi_port)}, - "NamingScheme": {"value": naming_scheme} + "NamingScheme": {"value": naming_scheme}, + "Legacy": {"value": pi_use_legacy} } } conn.request("POST", '/fledge/scheduled/task', json.dumps(data)) @@ -257,7 +259,8 @@ def _start_north_task_omf_web_api(fledge_url, pi_host, pi_port, pi_db="Dianomic" pi_user=None, pi_pwd=None, north_plugin="OMF", taskname="NorthReadingsToPI_WebAPI", start_task=True, naming_scheme="Backward compatibility", - default_af_location="fledge/room1/machine1"): + default_af_location="fledge/room1/machine1", + pi_use_legacy="true"): """Start north task""" _enabled = True if start_task else False @@ -278,7 +281,8 @@ def _start_north_task_omf_web_api(fledge_url, pi_host, pi_port, pi_db="Dianomic" "ServerPort": {"value": str(pi_port)}, "compression": {"value": "true"}, "DefaultAFLocation": {"value": default_af_location}, - "NamingScheme": {"value": naming_scheme} + "NamingScheme": {"value": naming_scheme}, + "Legacy": {"value": pi_use_legacy} } } @@ -297,7 +301,8 @@ def _start_north_omf_as_a_service(fledge_url, pi_host, pi_port, pi_db="Dianomic" pi_user=None, pi_pwd=None, north_plugin="OMF", service_name="NorthReadingsToPI_WebAPI", start=True, naming_scheme="Backward compatibility", - default_af_location="fledge/room1/machine1"): + default_af_location="fledge/room1/machine1", + pi_use_legacy="true"): """Start north service""" _enabled = True if start else False @@ -314,7 +319,8 @@ def _start_north_omf_as_a_service(fledge_url, pi_host, pi_port, pi_db="Dianomic" "ServerPort": {"value": str(pi_port)}, "compression": {"value": "true"}, "DefaultAFLocation": {"value": default_af_location}, - "NamingScheme": {"value": naming_scheme} + "NamingScheme": {"value": naming_scheme}, + "Legacy": {"value": pi_use_legacy} } } @@ -429,98 +435,57 @@ def read_data_from_pi_web_api(): def _read_data_from_pi_web_api(host, admin, password, pi_database, af_hierarchy_list, asset, sensor): """ This method reads data from pi web api """ - # List of pi databases - dbs = None - # PI logical grouping of attributes and child elements - elements = None - # List of elements - url_elements_list = None - # Element's recorded data url - url_recorded_data = None - # Resources in the PI Web API are addressed by WebID, parameter used for deletion of element - web_id = None - # List of elements - url_elements_data_list = None - username_password = "{}:{}".format(admin, password) username_password_b64 = base64.b64encode(username_password.encode('ascii')).decode("ascii") headers = {'Authorization': 'Basic %s' % username_password_b64} try: - conn = http.client.HTTPSConnection(host, context=ssl._create_unverified_context()) - conn.request("GET", '/piwebapi/assetservers', headers=headers) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + ctx.options |= ssl.PROTOCOL_TLSv1_1 + # With ssl.CERT_NONE as verify_mode, validation errors such as untrusted or expired cert + # are ignored and do not abort the TLS/SSL handshake. + ctx.verify_mode = ssl.CERT_NONE + conn = http.client.HTTPSConnection(host, context=ctx) + conn.request("GET", '/piwebapi/dataservers', headers=headers) res = conn.getresponse() r = json.loads(res.read().decode()) - dbs = r["Items"][0]["Links"]["Databases"] - - if dbs is not None: - conn.request("GET", dbs, headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - for el in r["Items"]: - if el["Name"] == pi_database: - url_elements_list = el["Links"]["Elements"] - - # This block is for iteration when we have multi-level hierarchy. - # For example, if we have DefaultAFLocation as "fledge/room1/machine1" then - # it will recursively find elements of "fledge" and then "room1". - # And next block is for finding element of "machine1". - - af_level_count = 0 - for level in af_hierarchy_list[:-1]: - if url_elements_list is not None: - conn.request("GET", url_elements_list, headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - for el in r["Items"]: - if el["Name"] == af_hierarchy_list[af_level_count]: - url_elements_list = el["Links"]["Elements"] - if af_level_count == 0: - web_id_root = el["WebId"] - af_level_count = af_level_count + 1 - - if url_elements_list is not None: - conn.request("GET", url_elements_list, headers=headers) + points= r["Items"][0]['Links']["Points"] + + if points is not None: + conn.request("GET", points, headers=headers) res = conn.getresponse() - r = json.loads(res.read().decode()) - items = r["Items"] - for el in items: - if el["Name"] == af_hierarchy_list[-1]: - url_elements_data_list = el["Links"]["Elements"] - - if url_elements_data_list is not None: - conn.request("GET", url_elements_data_list, headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - items = r["Items"] - for el2 in items: - if el2["Name"] == asset: - url_recorded_data = el2["Links"]["RecordedData"] - web_id = el2["WebId"] - - _data_pi = {} - if url_recorded_data is not None: - conn.request("GET", url_recorded_data, headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - _items = r["Items"] - for el in _items: - _recoded_value_list = [] - for _head in sensor: - # This checks if the recorded datapoint is present in the items that we retrieve from the PI server. - if _head in el["Name"]: - elx = el["Items"] - for _el in elx: - _recoded_value_list.append(_el["Value"]) - _data_pi[_head] = _recoded_value_list - - # Delete recorded elements - conn.request("DELETE", '/piwebapi/elements/{}'.format(web_id_root), headers=headers) - res = conn.getresponse() - res.read() - - return _data_pi - except (KeyError, IndexError, Exception): + r=json.loads(res.read().decode()) + data = r["Items"] + if data is not None: + value = None + if sensor == '': + search_string = asset + else: + search_string = "{}.{}".format(asset, sensor) + for el in data: + if search_string in el["Name"]: + value_url = el["Links"]["Value"] + if value_url is not None: + conn.request("GET", value_url, headers=headers) + res = conn.getresponse() + r = json.loads(res.read().decode()) + value = r["Value"] + if not value: + print("Could not find the latest reading of asset ->{}. sensor->{}".format(asset, + sensor)) + return value + else: + print("The latest value of asset->{}.sensor->{} is {}".format(asset, sensor, value)) + return(value) + else: + print("Data inside points not found.") + return None + else: + print("Could not find the points.") + return None + + except (KeyError, IndexError, Exception) as ex: + print("Failed to read data due to {}".format(ex)) return None return _read_data_from_pi_web_api @@ -672,7 +637,9 @@ def pytest_addoption(parser): help="PI Server user login password") parser.addoption("--pi-token", action="store", default="omf_north_0001", help="OMF Producer Token") - + parser.addoption("--pi-use-legacy", action="store", default="true", + help="Set false to override the default plugin behaviour i.e. for OMF version >=1.2.x to send linked data types.") + # OCS Config parser.addoption("--ocs-tenant", action="store", default="ocs_tenant_id", help="Tenant id of OCS") @@ -862,6 +829,11 @@ def pi_token(request): return request.config.getoption("--pi-token") +@pytest.fixture +def pi_use_legacy(request): + return request.config.getoption("--pi-use-legacy") + + @pytest.fixture def ocs_tenant(request): return request.config.getoption("--ocs-tenant") diff --git a/tests/system/python/packages/test_multiple_assets.py b/tests/system/python/packages/test_multiple_assets.py index f6a966baa9..70e6528422 100644 --- a/tests/system/python/packages/test_multiple_assets.py +++ b/tests/system/python/packages/test_multiple_assets.py @@ -94,103 +94,6 @@ def start_north(start_north_omf_as_a_service, fledge_url, yield start_north -@pytest.fixture -def read_data_from_pi_web_api(): - def _read_data_from_pi_web_api(host, admin, password, pi_database, af_hierarchy_list, asset, sensor): - """ This method reads data from pi web api """ - - # List of pi databases - dbs = None - # PI logical grouping of attributes and child elements - elements = None - # List of elements - url_elements_list = None - # Element's recorded data url - url_recorded_data = None - # Resources in the PI Web API are addressed by WebID, parameter used for deletion of element - web_id = None - # List of elements - url_elements_data_list = None - - username_password = "{}:{}".format(admin, password) - username_password_b64 = base64.b64encode(username_password.encode('ascii')).decode("ascii") - headers = {'Authorization': 'Basic %s' % username_password_b64} - - try: - conn = http.client.HTTPSConnection(host, context=ssl._create_unverified_context()) - conn.request("GET", '/piwebapi/assetservers', headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - dbs = r["Items"][0]["Links"]["Databases"] - - if dbs is not None: - conn.request("GET", dbs, headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - for el in r["Items"]: - if el["Name"] == pi_database: - url_elements_list = el["Links"]["Elements"] - - # This block is for iteration when we have multi-level hierarchy. - # For example, if we have DefaultAFLocation as "fledge/room1/machine1" then - # it will recursively find elements of "fledge" and then "room1". - # And next block is for finding element of "machine1". - - af_level_count = 0 - for level in af_hierarchy_list[:-1]: - if url_elements_list is not None: - conn.request("GET", url_elements_list, headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - for el in r["Items"]: - if el["Name"] == af_hierarchy_list[af_level_count]: - url_elements_list = el["Links"]["Elements"] - if af_level_count == 0: - web_id_root = el["WebId"] - af_level_count = af_level_count + 1 - - if url_elements_list is not None: - conn.request("GET", url_elements_list, headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - items = r["Items"] - for el in items: - if el["Name"] == af_hierarchy_list[-1]: - url_elements_data_list = el["Links"]["Elements"] - - if url_elements_data_list is not None: - conn.request("GET", url_elements_data_list, headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - items = r["Items"] - for el2 in items: - if el2["Name"] == asset: - url_recorded_data = el2["Links"]["RecordedData"] - web_id = el2["WebId"] - - _data_pi = {} - if url_recorded_data is not None: - conn.request("GET", url_recorded_data, headers=headers) - res = conn.getresponse() - r = json.loads(res.read().decode()) - _items = r["Items"] - for el in _items: - _recoded_value_list = [] - for _head in sensor: - # This checks if the recorded datapoint is present in the items that we retrieve from the PI server. - if _head in el["Name"]: - elx = el["Items"] - for _el in elx: - _recoded_value_list.append(_el["Value"]) - _data_pi[_head] = _recoded_value_list - - return _data_pi - except (KeyError, IndexError, Exception): - return None - - return _read_data_from_pi_web_api - - def add_benchmark(fledge_url, name, count): data = { "name": name, @@ -272,13 +175,13 @@ def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_d data_from_pi = None asset_name = "random-" + str(s) + str(a) print(asset_name) - recorded_datapoint = "{}measurement_{}".format(type_id, asset_name) + recorded_datapoint = "{}".format(asset_name) # Name of asset in the PI server - pi_asset_name = "{}-type{}".format(asset_name, type_id) + pi_asset_name = "{}".format(asset_name) while (data_from_pi is None or data_from_pi == []) and retry_count < retries: data_from_pi = read_data_from_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, af_hierarchy_level_list, - pi_asset_name, {recorded_datapoint}) + pi_asset_name, '') if data_from_pi is None: retry_count += 1 time.sleep(wait_time) diff --git a/tests/system/python/packages/test_omf_naming_scheme.py b/tests/system/python/packages/test_omf_naming_scheme.py index 517149d47c..2a46ff6a11 100644 --- a/tests/system/python/packages/test_omf_naming_scheme.py +++ b/tests/system/python/packages/test_omf_naming_scheme.py @@ -155,14 +155,14 @@ def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_d while (data_from_pi is None or data_from_pi == []) and retry_count < retries: data_from_pi = read_data_from_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, af_hierarchy_level_list, - pi_asset_name, {recorded_datapoint}) + pi_asset_name, '') retry_count += 1 time.sleep(wait_time * 2) if data_from_pi is None or retry_count == retries: assert False, "Failed to read data from PI" - assert data_from_pi[recorded_datapoint][-1] == DATAPOINT_VALUE + assert int(data_from_pi) == DATAPOINT_VALUE class TestOMFNamingScheme: @@ -246,7 +246,7 @@ def test_omf_with_type_suffix_naming(self, reset_fledge, start_south, start_nort type_id = 1 recorded_datapoint = "{}".format(south_asset_name) # Name of asset in the PI server - pi_asset_name = "{}-type{}".format(south_asset_name, type_id) + pi_asset_name = "{}".format(south_asset_name) _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries, recorded_datapoint, pi_asset_name) @@ -286,7 +286,7 @@ def test_omf_with_attribute_hash_naming(self, reset_fledge, start_south, start_n if not skip_verify_north_interface: type_id = 1 - recorded_datapoint = "{}measurement_{}".format(type_id, south_asset_name) + recorded_datapoint = "{}".format(south_asset_name) # Name of asset in the PI server pi_asset_name = "{}".format(south_asset_name) _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries, @@ -328,8 +328,8 @@ def test_omf_with_backward_compatibility_naming(self, reset_fledge, start_south, if not skip_verify_north_interface: type_id = 1 - recorded_datapoint = "{}measurement_{}".format(type_id, south_asset_name) + recorded_datapoint = "{}".format(south_asset_name) # Name of asset in the PI server - pi_asset_name = "{}-type{}".format(south_asset_name, type_id) + pi_asset_name = "{}".format(south_asset_name) _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries, recorded_datapoint, pi_asset_name) diff --git a/tests/system/python/packages/test_omf_north_service.py b/tests/system/python/packages/test_omf_north_service.py index 34445f3e82..77d4a24fe1 100644 --- a/tests/system/python/packages/test_omf_north_service.py +++ b/tests/system/python/packages/test_omf_north_service.py @@ -164,13 +164,13 @@ def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_d af_hierarchy_level_list = AF_HIERARCHY_LEVEL.split("/") type_id = 1 - recorded_datapoint = "{}measurement_{}".format(type_id, asset_name) + recorded_datapoint = asset_name # Name of asset in the PI server - pi_asset_name = "{}-type{}".format(asset_name, type_id) + pi_asset_name = asset_name while (data_from_pi is None or data_from_pi == []) and retry_count < retries: data_from_pi = read_data_from_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, af_hierarchy_level_list, - pi_asset_name, {recorded_datapoint}) + pi_asset_name, '') retry_count += 1 time.sleep(wait_time * 2) diff --git a/tests/system/python/packages/test_pi_webapi.py b/tests/system/python/packages/test_pi_webapi.py index 39e213138d..b2be96c9e1 100644 --- a/tests/system/python/packages/test_pi_webapi.py +++ b/tests/system/python/packages/test_pi_webapi.py @@ -106,20 +106,20 @@ def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_d af_hierarchy_level_list = AF_HIERARCHY_LEVEL.split("/") type_id = 1 - recorded_datapoint = "{}measurement_{}".format(type_id, asset_name) + recorded_datapoint = asset_name # Name of asset in the PI server - PI_ASSET_NAME = "{}-type{}".format(asset_name, type_id) + PI_ASSET_NAME = asset_name while (data_from_pi is None or data_from_pi == []) and retry_count < retries: data_from_pi = read_data_from_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, af_hierarchy_level_list, - PI_ASSET_NAME, {recorded_datapoint}) + ASSET, '') retry_count += 1 time.sleep(wait_time * 2) if data_from_pi is None or retry_count == retries: assert False, "Failed to read data from PI" - assert data_from_pi[recorded_datapoint][-1] == DATAPOINT_VALUE + assert int(data_from_pi) == DATAPOINT_VALUE @pytest.fixture diff --git a/tests/system/python/pair/test_c_north_service_pair.py b/tests/system/python/pair/test_c_north_service_pair.py index e596c568dc..8f086d4f8f 100644 --- a/tests/system/python/pair/test_c_north_service_pair.py +++ b/tests/system/python/pair/test_c_north_service_pair.py @@ -257,13 +257,13 @@ def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_d af_hierarchy_level_list = AF_HIERARCHY_LEVEL.split("/") type_id = 1 - recorded_datapoint = "{}measurement_{}".format(type_id, asset_name) + recorded_datapoint = "{}".format(asset_name) # Name of asset in the PI server - pi_asset_name = "{}-type{}".format(asset_name, type_id) + pi_asset_name = "{}".format(asset_name) while (data_from_pi is None or data_from_pi == []) and retry_count < retries: data_from_pi = read_data_from_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, af_hierarchy_level_list, - pi_asset_name, {recorded_datapoint}) + pi_asset_name, '') retry_count += 1 time.sleep(wait_time * 2) diff --git a/tests/system/python/pair/test_pyton_north_service_pair.py b/tests/system/python/pair/test_pyton_north_service_pair.py index fbd4ab3d90..d2a4884a4b 100644 --- a/tests/system/python/pair/test_pyton_north_service_pair.py +++ b/tests/system/python/pair/test_pyton_north_service_pair.py @@ -258,13 +258,13 @@ def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_d af_hierarchy_level_list = AF_HIERARCHY_LEVEL.split("/") type_id = 1 - recorded_datapoint = "{}measurement_{}".format(type_id, asset_name) + recorded_datapoint = "{}".format(asset_name) # Name of asset in the PI server - pi_asset_name = "{}-type{}".format(asset_name, type_id) + pi_asset_name = "{}".format(asset_name) while (data_from_pi is None or data_from_pi == []) and retry_count < retries: data_from_pi = read_data_from_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, af_hierarchy_level_list, - pi_asset_name, {recorded_datapoint}) + pi_asset_name, '') retry_count += 1 time.sleep(wait_time * 2) From ade2f8ce495a5b4ac8b4bad67c07e44975bec61a Mon Sep 17 00:00:00 2001 From: nandan Date: Tue, 27 Dec 2022 15:07:43 +0530 Subject: [PATCH 039/499] FOGL-7128: added code to refresh list of Empty tables in case of purging reading data Signed-off-by: nandan --- C/plugins/storage/sqlite/common/readings.cpp | 2 +- C/plugins/storage/sqlite/common/readings_catalogue.cpp | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index d729baca6e..0074ddbe79 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -2608,7 +2608,7 @@ sqlite3_stmt *stmt; sqlite3_free(zErrMsg); return 0; } - + readCat->loadEmptyAssetReadingCatalogue(); // Get numbwer of affected rows return (unsigned int)sqlite3_changes(dbHandle); } diff --git a/C/plugins/storage/sqlite/common/readings_catalogue.cpp b/C/plugins/storage/sqlite/common/readings_catalogue.cpp index 3e614fc655..1aa42e9f1d 100644 --- a/C/plugins/storage/sqlite/common/readings_catalogue.cpp +++ b/C/plugins/storage/sqlite/common/readings_catalogue.cpp @@ -1991,8 +1991,8 @@ bool ReadingsCatalogue::loadEmptyAssetReadingCatalogue() sql_cmd = "SELECT COUNT(*) FROM readings_" + to_string(dbId) + ".readings_" + to_string(dbId) + "_" + to_string(tableId) + " ;"; if (sqlite3_prepare_v2(dbHandle, sql_cmd.c_str(), -1, &stmt, NULL) != SQLITE_OK) { - raiseError("loadEmptyAssetReadingCatalogue", sqlite3_errmsg(dbHandle)); - return false; + sqlite3_finalize(stmt); + continue; } if (SQLStep(stmt) == SQLITE_ROW) @@ -2185,6 +2185,7 @@ int ReadingsCatalogue::purgeAllReadings(sqlite3 *dbHandle, const char *sqlCmdBa } } + loadEmptyAssetReadingCatalogue(); return(rc); } From 90f55a793432b77caae5bc9f66b71d9d9b811907 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 27 Dec 2022 16:51:07 +0530 Subject: [PATCH 040/499] -x509_strict check restricted when openssl version with 3.0 or so Signed-off-by: ashish-jabble --- python/fledge/common/utils.py | 14 ++++++++++++++ python/fledge/common/web/ssl_wrapper.py | 20 +++++++++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/python/fledge/common/utils.py b/python/fledge/common/utils.py index cd9babfa73..a953ea4eb8 100644 --- a/python/fledge/common/utils.py +++ b/python/fledge/common/utils.py @@ -124,3 +124,17 @@ def is_debian(): if id_like is not None and any(x in id_like.lower() for x in ['centos', 'rhel', 'redhat', 'fedora']): return False return True + + +def get_open_ssl_version(version_string=True): + """ Open SSL version info + + Args: + version_string + + Returns: + When version_string is True - The version string of the OpenSSL library loaded by the interpreter + When version_string is False - A tuple of five integers representing version information about the OpenSSL library + """ + import ssl + return ssl.OPENSSL_VERSION if version_string else ssl.OPENSSL_VERSION_INFO diff --git a/python/fledge/common/web/ssl_wrapper.py b/python/fledge/common/web/ssl_wrapper.py index 7dd13b5c3f..22480d58ae 100644 --- a/python/fledge/common/web/ssl_wrapper.py +++ b/python/fledge/common/web/ssl_wrapper.py @@ -9,7 +9,7 @@ import time import datetime import subprocess -from fledge.common import logger +from fledge.common import logger, utils __author__ = "Amarendra Kumar Sinha" __copyright__ = "Copyright (c) 2019 Dianomic Systems" @@ -80,21 +80,27 @@ def verify_against_revoked(cls): @classmethod def verify_against_ca(cls): echo_process = subprocess.Popen(['echo', cls.user_cert], stderr=subprocess.PIPE, stdout=subprocess.PIPE) - a = subprocess.Popen(["openssl", "verify", "-CAfile", cls.ca_cert, "-x509_strict"], stdin=echo_process.stdout, stderr=subprocess.PIPE, stdout=subprocess.PIPE) + args = ["openssl", "verify", "-CAfile", cls.ca_cert] + cmd = "openssl verify -CAfile {}".format(cls.ca_cert) + # TODO: Add JIRA for to handle x509 strict check when open ssl version is 3 or so + # This is completely a workaround as of now CentOS9 stream has openssl version with 3.0 + if utils.get_open_ssl_version(version_string=False)[0] < 3: + x509_strict = "-x509_strict" + args.append(x509_strict) + cmd += " {}".format(x509_strict) + a = subprocess.Popen(args, stdin=echo_process.stdout, stderr=subprocess.PIPE, stdout=subprocess.PIPE) outs, errs = a.communicate() if outs is None and errs is None: - raise OSError( - 'Verification error in executing command "{}"'.format("openssl verify -CAfile {} -x509_strict".format(cls.ca_cert))) + raise OSError('Verification error in executing command "{}"'.format(cmd)) if a.returncode != 0: - raise OSError( - 'Verification error in executing command "{}". Error: {}, returncode: {}'.format("openssl verify -CAfile {} -x509_strict".format(cls.ca_cert), errs.decode('utf-8').replace('\n', ''), a.returncode)) + raise OSError('Verification error in executing command "{}". Error: {}, returncode: {}'.format( + cmd, errs.decode('utf-8').replace('\n', ''), a.returncode)) d = [b for b in outs.decode('utf-8').split('\n') if b != ''] if "OK" not in d[0]: raise SSLVerifier.VerificationError( str(), 'failed verification', errs) return d - """ Common x509 options: -serial - print serial number value From 69cd6aa8c9f9b2ed097f2c39ae908eb8c5c09f10 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 27 Dec 2022 17:32:57 +0530 Subject: [PATCH 041/499] Notification API & E2E system tests against specific plugin branch Signed-off-by: ashish-jabble --- tests/system/python/api/test_notification.py | 3 ++- .../python/e2e/test_e2e_notification_service_with_plugins.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/system/python/api/test_notification.py b/tests/system/python/api/test_notification.py index 019fd6c581..1ea36cda29 100644 --- a/tests/system/python/api/test_notification.py +++ b/tests/system/python/api/test_notification.py @@ -106,8 +106,9 @@ def test_install_delivery_plugin(self, notify_branch, remove_directories): remove_directories(os.path.expandvars('$FLEDGE_ROOT/plugins/notificationDelivery')) remove_directories(os.path.expandvars('$FLEDGE_ROOT/plugins/notificationRule')) try: + # FIXME: notify_branch should be reverted on main FOGL-7260 branch subprocess.run(["$FLEDGE_ROOT/tests/system/python/scripts/install_c_plugin {} notify {}".format( - notify_branch, NOTIFY_PLUGIN)], shell=True, check=True) + "compilation-fixes-centos9-stream", NOTIFY_PLUGIN)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "{} installation failed".format(NOTIFY_PLUGIN) finally: diff --git a/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py b/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py index 5a76a3399c..605c3567b1 100644 --- a/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py +++ b/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py @@ -59,8 +59,9 @@ def _configure_and_start_service(service_branch, fledge_url, remove_directories) def _install_notify_plugin(notify_branch, plugin_name, remove_directories): try: + # FIXME: notify_branch should be reverted on main FOGL-7260 branch subprocess.run(["$FLEDGE_ROOT/tests/system/python/scripts/install_c_plugin {} notify {}".format( - notify_branch, plugin_name)], shell=True, check=True, stdout=subprocess.DEVNULL) + "compilation-fixes-centos9-stream", plugin_name)], shell=True, check=True, stdout=subprocess.DEVNULL) except subprocess.CalledProcessError: assert False, "{} installation failed".format(plugin_name) finally: From 1889a39630b8d8470d7f6346f4ecfdae1891ed05 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 28 Dec 2022 18:07:09 +0530 Subject: [PATCH 042/499] feedback fixes Signed-off-by: ashish-jabble --- python/fledge/common/utils.py | 28 ++++++++++--------------- python/fledge/common/web/ssl_wrapper.py | 17 +++++++-------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/python/fledge/common/utils.py b/python/fledge/common/utils.py index a953ea4eb8..53af53e5a6 100644 --- a/python/fledge/common/utils.py +++ b/python/fledge/common/utils.py @@ -92,23 +92,17 @@ def read_os_release(): import ast import re os_details = {} - try: - filename = '/etc/os-release' - f = open(filename) - except FileNotFoundError: - filename = '/usr/lib/os-release' - f = open(filename) - - for line_number, line in enumerate(f, start=1): - line = line.rstrip() - if not line or line.startswith('#'): - continue - m = re.match(r'([A-Z][A-Z_0-9]+)=(.*)', line) - if m: - name, val = m.groups() - if val and val[0] in '"\'': - val = ast.literal_eval(val) - os_details.update({name: val}) + with open('/etc/os-release', encoding="utf-8") as f: + for line_number, line in enumerate(f, start=1): + line = line.rstrip() + if not line or line.startswith('#'): + continue + m = re.match(r'([A-Z][A-Z_0-9]+)=(.*)', line) + if m: + name, val = m.groups() + if val and val[0] in '"\'': + val = ast.literal_eval(val) + os_details.update({name: val}) return os_details diff --git a/python/fledge/common/web/ssl_wrapper.py b/python/fledge/common/web/ssl_wrapper.py index 22480d58ae..b9179921a4 100644 --- a/python/fledge/common/web/ssl_wrapper.py +++ b/python/fledge/common/web/ssl_wrapper.py @@ -80,21 +80,18 @@ def verify_against_revoked(cls): @classmethod def verify_against_ca(cls): echo_process = subprocess.Popen(['echo', cls.user_cert], stderr=subprocess.PIPE, stdout=subprocess.PIPE) - args = ["openssl", "verify", "-CAfile", cls.ca_cert] - cmd = "openssl verify -CAfile {}".format(cls.ca_cert) - # TODO: Add JIRA for to handle x509 strict check when open ssl version is 3 or so - # This is completely a workaround as of now CentOS9 stream has openssl version with 3.0 + args = "openssl verify -CAfile {}".format(cls.ca_cert) + # TODO: FOGL-7302 to handle -x509_strict check when OpenSSL version >=3.x + # Removing the -x509_strict flag as an interim solution; as of now only CentOS Stream9 has OpenSSL version 3.0 if utils.get_open_ssl_version(version_string=False)[0] < 3: - x509_strict = "-x509_strict" - args.append(x509_strict) - cmd += " {}".format(x509_strict) - a = subprocess.Popen(args, stdin=echo_process.stdout, stderr=subprocess.PIPE, stdout=subprocess.PIPE) + args += " -x509_strict" + a = subprocess.Popen(args.split(), stdin=echo_process.stdout, stderr=subprocess.PIPE, stdout=subprocess.PIPE) outs, errs = a.communicate() if outs is None and errs is None: - raise OSError('Verification error in executing command "{}"'.format(cmd)) + raise OSError('Verification error in executing command "{}"'.format(args)) if a.returncode != 0: raise OSError('Verification error in executing command "{}". Error: {}, returncode: {}'.format( - cmd, errs.decode('utf-8').replace('\n', ''), a.returncode)) + args, errs.decode('utf-8').replace('\n', ''), a.returncode)) d = [b for b in outs.decode('utf-8').split('\n') if b != ''] if "OK" not in d[0]: raise SSLVerifier.VerificationError( From 9d91ded1e7dee3548dabb665511cd68188359be7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 28 Dec 2022 18:26:44 +0530 Subject: [PATCH 043/499] DEBUG tests file are removed Signed-off-by: ashish-jabble --- tests/system/python/api/test_notification.py | 3 +-- tests/system/python/api/test_plugin_discovery.py | 9 +++------ tests/system/python/api/test_service.py | 5 ++--- .../e2e/test_e2e_notification_service_with_plugins.py | 3 +-- tests/system/python/scripts/install_c_plugin | 3 +-- 5 files changed, 8 insertions(+), 15 deletions(-) diff --git a/tests/system/python/api/test_notification.py b/tests/system/python/api/test_notification.py index 1ea36cda29..019fd6c581 100644 --- a/tests/system/python/api/test_notification.py +++ b/tests/system/python/api/test_notification.py @@ -106,9 +106,8 @@ def test_install_delivery_plugin(self, notify_branch, remove_directories): remove_directories(os.path.expandvars('$FLEDGE_ROOT/plugins/notificationDelivery')) remove_directories(os.path.expandvars('$FLEDGE_ROOT/plugins/notificationRule')) try: - # FIXME: notify_branch should be reverted on main FOGL-7260 branch subprocess.run(["$FLEDGE_ROOT/tests/system/python/scripts/install_c_plugin {} notify {}".format( - "compilation-fixes-centos9-stream", NOTIFY_PLUGIN)], shell=True, check=True) + notify_branch, NOTIFY_PLUGIN)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "{} installation failed".format(NOTIFY_PLUGIN) finally: diff --git a/tests/system/python/api/test_plugin_discovery.py b/tests/system/python/api/test_plugin_discovery.py index 864a9630e3..b3e28b4c04 100644 --- a/tests/system/python/api/test_plugin_discovery.py +++ b/tests/system/python/api/test_plugin_discovery.py @@ -115,8 +115,7 @@ def test_south_plugins_installed(self, fledge_url, _type='south'): assert 'fledge-south-sinusoid' == plugins[0]['packageName'] # install one more south plugin (C version) - # FIXME: BRANCH when testing is done in main FOGL-7260 branch - install_plugin(_type, plugin='random', plugin_lang='C', branch='compilation-fixes-centos9-stream') + install_plugin(_type, plugin='random', plugin_lang='C') conn.request("GET", '/fledge/plugins/installed?type={}'.format(_type)) r = conn.getresponse() assert 200 == r.status @@ -180,8 +179,7 @@ def test_filter_plugins_installed(self, fledge_url, _type='filter'): def test_delivery_plugins_installed(self, fledge_url, _type='notify'): # install slack delivery plugin - # FIXME: BRANCH when testing is done in main FOGL-7260 branch - install_plugin(_type, plugin='slack', plugin_lang='C', branch='compilation-fixes-centos9-stream') + install_plugin(_type, plugin='slack', plugin_lang='C') conn = http.client.HTTPConnection(fledge_url) conn.request("GET", '/fledge/plugins/installed?type={}'.format(_type)) r = conn.getresponse() @@ -198,8 +196,7 @@ def test_delivery_plugins_installed(self, fledge_url, _type='notify'): def test_rule_plugins_installed(self, fledge_url, _type='rule'): # install OutOfBound rule plugin - # FIXME: BRANCH when testing is done in main FOGL-7260 branch - install_plugin(_type, plugin='outofbound', plugin_lang='C', branch='compilation-fixes-centos9-stream') + install_plugin(_type, plugin='outofbound', plugin_lang='C') conn = http.client.HTTPConnection(fledge_url) conn.request("GET", '/fledge/plugins/installed?type={}'.format(_type)) r = conn.getresponse() diff --git a/tests/system/python/api/test_service.py b/tests/system/python/api/test_service.py index c431988730..9eb38d1898 100644 --- a/tests/system/python/api/test_service.py +++ b/tests/system/python/api/test_service.py @@ -40,9 +40,8 @@ def install_plugins(): plugin_and_service.install('south', plugin='randomwalk') plugin_and_service.install('south', plugin='http') - # FIXME: BRANCH when testing is done in main FOGL-7260 branch - plugin_and_service.install('south', plugin='benchmark', plugin_lang='C', branch='compilation-fixes-centos9-stream') - plugin_and_service.install('south', plugin='random', plugin_lang='C', branch='compilation-fixes-centos9-stream') + plugin_and_service.install('south', plugin='benchmark', plugin_lang='C') + plugin_and_service.install('south', plugin='random', plugin_lang='C') plugin_and_service.install('south', plugin='csv-async', plugin_lang='C') diff --git a/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py b/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py index 605c3567b1..5a76a3399c 100644 --- a/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py +++ b/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py @@ -59,9 +59,8 @@ def _configure_and_start_service(service_branch, fledge_url, remove_directories) def _install_notify_plugin(notify_branch, plugin_name, remove_directories): try: - # FIXME: notify_branch should be reverted on main FOGL-7260 branch subprocess.run(["$FLEDGE_ROOT/tests/system/python/scripts/install_c_plugin {} notify {}".format( - "compilation-fixes-centos9-stream", plugin_name)], shell=True, check=True, stdout=subprocess.DEVNULL) + notify_branch, plugin_name)], shell=True, check=True, stdout=subprocess.DEVNULL) except subprocess.CalledProcessError: assert False, "{} installation failed".format(plugin_name) finally: diff --git a/tests/system/python/scripts/install_c_plugin b/tests/system/python/scripts/install_c_plugin index 7e94f62496..fdedbff529 100755 --- a/tests/system/python/scripts/install_c_plugin +++ b/tests/system/python/scripts/install_c_plugin @@ -43,8 +43,7 @@ install_binary_file () { then # fledge-service-notification repo is required to build notificationRule Plugins service_repo_name='fledge-service-notification' - # FIXME: BRANCH_NAME when testing is done in main FOGL-7260 branch - git clone -b develop --single-branch https://github.com/fledge-iot/${service_repo_name}.git /tmp/${service_repo_name} + git clone -b ${BRANCH_NAME} --single-branch https://github.com/fledge-iot/${service_repo_name}.git /tmp/${service_repo_name} export NOTIFICATION_SERVICE_INCLUDE_DIRS=/tmp/${service_repo_name}/C/services/common/include fi From 07408c056a80a11eacb4907c012d1b1fe9c5a3d9 Mon Sep 17 00:00:00 2001 From: pronoob Date: Fri, 30 Dec 2022 10:42:50 +0530 Subject: [PATCH 044/499] FOGL-7298 PI system Connector relay OMF version fix (#923) Can now send data to connector relay. Firced legacy =true for omf versions 1.1 and 1.0 --- C/plugins/north/OMF/include/omf.h | 2 ++ C/plugins/north/OMF/omf.cpp | 5 +++-- C/plugins/north/OMF/plugin.cpp | 17 +++++++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index 835c0a1a0c..a7995fde90 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -23,6 +23,8 @@ // Remove or comment out the line below to prevent the forcing // of the version #define EDS_OMF_VERSION "1.0" +#define CR_OMF_VERSION "1.0" + #define TYPE_ID_DEFAULT 1 #define FAKE_ASSET_KEY "_default_start_id_" diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index 83a17f2ada..80036d92af 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -1204,9 +1204,10 @@ uint32_t OMF::sendToServer(const vector& readings, string outData; // Use old style complex types if the user has forced it via configuration, - // we are running against an EDS endpoint or we have types defined for this + // we are running against an EDS endpoint or Connector Relay or we have types defined for this // asset already - if (legacyType || m_PIServerEndpoint == ENDPOINT_EDS || + if (legacyType || m_PIServerEndpoint == ENDPOINT_EDS || + m_PIServerEndpoint == ENDPOINT_CR || m_OMFDataTypes->find(keyComplete) != m_OMFDataTypes->end()) { // Legacy type support diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index c0c3737af5..30bc0bb99b 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -980,6 +980,13 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, connInfo->omfversion = EDS_OMF_VERSION; } #endif + + // Version for Connector Relay is 1.0 only. + if (connInfo->PIServerEndpoint == ENDPOINT_CR) + { + connInfo->omfversion = CR_OMF_VERSION; + } + connInfo->omf->setOMFVersion(connInfo->omfversion); // Generates the prefix to have unique asset_id across different levels of hierarchies @@ -997,8 +1004,14 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, connInfo->omf->setStaticData(&connInfo->staticData); connInfo->omf->setNotBlockingErrors(connInfo->notBlockingErrors); - connInfo->omf->setLegacyMode(connInfo->legacy); - + if (connInfo->omfversion == "1.1" || connInfo->omfversion == "1.0") { + Logger::getLogger()->info("Setting LegacyType to be true for OMF Version '%s'. This will force use old style complex types. ", connInfo->omfversion.c_str()); + connInfo->omf->setLegacyMode(true); + } + else + { + connInfo->omf->setLegacyMode(connInfo->legacy); + } // Send the readings data to the PI Server uint32_t ret = connInfo->omf->sendToServer(readings, connInfo->compression); From 9c5ec08c9510535e2060a972eb1f894a23cddfe8 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Tue, 3 Jan 2023 12:58:55 +0530 Subject: [PATCH 045/499] Handled review feedback Signed-off-by: Amandeep Singh Arora --- C/services/south/include/south_service.h | 2 +- C/services/south/south.cpp | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index 516fe1ed2a..7662c48142 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -52,7 +52,7 @@ class SouthService : public ServiceAuthHandler { void shutdown(); void restart(); void configChange(const std::string&, const std::string&); - void configChangeReal(const std::string&, const std::string&); + void processConfigChange(const std::string&, const std::string&); void configChildCreate(const std::string&, const std::string&, const std::string&){}; diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index e2867ad41b..f469ca342d 100755 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -550,7 +550,6 @@ void SouthService::start(string& coreAddress, unsigned short corePort) } else // V2 poll method { - PRINT_FUNC; while(1) { unsigned int numPendingReconfs; @@ -558,6 +557,7 @@ void SouthService::start(string& coreAddress, unsigned short corePort) lock_guard guard(m_pendingNewConfigMutex); numPendingReconfs = m_pendingNewConfig.size(); } + // if a reconf is pending, make this poll thread yield CPU, sleep_for is needed to sleep this thread for sufficiently long time if (numPendingReconfs) { Logger::getLogger()->debug("SouthService::start(): %d entries in m_pendingNewConfig, poll thread yielding CPU", numPendingReconfs); @@ -833,8 +833,11 @@ void SouthService::restart() /** * Configuration change notification + * + * @param categoryName Category name + * @param category Category value */ -void SouthService::configChangeReal(const string& categoryName, const string& category) +void SouthService::processConfigChange(const string& categoryName, const string& category) { logger->info("Configuration change in category %s: %s", categoryName.c_str(), category.c_str()); @@ -975,7 +978,8 @@ static void reconfThreadMain(void *arg) } /** - * Handle configuration change notification + * Handle configuration change notification; called by reconf thread + * Waits for some reconf operation(s) to get queued up, then works thru' them */ void SouthService::handlePendingReconf() { @@ -983,7 +987,7 @@ void SouthService::handlePendingReconf() mutex mtx; unique_lock lck(mtx); m_cvNewReconf.wait(lck); - Logger::getLogger()->debug("SouthService::handlePendingReconf: cv wait has completed"); + Logger::getLogger()->debug("SouthService::handlePendingReconf: cv wait has completed; some reconf request(s) has/have been queued up"); while(1) { @@ -1010,7 +1014,7 @@ void SouthService::handlePendingReconf() } std::string categoryName = reconfValue->first; std::string category = reconfValue->second; - configChangeReal(categoryName, category); + processConfigChange(categoryName, category); logger->debug("SouthService::handlePendingReconf(): Handling of configuration change #%d done", i); } @@ -1026,10 +1030,12 @@ void SouthService::handlePendingReconf() /** * Configuration change notification using a separate thread + * + * @param categoryName Category name + * @param category Category value */ void SouthService::configChange(const string& categoryName, const string& category) { - PRINT_FUNC; { lock_guard guard(m_pendingNewConfigMutex); m_pendingNewConfig.emplace_back(std::make_pair(categoryName, category)); From f3de8dc76693f8355f99811f349849d05fa4fbde Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 4 Jan 2023 12:34:05 +0530 Subject: [PATCH 046/499] feedback fixes Signed-off-by: ashish-jabble --- python/fledge/common/utils.py | 8 ++++---- python/fledge/services/core/api/plugins/common.py | 2 +- python/fledge/services/core/api/plugins/install.py | 2 +- python/fledge/services/core/api/plugins/remove.py | 2 +- python/fledge/services/core/api/plugins/update.py | 7 +++---- python/fledge/services/core/api/service.py | 4 ++-- python/fledge/services/core/api/support.py | 2 +- python/fledge/services/core/api/update.py | 7 +++---- python/fledge/services/core/support.py | 2 +- tests/system/python/conftest.py | 14 ++++++-------- .../system/python/packages/test_authentication.py | 5 ++--- tests/system/python/packages/test_eds.py | 5 ++--- tests/system/python/packages/test_gcp_gateway.py | 6 +++--- .../system/python/packages/test_multiple_assets.py | 5 ++--- tests/system/python/packages/test_opcua.py | 5 ++--- 15 files changed, 34 insertions(+), 42 deletions(-) diff --git a/python/fledge/common/utils.py b/python/fledge/common/utils.py index 53af53e5a6..09bf56a1a8 100644 --- a/python/fledge/common/utils.py +++ b/python/fledge/common/utils.py @@ -106,9 +106,9 @@ def read_os_release(): return os_details -def is_debian(): +def is_redhat_based(): """ - To check if the Operating system is of Debian type or Not + To check if the Operating system is of Red Hat family or Not Examples: a) For an operating system with "ID=centos", an assignment of "ID_LIKE="rhel fedora"" is appropriate b) For an operating system with "ID=ubuntu/raspbian", an assignment of "ID_LIKE=debian" is appropriate. @@ -116,8 +116,8 @@ def is_debian(): os_release = read_os_release() id_like = os_release.get('ID_LIKE') if id_like is not None and any(x in id_like.lower() for x in ['centos', 'rhel', 'redhat', 'fedora']): - return False - return True + return True + return False def get_open_ssl_version(version_string=True): diff --git a/python/fledge/services/core/api/plugins/common.py b/python/fledge/services/core/api/plugins/common.py index 48fcb9f202..149b4e54bc 100644 --- a/python/fledge/services/core/api/plugins/common.py +++ b/python/fledge/services/core/api/plugins/common.py @@ -171,7 +171,7 @@ async def fetch_available_packages(package_type: str = "") -> tuple: stdout_file_path = create_log_file(action="list") tmp_log_output_fp = stdout_file_path.split('logs/')[:1][0] + "logs/output.txt" pkg_type = "" if package_type is None else package_type - pkg_mgt = 'apt' if common_utils.is_debian() else 'yum' + pkg_mgt = 'yum' if common_utils.is_redhat_based() else 'apt' category = await server.Server._configuration_manager.get_category_all_items("Installation") max_update_cat_item = category['maxUpdate'] pkg_cache_mgr = server.Server._package_cache_manager diff --git a/python/fledge/services/core/api/plugins/install.py b/python/fledge/services/core/api/plugins/install.py index 571b9a994b..f7d0593459 100644 --- a/python/fledge/services/core/api/plugins/install.py +++ b/python/fledge/services/core/api/plugins/install.py @@ -118,7 +118,7 @@ async def add_plugin(request: web.Request) -> web.Response: if name not in plugins: raise KeyError('{} plugin is not available for the configured repository'.format(name)) - pkg_mgt = 'apt' if utils.is_debian() else 'yum' + pkg_mgt = 'yum' if utils.is_redhat_based() else 'apt' # Insert record into Packages table insert_payload = PayloadBuilder().INSERT(id=str(uuid.uuid4()), name=name, action=action, status=-1, log_file_uri="").payload() diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index e11b61e2bf..b2b972cd9b 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -241,7 +241,7 @@ def purge_plugin(plugin_type: str, name: str, uid: uuid, storage: connect) -> tu plugin_name = 'fledge-{}-{}'.format(plugin_type, name) try: - if not utils.is_debian(): + if utils.is_redhat_based(): rpm_list = os.popen('rpm -qa | grep fledge*').read() _logger.debug("rpm list : {}".format(rpm_list)) if len(rpm_list): diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index 271cc6a0fd..c88919aa68 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -217,10 +217,9 @@ def _update_repo_sources_and_plugin(_type: str, name: str) -> tuple: # irrespective of package name defined in the configured repo. name = "fledge-{}-{}".format(_type, name.lower()) stdout_file_path = common.create_log_file(action="update", plugin_name=name) - if utils.is_debian(): - pkg_mgt = 'apt' - cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) - else: + pkg_mgt = 'apt' + cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) + if utils.is_redhat_based(): pkg_mgt = 'yum' cmd = "sudo {} check-update > {} 2>&1".format(pkg_mgt, stdout_file_path) ret_code = os.system(cmd) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index 0554c06699..cf8626dbcd 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -262,7 +262,7 @@ async def add_service(request): delimiter = '.' if str(version).count(delimiter) != 2: raise ValueError('Service semantic version is incorrect; it should be like X.Y.Z') - pkg_mgt = 'apt' if utils.is_debian() else 'yum' + pkg_mgt = 'yum' if utils.is_redhat_based() else 'apt' # Check Pre-conditions from Packages table # if status is -1 (Already in progress) then return as rejected request storage = connect.get_storage_async() @@ -682,7 +682,7 @@ def do_update(http_enabled: bool, host: str, port: int, storage: connect, pkg_na schedules: list) -> None: _logger.info("{} service update started...".format(pkg_name)) stdout_file_path = common.create_log_file("update", pkg_name) - pkg_mgt = 'apt' if utils.is_debian() else 'yum' + pkg_mgt = 'yum' if utils.is_redhat_based() else 'apt' cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) protocol = "HTTP" if http_enabled else "HTTPS" if pkg_mgt == 'yum': diff --git a/python/fledge/services/core/api/support.py b/python/fledge/services/core/api/support.py index d9d53487c2..8ac08fda86 100644 --- a/python/fledge/services/core/api/support.py +++ b/python/fledge/services/core/api/support.py @@ -26,7 +26,7 @@ _logger = logger.setup(__name__, level=logging.INFO) -_SYSLOG_FILE = '/var/log/syslog' if utils.is_debian() else '/var/log/messages' +_SYSLOG_FILE = '/var/log/messages' if utils.is_redhat_based() else '/var/log/syslog' _SCRIPTS_DIR = "{}/scripts".format(_FLEDGE_ROOT) __DEFAULT_LIMIT = 20 __DEFAULT_OFFSET = 0 diff --git a/python/fledge/services/core/api/update.py b/python/fledge/services/core/api/update.py index 7777e20d60..382fae3e60 100644 --- a/python/fledge/services/core/api/update.py +++ b/python/fledge/services/core/api/update.py @@ -125,10 +125,9 @@ async def get_updates(request: web.Request) -> web.Response: Example curl -sX GET http://localhost:8081/fledge/update |jq """ - if utils.is_debian(): - update_cmd = "sudo apt update" - upgradable_pkgs_check_cmd = "apt list --upgradable | grep \^fledge" - else: + update_cmd = "sudo apt update" + upgradable_pkgs_check_cmd = "apt list --upgradable | grep \^fledge" + if utils.is_redhat_based(): update_cmd = "sudo yum check-update" upgradable_pkgs_check_cmd = "yum list updates | grep \^fledge" diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index 01b84b4f61..f230574140 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -35,7 +35,7 @@ _LOGGER = logger.setup(__name__, level=logging.INFO) _NO_OF_FILES_TO_RETAIN = 3 -_SYSLOG_FILE = '/var/log/syslog' if utils.is_debian() else '/var/log/messages' +_SYSLOG_FILE = '/var/log/messages' if utils.is_redhat_based() else '/var/log/syslog' _PATH = _FLEDGE_DATA if _FLEDGE_DATA else _FLEDGE_ROOT + '/data' diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index 44d2d4a934..2e388f5433 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -132,8 +132,7 @@ def clone_make_install(): clone_make_install() elif installation_type == 'package': try: - pkg_mgr = 'apt' if pytest.IS_DEBIAN else 'yum' - subprocess.run(["sudo {} install -y fledge-south-{}".format(pkg_mgr, south_plugin)], shell=True, + subprocess.run(["sudo {} install -y fledge-south-{}".format(pytest.PKG_MGR, south_plugin)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "{} package installation failed!".format(south_plugin) @@ -184,8 +183,7 @@ def clone_make_install(): clone_make_install() elif installation_type == 'package': try: - pkg_mgr = 'apt' if pytest.IS_DEBIAN else 'yum' - subprocess.run(["sudo {} install -y fledge-north-{}".format(pkg_mgr, north_plugin)], shell=True, + subprocess.run(["sudo {} install -y fledge-north-{}".format(pytest.PKG_MGR, north_plugin)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "{} package installation failed!".format(north_plugin) @@ -510,9 +508,8 @@ def _add_filter(filter_plugin, filter_plugin_branch, filter_name, filter_config, assert False, "{} filter plugin installation failed".format(filter_plugin) elif installation_type == 'package': try: - pkg_mgr = 'apt' if pytest.IS_DEBIAN else 'yum' - subprocess.run(["sudo {} install -y fledge-filter-{}".format(pkg_mgr, filter_plugin)], shell=True, - check=True) + subprocess.run(["sudo {} install -y fledge-filter-{}".format(pytest.PKG_MGR, filter_plugin)], + shell=True, check=True) except subprocess.CalledProcessError: assert False, "{} package installation failed!".format(filter_plugin) else: @@ -982,4 +979,5 @@ def start_north_as_service(request): def pytest_configure(): pytest.OS_PLATFORM_DETAILS = utils.read_os_release() - pytest.IS_DEBIAN = utils.is_debian() + pytest.IS_REDHAT = utils.is_redhat_based() + pytest.PKG_MGR = 'yum' if pytest.IS_REDHAT else 'apt' diff --git a/tests/system/python/packages/test_authentication.py b/tests/system/python/packages/test_authentication.py index 8159f0b0e9..cc44a0bb2a 100644 --- a/tests/system/python/packages/test_authentication.py +++ b/tests/system/python/packages/test_authentication.py @@ -14,7 +14,7 @@ import ssl from pathlib import Path import pytest -from pytest import IS_DEBIAN +from pytest import PKG_MGR __author__ = "Yash Tatkondawar" __copyright__ = "Copyright (c) 2019 Dianomic Systems" @@ -172,8 +172,7 @@ def remove_and_add_fledge_pkgs(package_build_version): assert False, "setup package script failed" try: - pkg_mgr = 'apt' if IS_DEBIAN else 'yum' - subprocess.run(["sudo {} install -y fledge-south-http-south".format(pkg_mgr)], shell=True, check=True) + subprocess.run(["sudo {} install -y fledge-south-http-south".format(PKG_MGR)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "installation of http-south package failed" diff --git a/tests/system/python/packages/test_eds.py b/tests/system/python/packages/test_eds.py index 3ef900767e..6790f4a7a4 100644 --- a/tests/system/python/packages/test_eds.py +++ b/tests/system/python/packages/test_eds.py @@ -20,7 +20,7 @@ from datetime import datetime import pytest import utils -from pytest import IS_DEBIAN +from pytest import PKG_MGR __author__ = "Yash Tatkondawar" __copyright__ = "Copyright (c) 2020 Dianomic Systems, Inc." @@ -72,8 +72,7 @@ def remove_and_add_pkgs(package_build_version): assert False, "setup package script failed" try: - pkg_mgr = 'apt' if IS_DEBIAN else 'yum' - subprocess.run(["sudo {} install -y fledge-south-sinusoid".format(pkg_mgr)], shell=True, check=True) + subprocess.run(["sudo {} install -y fledge-south-sinusoid".format(PKG_MGR)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "installation of sinusoid package failed" diff --git a/tests/system/python/packages/test_gcp_gateway.py b/tests/system/python/packages/test_gcp_gateway.py index 0adce10784..229f1e517e 100644 --- a/tests/system/python/packages/test_gcp_gateway.py +++ b/tests/system/python/packages/test_gcp_gateway.py @@ -17,7 +17,7 @@ from datetime import timezone, datetime import utils import pytest -from pytest import IS_DEBIAN +from pytest import PKG_MGR __author__ = "Yash Tatkondawar" @@ -67,8 +67,8 @@ def remove_and_add_pkgs(package_build_version): assert False, "setup package script failed" try: - pkg_mgr = 'apt' if IS_DEBIAN else 'yum' - subprocess.run(["sudo {} install -y fledge-north-gcp fledge-south-sinusoid".format(pkg_mgr)], shell=True, check=True) + subprocess.run(["sudo {} install -y fledge-north-gcp fledge-south-sinusoid".format(PKG_MGR)], + shell=True, check=True) except subprocess.CalledProcessError: assert False, "installation of gcp-gateway and sinusoid packages failed" diff --git a/tests/system/python/packages/test_multiple_assets.py b/tests/system/python/packages/test_multiple_assets.py index 42059ef648..5090005120 100644 --- a/tests/system/python/packages/test_multiple_assets.py +++ b/tests/system/python/packages/test_multiple_assets.py @@ -21,7 +21,7 @@ import pytest import utils -from pytest import IS_DEBIAN +from pytest import PKG_MGR # This gives the path of directory where fledge is cloned. test_file < packages < python < system < tests < ROOT @@ -61,8 +61,7 @@ def remove_and_add_pkgs(package_build_version): assert False, "setup package script failed" try: - pkg_mgr = 'apt' if IS_DEBIAN else 'yum' - subprocess.run(["sudo {} install -y fledge-south-benchmark".format(pkg_mgr)], shell=True, check=True) + subprocess.run(["sudo {} install -y fledge-south-benchmark".format(PKG_MGR)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "installation of benchmark package failed" diff --git a/tests/system/python/packages/test_opcua.py b/tests/system/python/packages/test_opcua.py index 4928a81fb4..5f48ff323a 100644 --- a/tests/system/python/packages/test_opcua.py +++ b/tests/system/python/packages/test_opcua.py @@ -35,7 +35,7 @@ from typing import Tuple import utils import pytest -from pytest import IS_DEBIAN +from pytest import PKG_MGR """ First FL instance IP Address """ @@ -141,8 +141,7 @@ def install_pkg(): """ Fixture used for to install packages and only used in First FL instance """ try: - pkg_mgr = 'apt' if IS_DEBIAN else 'yum' - subprocess.run(["sudo {} install -y {}".format(pkg_mgr, PKG_LIST)], shell=True, check=True) + subprocess.run(["sudo {} install -y {}".format(PKG_MGR, PKG_LIST)], shell=True, check=True) except subprocess.CalledProcessError: assert False, "{} one of installation package failed".format(PKG_LIST) From c04c3cac0654a73a0736ead9e9b146b3d14b7164 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Wed, 4 Jan 2023 15:19:38 +0530 Subject: [PATCH 047/499] FOGL-7273: Table interest registration and processing working fine Signed-off-by: Amandeep Singh Arora --- C/common/include/storage_client.h | 5 +- C/common/storage_client.cpp | 96 +++++++ C/services/storage/include/storage_api.h | 4 + C/services/storage/include/storage_registry.h | 20 ++ C/services/storage/storage_api.cpp | 100 ++++++++ C/services/storage/storage_registry.cpp | 238 ++++++++++++++++-- 6 files changed, 448 insertions(+), 15 deletions(-) mode change 100644 => 100755 C/common/include/storage_client.h mode change 100644 => 100755 C/services/storage/include/storage_api.h mode change 100644 => 100755 C/services/storage/include/storage_registry.h mode change 100644 => 100755 C/services/storage/storage_api.cpp mode change 100644 => 100755 C/services/storage/storage_registry.cpp diff --git a/C/common/include/storage_client.h b/C/common/include/storage_client.h old mode 100644 new mode 100755 index 4df4b8a701..58136c08ab --- a/C/common/include/storage_client.h +++ b/C/common/include/storage_client.h @@ -86,7 +86,10 @@ class StorageClient { const std::string& callbackUrl); bool unregisterAssetNotification(const std::string& assetName, const std::string& callbackUrl); - + bool registerTableNotification(const std::string& tableName, const std::string& key, + std::vector keyValues, const std::string& operation, const std::string& callbackUrl); + bool unregisterTableNotification(const std::string& tableName, + const std::string& callbackUrl); void registerManagement(ManagementClient *mgmnt) { m_management = mgmnt; }; bool createSchema(const std::string&); diff --git a/C/common/storage_client.cpp b/C/common/storage_client.cpp index 28732a4fe0..f189159e3a 100755 --- a/C/common/storage_client.cpp +++ b/C/common/storage_client.cpp @@ -1259,6 +1259,102 @@ bool StorageClient::unregisterAssetNotification(const string& assetName, return false; } +/** + * Register interest for a table + * + * @param tableName The table name to register for notification + * @param tableKey The key of interest in the table + * @param tableKeyValues The key values of interest + * @param tableOperation The table operation of interest (insert/update/delete) + * @param callbackUrl The callback URL to send change data + * @return True on success, false otherwise. + */ +bool StorageClient::registerTableNotification(const string& tableName, const string& key, std::vector keyValues, + const string& operation, const string& callbackUrl) +{ + try + { + ostringstream keyValuesStr; + for (auto & s : keyValues) + { + keyValuesStr << "\"" << s << "\""; + if (&s != &keyValues.back()) + keyValuesStr << ", "; + } + + ostringstream convert; + + convert << "{ "; + convert << "\"url\" : \"" << callbackUrl << "\", "; + convert << "\"key\" : \"" << key << "\", "; + convert << "\"values\" : [" << keyValuesStr.str() << "], "; + convert << "\"operation\" : \"" << operation << "\" "; + convert << "}"; + + auto res = this->getHttpClient()->request("POST", + "/storage/table/interest/" + urlEncode(tableName), + convert.str()); + if (res->status_code.compare("200 OK") == 0) + { + return true; + } + ostringstream resultPayload; + resultPayload << res->content.rdbuf(); + handleUnexpectedResponse("Register table", + tableName, + res->status_code, + resultPayload.str()); + m_logger->error("/storage/table/interest/%s: %s", + urlEncode(tableName).c_str(), res->status_code.c_str()); + + return false; + } catch (exception& ex) + { + handleException(ex, "register table '%s'", tableName.c_str()); + } + return false; +} + +/** + * Unregister interest for a table name + * + * @param tableName The table name to unregister interest in + * @param callbackUrl The callback URL provided in registration. + * @return True on success, false otherwise. + */ +bool StorageClient::unregisterTableNotification(const string& tableName, + const string& callbackUrl) +{ + try + { + ostringstream convert; + + convert << "{ \"url\" : \""; + convert << callbackUrl; + convert << "\" }"; + auto res = this->getHttpClient()->request("DELETE", + "/storage/table/interest/" + urlEncode(tableName), + convert.str()); + if (res->status_code.compare("200 OK") == 0) + { + return true; + } + ostringstream resultPayload; + resultPayload << res->content.rdbuf(); + handleUnexpectedResponse("Unregister table", + tableName, + res->status_code, + resultPayload.str()); + + return false; + } catch (exception& ex) + { + handleException(ex, "unregister table '%s'", tableName.c_str()); + } + return false; +} + + bool StorageClient::openStream() { try { diff --git a/C/services/storage/include/storage_api.h b/C/services/storage/include/storage_api.h old mode 100644 new mode 100755 index fc2f34a27c..d91fe7df94 --- a/C/services/storage/include/storage_api.h +++ b/C/services/storage/include/storage_api.h @@ -28,6 +28,8 @@ using HttpServer = SimpleWeb::Server; #define READING_QUERY "^/storage/reading/query" #define READING_PURGE "^/storage/reading/purge" #define READING_INTEREST "^/storage/reading/interest/([A-Za-z\\*][a-zA-Z0-9_%\\.\\-]*)$" +#define TABLE_INTEREST "^/storage/table/interest/([A-Za-z\\*][a-zA-Z0-9_%\\.\\-]*)$" + #define GET_TABLE_SNAPSHOTS "^/storage/table/([A-Za-z][a-zA-Z_0-9_]*)/snapshot$" #define CREATE_TABLE_SNAPSHOT GET_TABLE_SNAPSHOTS #define LOAD_TABLE_SNAPSHOT "^/storage/table/([A-Za-z][a-zA-Z_0-9_]*)/snapshot/([a-zA-Z_0-9_]*)$" @@ -79,6 +81,8 @@ class StorageApi { void readingPurge(shared_ptr response, shared_ptr request); void readingRegister(shared_ptr response, shared_ptr request); void readingUnregister(shared_ptr response, shared_ptr request); + void tableRegister(shared_ptr response, shared_ptr request); + void tableUnregister(shared_ptr response, shared_ptr request); void createTableSnapshot(shared_ptr response, shared_ptr request); void loadTableSnapshot(shared_ptr response, shared_ptr request); void deleteTableSnapshot(shared_ptr response, shared_ptr request); diff --git a/C/services/storage/include/storage_registry.h b/C/services/storage/include/storage_registry.h old mode 100644 new mode 100755 index fa3b9ddb6a..44925753ca --- a/C/services/storage/include/storage_registry.h +++ b/C/services/storage/include/storage_registry.h @@ -10,6 +10,16 @@ typedef std::vector > REGISTRY; +typedef struct { + std::string url; + std::string key; + std::vector keyValues; + std::string operation; +} TableRegistration; + +typedef std::vector > REGISTRY_TABLE; + + /** * StorageRegistry - a class that manages requests from other microservices * to register interest in new readings being inserted into the storage layer @@ -22,15 +32,25 @@ class StorageRegistry { void registerAsset(const std::string& asset, const std::string& url); void unregisterAsset(const std::string& asset, const std::string& url); void process(const std::string& payload); + void processTableInsert(const std::string& tableName, const std::string& payload); + void registerTable(const std::string& table, const std::string& url); + void unregisterTable(const std::string& table, const std::string& url); + void processInsert(const std::string& payload); + void insertTestTableReg(); void run(); private: void processPayload(char *payload); void sendPayload(const std::string& url, char *payload); void filterPayload(const std::string& url, char *payload, const std::string& asset); + void processInsert(char *tableName, char *payload); typedef std::pair Item; + typedef std::tuple TableItem; REGISTRY m_registrations; + REGISTRY_TABLE m_tableRegistrations; std::queue m_queue; + std::queue + m_tableInsertQueue; std::mutex m_qMutex; std::thread *m_thread; std::condition_variable m_cv; diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp old mode 100644 new mode 100755 index fb9254a612..9401ecb6e3 --- a/C/services/storage/storage_api.cpp +++ b/C/services/storage/storage_api.cpp @@ -252,6 +252,24 @@ void readingUnregisterWrapper(shared_ptr response, shared_ api->readingUnregister(response, request); } +/** + * Wrapper function for the table interest register API call. + */ +void tableRegisterWrapper(shared_ptr response, shared_ptr request) +{ + StorageApi *api = StorageApi::getInstance(); + api->tableRegister(response, request); +} + +/** + * Wrapper function for the table interest unregister API call. + */ +void tableUnregisterWrapper(shared_ptr response, shared_ptr request) +{ + StorageApi *api = StorageApi::getInstance(); + api->tableUnregister(response, request); +} + /** * Wrapper function for the create snapshot API call. */ @@ -421,6 +439,9 @@ void StorageApi::initResources() m_server->resource[READING_INTEREST]["POST"] = readingRegisterWrapper; m_server->resource[READING_INTEREST]["DELETE"] = readingUnregisterWrapper; + m_server->resource[TABLE_INTEREST]["POST"] = tableRegisterWrapper; + m_server->resource[TABLE_INTEREST]["DELETE"] = tableUnregisterWrapper; + m_server->resource[CREATE_TABLE_SNAPSHOT]["POST"] = createTableSnapshotWrapper; m_server->resource[LOAD_TABLE_SNAPSHOT]["PUT"] = loadTableSnapshotWrapper; m_server->resource[DELETE_TABLE_SNAPSHOT]["DELETE"] = deleteTableSnapshotWrapper; @@ -536,6 +557,7 @@ string responsePayload; int rval = plugin->commonInsert(tableName, payload); if (rval != -1) { + registry.processTableInsert(tableName, payload); responsePayload = "{ \"response\" : \"inserted\", \"rows_affected\" : "; responsePayload += to_string(rval); responsePayload += " }"; @@ -1118,6 +1140,84 @@ Document doc; } } +/** + * Register interest in readings for an asset + */ +void StorageApi::tableRegister(shared_ptr response, + shared_ptr request) +{ +string table; +string payload; +Document doc; + + payload = request->content.string(); + // URL decode table name + table = urlDecode(request->path_match[TABLE_NAME_COMPONENT]); + + doc.Parse(payload.c_str()); + if (doc.HasParseError()) + { + string resp = "{ \"error\" : \"Badly formed payload\" }"; + respond(response, + SimpleWeb::StatusCode::client_error_bad_request, + resp); + } + else + { + if (doc.HasMember("url")) + { + registry.registerTable(table, payload); // doc["url"].GetString()); + string resp = " { \"" + table + "\" : \"registered\" }"; + respond(response, resp); + } + else + { + string resp = "{ \"error\" : \"Missing url element in payload\" }"; + respond(response, SimpleWeb::StatusCode::client_error_bad_request, resp); + } + } +} + +/** + * Unregister interest in readings for an asset + */ +void StorageApi::tableUnregister(shared_ptr response, + shared_ptr request) +{ +string table; +string payload; +Document doc; + + payload = request->content.string(); + // URL decode asset name + table = urlDecode(request->path_match[TABLE_NAME_COMPONENT]); + doc.Parse(payload.c_str()); + if (doc.HasParseError()) + { + string resp = "{ \"error\" : \"Badly formed payload\" }"; + respond(response, + SimpleWeb::StatusCode::client_error_bad_request, + resp); + } + else + { + if (doc.HasMember("url")) + { + registry.unregisterAsset(table, doc["url"].GetString()); + string resp = " { \"" + table + "\" : \"unregistered\" }"; + respond(response, resp); + } + else + { + string resp = "{ \"error\" : \"Missing url element in payload\" }"; + respond(response, + SimpleWeb::StatusCode::client_error_bad_request, + resp); + } + } +} + + /** * Create a stream for high speed storage ingestion * diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp old mode 100644 new mode 100755 index 0e1e384721..d4c0e41e97 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -94,6 +94,42 @@ StorageRegistry::process(const string& payload) } } +/** + * Process a table insert payload and determine + * if any microservice has registered an interest + * in this table. + * + * @param payload The table insert payload + */ +void +StorageRegistry::processTableInsert(const string& tableName, const string& payload) +{ + // Logger::getLogger()->info("StorageRegistry::processTableInsert(): tableName=%s, payload=%s", tableName.c_str(), payload.c_str()); + if (m_tableRegistrations.size() == 0) + insertTestTableReg(); // TODO: only for testing + + if (m_tableRegistrations.size() > 0) + { + /* + * We have some registrations so queue a copy of the payload + * to be examined in the thread the send table notifications + * to interested parties. + */ + char *table = strdup(tableName.c_str()); + char *data = strdup(payload.c_str()); + + if (data != NULL && table != NULL) + { + time_t now = time(0); + TableItem item = make_tuple(now, table, data); + lock_guard guard(m_qMutex); + m_tableInsertQueue.push(item); + m_cv.notify_all(); + } + } +} + + /** * Handle a registration request from a client of the storage layer * @@ -130,13 +166,94 @@ StorageRegistry::unregisterAsset(const string& asset, const string& url) } } +/** + * Handle a registration request for a table from a client of the storage layer + * + * @param table The table of interest + * @param payload JSON payload describing the interest + */ +void +StorageRegistry::registerTable(const string& table, const string& payload) +{ + Document doc; + + doc.Parse(payload.c_str()); + if (doc.HasParseError()) + { + Logger::getLogger()->error("StorageRegistry::registerTable(): Parse error in subscription request payload"); + return; + } + + TableRegistration *reg = new TableRegistration; + if (!doc.HasMember("url")) + { + Logger::getLogger()->error("StorageRegistry::registerTable(): subscription request doesn't have url field"); + return; + } + if (!doc.HasMember("key")) + { + Logger::getLogger()->error("StorageRegistry::registerTable(): subscription request doesn't have url field"); + return; + } + if (!doc.HasMember("operation")) + { + Logger::getLogger()->error("StorageRegistry::registerTable(): subscription request doesn't have url field"); + return; + } + + reg->url = doc["url"].GetString(); + reg->key = doc["key"].GetString(); + reg->operation = doc["operation"].GetString(); + + if (reg->key.size()) + { + if (!doc.HasMember("values") || !doc["values"].IsArray()) + { + Logger::getLogger()->error("StorageRegistry::registerTable(): subscription request doesn't have a proper values field"); + return; + } + for (auto & v : doc["values"].GetArray()) + reg->keyValues.emplace_back(v.GetString()); + } + + m_tableRegistrations.push_back(pair(new string(table), reg)); + Logger::getLogger()->info("StorageRegistry::registerTable(): Registration entry added for table %s", table.c_str()); +} + +/** + * Handle a request to remove a registration of interest in a table + * + * @param table The table of interest + * @param url The URL to call + */ +#if 0 +void +StorageRegistry::unregisterTable(const string& table, const string& url) +{ + for (auto it = m_tableRegistrations.begin(); it != m_tableRegistrations.end(); ) + { + if (table.compare(*(it->first)) == 0 && url.compare(*(it->second)) == 0) + { + delete it->first; + delete it->second; + it = m_tableRegistrations.erase(it); + } + else + { + ++it; + } + } +} +#endif + + /** * The worker function that processes the queue of payloads * that may need to be sent to subscribers. */ void StorageRegistry::run() -{ +{ m_running = true; while (m_running) { @@ -146,7 +263,7 @@ StorageRegistry::run() #endif { unique_lock mlock(m_cvMutex); - while (m_queue.size() == 0) + while (m_queue.size() == 0 && m_tableInsertQueue.size() == 0) { m_cv.wait_for(mlock, std::chrono::seconds(REGISTRY_SLEEP_TIME)); if (!m_running) @@ -154,23 +271,53 @@ StorageRegistry::run() return; } } - Item item = m_queue.front(); - m_queue.pop(); - data = item.second; + + while (!m_queue.empty()) + { + Item item = m_queue.front(); + m_queue.pop(); + data = item.second; #if CHECK_QTIMES - qTime = item.first; + qTime = item.first; #endif - } - if (data) - { + if (data) + { #if CHECK_QTIMES - if (time(0) - qTime > QTIME_THRESHOLD) - { - Logger::getLogger()->error("Data has been queued for %d seconds to be sent to registered party", (time(0) - qTime)); + if (time(0) - qTime > QTIME_THRESHOLD) + { + Logger::getLogger()->error("Readings data has been queued for %d seconds to be sent to registered party", (time(0) - qTime)); + } +#endif + processPayload(data); + free(data); + } } + + while (!m_tableInsertQueue.empty()) + { + char *tableName = NULL; + + TableItem item = m_tableInsertQueue.front(); + m_tableInsertQueue.pop(); + tableName = get<1>(item); + data = get<2>(item); +#if CHECK_QTIMES + qTime = item.first; #endif - processPayload(data); - free(data); + if (tableName && data) + { +#if CHECK_QTIMES + if (time(0) - qTime > QTIME_THRESHOLD) + { + Logger::getLogger()->error("Table insert data has been queued for %d seconds to be sent to registered party", (time(0) - qTime)); + } +#endif + processInsert(tableName, data); + free(tableName); + free(data); + } + } + } } } @@ -317,3 +464,66 @@ ostringstream convert; Logger::getLogger()->error("filterPayload: exception %s sending reading data to interested party %s", e.what(), url.c_str()); } } + +/** + * Process an incoming payload and distribute as required to registered + * services + * + * @param payload The payload to potentially distribute + */ +void +StorageRegistry::processInsert(char *tableName, char *payload) +{ + Document payloadDoc; + + payloadDoc.Parse(payload); + if (payloadDoc.HasParseError()) + { + Logger::getLogger()->error("StorageRegistry::processInsert(): Parse error in payload for table:%s, payload=%s", tableName, payload); + return; + } + + for (auto & reg : m_tableRegistrations) + { + if (reg.first->compare(tableName) != 0) + + continue; + + TableRegistration *tblreg = reg.second; + + // If key is empty string, no need to match key/value pair in payload + // Also operation must be "insert" for initial implementation + if (tblreg->operation.compare("insert") != 0) + continue; + + bool match = (tblreg->key.size()==0); + if (!match && payloadDoc.HasMember(tblreg->key.c_str()) && payloadDoc[tblreg->key.c_str()].IsString()) + { + string payloadKeyValue = payloadDoc[tblreg->key.c_str()].GetString(); + if (std::find(tblreg->keyValues.begin(), tblreg->keyValues.end(), payloadKeyValue) != tblreg->keyValues.end()) + match = true; + } + if(match) + { + Logger::getLogger()->info("StorageRegistry::processInsert(): Sending matching payload: table=%s, url=%s, payload=%s", tableName, tblreg->url.c_str(), payload); + sendPayload(tblreg->url, payload); + } + else + Logger::getLogger()->info("StorageRegistry::processInsert(): Ignoring non-matching payload: table=%s, payload=%s", tableName, payload); + } +} + +void StorageRegistry::insertTestTableReg() +{ + string reg_payload = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl", "key": "code", "values":["CONAD", "PURGE", "CONCH", "FSTOP", "SRVRG"], "operation": "insert"} )***"; + Logger::getLogger()->error("StorageRegistry::insertTestTableReg(): reg_payload=%s", reg_payload.c_str()); + registerTable("log", reg_payload); + + string reg_payload2 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl2", "key": "", "operation": "insert"} )***"; + registerTable("asset_tracker", reg_payload2); + + string reg_payload3 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl3", "key": "event", "values":["Ingest", "Filter"], "operation": "insert"} )***"; + registerTable("asset_tracker", reg_payload3); +} + + From 9d8b023c5a429efd4dbb8a13e3861ae878d3b0e9 Mon Sep 17 00:00:00 2001 From: Mohit Tomar Date: Fri, 6 Jan 2023 11:52:46 +0530 Subject: [PATCH 048/499] DOCBRANCH set to develop Signed-off-by: Mohit Tomar --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 635789cc32..4069ee0648 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -177,4 +177,4 @@ # Pass Plugin DOCBRANCH argument in Makefile ; by default develop # NOTE: During release time we need to replace DOCBRANCH with actual released version -subprocess.run(["make generated DOCBRANCH='2.1.0RC'"], shell=True, check=True) +subprocess.run(["make generated DOCBRANCH='develop'"], shell=True, check=True) From d833eb0c8811b515cd96c21b852c0c8b27046d1f Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Fri, 6 Jan 2023 18:51:44 +0530 Subject: [PATCH 049/499] temp changes Signed-off-by: Amandeep Singh Arora --- C/common/include/storage_client.h | 0 C/common/storage_client.cpp | 0 C/services/storage/include/storage_api.h | 0 C/services/storage/include/storage_registry.h | 3 + C/services/storage/storage_api.cpp | 14 +- C/services/storage/storage_registry.cpp | 233 +++++++++++++++--- 6 files changed, 205 insertions(+), 45 deletions(-) mode change 100755 => 100644 C/common/include/storage_client.h mode change 100755 => 100644 C/common/storage_client.cpp mode change 100755 => 100644 C/services/storage/include/storage_api.h diff --git a/C/common/include/storage_client.h b/C/common/include/storage_client.h old mode 100755 new mode 100644 diff --git a/C/common/storage_client.cpp b/C/common/storage_client.cpp old mode 100755 new mode 100644 diff --git a/C/services/storage/include/storage_api.h b/C/services/storage/include/storage_api.h old mode 100755 new mode 100644 diff --git a/C/services/storage/include/storage_registry.h b/C/services/storage/include/storage_registry.h index 44925753ca..242a4a9c19 100755 --- a/C/services/storage/include/storage_registry.h +++ b/C/services/storage/include/storage_registry.h @@ -33,10 +33,12 @@ class StorageRegistry { void unregisterAsset(const std::string& asset, const std::string& url); void process(const std::string& payload); void processTableInsert(const std::string& tableName, const std::string& payload); + TableRegistration* parseTableSubscriptionPayload(const std::string& payload); void registerTable(const std::string& table, const std::string& url); void unregisterTable(const std::string& table, const std::string& url); void processInsert(const std::string& payload); void insertTestTableReg(); + void removeTestTableReg(int n); void run(); private: void processPayload(char *payload); @@ -52,6 +54,7 @@ class StorageRegistry { std::queue m_tableInsertQueue; std::mutex m_qMutex; + std::mutex m_tableRegistrationsMutex; std::thread *m_thread; std::condition_variable m_cv; std::mutex m_cvMutex; diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp index 9401ecb6e3..4a07bfb292 100755 --- a/C/services/storage/storage_api.cpp +++ b/C/services/storage/storage_api.cpp @@ -557,7 +557,9 @@ string responsePayload; int rval = plugin->commonInsert(tableName, payload); if (rval != -1) { + PRINT_FUNC; registry.processTableInsert(tableName, payload); + PRINT_FUNC; responsePayload = "{ \"response\" : \"inserted\", \"rows_affected\" : "; responsePayload += to_string(rval); responsePayload += " }"; @@ -1166,7 +1168,7 @@ Document doc; { if (doc.HasMember("url")) { - registry.registerTable(table, payload); // doc["url"].GetString()); + registry.registerTable(table, payload); string resp = " { \"" + table + "\" : \"registered\" }"; respond(response, resp); } @@ -1189,8 +1191,9 @@ string payload; Document doc; payload = request->content.string(); - // URL decode asset name + // URL decode table name table = urlDecode(request->path_match[TABLE_NAME_COMPONENT]); + doc.Parse(payload.c_str()); if (doc.HasParseError()) { @@ -1203,21 +1206,18 @@ Document doc; { if (doc.HasMember("url")) { - registry.unregisterAsset(table, doc["url"].GetString()); + registry.unregisterTable(table, payload); string resp = " { \"" + table + "\" : \"unregistered\" }"; respond(response, resp); } else { string resp = "{ \"error\" : \"Missing url element in payload\" }"; - respond(response, - SimpleWeb::StatusCode::client_error_bad_request, - resp); + respond(response, SimpleWeb::StatusCode::client_error_bad_request, resp); } } } - /** * Create a stream for high speed storage ingestion * diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp index d4c0e41e97..ea68d45785 100755 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -97,16 +97,23 @@ StorageRegistry::process(const string& payload) /** * Process a table insert payload and determine * if any microservice has registered an interest - * in this table. + * in this table. Called from StorageApi::commonInsert() * * @param payload The table insert payload */ void StorageRegistry::processTableInsert(const string& tableName, const string& payload) { + std::ostringstream ss; + ss << std::this_thread::get_id(); + Logger::getLogger()->info("StorageRegistry::processTableInsert(): START: thread_id = %s", ss.str().c_str()); + // Logger::getLogger()->info("StorageRegistry::processTableInsert(): tableName=%s, payload=%s", tableName.c_str(), payload.c_str()); if (m_tableRegistrations.size() == 0) - insertTestTableReg(); // TODO: only for testing + { + // insertTestTableReg(); // TODO: remove this; only for testing + Logger::getLogger()->info("StorageRegistry::processTableInsert(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); + } if (m_tableRegistrations.size() > 0) { @@ -127,6 +134,14 @@ StorageRegistry::processTableInsert(const string& tableName, const string& paylo m_cv.notify_all(); } } + +#if 0 + PRINT_FUNC; + int n = (std::hash{}(payload) % 5) + 1; + removeTestTableReg(n); // (n); // TODO: remove this; only for testing + PRINT_FUNC; + Logger::getLogger()->info("StorageRegistry::processTableInsert(): removeTestTableReg(%d): m_tableRegistrations.size()=%d", n, m_tableRegistrations.size()); +#endif } @@ -167,39 +182,38 @@ StorageRegistry::unregisterAsset(const string& asset, const string& url) } /** - * Handle a registration request for a table from a client of the storage layer + * Parse a table subscription (un)register JSON payload * - * @param table The table of interest * @param payload JSON payload describing the interest */ -void -StorageRegistry::registerTable(const string& table, const string& payload) +TableRegistration* StorageRegistry::parseTableSubscriptionPayload(const string& payload) { Document doc; doc.Parse(payload.c_str()); if (doc.HasParseError()) { - Logger::getLogger()->error("StorageRegistry::registerTable(): Parse error in subscription request payload"); - return; + Logger::getLogger()->error("StorageRegistry::parseTableSubscriptionPayload(): Parse error in subscription request payload"); + return NULL; } - - TableRegistration *reg = new TableRegistration; + if (!doc.HasMember("url")) { - Logger::getLogger()->error("StorageRegistry::registerTable(): subscription request doesn't have url field"); - return; + Logger::getLogger()->error("StorageRegistry::parseTableSubscriptionPayload(): subscription request doesn't have url field"); + return NULL; } if (!doc.HasMember("key")) { - Logger::getLogger()->error("StorageRegistry::registerTable(): subscription request doesn't have url field"); - return; + Logger::getLogger()->error("StorageRegistry::parseTableSubscriptionPayload(): subscription request doesn't have url field"); + return NULL; } if (!doc.HasMember("operation")) { - Logger::getLogger()->error("StorageRegistry::registerTable(): subscription request doesn't have url field"); - return; + Logger::getLogger()->error("StorageRegistry::parseTableSubscriptionPayload(): subscription request doesn't have url field"); + return NULL; } + + TableRegistration *reg = new TableRegistration; reg->url = doc["url"].GetString(); reg->key = doc["key"].GetString(); @@ -209,42 +223,108 @@ StorageRegistry::registerTable(const string& table, const string& payload) { if (!doc.HasMember("values") || !doc["values"].IsArray()) { - Logger::getLogger()->error("StorageRegistry::registerTable(): subscription request doesn't have a proper values field"); - return; + Logger::getLogger()->error("StorageRegistry::parseTableSubscriptionPayload(): subscription request" \ + " doesn't have a proper values field, payload=%s", payload.c_str()); + delete reg; + return NULL; } for (auto & v : doc["values"].GetArray()) reg->keyValues.emplace_back(v.GetString()); } - + + return reg; +} + +/** + * Handle a registration request for a table from a client of the storage layer + * + * @param table The table of interest + * @param payload JSON payload describing the interest + */ +void +StorageRegistry::registerTable(const string& table, const string& payload) +{ + TableRegistration *reg = parseTableSubscriptionPayload(payload); + + // lock_guard guard(m_qMutex); + + if (!reg) + { + Logger::getLogger()->info("StorageRegistry::registerTable(): Unable to register invalid Registration entry for table %s, payload=%s", table.c_str(), payload.c_str()); + return; + } + + lock_guard guard(m_tableRegistrationsMutex); + Logger::getLogger()->info("*** StorageRegistry::registerTable(): Adding registration entry for table %s", table.c_str()); m_tableRegistrations.push_back(pair(new string(table), reg)); Logger::getLogger()->info("StorageRegistry::registerTable(): Registration entry added for table %s", table.c_str()); + + delete reg; } /** * Handle a request to remove a registration of interest in a table * * @param table The table of interest - * @param url The URL to call + * @param payload JSON payload describing the interest */ -#if 0 void -StorageRegistry::unregisterTable(const string& table, const string& url) +StorageRegistry::unregisterTable(const string& table, const string& payload) { + PRINT_FUNC; + TableRegistration *reg = parseTableSubscriptionPayload(payload); + PRINT_FUNC; + + // lock_guard guard(m_qMutex); + + if (!reg) + { + Logger::getLogger()->info("StorageRegistry::unregisterTable(): Unable to unregister " \ + "invalid Registration entry for table %s, payload=%s", table.c_str(), payload.c_str()); + return; + } + PRINT_FUNC; + + lock_guard guard(m_tableRegistrationsMutex); + PRINT_FUNC; + Logger::getLogger()->info("StorageRegistry::unregisterTable(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); for (auto it = m_tableRegistrations.begin(); it != m_tableRegistrations.end(); ) { - if (table.compare(*(it->first)) == 0 && url.compare(*(it->second)) == 0) + PRINT_FUNC; + TableRegistration *reg_it = it->second; + PRINT_FUNC; + if (table.compare(*(it->first)) == 0 && + reg->url.compare(reg_it->url)==0 && + reg->key.compare(reg_it->key)==0 && + reg->operation.compare(reg_it->operation)==0) { - delete it->first; - delete it->second; - it = m_tableRegistrations.erase(it); + PRINT_FUNC; + // Either no key is to be matched or a key is to be matched against a possible set of values + if (reg->key.size()==0 || (reg->key.size() && reg->keyValues == reg_it->keyValues)) + { + PRINT_FUNC; + Logger::getLogger()->info("*** StorageRegistry::unregisterTable(): Removing registration for table %s and url %s", table, reg->key.c_str()); + delete it->first; + delete it->second; + it = m_tableRegistrations.erase(it); + Logger::getLogger()->info("*** StorageRegistry::unregisterTable(): Removed registration for table %s and url %s", table, reg->key.c_str()); + } + else + { + PRINT_FUNC; + ++it; + } } else { + PRINT_FUNC; ++it; - } + } } + PRINT_FUNC; + delete reg; + PRINT_FUNC; } -#endif /** @@ -474,35 +554,58 @@ ostringstream convert; void StorageRegistry::processInsert(char *tableName, char *payload) { + Logger::getLogger()->error("****** StorageRegistry::processInsert(): Handling for table:%s, payload=%s", tableName, payload); + Logger::getLogger()->info("StorageRegistry::processInsert(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); + if (m_tableRegistrations.size() == 0) + { + // insertTestTableReg(); // TODO: remove this; only for testing + Logger::getLogger()->info("StorageRegistry::processTableInsert(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); + } + Document payloadDoc; payloadDoc.Parse(payload); + PRINT_FUNC; if (payloadDoc.HasParseError()) { Logger::getLogger()->error("StorageRegistry::processInsert(): Parse error in payload for table:%s, payload=%s", tableName, payload); return; } - + PRINT_FUNC; + + lock_guard guard(m_tableRegistrationsMutex); for (auto & reg : m_tableRegistrations) { + PRINT_FUNC; if (reg.first->compare(tableName) != 0) - continue; - + + PRINT_FUNC; TableRegistration *tblreg = reg.second; + PRINT_FUNC; + // If key is empty string, no need to match key/value pair in payload // Also operation must be "insert" for initial implementation if (tblreg->operation.compare("insert") != 0) + { + PRINT_FUNC; continue; + } + + PRINT_FUNC; bool match = (tblreg->key.size()==0); + PRINT_FUNC; if (!match && payloadDoc.HasMember(tblreg->key.c_str()) && payloadDoc[tblreg->key.c_str()].IsString()) { string payloadKeyValue = payloadDoc[tblreg->key.c_str()].GetString(); + PRINT_FUNC; if (std::find(tblreg->keyValues.begin(), tblreg->keyValues.end(), payloadKeyValue) != tblreg->keyValues.end()) match = true; + PRINT_FUNC; } + PRINT_FUNC; if(match) { Logger::getLogger()->info("StorageRegistry::processInsert(): Sending matching payload: table=%s, url=%s, payload=%s", tableName, tblreg->url.c_str(), payload); @@ -510,20 +613,74 @@ StorageRegistry::processInsert(char *tableName, char *payload) } else Logger::getLogger()->info("StorageRegistry::processInsert(): Ignoring non-matching payload: table=%s, payload=%s", tableName, payload); + + PRINT_FUNC; } + +#if 0 + PRINT_FUNC; + int n = (std::hash{}(payload) % 5) + 1; + removeTestTableReg(n); // (n); // TODO: remove this; only for testing + PRINT_FUNC; + Logger::getLogger()->info("StorageRegistry::processTableInsert(): removeTestTableReg(%d): m_tableRegistrations.size()=%d", n, m_tableRegistrations.size()); +#endif } void StorageRegistry::insertTestTableReg() { - string reg_payload = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl", "key": "code", "values":["CONAD", "PURGE", "CONCH", "FSTOP", "SRVRG"], "operation": "insert"} )***"; - Logger::getLogger()->error("StorageRegistry::insertTestTableReg(): reg_payload=%s", reg_payload.c_str()); - registerTable("log", reg_payload); + string table1("log"); + string payload1 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl", "key": "code", "values":["CONAD", "PURGE", "CONCH", "FSTOP", "SRVRG"], "operation": "insert"} )***"; - string reg_payload2 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl2", "key": "", "operation": "insert"} )***"; - registerTable("asset_tracker", reg_payload2); + string table2("asset_tracker"); + string payload2 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl2", "key": "", "operation": "insert"} )***"; + + string table3("asset_tracker"); + string payload3 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl3", "key": "event", "values":["Ingest", "Filter"], "operation": "insert"} )***"; + + Logger::getLogger()->error("StorageRegistry::insertTestTableReg(): table=%s, payload=%s", table1.c_str(), payload1.c_str()); + registerTable(table1, payload1); - string reg_payload3 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl3", "key": "event", "values":["Ingest", "Filter"], "operation": "insert"} )***"; - registerTable("asset_tracker", reg_payload3); + Logger::getLogger()->error("StorageRegistry::insertTestTableReg(): table=%s, payload=%s", table2.c_str(), payload2.c_str()); + registerTable(table2, payload2); + + Logger::getLogger()->error("StorageRegistry::insertTestTableReg(): table=%s, payload=%s", table3.c_str(), payload3.c_str()); + registerTable(table3, payload3); +} + + +void StorageRegistry::removeTestTableReg(int n) +{ + string table1("log"); + string payload1 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl", "key": "code", "values":["CONAD", "PURGE", "CONCH", "FSTOP", "SRVRG"], "operation": "insert"} )***"; + + string table2("asset_tracker"); + string payload2 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl2", "key": "", "operation": "insert"} )***"; + + string table3("asset_tracker"); + string payload3 = R"***( {"url": "http://localhost:8081/dummyTableNotifyUrl3", "key": "event", "values":["Ingest", "Filter"], "operation": "insert"} )***"; + + switch(n) + { + case 1: + unregisterTable(table1, payload1); + Logger::getLogger()->error("StorageRegistry::removeTestTableReg(): table=%s, payload=%s", table1.c_str(), payload1.c_str()); + break; + + case 2: + unregisterTable(table2, payload2); + Logger::getLogger()->error("StorageRegistry::removeTestTableReg(): table=%s, payload=%s", table2.c_str(), payload2.c_str()); + break; + + case 3: + unregisterTable(table3, payload3); + Logger::getLogger()->error("StorageRegistry::removeTestTableReg(): table=%s, payload=%s", table3.c_str(), payload3.c_str()); + break; + + default: + Logger::getLogger()->error("StorageRegistry::removeTestTableReg(): unhandled value n=%d", n); + break; + } + PRINT_FUNC; } From 28bcd9c42a69d87fcc2d7971e451b60e2a76dc7d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 10 Jan 2023 16:06:15 +0530 Subject: [PATCH 050/499] centos 7version check added for devtoolset & postgresql13 Signed-off-by: ashish-jabble --- tests/system/python/scripts/install_c_plugin | 13 ++++++++----- tests/system/python/scripts/install_c_service | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/system/python/scripts/install_c_plugin b/tests/system/python/scripts/install_c_plugin index fdedbff529..387874c3ea 100755 --- a/tests/system/python/scripts/install_c_plugin +++ b/tests/system/python/scripts/install_c_plugin @@ -19,7 +19,8 @@ PLUGIN_NAME=$3 [[ -z "${PLUGIN_TYPE}" ]] && echo "Plugin type not found." && exit 1 [[ -z "${PLUGIN_NAME}" ]] && echo "Plugin name not found." && exit 1 -os_name=`(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g')` +os_name=$(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g') +os_version=$(grep -o '^VERSION_ID=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g') REPO_NAME=fledge-${PLUGIN_TYPE}-${PLUGIN_NAME,,} if [[ "${PLUGIN_TYPE}" = "rule" ]]; then rm -rf /tmp/fledge-service-notification; fi @@ -51,10 +52,12 @@ install_binary_file () { cd /tmp/${REPO_NAME}; ./build.sh -DFLEDGE_INSTALL=${FLEDGE_ROOT}; cd build && make install; else if [[ $os_name == *"Red Hat"* || $os_name == *"CentOS"* ]]; then - set +e - source scl_source enable rh-postgresql13 - source scl_source enable devtoolset-7 - set -e + if [[ ${os_version} -eq "7" ]]; then + set +e + source scl_source enable rh-postgresql13 + source scl_source enable devtoolset-7 + set -e + fi fi mkdir -p /tmp/${REPO_NAME}/build; cd /tmp/${REPO_NAME}/build; cmake -DFLEDGE_INSTALL=${FLEDGE_ROOT} ..; make -j4 && make install; cd - fi diff --git a/tests/system/python/scripts/install_c_service b/tests/system/python/scripts/install_c_service index bfe42bef60..cdea645f90 100755 --- a/tests/system/python/scripts/install_c_service +++ b/tests/system/python/scripts/install_c_service @@ -17,7 +17,8 @@ SERVICE_NAME=$2 [[ -z "${BRANCH_NAME}" ]] && echo "Branch name not found." && exit 1 [[ -z "${SERVICE_NAME}" ]] && echo "Service name not found." && exit 1 -os_name=`(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g')` +os_name=$(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g') +os_version=$(grep -o '^VERSION_ID=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g') REPO_NAME=fledge-service-${SERVICE_NAME} clean () { @@ -35,10 +36,12 @@ install_binary_file () { cd /tmp/${REPO_NAME}; ./build.sh -DFLEDGE_INSTALL=${FLEDGE_ROOT}; cd build && make install; else if [[ $os_name == *"Red Hat"* || $os_name == *"CentOS"* ]]; then - set +e - source scl_source enable rh-postgresql13 - source scl_source enable devtoolset-7 - set -e + if [[ ${os_version} -eq "7" ]]; then + set +e + source scl_source enable rh-postgresql13 + source scl_source enable devtoolset-7 + set -e + fi fi mkdir -p /tmp/${REPO_NAME}/build; cd /tmp/${REPO_NAME}/build; cmake -DFLEDGE_INSTALL=${FLEDGE_ROOT} ..; make -j4 && make install; cd - fi From 40b2ae44e4c355cdb0110b37eadd9c3d76e92801 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 10 Jan 2023 16:08:38 +0530 Subject: [PATCH 051/499] package based setup script updated as per CentOS Stream9 Signed-off-by: ashish-jabble --- tests/system/python/scripts/package/setup | 43 ++++++++++++----------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/tests/system/python/scripts/package/setup b/tests/system/python/scripts/package/setup index f26cff35ea..6d61f86ed2 100755 --- a/tests/system/python/scripts/package/setup +++ b/tests/system/python/scripts/package/setup @@ -15,46 +15,47 @@ PACKAGE_BUILD_VERSION=$1 [[ -z "${PACKAGE_BUILD_VERSION}" ]] && echo "Build Version not found." && exit 1 -OS_NAME=`(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g')` +OS_NAME=$(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g') ID=$(cat /etc/os-release | grep -w ID | cut -f2 -d"=" | tr -d '"') -UNAME=`uname -m` +UNAME=$(uname -m) VERSION_ID=$(cat /etc/os-release | grep -w VERSION_ID | cut -f2 -d"=" | tr -d '"') -echo "version id is "${VERSION_ID} +echo "Version ID is ${VERSION_ID}" if [[ ${OS_NAME} == *"Red Hat"* || ${OS_NAME} == *"CentOS"* ]]; then - ID="${ID}7" - echo "Build version is "${PACKAGE_BUILD_VERSION} - echo "ID is "${ID} - echo "uname is "${UNAME} - + if [[ ${VERSION_ID} -eq "7" ]]; then ARCHIVE_PKG_OS="${ID}7"; else ARCHIVE_PKG_OS="${ID}-stream-9"; fi + + echo "Build version is ${PACKAGE_BUILD_VERSION}" + echo "ID is ${ID} and Archive package OS is ${ARCHIVE_PKG_OS}" + echo "Uname is ${UNAME}" + sudo cp -f /etc/yum.repos.d/fledge.repo /etc/yum.repos.d/fledge.repo.bak | true - sudo yum update -y - echo "==================== DONE update, upgrade ============================" - + # Configure Fledge repo echo -e "[fledge]\n\ name=fledge Repository\n\ -baseurl=http://archives.fledge-iot.org/${PACKAGE_BUILD_VERSION}/${ID}/${UNAME}/\n\ +baseurl=http://archives.fledge-iot.org/${PACKAGE_BUILD_VERSION}/${ARCHIVE_PKG_OS}/${UNAME}/\n\ enabled=1\n\ gpgkey=http://archives.fledge-iot.org/RPM-GPG-KEY-fledge\n\ gpgcheck=1" | sudo tee /etc/yum.repos.d/fledge.repo - - sudo yum update -y - + # Install prerequisites if [[ ${ID} = "centos" ]]; then - sudo yum install -y centos-release-scl-rh - sudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm + if [[ ${VERSION_ID} -eq "7" ]]; then + sudo yum install -y centos-release-scl-rh + sudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm + fi elif [[ ${ID} = "rhel" ]]; then sudo yum-config-manager --enable 'Red Hat Software Collections RPMs for Red Hat Enterprise Linux 7 Server from RHUI' fi - sudo yum update -y - + + sudo yum -y check-update && sudo yum -y update + echo "==================== DONE check-update, update ============================" + time sudo yum install -y fledge echo "==================== DONE INSTALLING Fledge ==================" - - if [ "${FLEDGE_ENVIRONMENT}" != "docker" ]; then + + if [ "${FLEDGE_ENVIRONMENT}" != "docker" ]; then time sudo yum install -y fledge-gui echo "==================== DONE INSTALLING Fledge GUI ======================" fi From 7c3006867825481c4bea3ef5ab8daa9d3508228b Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Tue, 10 Jan 2023 18:08:58 +0530 Subject: [PATCH 052/499] Code cleanup and minor changes Signed-off-by: Amandeep Singh Arora --- C/common/include/storage_client.h | 4 +- C/common/storage_client.cpp | 31 +++++-- C/services/storage/include/storage_registry.h | 9 +- C/services/storage/storage_api.cpp | 2 - C/services/storage/storage_registry.cpp | 87 +++---------------- 5 files changed, 45 insertions(+), 88 deletions(-) mode change 100644 => 100755 C/common/include/storage_client.h mode change 100644 => 100755 C/common/storage_client.cpp diff --git a/C/common/include/storage_client.h b/C/common/include/storage_client.h old mode 100644 new mode 100755 index 58136c08ab..21a9dc5104 --- a/C/common/include/storage_client.h +++ b/C/common/include/storage_client.h @@ -88,8 +88,8 @@ class StorageClient { const std::string& callbackUrl); bool registerTableNotification(const std::string& tableName, const std::string& key, std::vector keyValues, const std::string& operation, const std::string& callbackUrl); - bool unregisterTableNotification(const std::string& tableName, - const std::string& callbackUrl); + bool unregisterTableNotification(const std::string& tableName, const std::string& key, + std::vector keyValues, const std::string& operation, const std::string& callbackUrl); void registerManagement(ManagementClient *mgmnt) { m_management = mgmnt; }; bool createSchema(const std::string&); diff --git a/C/common/storage_client.cpp b/C/common/storage_client.cpp old mode 100644 new mode 100755 index f189159e3a..2e7b916e6c --- a/C/common/storage_client.cpp +++ b/C/common/storage_client.cpp @@ -1304,7 +1304,7 @@ bool StorageClient::registerTableNotification(const string& tableName, const str tableName, res->status_code, resultPayload.str()); - m_logger->error("/storage/table/interest/%s: %s", + m_logger->error("POST /storage/table/interest/%s: %s", urlEncode(tableName).c_str(), res->status_code.c_str()); return false; @@ -1319,19 +1319,34 @@ bool StorageClient::registerTableNotification(const string& tableName, const str * Unregister interest for a table name * * @param tableName The table name to unregister interest in - * @param callbackUrl The callback URL provided in registration. + * @param tableKey The key of interest in the table + * @param tableKeyValues The key values of interest + * @param tableOperation The table operation of interest (insert/update/delete) + * @param callbackUrl The callback URL to send change data * @return True on success, false otherwise. */ -bool StorageClient::unregisterTableNotification(const string& tableName, - const string& callbackUrl) +bool StorageClient::unregisterTableNotification(const string& tableName, const string& key, std::vector keyValues, + const string& operation, const string& callbackUrl) { try { + ostringstream keyValuesStr; + for (auto & s : keyValues) + { + keyValuesStr << "\"" << s << "\""; + if (&s != &keyValues.back()) + keyValuesStr << ", "; + } + ostringstream convert; - convert << "{ \"url\" : \""; - convert << callbackUrl; - convert << "\" }"; + convert << "{ "; + convert << "\"url\" : \"" << callbackUrl << "\", "; + convert << "\"key\" : \"" << key << "\", "; + convert << "\"values\" : [" << keyValuesStr.str() << "], "; + convert << "\"operation\" : \"" << operation << "\" "; + convert << "}"; + auto res = this->getHttpClient()->request("DELETE", "/storage/table/interest/" + urlEncode(tableName), convert.str()); @@ -1345,6 +1360,8 @@ bool StorageClient::unregisterTableNotification(const string& tableName, tableName, res->status_code, resultPayload.str()); + m_logger->error("DELETE /storage/table/interest/%s: %s", + urlEncode(tableName).c_str(), res->status_code.c_str()); return false; } catch (exception& ex) diff --git a/C/services/storage/include/storage_registry.h b/C/services/storage/include/storage_registry.h index 242a4a9c19..8297f434a3 100755 --- a/C/services/storage/include/storage_registry.h +++ b/C/services/storage/include/storage_registry.h @@ -33,22 +33,23 @@ class StorageRegistry { void unregisterAsset(const std::string& asset, const std::string& url); void process(const std::string& payload); void processTableInsert(const std::string& tableName, const std::string& payload); - TableRegistration* parseTableSubscriptionPayload(const std::string& payload); void registerTable(const std::string& table, const std::string& url); void unregisterTable(const std::string& table, const std::string& url); - void processInsert(const std::string& payload); - void insertTestTableReg(); - void removeTestTableReg(int n); void run(); private: void processPayload(char *payload); void sendPayload(const std::string& url, char *payload); void filterPayload(const std::string& url, char *payload, const std::string& asset); void processInsert(char *tableName, char *payload); + TableRegistration* parseTableSubscriptionPayload(const std::string& payload); + void insertTestTableReg(); + void removeTestTableReg(int n); + typedef std::pair Item; typedef std::tuple TableItem; REGISTRY m_registrations; REGISTRY_TABLE m_tableRegistrations; + std::queue m_queue; std::queue diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp index 4a07bfb292..64e9b235ef 100755 --- a/C/services/storage/storage_api.cpp +++ b/C/services/storage/storage_api.cpp @@ -557,9 +557,7 @@ string responsePayload; int rval = plugin->commonInsert(tableName, payload); if (rval != -1) { - PRINT_FUNC; registry.processTableInsert(tableName, payload); - PRINT_FUNC; responsePayload = "{ \"response\" : \"inserted\", \"rows_affected\" : "; responsePayload += to_string(rval); responsePayload += " }"; diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp index ea68d45785..a5ec6e9bca 100755 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -104,16 +104,7 @@ StorageRegistry::process(const string& payload) void StorageRegistry::processTableInsert(const string& tableName, const string& payload) { - std::ostringstream ss; - ss << std::this_thread::get_id(); - Logger::getLogger()->info("StorageRegistry::processTableInsert(): START: thread_id = %s", ss.str().c_str()); - - // Logger::getLogger()->info("StorageRegistry::processTableInsert(): tableName=%s, payload=%s", tableName.c_str(), payload.c_str()); - if (m_tableRegistrations.size() == 0) - { - // insertTestTableReg(); // TODO: remove this; only for testing - Logger::getLogger()->info("StorageRegistry::processTableInsert(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); - } + Logger::getLogger()->debug("StorageRegistry::processTableInsert(): tableName=%s, payload=%s", tableName.c_str(), payload.c_str()); if (m_tableRegistrations.size() > 0) { @@ -134,14 +125,6 @@ StorageRegistry::processTableInsert(const string& tableName, const string& paylo m_cv.notify_all(); } } - -#if 0 - PRINT_FUNC; - int n = (std::hash{}(payload) % 5) + 1; - removeTestTableReg(n); // (n); // TODO: remove this; only for testing - PRINT_FUNC; - Logger::getLogger()->info("StorageRegistry::processTableInsert(): removeTestTableReg(%d): m_tableRegistrations.size()=%d", n, m_tableRegistrations.size()); -#endif } @@ -196,7 +179,6 @@ TableRegistration* StorageRegistry::parseTableSubscriptionPayload(const string& Logger::getLogger()->error("StorageRegistry::parseTableSubscriptionPayload(): Parse error in subscription request payload"); return NULL; } - if (!doc.HasMember("url")) { Logger::getLogger()->error("StorageRegistry::parseTableSubscriptionPayload(): subscription request doesn't have url field"); @@ -246,8 +228,6 @@ StorageRegistry::registerTable(const string& table, const string& payload) { TableRegistration *reg = parseTableSubscriptionPayload(payload); - // lock_guard guard(m_qMutex); - if (!reg) { Logger::getLogger()->info("StorageRegistry::registerTable(): Unable to register invalid Registration entry for table %s, payload=%s", table.c_str(), payload.c_str()); @@ -257,9 +237,6 @@ StorageRegistry::registerTable(const string& table, const string& payload) lock_guard guard(m_tableRegistrationsMutex); Logger::getLogger()->info("*** StorageRegistry::registerTable(): Adding registration entry for table %s", table.c_str()); m_tableRegistrations.push_back(pair(new string(table), reg)); - Logger::getLogger()->info("StorageRegistry::registerTable(): Registration entry added for table %s", table.c_str()); - - delete reg; } /** @@ -271,11 +248,7 @@ StorageRegistry::registerTable(const string& table, const string& payload) void StorageRegistry::unregisterTable(const string& table, const string& payload) { - PRINT_FUNC; TableRegistration *reg = parseTableSubscriptionPayload(payload); - PRINT_FUNC; - - // lock_guard guard(m_qMutex); if (!reg) { @@ -283,27 +256,21 @@ StorageRegistry::unregisterTable(const string& table, const string& payload) "invalid Registration entry for table %s, payload=%s", table.c_str(), payload.c_str()); return; } - PRINT_FUNC; lock_guard guard(m_tableRegistrationsMutex); - PRINT_FUNC; + Logger::getLogger()->info("StorageRegistry::unregisterTable(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); for (auto it = m_tableRegistrations.begin(); it != m_tableRegistrations.end(); ) { - PRINT_FUNC; TableRegistration *reg_it = it->second; - PRINT_FUNC; if (table.compare(*(it->first)) == 0 && reg->url.compare(reg_it->url)==0 && reg->key.compare(reg_it->key)==0 && reg->operation.compare(reg_it->operation)==0) { - PRINT_FUNC; // Either no key is to be matched or a key is to be matched against a possible set of values - if (reg->key.size()==0 || (reg->key.size() && reg->keyValues == reg_it->keyValues)) + if (reg->key.size()==0 || (reg->key.size()>0 && reg->keyValues == reg_it->keyValues)) { - PRINT_FUNC; - Logger::getLogger()->info("*** StorageRegistry::unregisterTable(): Removing registration for table %s and url %s", table, reg->key.c_str()); delete it->first; delete it->second; it = m_tableRegistrations.erase(it); @@ -311,19 +278,15 @@ StorageRegistry::unregisterTable(const string& table, const string& payload) } else { - PRINT_FUNC; ++it; } } else { - PRINT_FUNC; ++it; } } - PRINT_FUNC; delete reg; - PRINT_FUNC; } @@ -554,78 +517,53 @@ ostringstream convert; void StorageRegistry::processInsert(char *tableName, char *payload) { - Logger::getLogger()->error("****** StorageRegistry::processInsert(): Handling for table:%s, payload=%s", tableName, payload); - Logger::getLogger()->info("StorageRegistry::processInsert(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); - if (m_tableRegistrations.size() == 0) - { - // insertTestTableReg(); // TODO: remove this; only for testing - Logger::getLogger()->info("StorageRegistry::processTableInsert(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); - } + Logger::getLogger()->debug("StorageRegistry::processInsert(): Handling for table:%s, payload=%s", tableName, payload); + Logger::getLogger()->debug("StorageRegistry::processInsert(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); Document payloadDoc; payloadDoc.Parse(payload); - PRINT_FUNC; if (payloadDoc.HasParseError()) { Logger::getLogger()->error("StorageRegistry::processInsert(): Parse error in payload for table:%s, payload=%s", tableName, payload); return; } - PRINT_FUNC; lock_guard guard(m_tableRegistrationsMutex); for (auto & reg : m_tableRegistrations) { - PRINT_FUNC; if (reg.first->compare(tableName) != 0) continue; - PRINT_FUNC; TableRegistration *tblreg = reg.second; - PRINT_FUNC; - // If key is empty string, no need to match key/value pair in payload // Also operation must be "insert" for initial implementation if (tblreg->operation.compare("insert") != 0) { - PRINT_FUNC; continue; } - PRINT_FUNC; - bool match = (tblreg->key.size()==0); - PRINT_FUNC; if (!match && payloadDoc.HasMember(tblreg->key.c_str()) && payloadDoc[tblreg->key.c_str()].IsString()) { string payloadKeyValue = payloadDoc[tblreg->key.c_str()].GetString(); - PRINT_FUNC; if (std::find(tblreg->keyValues.begin(), tblreg->keyValues.end(), payloadKeyValue) != tblreg->keyValues.end()) match = true; - PRINT_FUNC; } - PRINT_FUNC; if(match) { Logger::getLogger()->info("StorageRegistry::processInsert(): Sending matching payload: table=%s, url=%s, payload=%s", tableName, tblreg->url.c_str(), payload); sendPayload(tblreg->url, payload); } else - Logger::getLogger()->info("StorageRegistry::processInsert(): Ignoring non-matching payload: table=%s, payload=%s", tableName, payload); - - PRINT_FUNC; + Logger::getLogger()->debug("StorageRegistry::processInsert(): Ignoring non-matching payload: table=%s, payload=%s", tableName, payload); } - -#if 0 - PRINT_FUNC; - int n = (std::hash{}(payload) % 5) + 1; - removeTestTableReg(n); // (n); // TODO: remove this; only for testing - PRINT_FUNC; - Logger::getLogger()->info("StorageRegistry::processTableInsert(): removeTestTableReg(%d): m_tableRegistrations.size()=%d", n, m_tableRegistrations.size()); -#endif } +/** + * Test function to add some dummy/test table subscriptions + */ void StorageRegistry::insertTestTableReg() { string table1("log"); @@ -647,7 +585,11 @@ void StorageRegistry::insertTestTableReg() registerTable(table3, payload3); } - +/** + * Test function to remove a dummy/test table subscription + * + * @param n The subscription number to remove + */ void StorageRegistry::removeTestTableReg(int n) { string table1("log"); @@ -680,7 +622,6 @@ void StorageRegistry::removeTestTableReg(int n) Logger::getLogger()->error("StorageRegistry::removeTestTableReg(): unhandled value n=%d", n); break; } - PRINT_FUNC; } From a5047ceb963f3a0d37632096a907f8cbe4c0b292 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Tue, 10 Jan 2023 18:12:22 +0530 Subject: [PATCH 053/499] Restore file permissions Signed-off-by: Amandeep Singh Arora --- C/common/include/storage_client.h | 0 C/common/storage_client.cpp | 0 C/services/storage/include/storage_registry.h | 0 C/services/storage/storage_api.cpp | 0 C/services/storage/storage_registry.cpp | 0 5 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 C/common/include/storage_client.h mode change 100755 => 100644 C/common/storage_client.cpp mode change 100755 => 100644 C/services/storage/include/storage_registry.h mode change 100755 => 100644 C/services/storage/storage_api.cpp mode change 100755 => 100644 C/services/storage/storage_registry.cpp diff --git a/C/common/include/storage_client.h b/C/common/include/storage_client.h old mode 100755 new mode 100644 diff --git a/C/common/storage_client.cpp b/C/common/storage_client.cpp old mode 100755 new mode 100644 diff --git a/C/services/storage/include/storage_registry.h b/C/services/storage/include/storage_registry.h old mode 100755 new mode 100644 diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp old mode 100755 new mode 100644 diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp old mode 100755 new mode 100644 From eb0fbfde5c4d7a0830db8a1ed813095e04a372d7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 11 Jan 2023 11:23:42 +0530 Subject: [PATCH 054/499] removed PYTHONPATH dependency and added some utility methods in conftest.py Signed-off-by: ashish-jabble --- tests/system/python/conftest.py | 38 ++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index 2e388f5433..8611e555a1 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -18,7 +18,6 @@ from urllib.parse import quote from pathlib import Path import pytest -from fledge.common import utils __author__ = "Vaibhav Singhal" @@ -977,7 +976,40 @@ def start_north_as_service(request): return request.config.getoption("--start-north-as-service") +def read_os_release(): + """ General information to identifying the operating system """ + import ast + import re + os_details = {} + with open('/etc/os-release', encoding="utf-8") as f: + for line_number, line in enumerate(f, start=1): + line = line.rstrip() + if not line or line.startswith('#'): + continue + m = re.match(r'([A-Z][A-Z_0-9]+)=(.*)', line) + if m: + name, val = m.groups() + if val and val[0] in '"\'': + val = ast.literal_eval(val) + os_details.update({name: val}) + return os_details + + +def is_redhat_based(): + """ + To check if the Operating system is of Red Hat family or Not + Examples: + a) For an operating system with "ID=centos", an assignment of "ID_LIKE="rhel fedora"" is appropriate + b) For an operating system with "ID=ubuntu/raspbian", an assignment of "ID_LIKE=debian" is appropriate. + """ + os_release = read_os_release() + id_like = os_release.get('ID_LIKE') + if id_like is not None and any(x in id_like.lower() for x in ['centos', 'rhel', 'redhat', 'fedora']): + return True + return False + + def pytest_configure(): - pytest.OS_PLATFORM_DETAILS = utils.read_os_release() - pytest.IS_REDHAT = utils.is_redhat_based() + pytest.OS_PLATFORM_DETAILS = read_os_release() + pytest.IS_REDHAT = is_redhat_based() pytest.PKG_MGR = 'yum' if pytest.IS_REDHAT else 'apt' From a39edc6848fa1b5da7c479ed8e887e21987db258 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 11 Jan 2023 16:59:00 +0530 Subject: [PATCH 055/499] repos configure CentOS Stream9 supported via API Signed-off-by: ashish-jabble --- .../services/core/api/repos/configure.py | 83 ++++++++++--------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/python/fledge/services/core/api/repos/configure.py b/python/fledge/services/core/api/repos/configure.py index 74dadba46e..13c885d9e3 100644 --- a/python/fledge/services/core/api/repos/configure.py +++ b/python/fledge/services/core/api/repos/configure.py @@ -12,7 +12,7 @@ from aiohttp import web from fledge.common.common import _FLEDGE_ROOT -from fledge.common import logger +from fledge.common import logger, utils __author__ = "Ashish Jabble" @@ -49,46 +49,56 @@ async def add_package_repo(request: web.Request) -> web.Response: raise ValueError('url param is required') _platform = platform.platform() - pkg_mgt = 'yum' if 'centos' in _platform or 'redhat' in _platform else 'apt' v_list = ['nightly', 'latest'] if not (version in v_list or version.startswith('fixes/')): if str(version).count('.') != 2: - raise ValueError('Invalid version; it should be latest, nightly or a valid semantic version X.Y.Z i.e. major.minor.patch') + raise ValueError('Invalid version; it should be latest, ' + 'nightly or a valid semantic version X.Y.Z i.e. major.minor.patch') - if 'x86_64-with-Ubuntu-18.04' in _platform: - os_name = "ubuntu1804" - architecture = "x86_64" - extra_commands = "" - elif 'x86_64-with-glib' in _platform: - os_name = "ubuntu2004" - architecture = "x86_64" - extra_commands = "" - elif 'armv7l-with-debian' in _platform: - os_name = "buster" - architecture = "armv7l" - extra_commands = "" - elif 'armv7l-with-glibc' in _platform: - os_name = "bullseye" - architecture = "armv7l" - extra_commands = "" - elif 'aarch64-with-Ubuntu-18.04' in _platform: - os_name = "ubuntu1804" - architecture = "aarch64" - extra_commands = "" - elif 'x86_64-with-redhat' in _platform: - os_name = "rhel7" - architecture = "x86_64" - extra_commands = "sudo yum-config-manager --enable 'Red Hat Enterprise Linux Server 7 RHSCL (RPMs)'" - elif 'aarch64-with-Mendel' in _platform: - os_name = "mendel" - architecture = "aarch64" - extra_commands = "" - elif 'x86_64-with-centos' in _platform: - os_name = "centos7" - architecture = "x86_64" - extra_commands = "sudo yum install -y centos-release-scl-rh epel-release" + if utils.is_redhat_based(): + pkg_mgt = 'yum' + if 'x86_64-with-redhat' in _platform: + os_name = "rhel7" + architecture = "x86_64" + extra_commands = "sudo yum-config-manager --enable 'Red Hat Enterprise Linux Server 7 RHSCL (RPMs)'" + elif 'x86_64-with-centos' in _platform: + os_name = "centos7" + architecture = "x86_64" + extra_commands = "sudo yum install -y centos-release-scl-rh epel-release" + elif 'x86_64-with-glibc' in _platform: + os_name = "centos-stream-9" + architecture = "x86_64" + extra_commands = "" + else: + raise ValueError("{} is not supported".format(_platform)) else: - raise ValueError("{} is not supported".format(_platform)) + pkg_mgt = 'apt' + if 'x86_64-with-Ubuntu-18.04' in _platform: + os_name = "ubuntu1804" + architecture = "x86_64" + extra_commands = "" + elif 'x86_64-with-glib' in _platform: + os_name = "ubuntu2004" + architecture = "x86_64" + extra_commands = "" + elif 'armv7l-with-debian' in _platform: + os_name = "buster" + architecture = "armv7l" + extra_commands = "" + elif 'armv7l-with-glibc' in _platform: + os_name = "bullseye" + architecture = "armv7l" + extra_commands = "" + elif 'aarch64-with-Ubuntu-18.04' in _platform: + os_name = "ubuntu1804" + architecture = "aarch64" + extra_commands = "" + elif 'aarch64-with-Mendel' in _platform: + os_name = "mendel" + architecture = "aarch64" + extra_commands = "" + else: + raise ValueError("{} is not supported".format(_platform)) stdout_file_path = _FLEDGE_ROOT + "/data/configure_repo_output.txt" if pkg_mgt == 'yum': @@ -130,4 +140,3 @@ async def add_package_repo(request: web.Request) -> web.Response: else: return web.json_response({"message": "Package repository configured successfully.", "output_log": stdout_file_path}) - From f591034d9a0624873b75f71f4dc0f01a6658168d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 12 Jan 2023 18:46:59 +0530 Subject: [PATCH 056/499] fledge start/stop as service without sudo Signed-off-by: ashish-jabble --- extras/scripts/fledge.service | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/extras/scripts/fledge.service b/extras/scripts/fledge.service index f4c25fe78b..4f79f14d73 100755 --- a/extras/scripts/fledge.service +++ b/extras/scripts/fledge.service @@ -67,11 +67,19 @@ get_pid() { } fledge_start() { - sudo -u ${FLEDGE_USER} "${FLEDGE_ROOT}/bin/fledge" start > /dev/null + if [ "$IS_RHEL" = "" ]; then + sudo -u ${FLEDGE_USER} "${FLEDGE_ROOT}/bin/fledge" start > /dev/null + else + "${FLEDGE_ROOT}/bin/fledge" start > /dev/null + fi } fledge_stop() { - sudo -u ${FLEDGE_USER} "${FLEDGE_ROOT}/bin/fledge" stop > /dev/null + if [ "$IS_RHEL" = "" ]; then + sudo -u ${FLEDGE_USER} "${FLEDGE_ROOT}/bin/fledge" stop > /dev/null + else + "${FLEDGE_ROOT}/bin/fledge" stop > /dev/null + fi } case "$1" in From afbf89cc8a5210d2d797862aeedf2d91dfaabb6d Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 13 Jan 2023 11:13:10 +0000 Subject: [PATCH 057/499] FOGL-7062 Add support to sqlitelb to create readings table (#863) * FOGL-7058 Resolve warnign on Ubuntu 18.04 platform Signed-off-by: Mark Riddoch * Initial changes Signed-off-by: Mark Riddoch * FOGL-7062 Create readings table and index if the readings.db is not there when we start Signed-off-by: Mark Riddoch * Fix typo Signed-off-by: Mark Riddoch * FOGL-7062 Remove debug option Signed-off-by: Mark Riddoch * Enable exist on stacktrace Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch --- .../storage/sqlitelb/common/connection.cpp | 164 ++++++++++++++++++ C/services/storage/storage.cpp | 3 +- 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/C/plugins/storage/sqlitelb/common/connection.cpp b/C/plugins/storage/sqlitelb/common/connection.cpp index b5848f16cc..c9a356caaf 100644 --- a/C/plugins/storage/sqlitelb/common/connection.cpp +++ b/C/plugins/storage/sqlitelb/common/connection.cpp @@ -524,6 +524,7 @@ Connection::Connection() const char *sqlStmt = attachDb.coalesce(); + zErrMsg = NULL; // Exec the statement rc = SQLexec(dbHandle, sqlStmt, @@ -551,6 +552,24 @@ Connection::Connection() } //Release sqlStmt buffer delete[] sqlStmt; + + bool initialiseReadings = false; + if (access(dbPathReadings.c_str(), R_OK) == -1) + { + sqlite3 *dbHandle; + // Readings do not exist so set flag to initialise + rc = sqlite3_open(dbPathReadings.c_str(), &dbHandle); + if(rc != SQLITE_OK) + { + } + else + { + // Enables the WAL feature + rc = sqlite3_exec(dbHandle, DB_CONFIGURATION, NULL, NULL, NULL); + } + sqlite3_close(dbHandle); + initialiseReadings = true; + } // Attach readings database SQLBuffer attachReadingsDb; @@ -587,6 +606,151 @@ Connection::Connection() //Release sqlStmt buffer delete[] sqlReadingsStmt; + if (initialiseReadings) + { + // Would really like to run an external script here, but until we have that + // worked out we have the SQL needed to create the table and indexes + + // Need to initialise the readings + SQLBuffer initReadings; + initReadings.append("CREATE TABLE readings.readings ("); + initReadings.append("id INTEGER PRIMARY KEY AUTOINCREMENT,"); + initReadings.append("asset_code character varying(50) NOT NULL,"); + initReadings.append("reading JSON NOT NULL DEFAULT '{}',"); + initReadings.append("user_ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')),"); + initReadings.append("ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW'))"); + initReadings.append(");"); + + const char *sqlReadingsStmt = initReadings.coalesce(); + + // Exec the statement + zErrMsg = NULL; + rc = SQLexec(dbHandle, + sqlReadingsStmt, + NULL, + NULL, + &zErrMsg); + + // Check result + if (rc != SQLITE_OK) + { + const char* errMsg = "Failed to create 'readings' table, "; + Logger::getLogger()->error("%s '%s': error %s", + errMsg, + sqlReadingsStmt, + zErrMsg); + connectErrorTime = time(0); + + sqlite3_free(zErrMsg); + sqlite3_close_v2(dbHandle); + } + else + { + Logger::getLogger()->info("Initialised readings database"); + } + //Release sqlStmt buffer + delete[] sqlReadingsStmt; + + SQLBuffer index1; + index1.append("CREATE INDEX readings.fki_readings_fk1 ON readings (asset_code, user_ts desc);"); + + const char *sqlIndex1Stmt = index1.coalesce(); + + // Exec the statement + zErrMsg = NULL; + rc = SQLexec(dbHandle, + sqlIndex1Stmt, + NULL, + NULL, + &zErrMsg); + + // Check result + if (rc != SQLITE_OK) + { + const char* errMsg = "Failed to create 'readings' index, "; + Logger::getLogger()->error("%s '%s': error %s", + errMsg, + sqlIndex1Stmt, + zErrMsg); + connectErrorTime = time(0); + + sqlite3_free(zErrMsg); + sqlite3_close_v2(dbHandle); + } + else + { + Logger::getLogger()->info("Initialised readings database"); + } + //Release sqlStmt buffer + delete[] sqlIndex1Stmt; + + SQLBuffer index2; + index2.append("CREATE INDEX readings.readings_ix2 ON readings (asset_code);"); + + const char *sqlIndex2Stmt = index2.coalesce(); + + // Exec the statement + zErrMsg = NULL; + rc = SQLexec(dbHandle, + sqlIndex2Stmt, + NULL, + NULL, + &zErrMsg); + + // Check result + if (rc != SQLITE_OK) + { + const char* errMsg = "Failed to create 'readings' index, "; + Logger::getLogger()->error("%s '%s': error %s", + errMsg, + sqlIndex2Stmt, + zErrMsg); + connectErrorTime = time(0); + + sqlite3_free(zErrMsg); + sqlite3_close_v2(dbHandle); + } + else + { + Logger::getLogger()->info("Initialised readings database"); + } + //Release sqlStmt buffer + delete[] sqlIndex2Stmt; + + SQLBuffer index3; + index3.append("CREATE INDEX readings.readings_ix3 ON readings (user_ts);"); + + const char *sqlIndex3Stmt = index3.coalesce(); + + // Exec the statement + zErrMsg = NULL; + rc = SQLexec(dbHandle, + sqlIndex3Stmt, + NULL, + NULL, + &zErrMsg); + + // Check result + if (rc != SQLITE_OK) + { + const char* errMsg = "Failed to create 'readings' index, "; + Logger::getLogger()->error("%s '%s': error %s", + errMsg, + sqlIndex3Stmt, + zErrMsg); + connectErrorTime = time(0); + + sqlite3_free(zErrMsg); + sqlite3_close_v2(dbHandle); + } + else + { + Logger::getLogger()->info("Initialised readings database"); + } + //Release sqlStmt buffer + delete[] sqlIndex3Stmt; + } + // Enable the WAL for the readings DB rc = sqlite3_exec(dbHandle, dbConfiguration.c_str(),NULL, NULL, &zErrMsg); if (rc != SQLITE_OK) diff --git a/C/services/storage/storage.cpp b/C/services/storage/storage.cpp index 4f3439b937..781ad34b31 100644 --- a/C/services/storage/storage.cpp +++ b/C/services/storage/storage.cpp @@ -26,7 +26,8 @@ #include #include -#define NO_EXIT_STACKTRACE 0 // Set to 1 to make storage loop after stacktrace +#define NO_EXIT_STACKTRACE 0 // Set to 1 to make storage loop after stacktrace + // This is useful to be able to attach a debbugger extern int makeDaemon(void); From 21ccaf5f54499e1d623d54a0c57e474473a2c848 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Fri, 13 Jan 2023 21:15:49 +0530 Subject: [PATCH 058/499] FOGL-6499: Some performance improvements Signed-off-by: Amandeep Singh Arora --- C/common/asset_tracking.cpp | 4 +- C/common/datapoint.cpp | 2 +- C/common/pythonreading.cpp | 8 +- C/common/pythonreadingset.cpp | 4 +- C/common/reading.cpp | 130 ++++++++++++------ .../ingest_callback_pymodule.cpp | 4 +- .../ingest_callback_pymodule.cpp | 3 +- C/services/south/ingest.cpp | 2 +- 8 files changed, 103 insertions(+), 54 deletions(-) mode change 100644 => 100755 C/common/asset_tracking.cpp mode change 100644 => 100755 C/common/datapoint.cpp mode change 100644 => 100755 C/common/reading.cpp mode change 100644 => 100755 C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp diff --git a/C/common/asset_tracking.cpp b/C/common/asset_tracking.cpp old mode 100644 new mode 100755 index 4329c540d0..5a8c0b0153 --- a/C/common/asset_tracking.cpp +++ b/C/common/asset_tracking.cpp @@ -54,8 +54,8 @@ void AssetTracker::populateAssetTrackingCache(string /*plugin*/, string /*event* { assetTrackerTuplesCache.insert(rec); - Logger::getLogger()->debug("Added asset tracker tuple to cache: '%s'", - rec->assetToString().c_str()); + // Logger::getLogger()->debug("Added asset tracker tuple to cache: '%s'", + // rec->assetToString().c_str()); } delete (&vec); } diff --git a/C/common/datapoint.cpp b/C/common/datapoint.cpp old mode 100644 new mode 100755 index 71160c379b..2d8aafc85a --- a/C/common/datapoint.cpp +++ b/C/common/datapoint.cpp @@ -216,7 +216,7 @@ DatapointValue::DatapointValue(const DatapointValue& obj) Datapoint *d = *it; // Add new allocated datapoint to the vector // using copy constructor - m_value.dpa->push_back(new Datapoint(*d)); + m_value.dpa->emplace_back(new Datapoint(*d)); } break; diff --git a/C/common/pythonreading.cpp b/C/common/pythonreading.cpp index 7c31068f6d..7df823472f 100755 --- a/C/common/pythonreading.cpp +++ b/C/common/pythonreading.cpp @@ -105,13 +105,13 @@ PythonReading::PythonReading(PyObject *pyReading) // or reading['ema'] if (PyUnicode_Check(dKey)) { - m_values.push_back(new Datapoint( + m_values.emplace_back(new Datapoint( string(PyUnicode_AsUTF8(dKey)), *dataPoint)); } else { - m_values.push_back(new Datapoint( + m_values.emplace_back(new Datapoint( string(PyBytes_AsString(dKey)), *dataPoint)); } @@ -231,7 +231,7 @@ DatapointValue *PythonReading::getDatapointValue(PyObject *value) DatapointValue *dpv = getDatapointValue(dValue); if (dpv) { - values->push_back(new Datapoint(string(PyBytes_AsString(dKey)), *dpv)); + values->emplace_back(new Datapoint(string(PyBytes_AsString(dKey)), *dpv)); // Remove temp objects delete dpv; } @@ -286,7 +286,7 @@ DatapointValue *PythonReading::getDatapointValue(PyObject *value) DatapointValue *dpv = getDatapointValue(val); if (dpv) { - values->push_back(new Datapoint(string(PyBytes_AsString(key)), *dpv)); + values->emplace_back(new Datapoint(string(PyBytes_AsString(key)), *dpv)); // Remove temp objects delete dpv; } diff --git a/C/common/pythonreadingset.cpp b/C/common/pythonreadingset.cpp index a0d2d4c24d..2bd00db0fc 100755 --- a/C/common/pythonreadingset.cpp +++ b/C/common/pythonreadingset.cpp @@ -117,7 +117,7 @@ PythonReadingSet::PythonReadingSet(PyObject *set) m_readings.push_back(reading); m_count++; m_last_id = reading->getId(); - Logger::getLogger()->debug("PythonReadingSet c'tor: LIST: reading->toJSON()='%s' ", reading->toJSON().c_str()); + // Logger::getLogger()->debug("PythonReadingSet c'tor: LIST: reading->toJSON()='%s' ", reading->toJSON().c_str()); } } else if (PyDict_Check(set)) @@ -129,7 +129,7 @@ PythonReadingSet::PythonReadingSet(PyObject *set) m_readings.push_back(reading); m_count++; m_last_id = reading->getId(); - Logger::getLogger()->debug("PythonReadingSet c'tor: DICT: reading->toJSON()=%s", reading->toJSON().c_str()); + // Logger::getLogger()->debug("PythonReadingSet c'tor: DICT: reading->toJSON()=%s", reading->toJSON().c_str()); } } else diff --git a/C/common/reading.cpp b/C/common/reading.cpp old mode 100644 new mode 100755 index 2826ce4c42..5654f2fc5a --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -88,7 +88,7 @@ Reading::Reading(const Reading& orig) : m_asset(orig.m_asset), { for (auto it = orig.m_values.cbegin(); it != orig.m_values.cend(); it++) { - m_values.push_back(new Datapoint(**it)); + m_values.emplace_back(new Datapoint(**it)); } } @@ -375,52 +375,98 @@ void Reading::setUserTimestamp(const string& timestamp) */ void Reading::stringToTimestamp(const string& timestamp, struct timeval *ts) { - char date_time [DATE_TIME_BUFFER_LEN]; - - strcpy (date_time, timestamp.c_str()); - - struct tm tm; - memset(&tm, 0, sizeof(struct tm)); - strptime(date_time, "%Y-%m-%d %H:%M:%S", &tm); - // Convert time to epoch - mktime assumes localtime so most adjust for that - ts->tv_sec = mktime(&tm); - extern long timezone; - ts->tv_sec -= timezone; - - // Now process the fractional seconds - const char *ptr = date_time; - while (*ptr && *ptr != '.') - ptr++; - if (*ptr) - { - char *eptr; - ts->tv_usec = strtol(ptr + 1, &eptr, 10); - int digits = eptr - (ptr + 1); // Number of digits we have - while (digits < 6) + char date_time [DATE_TIME_BUFFER_LEN]; + + strcpy (date_time, timestamp.c_str()); + + static char cached_timestamp_upto_sec[32] = ""; + static unsigned long cached_sec_since_epoch = 0; + + // auto start = std::chrono::high_resolution_clock::now(); + const int timestamp_str_len_till_sec = 19; + char timestamp_sec[32]; + strncpy(timestamp_sec, date_time, timestamp_str_len_till_sec); + timestamp_sec[timestamp_str_len_till_sec] = '\0'; + if(strlen(cached_timestamp_upto_sec) && cached_sec_since_epoch && (strncmp(timestamp_sec, cached_timestamp_upto_sec, timestamp_str_len_till_sec) == 0)) { - digits++; - ts->tv_usec *= 10; + ts->tv_sec = cached_sec_since_epoch; + // Logger::getLogger()->info("Reading::stringToTimestamp(): cache hit: cached_timestamp_upto_sec=%s, cached_sec_since_epoch=%d", + // cached_timestamp_upto_sec, cached_sec_since_epoch); } - } - else - { - ts->tv_usec = 0; - } + else + { + // Logger::getLogger()->info("Reading::stringToTimestamp(): cache miss for: timestamp_sec=%s", + // timestamp_sec); - // Get the timezone from the string and convert to UTC - ptr = date_time + 10; // Skip date as it contains '-' characters - while (*ptr && *ptr != '-' && *ptr != '+') - ptr++; - if (*ptr) - { - int h, m; - int sign = (*ptr == '+' ? -1 : +1); - ptr++; - sscanf(ptr, "%02d:%02d", &h, &m); - ts->tv_sec += sign * ((3600 * h) + (60 * m)); - } + struct tm tm; + memset(&tm, 0, sizeof(struct tm)); + strptime(date_time, "%Y-%m-%d %H:%M:%S", &tm); + // Convert time to epoch - mktime assumes localtime so most adjust for that + ts->tv_sec = mktime(&tm); + + extern long timezone; + ts->tv_sec -= timezone; + + strncpy(cached_timestamp_upto_sec, timestamp_sec, timestamp_str_len_till_sec); + timestamp_sec[timestamp_str_len_till_sec] = '\0'; + cached_sec_since_epoch = ts->tv_sec; + } + + // Now process the fractional seconds + const char *ptr = date_time; + while (*ptr && *ptr != '.') + ptr++; + if (*ptr) + { + char *eptr; + ts->tv_usec = strtol(ptr + 1, &eptr, 10); + int digits = eptr - (ptr + 1); // Number of digits we have + while (digits < 6) + { + digits++; + ts->tv_usec *= 10; + } + } + else + { + ts->tv_usec = 0; + } + + // Get the timezone from the string and convert to UTC + ptr = date_time + 10; // Skip date as it contains '-' characters + while (*ptr && *ptr != '-' && *ptr != '+') + ptr++; + if (*ptr) + { + int h, m; + int sign = (*ptr == '+' ? -1 : +1); + ptr++; + sscanf(ptr, "%02d:%02d", &h, &m); + ts->tv_sec += sign * ((3600 * h) + (60 * m)); + } + +#if 0 + auto end = std::chrono::high_resolution_clock::now(); + std::ostringstream os; + os << std::chrono::duration_cast(end - start).count(); + /*Logger::getLogger()->error("Reading::stringToTimestamp(): took %s (%d) microseconds for timestamp=%s", + os.str().c_str(), std::stoul(os.str(), nullptr, 0), timestamp.c_str() ); */ + + acc_time_taken_usec += std::stoul(os.str(), nullptr, 0); + reading_count++; + // Logger::getLogger()->error("Reading::stringToTimestamp(): acc_time_taken_usec=%d microseconds for %d readings", acc_time_taken_usec, reading_count); + + if(reading_count >= 80000) + { + Logger::getLogger()->error("Reading::stringToTimestamp(): took %d usec for %d readings: an average of %f usec per reading", + acc_time_taken_usec, reading_count, float(acc_time_taken_usec)/reading_count); + acc_time_taken_usec = 0; + reading_count = 0; + } +#endif } + /** * Escape quotes etc to allow the string to be a property value within * a JSON document diff --git a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp index 6b78e44437..0ebb0fe561 100755 --- a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp +++ b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp @@ -125,11 +125,13 @@ void filter_plugin_ingest_fn(PyObject *ingest_callback, return; } +#if 0 PyObject* objectsRepresentation = PyObject_Repr(readingsObj); const char* s = PyUnicode_AsUTF8(objectsRepresentation); Logger::getLogger()->debug("filter_plugin_ingest_fn:L%d : Py2C: filtered readings=%s", __LINE__, s); Py_CLEAR(objectsRepresentation); - +#endif + PythonReadingSet *pyReadingSet = NULL; // Check we have a list of readings diff --git a/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp b/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp old mode 100644 new mode 100755 index 92a4a5b601..db0a66bf0b --- a/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp +++ b/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp @@ -79,11 +79,12 @@ void plugin_ingest_fn(PyObject *ingest_callback, PyObject *ingest_obj_ref_data, ingest_callback, ingest_obj_ref_data, readingsObj); return; } - +#if 0 PyObject* objectsRepresentation = PyObject_Repr(readingsObj); const char* s = PyUnicode_AsUTF8(objectsRepresentation); Logger::getLogger()->debug("%s:%s:L%d : Py2C: filtered readings=%s", __FILE__, __FUNCTION__, __LINE__, s); Py_CLEAR(objectsRepresentation); +#endif PythonReadingSet *pyReadingSet = NULL; diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 956e41fc1b..582be26a71 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -363,7 +363,7 @@ vector *fullQueue = 0; { lock_guard guard(m_qMutex); - m_queue->push_back(new Reading(reading)); + m_queue->emplace_back(new Reading(reading)); if (m_queue->size() >= m_queueSizeThreshold || m_running == false) { fullQueue = m_queue; From e869ea8f5e2bc6e46a79fc39c31e4d73355c8005 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Fri, 13 Jan 2023 21:16:32 +0530 Subject: [PATCH 059/499] Restore file permissions Signed-off-by: Amandeep Singh Arora --- C/common/asset_tracking.cpp | 0 C/common/datapoint.cpp | 0 C/common/reading.cpp | 0 .../python/async_ingest_pymodule/ingest_callback_pymodule.cpp | 0 4 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 C/common/asset_tracking.cpp mode change 100755 => 100644 C/common/datapoint.cpp mode change 100755 => 100644 C/common/reading.cpp mode change 100755 => 100644 C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp diff --git a/C/common/asset_tracking.cpp b/C/common/asset_tracking.cpp old mode 100755 new mode 100644 diff --git a/C/common/datapoint.cpp b/C/common/datapoint.cpp old mode 100755 new mode 100644 diff --git a/C/common/reading.cpp b/C/common/reading.cpp old mode 100755 new mode 100644 diff --git a/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp b/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp old mode 100755 new mode 100644 From 025e5df94846cb9f830bb523e245d702f0283c78 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 16 Jan 2023 12:11:53 +0530 Subject: [PATCH 060/499] python packages info added in support bundle Signed-off-by: ashish-jabble --- python/fledge/services/core/api/python_packages.py | 14 ++++++++++---- python/fledge/services/core/support.py | 7 +++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/api/python_packages.py b/python/fledge/services/core/api/python_packages.py index 54578452f1..4fa55f6f82 100644 --- a/python/fledge/services/core/api/python_packages.py +++ b/python/fledge/services/core/api/python_packages.py @@ -10,8 +10,9 @@ import asyncio from aiohttp import web +from typing import List + from fledge.common import logger -from fledge.services.core import connect from fledge.common.audit_logger import AuditLogger from fledge.services.core import connect @@ -28,6 +29,13 @@ """ _LOGGER = logger.setup(__name__, level=logging.INFO) + +def get_packages_installed() -> List: + package_ws = pkg_resources.WorkingSet() + installed_pkgs = [{'package': dist.project_name, 'version': dist.version} for dist in package_ws] + return installed_pkgs + + async def get_packages(request: web.Request) -> web.Response: """ Args: @@ -39,9 +47,7 @@ async def get_packages(request: web.Request) -> web.Response: :Example: curl -X GET http://localhost:8081/fledge/python/packages """ - package_ws = pkg_resources.WorkingSet() - installed_pkgs = [{'package':dist.project_name,'version': dist.version} for dist in package_ws] - return web.json_response({'packages': installed_pkgs}) + return web.json_response({'packages': get_packages_installed()}) async def install_package(request: web.Request) -> web.Response: diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index f230574140..30bfbd82fe 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -24,6 +24,7 @@ from fledge.common.configuration_manager import ConfigurationManager from fledge.common.plugin_discovery import PluginDiscovery from fledge.common.storage_client import payload_builder +from fledge.services.core.api.python_packages import get_packages_installed from fledge.services.core.api.service import get_service_records, get_service_installed from fledge.services.core.connect import * @@ -98,6 +99,7 @@ async def build(self): self.add_script_dir_content(pyz) self.add_package_log_dir_content(pyz) self.add_software_list(pyz, file_spec) + self.add_python_packages_list(pyz, file_spec) finally: pyz.close() except Exception as ex: @@ -293,5 +295,10 @@ def add_software_list(self, pyz, file_spec) -> None: temp_file = self._interim_file_path + "/" + "software-{}".format(file_spec) self.write_to_tar(pyz, temp_file, data) + def add_python_packages_list(self, pyz, file_spec) -> None: + data = {'packages': get_packages_installed()} + temp_file = self._interim_file_path + "/" + "python-packages-{}".format(file_spec) + self.write_to_tar(pyz, temp_file, data) + def exclude_pycache(self, tar_info): return None if '__pycache__' in tar_info.name else tar_info From 24d31fd313d30f67f9a1b46dcca9a1fe2d56d2b9 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 16 Jan 2023 12:18:52 +0530 Subject: [PATCH 061/499] lint fixes and docstring arg fixes Signed-off-by: ashish-jabble --- .../services/core/api/python_packages.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/python/fledge/services/core/api/python_packages.py b/python/fledge/services/core/api/python_packages.py index 4fa55f6f82..e834df349d 100644 --- a/python/fledge/services/core/api/python_packages.py +++ b/python/fledge/services/core/api/python_packages.py @@ -5,12 +5,11 @@ # FLEDGE_END import logging -import pkg_resources -import json import asyncio - -from aiohttp import web +import json from typing import List +import pkg_resources +from aiohttp import web from fledge.common import logger from fledge.common.audit_logger import AuditLogger @@ -53,7 +52,7 @@ async def get_packages(request: web.Request) -> web.Response: async def install_package(request: web.Request) -> web.Response: """ Args: - Request: '{ "package" : "numpy", + request: '{ "package" : "numpy", "version" : "1.2" #optional }' @@ -84,29 +83,30 @@ def get_installed_package_info(input_package): installed_package, installed_version = get_installed_package_info(input_package_name) if installed_package: - #Package already exists + # Package already exists _LOGGER.info("Package: {} Version: {} already installed.".format(installed_package, installed_version)) return web.HTTPConflict(reason="Package already installed.", - body=json.dumps({"message":"Package {} version {} already installed." + body=json.dumps({"message": "Package {} version {} already installed." .format(installed_package, installed_version)})) - #Package not found, install package via pip - pip_process = await asyncio.create_subprocess_shell('python3 -m pip install '+ install_args, + # Package not found, install package via pip + pip_process = await asyncio.create_subprocess_shell('python3 -m pip install ' + install_args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) stdout, stderr = await pip_process.communicate() if pip_process.returncode == 0: _LOGGER.info("Package: {} successfully installed", format(input_package_name)) try: - #Audit log entry: PIPIN + # Audit log entry: PIPIN storage_client = connect.get_storage_async() pip_audit_log = AuditLogger(storage_client) - audit_message = {"package":input_package_name, "status": "Success"} + audit_message = {"package": input_package_name, "status": "Success"} if input_package_version: audit_message["version"] = input_package_version await pip_audit_log.information('PIPIN', audit_message) except: - _LOGGER.exception("Failed to log the audit entry for PIPIN, for package {} install", format(input_package_name)) + _LOGGER.exception("Failed to log the audit entry for PIPIN, for package {} install", format( + input_package_name)) response = "Package {} version {} installed successfully.".format(input_package_name, input_package_version) if not input_package_version: From a98a93a825034f32e297708dd8890d03dfa56667 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Mon, 16 Jan 2023 11:11:29 +0000 Subject: [PATCH 062/499] =?UTF-8?q?FOGL-7279=20Add=20display=20name=20and?= =?UTF-8?q?=20deprecated=20support=20to=20configuration=20ca=E2=80=A6=20(#?= =?UTF-8?q?933)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FOGL-7279 Add display name and deprecated support to configuration category get/set item attribute Signed-off-by: Mark Riddoch * Add rule and unit test Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch --- C/common/config_category.cpp | 35 ++++++++++++++++++++ C/common/include/config_category.h | 8 +++-- tests/unit/C/common/test_config_category.cpp | 25 ++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/C/common/config_category.cpp b/C/common/config_category.cpp index 3b14e60885..461db781ba 100644 --- a/C/common/config_category.cpp +++ b/C/common/config_category.cpp @@ -468,6 +468,12 @@ string ConfigCategory::getItemAttribute(const string& itemName, return m_items[i]->m_validity; case GROUP_ATTR: return m_items[i]->m_group; + case DISPLAY_NAME_ATTR: + return m_items[i]->m_displayName; + case DEPRECATED_ATTR: + return m_items[i]->m_deprecated; + case RULE_ATTR: + return m_items[i]->m_rule; default: throw new ConfigItemAttributeNotFound(); } @@ -524,6 +530,15 @@ bool ConfigCategory::setItemAttribute(const string& itemName, case GROUP_ATTR: m_items[i]->m_group = value; return true; + case DISPLAY_NAME_ATTR: + m_items[i]->m_displayName = value; + return true; + case DEPRECATED_ATTR: + m_items[i]->m_deprecated = value; + return true; + case RULE_ATTR: + m_items[i]->m_rule = value; + return true; default: return false; } @@ -1057,6 +1072,15 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, m_group = ""; } + if (item.HasMember("rule")) + { + m_rule = item["rule"].GetString(); + } + else + { + m_rule = ""; + } + if (item.HasMember("options")) { const Value& options = item["options"]; @@ -1339,6 +1363,7 @@ ConfigCategory::CategoryItem::CategoryItem(const CategoryItem& rhs) m_itemType = rhs.m_itemType; m_validity = rhs.m_validity; m_group = rhs.m_group; + m_rule = rhs.m_rule; } /** @@ -1424,6 +1449,11 @@ ostringstream convert; convert << ", \"validity\" : \"" << JSONescape(m_validity) << "\""; } + if (!m_rule.empty()) + { + convert << ", \"rule\" : \"" << JSONescape(m_rule) << "\""; + } + if (!m_group.empty()) { convert << ", \"group\" : \"" << m_group << "\""; @@ -1501,6 +1531,11 @@ ostringstream convert; convert << ", \"validity\" : \"" << JSONescape(m_validity) << "\""; } + if (!m_rule.empty()) + { + convert << ", \"rule\" : \"" << JSONescape(m_rule) << "\""; + } + if (!m_group.empty()) { convert << ", \"group\" : \"" << m_group << "\""; diff --git a/C/common/include/config_category.h b/C/common/include/config_category.h index 24aee86af1..bc87d943d0 100644 --- a/C/common/include/config_category.h +++ b/C/common/include/config_category.h @@ -126,7 +126,10 @@ class ConfigCategory { MAXIMUM_ATTR, LENGTH_ATTR, VALIDITY_ATTR, - GROUP_ATTR}; + GROUP_ATTR, + DISPLAY_NAME_ATTR, + DEPRECATED_ATTR, + RULE_ATTR}; std::string getItemAttribute(const std::string& itemName, ItemAttribute itemAttribute) const; @@ -170,10 +173,11 @@ class ConfigCategory { ItemType m_itemType; std::string m_validity; std::string m_group; + std::string m_rule; }; std::vector m_items; std::string m_name; - std::string m_parent_name; + std::string m_parent_name; std::string m_description; std::string m_displayName; diff --git a/tests/unit/C/common/test_config_category.cpp b/tests/unit/C/common/test_config_category.cpp index 43cd08cb0c..8ccda4287b 100644 --- a/tests/unit/C/common/test_config_category.cpp +++ b/tests/unit/C/common/test_config_category.cpp @@ -309,6 +309,17 @@ const char* bigCategory = "\"description\": \"Defines\"} " \ "}"; +const char *optionals = + "{\"item1\" : { "\ + "\"type\": \"integer\", \"displayName\": \"Item1\", " \ + "\"value\": \"3\", \"default\": \"3\", " \ + "\"description\": \"First Item\", " \ + "\"group\" : \"Group1\", " \ + "\"rule\" : \"1 = 0\", " \ + "\"deprecated\" : \"false\", " \ + "\"order\": \"10\"} " + "}"; + TEST(CategoriesTest, Count) { ConfigCategories confCategories(categories); @@ -615,3 +626,17 @@ TEST(CategoryTest, categoryValues) ASSERT_EQ(true, complex.getValue("plugin").compare("OMF") == 0); ASSERT_EQ(true, complex.getValue("OMFMaxRetry").compare("3") == 0); } + + +/** + * Test optional attributes + */ +TEST(CategoryTest, optionalItems) +{ + ConfigCategory category("optional", optionals); + ASSERT_EQ(0, category.getItemAttribute("item1", ConfigCategory::GROUP_ATTR).compare("Group1")); + ASSERT_EQ(0, category.getItemAttribute("item1", ConfigCategory::DEPRECATED_ATTR).compare("false")); + ASSERT_EQ(0, category.getItemAttribute("item1", ConfigCategory::RULE_ATTR).compare("1 = 0")); + ASSERT_EQ(0, category.getItemAttribute("item1", ConfigCategory::DISPLAY_NAME_ATTR).compare("Item1")); +} + From 1f67cc76661170b89cf5122d1bbafbf14ea3f32e Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 17 Jan 2023 14:30:12 +0000 Subject: [PATCH 063/499] FOGL-7074 Make storage plugin selection a choice from a drop down list (#865) * FOGL-7074 Make storage plugin selection a choice from a drop down list Signed-off-by: Mark Riddoch * FOGL-7074 Add option for uign same plugin for readings and configuration Signed-off-by: Mark Riddoch * Update with review comments Signed-off-by: Mark Riddoch * Update csche if not enumeration in cache Signed-off-by: Mark Riddoch * Add timnespan call Signed-off-by: Mark Riddoch * 7074 upgrade step Signed-off-by: Mark Riddoch * Bring files from develop Signed-off-by: Mark Riddoch * Fix default for reading plugin to be in the enumeration Signed-off-by: Mark Riddoch * Make sure databased category is in sync after restart Signed-off-by: Mark Riddoch * Fix typo Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch Co-authored-by: Ashish Jabble --- C/services/storage/configuration.cpp | 42 +++++++++++++++++++++++---- C/services/storage/storage.cpp | 37 +++++++++++++++++++---- docs/images/storage_config.png | Bin 63522 -> 67148 bytes docs/tuning_fledge.rst | 6 +++- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/C/services/storage/configuration.cpp b/C/services/storage/configuration.cpp index 8ddd427cb7..87dfde5ddb 100644 --- a/C/services/storage/configuration.cpp +++ b/C/services/storage/configuration.cpp @@ -23,15 +23,17 @@ static const char *defaultConfiguration = QUOTE({ "value" : "sqlite", "default" : "sqlite", "description" : "The main storage plugin to load", - "type" : "string", + "type" : "enumeration", + "options" : [ "sqlite", "sqlitelb", "postgres" ], "displayName" : "Storage Plugin", "order" : "1" }, "readingPlugin" : { - "value" : "", - "default" : "", - "description" : "The storage plugin to load for readings data. If blank the main storage plugin is used.", - "type" : "string", + "value" : "Use main plugin", + "default" : "Use main plugin", + "description" : "The storage plugin to load for readings data.", + "type" : "enumeration", + "options" : [ "Use main plugin", "sqlite", "sqlitelb", "sqlitememory", "postgres" ], "displayName" : "Readings Plugin", "order" : "2" }, @@ -83,6 +85,9 @@ using namespace rapidjson; /** * Constructor for storage service configuration class. + * + * TODO Update the options for plugin and readingPlugin with any other storage + * plugins that have been installed */ StorageConfiguration::StorageConfiguration() { @@ -293,10 +298,28 @@ DefaultConfigCategory *StorageConfiguration::getDefaultCategory() * end up reporting the wrong information in the UI when we look at the category, therefore * we special case the plugin name and set the default to whatever the current value is * for just this property. + * + * FOGL-7074 Make the plugin selection an enumeration */ void StorageConfiguration::checkCache() { +bool forceUpdate = false; + if (document->HasMember("plugin")) + { + Value& item = (*document)["plugin"]; + if (item.HasMember("type") && item["type"].IsString()) + { + const char *type = item["type"].GetString(); + if (strcmp(type, "enumeration")) + { + // It's not an enumeration currently + forceUpdate = true; + } + } + } + + if (forceUpdate == false && document->HasMember("plugin")) { Value& item = (*document)["plugin"]; if (item.HasMember("type")) @@ -305,11 +328,16 @@ void StorageConfiguration::checkCache() item["default"].SetString(val, strlen(val)); Value& rp = (*document)["readingPlugin"]; const char *rval = getValue("readingPlugin"); + if (strlen(rval) == 0) + { + rval = "Use main plugin"; + } rp["default"].SetString(rval, strlen(rval)); logger->info("Storage configuration cache is up to date"); return; } } + logger->info("Storage configuration cache is not up to date"); Document *newdoc = new Document(); newdoc->Parse(defaultConfiguration); @@ -335,6 +363,10 @@ void StorageConfiguration::checkCache() } if (strcmp(name, "readingPlugin") == 0) { + if (strlen(val) == 0) + { + val = "Use main plugin"; + } newval["default"].SetString(strdup(val), strlen(val)); logger->warn("Set default of %s to %s", name, val); } diff --git a/C/services/storage/storage.cpp b/C/services/storage/storage.cpp index 781ad34b31..d1d312303e 100644 --- a/C/services/storage/storage.cpp +++ b/C/services/storage/storage.cpp @@ -274,11 +274,24 @@ void StorageService::start(string& coreAddress, unsigned short corePort) ManagementClient *client = new ManagementClient(coreAddress, corePort); client->registerService(record); + // FOGL-7074 upgrade step + try { + ConfigCategory cat = client->getCategory("Storage"); + string rp = cat.getValue("readingPlugin"); + if (rp.empty()) + { + client->setCategoryItemValue("Storage", "readingPlugin", + "Use main plugin"); + } + } catch (...) { + // ignore + } + // Add the default configuration under the Advanced category unsigned int retryCount = 0; DefaultConfigCategory *conf = config->getDefaultCategory(); conf->setDescription(CATEGORY_DESCRIPTION); - while (client->addCategory(*conf, true) == false && ++retryCount < 10) + while (client->addCategory(*conf, false) == false && ++retryCount < 10) { sleep(2 * retryCount); } @@ -310,7 +323,7 @@ void StorageService::start(string& coreAddress, unsigned short corePort) } catch (...) { } - // Regsiter for configuration changes to our category + // Register for configuration changes to our category ConfigHandler *configHandler = ConfigHandler::getInstance(client); configHandler->registerCategory(this, STORAGE_CATEGORY); @@ -327,7 +340,7 @@ void StorageService::start(string& coreAddress, unsigned short corePort) children1.push_back(conf->getName()); client->addChildCategories(STORAGE_CATEGORY, children1); - // Regsiter for configuration changes to our category + // Register for configuration changes to our storage plugin category ConfigHandler *configHandler = ConfigHandler::getInstance(client); configHandler->registerCategory(this, conf->getName()); @@ -351,13 +364,19 @@ void StorageService::start(string& coreAddress, unsigned short corePort) children1.push_back(conf->getName()); client->addChildCategories(STORAGE_CATEGORY, children1); - // Regsiter for configuration changes to our category + // Regsiter for configuration changes to our reading category category ConfigHandler *configHandler = ConfigHandler::getInstance(client); configHandler->registerCategory(this, conf->getName()); } } } + // Now we are running force the plugin names back to the configuration manager to + // make sure they match what we are running. This can be out of sync if the storage + // configuration cache has been manually reset or altered while Fledge was down + client->setCategoryItemValue(STORAGE_CATEGORY, "plugin", config->getValue("plugin")); + client->setCategoryItemValue(STORAGE_CATEGORY, "readingPlugin", config->getValue("readingPlugin")); + // Wait for all the API threads to complete api->wait(); @@ -394,7 +413,7 @@ void StorageService::stop() /** * Load the configured storage plugin or plugins * - * @return bool True if the plugins have been l;oaded and support the correct operations + * @return bool True if the plugins have been loaded and support the correct operations */ bool StorageService::loadPlugin() { @@ -443,6 +462,14 @@ bool StorageService::loadPlugin() // Single plugin does everything return true; } + if (strcmp(readingPluginName, plugin) == 0 + || strcmp(readingPluginName, "Use main plugin") == 0) + { + // Storage plugin and reading plugin are the same, or we have been + // explicitly told to use the storage plugin for reading so no need + // to add a reading plugin + return true; + } if (plugin == NULL) { logger->error("Unable to fetch reading plugin name from configuration.\n"); diff --git a/docs/images/storage_config.png b/docs/images/storage_config.png index 2724c1ef3902d9d4035c0041055a22626c46f4ae..f764829e2ed6ff2b9bb5379238ef0d7146bfae1d 100644 GIT binary patch literal 67148 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 literal 63522 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�?mZaD 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 Date: Mon, 6 Feb 2023 16:01:55 +0530 Subject: [PATCH 086/499] FOGL-7354 : Review comment fixed Signed-off-by: nandan --- C/plugins/storage/sqlite/common/connection.cpp | 4 ++-- C/plugins/storage/sqlitelb/common/connection.cpp | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index 2481c286ee..ac165e6846 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -1229,7 +1229,7 @@ std::size_t arr = data.find("inserts"); // Number of inserts int ins = 0; - int failedInsertCount = 0; + int failedInsertCount = 0; // Generate sql query for prepared statement for (Value::ConstValueIterator iter = inserts.Begin(); @@ -1303,7 +1303,7 @@ std::size_t arr = data.find("inserts"); } } else if (itr->value.IsDouble()) { - sqlite3_bind_double(stmt, columID,itr->value.IsDouble()); + sqlite3_bind_double(stmt, columID,itr->value.GetDouble()); } else if (itr->value.IsInt64()) diff --git a/C/plugins/storage/sqlitelb/common/connection.cpp b/C/plugins/storage/sqlitelb/common/connection.cpp index 8eb4e338b5..f4d4c5ff59 100644 --- a/C/plugins/storage/sqlitelb/common/connection.cpp +++ b/C/plugins/storage/sqlitelb/common/connection.cpp @@ -1305,7 +1305,7 @@ std::size_t arr = data.find("inserts"); // Number of inserts int ins = 0; - int failedInsertCount = 0; + int failedInsertCount = 0; // Generate sql query for prepared statement for (Value::ConstValueIterator iter = inserts.Begin(); @@ -1379,7 +1379,7 @@ std::size_t arr = data.find("inserts"); } } else if (itr->value.IsDouble()) { - sqlite3_bind_double(stmt, columID,itr->value.IsDouble()); + sqlite3_bind_double(stmt, columID,itr->value.GetDouble()); } else if (itr->value.IsInt64()) From 052000495c8f45ba3bf23d425e1c401f833d29ac Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Mon, 6 Feb 2023 15:33:42 +0000 Subject: [PATCH 087/499] FOGL-7343 Document new user roles (#958) * FOGL-7343 Update user documentation Signed-off-by: Mark Riddoch * Update user management words Signed-off-by: Mark Riddoch * Update with review feedback Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- docs/images/add_user.jpg | Bin 41091 -> 59086 bytes docs/images/change_role.jpg | Bin 25645 -> 18617 bytes docs/images/update_user.jpg | Bin 0 -> 32692 bytes docs/images/user_management.jpg | Bin 100778 -> 123018 bytes docs/securing_fledge.rst | 33 +++++++++++++++++++++++++++----- 5 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 docs/images/update_user.jpg diff --git a/docs/images/add_user.jpg b/docs/images/add_user.jpg index d68af322f84d94d33367adaeb6a35d9ca30c1e71..8e53d4de75b118d7aaca6d756b887f3158099411 100644 GIT binary patch literal 59086 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 F_8;pKK=A+o diff --git a/docs/images/change_role.jpg b/docs/images/change_role.jpg index 68919a29eea490641cdfb0e8fe720d9c002899af..0f9583ac9ac4a3c6ed12472f3f0db9628825efcb 100644 GIT binary patch literal 18617 zcmeHt2Ut_xvgoEsmky#pkfJChDj=Pph=8adAQ4a^y~GfzL@B{Sk*fF$KPW^I1f)p` zNcjmRfTD=hNKi^pK?O-fV}O*q(ewZ3o^$Sd@4k1>Iq%%>GCQ;P?6udLy=L~TS+iD# z^NKSKh#xs@dl=y20stHE05~&1kQFA-4*-rG19k%dAOLW4Ndi0|#RVQfQXs(lg9d;d z;1vLPvJwG)@G1$S!(Hy*pK{0F<@ufF+OuBqfRm-IEqHbE4)gU54i7*_aApLz2Sh}K z7(=1K;X0l^XfIzKZ!`*u@eG0P)!73DOktQ1Pj94egtC{fe_*hg%2EqKMLE#NOa-og zY|pU}OW%M%n~Pz-P8W|mdtXF)8~Lcf%#}?s#u!ux$~VGO8G||(9Bzy;Q~AN%7^K&W zp(@Hhsze~oR8AdpP_{&e`6}<%*`u>Z1#BJW<7e!6=LC zQi!sy&K|(t|SEpBURiEwbVKWux{ z>PLjf4S0rxoLg^<3XTYKv_7bO%GFJoe-7XV4?#c+Q1{oAaCDvL@7Evv|2Wad z*26#m7}j3r6&TiF==kOm^kztC#w(3l?LVsh$Apj%7#4w#m>_C+hlE9dQKcG4J48i< ztkWO@(qUkyKstGy_Ww!WS*N{z(vN=Badxx>>kvU&+&jS22c$;FC`4R}d8d2jD2M56}bjfxZ8WdU`+VSpsMv1h@c%0fB%&5CB8~%D@4z zBm_7I_=2=85Da(&I_r7_`RxHwcU`|=TM+)Nw?CH!9s+>&=MDGM-S^|3OSlmL__8^iWjFxv?*af8iNjfI6J0KyzwTC;Nhf4+Emf+%%;NlPg2*`(z>j(VKn2Vc-mv0llfS`~tSOG5% zt^jy=xOsW__|^@ND;9hX@JjGWZr^)glhg@Mew9$^eV1*?<|Ff=l@JY;p)+Q#;Xlk-UzI4GXCkFTG9KwwaKMCAD>%!P|r zt|lZVC0|R)y7|kk?3`b7^9t`56+b8`Eqhp7SC4OKeB9LB*+uN`>3#CFZ)kXAbd2TBNq};3^YZfW@~_Lq#T^AA zj|4B@_Pv`V51iol43$#ZcS%6{;Ej7V9fGR5j#L@1ut6bNHN6>i+PY{zB>T?^cKQEF zvOfg-vs@E^B{-7TfgAksaC3v7iU$PVO}y){iGR}%;Qtc{`~bmq5dH%=AQP_jmOMOs z;7?d!lfWO({?i2K4Y<0~;E(|k9xl+Bcq9NAz;a}z!lvKAUX^!2c)z0H^zy-~Ry2j> z_(HS%B{|_uI=(3DVNHs9eYPwrm+U%T8!fcD##WHv0KB(2K%qScxV7ld0dAJlRZ;+E z6bDG+#dfG;=WlX=chRmK;O$4)Ob3K&i&$0T04pA2*cD@fm&RSO-jf{QG7nf);$Y_0 zIUbV7m@NxkHbtGzWT;iMB(cnDB#fDlsF8NypCvF2RoQ}?9N-X+189;5IKa*m9AL8m z$l(YF7~IJL`sj5W;OXd^gcT5H!U4K2!=|$cUu`+Sm)|PDQzT@A?}i>W>|tYEZ1{)& znSK)f1ksSm+;)FWhEYCURaX|*cTs=fvMJ($n_akl0>wlo*#CHuWbVG5v8{d|d`(VO1tKRqQI_hLU=d~_+I^cyaftdn1=olm?j++u`+ z=1%{*UCHNYU-A8OWxE9xghs#_su?rT0};bc-+{<5{ihXOmMzD$^h)32+lJ?&k-sXu zK4@UA<$GqcpuD4@9@nagRUC@x4mP{IvlB}eKF+$)3ZZfU586@&9xCXD$?Kf3!>DFp z9u&AoN!f?1g}N8{Ne<{$N#|HOyl6i;C5Y`f!rn~iL|=o6x9JD0Hg^5yJtLN7>N>ON zs+zqz@}?ANDQxk@DkR5ep&=s1W>!H&l4})eg*7EK5Ejd5@LqEX#>tr}r5G(*R40~U z8>hxNct-ZSV;qVcZf!HOhI4SlBg6b%i z+WbC!&43AKWyI({{Ny3Q$fdZe=Q%$fuJjqP>^)yQKcM*f_Psl%CV6LF)c4+!d^^o5 zqiv(l(d3e1qWr0LGw9BKY%flHW)P*{k3BT3Q?Whuz~!aSR#DNRp^7#6Pp(@@AANsd z+n()vh&*2%VNsK1B~j;OS^8&wex+Ui&Av!@Cva zn_hEAms>#jx>d}*wY`{$o{Mu#=~ViCst3M&ax!35T8YmXb@jWXEy^#rel6+O4ynC1 ztz%q8uQq+*GE#|$9W4coCq-#LEkBbf6E$NUe=aimRfM)>dz)Hw^ROeus=fddtbHiV zR=`B@r7oWzZ|P3{ydJ7QQ@U@NB3hMCv+kG@Fm)|ss*mDbhEOs7ekh%jeXqCK3=cF7!qZHDl9;^V!XA zle@As+>qlll5b2;-8h@JSEJ8XzS@X}X398C({re6GiV}=hv83D(y&h?vf_~MwytCfeb83x`5C>2dH#qx66bmENl(a)PGcU4keS?y>&BA&Ik_*I1 zu*Ozvg}h&s^qcGJYg2k1A77Swd)kIRBdh0da->X}i={S*NP*OrFr~x0SJ4aGsMucY zR`9F!asa6|l>uyVfSboPv;w@DFk*ue!Jphv#t(Vazk& zs3PJn;lx`Z6*2o5H)p1V&jfv$&#TW~&aF^SpLK6BDYx}a#JHSzSEsqjx*hzEHl@|w zgmEPODdGzDQOUxG=~xqsu{brR4UKfQQhiv@Y@bcrsOkBJd6FF5z`(IrT70{&OSbjX zD2IC)q2F-}-D{`j3H829DW)>zm~dyRbvjcl6pgNCgu6=U_g7D-?sI>hHSttS*ZsxA zN#~j-(VE&#M!;%MipE{;e+Jgz3F7l{+v?ZiS|SxI&T!qRxjE187#pu&hqyx?drXjK zt6r-9s97?8Y;VdgAHJ+DO%v_C9{VuzMy3w@Lj;!uO1EKPo-6a6j}`Wta8sB zV161zF86j5`1P?|^Kx4oQ>&)&kC%xq=Rd~uG@tlhDTCpNK4W;3viBUMZ@v4pob`^~NCF>?FPC`){udjEvj5N0j2@Rf_X z!hW?bVK;U{w~HWT3ZXVm<1*`K?=h{Z59%nk-`)sC>koV5Th4rtb^kj_Uf9`o^S-^(!Bnka>0tcK9 zqBmajoxF{tU(nV!5#4W7bu!=0Ani$-YPbrsVjy$A6I^|+fF=>b)S`ax!d=Px+%z-5 zgGp`Qi)kUkn}=O7b#MqNK zacsb%Q~Tt0T25DW>Z*BSP7YhHCe~%idW317)y~&lr21mb^yQH)M=sH8r!&G4HF^&G zZ=vlU;?&t=Q;kGOgY={T$y90@%7Zz$)YZI`sV-ajAjr7ke%XM?@bQKWks5NZ^Z_0l z>AD7Ig*^})xi_gz8RPLCyP!{ zE3zx4mXvq<>$A)TEg&zVx)Jf&IBvo@Txx+aV|a!Gq+V=Hj8j33BlBb{sY4v#RKV5C zquO(Izu|`-q-XhL!(Q9W_YKyje@cV9Q|IWRB<9Xp?706KDzpnK$*?|>*~8YPZL0Py zKQmC}@!?bHhem-M3deYj>SHBd2a?Ciz3AU*r>)8cw6FBt)Odqd(F(HM4!@M4dmXuoDvXD1T55G# zv%sXXxA%{pHlj!$oQHzNC3<%L&nu<+|ll#~vJ_JY9+&ng_Uuo@hE7g?bZ5r3X9^oylXsh|T_2g7rlWhJ;(t{?3S3^;0{po{WWWLK9oQe!m?H3 z^#V2;ZzycTNHzw>|5s-K;|#OeKXwY%cMJYSku&nMk8fivX*}(5l4JE)QB1Y_qhWoB z_zQaT$;vm+Lk40^1AH)xgRk_eN`-G(*f`j3kq&@~oAOg|;z)BzYEPqQSx}o|wuU}Z z)BV@?j~``6NcQAN+?P!#*r#Y2?|R%?f%Hy{DPux(gLm4eLF()mch0_#hVn8Xy>Q6H z>fQ9WNyk%949_(r2KH%Q2wABp9ojErCsJS)=fk*9$!MCj7hw+*@R{w(4?MqT!lOf}1c}ya*wepz#nM4Y#buja>A9Eu-x92_ss%=@YFL1n zL|h-1XDP;rf)HnTw{I;;Wu7R;+$XlF(xS=bist)`BSIMux{hVL48Q-SPq`TxyS$>g zt%?7+(gao0l~sZb$V|o6p*zJ=5b}7MZv{q-NNy`D!yH^)vOg1MX5f^dr54pvSD*W9 zySJo0J%D_6%l$3y)Z<|#L7FS>3>3|tp8m2;jbTEMZ(@{C`t8;b^gNQeeE-Z^zeRr` z2RLi5{rOdQjlxsGgqR$y82pMe9{?Y~iE@D8sWkK5tRl6MHU-99_pV|_OBX_Fo8!>8 zB}wz*(o=8d8*1(Se1`AW)Z+*JFAeXtLTL@@9g-KK4gpx7uW;JH-PdtL)N2J zF)um)MS^c=xW6gvWu4;?>;^=<1YWgK*R7O7X@GXPYZhf55W}=XY}VFKo%ZJ zGy;4*FN-N(IeKclja98ma_6ED|Fgyidq1RPrvTnoQazrLd;%SghP@AVeFY<4<&&px zZ5h}BKHHVP)yO51x9rK2`HMbrn{ZZu^of5R#B#=d%J{z3a~8(WG^O^>rJG{QUFR?E zFDO8bvhNwYyIbV)L}WcVDeiMATiJ&+01+i1Gn1y|Ox+EV*bo7{=9ZWPI`PfFz{e6N zr+bS!%C+cIZByafxdpe z$|=VVlgEA2iVnY{H16wCD~ptTrWW9H>F_s0eQH!!^>`gUy`GU2O*61%TtLovUZ6pU z?A`cbXjR3#L=%)<=TlF#`VhHa<#Pc;=5dDVb{C!Ydd!V@m{Y64+9CG9LtBb|y-?GbmH39|d!#2N05a0mYLp(0tXUFe{r z2QG8*+DRq|dFF-&h{;bXS1sZ3!s5015} zQxIE`O0bcVp|9x%U9xnN+&+7>@bR^T(b0#O-jx}N*emYB@o)e%cKq2aq66hVTRc`e zxoN1?pyCV$dnT!y?U5N;^fBA_P(x$fxkR<={FcNt0nZm<`~wK;3GBE5Wnj^o7Bwx+ z0mS@ABl354#_b@qk@SC)TovBN+DAqTdS#q)^K#8C-hI_tAsuwyVX*x%;0t+ISr6G; zn2L;ZllqJxoP<6)5hl&Fr9h=V=wCTZjJUx?E!^E}wJ9)r8 z-LRMgYaUGFAvP=q%8Uk<{w$i?D0OgT=C|mjmH1J@7>(hPGFAdiEQ_G|y6FHCIL${$Tg|S&u5@ zW=1pVr0BjZ`Hz%yWChn4JEsWYNHjB)5HwP_Kh5%V+RNDv`-_{ zm2}6N^l$)K)14HE$Qwpf`+l}q0fhrNQ<=@Eg_;5BVw847w8m%kbP<%wnW@*YcLjy^ zHTj2dvA74Vq=6IvTF3>i^?%05RWSGXkLGHRhWxSvrlXO$xt_OXv}pCan|yY^;+jaK zfb0INFfe_ifHcKW4Ces70X$`eO=yPYSKP{CEC;wmf-%o${^f1Fah_)O-?aOif(=H0 zqmsWV_#dyM%t2-2sB6&rUww0@?T~@D{BFJ9{Y9_umA}@j?Q&_97NNiU67TorHT8YPz;sCswXcJQK22Daz9H5tE@o7OZKh#48ko zu3pAXZ%^tcN?c7;8FFi6L4pbr;YUc(&_St=;2ba~)Km5)TGdEugq^$sblqT-gAVW| zo^gBa0JJACvR?wV$UHHVDRt`hl9)EV?m?#LQ=h4Ex^e28Qhe=W*>n90E;GcsCktP8 z?Y%B`oT0yV4l_%EcA~Hpokl9ITNC>L(*mb<7u+b(P9N=`8D6T=wnV!Rqs6ixNrb8s zUv{cLNq_5@HaY%)i6X`6#XY}R-OT~UJD+1aE!8v1m2yfxJ*r)Cdy%*7ZdUv@^hnW` zrR^B?FYkJsZ+Y#pf8J)yijT3Q5cu9OxS=v1~p zL=vtd4mQeFn(}TrgqC(6I+hDN8utWJmyTPwO_e&%kT02X|F|cRoLCpD(2%|B!9?Ll za(X`_Mf247?6vK3N{$c3A6pzOHL>7-5ogSLtP`0jgu(W}ghckQ?zl&tf-_PpJdOpC z8jT~W=;m>+vO+tMZ!nPg?t&F@^W;${YoX_faEtKMI}>1MMZuYsi!nN#ijcD1%cwz@ z1!hax2JF2T2=DxCQ!#r$WBGnVGodxtJb>+b=&XiCZz_o}oy!&)X*24lT@+w$kI|3* zZ7Vnn<#^Grpo(Ez1$}CZC5H=OH|Q&V2g9q7iK|yJ@%V1=@DZn z*(vyFdJ)X)n2kV}etv$PZ>?qcPnSiGB%A^!h{-rB zQVNrcVa5T3blVN$_}PQ(ku`X-sa4eq+x|x58unz%2(z+f%G{pZTDh`94<@%XWCs0T z{rxM}U!4r2I)gTUez=HF+Q;_=R`=J77F+h&^aZsFykCx{#nXLWOh9VxG~|t0jjsbSn{D~j{V&i#@OF?v1Y$D zy~wB7iMju_gJ-e}tqk6cNSuk9PH-IsNn)5Vx*5iY& z^|-=diopZM6+NYYT~JfE{VIc_!}A$ z+yV+D2euvUPY9`uSG>@p`RE_8P5fhbs~TKCT)omWwY=;N8hA^U;;U_JWn_}vk)g+fH*F*yW19V zcnB*6Q=qg~Z-eVqI^GiOgJ$Go)oS({5k@C%w;TK~b}+a; zcR_15Yju)Le*b~DAmUnJC?QxRV)6-F9a6BUF`o|k8oqPlfh`DFbB96MTlEn zz4=XRD(B`a=SBtud493@dqa|ZB+c==6M@h=7EvzzbmJ%UqGLbG3E-(HyOgsx~RZQ%fq@Hy;^ zBdpQtK6E0YZf8$Agt8;466tn?Og=R=@w#92^ucyD9R+HZuB1hK`zJ)XKJB#Ywfk;( zgTp~C_I@~o>Q@iG4pP_5r`=y)yJz8t&zN?mXjI=cpZ~9z?BA>Hk5uL=lbN{;rUA{e zJr71i^S|!BP>jqDnR4rw;)kb1Y%Y>mxbVHICmtNJ|DsrBLVO{VU=R?IC@7$)1VKR%hK!Pi z98@HO@? z36PKgfHwFC5T}8Q8vf1>0HCiAhynmW4Um#B17sjX0{#K_odNRiFaQXH-vB_GLHgGx zWTF{le}ze64_*?dX^uI0d%G)1Nx6ARTHC>F>?Lhsu2TNi?o!f{r=$QCh`+nFt&6=k zzm2`4v)fsLjcTL-zq8$00W*31Q~K`e_D;^)w>|BRZ(q7%d)viU(M|xO%CF+DcR~6j+1QU#d6&e{ZWQ|F86m0>3EmivqtW@QVV!DDXdz z0)NwX?A<^W#}CwH0OA^O#?T4o4fAq>x%10Po&wIC*U=|GP_n@E9X|dYWbEgO8IA`^ z$iIfrJXyt@4<^o#^6RLpLk%w*XzEspden zj0vCzP65K!wqEXPhK3gp%KYp1_ws-L*cm)f^Z=kw;-D-CBYy4Yg98D>`&`em7b7Bn z&-T9uXzf6?2z-SDFKb(OPj66fyaB?3e%|f}a5xCFdV-At;U@>M<8ScW1K8#_xc+;d zE0@*5JpCZdXzOHc2f}k8EN=6|bH^Xxztjt60jLZw%E-w}AAtYZn~+H@c)qxS?Q#!F zNY=(Pl!lg_gYyX2abb}YCq?DtPb-{JR8qg7p{b>L?u3YoRpmMb8_G0m6Vp1S5&@xU)9*u+|t_C-tnQg zuYX{0=*#fP^vvws{KDeW*JbqP*7moZUCiG80bV2k*>7lppTA-DC%l+IyhzE($;hb= z@FF4g11~Zra*AWpl+0>IRMs9W0y1~0S(g z1S|vmF(vp*Q|{!+C6a+ zGWBRAC_D9j^SH?@2Gd4kYD|=;fL5p>$Y0Bx-VIDpb3U1;QH@cT1?-aN(F0Xh|8Exe}-3 zP6T?c@L?_yf$tqN-Gxh1g{)eA@44n4u;-+SK*Mp{2Xl##sH@IbWz#WQ@j`U)ZoS@D z2hBP99A^v`V~oUaBEF>l2N8k)ftL8lmmv@yA~4;T{tfKm-?)em^|fn15B{BhkT(CX z^t{Z65t@ZFw476|x$;ksg)dzWJmI}vgs_b);p+zF2RGJSJ&+_qT zT2)GcE+|Czku6~7!w*>g&;10l{12UPw))d-_!qhRoelplg7*;KAk5}fIrHR*;u+Ki z$kAb<_~--F;6Dm1Nb2836={)IUT-{^^|cbCUY289$C&{~FXV0f#&S4J22~S@0{I z#roZQvR#icCs*=YL0lTOk8^jaU3M-<0fbAcVmJ|WP6Pail|NtG>Ch3vm{?Y;Uz3W# zuH6Ha!m)i)>ISxjfqigVqJZ|T+4ct?SnT zwiHHa2sdDWO+g+E*d_-v8#yR0sh|jZ?jDRxq9L>=UjBPtqKdDhCWO=cuFk3@JDdRy z&!tYU*9l;JC)BwDc7daA4=5*b0V#WiQ~?@ZN?Os|XC2fi%32lUwO?^_;uZPRZ+KbB z1FS~BO|K1CiNHcKe5{EGyc@y!A-=?8#Q1Q%UDzk^U2bUHHVLukr-+H-8%b%ty%7#~ zoaDwlYEtNPu6h-bYF|-Z>6+L~u%Z8O=P1i{JXDb|beh8=Wwj5@Jn~sqDEg&=w9$|q zg}oO>fCXbn^)^tUu4X`$2V-g6@8A-hU&~>T7Vvz2F4@w5csSnklaBlr5x869GUL)U zGBlGOE-!I;#)?d{yG;3+GDl#8s}NdDcd0h|`JaE7nd zJk)|>Zc!gRdJ^uM@~D$C2-G=X>^%;bW3S_G4{WztMxTXfb(eK4oIgS_GRlLV^LS6^ z_EstXBxT6iuq*fih~rsqy62`^vFM$!0@UbGq0Y?R%^&CMc61Cu^INfStgf;aO;apAbUIaK+2g{%9e z<{MI%XL=QG!C4dAN1xpcZA+vGP*}S=WpJOJ2$ZBeShefBC4Nf<^14kjdagAgS})vN z&x3?>bb(n=+2C`b+Gm%}8oe1QnwBZp@NA=FeLAYB#w#H6%cQ@Hej5I*xNB#NpnKC}m2s}!1tu`=Z$gkribf;6QiXF%LlePF-lzx3!-Z>^J6ftv#WkYZ7Q^E&Z zDxm1{dwxeDv#3CJd~ppu+Hxl4K^xXsZYD3*Wu=BU6j;ql@h@e{xEohq%?xCgs{dx zyDW_!-MQBogLokK$)oQy;v8?&?8b=k6ts!nCs2A8!TH5G^$l&OQ((tjBt+DnF+0sv zOYqZZ163y5%E;;RZ2xH8@*(d-U*vg|_K&BIEDvfVAIWlyiObF>TXoso+A)4K+nO~c z#R!UyDig}>z1NIB+|)IA*RsD*uwSHl>#1GnJoBIl)60Zn1-6mVqC;H!k9O2h3Q?-O z{$kA&+-UmGQ9H9OO0k14Y%`(d_`)QLuH-fA_X)I|_Ts)w>CZ$Soui7u+0H!U8zq}w z41N1eIkHT`pvj_kU?B|=DU!xRu&mm(Z=l+%EC#ZBMM%9-Wf`^pv>`t$vHBx z_E@wpE#G0gLA}eiMb#n}=?xuqncB&DQ#CrWoXTGP+ERD%=GiajXH?P@PJg3SO1?oQ zTo;1D=YJjHsNdjQzMcIQ2IGtPrXe|ZLQ0}6p*8HR`FIkmpA9hAO+|(>n|Yo7D)>_{ z1Li5(wpn^KyGn&+ayj*J#*yQm=Q>ns;`~hv&Eo+UtHma0d^#23)U~lMI7PHZTCtx) z+O5Gnq0niYcHSEi-f^1oLiE$Rp;{_2xH~Y^GZXaC>MImPx9?l5pcYy)VjmLe?Lr^8 z&~)Wp#OVZy*O7eEVM6sUqCP2d?v*IR5Bq;MM>zXGCD6Lq>t#eBT;C?jEB}1!9owV5;@b7iN1wMh z_#dGPi{Zj;;Ge$6tiY{jCt3Xs8q*jDH_8$0%cvE5Wwn^v4Tmx++%R6y9T@gf_iysgeCrZS18?2Zz$Vte9p&kEED5 z8Gou=s;_=w!4iRrn6N^Zx}zx5YFPdcVXgrs*0 zW=37QpNR?nEGDL>uQbTnD|F~#LheUK$_0_*Pd0UgHeF`5SS2-*6sXBbk#T1FwUd?h&%YlKtIlPu+MZYK+S ze74fWy_qu^563L5bc@+5-)We|nK*C)wlA6=(pHW@8H2@fXE4TDO@+7%Lu+zk%E`lI zR?=I-H)Jk&oN&H&w}Uo^2MtYEJ%xfbpM*F;NCU<2xws&oSw8X=YsB{HioODG7w&Q2 zj=Pp(OBtq;)!L$T_wOj3Xtm_+%K7qj;yAv>XB!=8G_E&|r1;V~ohy0H`+=k|-_7^; zxbN~O#Msw=J}lb@@acoCs7Ce8EW9}V1~uD!`t~aOCuR?~PbH04?E~BG4vD%gQapB~ z;yJ8hwsRScj0`lIhDIu-$CL*kkR>WXGlw`uY90xUwDxtJx;7(|N^MyiRR*t`BM z;ML}_aM^4xy>21nDsh=oURJ?k+ZacIS^`obSRCZ0#aXE^8Jubz3f=^xcVagjt$3EJ zUNCl{%J^Knbs7yC*j88#sTQbfIld0HE*6}?0scUP4obATxHQJ2Zcvceh8~lSBvO5|2(-3A1vb9d2J8{s`N?Cuv z$LC#HpGteGQ1XNzpe|0^*6w^ERQ1&RBZb&jB2X%I7w0k^Pr0OZd2riGtNwJ4g%cOy zk#9v+L7B(-ho@vBKPaRoAfBicF(~`a4fQ-Wr0NXj#is-^Wcp*9Agq$|>5MDaPolbZ zBF6Ph>-64lq!?CWrS)DrscmMQZ>63-^-{K)u_ew=0^+zq_Z4~srYgUrG+6uUrc!0H zPmpLY+E}}Pf|H9nZDFkWQzuu&d{YSbLq*pKG}tMXGw@-(#;VE(<#1MvdP|)Yimfg3 z6;`tpHXUSWZZUajcv86)t;_7!CHc&&qg6*_IQR%o2Ibc%MLp^-R=?xWcd61bDf)e~ z^Wx(Lo!1F<&O%=OY4reU2o+od2!OYwhfPpm2BtHo;XLLws0NvF^RdzP8`UN6&zXt}+kFtf*Pe z5wfF@yATKDVSl~Wigczx^%*ELX13YFj(OBMCHQ!qWoC|+(rU_TPaCUtyg*>#oV0b? zQ~7OIR+24-Xrz7oA(siUfk4M;12+HCdW*QahHnYet{2VKa&=|sdO`BMv|*UkubF#z zHPL$=V!v^317%>0p}$eN9~}0!y^>?scPoF{OyRtLj>`j)R7I2dglWT;hp|~V?*X<; zRVPTxU%d4_?QVWSN6)|axWq@cbF7Y>N9qv@Xi$|u?Ei6wFSPFXa*9In%`|dXowB8j zMEQ!x%Mahm;4>{md`EI=Z+SPYB(&U)WhI#-0-Sa97~@%3gey*d<|*P<>^*#4j&-hc zVm8O1nQ!OgL>i$ulgnzNp7+CAsmW~u4KXCsQc-?Nr{Cu;TYQe52_ltcyZ=B=x!5*O zi#tY1_N{}k4$bk$Z6P|NWwK=Jpuj~0ih{r4yLbEZtC=t5JhsgfCkF*A?{vbO71CY% zdB;zglHbTpXgs1elBYzM$mZeL2nTQ^(@-i*X?;A;=hBy$E|Y$rpv#lS-Vf!f(t6~a zY~lop3mZ!F-z)~ZYn6j8Z5}l&yFb(5N39_obQa3!Z=j#3C{ZJp=fdk7M=s8FX5#n* zp~MsI+)U5!ksS@y2#SbI5NP8Hzd&Mg)+@AKZADed<-?VuTKx8lRS^%M#ZKvKDVx#O~&&k`>9K#%va@(~GcM zalmc|!XbjQU`CtYFrhWmL<=CPC(Zs1LC|wL^){-22+)?gQxPuk8+<22baBY_)xCkfnwhO4S^97!40HilfiXH z5onnSrWzd4!$AceRHi&933DquQU9J!?>{-~Axy}rl1QNn%*9x7kCUo`doT3P-1_Q2 z9QK?*iR1~Ha%(sq)!#g2aMJm;1_#H~Y4t0X;E*ctHqXxs$ z>d)lLE4P2kMDfM8A`e>6sE2-l4L3iR04)8NJiy=Qul{%>4u@Lihg0>O9|SEklD8 zd)q8WPX_93)$`Sk9jjuHm0^Pn%PN>`x4 zrP^}X{i@24wZoYO<}TVtm_G?`$;oOZJtm0*$k(khORpPc6DA3qX|8g~B>+_tU$0mt(DK^#L3ab?oCSbquJDMjpQ@L^Iy3rJ_&W z{*6}N&D|<^c&4^zx=Y;D(@YZ{6Vp>qx{^CLLrM0z(IPtoJ*s@TYeT_@u3|)*c}38? zNsg`_&akWtlGh1(Po|%J)b8fch*Q*4F5)Lwv5P90L5})qq9&V>%z;A1D2@j;YBR2s zy`LAvcmyWbysVz(`@2~MmQlRC%6v1(Kb-+*0tP@dsN(`=u(f2y@lCL) zXiH@thdVlTP4dmo*_m<5uMJMlH+zz@MF%3%BHrReQ3lbVV-<6PuZ0M3-&CS}<(jdq z%2yQHWS08*>Z+UFRquLqlV`g?!rj;~(PBmQ^eT6fr8*LPSuhu7ataw)C!?RUTy^U@ z%R4M@epZ>lo$V^mXjb<;Mxib9#y85R)L|p6sZA)6wKw(%e=tDhItk`33S3JFCVkc# zY1veHyF^ee(498lb)=`%{v78mcB?OR^ntKUe}$bClw%WOL~>=??QY@H$oiYv1TUKv zTlw<&i9|QanBK2-HYbxRN~|--Mrnq(TnpXWrg!62)In#fS%Lm0l%xWZEO z!mnL$bxpH-SDUOsZrQ-`Sd|Ef_}4cUUwh$5pjAMvH=XanIluCkYDvur-{6|y(X)zc z*KmA2{z+yqLQD195Yu5RqsHg4g>84caHJ@P*s}Nskn$IV(&{axyLk9DiiPfao`BN+qDXY|+xZk4^Dr<^bH5r5Pv zNCfiDXtlX@>W^2%IF@1#Vbw5}vx6ybb+W%emn;?|HZG+XI^BM2BetfxC=Mj3LBv?y z8K^u@@iV$?`OP32;OrRVne@U|BRr_%{Y}XC=)y3}JTf!Yq3c1roNJW>Fk* zP^sM;CB;|9u1eEoK}-oPbwIpE6&ewL3`G^Ga)>I@&2^>%#<~`j?;Z7 zTpW0Bv|4>+zr~z)Bt#YUGU^2IZ*Y;J?+|FWX+ZPOAXEz%6C_Op3_swmk4#QNaxi9V z9Dkl7hJJ2}_|xS5@3`^~`Kc+h`{(G9ncI%8r)ADAI>O@-X11aLM&rbTp*w+a!E>BM z!08k%TPfgK>YF@nzTGU7**tE>>Lw(BFxU-&+RN zy`HQ!4!*4cFrqBWu;(00(92oEZ6to%39HeD`v%7@^~)KOPyQ^OXquYEAdVL2VWj@*c!b0TZ66CJ+3456zrv(C}%9XH5= z3Y|y~8f(Owc#Ne{)drYM#^4lZAxHgP0~PahqR~S zRnP70V(=%=0p1x5v4LW~x8%nCv`|3bH!C}g#g3kM5* zq_37Aa3n)m09&wd0(Z|g1KH9p*DY>IaOMBQ%;xlynjsd>D=7(I65Afd)w=_nY+tV! zougp=$6Z_&zjMu79w~wq6!4V{~#y z1y_@W&8*s6E-~;w^p_*qp{ZDSZqf40jG+uo_6_HO)Xif-x{fLz|(jUr+C6q6TB~?8t8*yt*M z_Vm2%L!n4?k@c1!P@gY$lKimjJ=EgP>tc*KRyOeXd-N5Hp*R)SS!J#J`h5~d5c2w( zb}y2$6Lqdu423TWz>cFSPJsXIp|L zV9)UB?L;6pxDWI&TnFP|AHl~uz&O|sII9eXpLGZBQSEG?x*~BpK|x^Tt!SVI{*eR5cxp0qk~f$E2dVDI zX=Mt>d(2GIMO!|@Mt97Gp0rCUIL=(-sdXYjh&Jq_qbY5jx%^r>i#W~?^>OEIH@F;! z7po@-pkZM+jp@i$skhs;XI~F`zq!s7vUAB)`w4~qXa$Av1siS>q`?xnzo3ggPz-|r z=jg-1eSAmd;Z)02WH^l;gg6c|-LdxkW+gRSq+{7RJLaM*fa}=$XHCsR?y4df3bg3Z zbj~UsEwI?ADl#BZb1ZwzIZdeB8(wx56Vao-RPDnX4#66FD&PHn;v{&}t zV}<=0E>&Y13UQZu2uJ-tHdftIGU_Yv={kBdMJjJu*z`fFRFOf=Tb=ntp{=Ky17yj^ z;Mz<78q2l+uQn=#13ra-Z|NY{?Y=uyC5H~Ip>DZwwf0A7te# zay#uN(El%*Q?5 zE8>=v9La70+uj{CuK|tI&Isf|`3jyJS{GfP%F;A#b=zf4_VOo2-9{)qwCN$t&5zY5 zROGRC!g`|onYYO3e1=GHAm4;U?x!V8k9NG38&=^KiHyjQw-nPVdJt13V4;0zq9xE_ z%akqPQyy!?MOpxF`m#b-OhxdAe@s8UxoMG{p5;7bQCHx>_98Zh`P$gn#Rh>0&MP&E%10?o40S|8 zD6DebTnjcN=F6%dh=h7O%|~e*P85uhDR~~6eU#B(AVg(nWtO3FMT*1{XH<>We992M zo-BK~aFWQE>u_}Jtg({VyFWBt{w_DfqPyh}f zyBw=F*#A~V1iGgRbU!{LsQadWTLC?MnGF_?0V568fA&Rkulc0g z$^5|rZ+Gh>)Vd+J&xl=qW9hszSaQUMSvH~4yQJnVMVK&F?*HTp(1QyE?1TrKM;LTn7mEjj1e(}REx%f3c{2C{JYC1SL zpGNF#%PJ8-l>q4UXi2w-^SaKu+AA&$lL(qr_ z&>Num2oZ2x&Ln(O{tj|E4m6L?nZQwC7*G4&N$iTLY6Pd)?Gr2Tl323XsV>bc9q%LYn4hLx7beFf15VZ z3f9pyMFyL$f~PLH^FH}cFaZA%2IlwkpeR6hV=5SH^4speL8J}1$ppEj3kHsY)ERRP zRsyZa{=eHt^1tIw%+}5b$Q359;9r_xL?FA;XNM;+0P70x5bZ^-T7#NcuVob$!T_Jk zY5?P5wjCQVnM6RSP#MRtr8PgmYLfWEA8xo(|-0-o^3o5Z%pMVV)l4@-x zi&kHmohLiH>4Md)5SGii#602laVY5{^fB}kfuw0##NvfT!p-h@+3kfG@2#4Y4~x%@ zu7880o|X^xvNVSF)@w{*r5JA}PK=d#q|3~kCm2?Y+Nm=?G%H{>dV#V~op5Wk>anucPG1-wE9vhxC_TifFkbkVu@CcEdS z?iQI_e)qP3B&($Qn8**(iu57cCl|jcHgr9En>>1Oz|&ctM?+e#@0vxRyK~=cF85zw z{+K$rce8LXHRq0801w+5S7qZx|E%VURENq}qfScnUn?FN*Q9z^KA2C`h!~E4XrCBV zTrrg(gUen#I`kZq-?)zm`}W5B>@`h?Sb?X0Z|B3~-e5WjTBX-< zI8Vo_23=bCXcoGDRpMcX^gZh|8534^8!nA0Qje~i`|G3ymr@w5MNJZoj4f)J(7Zz3 z`IZsVWw|XME*K9esKha~x_0I}400(i`8=vAf)R{M@}HF|q7X_}hWLisU=l+s@;>!x@;# z1_FOI`k8@?pUZSDq=CJPC;Pe`9OlGdS8OERu{q+o+Q->I8@w>3dJ~k2oT(s69+8F- z`-(SrrX%I>Hf=^#F9a(4g@ase&Fv$OCu=|I*c6b?(pI+)W2%k^!yE7Eq0I>4U5wy_ zQwWY)vEUr{XG{=Wif;P8uOcs<;-WaYmgFqw- z?76cGT$3K@l;L^1W~dt1$aGPQ_vP@A%Y$Wj!DiXz-GVm_ZlP-;p=W}xl9%$IuqD@= zPeN}Ul{O>-thj4EQYvS&mPUM9-{g-wtMbR2b12;_D35_LYqFkyXsIL6%yyqsL{VhE z-eL<3yh5_UmoB0i2(;@+%m5$iO|U39qdlGB!?BIS(Rn>LJfY59rG?M*YaMwkT`T9S zd(2&jEjgZ`sY;VLt)JWUS{NsTJ14g)MF++=5Vd0a1L-YYG{X~^N`s=>!R>L`=G4cx z$7{Dw`}R&)SdpE^@KU{F>ZCC*I;z8MuQnp1;a?fAqN`#yb5j1yI%4CPc4lRSl}?t+ zqoXG{7MMg|(#_@3tzPEvvh@H>#6Oet@0i2t441hodnw{lSwL#}Sk|!E?ub}HP2p(Z z``BApZZ+$??sQStVa*^*--;1M)s(@Z5}kl=a5Xl}eOr{{%;(Gi`k3nsgEIl+whXH2 z9(1}_XQc!)OpL}70mSGB9JEc`jkmf%^PYxq?W@(k@;!+=ex=y>N!7UFU@^K<8k>{4 zG*~e!`8#*?M(~A*WpJ7friAPLpIkBjYR?ajeKdvcy@Kza0_Ww^NFJ9RF#_#J&^Lr2 zEb!r5hh_^v-_YPsjV}KkTthtYKj|n#t7bU16oNAp{|SPPMYLG}#G$Qpj7VGC#-Tt? z%>B1Z!KbUNAev}z_U`i4`;4RG=4BD=k9Ra#lpb|6%R;;5BeHUqD-tHZ`Wa4G>MCf- z*F=lEG5FXEF>TA^?(`;JA~}18x1R+6paAt>&}b>L~r*WiEyA j8}IMIY!xGBmeETQhsB9)?SQ9;5{Lg6pGPs# diff --git a/docs/images/update_user.jpg b/docs/images/update_user.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b5047bb0a40966cc2238b5b8c222eec2885646c8 GIT binary patch literal 32692 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 literal 0 HcmV?d00001 diff --git a/docs/images/user_management.jpg b/docs/images/user_management.jpg index 9d05e795fd9246a248db93b49ae3b3a66a660eca..4dfbe8be095b84fe6ed30854c6a3928fe1f1e944 100644 GIT binary patch literal 123018 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 z zqK%^}^RH5kDJ#JO{V%`K22AybH=&94e)3H@nreIXoW;2<;GW)WE(d=^?Dt_8RfUBtXCi2<{@d5670cpUO$u`wCjNIq;jr= zQx!=j?R{c+{476$_2q55Y#k3NlZLol(5jMNW?JK`UA-Eac1@mc1!vaqRdC%{XTK=J z5(tBbaKRrtCAx50Qs(;_)1#rtvRhuccTWXqrDlhRFT3)RP~#9=O6LnI$K;%AnFgW%4WN6b8pCx-?yP9quZvb$?Wl2f~KIcZgdzYg>w#?h!kR&Qe%_! z30rN-fJ{EJnx&qbKVj6@%hd8j?gc-298*wgjc6ne>+EO zLcj4KL*=gj3w(*+F#mSDlHOg%&Sl-)vW2HtHBx)xjPY4FIiA5Fbk2`JTI^m9 z&Ks37>gZY(M8t3q;05OnSKb`EQ~$>D%80te`4+VPD2*^|76M~d8`Y;NJxsG#Gp%}! z%-DO$WP_v0=oMTj<&18xg`xEumlMj$K^iCaf2eD2%n6h#Fk{wBp&p-be#Oc5h?SJj zzlYJYoP#D1*6|nuw-cor^U^zCu!Ra+cumaAIr4ou^n`nIK}%!0?@Qh@A6)mPXWbP@ z&f|R`QL;KfY3{uMToP0;;Aa<6Oqog1Gmy=C?N;OB;#1kFs?lsH^R7GN4ou)oPs*Z% z$S)Xi>N-Q9-?%lG6pY73{j$gupodYt-p@|#=Q_T%4dWzp|59X`42Y>)>~+ zDP;bpSW%#`$=OG;0XwOy6BInYjdhgaMLx&b?x?gLvt=P~HjGuwl?k)u3Mwmz85mWN z&C)JND`rwCDHmoskNTi%X@2j%MN>ONa026ybIg-85x+`m6{<8!?0BLF$_i= z9)4jSH)N%MA4qreFuP?hjFQvj`*MP#Xs;ULi4j1c#u%z)^b0$K?|Hgk&|fGgMurk| zC#u#|l*r?iVqU&B$y%$|%{+B$f0@;1+}ew1H6o#t^mVBIu?!$3RPY=6uvdL^6S_T^ z5XJ8k(B;GXxR+*r@kAI~*S_Y?A;PQso+^#(ETNE9-QBz1KUp}{p>aBv5%(l~oksLP z1%1J#OIi2L?-u)Lr90MlR0bz66jf!ab{~t;)@#ahy^s(LszplpHqM-&hrNL3ULz<6 zN$89&xkPfed5=u8J>$vO#`Mo7qU%IfMV zaS#8Is;^E@iV|kzE)2H21+-Tt>_cOjLW?MY*8gYPJa$4KgEG^dFMX-iyLK$wQ#E`0 z?lv>;{id;^EHUm+rmFUz-NC29d?SOvkSc75k5n489;uGA{-nf*{=bbm_FtUBmtUVz zBQ#OvhxtQiN%p+{l;v`(xtM=Sefh;es@bE*y5Kw3MKk00&x63jCm4WBlWy9l=B7Ok z_>rpCZSk`t`sJGBy0>y-p(oyM@!b4k=lgiC@Kg1h?EiG``u?}=De!m+f$*zSu&s>1 zCpNlj0^f>>n!CT9YpVDDkTEGvU$$)V-&K0X`-0`pE2sNto-6-1^)v90kz>H+yiQl- de+b?Ge}DNQd++}Y&spnA0@(lG1OOp+@`nHb literal 100778 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 diff --git a/docs/securing_fledge.rst b/docs/securing_fledge.rst index d2ea317695..0b3e2d9d30 100644 --- a/docs/securing_fledge.rst +++ b/docs/securing_fledge.rst @@ -11,6 +11,7 @@ .. |password_rotation| image:: images/password_rotation.jpg .. |user_management| image:: images/user_management.jpg .. |add_user| image:: images/add_user.jpg +.. |update_user| image:: images/update_user.jpg .. |delete_user| image:: images/delete_user.jpg .. |change_role| image:: images/change_role.jpg .. |reset_password| image:: images/reset_password.jpg @@ -48,7 +49,7 @@ After enabling HTTPS and selecting save you must restart Fledge in order for the *Note*: if using the default self-signed certificate you might need to authorise the browser to connect to IP:PORT. Just open a new browser tab and type the URL https://YOUR_FLEDGE_IP:1995 - +; Then follow browser instruction in order to allow the connection and close the tab. In the Fledge GUI you should see the green icon (Fledge is running). @@ -130,7 +131,12 @@ Whenever a user logs into Fledge the age of their password is checked against th User Management =============== -Once mandatory authentication has been enabled and the currently logged in user has the role *admin*, a new option appears in the GUI, *User Management*. +The user management option becomes active once the Fledge has been configured to require authentication of users. This is enabled via the *Admin API* page of the *Configuration* menu item. A new menu item *User Management* will appear in the left hand menu. + +.. note:: + + After setting the Authentication option to mandatory in the configuration page the Fledge instance should be restarted. + +-------------------+ | |user_management| | @@ -142,11 +148,19 @@ The user management pages allows - Deleting users. - Resetting user passwords. - Changing the role of a user. + - Changing the details of a user + +Fledge currently supports four roles for users: + + - **Administrator**: a user with admin role is able to fully configure Fledge, view the data read by the Fledge instance and also manage Fledge users. + + - **Editor**: a user with this role is able to configure Fledge and view the data read by Fledge. The user can not manage other users or add new users. -Fledge currently supports two roles for users, + - **Viewer**: a user that can only view the configuration of the Fledge instance and the data that has been read by Fledge. The user has no ability to modify the Fledge instance in any way. - - **admin**: a user with admin role is able to fully configure Fledge and also manage Fledge users - - **user**: a user with this role is able to configure Fledge but can not manage users + - **Data Viewer**: a user that can only view the data in Fledge and not the configuration of Fledge itself. The user has no ability to modify the Fledge instance in any way. + +Restrictions apply to both the API calls that can be made when authenticated as particular users and the access the user will have to the graphical user interface. Users will observe both menu items will be removed completely or options on certain pages will be unavailable. Adding Users ------------ @@ -159,6 +173,15 @@ To add a new user from the *User Management* page select the *Add User* icon in You can select a role for the new user, a user name and an initial password for the user. Only users with the role *admin* can add new users. +Update User Details +------------------- + +The edit user option allows the name, authentication method and description of a user to be updated. This option is only available to users with the *admin* role. + ++---------------+ +| |update_user| | ++---------------+ + Changing User Roles ------------------- From 62ccf65e85b1531fe195449e9a8113959f86b4d7 Mon Sep 17 00:00:00 2001 From: Rob Raesemann Date: Tue, 7 Feb 2023 07:31:43 -0500 Subject: [PATCH 088/499] Create ADOPTERS.MD (#906) * Create ADOPTERS.MD Stage 3 submission requires a public list of adopters. Signed-off-by: Rob Raesemann * Update ADOPTERS.MD Signed-off-by: Rob Raesemann --- ADOPTERS.MD | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 ADOPTERS.MD diff --git a/ADOPTERS.MD b/ADOPTERS.MD new file mode 100644 index 0000000000..93494da197 --- /dev/null +++ b/ADOPTERS.MD @@ -0,0 +1,18 @@ +# Fledge Adopters + +- Beckhoff - PLC Vendor +- Dianomic - IIoT Software +- Flir - IR/Gas Cameras +- General Atomics - Predator Drone +- Google - Search-ML-Cloud-TPUs +- JEA - Energy/Water Company +- [Motorsports.ai](http://motorsports.ai/) - Racing Digital Twins +- Nexcom - Industrial Gateways +- Nokia - Wireless Communications +- OSIsoft - Data Infrastructure +- Rovisys - Industrial SI +- Transpara - HMI for Process Manufacturers +- Wago - PLC Vendor +- Zededa - VMs for IoT +- RTE France - T&D +- Nueman Aluminium \ No newline at end of file From ac1f1bf9e541f4209628482a4adf65ccb23743db Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 7 Feb 2023 13:13:52 +0000 Subject: [PATCH 089/499] Initial update Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/linkdata.cpp | 17 +++++++++++++---- C/plugins/north/OMF/plugin.cpp | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index f3f8d0d9ec..8f97d6af36 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -47,13 +47,17 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi { if (typeid(**it) == typeid(OMFTagNameHint)) { - assetName = (*it)->getHint(); - Logger::getLogger()->info("Using OMF TagName hint: %s", assetName.c_str()); + string hintValue = (*it)->getHint(); + Logger::getLogger()->info("Using OMF TagName hint: %s for asset %s", + hintValue.c_str(), assetName.c_str()); + assetName = hintValue; } if (typeid(**it) == typeid(OMFTagHint)) { - assetName = (*it)->getHint(); - Logger::getLogger()->info("Using OMF Tag hint: %s", assetName.c_str()); + string hintValue = (*it)->getHint(); + Logger::getLogger()->info("Using OMF Tag hint: %s for asset %s", + hintValue.c_str(), assetName.c_str()); + assetName = hintValue; } } } @@ -140,6 +144,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi if (baseType.empty()) { // Type is not supported, skip the datapoint + skipDatapoints++;; continue; } if (m_linkSent->find(link) == m_linkSent->end()) @@ -170,6 +175,10 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi outData.append("} ] }"); } } + if (skipDatapoints > 0) + { + Logger::getLogger()->warn("The asset %s had a number of datapoints that are nor supported by OMF and have been omitted", reading.getAssetName().c_str()); + } Logger::getLogger()->debug("Created data messasges %s", outData.c_str()); return outData; } diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 30bc0bb99b..667556be23 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -1699,6 +1699,8 @@ double GetElapsedTime(struct timeval *startTime) bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) { static std::chrono::steady_clock::time_point nextCheck; + static bool reported = false; // Has the state been reported yet + static bool reportedState; // What was the last reported state if (!s_connected && connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) { @@ -1713,11 +1715,25 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) now = std::chrono::steady_clock::now(); nextCheck = now + std::chrono::seconds(60); Logger::getLogger()->debug("PI Web API %s is not available. HTTP Code: %d", connInfo->hostAndPort.c_str(), httpCode); + if (reported == false || reportedState == true) + { + reportedState = false; + reported = true; + Logger::getLogger()->error("The PI Web API service %s is not available", + connInfo->hostAndPort); + } } else { s_connected = true; Logger::getLogger()->info("%s reconnected to %s", version.c_str(), connInfo->hostAndPort.c_str()); + if (reported == true || reportedState == false) + { + reportedState = true; + reported = true; + Logger::getLogger()->warn("The PI Web API service %s has become available", + connInfo->hostAndPort); + } } } } From 1ff182d02bc4b0c4500f6af37d1f65b7e3d08201 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 8 Feb 2023 12:01:32 +0530 Subject: [PATCH 090/499] NOTIFICATION_SERVICE_INCLUDE_DIRS path updated as latest in test scripts Signed-off-by: ashish-jabble --- tests/system/python/scripts/install_c_plugin | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/python/scripts/install_c_plugin b/tests/system/python/scripts/install_c_plugin index 387874c3ea..7cf7064f3a 100755 --- a/tests/system/python/scripts/install_c_plugin +++ b/tests/system/python/scripts/install_c_plugin @@ -45,7 +45,7 @@ install_binary_file () { # fledge-service-notification repo is required to build notificationRule Plugins service_repo_name='fledge-service-notification' git clone -b ${BRANCH_NAME} --single-branch https://github.com/fledge-iot/${service_repo_name}.git /tmp/${service_repo_name} - export NOTIFICATION_SERVICE_INCLUDE_DIRS=/tmp/${service_repo_name}/C/services/common/include + export NOTIFICATION_SERVICE_INCLUDE_DIRS=/tmp/${service_repo_name}/C/services/notification/include fi if [ -f /tmp/${REPO_NAME}/build.sh ]; then From 0114487aa8b192f4b0a769faaa95468bb9c350d6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 8 Feb 2023 12:02:22 +0530 Subject: [PATCH 091/499] api notification system tests updated as new inbuilt DataAvailability rule added Signed-off-by: ashish-jabble --- tests/system/python/api/test_notification.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/system/python/api/test_notification.py b/tests/system/python/api/test_notification.py index 019fd6c581..00413ed119 100644 --- a/tests/system/python/api/test_notification.py +++ b/tests/system/python/api/test_notification.py @@ -23,7 +23,7 @@ SERVICE = "notification" SERVICE_NAME = "Notification Server #1" NOTIFY_PLUGIN = "slack" -NOTIFY_INBUILT_RULES = ["Threshold"] +NOTIFY_INBUILT_RULES = ["Threshold", "DataAvailability"] DATA = {"name": "Test - 1", "description": "Test4_Notification", "rule": NOTIFY_INBUILT_RULES[0], @@ -174,7 +174,9 @@ def test_create_valid_notification_instance(self, fledge_url): assert 2 == len(jdoc) assert NOTIFY_PLUGIN == jdoc['delivery'][0]['name'] assert "notify" == jdoc['delivery'][0]['type'] - assert 1 == len(jdoc['rules']) + assert 2 == len(jdoc['rules']) + assert NOTIFY_INBUILT_RULES[0] == jdoc['rules'][1]['name'] + assert NOTIFY_INBUILT_RULES[1] == jdoc['rules'][0]['name'] @pytest.mark.parametrize("test_input, expected_error", [ ({"rule": "+"}, '400: Invalid rule property in payload.'), From 6e26ec4a62a47a1879fd36f390c8e3350adc32c9 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 8 Feb 2023 12:02:54 +0530 Subject: [PATCH 092/499] E2e notification system tests updated as new inbuilt DataAvailability rule added Signed-off-by: ashish-jabble --- .../test_e2e_notification_service_with_plugins.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py b/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py index 5a76a3399c..78c6f6f0cb 100644 --- a/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py +++ b/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py @@ -30,7 +30,7 @@ SERVICE = "notification" SERVICE_NAME = "NotificationServer #1" NOTIFY_PLUGIN = "python35" -NOTIFY_INBUILT_RULES = ["Threshold"] +NOTIFY_INBUILT_RULES = ["Threshold", "DataAvailability"] def _configure_and_start_service(service_branch, fledge_url, remove_directories): @@ -139,8 +139,9 @@ def test_get_default_notification_plugins(self, fledge_url, remove_directories): remove_directories(os.environ['FLEDGE_ROOT'] + 'cmake_build/C/plugins/notificationRule') jdoc = _get_result(fledge_url, '/fledge/notification/plugin') assert [] == jdoc['delivery'] - assert 1 == len(jdoc['rules']) - assert NOTIFY_INBUILT_RULES[0] == jdoc['rules'][0]['name'] + assert 2 == len(jdoc['rules']) + assert NOTIFY_INBUILT_RULES[0] == jdoc['rules'][1]['name'] + assert NOTIFY_INBUILT_RULES[1] == jdoc['rules'][0]['name'] class TestNotificationCRUD: @@ -164,8 +165,9 @@ def test_inbuilt_rule_plugin_and_notify_python35_delivery(self, fledge_url): jdoc = _get_result(fledge_url, '/fledge/notification/plugin') assert 1 == len(jdoc['delivery']) assert NOTIFY_PLUGIN == jdoc['delivery'][0]['name'] - assert 1 == len(jdoc['rules']) - assert NOTIFY_INBUILT_RULES[0] == jdoc['rules'][0]['name'] + assert 2 == len(jdoc['rules']) + assert NOTIFY_INBUILT_RULES[0] == jdoc['rules'][1]['name'] + assert NOTIFY_INBUILT_RULES[1] == jdoc['rules'][0]['name'] def test_get_notifications_and_audit_entry(self, fledge_url): jdoc = _get_result(fledge_url, '/fledge/notification') From edc11a3b2b2ff972469c12da5118d93b0bc792b8 Mon Sep 17 00:00:00 2001 From: nandan Date: Wed, 8 Feb 2023 13:49:49 +0530 Subject: [PATCH 093/499] FOGL-7353 : Improved exeption handling error logs Signed-off-by: nandan --- C/common/reading_set.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index f4e23b5e6e..0a57679c26 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -215,11 +215,17 @@ ReadingSet::copy(const ReadingSet& src) } } } - catch(...) + catch (std::bad_alloc& ex) + { + copyResult = false; + readings->clear(); + Logger::getLogger()->error("Insufficient memory :: failed while copying :%d: reading from ReadingSet :%s: ",readings->size()+1, ex.what()); + } + catch (std::exception& ex) { copyResult = false; readings->clear(); - Logger::getLogger()->error("Copy opeation failed"); + Logger::getLogger()->error("Unknown exception :: failed while copying :%d: reading from ReadingSet :%s: ",readings->size()+1, ex.what()); } //Append if All elements have been copied successfully From 252de04b6cfc2adf253f17bcf41682dde70c297c Mon Sep 17 00:00:00 2001 From: Yannick Marchetaux <108793884+YmaIneo@users.noreply.github.com> Date: Wed, 8 Feb 2023 12:53:32 +0100 Subject: [PATCH 094/499] DataPointUtility and JsonToDatapoint (#880) * DataPointUtility and JsonToDatapoint Added features: * Utility methods for searching and creating datapoints (namespace DatapointUtility, files datapoint_utility.h/cpp) * Method to convert JSOn format to datapoint format (included in the Datapoint class) * Corrections DatapointUtility and JSON parsing: * findStringElement : returns the string value using the toStringValue() method of the DatapointValue * Memory leak removal : Add method for deleting pointers Signed-off-by: Yannick Marchetaux * Corrections reviews * Add method createListElement * Add method findDictOrListElement and createDictOrListElement * Removal of the use of "auto". --------- Signed-off-by: Yannick Marchetaux --- C/common/datapoint.cpp | 56 +++++++ C/common/datapoint_utility.cpp | 217 +++++++++++++++++++++++++++ C/common/include/datapoint.h | 9 ++ C/common/include/datapoint_utility.h | 43 ++++++ 4 files changed, 325 insertions(+) create mode 100644 C/common/datapoint_utility.cpp create mode 100644 C/common/include/datapoint_utility.h diff --git a/C/common/datapoint.cpp b/C/common/datapoint.cpp index 2d8aafc85a..38bb6077ca 100644 --- a/C/common/datapoint.cpp +++ b/C/common/datapoint.cpp @@ -353,3 +353,59 @@ int bscount = 0; } return rval; } + +/** + * Parsing a Json string + * + * @param json : string json + * @return vector of datapoints +*/ +std::vector *Datapoint::parseJson(const std::string& json) { + + rapidjson::Document document; + + const auto& parseResult = document.Parse(json.c_str()); + if (parseResult.HasParseError()) { + Logger::getLogger()->fatal("Parsing error %d (%s).", parseResult.GetParseError(), json.c_str()); + printf("Parsing error %d (%s).", parseResult.GetParseError(), json.c_str()); + return nullptr; + } + + if (!document.IsObject()) { + return nullptr; + } + return recursiveJson(document); +} + +/** + * Recursive method to convert a JSON string to a datapoint + * + * @param document : object rapidjon + * @return vector of datapoints +*/ +std::vector *Datapoint::recursiveJson(const rapidjson::Value& document) { + std::vector* p = new std::vector(); + + for (rapidjson::Value::ConstMemberIterator itr = document.MemberBegin(); itr != document.MemberEnd(); ++itr) + { + if (itr->value.IsObject()) { + std::vector * vec = recursiveJson(itr->value); + DatapointValue d(vec, true); + p->push_back(new Datapoint(itr->name.GetString(), d)); + } + else if (itr->value.IsString()) { + DatapointValue d(itr->value.GetString()); + p->push_back(new Datapoint(itr->name.GetString(), d)); + } + else if (itr->value.IsDouble()) { + DatapointValue d(itr->value.GetDouble()); + p->push_back(new Datapoint(itr->name.GetString(), d)); + } + else if (itr->value.IsNumber() && !itr->value.IsDouble()) { + DatapointValue d((long)itr->value.GetInt()); + p->push_back(new Datapoint(itr->name.GetString(), d)); + } + } + + return p; +} \ No newline at end of file diff --git a/C/common/datapoint_utility.cpp b/C/common/datapoint_utility.cpp new file mode 100644 index 0000000000..36b1bc7c22 --- /dev/null +++ b/C/common/datapoint_utility.cpp @@ -0,0 +1,217 @@ +/* + * Datapoint utility. + * + * Copyright (c) 2020, RTE (https://www.rte-france.com) + * + * Released under the Apache 2.0 Licence + * + * Author: Yannick Marchetaux + * + */ +#include +#include + +using namespace std; + +/** + * Search a dictionary from a key + * + * @param dict : parent dictionary + * @param key : key to research + * @return vector of datapoint otherwise null pointer +*/ +DatapointUtility::Datapoints *DatapointUtility::findDictElement(Datapoints *dict, const string& key) { + return findDictOrListElement(dict, key, DatapointValue::T_DP_DICT); +} + +/** + * Search a array from a key + * + * @param dict : parent dictionary + * @param key : key to research + * @return vector of datapoint otherwise null pointer +*/ +DatapointUtility::Datapoints *DatapointUtility::findListElement(Datapoints *dict, const string& key) { + return findDictOrListElement(dict, key, DatapointValue::T_DP_LIST); +} + +/** + * Search a list or dictionary from a key + * + * @param dict : parent dictionary + * @param key : key to research + * @param type : type of data searched + * @return vector of datapoint otherwise null pointer +*/ +DatapointUtility::Datapoints *DatapointUtility::findDictOrListElement(Datapoints *dict, const string& key, DatapointValue::dataTagType type) { + Datapoint *dp = findDatapointElement(dict, key); + + if (dp == nullptr) { + return nullptr; + } + + DatapointValue& data = dp->getData(); + if (data.getType() == type) { + return data.getDpVec(); + } + + return nullptr; +} + +/** + * Search a DatapointValue from a key + * + * @param dict : parent dictionary + * @param key : key to research + * @return corresponding datapointValue otherwise null pointer +*/ +DatapointValue *DatapointUtility::findValueElement(Datapoints *dict, const string& key) { + + Datapoint *dp = findDatapointElement(dict, key); + + if (dp == nullptr) { + return nullptr; + } + + return &dp->getData(); +} + +/** + * Search a Datapoint from a key + * + * @param dict : parent dictionary + * @param key : key to research + * @return corresponding datapoint otherwise null pointer +*/ +Datapoint *DatapointUtility::findDatapointElement(Datapoints *dict, const string& key) { + if (dict == nullptr) { + return nullptr; + } + + for (Datapoint *dp : *dict) { + if (dp->getName() == key) { + return dp; + } + } + return nullptr; +} + +/** + * Search a string from a key + * + * @param dict : parent dictionary + * @param key : key to research + * @return corresponding string otherwise empty string +*/ +string DatapointUtility::findStringElement(Datapoints *dict, const string& key) { + + Datapoint *dp = findDatapointElement(dict, key); + + if (dp == nullptr) { + return ""; + } + + DatapointValue& data = dp->getData(); + const DatapointValue::dataTagType dType(data.getType()); + if (dType == DatapointValue::T_STRING) { + return data.toStringValue(); + } + + return ""; +} + +/** + * Method to delete and to free elements from a vector + * + * @param dps dict of values + * @param key key of dict +*/ +void DatapointUtility::deleteValue(Datapoints *dps, const string& key) { + for (Datapoints::iterator it = dps->begin(); it != dps->end(); it++){ + if ((*it)->getName() == key) { + delete (*it); + dps->erase(it); + break; + } + } +} + +/** + * Generate default attribute integer on Datapoint + * + * @param dps dict of values + * @param key key of dict + * @param valueDefault value attribute of dict + * @return pointer of the created datapoint + */ +Datapoint *DatapointUtility::createIntegerElement(Datapoints *dps, const string& key, long valueDefault) { + + deleteValue(dps, key); + + DatapointValue dv(valueDefault); + Datapoint *dp = new Datapoint(key, dv); + dps->push_back(dp); + + return dp; +} + +/** + * Generate default attribute string on Datapoint + * + * @param dps dict of values + * @param key key of dict + * @param valueDefault value attribute of dict + * @return pointer of the created datapoint + */ +Datapoint *DatapointUtility::createStringElement(Datapoints *dps, const string& key, const string& valueDefault) { + + deleteValue(dps, key); + + DatapointValue dv(valueDefault); + Datapoint *dp = new Datapoint(key, dv); + dps->push_back(dp); + + return dp; +} + +/** + * Generate default attribute dict on Datapoint + * + * @param dps dict of values + * @param key key of dict + * @param dict if the element is a dictionary + * @return pointer of the created datapoint + */ +Datapoint *DatapointUtility::createDictOrListElement(Datapoints* dps, const string& key, bool dict) { + + deleteValue(dps, key); + + Datapoints *newVec = new Datapoints; + DatapointValue dv(newVec, dict); + Datapoint *dp = new Datapoint(key, dv); + dps->push_back(dp); + + return dp; +} + +/** + * Generate default attribute dict on Datapoint + * + * @param dps dict of values + * @param key key of dict + * @return pointer of the created datapoint + */ +Datapoint *DatapointUtility::createDictElement(Datapoints* dps, const string& key) { + return createDictOrListElement(dps, key, true); +} + +/** + * Generate default attribute list on Datapoint + * + * @param dps dict of values + * @param key key of dict + * @return pointer of the created datapoint + */ +Datapoint *DatapointUtility::createListElement(Datapoints* dps, const string& key) { + return createDictOrListElement(dps, key, false); +} \ No newline at end of file diff --git a/C/common/include/datapoint.h b/C/common/include/datapoint.h index 3b0b012b45..2f6d604d38 100644 --- a/C/common/include/datapoint.h +++ b/C/common/include/datapoint.h @@ -17,6 +17,7 @@ #include #include #include +#include class Datapoint; /** @@ -347,6 +348,14 @@ class Datapoint { { return m_value; } + + /** + * Parse a json string and generates + * a corresponding datapoint vector + */ + std::vector* parseJson(const std::string& json); + std::vector* recursiveJson(const rapidjson::Value& document); + private: std::string m_name; DatapointValue m_value; diff --git a/C/common/include/datapoint_utility.h b/C/common/include/datapoint_utility.h new file mode 100644 index 0000000000..63f0ea81ac --- /dev/null +++ b/C/common/include/datapoint_utility.h @@ -0,0 +1,43 @@ +#ifndef INCLUDE_DATAPOINT_UTILITY_H_ +#define INCLUDE_DATAPOINT_UTILITY_H_ +/* + * Fledge + * + * Copyright (c) 2021 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Yannick Marchetaux + * + */ + +#include +#include +#include "datapoint.h" +#include "reading.h" + +namespace DatapointUtility { + // Define type + using Datapoints = std::vector; + using Readings = std::vector; + + // Function for search value + Datapoints *findDictElement (Datapoints* dict, const std::string& key); + DatapointValue *findValueElement (Datapoints* dict, const std::string& key); + Datapoint *findDatapointElement (Datapoints* dict, const std::string& key); + Datapoints *findDictOrListElement (Datapoints *dict, const std::string& key, DatapointValue::dataTagType type); + Datapoints *findListElement (Datapoints *dict, const std::string& key); + std::string findStringElement (Datapoints* dict, const std::string& key); + + // delete + void deleteValue(Datapoints *dps, const std::string& key); + + // Function for create element + Datapoint *createStringElement (Datapoints *dps, const std::string& key, const std::string& valueDefault); + Datapoint *createIntegerElement (Datapoints *dps, const std::string& key, long valueDefault); + Datapoint *createDictElement (Datapoints *dps, const std::string& key); + Datapoint *createListElement (Datapoints *dps, const std::string& key); + Datapoint *createDictOrListElement (Datapoints* dps, const std::string& key, bool dict); +}; + +#endif // INCLUDE_DATAPOINT_UTILITY_H_ \ No newline at end of file From 28d7cb4f649a2110767d9af8853a44028d76677b Mon Sep 17 00:00:00 2001 From: nandan Date: Wed, 8 Feb 2023 19:30:24 +0530 Subject: [PATCH 095/499] FOGL-7398 : Fixed warning message not be shown for blank JSON Docs Signed-off-by: nandan --- C/plugins/north/OMF/plugin.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 30bc0bb99b..d33fabc120 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -1486,7 +1486,12 @@ void loadSentDataTypes(CONNECTOR_INFO* connInfo, } else { - Logger::getLogger()->warn("Persisted data is not of the correct format, ignoring"); + // There is no stored data when plugin starts first time + if (JSONData.MemberBegin() != JSONData.MemberEnd()) + { + Logger::getLogger()->warn("Persisted data is not of the correct format, ignoring"); + } + OMFDataTypes dataType; dataType.typeId = connInfo->typeId; dataType.types = "{}"; From 058f38a87f781727860fc4193d7b5192e5659bd1 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Wed, 8 Feb 2023 15:05:34 -0500 Subject: [PATCH 096/499] Add support for OCS data center region Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/plugin.cpp | 72 ++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 30bc0bb99b..69af5c2eae 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -87,7 +87,7 @@ using namespace SimpleWeb; #define ENDPOINT_URL_PI_WEB_API "https://HOST_PLACEHOLDER:PORT_PLACEHOLDER/piwebapi/omf" #define ENDPOINT_URL_CR "https://HOST_PLACEHOLDER:PORT_PLACEHOLDER/ingress/messages" -#define ENDPOINT_URL_OCS "https://dat-b.osisoft.com:PORT_PLACEHOLDER/api/v1/tenants/TENANT_ID_PLACEHOLDER/Namespaces/NAMESPACE_ID_PLACEHOLDER/omf" +#define ENDPOINT_URL_OCS "https://REGION_PLACEHOLDER.osisoft.com:PORT_PLACEHOLDER/api/v1/tenants/TENANT_ID_PLACEHOLDER/Namespaces/NAMESPACE_ID_PLACEHOLDER/omf" #define ENDPOINT_URL_ADH "https://REGION_PLACEHOLDER.datahub.connect.aveva.com:PORT_PLACEHOLDER/api/v1/Tenants/TENANT_ID_PLACEHOLDER/Namespaces/NAMESPACE_ID_PLACEHOLDER/omf" #define ENDPOINT_URL_EDS "http://localhost:PORT_PLACEHOLDER/api/v1/tenants/default/namespaces/default/omf" @@ -162,14 +162,14 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "displayName": "Endpoint" }, "ADHRegions": { - "description": "AVEVA Data Hub region", - "type": "enumeration", - "options":["US-West", "EU-West", "Australia"], - "default": "US-West", - "order": "2", - "displayName": "ADH Region", - "validity" : "PIServerEndpoint == \"AVEVA Data Hub\"" - }, + "description": "AVEVA Data Hub or OSIsoft Cloud Services region", + "type": "enumeration", + "options":["US-West", "EU-West", "Australia"], + "default": "US-West", + "order": "2", + "displayName": "Cloud Service Region", + "validity" : "PIServerEndpoint == \"AVEVA Data Hub\" || PIServerEndpoint == \"OSIsoft Cloud Services\"" + }, "SendFullStructure": { "description": "It sends the minimum OMF structural messages to load data into Data Archive if disabled", "type": "boolean", @@ -562,6 +562,12 @@ PLUGIN_HANDLE plugin_init(ConfigCategory* configData) Logger::getLogger()->debug("End point manually selected - OSIsoft Cloud Services"); connInfo->PIServerEndpoint = ENDPOINT_OCS; url = ENDPOINT_URL_OCS; + std::string region = "dat-b"; + if(ADHRegions.compare("EU-West") == 0) + region = "dat-d"; + else if(ADHRegions.compare("Australia") == 0) + Logger::getLogger()->error("OSIsoft Cloud Services are not hosted in Australia"); + StringReplace(url, "REGION_PLACEHOLDER", region); endpointPort = ENDPOINT_PORT_OCS; } else if(PIServerEndpoint.compare("Edge Data Store") == 0) @@ -653,26 +659,35 @@ PLUGIN_HANDLE plugin_init(ConfigCategory* configData) connInfo->OCSClientSecret = OCSClientSecret; // PI Web API end-point - evaluates the authentication method requested - if (PIWebAPIAuthMethod.compare("anonymous") == 0) - { - Logger::getLogger()->debug("PI Web API end-point - anonymous authentication"); - connInfo->PIWebAPIAuthMethod = "a"; - } - else if (PIWebAPIAuthMethod.compare("basic") == 0) - { - Logger::getLogger()->debug("PI Web API end-point - basic authentication"); - connInfo->PIWebAPIAuthMethod = "b"; - connInfo->PIWebAPICredentials = AuthBasicCredentialsGenerate(PIWebAPIUserId, PIWebAPIPassword); - } - else if (PIWebAPIAuthMethod.compare("kerberos") == 0) + if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) { - Logger::getLogger()->debug("PI Web API end-point - kerberos authentication"); - connInfo->PIWebAPIAuthMethod = "k"; - AuthKerberosSetup(connInfo->KerberosKeytab, KerberosKeytabFileName); + if (PIWebAPIAuthMethod.compare("anonymous") == 0) + { + Logger::getLogger()->debug("PI Web API end-point - anonymous authentication"); + connInfo->PIWebAPIAuthMethod = "a"; + } + else if (PIWebAPIAuthMethod.compare("basic") == 0) + { + Logger::getLogger()->debug("PI Web API end-point - basic authentication"); + connInfo->PIWebAPIAuthMethod = "b"; + connInfo->PIWebAPICredentials = AuthBasicCredentialsGenerate(PIWebAPIUserId, PIWebAPIPassword); + } + else if (PIWebAPIAuthMethod.compare("kerberos") == 0) + { + Logger::getLogger()->debug("PI Web API end-point - kerberos authentication"); + connInfo->PIWebAPIAuthMethod = "k"; + AuthKerberosSetup(connInfo->KerberosKeytab, KerberosKeytabFileName); + } + else + { + Logger::getLogger()->error("Invalid authentication method for PI Web API :%s: ", PIWebAPIAuthMethod.c_str()); + } } else { - Logger::getLogger()->error("Invalid authentication method for PI Web API :%s: ", PIWebAPIAuthMethod.c_str()); + // For all other endpoint types, set PI Web API authentication to 'anonymous.' + // This prevents the HttpSender from inserting PI Web API authentication headers. + connInfo->PIWebAPIAuthMethod = "a"; } // Use compression ? @@ -790,6 +805,12 @@ void plugin_start(const PLUGIN_HANDLE handle, Logger* logger = Logger::getLogger(); CONNECTOR_INFO* connInfo = (CONNECTOR_INFO *)handle; + logger->info("Host: %s", connInfo->hostAndPort.c_str()); + if ((connInfo->PIServerEndpoint == ENDPOINT_OCS) || (connInfo->PIServerEndpoint == ENDPOINT_ADH)) + { + logger->info("Namespace: %s", connInfo->OCSNamespace.c_str()); + } + // Parse JSON plugin_data Document JSONData; JSONData.Parse(storedData.c_str()); @@ -1725,6 +1746,7 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) { // Endpoints other than PI Web API fail quickly when they are unavailable // so there is no need to check their status in advance. + version = "1.0"; s_connected = true; } From cc6a272a421de8ad4c325f4ab40ee7ca0fb57b2b Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Wed, 8 Feb 2023 15:18:41 -0500 Subject: [PATCH 097/499] Add support for OCS data center region Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/plugin.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 69af5c2eae..e7fa666f17 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -564,7 +564,7 @@ PLUGIN_HANDLE plugin_init(ConfigCategory* configData) url = ENDPOINT_URL_OCS; std::string region = "dat-b"; if(ADHRegions.compare("EU-West") == 0) - region = "dat-d"; + region = "dat-d"; else if(ADHRegions.compare("Australia") == 0) Logger::getLogger()->error("OSIsoft Cloud Services are not hosted in Australia"); StringReplace(url, "REGION_PLACEHOLDER", region); From 40889488101bf8a99d16e9dcc0880e9c04c5d77a Mon Sep 17 00:00:00 2001 From: nandan Date: Thu, 9 Feb 2023 11:00:43 +0530 Subject: [PATCH 098/499] FOGL-7398 : Optimized not to send stored data in case of dryrun Signed-off-by: nandan --- C/services/north/north.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/C/services/north/north.cpp b/C/services/north/north.cpp index 1645c3c52b..36c582952d 100755 --- a/C/services/north/north.cpp +++ b/C/services/north/north.cpp @@ -394,10 +394,17 @@ void NorthService::start(string& coreAddress, unsigned short corePort) { logger->debug("Plugin %s requires persisted data", m_pluginName.c_str()); m_pluginData = new PluginData(m_storage); - string key = m_name + m_pluginName; - string storedData = m_pluginData->loadStoredData(key); - logger->debug("Starting plugin with storedData: %s", storedData.c_str()); - northPlugin->startData(storedData); + if (!m_dryRun) + { + string key = m_name + m_pluginName; + string storedData = m_pluginData->loadStoredData(key); + logger->debug("Starting plugin with storedData: %s", storedData.c_str()); + northPlugin->startData(storedData); + } + else + { + northPlugin->startData("{}"); + } } else { From 29b8bc8c567f56b44b037d6818a5297ab1ce4335 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 9 Feb 2023 13:13:07 +0530 Subject: [PATCH 099/499] reset prompt fixes when plugin & reading plugin having different values Signed-off-by: ashish-jabble --- tests/system/lab/reset | 2 +- tests/system/memory_leak/scripts/reset | 2 +- tests/system/python/conftest.py | 2 +- tests/system/python/pair/test_e2e_fledge_pair.py | 2 +- tests/system/python/scripts/package/reset | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/system/lab/reset b/tests/system/lab/reset index 469b3d5a65..95095e2a01 100755 --- a/tests/system/lab/reset +++ b/tests/system/lab/reset @@ -39,7 +39,7 @@ fi echo "Stopping Fledge using systemctl ..." sudo systemctl stop fledge -echo "YES" | /usr/local/fledge/bin/fledge reset || exit 1 +echo -e "YES\nYES" | /usr/local/fledge/bin/fledge reset || exit 1 echo echo "Starting Fledge using systemctl ..." sudo systemctl start fledge diff --git a/tests/system/memory_leak/scripts/reset b/tests/system/memory_leak/scripts/reset index 64e27cf867..2cc67c71f1 100755 --- a/tests/system/memory_leak/scripts/reset +++ b/tests/system/memory_leak/scripts/reset @@ -7,7 +7,7 @@ export FLEDGE_ROOT=$1 cd ${1}/scripts/ && ./fledge stop echo 'resetting fledge' -echo "YES" | ./fledge reset || exit 1 +echo -e "YES\nYES" | ./fledge reset || exit 1 echo echo "Starting Fledge" ./fledge start diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index 8611e555a1..624730b054 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -62,7 +62,7 @@ def reset_and_start_fledge(storage_plugin): else: subprocess.run(["sed -i 's/postgres/sqlite/g' $FLEDGE_ROOT/data/etc/storage.json"], shell=True, check=True) - subprocess.run(["echo YES | $FLEDGE_ROOT/scripts/fledge reset"], shell=True, check=True) + subprocess.run(["echo 'YES\nYES' | $FLEDGE_ROOT/scripts/fledge reset"], shell=True, check=True) subprocess.run(["$FLEDGE_ROOT/scripts/fledge start"], shell=True) stat = subprocess.run(["$FLEDGE_ROOT/scripts/fledge status"], shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) diff --git a/tests/system/python/pair/test_e2e_fledge_pair.py b/tests/system/python/pair/test_e2e_fledge_pair.py index 8996bd1971..233d3c2353 100644 --- a/tests/system/python/pair/test_e2e_fledge_pair.py +++ b/tests/system/python/pair/test_e2e_fledge_pair.py @@ -85,7 +85,7 @@ def reset_and_start_fledge_remote(self, storage_plugin, remote_user, remote_ip, else: subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} sed -i 's/postgres/sqlite/g' {}/data/etc/storage.json".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) - subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};echo YES | $FLEDGE_ROOT/scripts/fledge reset'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) + subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};echo 'YES\nYES' | $FLEDGE_ROOT/scripts/fledge reset'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};$FLEDGE_ROOT/scripts/fledge start'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True) stat = subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={}; $FLEDGE_ROOT/scripts/fledge status'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, stdout=subprocess.PIPE) assert "Fledge not running." not in stat.stdout.decode("utf-8") diff --git a/tests/system/python/scripts/package/reset b/tests/system/python/scripts/package/reset index 0da7a13a92..f5353104e8 100755 --- a/tests/system/python/scripts/package/reset +++ b/tests/system/python/scripts/package/reset @@ -2,7 +2,7 @@ if [ "${FLEDGE_ENVIRONMENT}" == "docker" ]; then /usr/local/fledge/bin/fledge stop - echo "YES" | /usr/local/fledge/bin/fledge reset || exit 1 + echo -e "YES\nYES" | /usr/local/fledge/bin/fledge reset || exit 1 echo /usr/local/fledge/bin/fledge start echo "Fledge Status" @@ -10,7 +10,7 @@ if [ "${FLEDGE_ENVIRONMENT}" == "docker" ]; then else echo "Stopping Fledge using systemctl ..." sudo systemctl stop fledge - echo "YES" | /usr/local/fledge/bin/fledge reset || exit 1 + echo -e "YES\nYES" | /usr/local/fledge/bin/fledge reset || exit 1 echo echo "Starting Fledge using systemctl ..." sudo systemctl start fledge From a87014695d472c063d47104b5c1ec67b6e180620 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 9 Feb 2023 13:49:06 +0530 Subject: [PATCH 100/499] sed expression fixes for storage plugin & reading value updates in storage .json Signed-off-by: ashish-jabble --- tests/system/lab/reset | 19 +++++++++++-------- tests/system/python/conftest.py | 10 +++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/system/lab/reset b/tests/system/lab/reset index 95095e2a01..c28ced7bb2 100755 --- a/tests/system/lab/reset +++ b/tests/system/lab/reset @@ -1,24 +1,27 @@ #!/usr/bin/env bash +FLEDGE_ROOT="/usr/local/fledge" + _postgres() { sudo apt install -y postgresql sudo -u postgres createuser -d "$(whoami)" - sudo sed -i 's/"plugin":{"value":"sqlite"/"plugin":{"value":"postgres"/g' /usr/local/fledge/data/etc/storage.json - sudo sed -i 's/"readingPlugin":{"value":"sqlitememory"/"readingPlugin":{"value":""/g' /usr/local/fledge/data/etc/storage.json + echo $(jq -c --arg STORAGE_PLUGIN_VAL "postgres" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json + echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json } + _sqliteinmemory () { - sudo sed -i 's/"plugin":{"value":"postgres"/"plugin":{"value":"sqlite"/g' /usr/local/fledge/data/etc/storage.json - sudo sed -i 's/"readingPlugin":{"value":""/"readingPlugin":{"value":"sqlitememory"/g' /usr/local/fledge/data/etc/storage.json + echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlite" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json + echo $(jq -c --arg READING_PLUGIN_VAL "sqlitememory" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json } _sqlite () { - sudo sed -i 's/"plugin":{"value":"postgres"/"plugin":{"value":"sqlite"/g' /usr/local/fledge/data/etc/storage.json - sudo sed -i 's/"readingPlugin":{"value":"sqlitememory"/"readingPlugin":{"value":""/g' /usr/local/fledge/data/etc/storage.json + echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlite" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json + echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json } _sqlitelb () { - sudo sed -i 's/"plugin":{"value":"sqlite"/"plugin":{"value":"sqlitelb"/g' /usr/local/fledge/data/etc/storage.json - sudo sed -i 's/"readingPlugin":{"value":"sqlitememory"/"readingPlugin":{"value":""/g' /usr/local/fledge/data/etc/storage.json + echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlitelb" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json + echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json } # check for storage plugin diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index 624730b054..9acc40dbb2 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -57,11 +57,11 @@ def reset_and_start_fledge(storage_plugin): assert os.environ.get('FLEDGE_ROOT') is not None subprocess.run(["$FLEDGE_ROOT/scripts/fledge kill"], shell=True, check=True) - if storage_plugin == 'postgres': - subprocess.run(["sed -i 's/sqlite/postgres/g' $FLEDGE_ROOT/data/etc/storage.json"], shell=True, check=True) - else: - subprocess.run(["sed -i 's/postgres/sqlite/g' $FLEDGE_ROOT/data/etc/storage.json"], shell=True, check=True) - + storage_plugin_val = "postgres" if storage_plugin == 'postgres' else "sqlite" + subprocess.run( + ["echo $(jq -c --arg STORAGE_PLUGIN_VAL {} '.plugin.value=$STORAGE_PLUGIN_VAL' " + "$FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json".format(storage_plugin_val)], + shell=True, check=True) subprocess.run(["echo 'YES\nYES' | $FLEDGE_ROOT/scripts/fledge reset"], shell=True, check=True) subprocess.run(["$FLEDGE_ROOT/scripts/fledge start"], shell=True) stat = subprocess.run(["$FLEDGE_ROOT/scripts/fledge status"], shell=True, stdout=subprocess.PIPE, From 8bcc3342c712e4823cb334d6e80b7aeca61dd658 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 9 Feb 2023 16:03:01 +0530 Subject: [PATCH 101/499] lab reset script refinements Signed-off-by: ashish-jabble --- tests/system/lab/reset | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/system/lab/reset b/tests/system/lab/reset index c28ced7bb2..bc920e9df4 100755 --- a/tests/system/lab/reset +++ b/tests/system/lab/reset @@ -5,23 +5,23 @@ FLEDGE_ROOT="/usr/local/fledge" _postgres() { sudo apt install -y postgresql sudo -u postgres createuser -d "$(whoami)" - echo $(jq -c --arg STORAGE_PLUGIN_VAL "postgres" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json - echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "postgres" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true } _sqliteinmemory () { - echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlite" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json - echo $(jq -c --arg READING_PLUGIN_VAL "sqlitememory" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlite" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "sqlitememory" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true } _sqlite () { - echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlite" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json - echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlite" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true } _sqlitelb () { - echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlitelb" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json - echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlitelb" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true } # check for storage plugin @@ -42,7 +42,7 @@ fi echo "Stopping Fledge using systemctl ..." sudo systemctl stop fledge -echo -e "YES\nYES" | /usr/local/fledge/bin/fledge reset || exit 1 +echo -e "YES\nYES" | $FLEDGE_ROOT/bin/fledge reset || exit 1 echo echo "Starting Fledge using systemctl ..." sudo systemctl start fledge From 1e51a182a8a6f8c18cfed4866c5346268e1cb770 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 9 Feb 2023 16:03:22 +0530 Subject: [PATCH 102/499] readings-plugin option added in conftest Signed-off-by: ashish-jabble --- tests/system/python/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index 9acc40dbb2..c49728b830 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -575,6 +575,8 @@ def _disable_sch(fledge_url, sch_name): def pytest_addoption(parser): parser.addoption("--storage-plugin", action="store", default="sqlite", help="Database plugin to use for tests") + parser.addoption("--readings-plugin", action="store", default="Use main plugin", + help="Readings plugin to use for tests") parser.addoption("--fledge-url", action="store", default="localhost:8081", help="Fledge client api url") parser.addoption("--use-pip-cache", action="store", default=False, @@ -706,6 +708,11 @@ def storage_plugin(request): return request.config.getoption("--storage-plugin") +@pytest.fixture +def readings_plugin(request): + return request.config.getoption("--readings-plugin") + + @pytest.fixture def remote_user(request): return request.config.getoption("--remote-user") From d46de0fa8b97cd1f1287aa25338936a10f5b224d Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Thu, 9 Feb 2023 08:34:31 -0500 Subject: [PATCH 103/499] Fix version check when sending data Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/plugin.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index e7fa666f17..150858eb0a 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -895,13 +895,11 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, // Check if the endpoint is PI Web API and if the PI Web API server is available if (!IsPIWebAPIConnected(connInfo, version)) { - Logger::getLogger()->fatal("OMF Endpoint is not available"); + Logger::getLogger()->warn("PI Web API server %s is not available. Unable to send data to PI", connInfo->hostAndPort.c_str()); return 0; } - // FIXME - The above call is not working. Investigate why? FOGL-7293 - // Above call does not always populate version - if (version.empty()) + if (version.empty() && connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) { PIWebAPIGetVersion(connInfo, version, false); } From de3235014494af9edd59193e93ab16853468a6cd Mon Sep 17 00:00:00 2001 From: nandan Date: Thu, 9 Feb 2023 19:41:44 +0530 Subject: [PATCH 104/499] FOGL-7353 : Added unit test case and fixed review comments Signed-off-by: nandan --- C/common/reading_set.cpp | 28 +-- .../unit/C/services/core/reading_set_copy.cpp | 184 ++++++++++++++++++ 2 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 tests/unit/C/services/core/reading_set_copy.cpp diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 0a57679c26..8747bac98b 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -198,40 +198,44 @@ ReadingSet::append(const vector& readings) bool ReadingSet::copy(const ReadingSet& src) { - vector *readings = new vector; + vector readings; bool copyResult = true; try { - for ( auto rs : src.getAllReadings()) + // Iterate over all the readings in ReadingSet + for ( auto &reading : src.getAllReadings()) { - std::string assetName = rs->getAssetName(); - for ( auto dp : rs->getReadingData()) + std::string assetName = reading->getAssetName(); + std::vector dataPoints; + // Iterate over all the datapoints associated with one reading + for ( auto &dp : reading->getReadingData()) { std::string dataPointName = dp->getName(); DatapointValue dv = dp->getData(); - Datapoint *value = new Datapoint(dataPointName, dv); - Reading *in = new Reading(assetName, value); - readings->push_back(in); + dataPoints.emplace_back(new Datapoint(dataPointName, dv)); + } + Reading *in = new Reading(assetName, dataPoints); + readings.emplace_back(in); } } catch (std::bad_alloc& ex) { + Logger::getLogger()->error("Insufficient memory, failed while copying %d reading from ReadingSet %s ",readings.size()+1, ex.what()); copyResult = false; - readings->clear(); - Logger::getLogger()->error("Insufficient memory :: failed while copying :%d: reading from ReadingSet :%s: ",readings->size()+1, ex.what()); + readings.clear(); } catch (std::exception& ex) { + Logger::getLogger()->error("Unknown exception, failed while copying %d reading from ReadingSet %s ",readings.size()+1, ex.what()); copyResult = false; - readings->clear(); - Logger::getLogger()->error("Unknown exception :: failed while copying :%d: reading from ReadingSet :%s: ",readings->size()+1, ex.what()); + readings.clear(); } //Append if All elements have been copied successfully if (copyResult) { - append(*readings); + append(readings); } return copyResult; diff --git a/tests/unit/C/services/core/reading_set_copy.cpp b/tests/unit/C/services/core/reading_set_copy.cpp new file mode 100644 index 0000000000..1a2bf467c3 --- /dev/null +++ b/tests/unit/C/services/core/reading_set_copy.cpp @@ -0,0 +1,184 @@ +/* + * unit tests - FOGL-7353 Fledge Copy ReadingSet + * + * Copyright (c) 2023 Dianomic Systems, Inc. + * + * Released under the Apache 2.0 Licence + * + * Author: Devki Nandan Ghildiyal + */ + +#include +#include +#include +#include + +using namespace std; + + +const char *ReadingJSON = R"( + { + "count" : 1, "rows" : [ + { + "id": 1, "asset_code": "temperature", + "reading": { "degrees": 200.65 }, + "user_ts": "2023-02-06 14:00:08.532958", + "ts": "2023-02-06 14:47:18.872708" + } + ] + } +)"; + +const char *NestedReadingJSON = R"( + { + "count" : 1, "rows" : [ + { + "id": 1, "asset_code": "SiteStatus", + "reading": { "degrees": [200.65,34.45,500.36],"pressure": {"floor1":30, "floor2":34, "floor3":36 } }, + "user_ts": "2023-02-06 14:00:08.532958", + "ts": "2023-02-06 14:47:18.872708" + } + ] + } +)"; + +TEST(READINGSET, DeepCopyCheckReadingFromNestedJSON) +{ + ReadingSet* readingSet1 = new ReadingSet(NestedReadingJSON); + ReadingSet *readingSet2 = new ReadingSet(); + readingSet2->copy(*readingSet1); + + auto r1 = readingSet1->getAllReadings(); + auto dp1 = r1[0]->getReadingData(); + + // Fetch neted datapoints + ASSERT_EQ (dp1[0]->getName(),"degrees"); + ASSERT_EQ(dp1[0]->getData().toString(),"[200.65, 34.45, 500.36]"); + ASSERT_EQ (dp1[1]->getName(),"pressure"); + ASSERT_EQ(dp1[1]->getData().toString(),"{\"floor1\":30, \"floor2\":34, \"floor3\":36}"); + + auto r2 = readingSet2->getAllReadings(); + auto dp2 = r2[0]->getReadingData(); + ASSERT_EQ (dp2[0]->getName(),"degrees"); + ASSERT_EQ(dp2[0]->getData().toString(),"[200.65, 34.45, 500.36]"); + ASSERT_EQ (dp2[1]->getName(),"pressure"); + ASSERT_EQ(dp2[1]->getData().toString(),"{\"floor1\":30, \"floor2\":34, \"floor3\":36}"); + + // Check the address od datapoints + ASSERT_NE(dp1[0],dp2[0]); + ASSERT_NE(dp1[1],dp2[1]); + + // Confirm there is no error of double delete + delete readingSet1; + delete readingSet2; + +} + +TEST(READINGSET, DeepCopyCheckReadingFromJSON) +{ + ReadingSet* readingSet1 = new ReadingSet(ReadingJSON); + ReadingSet *readingSet2 = new ReadingSet(); + readingSet2->copy(*readingSet1); + + delete readingSet1; + + // Fetch value after deleting readingSet1 to check readingSet2 is pointing to different memory location + for ( auto reading : readingSet2->getAllReadings()) + { + for ( auto &dp : reading->getReadingData()) + { + std::string dataPointName = dp->getName(); + DatapointValue dv = dp->getData(); + ASSERT_EQ (dataPointName,"degrees"); + ASSERT_EQ (dv.toDouble(),200.65); + } + + } + + // Confirm there is no error of double delete + delete readingSet2; + +} + +TEST(READINGSET, DeepCopyCheckReadingFromVector) +{ + vector* readings1 = new vector; + long integerValue = 100; + DatapointValue dpv(integerValue); + Datapoint *value = new Datapoint("kPa", dpv); + Reading *in = new Reading("Pressure", value); + readings1->push_back(in); + + + ReadingSet *readingSet1 = new ReadingSet(readings1); + ReadingSet *readingSet2 = new ReadingSet(); + readingSet2->copy(*readingSet1); + + delete readingSet1; + + // Fetch value after deleting readingSet1 to check readingSet2 is pointing to different memory location + for ( auto reading : readingSet2->getAllReadings()) + { + for ( auto &dp : reading->getReadingData()) + { + std::string dataPointName = dp->getName(); + DatapointValue dv = dp->getData(); + ASSERT_EQ (dataPointName,"kPa"); + ASSERT_EQ (dv.toInt(),100); + } + + } + // Confirm there is no error of double delete + delete readingSet2; +} + +TEST(READINGSET, DeepCopyCheckAppend) +{ + vector* readings1 = new vector; + long integerValue = 100; + DatapointValue dpv(integerValue); + Datapoint *value = new Datapoint("kPa", dpv); + Reading *in = new Reading("Pressure", value); + readings1->push_back(in); + ReadingSet *readingSet1 = new ReadingSet(readings1); + + vector* readings2 = new vector; + long integerValue2 = 400; + DatapointValue dpv2(integerValue2); + Datapoint *value2 = new Datapoint("kPa", dpv2); + Reading *in2 = new Reading("Pressure", value2); + readings2->push_back(in2); + ReadingSet *readingSet2 = new ReadingSet(readings2); + + readingSet2->copy(*readingSet1); + + int size = readingSet2->getAllReadings().size(); + ASSERT_EQ (size,2); + +} + +TEST(READINGSET, DeepCopyCheckAddress) +{ + vector* readings1 = new vector; + long integerValue = 100; + DatapointValue dpv(integerValue); + Datapoint *value = new Datapoint("kPa", dpv); + Reading *in = new Reading("Pressure", value); + readings1->push_back(in); + + + ReadingSet *readingSet1 = new ReadingSet(readings1); + ReadingSet *readingSet2 = new ReadingSet(); + readingSet2->copy(*readingSet1); + + auto r1 = readingSet1->getAllReadings(); + auto dp1 = r1[0]->getReadingData(); + + auto r2 = readingSet2->getAllReadings(); + auto dp2 = r2[0]->getReadingData(); + + ASSERT_NE (dp1,dp2); + + +} + From 970492bfff4a46ae7eff866815e2d22069dff83a Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 10 Feb 2023 12:58:42 +0530 Subject: [PATCH 105/499] FOGL-7353 : Corrected whitespaces and fromatting Signed-off-by: nandan --- C/common/reading_set.cpp | 4 +- .../unit/C/services/core/reading_set_copy.cpp | 127 ++++++++---------- 2 files changed, 60 insertions(+), 71 deletions(-) diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 8747bac98b..d897fd7f60 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -221,13 +221,13 @@ ReadingSet::copy(const ReadingSet& src) } catch (std::bad_alloc& ex) { - Logger::getLogger()->error("Insufficient memory, failed while copying %d reading from ReadingSet %s ",readings.size()+1, ex.what()); + Logger::getLogger()->error("Insufficient memory, failed while copying %d reading from ReadingSet, %s ",readings.size()+1, ex.what()); copyResult = false; readings.clear(); } catch (std::exception& ex) { - Logger::getLogger()->error("Unknown exception, failed while copying %d reading from ReadingSet %s ",readings.size()+1, ex.what()); + Logger::getLogger()->error("Unknown exception, failed while copying %d reading from ReadingSet, %s ",readings.size()+1, ex.what()); copyResult = false; readings.clear(); } diff --git a/tests/unit/C/services/core/reading_set_copy.cpp b/tests/unit/C/services/core/reading_set_copy.cpp index 1a2bf467c3..93c279b5c8 100644 --- a/tests/unit/C/services/core/reading_set_copy.cpp +++ b/tests/unit/C/services/core/reading_set_copy.cpp @@ -1,5 +1,5 @@ /* - * unit tests - FOGL-7353 Fledge Copy ReadingSet + * unit tests - FOGL-7353 Fledge Copy ReadingSet * * Copyright (c) 2023 Dianomic Systems, Inc. * @@ -15,7 +15,6 @@ using namespace std; - const char *ReadingJSON = R"( { "count" : 1, "rows" : [ @@ -44,89 +43,84 @@ const char *NestedReadingJSON = R"( TEST(READINGSET, DeepCopyCheckReadingFromNestedJSON) { - ReadingSet* readingSet1 = new ReadingSet(NestedReadingJSON); + ReadingSet *readingSet1 = new ReadingSet(NestedReadingJSON); ReadingSet *readingSet2 = new ReadingSet(); readingSet2->copy(*readingSet1); auto r1 = readingSet1->getAllReadings(); auto dp1 = r1[0]->getReadingData(); - - // Fetch neted datapoints - ASSERT_EQ (dp1[0]->getName(),"degrees"); - ASSERT_EQ(dp1[0]->getData().toString(),"[200.65, 34.45, 500.36]"); - ASSERT_EQ (dp1[1]->getName(),"pressure"); - ASSERT_EQ(dp1[1]->getData().toString(),"{\"floor1\":30, \"floor2\":34, \"floor3\":36}"); + + // Fetch neted datapoints + ASSERT_EQ(dp1[0]->getName(), "degrees"); + ASSERT_EQ(dp1[0]->getData().toString(), "[200.65, 34.45, 500.36]"); + ASSERT_EQ(dp1[1]->getName(), "pressure"); + ASSERT_EQ(dp1[1]->getData().toString(), "{\"floor1\":30, \"floor2\":34, \"floor3\":36}"); auto r2 = readingSet2->getAllReadings(); auto dp2 = r2[0]->getReadingData(); - ASSERT_EQ (dp2[0]->getName(),"degrees"); - ASSERT_EQ(dp2[0]->getData().toString(),"[200.65, 34.45, 500.36]"); - ASSERT_EQ (dp2[1]->getName(),"pressure"); - ASSERT_EQ(dp2[1]->getData().toString(),"{\"floor1\":30, \"floor2\":34, \"floor3\":36}"); + ASSERT_EQ(dp2[0]->getName(), "degrees"); + ASSERT_EQ(dp2[0]->getData().toString(), "[200.65, 34.45, 500.36]"); + ASSERT_EQ(dp2[1]->getName(), "pressure"); + ASSERT_EQ(dp2[1]->getData().toString(), "{\"floor1\":30, \"floor2\":34, \"floor3\":36}"); // Check the address od datapoints - ASSERT_NE(dp1[0],dp2[0]); - ASSERT_NE(dp1[1],dp2[1]); - + ASSERT_NE(dp1[0], dp2[0]); + ASSERT_NE(dp1[1], dp2[1]); + // Confirm there is no error of double delete delete readingSet1; delete readingSet2; - } TEST(READINGSET, DeepCopyCheckReadingFromJSON) { - ReadingSet* readingSet1 = new ReadingSet(ReadingJSON); + ReadingSet *readingSet1 = new ReadingSet(ReadingJSON); ReadingSet *readingSet2 = new ReadingSet(); readingSet2->copy(*readingSet1); delete readingSet1; // Fetch value after deleting readingSet1 to check readingSet2 is pointing to different memory location - for ( auto reading : readingSet2->getAllReadings()) + for (auto reading : readingSet2->getAllReadings()) { - for ( auto &dp : reading->getReadingData()) + for (auto &dp : reading->getReadingData()) { - std::string dataPointName = dp->getName(); - DatapointValue dv = dp->getData(); - ASSERT_EQ (dataPointName,"degrees"); - ASSERT_EQ (dv.toDouble(),200.65); + std::string dataPointName = dp->getName(); + DatapointValue dv = dp->getData(); + ASSERT_EQ(dataPointName, "degrees"); + ASSERT_EQ(dv.toDouble(), 200.65); } - } // Confirm there is no error of double delete delete readingSet2; - } TEST(READINGSET, DeepCopyCheckReadingFromVector) { - vector* readings1 = new vector; + vector *readings1 = new vector; long integerValue = 100; - DatapointValue dpv(integerValue); - Datapoint *value = new Datapoint("kPa", dpv); - Reading *in = new Reading("Pressure", value); + DatapointValue dpv(integerValue); + Datapoint *value = new Datapoint("kPa", dpv); + Reading *in = new Reading("Pressure", value); readings1->push_back(in); - - - ReadingSet *readingSet1 = new ReadingSet(readings1); + + ReadingSet *readingSet1 = new ReadingSet(readings1); ReadingSet *readingSet2 = new ReadingSet(); readingSet2->copy(*readingSet1); delete readingSet1; - + // Fetch value after deleting readingSet1 to check readingSet2 is pointing to different memory location - for ( auto reading : readingSet2->getAllReadings()) + for (auto reading : readingSet2->getAllReadings()) { - for ( auto &dp : reading->getReadingData()) + for (auto &dp : reading->getReadingData()) { - std::string dataPointName = dp->getName(); - DatapointValue dv = dp->getData(); - ASSERT_EQ (dataPointName,"kPa"); - ASSERT_EQ (dv.toInt(),100); + std::string dataPointName = dp->getName(); + DatapointValue dv = dp->getData(); + ASSERT_EQ(dataPointName, "kPa"); + ASSERT_EQ(dv.toInt(), 100); } - } // Confirm there is no error of double delete delete readingSet2; @@ -134,51 +128,46 @@ TEST(READINGSET, DeepCopyCheckReadingFromVector) TEST(READINGSET, DeepCopyCheckAppend) { - vector* readings1 = new vector; + vector *readings1 = new vector; long integerValue = 100; - DatapointValue dpv(integerValue); - Datapoint *value = new Datapoint("kPa", dpv); - Reading *in = new Reading("Pressure", value); + DatapointValue dpv(integerValue); + Datapoint *value = new Datapoint("kPa", dpv); + Reading *in = new Reading("Pressure", value); readings1->push_back(in); - ReadingSet *readingSet1 = new ReadingSet(readings1); - - vector* readings2 = new vector; + ReadingSet *readingSet1 = new ReadingSet(readings1); + + vector *readings2 = new vector; long integerValue2 = 400; - DatapointValue dpv2(integerValue2); - Datapoint *value2 = new Datapoint("kPa", dpv2); - Reading *in2 = new Reading("Pressure", value2); + DatapointValue dpv2(integerValue2); + Datapoint *value2 = new Datapoint("kPa", dpv2); + Reading *in2 = new Reading("Pressure", value2); readings2->push_back(in2); ReadingSet *readingSet2 = new ReadingSet(readings2); readingSet2->copy(*readingSet1); - int size = readingSet2->getAllReadings().size(); - ASSERT_EQ (size,2); - + int size = readingSet2->getAllReadings().size(); + ASSERT_EQ(size, 2); } TEST(READINGSET, DeepCopyCheckAddress) { - vector* readings1 = new vector; + vector *readings1 = new vector; long integerValue = 100; - DatapointValue dpv(integerValue); - Datapoint *value = new Datapoint("kPa", dpv); - Reading *in = new Reading("Pressure", value); + DatapointValue dpv(integerValue); + Datapoint *value = new Datapoint("kPa", dpv); + Reading *in = new Reading("Pressure", value); readings1->push_back(in); - - - ReadingSet *readingSet1 = new ReadingSet(readings1); + + ReadingSet *readingSet1 = new ReadingSet(readings1); ReadingSet *readingSet2 = new ReadingSet(); readingSet2->copy(*readingSet1); - + auto r1 = readingSet1->getAllReadings(); - auto dp1 = r1[0]->getReadingData(); - - auto r2 = readingSet2->getAllReadings(); - auto dp2 = r2[0]->getReadingData(); + auto dp1 = r1[0]->getReadingData(); - ASSERT_NE (dp1,dp2); + auto r2 = readingSet2->getAllReadings(); + auto dp2 = r2[0]->getReadingData(); - + ASSERT_NE(dp1, dp2); } - From e9ae4aebed2625cc270ecca8f5654cba48d1d5b5 Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 10 Feb 2023 14:18:48 +0530 Subject: [PATCH 106/499] FOGL-7398: Modified not to call start and shutdown method on north plugin in case of dryrun Signed-off-by: nandan --- C/services/north/north.cpp | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/C/services/north/north.cpp b/C/services/north/north.cpp index 36c582952d..c84cf40919 100755 --- a/C/services/north/north.cpp +++ b/C/services/north/north.cpp @@ -390,27 +390,24 @@ void NorthService::start(string& coreAddress, unsigned short corePort) } // Deal with persisted data and start the plugin - if (northPlugin->persistData()) + if (!m_dryRun) { - logger->debug("Plugin %s requires persisted data", m_pluginName.c_str()); - m_pluginData = new PluginData(m_storage); - if (!m_dryRun) + if (northPlugin->persistData()) { + logger->debug("Plugin %s requires persisted data", m_pluginName.c_str()); + m_pluginData = new PluginData(m_storage); string key = m_name + m_pluginName; string storedData = m_pluginData->loadStoredData(key); logger->debug("Starting plugin with storedData: %s", storedData.c_str()); northPlugin->startData(storedData); + } else { - northPlugin->startData("{}"); + logger->debug("Start %s plugin", m_pluginName.c_str()); + northPlugin->start(); } } - else - { - logger->debug("Start %s plugin", m_pluginName.c_str()); - northPlugin->start(); - } // Create default security category this->createSecurityCategories(m_mgtClient, m_dryRun); @@ -471,7 +468,7 @@ void NorthService::start(string& coreAddress, unsigned short corePort) // Shutdown the north plugin - if (northPlugin) + if (northPlugin && !m_dryRun) { if (m_pluginData) { From b64bf418e62f473a0c4268946c989de3f9bb7c82 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 10 Feb 2023 10:43:18 +0000 Subject: [PATCH 107/499] FOGL-7421 Add mechanism for C++ plugins to add audit data and implement (#961) * FOGL-7421 Add mechanism for C++ plugins to add audit data and implement example in OMF north plugin Signed-off-by: Mark Riddoch * Update unit tests Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/audit_logger.cpp | 76 +++++++++++++++++++ C/common/include/audit_logger.h | 39 ++++++++++ C/common/include/process.h | 2 + C/common/process.cpp | 3 + C/plugins/north/OMF/include/omf.h | 13 +++- C/plugins/north/OMF/omf.cpp | 34 ++++++++- C/plugins/north/OMF/plugin.cpp | 13 ++-- C/services/north/include/north_service.h | 2 + C/services/north/north.cpp | 3 + C/services/south/include/south_service.h | 2 + C/services/south/south.cpp | 3 + .../C/plugins/common/test_omf_translation.cpp | 16 ++-- 12 files changed, 186 insertions(+), 20 deletions(-) create mode 100644 C/common/audit_logger.cpp create mode 100644 C/common/include/audit_logger.h diff --git a/C/common/audit_logger.cpp b/C/common/audit_logger.cpp new file mode 100644 index 0000000000..dca431f0e2 --- /dev/null +++ b/C/common/audit_logger.cpp @@ -0,0 +1,76 @@ +/* + * Fledge Singleton Audit Logger interface + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ + +#include + +AuditLogger *AuditLogger::m_instance = 0; + +using namespace std; + +/** + * Constructor for an audit logger that is passed + * the management client. This must be called early in + * a service or task creation before any audit logs are + * created. + * + * @param mgmt Pointer to the management client + */ +AuditLogger::AuditLogger(ManagementClient *mgmt) : m_mgmt(mgmt) +{ + m_instance = this; +} + +/** + * Destructor for an audit logger + */ +AuditLogger::~AuditLogger() +{ +} + +/** + * Get the audit logger singleton + */ +AuditLogger *AuditLogger::getLogger() +{ + if (!m_instance) + { + Logger::getLogger()->error("An attempt has been made to obtain the audit logger before it has been created."); + } + return m_instance; +} + +void AuditLogger::auditLog(const string& code, + const string& level, + const string& data) +{ + if (m_instance) + { + m_instance->audit(code, level, data); + } + else + { + Logger::getLogger()->error("An attempt has been made to log an audit event when no audit logger is available"); + Logger::getLogger()->error("Audit event is: %s, %s, %s", code.c_str(), level.c_str(), data.c_str()); + } +} + +/** + * Log an audit message + * + * @param code The audit code + * @param level The audit level + * @param data Optional data associated with the audit entry + */ +void AuditLogger::audit(const string& code, + const string& level, + const string& data) +{ + m_mgmt->addAuditEntry(code, level, data); +} diff --git a/C/common/include/audit_logger.h b/C/common/include/audit_logger.h new file mode 100644 index 0000000000..2f0f5b5fae --- /dev/null +++ b/C/common/include/audit_logger.h @@ -0,0 +1,39 @@ +#ifndef _AUDIT_LOGGER_H +#define _AUDIT_LOGGER_H +/* + * Fledge Singleton Audit Logger interface + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ + +#include +#include +#include + +/** + * A singleton class for access to the audit logger within services. The + * service must create this with the maagement client before any access to it is used. + */ +class AuditLogger { + public: + AuditLogger(ManagementClient *mgmt); + ~AuditLogger(); + + static AuditLogger *getLogger(); + static void auditLog(const std::string& code, + const std::string& level, + const std::string& data = ""); + + void audit(const std::string& code, + const std::string& level, + const std::string& data = ""); + + private: + static AuditLogger *m_instance; + ManagementClient *m_mgmt; +}; +#endif diff --git a/C/common/include/process.h b/C/common/include/process.h index bb54cf2bbe..e992cf7ccb 100644 --- a/C/common/include/process.h +++ b/C/common/include/process.h @@ -12,6 +12,7 @@ #include #include +#include #include /** @@ -44,6 +45,7 @@ class FledgeProcess ManagementClient* m_client; StorageClient* m_storage; Logger* m_logger; + AuditLogger* m_auditLogger; }; #endif diff --git a/C/common/process.cpp b/C/common/process.cpp index c580d11b56..4deed3f1fc 100644 --- a/C/common/process.cpp +++ b/C/common/process.cpp @@ -144,6 +144,9 @@ FledgeProcess::FledgeProcess(int argc, char** argv) : // Connection to Fledge core microservice m_client = new ManagementClient(m_core_mngt_host, m_core_mngt_port); + // Create Audit Logger + m_auditLogger = new AuditLogger(m_client); + // Storage layer handle ServiceRecord storageInfo("Fledge Storage"); diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index a7995fde90..78ae7efee3 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -95,12 +95,14 @@ class OMF * Constructor: * pass server URL path, OMF_type_id and producerToken. */ - OMF(HttpSender& sender, + OMF(const std::string& name, + HttpSender& sender, const std::string& path, const long typeId, const std::string& producerToken); - OMF(HttpSender& sender, + OMF(const std::string& name, + HttpSender& sender, const std::string& path, std::map& types, const std::string& producerToken); @@ -223,7 +225,7 @@ class OMF bool getAFMapEmptyMetadata() const { return m_AFMapEmptyMetadata; }; bool getConnected() const { return m_connected; }; - void setConnected(const bool connectionStatus) { m_connected = connectionStatus; }; + void setConnected(const bool connectionStatus); void setLegacyMode(bool legacy) { m_legacy = legacy; }; @@ -499,6 +501,11 @@ class OMF * Force the data to be sent using the legacy, complex OMF types */ bool m_legacy; + + /** + * Service name + */ + const std::string m_name; }; /** diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index 80036d92af..972d1faf41 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -32,6 +32,7 @@ #include #include +#include using namespace std; using namespace rapidjson; @@ -223,7 +224,8 @@ const string& OMFData::OMFdataVal() const /** * OMF constructor */ -OMF::OMF(HttpSender& sender, +OMF::OMF(const string& name, + HttpSender& sender, const string& path, const long id, const string& token) : @@ -231,7 +233,8 @@ OMF::OMF(HttpSender& sender, m_typeId(id), m_producerToken(token), m_sender(sender), - m_legacy(false) + m_legacy(false), + m_name(name) { m_lastError = false; m_changeTypeId = false; @@ -243,14 +246,16 @@ OMF::OMF(HttpSender& sender, * OMF constructor with per asset data types */ -OMF::OMF(HttpSender& sender, +OMF::OMF(const string& name, + HttpSender& sender, const string& path, map& types, const string& token) : m_path(path), m_OMFDataTypes(&types), m_producerToken(token), - m_sender(sender) + m_sender(sender), + m_name(name) { // Get starting type-id sequence or set the default value auto it = (*m_OMFDataTypes).find(FAKE_ASSET_KEY); @@ -4734,3 +4739,24 @@ string AFDataMessage; } return AFDataMessage; } + +/** + * Set the connection state + */ +void OMF::setConnected(const bool connectionStatus) +{ + if (connectionStatus != m_connected) + { + // Send an audit event for the change of state + string data = "{ \"plugin\" : \"OMF\", \"service\" : \"" + m_name + "\" }"; + if (!connectionStatus) + { + AuditLogger::auditLog("NHDWN", "ERROR", data); + } + else + { + AuditLogger::auditLog("NHAVL", "INFORMATION", data); + } + } + m_connected = connectionStatus; +} diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 150858eb0a..5e54c84fdd 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -410,15 +410,15 @@ typedef struct { HttpSender *sender; // HTTPS connection OMF *omf; // OMF data protocol - bool sendFullStructure; // It sends the minimum OMF structural messages to load data into Data Archive if disabled + bool sendFullStructure; // It sends the minimum OMF structural messages to load data into Data Archive if disabled bool compression; // whether to compress readings' data string protocol; // http / https string hostAndPort; // hostname:port for SimpleHttps - unsigned int retrySleepTime; // Seconds between each retry + unsigned int retrySleepTime; // Seconds between each retry unsigned int maxRetry; // Max number of retries in the communication unsigned int timeout; // connect and operation timeout - string path; // PI Server application path - long typeId; // OMF protocol type-id prefix + string path; // PI Server application path + long typeId; // OMF protocol type-id prefix string producerToken; // PI Server connector token string formatNumber; // OMF protocol Number format string formatInteger; // OMF protocol Integer format @@ -458,6 +458,7 @@ typedef struct assetsDataTypes; string omfversion; bool legacy; + string name; } CONNECTOR_INFO; unsigned long calcTypeShort (const string& dataTypes); @@ -520,6 +521,7 @@ PLUGIN_HANDLE plugin_init(ConfigCategory* configData) */ // Allocate connector struct CONNECTOR_INFO *connInfo = new CONNECTOR_INFO; + connInfo->name = configData->getName(); // PIServerEndpoint handling string PIServerEndpoint = configData->getValue("PIServerEndpoint"); @@ -980,7 +982,8 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, } // Allocate the OMF class that implements the PI Server data protocol - connInfo->omf = new OMF(*connInfo->sender, + connInfo->omf = new OMF(connInfo->name, + *connInfo->sender, connInfo->path, connInfo->assetsDataTypes, connInfo->producerToken); diff --git a/C/services/north/include/north_service.h b/C/services/north/include/north_service.h index b841921354..77581d670f 100644 --- a/C/services/north/include/north_service.h +++ b/C/services/north/include/north_service.h @@ -18,6 +18,7 @@ #include #include #include +#include #define SERVICE_NAME "Fledge North" @@ -78,5 +79,6 @@ class NorthService : public ServiceAuthHandler { bool m_allowControl; bool m_dryRun; bool m_requestRestart; + AuditLogger *m_auditLogger; }; #endif diff --git a/C/services/north/north.cpp b/C/services/north/north.cpp index 1645c3c52b..15dcfef283 100755 --- a/C/services/north/north.cpp +++ b/C/services/north/north.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #define SERVICE_TYPE "Northbound" @@ -328,6 +329,8 @@ void NorthService::start(string& coreAddress, unsigned short corePort) m_token); // Token); m_mgtClient = new ManagementClient(coreAddress, corePort); + m_auditLogger = new AuditLogger(m_mgtClient); + // Create an empty North category if one doesn't exist DefaultConfigCategory northConfig(string("North"), string("{}")); northConfig.setDescription("North"); diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index 3731ab128d..814b134439 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -18,6 +18,7 @@ #include #include #include +#include #define MAX_SLEEP 5 // Maximum number of seconds the service will sleep during a poll cycle @@ -119,6 +120,7 @@ class SouthService : public ServiceAuthHandler { std::condition_variable m_pollCV; std::mutex m_pollMutex; bool m_doPoll; + AuditLogger *m_auditLogger; }; #endif diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index f1075b927c..90ebf014fa 100755 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -286,6 +286,9 @@ void SouthService::start(string& coreAddress, unsigned short corePort) // Allocate and save ManagementClient object m_mgtClient = new ManagementClient(coreAddress, corePort); + // Create the audit logger instance + m_auditLogger = new AuditLogger(m_mgtClient); + // Create an empty South category if one doesn't exist DefaultConfigCategory southConfig(string("South"), string("{}")); southConfig.setDescription("South"); diff --git a/tests/unit/C/plugins/common/test_omf_translation.cpp b/tests/unit/C/plugins/common/test_omf_translation.cpp index 42c70ad399..90eecc6065 100644 --- a/tests/unit/C/plugins/common/test_omf_translation.cpp +++ b/tests/unit/C/plugins/common/test_omf_translation.cpp @@ -318,7 +318,7 @@ TEST(OMF_transation, OneReading) TEST(OMF_transation, SuperSet) { SimpleHttps sender("0.0.0.0:0", 10, 10, 10, 1); - OMF omf(sender, "/", 1, "ABC"); + OMF omf("test", sender, "/", 1, "ABC"); // Build a ReadingSet from JSON ReadingSet readingSet(readings_with_different_datapoints); vectorreadings = readingSet.getAllReadings(); @@ -444,7 +444,7 @@ TEST(OMF_AfHierarchy, HandleAFMapNamesGood) // Dummy initializations SimpleHttps sender("0.0.0.0:0", 10, 10, 10, 1); - OMF omf(sender, "/", 1, "ABC"); + OMF omf("test", sender, "/", 1, "ABC"); omf.setAFMap(af_hierarchy_test01); @@ -474,7 +474,7 @@ TEST(OMF_AfHierarchy, HandleAFMapEmpty) // Dummy initializations SimpleHttps sender("0.0.0.0:0", 10, 10, 10, 1); - OMF omf(sender, "/", 1, "ABC"); + OMF omf("test", sender, "/", 1, "ABC"); // Test omf.setAFMap(af_hierarchy_test02); @@ -494,7 +494,7 @@ TEST(OMF_AfHierarchy, HandleAFMapNamesBad) // Dummy initializations SimpleHttps sender("0.0.0.0:0", 10, 10, 10, 1); - OMF omf(sender, "/", 1, "ABC"); + OMF omf("test", sender, "/", 1, "ABC"); omf.setAFMap(af_hierarchy_test01); MetadataRulesExist = omf.getMetadataRulesExist(); @@ -510,7 +510,7 @@ TEST(PiServer_NamingRules, NamingRulesCheck) // Dummy initializations SimpleHttps sender("0.0.0.0:0", 10, 10, 10, 1); - OMF omf(sender, "/", 1, "ABC"); + OMF omf("test", sender, "/", 1, "ABC"); ASSERT_EQ(omf.ApplyPIServerNamingRulesInvalidChars("asset_1", &changed), "asset_1"); ASSERT_EQ(changed, false); @@ -547,7 +547,7 @@ TEST(PiServer_NamingRules, Suffix) string assetName; // Dummy initializations SimpleHttps sender("0.0.0.0:0", 10, 10, 10, 1); - OMF omf(sender, "/", 1, "ABC"); + OMF omf("test", sender, "/", 1, "ABC"); assetName = "asset_1"; @@ -578,7 +578,7 @@ TEST(PiServer_NamingRules, Prefix) // Dummy initializations SimpleHttps sender("0.0.0.0:0", 10, 10, 10, 1); - OMF omf(sender, "/", 1, "ABC"); + OMF omf("test", sender, "/", 1, "ABC"); asset="asset_1"; @@ -777,4 +777,4 @@ TEST(OMF_hints, variableExtract) ASSERT_EQ (variable, "${Orange:unknown12}"); ASSERT_EQ (value, "Orange"); ASSERT_EQ (deafult, "unknown12"); -} \ No newline at end of file +} From d047f01241c383c64acb6d4237b056634a8205de Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 10 Feb 2023 12:39:37 +0000 Subject: [PATCH 108/499] FOGL-7433 Support in core for subscribing to table updates. Also update (#966) Threshold rule documentation. Signed-off-by: Mark Riddoch Co-authored-by: Aman <40791522+AmandeepArora@users.noreply.github.com> --- C/services/storage/include/storage_registry.h | 13 +- C/services/storage/storage_api.cpp | 3 + C/services/storage/storage_registry.cpp | 237 +++++++++++++++++- .../images/threshold.jpg | Bin 140147 -> 72883 bytes .../images/threshold_source.jpg | Bin 0 -> 20228 bytes docs/fledge-rule-Threshold/index.rst | 35 ++- 6 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 docs/fledge-rule-Threshold/images/threshold_source.jpg diff --git a/C/services/storage/include/storage_registry.h b/C/services/storage/include/storage_registry.h index 8297f434a3..5117098c46 100644 --- a/C/services/storage/include/storage_registry.h +++ b/C/services/storage/include/storage_registry.h @@ -33,27 +33,32 @@ class StorageRegistry { void unregisterAsset(const std::string& asset, const std::string& url); void process(const std::string& payload); void processTableInsert(const std::string& tableName, const std::string& payload); + void processTableUpdate(const std::string& tableName, const std::string& payload); void registerTable(const std::string& table, const std::string& url); void unregisterTable(const std::string& table, const std::string& url); void run(); private: void processPayload(char *payload); - void sendPayload(const std::string& url, char *payload); + void sendPayload(const std::string& url, const char *payload); void filterPayload(const std::string& url, char *payload, const std::string& asset); void processInsert(char *tableName, char *payload); - TableRegistration* parseTableSubscriptionPayload(const std::string& payload); - void insertTestTableReg(); + void processUpdate(char *tableName, char *payload); + TableRegistration* + parseTableSubscriptionPayload(const std::string& payload); + void insertTestTableReg(); void removeTestTableReg(int n); typedef std::pair Item; typedef std::tuple TableItem; REGISTRY m_registrations; - REGISTRY_TABLE m_tableRegistrations; + REGISTRY_TABLE m_tableRegistrations; std::queue m_queue; std::queue m_tableInsertQueue; + std::queue + m_tableUpdateQueue; std::mutex m_qMutex; std::mutex m_tableRegistrationsMutex; std::thread *m_thread; diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp index 5169fda47c..d878a415d7 100644 --- a/C/services/storage/storage_api.cpp +++ b/C/services/storage/storage_api.cpp @@ -634,6 +634,7 @@ string responsePayload; int rval = plugin->commonUpdate(tableName, payload); if (rval != -1) { + registry.processTableUpdate(tableName, payload); responsePayload = "{ \"response\" : \"updated\", \"rows_affected\" : "; responsePayload += to_string(rval); responsePayload += " }"; @@ -1544,6 +1545,7 @@ string responsePayload; int rval = plugin->commonInsert(tableName, payload, const_cast(schemaName.c_str())); if (rval != -1) { + registry.processTableInsert(tableName, payload); responsePayload = "{ \"response\" : \"inserted\", \"rows_affected\" : "; responsePayload += to_string(rval); responsePayload += " }"; @@ -1622,6 +1624,7 @@ string responsePayload; int rval = plugin->commonUpdate(tableName, payload, const_cast(schemaName.c_str())); if (rval != -1) { + registry.processTableUpdate(tableName, payload); responsePayload = "{ \"response\" : \"updated\", \"rows_affected\" : "; responsePayload += to_string(rval); responsePayload += " }"; diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp index a5ec6e9bca..01e75c7420 100644 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -8,6 +8,8 @@ * Author: Mark Riddoch */ #include +#include "rapidjson/stringbuffer.h" +#include #include "storage_registry.h" #include "client_http.hpp" #include "server_http.hpp" @@ -127,6 +129,38 @@ StorageRegistry::processTableInsert(const string& tableName, const string& paylo } } +/** + * Process a table update payload and determine + * if any microservice has registered an interest + * in this table. Called from StorageApi::commonUpdate() + * + * @param payload The table insert payload + */ +void +StorageRegistry::processTableUpdate(const string& tableName, const string& payload) +{ + Logger::getLogger()->info("Checking for registered interest in table %s with update %s", tableName.c_str(), payload.c_str()); + + if (m_tableRegistrations.size() > 0) + { + /* + * We have some registrations so queue a copy of the payload + * to be examined in the thread the send table notifications + * to interested parties. + */ + char *table = strdup(tableName.c_str()); + char *data = strdup(payload.c_str()); + + if (data != NULL && table != NULL) + { + time_t now = time(0); + TableItem item = make_tuple(now, table, data); + lock_guard guard(m_qMutex); + m_tableUpdateQueue.push(item); + m_cv.notify_all(); + } + } +} /** * Handle a registration request from a client of the storage layer @@ -211,7 +245,7 @@ TableRegistration* StorageRegistry::parseTableSubscriptionPayload(const string& return NULL; } for (auto & v : doc["values"].GetArray()) - reg->keyValues.emplace_back(v.GetString()); + reg->keyValues.emplace_back(v.GetString()); } return reg; @@ -230,7 +264,7 @@ StorageRegistry::registerTable(const string& table, const string& payload) if (!reg) { - Logger::getLogger()->info("StorageRegistry::registerTable(): Unable to register invalid Registration entry for table %s, payload=%s", table.c_str(), payload.c_str()); + Logger::getLogger()->error("StorageRegistry::registerTable(): Unable to register invalid Registration entry for table %s, payload=%s", table.c_str(), payload.c_str()); return; } @@ -360,6 +394,31 @@ StorageRegistry::run() free(data); } } + + while (!m_tableUpdateQueue.empty()) + { + char *tableName = NULL; + + TableItem item = m_tableUpdateQueue.front(); + m_tableUpdateQueue.pop(); + tableName = get<1>(item); + data = get<2>(item); +#if CHECK_QTIMES + qTime = item.first; +#endif + if (tableName && data) + { +#if CHECK_QTIMES + if (time(0) - qTime > QTIME_THRESHOLD) + { + Logger::getLogger()->error("Table update data has been queued for %d seconds to be sent to registered party", (time(0) - qTime)); + } +#endif + processUpdate(tableName, data); + free(tableName); + free(data); + } + } } } @@ -414,7 +473,7 @@ bool allDone = true; * @param payload The payload to send */ void -StorageRegistry::sendPayload(const string& url, char *payload) +StorageRegistry::sendPayload(const string& url, const char *payload) { size_t found = url.find_first_of("://"); size_t found1 = url.find_first_of("/", found + 3); @@ -525,7 +584,7 @@ StorageRegistry::processInsert(char *tableName, char *payload) payloadDoc.Parse(payload); if (payloadDoc.HasParseError()) { - Logger::getLogger()->error("StorageRegistry::processInsert(): Parse error in payload for table:%s, payload=%s", tableName, payload); + Logger::getLogger()->error("Internal error unable to parse payload for insert into table %s, payload is %s", tableName, payload); return; } @@ -544,20 +603,174 @@ StorageRegistry::processInsert(char *tableName, char *payload) continue; } - bool match = (tblreg->key.size()==0); - if (!match && payloadDoc.HasMember(tblreg->key.c_str()) && payloadDoc[tblreg->key.c_str()].IsString()) + if (tblreg->key.size() == 0) { - string payloadKeyValue = payloadDoc[tblreg->key.c_str()].GetString(); - if (std::find(tblreg->keyValues.begin(), tblreg->keyValues.end(), payloadKeyValue) != tblreg->keyValues.end()) - match = true; + sendPayload(tblreg->url, payload); } - if(match) + else { - Logger::getLogger()->info("StorageRegistry::processInsert(): Sending matching payload: table=%s, url=%s, payload=%s", tableName, tblreg->url.c_str(), payload); + if (payloadDoc.HasMember("inserts") && payloadDoc["inserts"].IsArray()) + { + // We have multiple inserts in the payload, parse each one and send + // only the insert for which the key has been registered + Value &inserts = payloadDoc["inserts"]; + for (Value::ConstValueIterator iter = inserts.Begin(); + iter != inserts.End(); ++iter) + { + if (iter->HasMember(tblreg->key.c_str())) + { + string payloadKeyValue = (*iter)[tblreg->key.c_str()].GetString(); + if (std::find(tblreg->keyValues.begin(), tblreg->keyValues.end(), payloadKeyValue) != tblreg->keyValues.end()) + { + StringBuffer buffer; + Writer writer(buffer); + iter->Accept(writer); + + const char *output = buffer.GetString(); + sendPayload(tblreg->url, output); + } + } + } + } + else + { + if (payloadDoc.HasMember(tblreg->key.c_str()) && payloadDoc[tblreg->key.c_str()].IsString()) + { + string payloadKeyValue = payloadDoc[tblreg->key.c_str()].GetString(); + if (std::find(tblreg->keyValues.begin(), tblreg->keyValues.end(), payloadKeyValue) != tblreg->keyValues.end()) + { + sendPayload(tblreg->url, payload); + } + } + } + } + } +} + +/** + * Process an incoming payload and distribute as required to registered + * services + * + * @param payload The payload to potentially distribute + */ +void +StorageRegistry::processUpdate(char *tableName, char *payload) +{ + Document doc; + + doc.Parse(payload); + if (doc.HasParseError()) + { + Logger::getLogger()->error("Unable to parse table update payload for table %s, request is %s", tableName, payload); + return; + } + + lock_guard guard(m_tableRegistrationsMutex); + for (auto & reg : m_tableRegistrations) + { + if (reg.first->compare(tableName) != 0) + continue; + + TableRegistration *tblreg = reg.second; + + // If key is empty string, no need to match key/value pair in payload + if (tblreg->operation.compare("update") != 0) + { + continue; + } + + if (tblreg->key.empty()) + { + // No key to match, send alll updates to table sendPayload(tblreg->url, payload); } else - Logger::getLogger()->debug("StorageRegistry::processInsert(): Ignoring non-matching payload: table=%s, payload=%s", tableName, payload); + { + if (doc.HasMember("updates") && doc["updates"].IsArray()) + { + // Multiple updates in a single call + Value &updates = doc["updates"]; + for (Value::ConstValueIterator iter = updates.Begin(); + iter != updates.End(); ++iter) + { + const Value& where = (*iter)["where"]; + if (where.HasMember("column") && where["column"].IsString() && + where.HasMember("value") && where["value"].IsString()) + { + string updateKey = where["column"].GetString(); + string keyValue = where["value"].GetString(); + if (updateKey.compare(tblreg->key) == 0 && + std::find(tblreg->keyValues.begin(), tblreg->keyValues.end(), keyValue) + != tblreg->keyValues.end()) + { + if (iter->HasMember("values")) + { + const Value& values = (*iter)["values"]; + StringBuffer buffer; + Writer writer(buffer); + values.Accept(writer); + + const char *output = buffer.GetString(); + sendPayload(tblreg->url, output); + } + else if (iter->HasMember("expressions")) + { + const Value& expressions = (*iter)["expressions"]; + for (Value::ConstValueIterator expr = expressions.Begin(); + expr != expressions.End(); ++expr) + { + StringBuffer buffer; + Writer writer(buffer); + expr->Accept(writer); + + const char *output = buffer.GetString(); + sendPayload(tblreg->url, output); + } + } + } + } + } + } + else if (doc.HasMember("where") && doc["where"].IsObject()) + { + const Value& where = doc["where"]; + if (where.HasMember("column") && where["column"].IsString() && + where.HasMember("value") && where["value"].IsString()) + { + string updateKey = where["column"].GetString(); + string keyValue = where["value"].GetString(); + if (updateKey.compare(tblreg->key) == 0 && + std::find(tblreg->keyValues.begin(), tblreg->keyValues.end(), keyValue) + != tblreg->keyValues.end()) + { + if (doc.HasMember("values")) + { + const Value& values = doc["values"]; + StringBuffer buffer; + Writer writer(buffer); + values.Accept(writer); + + const char *output = buffer.GetString(); + sendPayload(tblreg->url, output); + } + else if (doc.HasMember("expressions")) + { + const Value& expressions = doc["expressions"]; + for (Value::ConstValueIterator expr = expressions.Begin(); + expr != expressions.End(); ++expr) + { + StringBuffer buffer; + Writer writer(buffer); + expr->Accept(writer); + + const char *output = buffer.GetString(); + sendPayload(tblreg->url, output); + } + } + } + } + } + } } } diff --git a/docs/fledge-rule-Threshold/images/threshold.jpg b/docs/fledge-rule-Threshold/images/threshold.jpg index c73e9527311a586c0bc8b070342bf2b026e846a6..37f85b4993f47038e73d7d0920fb7c60620c219a 100644 GIT binary patch literal 72883 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

-?iUAnA#eyq3Y&?a28kC zae6ziV#`XxW5f8_7gDdOpMXh7B4U(@wxfV-f$Bo@(^N^R4GYts!WU^0MPn{@dkCJ` zWLx}-Zn}GPnx1e1{zTk|@O#BCk)tg3dc7#1H6Lh1xfnSkyBw|&kUv*Q^0K5pnIav& zan&)^?akL?uk@7Lbnz#nukxxMXgPQ9$+pONy=5P6I!Y4-m;nR=#6`W4oKoyCb{g9P z@UrS?X4Lx$g#lW!6TpxF(pXU}kk4ocA56<5J7s zSYb0SsE2`Q)>PDFDZD`*l$pDhiC`RTPin+lx7?fz_YSFWtBT%E?6mS;&GQi7t#duN zM~N|BENZt}DExCw1cZj50f2G67}^aUg8{UCS2PdBfdIWb3UHx2G+Tj<*Got{i zN*ov=TnB&EG~~%_3#kY2%8O7o4QuzD&lWFKve;5^wjvU*D^gpn4hp`xlbLcVm+sz6 zNUPrkrnS$2Y6z?g>kq@Gz)FDs8^i%I5{qkXMTlCH#B{q+dp=hx@d`o%O+QZb8UvVi z@xDe4{cRpcSLqWTN9w;7a1SD`{5d!z{g2|sST(SON8bg7h}8(7R%Ie(2-~qQN9R)Y zLaX6Z(KlJyFTAR8eo#|7`tH^xqo}?LZ6c&8zzNHl7;d#KWtClH6caV|FGq;NR=W}{QHkAu2GiE z9u6y70Z%oqOlDvUCF{|^ZKkzW0eCXxsW&It4wOHit2`Uq%;o2;6qzZMR%?U}mBroR zRJisbJTory;qm+?{rkRiuOPd29-v>jWkkh*WQlPLijzRVS63#vpanFHafXrBx01Dw zc9*uxo_a4-qSz^XS!}#nJdTfRMMl2PT)F@ zF9^JfE%AX56MO3_kEdJdw%P<9JHh+1xi0o*gH;PWE7X~g6H4pG}D4EWu`2^ZLBK*{1hjFQy!wP4a3SsZI;9YMA)k zcTuTZ`krPkt20QLu>z0VK4RQkLi2M(`hV)hFpv^v*OdQ0xCIA4z(e+8L8oSzA-y>wS6U?Rj?(xsT{ErKq!y zMUW-Yk(}F}pye?|t&7$(=#j`~fL2CNrKBnr`L7udM>f)qc5BFsXL-_w2s#S}Sw=xa zt@Lkd-W+>f;6sg8#tNc(vG_B~qhMVwLO3%%1JDywudC&u$?Z~ph(_GO@x$Ftk5|wm zXZ*L{hGlhzVoTAD(wJaPJ^}*dT{!*V^|U<$8b{r5LQ6^u1HQXQwWVTJ&VQl5n=~xG zpRvJ7LM%ji;T7@80@hhpS9WD1lWG1 zC?Rw`pb!w8iG*5e6q3;%thiqJ@xp{g{a!Yj#?1G8k*td8eH(LRMoLT3WA$VcvSM_i zHV@yrM2{D0nULwzV4Ug{niH(8$_Q~=QfoIYuc#_xUXOa!DyHGLT(Qe?1&Vj1aUj8p zA}|8gy$;74rtDucIt-_#mfo3$FD?j_B;QIoWw0d1`MFE~UWtBhbXZ$hh>x$pdt*AX z8fG}Mr5szR*9aT(ZDh}++`?OidvDOh-nnHOJENWLx6NFj^*?EaKDrO6EwXmHO_y7$ zd>QvZhrOXC&^p5cmO0vyGD7VgiAbDKeU__L;Jn4hflYkzp1_H-rQ`8Q%x(yaURlQqy}UI^#&eB$~9BmL=ah z&NO35;TAG2xn)qROq%e&$`JI4*3vD;TXx0xr9z7S?5?v+3+s`Z>d4)AzQ{EY{+&=G zB!s3dY?f&@KA)UdAr2<0$1pwI6;otzzi#Atxzch3lvO!)_ktZq4^rp3?Uh;~^8mXx zp^8*oy`$-zhl7Ru4oAiZ6$12P5i}_JN+-SF%}L@5>PrnSz?5rjq_GcFJL2diLoW|f z_zk%EZCykZYqV~@B=f5mRk0NMo; z7>87P4%4{>V=k;0w65;)gc$v4=rX7MY3lhjR;Il zXqF)o;(XlntNwiDNc~)ojPMAxM)gx-aS!V)b~c6ZYYcW`OV*cWrYDC#wPNWh`=8Ab zNJXtP&f$BzWR;~2zv{a?q?japxX~&#wwGq{ez0 zQ(-xNu#tZ$Ps4d8mi>yTRH#X<|FE2l(mRy_{b^ON({@fbw(BY~3ZZX6XVF7slYRu+ z5lWKm&wnBPmI#d&OsPKgNz>-i>W%t2`oqGvx|oO{n!~a%g`n(B%4$NBPOr(6^t=rd ziu!zvmxR!TRyOHUN@ktd_86`A5hg>2WVS2nn396XI;c)8v!xL_B2^u~9%5;AcErEG z)X;73jV%5{8$FVb*!0Fbt!R5`;;3#6D~$tPuYh%19+GdxA__^Fs!gLyCQ3o}*Bi+y zwuWaMt6cA*P8~-k(4`Dsb0L1}I86ow0B$HQs}WcfSQBFFJNZZ_>QTZ2d77M`uel1Y zM`#>Fj z-Cy4O)i{jv!7F>EZ9*>S5Zuy)`HJk=2UF9Xg%(ue*QZrla~J)k({E z7;bg46_07L;)@V6bT3cW?{i-^DrW21kR!8;2=Tmkg_i0qHsVCAPj-1j!)Sc9wFJ54 z#mHL$mwGPvu*P(k563ymstj73_z==~P(-`nqb1R`AANZ;0)U)M3j#HPtVy7i3rhyc zp<#l4Ag@+@%r)t1Wx&g3*=L68ZY$kQ#dfj-J&VobkED6spP;<_4o|@v*mt1ochs`c z22cWcY6_+)$2dHpR|lt$Gs(`5G_MF3-sy}=|zsEF5sVcY&AXU+fM^CP#g zByu8^Nb|AFwf(-|#4eYw7DS<4jm*pkHMAY3+1XtOHy;iJU76UcQyAO9jApr1e|Z)e z)2<-)WDyX;$$}9ZH#>1HH8_#$?I*hAYT=wl&#zcrey%(&`dCTV`tF`6UZ``N;qFt9 z@A))e6I5+RM2-X+;llU64ry6Z_8z@PWGpXJaZ?DZ<8-Qw*Kr9vRV22};OZVL2>DV3 zX|;)Vh!mVgQaz=Q)XuhZQ?;_$?7+Q$-+ZGboNvPIzOVs8NL70E^k{`AU+S$Er~uOK z+SHOm^8js6Xrr$hJj7ku+FBxlkI!tpZiN%s)nR^wqeV=NP1Aj9=_&i`0|jyUnqn05 z8qysysreK9MQzr&Zl^RT^aT&I&7e$}KBe@Lz9qIZJncF$uEvXYvle;+kI}m-YXE zd1<3V@UTWr4*N>prRNZ~_$mgW^zoWA8Pj179fGe70&v@JG%-f!X*8>A z@95a)O`lxO3$5aZG(6KvOxjMUJ%jLAI5OUzy2t$4oU6`md#pv};7oU}#pZ^fdh#6v zf5K&yfGzRM#qoN{V+*~oSJ=-5H1+R{Fft1bgso<<)SHl1TZg(@X2MP@y#g1aWlk0t zGqO`BjxuP_k!#v+;F(T)i#}}do1DrIn(-3JT~FjyyE#G)&Rt3`U#aa3%q>g|o(;Xu z8T~=c(T{%*$C^BmkE#srf!ryAAWXo2NHJbn=@?C*8Q{uKrbzm$vmU8%;jq_1RQLo%WTcQkdxSjsWFk$<*M z{rt8YWjl^&OhrJd{tspITrIY+XEC#{^l1FV1cRcXOfClp_vK=RItW`v|6T7mhPZoe zjeBCWx8D}|Ki6=drTf$W_f#F3$P$??)RE}7l-kau70`T+Hqnp$R8CXFPHTzA>G)%b zL=bK}h9&d^aaCA(DPm&@G^GO4S*8pVI|Q{R2i=LF_@EnNmzJz)x6m^=G;X@j+?L6} z@$Smx>L}=UaC|!X7Rb@0PLe<_J{WRShv(xr$mmEpg$UZVCFQ`@FK*T9R?GRH);s7O z^5(DG>2Mc$Sa@b!^p+g53;>pfG3Z%?0oX#z46u?e_4y}AMEyeU)gU85Z~99j!FhV1 z6yIxIUE8~)+-`97M z<9o$gVZT!a_;!|iEpfd_o4S?>|59V_eZD`pREt6_Cs)ebrexFKtpGrawM{KP*XUzMN_mQ(X7^8vD0Anw6VzQOJ+Ga7Z3D+_EK#7l%q9qXfC^Vi0KporGpw(kuPRXk8$5CL78iP+5gzL(4>mE z3)KwLk%9N1qyzF4@o^*w&F?TKvJYWDuP&io$(AWVNo}P%Hh`M5I#Y+mpLmVpqjkbr zs+oYNGA<(t-2@NX{8*gphi=9U#Tj^**}^_slnZ*7bIKH|F`6Rre&P-wxY7s38=^@E zfL?exCKT!W)bHi7U8Jn(+Es}7^UKjI*Noq9V@NxSe7$|}t&mr$rzR5# zG+@Rj)C|;aiU_F~;XL(@-Henz1r1-XW$#^k)HAcVZneMkL1p}8H42?vF*17klUp#k zLf{Cj2va1#xK4mi$%pu0NVZsxd`)q`Si2gs&0y%6AcLaPs9pPOGBX(OUdumYsK3}W z8`o27-iMxgP|Gxcp2|onUn1v_;_{WKZl_erQCx2FV+PsEL9QYAG5?{r#lDfwahOLb z)NJ~xWa zt&U#%@ZjDNh9kn3wog%$*fL@b0oyoPXu0Sx1!Z`vgk~y{7Z_G~eeD*)^-{0Qj;_M! zS^rCC7j`L73SI|kRGllHLo;Hh!YrUWU>7GN(S=VYIX@2gz#`(UTWAs$J!Nm59qd`F zd2dWQkuR)TjPG?c`=on_v~%0)SKq}A5YPAonj;6MXEwM+_bTu$COXIXxGxyj#SwV!{nlOsPE*9YBO-N!>MxqNd@RmHZIezS> zQ@T>OOBvT(4DXlew(`_&hIoN(+xMS1G}a73V^GXAush{KJ7Gu3#1?8M%ni$KHRKmm zH^GRX-AuTjW~ba7{I2P2qww|~*M}D_Rmlp;KT4jNEWP7+lq{wP+NQv`-w7fD$VI^+ zNSn%T&ArG&1dl9SC#zq>sQ&S?l`BzsvejjZHgDovZtS_*M(_UNRD#(YnhiD;y%*_B zC~tw5Og4ipM0hds2L3~8XuhGuW46cFxXZG0h+$R-?e_05L5AJkb29s-)oD&Gjmhax z6z)VK8K2bz0AB*s93;!sxU>=VnbPPB4IZy&wzlnZk6%z9%gMTznm-9fAMVg`@Mxp+ zU~hA4DTyV?wKl6Shro+FqvbUAW(MT6ss+ze48kouYlVBSe$8>`>x>e(p84_hagD}3 z@97rei0pMRlK65{=MIWJ$(|rYOd<9A6ATSVVy?0Uch-W^wKFo)4LG%mZQltO)%3+R zMZ7$JuuYmPrM{v@jJCslN>+277!$wVvNAS8xn5FT(w+A{rBp+vJn{NG>j!iOrX;)C z#c?MCbY81-Pd3vC`c>kR8!#s*BdE3n=-Kotk)b>NxLR+9235nZT`6K-Haj&UnQGzd zc=7%G?zHTw7-QqE2+}ff7d2iMb_qJ1s6)l*R??(u3oB}CfsUU$tsMxJW!t>HCez9H zSg!Z+Hgv8Bw-@}VTiVHAS=>x26`LA z>s$y>G}VW*&rhEBThJ-)QI6K#ys)>rEP-Ee;8u2;euhEn`5hzC;@aY84HcirkMI)l zBi5~MSD%T6AD4=`V}5ZbBMe|%C{L~p}N52#iU_n~v3NEJI)<8x4|8>x=)bG@{XXjyZGc2ya z>|fD&=f6oH=I;&M{G;dpionmG#I}0X?k%dV;Pu_S!t7L7vD$R?#xkR*iooKH*J2u% zy<$qc0W-0jA}&}?g)C#2s}MAp-VaB=JGX1+pu5<}G%^_aXV%aSe?0v97yI|ETY#$B zs*8VXCR=r}RTo?1Mf;C~<<{8wZ=GVRF8&qjLZVpnHBMG;=6K|(R3HJ`Roz>yZX)b{ ziQ&3BfCcim+>Emw2U(pzZ@EzYG1uI5C*%`ia1VK~gkE{} zq>WwfgHv=M0R}qAVxW=P8jGFVhyLv|pwa0^rUgpUs5$^S3&LuBmhG?~J_Gc`KVAR{ zFxtAwR-J6M6N=7O-`N@pnzn|5t)XCRDERkQ1^TtBgyGqF*4WJteBU4(T2K?q2gs92 z2bEc*eUAF|=-SWm=fmRiIpqaNZ$m_>G3d`vfc%r=^U=u2@T89)Mht{MJdQDV$~tP& zd`0q9`nD;Hz^I)ND}eZfefymVaxho%VBcBBgAQXybHkVDZ{&w3YDC;g|H@6*TAOme zC#UR*cJmD<9;MFWJjgZdaBDAxW{!4-8n13xIuaez7u&Uj5yWc%Z4P?~{q5Ux!vT+7 z$ckM|LewV#u+9&9zkhoJ?qsFeQ9=h;55HY*@|Uk|6NeLGi)FX&{k=-J>IC+1tDS81 zovrcYZysY)1EkD*!#`F|7Rw$e2eJ2A75;hYODvu>FY)0zX9`ot&eEHxpNr6f@N<`Z zPeags`yFL{0OWtZ|2w_&P)W(zZT2n=J>6Q&!Rai9IuHroC|Ox<$Rn$*um0QB#znRV zf54BUzx@v2{ok*1>+iq$KY#ljANS|$B>Re)&55hLU3X-PbawIz&^`CO9XGaZ_$kYp z#R`e>mFoO@a@Ep#2!$@}8|1JdKxr{ib(XOQV5E1Dize0pZ+08qXGdNM`UZ*W{xAA= zV0&@8t)ItyN90?%sj9eV`y93Arv>U6dA1+AyIXtd-Tzzp9?Mxv3eO<1gmi~2hvY4- zA^8-kQ!VC&4t%)hand!xS*AU%>Pp?H_KepyZF(LG7DGDdy!7e}dRpllq|H6_yEy9S zG1+M+$JC1nn!)FfvzELr@I{>Ld|#2+l#7*U(crn_^(5YCWu%Rk2c690p)dd+q;{Bl z?4}5MB^dxe~)-m@ZukISPR-R$^|_`q7T; z5$lsesBI))oJG1ojIBl6$by?l2V;{$2i>u@aFtKBhv7mknj$l#8xPe;NpVk{mi#95 zK6%IHO`p-IckaJp(%4|~;2d?~O05w_*F=YBV`Kf#qwImY%(ve`BY(CZN~?nqL89>{ z4MbkM2?Np2S70ljf%NnMdQ(#kvMzYwrBxy&C?B=}fcIird@cpJL8624kgpXrxRynS z%;O6BUZc6#9MriI-~VvTvfW=~QS7Yy8NWDt6^t`iIIX8e@$u+;>Gf3js2MS&5QxFG zH;)igrr*J)<|ZH*kcF3j0B#b ztMz9O?h>s{A-R#gD91@Jrk00k?b_YB?$wTj!zQ(*1f!@1-B5$b#%rgOrlXkH1f}O@ zN<;lG3OdTLd$RYk%3Jh&ZQt?Li1(v(yQ^1`i?D&v#e1G@`bYM}=D`b^5X0-lGExo> z=;Fb)YqHSVG}-XzV9L3fJk=(v#jetlF27qRdc571iw*V-kLt(2-QV2Yp>5)*Bh#aP zsE(-*DNjZsLASYE+<<1P-7}}A6C0IA%ARDaHcco{Ds!iwOB8%px)k&A!fs>E(P^h$ zXCL!@Y^Z^tN=)DnF(uHY&x8|GUtSP4I~!?2#r?h0Mu~Ty_r|mb#gg<>^}l!^kQjxL zk++te_fGJm4?;0G(N!`J@&+lEFxi;&z6!U@;m6l9a+0&2`Vc2iXRAIuE)#M3Rj0f7 zpgclp*LE-UP_`q-Gj_9=<#bj9TIiwJO&V---<37N)n%Y-(IG~|#?r!)ZXf!yP4CZK zNApEbO<_00G=YEWtNuh<7`EWUc5Kcv>4-c{zcU2AsO$**73CK;v-R>(NWrLCO`zw3_3co23z~ zq(P(Gp3h}ZYumq+T3xZ-x5I;-&ch70(q}p3^mx(pe3;1-c>!JHq)mw=lgQpB)yv-? zNo^B^mB${2GLFpscb5*dNhgeyd>OT(KFA+*tC~Y=np-Y{;pfmAoSR}u;F}qYs6IS{ z;JX0#`v$S?zUsZqaN&P-ccx!WUU?i3f`VmYMkQcDlJTb@sRDTf1O*jj-;D)^>x1yt+?3dncI^3ITt*h_Zo1o-%@}G!c9@*Z#kw+71`!PmYJRG&V z5l7b`?>R;c_ltOJ`1mY`d#2ye$F?L8%UU$7c$9lIN2!awpz$2tr5H9aJJsmmm@b&D zGAz^Uumuk^Xlf1EMWxkQIVg9sIL3`>wKon6rp~@iy&G!#%hro>*v*k#NyZ#A7pq*L za)4Un0e$GitRi~X>d9D#;aoSHfXp2R(gSz;nnHfiGlR+WvW|A3pR*o6*8EXI{a^oprCdzfHtl-Q;-;C zA!uqRRheERoa3+V50ZE5r&pn zFu-oX+u%NsF5ia#`;XT;XGyGmkUby2)77*fvmp49RjJ2ol+}FuL;=4(|2uDTc2us~ znGF;-CEX+vE4fokjs@r;$zb3e(UW*8#@+c4Yi04IpUt=jhBU_L@aFCB zJ&qjj66YdFrk~en(B2{z{tF~?+URr)f_a@AICg!lanfaQgdLIg7Ggz;uYJ=FZsPS?4u^A#GjllQ~3z0^mkgw*wOsHQ$P&d`H(u+ zm+U21M9RSzpgjMFopmzM8wAtB1U61ZnjjKaZY0?akbBU;1=`x>;4@g~*U8ujf%KGG^2hnkOv8fQ+w6EuF_%dNVzGoBRFU;yZNoEzQ&J#`5?XmEiWudlycRb3*&Izy4p#aelY4g`2CjjERLYEQhP zSG3y66&^S#`^7Wgsp9b67vLR|YTgW|Vm4Wo%7ypEGFvo9L!S2Cm(YjODzl0wj`WON ze#|V$owb=0TXJ>Y?sh#M?dwI}+wTsJnc-YcFgK3R^=YdQ3PafCwhYse_f4Sn*0f2^ z>4GqWVm8(wS6IAN4a{ic*7;M@4F%r!XfbR`_ZS!|z-{&x|)?*0oxX zF6)d2Ym_r=x(I|=CMrh3kMLD2IsZ1Sa9_MPjV2&LIUtf*(Ibf~Wb`&A)VR0g6~%No zIKMENQy0>_xgkiCx%u)#hQeSARHhfF{-5il Gl^UXJ>uzLTxcGap{t5#L5T2(FKD`5tpzoe<7 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%0A_5KjIWxBr4|pt)xS7P!65<_d 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�z1L>Vhy1a6Sbw#R-*HNb_cyZl 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 diff --git a/docs/fledge-rule-Threshold/images/threshold_source.jpg b/docs/fledge-rule-Threshold/images/threshold_source.jpg new file mode 100644 index 0000000000000000000000000000000000000000..700bfe9a89dd0e3013aefcaa8bfa7ae639b5e984 GIT binary patch literal 20228 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 IX(%KA3shMtUjP6A literal 0 HcmV?d00001 diff --git a/docs/fledge-rule-Threshold/index.rst b/docs/fledge-rule-Threshold/index.rst index af77e50ab0..659c80bcf6 100644 --- a/docs/fledge-rule-Threshold/index.rst +++ b/docs/fledge-rule-Threshold/index.rst @@ -1,5 +1,6 @@ .. Images .. |threshold| image:: images/threshold.jpg +.. |source| image:: images/threshold_source.jpg Threshold Rule ============== @@ -12,9 +13,15 @@ The configuration of the rule allows the threshold value to be set, the operatio | |threshold| | +-------------+ - - **Asset name**: The name of the asset that is tested by the rule. + - **Data Source**: The source of the data used for the rule evaluation. This may be one of Readings, Statistics or Statistics History. See details below. - - **Datapoint Name**: The name of the datapoint in the asset used for the test. + +----------+ + | |source| | + +----------+ + + - **Name**: The name of the asset or statistics that is tested by the rule. + + - **Value**: The name of the datapoint in the asset used for the test. This is only required if the *Data Source* above is set to *Readings*. - **Condition**: The condition that is being tested, this may be one of >, >=, <= or <. @@ -25,3 +32,27 @@ The configuration of the rule allows the threshold value to be set, the operatio - **Window evaluation**: Only valid if evaluation data is set to Window. This determines if the value used in the rule evaluation is the average, minimum or maximum over the duration of the window. - **Time window**: Only valid if evaluation data is set to Window. This determines the time span of the window. + +Data Source +----------- + +The rule may be used to test the values of the data that is ingested by +south services within Fledge or the statistics that Fledge itself creates. + +When the rule examines a reading in the Fledge data stream it must be +given then name of the asset to observe and the name of the data point +within that asset. The data points within the asset should contain +numeric data. + +When observing a statistic there are two choices that can be made, +to monitor the raw statistics value, which is a simple count, or to +examine the statistic history. The value received by the threshold rule +for a statistic is the increment that is added to the statistic and not +the absolute value of the statistics. + +The statistic history is the value seen plotted in +the dashboard graphs and shows the change in the statistic value over +a defined period. By default the period is 15 seconds, however this is +configurable. In the case of statistics all that is required is the name +of the statistic to monitor, there is no associated data point name as +each statistic is a single value. From 76fb06bfe92ebb0d32506b1284eeb47f8521df78 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 10 Feb 2023 14:57:27 +0000 Subject: [PATCH 109/499] FOGL-7428 Logging improvements for OMF Signed-off-by: Mark Riddoch --- C/plugins/common/include/http_sender.h | 20 +++ C/plugins/common/libcurl_https.cpp | 4 + C/plugins/common/piwebapi.cpp | 8 ++ C/plugins/common/simple_http.cpp | 6 +- C/plugins/common/simple_https.cpp | 6 +- C/plugins/north/OMF/include/omf.h | 9 ++ C/plugins/north/OMF/include/omflinkeddata.h | 1 + C/plugins/north/OMF/linkdata.cpp | 35 +++-- C/plugins/north/OMF/omf.cpp | 124 +++++++++++------- C/plugins/north/OMF/plugin.cpp | 8 +- docs/images/OMF_Formats.jpg | Bin 0 -> 71804 bytes .../troubleshooting_pi-server_integration.rst | 19 ++- .../services/core/scheduler/scheduler.py | 2 +- 13 files changed, 178 insertions(+), 64 deletions(-) create mode 100644 docs/images/OMF_Formats.jpg diff --git a/C/plugins/common/include/http_sender.h b/C/plugins/common/include/http_sender.h index 810dec3d70..bd35561bc5 100644 --- a/C/plugins/common/include/http_sender.h +++ b/C/plugins/common/include/http_sender.h @@ -77,4 +77,24 @@ class BadRequest : public std::exception { private: std::string m_errmsg; }; + +/** + * Unauthorized exception + */ +class Unauthorized : public std::exception { + public: + // Constructor with parameter + Unauthorized (const std::string& serverReply) + { + m_errmsg = serverReply; + }; + + virtual const char *what() const throw() + { + return m_errmsg.c_str(); + } + + private: + std::string m_errmsg; +}; #endif diff --git a/C/plugins/common/libcurl_https.cpp b/C/plugins/common/libcurl_https.cpp index 722950857b..3481f6e82d 100644 --- a/C/plugins/common/libcurl_https.cpp +++ b/C/plugins/common/libcurl_https.cpp @@ -443,6 +443,10 @@ int LibcurlHttps::sendRequest( { throw BadRequest(errorMessage); } + else if (httpCode == 401) + { + throw Unauthorized(errorMessage); + } else if (httpCode >= 401) { string errorMessageHTTP; diff --git a/C/plugins/common/piwebapi.cpp b/C/plugins/common/piwebapi.cpp index a79f777da7..d9e1db670e 100644 --- a/C/plugins/common/piwebapi.cpp +++ b/C/plugins/common/piwebapi.cpp @@ -150,6 +150,14 @@ int PIWebAPI::GetVersion(const string& host, string &version, bool logMessage) } httpCode = (int) SimpleWeb::StatusCode::client_error_bad_request; } + catch (const Unauthorized& ex) + { + if (logMessage) + { + Logger::getLogger()->error("The PI Web API server at %s has rejected our request due to authentication issue. Please check the authentication method and crednentials are correctly confiogurd.", host.c_str()); + } + httpCode = (int) SimpleWeb::StatusCode::client_error_unauthorized; + } catch (exception &ex) { if (logMessage) diff --git a/C/plugins/common/simple_http.cpp b/C/plugins/common/simple_http.cpp index d9d55d31f2..59d65b2f7f 100644 --- a/C/plugins/common/simple_http.cpp +++ b/C/plugins/common/simple_http.cpp @@ -237,7 +237,11 @@ int SimpleHttp::sendRequest( { throw BadRequest(response); } - else if (http_code >= 401) + else if (http_code == 401) + { + throw Unauthorized(response); + } + else if (http_code > 401) { std::stringstream error_message; error_message << "HTTP code |" << to_string(http_code) << "| HTTP error |" << response << "|"; diff --git a/C/plugins/common/simple_https.cpp b/C/plugins/common/simple_https.cpp index 372937c88d..f0203b45dd 100644 --- a/C/plugins/common/simple_https.cpp +++ b/C/plugins/common/simple_https.cpp @@ -245,7 +245,11 @@ int SimpleHttps::sendRequest( { throw BadRequest(response); } - else if (http_code >= 401) + else if (http_code == 401) + { + throw Unauthorized(response); + } + else if (http_code > 401) { std::stringstream error_message; error_message << "HTTP code |" << to_string(http_code) << "| HTTP error |" << response << "|"; diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index a7995fde90..67be1eae2f 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -233,6 +233,7 @@ class OMF static std::string variableValueHandle(const Reading& reading, std::string &AFHierarchy); static bool extractVariable(string &strToHandle, string &variable, string &value, string &defaultValue); + static void reportAsset(const string& asset, const string& level, const string& msg); private: /** @@ -350,6 +351,7 @@ class OMF // string createAFLinks(Reading &reading, OMFHints *hints); + private: // Use for the evaluation of the OMFDataTypes.typesShort union t_typeCount { @@ -499,6 +501,13 @@ class OMF * Force the data to be sent using the legacy, complex OMF types */ bool m_legacy; + + /** + * Assets that have been logged as avign errors. This prevents us + * from flooding the logs with reports for the same asset. + */ + static std::vector + m_reportedAssets; }; /** diff --git a/C/plugins/north/OMF/include/omflinkeddata.h b/C/plugins/north/OMF/include/omflinkeddata.h index c2e174a962..7ce56f7155 100644 --- a/C/plugins/north/OMF/include/omflinkeddata.h +++ b/C/plugins/north/OMF/include/omflinkeddata.h @@ -100,5 +100,6 @@ class OMFLinkedData std::string m_containers; std::string m_doubleFormat; std::string m_integerFormat; + }; #endif diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index 8f97d6af36..2e13b4a077 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -20,10 +20,12 @@ #include #include +#include #include using namespace std; + /** * OMFLinkedData constructor, generates the OMF message containing the data * @@ -65,7 +67,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi // Get reading data const vector data = reading.getReadingData(); - unsigned long skipDatapoints = 0; + vector skippedDatapoints; Logger::getLogger()->info("Processing %s with new OMF method", assetName.c_str()); @@ -95,7 +97,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi } if (!isTypeSupported((*it)->getData())) { - skipDatapoints++;; + skippedDatapoints.push_back(dpName); continue; } else @@ -144,7 +146,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi if (baseType.empty()) { // Type is not supported, skip the datapoint - skipDatapoints++;; + skippedDatapoints.push_back(dpName); continue; } if (m_linkSent->find(link) == m_linkSent->end()) @@ -175,9 +177,23 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi outData.append("} ] }"); } } - if (skipDatapoints > 0) + if (skippedDatapoints.size() > 0) { - Logger::getLogger()->warn("The asset %s had a number of datapoints that are nor supported by OMF and have been omitted", reading.getAssetName().c_str()); + string points; + for (string& dp : skippedDatapoints) + { + if (!points.empty()) + points.append(", "); + points.append(dp); + } + auto pos = points.find_last_of(","); + if (pos != string::npos) + { + points.replace(pos, 1, " and"); + } + string assetName = reading.getAssetName(); + string msg = "The asset " + assetName + " had a number of datapoints, " + points + " that are not supported by OMF and have been omitted"; + OMF::reportAsset(assetName, "warn", msg); } Logger::getLogger()->debug("Created data messasges %s", outData.c_str()); return outData; @@ -235,7 +251,8 @@ string OMFLinkedData::sendContainer(string& linkName, Datapoint *dp, const strin break; } default: - Logger::getLogger()->error("Unsupported type %s", dp->getData().getTypeStr()); + Logger::getLogger()->error("Unsupported type %s for the data point %s", dp->getData().getTypeStr(), + dp->getName().c_str()); // Not supported return baseType; } @@ -364,7 +381,7 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect payload); if ( ! (res >= 200 && res <= 299) ) { - Logger::getLogger()->error("Sending containers, HTTP code %d - %s %s", + Logger::getLogger()->error("An error occured sending the container data. HTTP code %d - %s %s", res, sender.getHostPort().c_str(), path.c_str()); @@ -375,7 +392,7 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect catch (const BadRequest& e) { - Logger::getLogger()->warn("Sending containers, not blocking issue: %s - %s %s", + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending containers: %s - %s %s", e.what(), sender.getHostPort().c_str(), path.c_str()); @@ -385,7 +402,7 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect catch (const std::exception& e) { - Logger::getLogger()->error("Sending containers, %s - %s %s", + Logger::getLogger()->error("An exception occured when sending container informatin the the OMF endpoint, %s - %s %s", e.what(), sender.getHostPort().c_str(), path.c_str()); diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index 80036d92af..7a1b99ccc1 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -37,6 +37,7 @@ using namespace std; using namespace rapidjson; static bool isTypeSupported(DatapointValue& dataPoint); +vector OMF::m_reportedAssets; // 1 enable performance tracking #define INSTRUMENT 0 @@ -350,6 +351,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) // Build an HTTPS POST with 'resType' headers // and 'typeData' JSON payload // Then get HTTPS POST ret code and return 0 to client on error + string assetName = row.getAssetName(); try { res = m_sender.sendRequest("POST", @@ -358,10 +360,9 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) typeData); if ( ! (res >= 200 && res <= 299) ) { - Logger::getLogger()->error("Sending JSON dataType message 'Type', HTTP code %d - %s %s", - res, - m_sender.getHostPort().c_str(), - m_path.c_str()); + string msg = "An error occured sending the dataType message for the asset " + assetName; + msg.append(". HTTP error code " + to_string(res)); + reportAsset(assetName, "error", msg); return false; } } @@ -373,24 +374,30 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) // Data type error: force type-id change m_changeTypeId = true; } - string errorMsg = errorMessageHandler(e.what()); + string errorMsg = errorMessageHandler(e.what()); - Logger::getLogger()->warn("Sending dataType message 'Type', not blocking issue: %s %s - %s %s", - (m_changeTypeId ? "Data Type " : "" ), - errorMsg.c_str(), - m_sender.getHostPort().c_str(), - m_path.c_str()); + string msg = "An error occured sending the dataType message for the asset " + assetName + + ". " + errorMsg; + if (m_changeTypeId) + { + msg.append(". A data type change will take place to try to resolve this error"); + } + reportAsset(assetName, "error", msg); return false; } + catch (const Unauthorized& e) + { + Logger::getLogger()->error("OMF endpoint reported we are not authorized, please check configuration of the authentication method and credentials"); + return false; + } catch (const std::exception& e) { string errorMsg = errorMessageHandler(e.what()); - Logger::getLogger()->error("Sending dataType message 'Type', %s - %s %s", - errorMsg.c_str(), - m_sender.getHostPort().c_str(), - m_path.c_str()); + string msg = "An error occured sending the dataType message for the asset " + assetName + + ". " + errorMsg; + reportAsset(assetName, "error", msg); m_connected = false; return false; } @@ -411,11 +418,9 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) typeContainer); if ( ! (res >= 200 && res <= 299) ) { - Logger::getLogger()->error("Sending JSON dataType message 'Container' " - "- error: HTTP code |%d| - %s %s", - res, - m_sender.getHostPort().c_str(), - m_path.c_str() ); + string msg = "An error occured sending the dataType container message for the asset " + assetName; + msg.append(". HTTP error code " + to_string(res)); + reportAsset(assetName, "error", msg); return false; } } @@ -429,22 +434,27 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) } string errorMsg = errorMessageHandler(e.what()); - Logger::getLogger()->warn("Sending JSON dataType message 'Container' " - "not blocking issue: |%s| - %s - %s %s", - (m_changeTypeId ? "Data Type " : "" ), - errorMsg.c_str(), - m_sender.getHostPort().c_str(), - m_path.c_str() ); + string msg = "An error occured sending the dataType container message for the asset " + assetName + + ". " + errorMsg; + if (m_changeTypeId) + { + msg.append(". A data type change will take place to try to resolve this error"); + } + reportAsset(assetName, "error", msg); + return false; + } + catch (const Unauthorized& e) + { + Logger::getLogger()->error("OMF endpoint reported we are not authorized, please check configuration of the authentication method and credentials"); return false; } catch (const std::exception& e) { string errorMsg = errorMessageHandler(e.what()); - Logger::getLogger()->error("Sending JSON dataType message 'Container' - %s - %s %s", - errorMsg.c_str(), - m_sender.getHostPort().c_str(), - m_path.c_str()); + string msg = "An error occured sending the dataType message for the asset " + assetName + + ". " + errorMsg; + reportAsset(assetName, "error", msg); m_connected = false; return false; } @@ -468,11 +478,9 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) typeStaticData); if ( ! (res >= 200 && res <= 299) ) { - Logger::getLogger()->error("Sending JSON dataType message 'StaticData' " - "- error: HTTP code |%d| - %s %s", - res, - m_sender.getHostPort().c_str(), - m_path.c_str() ); + string msg = "An error occured sending the StaticData dataType message for the asset " + assetName; + msg.append(". HTTP error code " + to_string(res)); + reportAsset(assetName, "warn", msg); return false; } } @@ -486,23 +494,22 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) } string errorMsg = errorMessageHandler(e.what()); - Logger::getLogger()->warn("Sending JSON dataType message 'StaticData'" - "not blocking issue: |%s| - %s - %s %s", - (m_changeTypeId ? "Data Type " : "" ), - errorMsg.c_str(), - m_sender.getHostPort().c_str(), - m_path.c_str() ); + string msg = "An error occured sending the dataType staticData message for the asset " + assetName + + ". " + errorMsg; + if (m_changeTypeId) + { + msg.append(". A data type change will take place to try to resolve this error"); + } + reportAsset(assetName, "warn", msg); return false; } catch (const std::exception& e) { string errorMsg = errorMessageHandler(e.what()); - Logger::getLogger()->error("Sending JSON dataType message 'StaticData'" - "- generic error: %s - %s %s", - errorMsg.c_str(), - m_sender.getHostPort().c_str(), - m_path.c_str() ); + string msg = "An error occured sending the dataType staticData message for the asset " + assetName + + ". " + errorMsg; + reportAsset(assetName, "warn", msg); m_connected = false; return false; } @@ -569,7 +576,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) return false; } } - // Exception raised fof HTTP 400 Bad Request + // Exception raised for HTTP 400 Bad Request catch (const BadRequest &e) { if (OMF::isDataTypeError(e.what())) @@ -4734,3 +4741,28 @@ string AFDataMessage; } return AFDataMessage; } + +/** + * Report an error related to an asset if the asset has not already been reported + * + * @param asset The asset name + * @param level The level to log the message at + * @param msg The message to log + */ +void OMF::reportAsset(const string& asset, const string& level, const string& msg) +{ + if (std::find(m_reportedAssets.begin(), m_reportedAssets.end(), asset) == m_reportedAssets.end()) + { + m_reportedAssets.push_back(asset); + if (level.compare("error") == 0) + Logger::getLogger()->error(msg); + else if (level.compare("warn") == 0) + Logger::getLogger()->warn(msg); + else if (level.compare("fatal") == 0) + Logger::getLogger()->fatal(msg); + else if (level.compare("info") == 0) + Logger::getLogger()->info(msg); + else + Logger::getLogger()->debug(msg); + } +} diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index c310dd040a..f5a2e008df 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -895,7 +895,7 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, // Check if the endpoint is PI Web API and if the PI Web API server is available if (!IsPIWebAPIConnected(connInfo, version)) { - Logger::getLogger()->warn("PI Web API server %s is not available. Unable to send data to PI", connInfo->hostAndPort.c_str()); + // Error already reported by IsPIWebAPIConnected return 0; } @@ -1728,7 +1728,7 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) if (now >= nextCheck) { int httpCode = PIWebAPIGetVersion(connInfo, version, false); - if (httpCode >= 500) + if (httpCode >= 400) { s_connected = false; now = std::chrono::steady_clock::now(); @@ -1739,7 +1739,7 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) reportedState = false; reported = true; Logger::getLogger()->error("The PI Web API service %s is not available", - connInfo->hostAndPort); + connInfo->hostAndPort.c_str()); } } else @@ -1751,7 +1751,7 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) reportedState = true; reported = true; Logger::getLogger()->warn("The PI Web API service %s has become available", - connInfo->hostAndPort); + connInfo->hostAndPort.c_str()); } } } diff --git a/docs/images/OMF_Formats.jpg b/docs/images/OMF_Formats.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ea6bcba7a65f330025dd502d508ae42120a87a1a GIT binary patch literal 71804 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%0D#KQWJwWuC8 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 1.9.1 and PI Web API 2019 SP1 1.13.0.6518 @@ -27,10 +29,23 @@ using Fledge version >= 1.9.1 and PI Web API 2019 SP1 1.13.0.6518 - `Error messages and causes`_ - `Possible solutions to common problems`_ +Fledge 2.1.0 and later +====================== + +In vesion 2.1 of Fledge a major change was introduced to the OMF plugin in the form of support for OMF version 1.2. This provides for a different method of adding data to the OMF end points that greatly improves the flexibility and removes the need to create complex types in OMF to map onto the Fledge reading structure. +When upgrading from a version prior to 2.1 where data had previously been sent to OMF, the plugin will continue to use the older, pre-OMF 1.2 method to add data. This ensures that data will continue to be written to the same tags within the PI Server or other OMF end points. New data, not previosuly sent to OMF will be written using the newer OMF 1.2 mechanism. + +It is possible to force the OMF plugin to always send data in the pre-OMF 1.2 format, using complex OMF data types, by turning on the option *Complex Types* in the *Formats & Types* tab of the plugin configuration. + ++---------------+ +| |OMF_Formats| | ++---------------+ + Log files ========= Fledge logs messages at error and warning levels by default, it is possible to increase the verbosity of messages logged to include information and debug messages also. This is done by altering the minimum log level setting for the north service or task. To change the minimal log level within the graphical user interface select the north service or task, click on the advanced settings link and then select a new minimal log level from the option list presented. + The name of the north instance should be used to extract just the logs about the PI Server integration, as in this example: screenshot from the Fledge GUI @@ -147,8 +162,8 @@ Managing Plugin Persisted Data This is not a feature that users would ordinarily need to be concerned with, however it is possible to enable *Developer Features* in the Fledge User Interface that will provide a mechanism to manage this data. -Enable Develop Features -~~~~~~~~~~~~~~~~~~~~~~~ +Enable Developer Features +~~~~~~~~~~~~~~~~~~~~~~~~~ Navigate to the *Settings* page of the GUI and toggle on the *Developer Features* check box on the bottom left of the page. diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index 91ee7ba0ec..fbe968530c 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -135,7 +135,7 @@ def __init__(self, core_management_host=None, core_management_port=None, is_safe # Initialize class attributes if not cls._logger: - cls._logger = logger.setup(__name__, level=logging.INFO) + cls._logger = logger.setup(__name__, level=logging.WARN) # cls._logger = logger.setup(__name__, level=logging.DEBUG) if not cls._core_management_port: cls._core_management_port = core_management_port From 71dcb5ab6d9b81fa9ea87ce2d86c840430974db7 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 10 Feb 2023 15:20:13 +0000 Subject: [PATCH 110/499] More clean and fix compiler warning Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/omf.cpp | 42 +++++++++++++++---------------------- C/services/south/south.cpp | 5 +++-- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index 7a1b99ccc1..98c8eea254 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -509,7 +509,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) string msg = "An error occured sending the dataType staticData message for the asset " + assetName + ". " + errorMsg; - reportAsset(assetName, "warn", msg); + reportAsset(assetName, "debug", msg); m_connected = false; return false; } @@ -547,13 +547,6 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) objectPrefix = prefix; } - Logger::getLogger()->debug("%s - assetName :%s: AFHierarchy :%s: prefix :%s: objectPrefix :%s: AFHierarchyLevel :%s: ", __FUNCTION__ - ,assetName.c_str() - , AFHierarchy.c_str() - , prefix.c_str() - , objectPrefix.c_str() - , AFHierarchyLevel.c_str() ); - // Create data for Static Data message string typeLinkData = OMF::createLinkData(row, AFHierarchyLevel, prefix, objectPrefix, hints, true); string payload = "[" + typeLinkData + "]"; @@ -569,10 +562,9 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) payload); if (!(res >= 200 && res <= 299)) { - Logger::getLogger()->error("Sending JSON dataType message 'Data' (lynk) - error: HTTP code |%d| - %s %s", - res, - m_sender.getHostPort().c_str(), - m_path.c_str()); + string msg = "An error occured sending the link dataType message for the asset " + assetName; + msg.append(". HTTP error code " + to_string(res)); + reportAsset(assetName, "warn", msg); return false; } } @@ -585,31 +577,31 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) m_changeTypeId = true; } string errorMsg = errorMessageHandler(e.what()); + string msg = "An error occured sending the dataType link message for the asset " + assetName + + ". " + errorMsg; + if (m_changeTypeId) + { + msg.append(". A data type change will take place to try to resolve this error"); + } + reportAsset(assetName, "warn", msg); - Logger::getLogger()->warn("Sending JSON dataType message 'Data' (lynk) " - "not blocking issue: |%s| - %s - %s %s", - (m_changeTypeId ? "Data Type " : ""), - errorMsg.c_str(), - m_sender.getHostPort().c_str(), - m_path.c_str() ); return false; } catch (const std::exception &e) { string errorMsg = errorMessageHandler(e.what()); - Logger::getLogger()->error("Sending JSON dataType message 'Data' (lynk) " - "- generic error: %s - %s %s", - errorMsg.c_str(), - m_sender.getHostPort().c_str(), - m_path.c_str() ); + string msg = "An error occured sending the dataType staticData message for the asset " + assetName + + ". " + errorMsg; + reportAsset(assetName, "debug", msg); return false; } } } else { - Logger::getLogger()->error("AF hiererachy is not defined for the asset Name |%s|", assetName.c_str()); + string msg("AF hiererachy is not defined for the asset " + assetName); + reportAsset(assetName, "warn", msg); } } // All data types sent: success @@ -1048,7 +1040,7 @@ uint32_t OMF::sendToServer(const vector& readings, { if (!sendBaseTypes()) { - Logger::getLogger()->error("Unable to send base types, linked assets will not be sent"); + Logger::getLogger()->error("Unable to send base types, linked assets will not be sent. The system will fall back to using complex types."); m_linkedProperties = false; } } diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index f1075b927c..bc3473960f 100755 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -1433,7 +1433,7 @@ bool SouthService::syncToNextPoll() time_t tim = time(0); struct tm tm; localtime_r(&tim, &tm); - unsigned long waitFor; + unsigned long waitFor = 1; if (m_hours.size() == 0 && m_minutes.size() == 0 && m_seconds.size() == 0) { @@ -1590,7 +1590,8 @@ bool SouthService::syncToNextPoll() uint64_t exp; while (waitFor) { - read(m_timerfd, &exp, sizeof(uint64_t)); + if (read(m_timerfd, &exp, sizeof(uint64_t)) == -1) + return false; waitFor--; if (m_shutdown) return false; From fa1d7a511c78d0c901ed59c28f357b8ee2caa215 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 10 Feb 2023 16:53:41 +0000 Subject: [PATCH 111/499] FOGL-7428 Revert change to make the unit tyests oass. This need to be fixed at soem point as INFO is the wrong default log level Signed-off-by: Mark Riddoch --- python/fledge/services/core/scheduler/scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index fbe968530c..91ee7ba0ec 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -135,7 +135,7 @@ def __init__(self, core_management_host=None, core_management_port=None, is_safe # Initialize class attributes if not cls._logger: - cls._logger = logger.setup(__name__, level=logging.WARN) + cls._logger = logger.setup(__name__, level=logging.INFO) # cls._logger = logger.setup(__name__, level=logging.DEBUG) if not cls._core_management_port: cls._core_management_port = core_management_port From 7ca9659d8f03d0ea7d8e787891e058113af0dbe7 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 10 Feb 2023 19:07:55 +0000 Subject: [PATCH 112/499] Fix review comments Signed-off-by: Mark Riddoch --- C/plugins/common/piwebapi.cpp | 2 +- C/plugins/north/OMF/include/omf.h | 2 +- C/plugins/north/OMF/linkdata.cpp | 6 +++--- C/plugins/north/OMF/omf.cpp | 26 +++++++++++++------------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/C/plugins/common/piwebapi.cpp b/C/plugins/common/piwebapi.cpp index d9e1db670e..f29ffd6b5b 100644 --- a/C/plugins/common/piwebapi.cpp +++ b/C/plugins/common/piwebapi.cpp @@ -154,7 +154,7 @@ int PIWebAPI::GetVersion(const string& host, string &version, bool logMessage) { if (logMessage) { - Logger::getLogger()->error("The PI Web API server at %s has rejected our request due to authentication issue. Please check the authentication method and crednentials are correctly confiogurd.", host.c_str()); + Logger::getLogger()->error("The PI Web API server at %s has rejected our request due to an authentication issue. Please check the authentication method and credentials are correctly configured.", host.c_str()); } httpCode = (int) SimpleWeb::StatusCode::client_error_unauthorized; } diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index 67be1eae2f..66a55e9b6a 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -503,7 +503,7 @@ class OMF bool m_legacy; /** - * Assets that have been logged as avign errors. This prevents us + * Assets that have been logged as having errors. This prevents us * from flooding the logs with reports for the same asset. */ static std::vector diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index 2e13b4a077..778e199197 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -381,10 +381,10 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect payload); if ( ! (res >= 200 && res <= 299) ) { - Logger::getLogger()->error("An error occured sending the container data. HTTP code %d - %s %s", + Logger::getLogger()->error("An error occurred sending the container data. HTTP code %d - %s %s", res, sender.getHostPort().c_str(), - path.c_str()); + m_sender.getHTTPResponse()); return false; } } @@ -402,7 +402,7 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect catch (const std::exception& e) { - Logger::getLogger()->error("An exception occured when sending container informatin the the OMF endpoint, %s - %s %s", + Logger::getLogger()->error("An exception occurred when sending container information the the OMF endpoint, %s - %s %s", e.what(), sender.getHostPort().c_str(), path.c_str()); diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index 98c8eea254..bc5064302b 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -360,7 +360,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) typeData); if ( ! (res >= 200 && res <= 299) ) { - string msg = "An error occured sending the dataType message for the asset " + assetName; + string msg = "An error occurred sending the dataType message for the asset " + assetName; msg.append(". HTTP error code " + to_string(res)); reportAsset(assetName, "error", msg); return false; @@ -376,7 +376,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) } string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occured sending the dataType message for the asset " + assetName + string msg = "An error occurred sending the dataType message for the asset " + assetName + ". " + errorMsg; if (m_changeTypeId) { @@ -395,7 +395,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) { string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occured sending the dataType message for the asset " + assetName + string msg = "An error occurred sending the dataType message for the asset " + assetName + ". " + errorMsg; reportAsset(assetName, "error", msg); m_connected = false; @@ -418,7 +418,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) typeContainer); if ( ! (res >= 200 && res <= 299) ) { - string msg = "An error occured sending the dataType container message for the asset " + assetName; + string msg = "An error occurred sending the dataType container message for the asset " + assetName; msg.append(". HTTP error code " + to_string(res)); reportAsset(assetName, "error", msg); return false; @@ -434,7 +434,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) } string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occured sending the dataType container message for the asset " + assetName + string msg = "An error occurred sending the dataType container message for the asset " + assetName + ". " + errorMsg; if (m_changeTypeId) { @@ -452,7 +452,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) { string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occured sending the dataType message for the asset " + assetName + string msg = "An error occurred sending the dataType message for the asset " + assetName + ". " + errorMsg; reportAsset(assetName, "error", msg); m_connected = false; @@ -478,7 +478,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) typeStaticData); if ( ! (res >= 200 && res <= 299) ) { - string msg = "An error occured sending the StaticData dataType message for the asset " + assetName; + string msg = "An error occurred sending the StaticData dataType message for the asset " + assetName; msg.append(". HTTP error code " + to_string(res)); reportAsset(assetName, "warn", msg); return false; @@ -494,7 +494,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) } string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occured sending the dataType staticData message for the asset " + assetName + string msg = "An error occurred sending the dataType staticData message for the asset " + assetName + ". " + errorMsg; if (m_changeTypeId) { @@ -507,7 +507,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) { string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occured sending the dataType staticData message for the asset " + assetName + string msg = "An error occurred sending the dataType staticData message for the asset " + assetName + ". " + errorMsg; reportAsset(assetName, "debug", msg); m_connected = false; @@ -562,7 +562,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) payload); if (!(res >= 200 && res <= 299)) { - string msg = "An error occured sending the link dataType message for the asset " + assetName; + string msg = "An error occurred sending the link dataType message for the asset " + assetName; msg.append(". HTTP error code " + to_string(res)); reportAsset(assetName, "warn", msg); return false; @@ -577,7 +577,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) m_changeTypeId = true; } string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occured sending the dataType link message for the asset " + assetName + string msg = "An error occurred sending the dataType link message for the asset " + assetName + ". " + errorMsg; if (m_changeTypeId) { @@ -591,7 +591,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) { string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occured sending the dataType staticData message for the asset " + assetName + string msg = "An error occurred sending the dataType staticData message for the asset " + assetName + ". " + errorMsg; reportAsset(assetName, "debug", msg); return false; @@ -600,7 +600,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) } else { - string msg("AF hiererachy is not defined for the asset " + assetName); + string msg("AF hierarchy is not defined for the asset " + assetName); reportAsset(assetName, "warn", msg); } } From 785a761e747604cf1b2d430e51fb996cf5bf4ad9 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 10 Feb 2023 23:40:59 +0000 Subject: [PATCH 113/499] Add ceck to see if container needs updating Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/include/omflinkeddata.h | 3 ++- C/plugins/north/OMF/linkdata.cpp | 29 ++++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/C/plugins/north/OMF/include/omflinkeddata.h b/C/plugins/north/OMF/include/omflinkeddata.h index 7ce56f7155..315d028e57 100644 --- a/C/plugins/north/OMF/include/omflinkeddata.h +++ b/C/plugins/north/OMF/include/omflinkeddata.h @@ -55,7 +55,8 @@ class OMFLinkedData m_integerFormat = integerFormat; }; private: - std::string sendContainer(std::string& link, Datapoint *dp, const std::string& format, OMFHints * hints); + std::string getBaseType(Datapoint *dp, const std::string& format); + void sendContainer(std::string& link, Datapoint *dp, OMFHints * hints, const std::string& baseType); bool isTypeSupported(DatapointValue& dataPoint) { switch (dataPoint.getType()) diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index abff7dd318..364879029f 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -132,16 +132,20 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi // Create the link for the asset if not already created string link = assetName + "." + dpName; - string baseType; + string baseType = getBaseType(*it, format); auto container = m_containerSent->find(link); if (container == m_containerSent->end()) { - baseType = sendContainer(link, *it, format, hints); + sendContainer(link, *it, hints, baseType); m_containerSent->insert(pair(link, baseType)); } else { - baseType = container->second; + if (baseType.compare(container->second) != 0) + { + sendContainer(link, *it, hints, baseType); + (*m_containerSent)[link] = baseType; + } } if (baseType.empty()) { @@ -200,15 +204,13 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi } /** - * Send the container message for the linked datapoint + * Calculate the base type we need to link the container * - * @param linkName The name to use for the container * @param dp The datapoint to process * @param format The format to use based on a hint, this may be empty - * @param hints Hints related to this asset * @return The base type linked in the container */ -string OMFLinkedData::sendContainer(string& linkName, Datapoint *dp, const string& format, OMFHints * hints) +string OMFLinkedData::getBaseType(Datapoint *dp, const string& format) { string baseType; switch (dp->getData().getType()) @@ -256,7 +258,18 @@ string OMFLinkedData::sendContainer(string& linkName, Datapoint *dp, const strin // Not supported return baseType; } +} +/** + * Send the container message for the linked datapoint + * + * @param linkName The name to use for the container + * @param dp The datapoint to process + * @param hints Hints related to this asset + * @param baseType The baseType we will sue + */ +void OMFLinkedData::sendContainer(string& linkName, Datapoint *dp, OMFHints * hints, const string& baseType) +{ string dataSource = "Fledge"; string uom, minimum, maximum, interpolation; bool propertyOverrides = false; @@ -354,8 +367,6 @@ string OMFLinkedData::sendContainer(string& linkName, Datapoint *dp, const strin if (! m_containers.empty()) m_containers += ","; m_containers.append(container); - - return baseType; } /** From 481fd36444d2af0c4381d2bfc3a6c47b7a14f813 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Sat, 11 Feb 2023 23:07:19 +0000 Subject: [PATCH 114/499] Addition of OMFError class Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/OMFError.cpp | 155 +++++++++++++++++++++++++ C/plugins/north/OMF/include/omferror.h | 57 +++++++++ C/plugins/north/OMF/linkdata.cpp | 28 +++-- 3 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 C/plugins/north/OMF/OMFError.cpp create mode 100644 C/plugins/north/OMF/include/omferror.h diff --git a/C/plugins/north/OMF/OMFError.cpp b/C/plugins/north/OMF/OMFError.cpp new file mode 100644 index 0000000000..3d29f3a40a --- /dev/null +++ b/C/plugins/north/OMF/OMFError.cpp @@ -0,0 +1,155 @@ +/* + * Fledge OSIsoft OMF interface to PI Server. + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ + +#include +#include +#include +#include +#include +#include "string_utils.h" + +#include +#include +#include + +#include +#include + +#include + +using namespace std; +using namespace rapidjson; + +/** + * Constructor + */ +OMFError::OMFError(const string& json) : m_messageCount(0) +{ + char *p = (char *)json.c_str(); + + FILE *fp = fopen("/tmp/error", "a"); + fprintf(fp, "%s\n\n", p); + fclose(fp); + + while (*p && *p != '{') + p++; + Document doc; + if (doc.ParseInsitu(p).HasParseError()) + { + Logger::getLogger()->error("Unable to parse response form ONF endpoint: %s", + GetParseError_En(doc.GetParseError())); + Logger::getLogger()->error("Error response was: %s", json.c_str()); + } + else if (doc.HasMember("Messages") && doc["Messages"].IsArray()) + { + const Value& messages = doc["Messages"].GetArray(); + m_messageCount = messages.Size(); + + for (Value::ConstValueIterator a = messages.Begin(); a != messages.End(); a++) + { + const Value& msg = *a; + if (msg.HasMember("Events") && msg["Events"].IsArray()) + { + const Value& events = msg["Events"]; + const Value& event = events[0]; + string message, reason, severity; + if (event.HasMember("Severity") && event["Severity"].IsString()) + { + severity = event["Severity"].GetString(); + if (severity.compare("Error") == 0) + { + m_hasErrors = true; + } + } + if (event.HasMember("EventInfo") && event["EventInfo"].IsObject()) + { + const Value& eventInfo = event["EventInfo"]; + if (eventInfo.HasMember("Message") && eventInfo["Message"].IsString()) + { + message = eventInfo["Message"].GetString(); + } + if (eventInfo.HasMember("Reason") && eventInfo["Reason"].IsString()) + { + reason = eventInfo["Reason"].GetString(); + } + + } + m_messages.push_back(Message(severity, message, reason)); + } + } + } +} + +/** + * Destructor for the error class + */ +OMFError::~OMFError() +{ +} + +/** + * Return the number of messages within the error report + */ +unsigned int OMFError::messageCount() +{ + return m_messageCount; +} + +/** + * Return the error message for the given message + * + * @param offset The error within the report to return + * @return string The event message + */ +string OMFError::getMessage(unsigned int offset) +{ +string rval; + + if (offset < m_messageCount) + { + rval = m_messages[offset].getMessage(); + } + return rval; +} + +/** + * Return the error reason for the given message + * + * @param offset The error within the report to return + * @return string The event reason + */ +string OMFError::getEventReason(unsigned int offset) +{ +string rval; + + if (offset < m_messageCount) + { + rval = m_messages[offset].getReason(); + } + return rval; +} + +/** + * Get the event severity for a given message + * + * @param offset The message to examine + * @return string The event severity + */ +string OMFError::getEventSeverity(unsigned int offset) +{ +string rval; + + if (offset < m_messageCount) + { + rval = m_messages[offset].getSeverity(); + } + return rval; +} + diff --git a/C/plugins/north/OMF/include/omferror.h b/C/plugins/north/OMF/include/omferror.h new file mode 100644 index 0000000000..debfa506c0 --- /dev/null +++ b/C/plugins/north/OMF/include/omferror.h @@ -0,0 +1,57 @@ +#ifndef _OMFERROR_H +#define _OMFERROR_H +/* + * Fledge OSIsoft OMF interface to PI Server. + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ + +#include +#include +#include + +/** + * An encapsulation of an error return from an OMF call. + * The class parses the JSON response and gives access to porion of that JSON response. + */ +class OMFError { + public: + OMFError(const std::string& json); + ~OMFError(); + + unsigned int messageCount(); + std::string getMessage(unsigned int offset); + std::string getEventReason(unsigned int offset); + std::string getEventSeverity(unsigned int offset); + /** + * The error report contains at least on error level event + */ + bool hasErrors() { return m_hasErrors; }; + private: + unsigned int m_messageCount; + class Message { + public: + Message(const std::string& severity, + const std::string& message, + const std::string& reason) : + m_severity(severity), + m_message(message), + m_reason(reason) + { + }; + std::string getSeverity() { return m_severity; }; + std::string getMessage() { return m_message; }; + std::string getReason() { return m_reason; }; + private: + std::string m_severity; + std::string m_message; + std::string m_reason; + }; + std::vector m_messages; + bool m_hasErrors; +}; +#endif diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index 364879029f..89086415b0 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -23,6 +23,7 @@ #include #include +#include using namespace std; @@ -139,9 +140,18 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi sendContainer(link, *it, hints, baseType); m_containerSent->insert(pair(link, baseType)); } - else + else if (baseType.compare(container->second) != 0) { - if (baseType.compare(container->second) != 0) + if (container->second.compare(0, 6, "Double") == 0 && + (baseType.compare(0, 7, "Integer") == 0 + || baseType.compare(0, 8, "UInteger") == 0)) + { + string msg = "Asset " + assetName + " data point " + dpName + + " conversion from floating point to integer is being ignored"; + OMF::reportAsset(assetName, "warn", msg); + baseType = container->second; + } + else { sendContainer(link, *it, hints, baseType); (*m_containerSent)[link] = baseType; @@ -402,13 +412,15 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect // Exception raised for HTTP 400 Bad Request catch (const BadRequest& e) { + OMFError error(sender.getHTTPResponse()); + // FIXME The following is too verbose + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending containers: %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), error.getEventReason(i).c_str()); - Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending containers: %s - %s %s", - e.what(), - sender.getHostPort().c_str(), - path.c_str()); - - return false; + return error.hasErrors(); } catch (const std::exception& e) { From 3d59eeaf64bf037fd8cfdc361b6dee9033cf101a Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Sun, 12 Feb 2023 16:23:23 +0000 Subject: [PATCH 115/499] Further enahcnemnts to erro rhandlign and new exxcpetion type Signed-off-by: Mark Riddoch --- C/plugins/common/include/http_sender.h | 20 ++++++ C/plugins/common/libcurl_https.cpp | 4 ++ C/plugins/common/simple_http.cpp | 4 ++ C/plugins/common/simple_https.cpp | 4 ++ C/plugins/north/OMF/linkdata.cpp | 37 +++++++++-- C/plugins/north/OMF/omf.cpp | 89 ++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 6 deletions(-) diff --git a/C/plugins/common/include/http_sender.h b/C/plugins/common/include/http_sender.h index bd35561bc5..c00d343cbd 100644 --- a/C/plugins/common/include/http_sender.h +++ b/C/plugins/common/include/http_sender.h @@ -97,4 +97,24 @@ class Unauthorized : public std::exception { private: std::string m_errmsg; }; + +/** + * Conflict exception + */ +class Conflict : public std::exception { + public: + // Constructor with parameter + Conflict (const std::string& serverReply) + { + m_errmsg = serverReply; + }; + + virtual const char *what() const throw() + { + return m_errmsg.c_str(); + } + + private: + std::string m_errmsg; +}; #endif diff --git a/C/plugins/common/libcurl_https.cpp b/C/plugins/common/libcurl_https.cpp index 3481f6e82d..f1cc6eb582 100644 --- a/C/plugins/common/libcurl_https.cpp +++ b/C/plugins/common/libcurl_https.cpp @@ -447,6 +447,10 @@ int LibcurlHttps::sendRequest( { throw Unauthorized(errorMessage); } + else if (httpCode == 409) + { + throw Conflict(errorMessage); + } else if (httpCode >= 401) { string errorMessageHTTP; diff --git a/C/plugins/common/simple_http.cpp b/C/plugins/common/simple_http.cpp index 59d65b2f7f..4c76a0c28c 100644 --- a/C/plugins/common/simple_http.cpp +++ b/C/plugins/common/simple_http.cpp @@ -241,6 +241,10 @@ int SimpleHttp::sendRequest( { throw Unauthorized(response); } + else if (http_code == 409) + { + throw Conflict(response); + } else if (http_code > 401) { std::stringstream error_message; diff --git a/C/plugins/common/simple_https.cpp b/C/plugins/common/simple_https.cpp index f0203b45dd..35dce537d6 100644 --- a/C/plugins/common/simple_https.cpp +++ b/C/plugins/common/simple_https.cpp @@ -249,6 +249,10 @@ int SimpleHttps::sendRequest( { throw Unauthorized(response); } + else if (http_code == 409) + { + throw Conflict(response); + } else if (http_code > 401) { std::stringstream error_message; diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index 89086415b0..d9b1f1a1fb 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -413,12 +413,37 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect catch (const BadRequest& e) { OMFError error(sender.getHTTPResponse()); - // FIXME The following is too verbose - Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending containers: %d messages", - error.messageCount()); - for (unsigned int i = 0; i < error.messageCount(); i++) - Logger::getLogger()->warn("Message %d: %s, %s, %s", - i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), error.getEventReason(i).c_str()); + if (error.hasErrors()) + { + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending containers: %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + { + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), error.getEventReason(i).c_str()); + } + } + + return error.hasErrors(); + } + catch (const Conflict& e) + { + OMFError error(sender.getHTTPResponse()); + // The following is possibily to verbose + if (error.hasErrors()) + { + Logger::getLogger()->warn("The OMF endpoint reported a conflict when sending containers: %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + { + string severity = error.getEventSeverity(i); + if (severity.compare("Error") == 0) + { + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), error.getEventReason(i).c_str()); + } + } + } return error.hasErrors(); } diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index de30f132b3..eedec8384a 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -33,6 +33,7 @@ #include #include #include +#include using namespace std; using namespace rapidjson; @@ -374,6 +375,17 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) // Exception raised for HTTP 400 Bad Request catch (const BadRequest& e) { + OMFError error(m_sender.getHTTPResponse()); + // FIXME The following is too verbose + if (error.hasErrors()) + { + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending data types: %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), error.getEventReason(i).c_str()); + } + if (OMF::isDataTypeError(e.what())) { // Data type error: force type-id change @@ -432,6 +444,19 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) // Exception raised for HTTP 400 Bad Request catch (const BadRequest& e) { + OMFError error(m_sender.getHTTPResponse()); + // FIXME The following is too verbose + if (error.hasErrors()) + { + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending dta type contianers : %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + { + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), error.getEventReason(i).c_str()); + } + } + if (OMF::isDataTypeError(e.what())) { // Data type error: force type-id change @@ -492,6 +517,19 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) // Exception raised fof HTTP 400 Bad Request catch (const BadRequest& e) { + OMFError error(m_sender.getHTTPResponse()); + // FIXME The following is too verbose + if (error.hasErrors()) + { + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending Static dataType: %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + { + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), error.getEventReason(i).c_str()); + } + } + if (OMF::isDataTypeError(e.what())) { // Data type error: force type-id change @@ -576,6 +614,20 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) // Exception raised for HTTP 400 Bad Request catch (const BadRequest &e) { + OMFError error(m_sender.getHTTPResponse()); + // FIXME The following is too verbose + if (error.hasErrors()) + { + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending link types: %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + { + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), + error.getMessage(i).c_str(), error.getEventReason(i).c_str()); + } + } + if (OMF::isDataTypeError(e.what())) { // Data type error: force type-id change @@ -639,6 +691,17 @@ bool OMF::AFHierarchySendMessage(const string& msgType, string& jsonData, const } catch (const BadRequest& ex) { + OMFError error(m_sender.getHTTPResponse()); + // FIXME The following is too verbose + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending AF hierarchy: %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + { + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), + error.getEventReason(i).c_str()); + } + success = false; errorMessage = ex.what(); } @@ -1466,6 +1529,19 @@ uint32_t OMF::sendToServer(const vector& readings, // Exception raised for HTTP 400 Bad Request catch (const BadRequest& e) { + OMFError error(m_sender.getHTTPResponse()); + // FIXME The following is too verbose + if (error.hasErrors()) + { + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending data: %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + { + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), error.getEventReason(i).c_str()); + } + } + if (OMF::isDataTypeError(e.what())) { // Some assets have invalid or redefined data type @@ -4651,6 +4727,19 @@ bool OMF::sendBaseTypes() // Exception raised for HTTP 400 Bad Request catch (const BadRequest& e) { + OMFError error(m_sender.getHTTPResponse()); + // FIXME The following is too verbose + if (error.hasErrors()) + { + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending base types: %d messages", + error.messageCount()); + for (unsigned int i = 0; i < error.messageCount(); i++) + { + Logger::getLogger()->warn("Message %d: %s, %s, %s", + i, error.getEventSeverity(i).c_str(), error.getMessage(i).c_str(), error.getEventReason(i).c_str()); + } + } + if (OMF::isDataTypeError(e.what())) { // Data type error: force type-id change From 89aa4bfee7d42f3775f6b7b339beb462a6caa6a0 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 13 Feb 2023 19:15:32 +0530 Subject: [PATCH 116/499] escaping issue fixes in pair tests Signed-off-by: ashish-jabble --- tests/system/python/pair/test_e2e_fledge_pair.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/python/pair/test_e2e_fledge_pair.py b/tests/system/python/pair/test_e2e_fledge_pair.py index 233d3c2353..9fc43c62cb 100644 --- a/tests/system/python/pair/test_e2e_fledge_pair.py +++ b/tests/system/python/pair/test_e2e_fledge_pair.py @@ -85,7 +85,7 @@ def reset_and_start_fledge_remote(self, storage_plugin, remote_user, remote_ip, else: subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} sed -i 's/postgres/sqlite/g' {}/data/etc/storage.json".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) - subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};echo 'YES\nYES' | $FLEDGE_ROOT/scripts/fledge reset'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) + subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};echo \"YES\nYES\" | $FLEDGE_ROOT/scripts/fledge reset'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};$FLEDGE_ROOT/scripts/fledge start'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True) stat = subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={}; $FLEDGE_ROOT/scripts/fledge status'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, stdout=subprocess.PIPE) assert "Fledge not running." not in stat.stdout.decode("utf-8") From 33d0a7329cff54b9f0e88a94707b7a071a576785 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 14 Feb 2023 16:57:50 +0530 Subject: [PATCH 117/499] sed expression fixes in fledge pair system tests for storage plugin value Signed-off-by: ashish-jabble --- tests/system/python/pair/test_e2e_fledge_pair.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/system/python/pair/test_e2e_fledge_pair.py b/tests/system/python/pair/test_e2e_fledge_pair.py index 9fc43c62cb..f8726aafdd 100644 --- a/tests/system/python/pair/test_e2e_fledge_pair.py +++ b/tests/system/python/pair/test_e2e_fledge_pair.py @@ -80,11 +80,8 @@ def reset_and_start_fledge_remote(self, storage_plugin, remote_user, remote_ip, subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};$FLEDGE_ROOT/scripts/fledge kill'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) - if storage_plugin == 'postgres': - subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} sed -i 's/sqlite/postgres/g' {}/data/etc/storage.json".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) - else: - subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} sed -i 's/postgres/sqlite/g' {}/data/etc/storage.json".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) - + storage_plugin_val = "postgres" if storage_plugin == 'postgres' else "sqlite" + subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} echo $(jq -c --arg STORAGE_PLUGIN_VAL {} '.plugin.value=$STORAGE_PLUGIN_VAL' {}/data/etc/storage.json) > {}/data/etc/storage.json".format(key_path, remote_user, remote_ip, storage_plugin_val, remote_fledge_path, remote_fledge_path)], shell=True, check=True) subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};echo \"YES\nYES\" | $FLEDGE_ROOT/scripts/fledge reset'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};$FLEDGE_ROOT/scripts/fledge start'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True) stat = subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={}; $FLEDGE_ROOT/scripts/fledge status'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, stdout=subprocess.PIPE) From 08f7c57499e9a6b2cdd4cb69b98dd4aeb1b578af Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Tue, 14 Feb 2023 16:51:45 -0500 Subject: [PATCH 118/499] Fixed PI Web API version check Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/plugin.cpp | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 8c36eaea22..3d283eec63 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -469,9 +469,9 @@ OMF_ENDPOINT identifyPIServerEndpoint (CONNECTOR_INFO* connInfo); string AuthBasicCredentialsGenerate (string& userId, string& password); void AuthKerberosSetup (string& keytabFile, string& keytabFileName); string OCSRetrieveAuthToken (CONNECTOR_INFO* connInfo); -int PIWebAPIGetVersion (CONNECTOR_INFO* connInfo, std::string &version, bool logMessage = true); +int PIWebAPIGetVersion (CONNECTOR_INFO* connInfo, bool logMessage = true); double GetElapsedTime (struct timeval *startTime); -bool IsPIWebAPIConnected (CONNECTOR_INFO* connInfo, std::string& version); +bool IsPIWebAPIConnected (CONNECTOR_INFO* connInfo); /** @@ -864,7 +864,7 @@ void plugin_start(const PLUGIN_HANDLE handle, s_connected = true; if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) { - int httpCode = PIWebAPIGetVersion(connInfo, connInfo->PIWebAPIVersion); + int httpCode = PIWebAPIGetVersion(connInfo); if (httpCode >= 200 && httpCode < 400) { Logger::getLogger()->info("%s connected to %s" ,connInfo->PIWebAPIVersion.c_str(), connInfo->hostAndPort.c_str()); @@ -895,15 +895,19 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, string version; // Check if the endpoint is PI Web API and if the PI Web API server is available - if (!IsPIWebAPIConnected(connInfo, version)) + if (!IsPIWebAPIConnected(connInfo)) { // Error already reported by IsPIWebAPIConnected return 0; } - if (version.empty() && connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) + if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) { - PIWebAPIGetVersion(connInfo, version, false); + version = connInfo->PIWebAPIVersion; + } + else + { + version = "1.0"; } Logger::getLogger()->info("Version is '%s'", version.c_str()); @@ -1544,12 +1548,11 @@ long getMaxTypeId(CONNECTOR_INFO* connInfo) /** * Calls the PI Web API to retrieve the version * - * @param connInfo The CONNECTOR_INFO data structure - * @param version Returned version string + * @param connInfo The CONNECTOR_INFO data structure which includes version * @param logMessage If true, log error messages (default: true) * @return httpCode HTTP response code */ -int PIWebAPIGetVersion(CONNECTOR_INFO* connInfo, std::string &version, bool logMessage) +int PIWebAPIGetVersion(CONNECTOR_INFO* connInfo, bool logMessage) { PIWebAPI *_PIWebAPI; @@ -1559,7 +1562,7 @@ int PIWebAPIGetVersion(CONNECTOR_INFO* connInfo, std::string &version, bool logM _PIWebAPI->setAuthMethod (connInfo->PIWebAPIAuthMethod); _PIWebAPI->setAuthBasicCredentials(connInfo->PIWebAPICredentials); - int httpCode = _PIWebAPI->GetVersion(connInfo->hostAndPort, version, logMessage); + int httpCode = _PIWebAPI->GetVersion(connInfo->hostAndPort, connInfo->PIWebAPIVersion, logMessage); delete _PIWebAPI; return httpCode; @@ -1715,10 +1718,9 @@ double GetElapsedTime(struct timeval *startTime) * Check if the PI Web API server is available by reading the product version * * @param connInfo The CONNECTOR_INFO data structure - * @param version Returned version string * @return Connection status */ -bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) +bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo) { static std::chrono::steady_clock::time_point nextCheck; static bool reported = false; // Has the state been reported yet @@ -1730,7 +1732,7 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) if (now >= nextCheck) { - int httpCode = PIWebAPIGetVersion(connInfo, version, false); + int httpCode = PIWebAPIGetVersion(connInfo, false); if (httpCode >= 400) { s_connected = false; @@ -1748,7 +1750,7 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) else { s_connected = true; - Logger::getLogger()->info("%s reconnected to %s", version.c_str(), connInfo->hostAndPort.c_str()); + Logger::getLogger()->info("%s reconnected to %s", connInfo->PIWebAPIVersion.c_str(), connInfo->hostAndPort.c_str()); if (reported == true || reportedState == false) { reportedState = true; @@ -1763,7 +1765,6 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo, std::string& version) { // Endpoints other than PI Web API fail quickly when they are unavailable // so there is no need to check their status in advance. - version = "1.0"; s_connected = true; } From 5f0edf94bc2a09938a0af107fe6b10270ff7aa5f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 15 Feb 2023 15:42:59 +0530 Subject: [PATCH 119/499] Documentation added for new auth certificates to login Signed-off-by: ashish-jabble --- docs/securing_fledge.rst | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/securing_fledge.rst b/docs/securing_fledge.rst index 0b3e2d9d30..1e1f3274c0 100644 --- a/docs/securing_fledge.rst +++ b/docs/securing_fledge.rst @@ -19,6 +19,18 @@ .. |update_certificate| image:: images/update_certificate.jpg +.. Links +.. |REST API| raw:: html + + REST API + +.. |Require User Login| raw:: html + + Require User Login + +.. |User Management| raw:: html + + User Management ***************** Securing Fledge @@ -243,3 +255,47 @@ To add a new certificate select the *Import* icon in the top right of the certif +----------------------+ A dialog will appear that allows a key file and/or a certificate file to be selected and uploaded to the *Certificate Store*. An option allows to allow overwrite of an existing certificate. By default certificates may not be overwritten. + + +Generate a new auth certificates for user login +----------------------------------------------- + +Default ca certificate is available inside $FLEDGE_DATA/etc/certs and named as ca.cert. Also default admin and non-admin certs are available in the same location which will be used for Login with Certificate in Fledge i.e admin.cert, user.cert. See |Require User Login| + +Below are the steps to create custom certificate along with existing fledge based ca signed for auth certificates. + +a) Create a new certificate for username. Let say **test** + +.. code-block:: console + + $ cd $FLEDGE_ROOT + $ ./scripts/auth_certificates user test 365 + + Here script arguments are: $1=user $2=FLEDGE_USERNAME $3=SSL_DAYS_EXPIRATION + +And now you can find **test** cert inside $FLEDGE_DATA/etc/certs/ + +b) Now, it's time to create user with name **test** (case sensitive). Also only admin can create user. Below are the cURL Commands + +.. code-block:: console + + $ AUTH_TOKEN=$(curl -d '{"username": "admin", "password": "fledge"}' -sX POST ://:/fledge/login | jq '.token' | tr -d '""') + $ curl -H "authorization: $AUTH_TOKEN" -skX POST ://:/fledge/admin/user -d '{"username":"test","real_name":"Test","access_method":"cert","description":"Non-admin based role","role_id":2}' + +.. note:: + + role_id:2 (non-admin user) | if new user requires admin privileges then pass role_id:1 + +You may also refer the documentation of |REST API| cURL commands. If you are not comfortable with cURL commands then use the GUI steps |User Management| and make sure Login with admin user. + +.. note:: + + Steps a (cert creation) and b (create user) can be executed in any order. + +c) Now you can login with the newly created user **test**, with the following cURL + +.. code-block:: console + + $ curl -T $FLEDGE_DATA/etc/certs/test.cert -skX POST ://:/fledge/login + +Or use GUI |Require User Login| From e64978cc82244c3f2733704112ca4a2e5760c5dd Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Wed, 15 Feb 2023 17:31:35 -0500 Subject: [PATCH 120/499] Cleaned up error logging Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/linkdata.cpp | 28 +++++++++++- C/plugins/north/OMF/omf.cpp | 4 +- C/plugins/north/OMF/plugin.cpp | 74 +++++++++++++++++++------------- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index d9b1f1a1fb..5c14581803 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -27,6 +27,30 @@ using namespace std; +/** + * Create a comma-separated string of all Datapoint names in a Reading + * + * @param reading Reading + * @return Datapoint names in the Reading + */ +static std::string DataPointNamesAsString(const Reading& reading) +{ + std::string dataPointNames; + + for (Datapoint *datapoint : reading.getReadingData()) + { + dataPointNames.append(datapoint->getName()); + dataPointNames.append(","); + } + + if (dataPointNames.size() > 0) + { + dataPointNames.resize(dataPointNames.size() - 1); // remove trailing comma + } + + return dataPointNames; +} + /** * OMFLinkedData constructor, generates the OMF message containing the data * @@ -70,7 +94,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi const vector data = reading.getReadingData(); vector skippedDatapoints; - Logger::getLogger()->info("Processing %s with new OMF method", assetName.c_str()); + Logger::getLogger()->debug("Processing %s (%s) using Linked Types", assetName.c_str(), DataPointNamesAsString(reading).c_str()); bool needDelim = false; if (m_assetSent->find(assetName) == m_assetSent->end()) @@ -209,7 +233,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi string msg = "The asset " + assetName + " had a number of datapoints, " + points + " that are not supported by OMF and have been omitted"; OMF::reportAsset(assetName, "warn", msg); } - Logger::getLogger()->debug("Created data messasges %s", outData.c_str()); + Logger::getLogger()->debug("Created data messages %s", outData.c_str()); return outData; } diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index eedec8384a..6b2464d51c 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -1459,7 +1459,7 @@ uint32_t OMF::sendToServer(const vector& readings, */ // Create header for Readings data - vector> readingData = OMF::createMessageHeader("Data"); + vector> readingData = OMF::createMessageHeader("Data", "update"); if (compression) readingData.push_back(pair("compression", "gzip")); @@ -4766,7 +4766,7 @@ bool OMF::sendBaseTypes() m_connected = false; return false; } - Logger::getLogger()->info("Base types successully sent"); + Logger::getLogger()->debug("Base types successfully sent"); return true; } diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 3d283eec63..a3ae90221b 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -472,6 +472,7 @@ string OCSRetrieveAuthToken (CONNECTOR_INFO* connInfo); int PIWebAPIGetVersion (CONNECTOR_INFO* connInfo, bool logMessage = true); double GetElapsedTime (struct timeval *startTime); bool IsPIWebAPIConnected (CONNECTOR_INFO* connInfo); +void SetOMFVersion (CONNECTOR_INFO* connInfo); /** @@ -867,7 +868,9 @@ void plugin_start(const PLUGIN_HANDLE handle, int httpCode = PIWebAPIGetVersion(connInfo); if (httpCode >= 200 && httpCode < 400) { - Logger::getLogger()->info("%s connected to %s" ,connInfo->PIWebAPIVersion.c_str(), connInfo->hostAndPort.c_str()); + SetOMFVersion(connInfo); + Logger::getLogger()->info("%s connected to %s OMF Version: %s", + connInfo->PIWebAPIVersion.c_str(), connInfo->hostAndPort.c_str(), connInfo->omfversion.c_str()); s_connected = true; } else @@ -875,6 +878,11 @@ void plugin_start(const PLUGIN_HANDLE handle, s_connected = false; } } + else + { + SetOMFVersion(connInfo); + Logger::getLogger()->info("OMF Version: %s", connInfo->omfversion.c_str()); + } #if INSTRUMENT Logger::getLogger()->debug("plugin_start elapsed time: %6.3f seconds", GetElapsedTime(&startTime)); @@ -901,33 +909,6 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, return 0; } - if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) - { - version = connInfo->PIWebAPIVersion; - } - else - { - version = "1.0"; - } - - Logger::getLogger()->info("Version is '%s'", version.c_str()); - - // Until we know better assume OMF 1.2 as this is the base base point - // to give us the flexible type support we need - connInfo->omfversion = "1.2"; - if (version.find("2019") != std::string::npos) - { - connInfo->omfversion = "1.0"; - } - else if (version.find("2020") != std::string::npos) - { - connInfo->omfversion = "1.1"; - } - else if (version.find("2021") != std::string::npos) - { - connInfo->omfversion = "1.2"; - } - Logger::getLogger()->info("Using OMF Version '%s'", connInfo->omfversion.c_str()); /** * Select the transport library based on the authentication method and transport encryption * requirements. @@ -1568,6 +1549,39 @@ int PIWebAPIGetVersion(CONNECTOR_INFO* connInfo, bool logMessage) return httpCode; } +/** + * Set the supported OMF Version for the OMF endpoint + * + * @param connInfo The CONNECTOR_INFO data structure + */ +void SetOMFVersion(CONNECTOR_INFO* connInfo) +{ + if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) + { + if (connInfo->PIWebAPIVersion.find("2019") != std::string::npos) + { + connInfo->omfversion = "1.0"; + } + else if (connInfo->PIWebAPIVersion.find("2020") != std::string::npos) + { + connInfo->omfversion = "1.1"; + } + else if (connInfo->PIWebAPIVersion.find("2021") != std::string::npos) + { + connInfo->omfversion = "1.2"; + } + else + { + connInfo->omfversion = "1.2"; + } + } + else + { + // Assume all other OMF endpoint types support OMF Version 1.2 + connInfo->omfversion = "1.2"; + } +} + /** * Calls the OCS API to retrieve the authentication token * @@ -1750,7 +1764,9 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo) else { s_connected = true; - Logger::getLogger()->info("%s reconnected to %s", connInfo->PIWebAPIVersion.c_str(), connInfo->hostAndPort.c_str()); + SetOMFVersion(connInfo); + Logger::getLogger()->info("%s reconnected to %s OMF Version: %s", + connInfo->PIWebAPIVersion.c_str(), connInfo->hostAndPort.c_str(), connInfo->omfversion.c_str()); if (reported == true || reportedState == false) { reportedState = true; From 6a0bdb9805f169beb17d908611fc5523bc25e7a7 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 16 Feb 2023 10:37:14 +0000 Subject: [PATCH 121/499] FOGL-7419 Wakeup on demand poll when changing to interval poll mode (#974) Signed-off-by: Mark Riddoch --- C/services/south/south.cpp | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 90ebf014fa..7838977f6a 100755 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -875,6 +875,11 @@ void SouthService::processConfigChange(const string& categoryName, const string& } string units = m_configAdvanced.getValue("units"); string pollType = m_configAdvanced.getValue("pollType"); + bool wakeup = false; + if (m_pollType == POLL_ON_DEMAND) + { + wakeup = true; + } if (pollType.compare("Fixed Times") == 0) { m_pollType = POLL_FIXED; @@ -889,6 +894,11 @@ void SouthService::processConfigChange(const string& categoryName, const string& m_desiredRate.tv_sec = 1; m_desiredRate.tv_usec = 0; + if (wakeup) + { + // Wakup from on demand polling + m_pollCV.notify_all(); + } } else if (pollType.compare("Interval") == 0 && (newval != m_readingsPerSec || m_rateUnits.compare(units) != 0)) @@ -900,6 +910,21 @@ void SouthService::processConfigChange(const string& categoryName, const string& calculateTimerRate(); m_currentRate = m_desiredRate; m_timerfd = createTimerFd(m_desiredRate); // interval to be passed is in usecs + if (wakeup) + { + // Wakup from on demand polling + m_pollCV.notify_all(); + } + } + else if (pollType.compare("Interval") == 0 && m_pollType != POLL_INTERVAL) + { + // Change to interval mode without the rate changing + m_pollType = POLL_INTERVAL; + if (wakeup) + { + // Wakup from on demand polling + m_pollCV.notify_all(); + } } else if (pollType.compare("On Demand") == 0) { @@ -1436,7 +1461,7 @@ bool SouthService::syncToNextPoll() time_t tim = time(0); struct tm tm; localtime_r(&tim, &tm); - unsigned long waitFor; + unsigned long waitFor = 0; if (m_hours.size() == 0 && m_minutes.size() == 0 && m_seconds.size() == 0) { @@ -1593,8 +1618,9 @@ bool SouthService::syncToNextPoll() uint64_t exp; while (waitFor) { - read(m_timerfd, &exp, sizeof(uint64_t)); - waitFor--; + int s = read(m_timerfd, &exp, sizeof(uint64_t)); + if (s > 0) + waitFor--; if (m_shutdown) return false; if (m_pollType != POLL_FIXED) // Configuration has change to the poll type From 8eb9e1b6fca9eee998064f21a9eb86de4eac4445 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 16 Feb 2023 10:42:06 +0000 Subject: [PATCH 122/499] Fix unit test build Signed-off-by: Mark Riddoch --- tests/unit/C/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/C/CMakeLists.txt b/tests/unit/C/CMakeLists.txt index 4460dd7a2c..5b81aea055 100644 --- a/tests/unit/C/CMakeLists.txt +++ b/tests/unit/C/CMakeLists.txt @@ -96,6 +96,7 @@ set(LIB_NAME OMF) file(GLOB OMF_LIB_SOURCES ../../../C/plugins/north/OMF/omf.cpp ../../../C/plugins/north/OMF/omfhints.cpp + ../../../C/plugins/north/OMF/omferror.cpp ../../../C/plugins/north/OMF/linkdata.cpp) add_library(${LIB_NAME} SHARED ${OMF_LIB_SOURCES}) From 2f6271cdf26295360fc527201b3321f486247761 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 16 Feb 2023 11:52:36 +0000 Subject: [PATCH 123/499] Fix naming of OMFError.cpp Signed-off-by: Mark Riddoch --- tests/unit/C/CMakeLists.txt | 2 +- tests/unit/C/services/storage/postgres/etc/storage.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/C/CMakeLists.txt b/tests/unit/C/CMakeLists.txt index 5b81aea055..53ba41dd3a 100644 --- a/tests/unit/C/CMakeLists.txt +++ b/tests/unit/C/CMakeLists.txt @@ -96,7 +96,7 @@ set(LIB_NAME OMF) file(GLOB OMF_LIB_SOURCES ../../../C/plugins/north/OMF/omf.cpp ../../../C/plugins/north/OMF/omfhints.cpp - ../../../C/plugins/north/OMF/omferror.cpp + ../../../C/plugins/north/OMF/OMFError.cpp ../../../C/plugins/north/OMF/linkdata.cpp) add_library(${LIB_NAME} SHARED ${OMF_LIB_SOURCES}) diff --git a/tests/unit/C/services/storage/postgres/etc/storage.json b/tests/unit/C/services/storage/postgres/etc/storage.json index dabc26a8cf..bfa3e91799 100644 --- a/tests/unit/C/services/storage/postgres/etc/storage.json +++ b/tests/unit/C/services/storage/postgres/etc/storage.json @@ -1 +1 @@ -{"plugin":{"value":"postgres","default":"postgres","description":"The main storage plugin to load","type":"string","displayName":"Storage Plugin","order":"1"},"readingPlugin":{"value":"","default":"","description":"The storage plugin to load for readings data. If blank the main storage plugin is used.","type":"string","displayName":"Readings Plugin","order":"2"},"threads":{"value":"1","default":"1","description":"The number of threads to run","type":"integer","displayName":"Database threads","order":"3"},"managedStatus":{"value":"false","default":"false","description":"Control if Fledge should manage the storage provider","type":"boolean","displayName":"Manage Storage","order":"4"},"port":{"value":"8080","default":"0","description":"The port to listen on","type":"integer","displayName":"Service Port","order":"5"},"managementPort":{"value":"1081","default":"0","description":"The management port to listen on.","type":"integer","displayName":"Management Port","order":"6"}} \ No newline at end of file +{"plugin":{"value":"postgres","default":"postgres","description":"The main storage plugin to load","type":"enumeration","options":["sqlite","sqlitelb","postgres"],"displayName":"Storage Plugin","order":"1"},"readingPlugin":{"value":"","default":"Use main plugin","description":"The storage plugin to load for readings data.","type":"enumeration","options":["Use main plugin","sqlite","sqlitelb","sqlitememory","postgres"],"displayName":"Readings Plugin","order":"2"},"threads":{"value":"1","default":"1","description":"The number of threads to run","type":"integer","displayName":"Database threads","order":"3"},"managedStatus":{"value":"false","default":"false","description":"Control if Fledge should manage the storage provider","type":"boolean","displayName":"Manage Storage","order":"4"},"port":{"value":"8080","default":"0","description":"The port to listen on","type":"integer","displayName":"Service Port","order":"5"},"managementPort":{"value":"1081","default":"0","description":"The management port to listen on.","type":"integer","displayName":"Management Port","order":"6"},"logLevel":{"value":"warning","default":"warning","description":"Minimum level of messages to log","type":"enumeration","displayName":"Log Level","options":["error","warning","info","debug"],"order":"7"}} \ No newline at end of file From a94dbde55f56788a8b0572bb7540590d2442073b Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 16 Feb 2023 11:58:04 +0000 Subject: [PATCH 124/499] Add missing return statement Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/linkdata.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index 5c14581803..40dee15161 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -292,6 +292,7 @@ string OMFLinkedData::getBaseType(Datapoint *dp, const string& format) // Not supported return baseType; } + return baseType; } /** From d59ad1e8bb3747c6749bf0d6ad74060f0faf4f8a Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 16 Feb 2023 15:46:25 +0000 Subject: [PATCH 125/499] Fix review comment Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/OMFError.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C/plugins/north/OMF/OMFError.cpp b/C/plugins/north/OMF/OMFError.cpp index 3d29f3a40a..4737eb3366 100644 --- a/C/plugins/north/OMF/OMFError.cpp +++ b/C/plugins/north/OMF/OMFError.cpp @@ -43,7 +43,7 @@ OMFError::OMFError(const string& json) : m_messageCount(0) Document doc; if (doc.ParseInsitu(p).HasParseError()) { - Logger::getLogger()->error("Unable to parse response form ONF endpoint: %s", + Logger::getLogger()->error("Unable to parse response form OMF endpoint: %s", GetParseError_En(doc.GetParseError())); Logger::getLogger()->error("Error response was: %s", json.c_str()); } From d8e2af176ade0aebb0c02a3400537479c3e5c109 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 16 Feb 2023 15:48:25 +0000 Subject: [PATCH 126/499] Fix review comment Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/OMFError.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C/plugins/north/OMF/OMFError.cpp b/C/plugins/north/OMF/OMFError.cpp index 4737eb3366..a97f222b11 100644 --- a/C/plugins/north/OMF/OMFError.cpp +++ b/C/plugins/north/OMF/OMFError.cpp @@ -43,7 +43,7 @@ OMFError::OMFError(const string& json) : m_messageCount(0) Document doc; if (doc.ParseInsitu(p).HasParseError()) { - Logger::getLogger()->error("Unable to parse response form OMF endpoint: %s", + Logger::getLogger()->error("Unable to parse response from OMF endpoint: %s", GetParseError_En(doc.GetParseError())); Logger::getLogger()->error("Error response was: %s", json.c_str()); } From 880037bdbbedfa89c934f8769df41acdc0ab618e Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 16 Feb 2023 17:02:12 +0000 Subject: [PATCH 127/499] Fix various typos Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/linkdata.cpp | 4 ++-- C/plugins/north/OMF/omf.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index 40dee15161..fd5b34294a 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -301,7 +301,7 @@ string OMFLinkedData::getBaseType(Datapoint *dp, const string& format) * @param linkName The name to use for the container * @param dp The datapoint to process * @param hints Hints related to this asset - * @param baseType The baseType we will sue + * @param baseType The baseType we will use */ void OMFLinkedData::sendContainer(string& linkName, Datapoint *dp, OMFHints * hints, const string& baseType) { @@ -454,7 +454,7 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect catch (const Conflict& e) { OMFError error(sender.getHTTPResponse()); - // The following is possibily to verbose + // The following is possibily too verbose if (error.hasErrors()) { Logger::getLogger()->warn("The OMF endpoint reported a conflict when sending containers: %d messages", diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index 6b2464d51c..fa4b826bdd 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -379,7 +379,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) // FIXME The following is too verbose if (error.hasErrors()) { - Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending data types: %d messages", + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending data types: %d messages", error.messageCount()); for (unsigned int i = 0; i < error.messageCount(); i++) Logger::getLogger()->warn("Message %d: %s, %s, %s", From 8fc66897999ccf04ac12ffa36047db85ae23cf23 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 16 Feb 2023 17:56:22 +0000 Subject: [PATCH 128/499] Fix more typos Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/linkdata.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index fd5b34294a..b260893e50 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -454,7 +454,7 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect catch (const Conflict& e) { OMFError error(sender.getHTTPResponse()); - // The following is possibily too verbose + // The following is possibly too verbose if (error.hasErrors()) { Logger::getLogger()->warn("The OMF endpoint reported a conflict when sending containers: %d messages", @@ -475,7 +475,7 @@ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vect catch (const std::exception& e) { - Logger::getLogger()->error("An exception occurred when sending container information the the OMF endpoint, %s - %s %s", + Logger::getLogger()->error("An exception occurred when sending container information the OMF endpoint, %s - %s %s", e.what(), sender.getHostPort().c_str(), path.c_str()); From 5aa58a57c3fbe2c5450b6c613709d5827d7a0798 Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Fri, 17 Feb 2023 01:47:04 +0530 Subject: [PATCH 129/499] fix quote for source name to fetch syslog --- python/fledge/services/core/api/support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/support.py b/python/fledge/services/core/api/support.py index 8ac08fda86..8bb780f53c 100644 --- a/python/fledge/services/core/api/support.py +++ b/python/fledge/services/core/api/support.py @@ -197,7 +197,7 @@ async def get_syslog_entries(request): scriptPath = os.path.join(_SCRIPTS_DIR, "common", "get_logs.sh") # cmd = non_total_template.format(valid_source[source], _SYSLOG_FILE, offset, limit) pattern = '({})\[.*\].*{}:'.format(valid_source[source], levels) - cmd = '{} -offset {} -limit {} -pattern \'{}\' -logfile {} -source {} -level {}'.format(scriptPath, offset, limit, pattern, _SYSLOG_FILE, source, level) + cmd = '{} -offset {} -limit {} -pattern \'{}\' -logfile {} -source \'{}\' -level {}'.format(scriptPath, offset, limit, pattern, _SYSLOG_FILE, source, level) _logger.debug('********* non_totals=true: new shell command: {}'.format(cmd)) t1 = datetime.datetime.now() From dee2bbcf2458dee506bfe370f6d3659ce3c61262 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 17 Feb 2023 11:40:04 +0530 Subject: [PATCH 130/499] omf format image file not readable & some typos fixes in troubleshooting documentation Signed-off-by: ashish-jabble --- docs/troubleshooting_pi-server_integration.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/troubleshooting_pi-server_integration.rst b/docs/troubleshooting_pi-server_integration.rst index c0cbf21a08..78ff9a027e 100644 --- a/docs/troubleshooting_pi-server_integration.rst +++ b/docs/troubleshooting_pi-server_integration.rst @@ -13,7 +13,7 @@ .. |OMF_Persisted| image:: images/OMF_Persisted.png .. |PersistedPlugins| image:: images/PersistedPlugins.png .. |PersistedActions| image:: images/PersistActions.png -.. |OMF_Formats| image:: images/OMF_Formats.png +.. |OMF_Formats| image:: images/OMF_Formats.jpg ***************************************** Troubleshooting the PI Server integration @@ -32,8 +32,8 @@ using Fledge version >= 1.9.1 and PI Web API 2019 SP1 1.13.0.6518 Fledge 2.1.0 and later ====================== -In vesion 2.1 of Fledge a major change was introduced to the OMF plugin in the form of support for OMF version 1.2. This provides for a different method of adding data to the OMF end points that greatly improves the flexibility and removes the need to create complex types in OMF to map onto the Fledge reading structure. -When upgrading from a version prior to 2.1 where data had previously been sent to OMF, the plugin will continue to use the older, pre-OMF 1.2 method to add data. This ensures that data will continue to be written to the same tags within the PI Server or other OMF end points. New data, not previosuly sent to OMF will be written using the newer OMF 1.2 mechanism. +In version 2.1 of Fledge a major change was introduced to the OMF plugin in the form of support for OMF version 1.2. This provides for a different method of adding data to the OMF end points that greatly improves the flexibility and removes the need to create complex types in OMF to map onto the Fledge reading structure. +When upgrading from a version prior to 2.1 where data had previously been sent to OMF, the plugin will continue to use the older, pre-OMF 1.2 method to add data. This ensures that data will continue to be written to the same tags within the PI Server or other OMF end points. New data, not previously sent to OMF will be written using the newer OMF 1.2 mechanism. It is possible to force the OMF plugin to always send data in the pre-OMF 1.2 format, using complex OMF data types, by turning on the option *Complex Types* in the *Formats & Types* tab of the plugin configuration. From d52e539edc9f938ed8b2220fbf62e28714232329 Mon Sep 17 00:00:00 2001 From: Rob Raesemann Date: Sun, 19 Feb 2023 06:41:16 -0500 Subject: [PATCH 131/499] Added Governance and Security MD files for stage 3 submittal (#973) Stage 3 criteria require these files. Signed-off-by: Rob Raesemann Co-authored-by: Mark Riddoch --- GOVERNANCE.MD | 7 +++++++ SECURITY.MD | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 GOVERNANCE.MD create mode 100644 SECURITY.MD diff --git a/GOVERNANCE.MD b/GOVERNANCE.MD new file mode 100644 index 0000000000..061a02bc76 --- /dev/null +++ b/GOVERNANCE.MD @@ -0,0 +1,7 @@ +# Governance + +Project governance as well as policies, procedures and instructions for contributing to FLEDGE can be found on our Wiki site at the following locations: + + +- [Fledge Governance Wiki Page](https://wiki.lfedge.org/display/FLEDGE/Governance) +- [Contributor's Guide](CONTRIBUTING.md) diff --git a/SECURITY.MD b/SECURITY.MD new file mode 100644 index 0000000000..d322363696 --- /dev/null +++ b/SECURITY.MD @@ -0,0 +1,33 @@ + + +## Security + +Fledge takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Fledge](https://github.com/Fledge-iot) + + +If you believe you have found a security vulnerability in any Fledge repository please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues, instead email to security@dianomic.com.** + +You should receive a response soon. If for some reason you do not, please follow up via email to ensure we received your original message. + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + + * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + + +## Preferred Languages + +We prefer all communications to be in English. + + \ No newline at end of file From 2ab47c828fe162fcbb8a4cee3af1bea03b65e69c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 21 Feb 2023 12:06:21 +0530 Subject: [PATCH 132/499] logs corrected in add service endpoint Signed-off-by: ashish-jabble --- python/fledge/services/core/api/service.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index cf8626dbcd..b1a5d05fda 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -353,16 +353,17 @@ async def add_service(request): plugin_info = common.load_and_fetch_python_plugin_info(plugin_module_path, plugin, service_type) plugin_config = plugin_info['config'] if not plugin_config: - _logger.exception("Plugin %s import problem from path %s", plugin, plugin_module_path) - raise web.HTTPNotFound(reason='Plugin "{}" import problem from path "{}".'.format( - plugin, plugin_module_path)) + msg = "Plugin '{}' import problem from path '{}''.".format(plugin, plugin_module_path) + _logger.exception(msg) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except FileNotFoundError as ex: # Checking for C-type plugins plugin_config = load_c_plugin(plugin, service_type) + plugin_module_path = "{}/plugins/{}/{}".format(_FLEDGE_ROOT, service_type, plugin) if not plugin_config: - _logger.exception("Plugin %s import problem from path %s. %s", plugin, plugin_module_path, str(ex)) - raise web.HTTPNotFound(reason='Plugin "{}" import problem from path "{}".'.format( - plugin, plugin_module_path)) + msg = "Plugin '{}' not found in path '{}'.".format(plugin, plugin_module_path) + _logger.exception("{} Detailed error logs are: {}".format(msg, str(ex))) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except TypeError as ex: _logger.exception(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) From fcb568dc1c24614a4a88ec385374372bde3715d8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 21 Feb 2023 12:07:03 +0530 Subject: [PATCH 133/499] logs cleanup & some PEP8 warning fixes in core server Signed-off-by: ashish-jabble --- python/fledge/services/core/server.py | 36 +++++++++++++-------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 618b298c53..ce249705b4 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -64,7 +64,7 @@ _SCRIPTS_DIR = os.path.expanduser(_FLEDGE_ROOT + '/scripts') # PID dir and filename -_FLEDGE_PID_DIR= "/var/run" +_FLEDGE_PID_DIR = "/var/run" _FLEDGE_PID_FILE = "fledge.core.pid" @@ -626,7 +626,7 @@ def pid_filename(): def _pidfile_exists(cls): """ Check whether the PID file exists """ try: - fh = open(cls._pidfile,'r') + fh = open(cls._pidfile, 'r') fh.close() return True except (FileNotFoundError, IOError, TypeError): @@ -675,11 +675,11 @@ def _write_pid(cls, api_address, api_port): raise # Build the JSON object to write into PID file - info_data = {'processID' : pid,\ - 'adminAPI' : {\ + info_data = {'processID': pid,\ + 'adminAPI': {\ "protocol": "HTTP" if cls.is_rest_server_http_enabled else "HTTPS",\ "addresses": [api_address],\ - "port": api_port }\ + "port": api_port}\ } # Write data into PID file @@ -1071,7 +1071,7 @@ def get_process_id(name): """Return process ids found by (partial) name or regex.""" child = subprocess.Popen(['pgrep', '-f', 'name={}'.format(name)], stdout=subprocess.PIPE, shell=False) response = child.communicate()[0] - return [int(pid) for pid in response.split()] + return [int(_pid) for _pid in response.split()] try: shutdown_threshold = 0 @@ -1198,8 +1198,8 @@ async def register(cls, request): # Add public token claims claims = { 'aud': service_type, - 'sub' : service_name, - 'iss' : SERVICE_JWT_AUDIENCE, + 'sub': service_name, + 'iss': SERVICE_JWT_AUDIENCE, 'exp': exp } @@ -1215,7 +1215,7 @@ async def register(cls, request): _response = { 'id': registered_service_id, 'message': "Service registered successfully", - 'bearer_token' : bearer_token + 'bearer_token': bearer_token } _logger.debug("For service: {} SERVER RESPONSE: {}".format(service_name, _response)) @@ -1291,7 +1291,6 @@ async def restart_service(cls, request): msg = str(ex) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) - @classmethod async def get_service(cls, request): """ Returns a list of all services or as per name &|| type filter @@ -1642,7 +1641,7 @@ async def add_track(cls, request): if not isinstance(data, dict): raise ValueError('Data payload must be a dictionary') - jsondata=data.get("data") + jsondata = data.get("data") try: if jsondata is None: @@ -1673,7 +1672,6 @@ async def enable_disable_schedule(cls, request: web.Request) -> web.Response: try: schedule_id = request.match_info.get('schedule_id', None) is_enabled = data.get('value', False) - _logger.exception("{} is_enabled: {}".format(cls.core_management_port, is_enabled)) if is_enabled: status, reason = await cls.scheduler.enable_schedule(uuid.UUID(schedule_id)) else: @@ -1764,9 +1762,9 @@ async def add_audit(cls, request): raise ValueError('Data payload must be a dictionary') try: - code=data.get("source") - level=data.get("severity") - message=data.get("details") + code = data.get("source") + level = data.get("severity") + message = data.get("details") # Add audit entry code and message for the given level await getattr(cls._audit, str(level).lower())(code, message) @@ -1818,10 +1816,10 @@ def validate_token(cls, token): ret = jwt.decode(token, SERVICE_JWT_SECRET, algorithms=[SERVICE_JWT_ALGORITHM], - options = {"verify_signature": True, "verify_aud": False, "verify_exp": True}) + options={"verify_signature": True, "verify_aud": False, "verify_exp": True}) return ret except Exception as e: - return { 'error' : str(e) } + return {'error': str(e)} @classmethod async def refresh_token(cls, request): @@ -1841,14 +1839,14 @@ async def refresh_token(cls, request): try: claims = cls.get_token_common(request) # Expiration set to now + delta - claims['exp'] = int(time.time()) + SERVICE_JWT_EXP_DELTA_SECONDS + claims['exp'] = int(time.time()) + SERVICE_JWT_EXP_DELTA_SECONDS bearer_token = jwt.encode(claims, SERVICE_JWT_SECRET, SERVICE_JWT_ALGORITHM).decode("utf-8") # Replace bearer_token for the service ServiceRegistry.addBearerToken(claims['sub'], bearer_token) - ret = {'bearer_token' : bearer_token} + ret = {'bearer_token': bearer_token} return web.json_response(ret) From d52eec57673d2979c2c3423a46c243d2acc08209 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 22 Feb 2023 13:47:46 +0530 Subject: [PATCH 134/499] keyword request parameter added in syslog API Signed-off-by: ashish-jabble --- python/fledge/services/core/api/support.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/api/support.py b/python/fledge/services/core/api/support.py index 8bb780f53c..17182638cc 100644 --- a/python/fledge/services/core/api/support.py +++ b/python/fledge/services/core/api/support.py @@ -128,9 +128,11 @@ async def get_syslog_entries(request): curl -X GET "http://localhost:8081/fledge/syslog?limit=5&source=storage" curl -X GET "http://localhost:8081/fledge/syslog?limit=5&offset=5&source=storage" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true" + curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&keyword=Storage%20error" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&source=|" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&limit=5" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&limit=100&offset=50" + curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&limit=100&offset=50&keyword=fledge" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&source=|&limit=10&offset=50" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&source=|" """ @@ -179,7 +181,10 @@ async def get_syslog_entries(request): template = __GET_SYSLOG_CMD_WITH_ERROR_TEMPLATE lines = __GET_SYSLOG_ERROR_MATCHED_LINES levels = "(ERROR|FATAL)" - + # keyword + keyword = '' + if 'keyword' in request.query and request.query['keyword'] != '': + keyword = request.query['keyword'].strip() response = {} # nontotals non_totals = request.query['nontotals'].lower() if 'nontotals' in request.query and request.query[ @@ -194,11 +199,14 @@ async def get_syslog_entries(request): response['count'] = total_lines cmd = template.format(valid_source[source], _SYSLOG_FILE, total_lines - offset, limit) else: - scriptPath = os.path.join(_SCRIPTS_DIR, "common", "get_logs.sh") + script_path = os.path.join(_SCRIPTS_DIR, "common", "get_logs.sh") # cmd = non_total_template.format(valid_source[source], _SYSLOG_FILE, offset, limit) pattern = '({})\[.*\].*{}:'.format(valid_source[source], levels) - cmd = '{} -offset {} -limit {} -pattern \'{}\' -logfile {} -source \'{}\' -level {}'.format(scriptPath, offset, limit, pattern, _SYSLOG_FILE, source, level) - _logger.debug('********* non_totals=true: new shell command: {}'.format(cmd)) + cmd = '{} -offset {} -limit {} -pattern \'{}\' -logfile {} -source \'{}\' -level {}'.format( + script_path, offset, limit, pattern, _SYSLOG_FILE, source, level) + if len(keyword): + cmd += ' -keyword \'{}\''.format(keyword) + _logger.debug('********* non_totals={}: new shell command: {}'.format(non_totals, cmd)) t1 = datetime.datetime.now() rv = subprocess.Popen([cmd], shell=True, stdout=subprocess.PIPE).stdout.readlines() From 1aec14751156a7a936cb6f006a264b2522cd9cb9 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 22 Feb 2023 13:48:22 +0530 Subject: [PATCH 135/499] common get logs script updated as per new keyword request param added in syslog API Signed-off-by: ashish-jabble --- scripts/common/get_logs.sh | 51 ++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/scripts/common/get_logs.sh b/scripts/common/get_logs.sh index 47d6e75652..4fe092ae66 100755 --- a/scripts/common/get_logs.sh +++ b/scripts/common/get_logs.sh @@ -3,7 +3,6 @@ __author__="Amandeep Singh Arora" __version__="1.0" - # open a log file at FD 2 for debugging purposes > /tmp/fl_syslog.log exec 2<&- @@ -15,6 +14,7 @@ NUM_LOGFILE_LINES_TO_CHECK_INITIALLY=2000 offset=100 limit=5 pattern="" +keyword="" level="debug" logfile="/var/log/syslog" sourceApp="fledge" @@ -22,6 +22,7 @@ sourceApp="fledge" while [ "$#" -gt 0 ]; do case "$1" in -pattern) pattern="$2"; shift 2;; + -keyword) keyword="$2"; shift 2;; -offset) offset="$2"; shift 2;; -limit) limit="$2"; shift 2;; -level) level="$2"; shift 2;; @@ -31,6 +32,16 @@ while [ "$#" -gt 0 ]; do done sum=$(($offset + $limit)) +factor_search=${#keyword} +if [[ $factor_search -gt 0 ]]; then + factor_keyword="$sourceApp:$level:$keyword:" + full_pattern="$pattern.*$keyword" +else + factor_keyword="$sourceApp:$level:" + full_pattern="$pattern" +fi + + echo "" >&2 echo "****************************************************************************************" >&2 echo "************************************* START ********************************************" >&2 @@ -50,24 +61,28 @@ if [[ $script_runs -gt ${RECALC_AFTER_N_SCRIPT_RUNS} ]]; then echo -n "$script_runs" > /tmp/fl_syslog_script_runs fi echo -n "$script_runs" > /tmp/fl_syslog_script_runs -echo "offset=$offset, limit=$limit, sum=$sum, pattern=$pattern, sourceApp=$sourceApp, level=$level, script_runs=$script_runs" >&2 +echo "offset=$offset, limit=$limit, sum=$sum, pattern=$full_pattern, sourceApp=$sourceApp, level=$level, script_runs=$script_runs" >&2 # calculate how many log lines are to be checked to get 'n' result lines for a given service and log level # if for getting 100 lines of interest, 6400 last syslog lines need to be checked, then factor would be 64 factor=2 if [[ $script_runs -eq 0 ]]; then - factor=$((${NUM_LOGFILE_LINES_TO_CHECK_INITIALLY} / $sum)) - [[ $factor -lt 2 ]] && factor=2 + factor=$((${NUM_LOGFILE_LINES_TO_CHECK_INITIALLY} / $sum)) + [[ $factor -lt 2 ]] && factor=2 else - if [ -f /tmp/fl_syslog_factor ]; then - echo "Reading factor value from /tmp/fl_syslog_factor" >&2 - factor=$(grep "$sourceApp:$level:" /tmp/fl_syslog_factor | cut -d: -f3) - echo "Read factor value of '$factor' from /tmp/fl_syslog_factor" >&2 - [ -z $factor ] && factor=2 && echo "Using factor value of $factor" >&2 - else - [ -z $factor ] && factor=2 && echo "Using factor value of $factor; file '/tmp/fl_syslog_factor' is missing" >&2 - echo "Starting with factor=$factor" >&2 - fi + if [ -f /tmp/fl_syslog_factor ]; then + echo "Reading factor value from /tmp/fl_syslog_factor" >&2 + if [[ $factor_search -gt 0 ]]; then + factor_value=$(grep "$factor_keyword" /tmp/fl_syslog_factor | rev | cut -d: -f1 | rev) + else + factor_value=$(grep "$factor_keyword[0-9]+$" /tmp/fl_syslog_factor | rev | cut -d: -f1 | rev) + fi + echo "Read factor value of '$factor' from /tmp/fl_syslog_factor" >&2 + [ -z $factor ] && factor=2 && echo "Using factor value of $factor" >&2 + else + [ -z $factor ] && factor=2 && echo "Using factor value of $factor; file '/tmp/fl_syslog_factor' is missing" >&2 + echo "Starting with factor=$factor" >&2 + fi fi tmpfile=$(mktemp) @@ -84,7 +99,7 @@ do echo "loop_iters=$loop_iters: factor=$factor, lines=$lines, tmpfile=$tmpfile" >&2 - cmd="tail -n $lines $logfile | grep -a -E '${pattern}' > $tmpfile" + cmd="tail -n $lines $logfile | grep -a -E '${full_pattern}' > $tmpfile" echo "cmd=$cmd, filesz=$filesz" >&2 eval "$cmd" t2=$(date +%s%N) @@ -98,8 +113,8 @@ do rm $tmpfile touch /tmp/fl_syslog_factor - grep -v "$sourceApp:$level:" /tmp/fl_syslog_factor > /tmp/fl_syslog_factor.out; mv /tmp/fl_syslog_factor.out /tmp/fl_syslog_factor - echo "$sourceApp:$level:$factor" >> /tmp/fl_syslog_factor + grep -v "$factor_keyword" /tmp/fl_syslog_factor > /tmp/fl_syslog_factor.out; mv /tmp/fl_syslog_factor.out /tmp/fl_syslog_factor + echo "$factor_keyword$factor" >> /tmp/fl_syslog_factor break else new_factor=$factor @@ -118,8 +133,8 @@ do echo "Log results END:" >&2 rm $tmpfile touch /tmp/fl_syslog_factor - grep -v "$sourceApp:$level:" /tmp/fl_syslog_factor > /tmp/fl_syslog_factor.out; mv /tmp/fl_syslog_factor.out /tmp/fl_syslog_factor - echo "$sourceApp:$level:$factor" >> /tmp/fl_syslog_factor + grep -v "$factor_keyword" /tmp/fl_syslog_factor > /tmp/fl_syslog_factor.out; mv /tmp/fl_syslog_factor.out /tmp/fl_syslog_factor + echo "$factor_keyword$factor" >> /tmp/fl_syslog_factor break fi From 71f05549b78f6df10ad031dabeaf109bcf2d36bc Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Wed, 22 Feb 2023 16:41:26 +0530 Subject: [PATCH 136/499] FOGL-7411: replace the input readingset with output/filtered readings Signed-off-by: Amandeep Singh Arora --- .../python/python_plugin_interface.cpp | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp index b3ac285f53..7be2634ed1 100755 --- a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp @@ -273,8 +273,7 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) readingsList); Py_CLEAR(pFunc); // Remove input data - delete (ReadingSet *)data; - data = NULL; + data->removeAll(); // Handle returned data if (!pReturn) @@ -285,6 +284,34 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) logErrorMessage(); } + PythonReadingSet *filteredReadingSet = NULL; + + if (pReturn) + { + // Check we have a list of readings + if (PyList_Check(readingsList)) + { + try + { + // Create ReadingSet from Python reading list + filteredReadingSet = new PythonReadingSet(readingsList); + data->append(filteredReadingSet); + delete filteredReadingSet; + } + catch (std::exception e) + { + Logger::getLogger()->warn("Unable to create a PythonReadingSet, error: %s", e.what()); + filteredReadingSet = NULL; + } + } + else + { + Logger::getLogger()->error("Filter did not return a Python List " + "but object type %s", + Py_TYPE(readingsList)->tp_name); + } + } + // Remove readings to dict Py_CLEAR(readingsList); // Remove CallFunction result From bf5ed6b2be540c918b7ea0aba1bf3a00b01338db Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 22 Feb 2023 21:52:46 +0530 Subject: [PATCH 137/499] data_accessed check fix when category is not available in cache Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index c05bba4465..be5de04971 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -91,9 +91,11 @@ def remove_oldest(self): for category_name in self.cache: if oldest_entry is None: oldest_entry = category_name - elif self.cache[category_name]['date_accessed'] < self.cache[oldest_entry]['date_accessed']: + elif self.cache[category_name].get('date_accessed') and self.cache[oldest_entry].get('date_accessed') \ + and self.cache[category_name]['date_accessed'] < self.cache[oldest_entry]['date_accessed']: oldest_entry = category_name - self.cache.pop(oldest_entry) + if oldest_entry: + self.cache.pop(oldest_entry) def remove(self, key): """Remove the entry with given key name""" From 42f2359efbd94a54c9cd507bb349a97c60782d8e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 23 Feb 2023 17:22:19 +0530 Subject: [PATCH 138/499] keyword PATTERN as a list of fixed strings (instead of regular expressions) Signed-off-by: ashish-jabble --- scripts/common/get_logs.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/common/get_logs.sh b/scripts/common/get_logs.sh index 4fe092ae66..e682343fa0 100755 --- a/scripts/common/get_logs.sh +++ b/scripts/common/get_logs.sh @@ -31,14 +31,15 @@ while [ "$#" -gt 0 ]; do esac done + sum=$(($offset + $limit)) factor_search=${#keyword} if [[ $factor_search -gt 0 ]]; then factor_keyword="$sourceApp:$level:$keyword:" - full_pattern="$pattern.*$keyword" + search_pattern="grep -a -E '${pattern}' | grep -F '$keyword'" else factor_keyword="$sourceApp:$level:" - full_pattern="$pattern" + search_pattern="grep -a -E '${pattern}'" fi @@ -61,7 +62,7 @@ if [[ $script_runs -gt ${RECALC_AFTER_N_SCRIPT_RUNS} ]]; then echo -n "$script_runs" > /tmp/fl_syslog_script_runs fi echo -n "$script_runs" > /tmp/fl_syslog_script_runs -echo "offset=$offset, limit=$limit, sum=$sum, pattern=$full_pattern, sourceApp=$sourceApp, level=$level, script_runs=$script_runs" >&2 +echo "offset=$offset, limit=$limit, sum=$sum, pattern=$search_pattern, sourceApp=$sourceApp, level=$level, script_runs=$script_runs" >&2 # calculate how many log lines are to be checked to get 'n' result lines for a given service and log level # if for getting 100 lines of interest, 6400 last syslog lines need to be checked, then factor would be 64 @@ -99,7 +100,7 @@ do echo "loop_iters=$loop_iters: factor=$factor, lines=$lines, tmpfile=$tmpfile" >&2 - cmd="tail -n $lines $logfile | grep -a -E '${full_pattern}' > $tmpfile" + cmd="tail -n $lines $logfile | ${search_pattern} > $tmpfile" echo "cmd=$cmd, filesz=$filesz" >&2 eval "$cmd" t2=$(date +%s%N) From 03deab519f0373cb15811f54c3ebeae9553bfde9 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 23 Feb 2023 17:26:28 +0530 Subject: [PATCH 139/499] strip code removed from keyword search pattern Signed-off-by: ashish-jabble --- python/fledge/services/core/api/support.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/support.py b/python/fledge/services/core/api/support.py index 17182638cc..6149c1ef7d 100644 --- a/python/fledge/services/core/api/support.py +++ b/python/fledge/services/core/api/support.py @@ -132,7 +132,7 @@ async def get_syslog_entries(request): curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&source=|" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&limit=5" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&limit=100&offset=50" - curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&limit=100&offset=50&keyword=fledge" + curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&limit=100&offset=50&keyword=fledge.services" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&source=|&limit=10&offset=50" curl -sX GET "http://localhost:8081/fledge/syslog?nontotals=true&source=|" """ @@ -184,7 +184,7 @@ async def get_syslog_entries(request): # keyword keyword = '' if 'keyword' in request.query and request.query['keyword'] != '': - keyword = request.query['keyword'].strip() + keyword = request.query['keyword'] response = {} # nontotals non_totals = request.query['nontotals'].lower() if 'nontotals' in request.query and request.query[ From 0a1dab1184fe1ef166e8d2e16ae4d500d8759fea Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Thu, 23 Feb 2023 18:18:24 -0500 Subject: [PATCH 140/499] Add exception handler to config change Signed-off-by: Ray Verhoeff --- C/services/common/management_api.cpp | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/C/services/common/management_api.cpp b/C/services/common/management_api.cpp index 0249012e4f..6d0e6d1c54 100644 --- a/C/services/common/management_api.cpp +++ b/C/services/common/management_api.cpp @@ -197,11 +197,23 @@ ostringstream convert; string responsePayload; string category, items, payload; - payload = request->content.string(); - ConfigCategoryChange conf(payload); - ConfigHandler *handler = ConfigHandler::getInstance(NULL); - handler->configChange(conf.getName(), conf.itemsToJSON(true)); - convert << "{ \"message\" ; \"Config change accepted\" }"; + try + { + payload = request->content.string(); + ConfigCategoryChange conf(payload); + ConfigHandler *handler = ConfigHandler::getInstance(NULL); + handler->configChange(conf.getName(), conf.itemsToJSON(true)); + convert << "{ \"message\" ; \"Config change accepted\" }"; + } + catch(const ConfigMalformed& e) + { + convert << "{ \"exception\" : \"ConfigMalformed\", \"message\" : \"" << e.what() << "\"}"; + } + catch(const std::exception& e) + { + convert << "{ \"exception\" : \"Any\", \"message\" : \"" << e.what() << "\"}"; + } + responsePayload = convert.str(); respond(response, responsePayload); } From 449c121022dfe7a92600b2c1cd21c1073b099fb0 Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 24 Feb 2023 17:35:36 +0530 Subject: [PATCH 141/499] FOGL-7478: Updated SQL query not to enclose strftime function within quotes Signed-off-by: nandan --- C/plugins/storage/sqlite/common/connection.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index ac165e6846..0b7ddb4253 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -207,8 +207,16 @@ bool Connection::applyColumnDateTimeFormat(sqlite3_stmt *pStmt, // Column metadata found and column datatype is "pzDataType" formatStmt = string("SELECT strftime('"); formatStmt += string(F_DATEH24_MS); - formatStmt += "', '" + string((char *) sqlite3_column_text(pStmt, i)); - formatStmt += "')"; + + string columnText ((char *) sqlite3_column_text(pStmt, i)); + if (columnText.find("strftime") != string::npos) + { + formatStmt += "', " + columnText + ")"; + } + else + { + formatStmt += "', '" + columnText + "')"; + } apply_format = true; From acace38eb3222682e07954122a3c58558848ae7b Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Fri, 24 Feb 2023 08:30:28 -0500 Subject: [PATCH 142/499] Updated exception handler for config change Signed-off-by: Ray Verhoeff --- C/services/common/management_api.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/C/services/common/management_api.cpp b/C/services/common/management_api.cpp index 6d0e6d1c54..fbaffe7887 100644 --- a/C/services/common/management_api.cpp +++ b/C/services/common/management_api.cpp @@ -195,23 +195,23 @@ void ManagementApi::configChange(shared_ptr response, shar { ostringstream convert; string responsePayload; -string category, items, payload; +string payload; try - { + { payload = request->content.string(); - ConfigCategoryChange conf(payload); + ConfigCategoryChange conf(payload); ConfigHandler *handler = ConfigHandler::getInstance(NULL); handler->configChange(conf.getName(), conf.itemsToJSON(true)); - convert << "{ \"message\" ; \"Config change accepted\" }"; + convert << "{ \"message\" : \"Config change accepted\" }"; } - catch(const ConfigMalformed& e) + catch(const std::exception& e) { - convert << "{ \"exception\" : \"ConfigMalformed\", \"message\" : \"" << e.what() << "\"}"; + convert << "{ \"exception\" : \"" << e.what() << "\" }"; } - catch(const std::exception& e) + catch(...) { - convert << "{ \"exception\" : \"Any\", \"message\" : \"" << e.what() << "\"}"; + convert << "{ \"exception\" : \"generic\" }"; } responsePayload = convert.str(); From 44d342b80b78e4a62bb047ae47ff330d1aa6b81c Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Fri, 24 Feb 2023 21:00:57 +0530 Subject: [PATCH 143/499] FOGL-7303 System tests for the newly introduced linked data type support in the OMF plugin. (#950) * Added test_pi_webapi_linked_data_type.py file * Updated conftest.py and test_pi_webapi_linked_data_type.py file to check heirarchy and verify data sent to pi from fledge is equal * added one more test for testing with filters * Refinements for verifying heirarchy as well as data sent from fledge to PI are equal * Added test that enables and disable filter and check data on fledge as well as PI server, Apart of that Some feedback changes --- tests/system/python/conftest.py | 96 +++++ .../test_pi_webapi_linked_data_type.py | 361 ++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 tests/system/python/packages/test_pi_webapi_linked_data_type.py diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index c49728b830..a0247f305f 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -423,6 +423,102 @@ def clear_pi_system_through_pi_web_api(): return clear_pi_system_pi_web_api +@pytest.fixture +def verify_hierarchy_and_get_datapoints_from_pi_web_api(): + def _verify_hierarchy_and_get_datapoints_from_pi_web_api(host, admin, password, pi_database, af_hierarchy_list, asset, sensor): + """ This method verifies hierarchy created in pi web api is correctly """ + + username_password = "{}:{}".format(admin, password) + username_password_b64 = base64.b64encode(username_password.encode('ascii')).decode("ascii") + headers = {'Authorization': 'Basic %s' % username_password_b64} + AF_HIERARCHY_LIST=af_hierarchy_list.split('/')[1:] + AF_HIERARCHY_COUNT=len(AF_HIERARCHY_LIST) + + try: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + ctx.options |= ssl.PROTOCOL_TLSv1_1 + # With ssl.CERT_NONE as verify_mode, validation errors such as untrusted or expired cert + # are ignored and do not abort the TLS/SSL handshake. + ctx.verify_mode = ssl.CERT_NONE + conn = http.client.HTTPSConnection(host, context=ctx) + conn = http.client.HTTPSConnection(host, context=ctx) + conn.request("GET", '/piwebapi/assetservers', headers=headers) + res = conn.getresponse() + r = json.loads(res.read().decode()) + dbs_url= r['Items'][0]['Links']['Databases'] + print(dbs_url) + if dbs_url is not None: + conn.request("GET", dbs_url, headers=headers) + res = conn.getresponse() + r = json.loads(res.read().decode()) + items = r['Items'] + CHECK_DATABASE_EXISTS = list(filter(lambda items: items['Name'] == pi_database, items))[0] + + if len(CHECK_DATABASE_EXISTS) > 0: + elements_url = CHECK_DATABASE_EXISTS['Links']['Elements'] + else: + raise Exception('Database not exist') + + if elements_url is not None: + conn.request("GET", elements_url, headers=headers) + res = conn.getresponse() + r = json.loads(res.read().decode()) + items = r['Items'] + + CHECK_AF_ELEMENT_EXISTS = list(filter(lambda items: items['Name'] == AF_HIERARCHY_LIST[0], items))[0] + if len(CHECK_AF_ELEMENT_EXISTS) != 0: + + counter = 0 + while counter < AF_HIERARCHY_COUNT: + if CHECK_AF_ELEMENT_EXISTS['Name'] == AF_HIERARCHY_LIST[counter]: + counter+=1 + elements_url = CHECK_AF_ELEMENT_EXISTS['Links']['Elements'] + conn.request("GET", elements_url, headers=headers) + res = conn.getresponse() + CHECK_AF_ELEMENT_EXISTS = json.loads(res.read().decode())['Items'][0] + else: + raise Exception("AF Heirarchy is incorrect") + + record = dict() + if CHECK_AF_ELEMENT_EXISTS['Name'] == asset: + record_url = CHECK_AF_ELEMENT_EXISTS['Links']['RecordedData'] + get_record_url = quote("{}?limit=10000".format(record_url), safe='?,=&/.:') + print(get_record_url) + conn.request("GET", get_record_url, headers=headers) + res = conn.getresponse() + items = json.loads(res.read().decode())['Items'] + no_of_datapoint_in_pi_server = len(items) + Item_matched = False + count = 0 + if no_of_datapoint_in_pi_server == 0: + raise "Data points are not created in PI Server" + else: + for item in items: + count += 1 + if item['Name'] in sensor: + print(item['Name']) + record[item['Name']] = list(map(lambda val: val['Value'], filter(lambda ele: isinstance(ele['Value'], int) or isinstance(ele['Value'], float) , item['Items']))) + Item_matched = True + elif count == no_of_datapoint_in_pi_server and Item_matched == False: + raise "Required Data points is not Present --> {}".format(sensor) + else: + raise "Asset does not exist, Although Hierarchy is correct" + + return(record) + + else: + raise Exception("AF Root not exists") + else: + raise Exception("Elements URL not found") + else: + raise Exception("DataBase URL not found") + + + except (KeyError, IndexError, Exception) as ex: + print("Failed to read data due to {}".format(ex)) + return None + + return(_verify_hierarchy_and_get_datapoints_from_pi_web_api) @pytest.fixture def read_data_from_pi_web_api(): diff --git a/tests/system/python/packages/test_pi_webapi_linked_data_type.py b/tests/system/python/packages/test_pi_webapi_linked_data_type.py new file mode 100644 index 0000000000..46f19a937e --- /dev/null +++ b/tests/system/python/packages/test_pi_webapi_linked_data_type.py @@ -0,0 +1,361 @@ +# -*- coding: utf-8 -*- + +# FLEDGE_BEGIN +# See: http://fledge-iot.readthedocs.io/ +# FLEDGE_END + +""" Test sending data to PI using Web API + +""" + +__author__ = "Mohit Singh Tomar" +__copyright__ = "Copyright (c) 2023 Dianomic Systems Inc" +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + +import subprocess +import http.client +import pytest +import os +import time +import utils +import json +from pathlib import Path +import urllib.parse +import base64 +import ssl +import csv + +ASSET = "FOGL-7303" +ASSET_DICT = {ASSET: ['sinusoid', 'randomwalk', 'sinusoid_exp', 'randomwalk_exp']} +SOUTH_PLUGINS_LIST = ["sinusoid", "randomwalk"] +NORTH_INSTANCE_NAME = "NorthReadingsToPI_WebAPI" +FILTER = "expression" +print("Asset Dict -->", ASSET_DICT) +# This gives the path of directory where fledge is cloned. test_file < packages < python < system < tests < ROOT +PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent +SCRIPTS_DIR_ROOT = "{}/tests/system/python/scripts/package/".format(PROJECT_ROOT) +DATA_DIR_ROOT = "{}/tests/system/python/packages/data/".format(PROJECT_ROOT) +AF_HIERARCHY_LEVEL = '/testpilinkeddata/testpilinkeddatalvl2/testpilinkeddatalvl3' +AF_HIERARCHY_LEVEL_LIST = AF_HIERARCHY_LEVEL.split("/")[1:] +print('AF HEIR -->', AF_HIERARCHY_LEVEL_LIST) + +@pytest.fixture +def reset_fledge(wait_time): + try: + subprocess.run(["cd {} && ./reset".format(SCRIPTS_DIR_ROOT)], shell=True, check=True) + except subprocess.CalledProcessError: + assert False, "reset package script failed!" + +def verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries): + get_url = "/fledge/ping" + ping_result = utils.get_request(fledge_url, get_url) + assert "dataRead" in ping_result + assert "dataSent" in ping_result + assert 0 < ping_result['dataRead'], "South data NOT seen in ping header" + + retry_count = 1 + sent = 0 + if not skip_verify_north_interface: + while retries > retry_count: + sent = ping_result["dataSent"] + if sent >= 1: + break + else: + time.sleep(wait_time) + + retry_count += 1 + ping_result = utils.get_request(fledge_url, get_url) + + assert 1 <= sent, "Failed to send data via PI Web API using Basic auth" + return ping_result + +def verify_asset(fledge_url): + get_url = "/fledge/asset" + result = utils.get_request(fledge_url, get_url) + assert len(result), "No asset found" + assert ASSET in [s["assetCode"] for s in result] + +def verify_statistics_map(fledge_url, skip_verify_north_interface): + get_url = "/fledge/statistics" + jdoc = utils.get_request(fledge_url, get_url) + actual_stats_map = utils.serialize_stats_map(jdoc) + assert 1 <= actual_stats_map[ASSET.upper()] + assert 1 <= actual_stats_map['READINGS'] + if not skip_verify_north_interface: + assert 1 <= actual_stats_map['Readings Sent'] + assert 1 <= actual_stats_map[NORTH_INSTANCE_NAME] + +def verify_asset_tracking_details(fledge_url, skip_verify_north_interface): + tracking_details = utils.get_asset_tracking_details(fledge_url, "Ingest") + assert len(tracking_details["track"]), "Failed to track Ingest event" + for item in tracking_details["track"]: + tracked_item= item + assert ASSET == tracked_item["asset"] + assert tracked_item["plugin"].lower() in SOUTH_PLUGINS_LIST + + if not skip_verify_north_interface: + egress_tracking_details = utils.get_asset_tracking_details(fledge_url, "Egress") + assert len(egress_tracking_details["track"]), "Failed to track Egress event" + tracked_item = egress_tracking_details["track"][0] + assert ASSET == tracked_item["asset"] + assert "OMF" == tracked_item["plugin"] + +def get_data_from_fledge(fledge_url, PLUGINS_LIST): + record = dict() + get_url = "/fledge/asset/{}?limit=10000".format(ASSET) + jdoc = utils.get_request(fledge_url, urllib.parse.quote(get_url, safe='?,=,&,/')) + for plugin in PLUGINS_LIST: + record[plugin] = list(map(lambda val: val['reading'][plugin], filter(lambda item: (len(item['reading'].keys())==1 and list(item['reading'].keys())[0] == plugin) or (len(item['reading'].keys())==2 and (plugin in list(item['reading'].keys()))), jdoc) )) + return(record) + +def verify_equality_between_fledge_and_pi(data_from_fledge, data_from_pi, PLUGINS_LIST): + matched = "" + for plugin in PLUGINS_LIST: + listA = sorted(data_from_fledge[plugin]) + listB = sorted(data_from_pi[plugin]) + if listA == listB: + matched = "Equal" + else: + matched = "Data of {} is unequal".format(plugin) + break + return(matched) + +def verify_filter_added(fledge_url): + get_url = "/fledge/filter" + result = utils.get_request(fledge_url, get_url)["filters"] + assert len(result) + list_of_filters = list(map(lambda val: val['name'], result)) + for plugin in SOUTH_PLUGINS_LIST: + assert "{}_exp".format(plugin) in list_of_filters + +def verify_service_added(fledge_url, ): + get_url = "/fledge/south" + result = utils.get_request(fledge_url, urllib.parse.quote(get_url, safe='?,=,&,/'))['services'] + assert len(result) + list_of_southbounds = list(map(lambda val: val['name'], result)) + for plugin in SOUTH_PLUGINS_LIST: + assert "{}_{}".format(ASSET, plugin) in list_of_southbounds + + get_url = "/fledge/north" + result = utils.get_request(fledge_url, get_url) + assert len(result) + list_of_northbounds = list(map(lambda val: val['name'], result)) + assert NORTH_INSTANCE_NAME in list_of_northbounds + + get_url = "/fledge/service" + result = utils.get_request(fledge_url, urllib.parse.quote(get_url, safe='?,=,&,/'))['services'] + assert len(result) + list_of_services = list(map(lambda val: val['name'], result)) + for plugin in SOUTH_PLUGINS_LIST: + assert "{}_{}".format(ASSET, plugin) in list_of_services + assert NORTH_INSTANCE_NAME in list_of_services + +def verify_data_between_fledge_and_piwebapi(fledge_url, pi_host, pi_admin, pi_passwd, pi_db, AF_HIERARCHY_LEVEL, ASSET, PLUGINS_LIST, verify_hierarchy_and_get_datapoints_from_pi_web_api, wait_time): + # Wait until All data loaded to PI server properly + time.sleep(wait_time) + # Checking if hierarchy created properly or not, and retrieveing data from PI Server + data_from_pi = verify_hierarchy_and_get_datapoints_from_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, AF_HIERARCHY_LEVEL, ASSET, ASSET_DICT[ASSET]) + assert len(data_from_pi) > 0, "Datapoint does not exist" + print('Data from PI Web API') + print(data_from_pi) + # Getting Data from fledge + data_from_fledge = dict() + data_from_fledge = get_data_from_fledge(fledge_url, PLUGINS_LIST) + print('data fledge retrieved') + print(data_from_fledge) + + # For verifying data send to PI Server from fledge is same. + check_data = verify_equality_between_fledge_and_pi(data_from_fledge, data_from_pi, PLUGINS_LIST) + assert check_data == 'Equal', "Data is not equal" + +def update_filter_config(fledge_url, plugin, mode): + data = {"enable": "{}".format(mode)} + put_url = "/fledge/category/{0}_{1}_{1}_exp".format(ASSET, plugin) + utils.put_request(fledge_url, urllib.parse.quote(put_url, safe='?,=,&,/'), data) + +def add_configure_filter(add_filter, fledge_url, south_plugin): + filter_cfg = {"enable": "true", "expression": "log({})".format(south_plugin), "name": "{}_exp".format(south_plugin)} + add_filter("expression", None, "{}_exp".format(south_plugin), filter_cfg, fledge_url, "{}_{}".format(ASSET, south_plugin), installation_type='package') + +@pytest.fixture +def start_south_north(add_south, start_north_task_omf_web_api, add_filter, remove_data_file, + fledge_url, pi_host, pi_port, pi_admin, pi_passwd, pi_db, + start_north_omf_as_a_service, asset_name=ASSET): + """ This fixture starts two south plugins,i.e. sinusoid and randomwalk., and one north plugin OMF for PIWebAPI. Also puts a filter + to insert reading id as a datapoint when we send the data to north. + clean_setup_fledge_packages: purge the fledge* packages and install latest for given repo url + add_south: Fixture that adds a south service with given configuration + start_north_task_omf_web_api: Fixture that starts PI north task + remove_data_file: Fixture that remove data file created during the tests """ + + poll_rate=1 + + _config = {"assetName": {"value": "{}".format(ASSET)}} + for south_plugin in SOUTH_PLUGINS_LIST: + add_south(south_plugin, None, fledge_url, config=_config, + service_name="{0}_{1}".format(ASSET, south_plugin), installation_type='package', start_service=False) + # Wait for 10 seconds, SO that Services can be added + time.sleep(10) + + data = {"readingsPerSec": "{}".format(poll_rate)} + put_url="/fledge/category/{0}_{1}Advanced".format(ASSET, south_plugin) + utils.put_request(fledge_url, urllib.parse.quote(put_url, safe='?,=,&,/'), data) + + poll_rate+=5 + + start_north_omf_as_a_service(fledge_url, pi_host, pi_port, pi_user=pi_admin, pi_pwd=pi_passwd, pi_use_legacy="false", + service_name=NORTH_INSTANCE_NAME, default_af_location=AF_HIERARCHY_LEVEL) + +class Test_linked_data_PIWebAPI: + # @pytest.mark.skip(reason="no way of currently testing this") + def test_linked_data(self, clean_setup_fledge_packages, reset_fledge, start_south_north, fledge_url, + pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries, pi_port, enable_schedule, disable_schedule, + verify_hierarchy_and_get_datapoints_from_pi_web_api, clear_pi_system_through_pi_web_api, + skip_verify_north_interface, asset_name=ASSET): + + """ Test that check data is inserted in Fledge and sent to PI are equal + clean_setup_fledge_packages: Fixture for removing fledge from system completely if it is already present + and reinstall it baased on commandline arguments. + reset_fledge: Fixture that reset and cleanup the fledge + start_south_north: Fixture that add south and north instance + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + verify_hierarchy_and_get_datapoints_from_pi_web_api: Fixture to read data from PI and Verify hierarchy + clear_pi_system_through_pi_web_api: Fixture for cleaning up PI Server + skip_verify_north_interface: Flag for assertion of data using PI web API + + Assertions: + on endpoint GET /fledge/statistics + on endpoint GET /fledge/service + on endpoint GET /fledge/asset/ + data received from PI is same as data sent""" + + clear_pi_system_through_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, AF_HIERARCHY_LEVEL_LIST, ASSET_DICT) + + for south_plugin in SOUTH_PLUGINS_LIST: + enable_schedule(fledge_url, "{0}_{1}".format(ASSET, south_plugin)) + + # Wait until south, north services are created and some data is loaded + time.sleep(wait_time) + + for south_plugin in SOUTH_PLUGINS_LIST: + disable_schedule(fledge_url,"{}_{}".format(ASSET, south_plugin)) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_service_added(fledge_url) + verify_asset(fledge_url) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface) + + # Verify Data from fledge sent to PI Server is same. + verify_data_between_fledge_and_piwebapi(fledge_url, pi_host, pi_admin, pi_passwd, pi_db, AF_HIERARCHY_LEVEL, ASSET, SOUTH_PLUGINS_LIST, verify_hierarchy_and_get_datapoints_from_pi_web_api, wait_time) + + # @pytest.mark.skip(reason="no way of currently testing this") + def test_linked_data_with_filter(self, reset_fledge, start_south_north, fledge_url, pi_host, pi_admin, pi_passwd, add_filter, pi_db, wait_time, + retries, pi_port, enable_schedule, disable_schedule, verify_hierarchy_and_get_datapoints_from_pi_web_api, + clear_pi_system_through_pi_web_api, skip_verify_north_interface, asset_name=ASSET): + + """ Test that apply filter and check data is inserted in Fledge and sent to PI are equal. + reset_fledge: Fixture that reset and cleanup the fledge + start_south_north: Fixture that add south and north instance + add_filter: Fixture that adds filter to the Services + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + verify_hierarchy_and_get_datapoints_from_pi_web_api: Fixture to read data from PI and Verify hierarchy + clear_pi_system_through_pi_web_api: Fixture for cleaning up PI Server + skip_verify_north_interface: Flag for assertion of data using PI web API + + Assertions: + on endpoint GET /fledge/statistics + on endpoint GET /fledge/service + on endpoint GET /fledge/asset/ + on endpoint GET /fledge/filter + data received from PI is same as data sent""" + + clear_pi_system_through_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, AF_HIERARCHY_LEVEL_LIST, ASSET_DICT) + + for south_plugin in SOUTH_PLUGINS_LIST: + add_configure_filter(add_filter, fledge_url, south_plugin) + enable_schedule(fledge_url, "{0}_{1}".format(ASSET, south_plugin)) + + # Wait until south, north services and filters are created and some data is loaded + time.sleep(wait_time) + + for south_plugin in SOUTH_PLUGINS_LIST: + disable_schedule(fledge_url,"{}_{}".format(ASSET, south_plugin)) + + verify_asset(fledge_url) + verify_service_added(fledge_url) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface) + verify_filter_added(fledge_url) + + # Verify Data from fledge sent to PI Server is same. + verify_data_between_fledge_and_piwebapi(fledge_url, pi_host, pi_admin, pi_passwd, pi_db, AF_HIERARCHY_LEVEL, ASSET, ASSET_DICT[ASSET], verify_hierarchy_and_get_datapoints_from_pi_web_api, wait_time) + + # @pytest.mark.skip(reason="no way of currently testing this") + def test_linked_data_with_onoff_filter(self, reset_fledge, start_south_north, fledge_url, pi_host, pi_admin, pi_passwd, add_filter, pi_db, wait_time, + retries, pi_port, enable_schedule, disable_schedule, verify_hierarchy_and_get_datapoints_from_pi_web_api, + clear_pi_system_through_pi_web_api, skip_verify_north_interface, asset_name=ASSET): + + """ Test that apply filter and check data is inserted in Fledge and sent to PI are equal. + reset_fledge: Fixture that reset and cleanup the fledge + start_south_north: Fixture that add south and north instance + add_filter: Fixture that adds filter to the Services + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + verify_hierarchy_and_get_datapoints_from_pi_web_api: Fixture to read data from PI and Verify hierarchy + clear_pi_system_through_pi_web_api: Fixture for cleaning up PI Server + skip_verify_north_interface: Flag for assertion of data using PI web API + + Assertions: + on endpoint GET /fledge/statistics + on endpoint GET /fledge/service + on endpoint GET /fledge/asset/ + on endpoint GET /fledge/filter + data received from PI is same as data sent""" + + clear_pi_system_through_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, AF_HIERARCHY_LEVEL_LIST, ASSET_DICT) + + for south_plugin in SOUTH_PLUGINS_LIST: + add_configure_filter(add_filter, fledge_url, south_plugin) + enable_schedule(fledge_url, "{0}_{1}".format(ASSET, south_plugin)) + + # Wait until south, north services and filters are created and some data is loaded + time.sleep(wait_time) + + for south_plugin in SOUTH_PLUGINS_LIST: + disable_schedule(fledge_url,"{}_{}".format(ASSET, south_plugin)) + + verify_asset(fledge_url) + verify_service_added(fledge_url) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface) + verify_filter_added(fledge_url) + + old_ping_result = verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + + for south_plugin in SOUTH_PLUGINS_LIST: + enable_schedule(fledge_url, "{0}_{1}".format(ASSET, south_plugin)) + time.sleep(wait_time) + + print("On/Off of filter starts") + count = 0 + while count<3: + for south_plugin in SOUTH_PLUGINS_LIST: + # For Disabling filter + update_filter_config(fledge_url, south_plugin, 'false') + time.sleep(wait_time*2) + for south_plugin in SOUTH_PLUGINS_LIST: + # For enabling filter + update_filter_config(fledge_url, south_plugin, 'true') + time.sleep(wait_time*2) + count+=1 + + for south_plugin in SOUTH_PLUGINS_LIST: + disable_schedule(fledge_url,"{}_{}".format(ASSET, south_plugin)) + + # Verify Data from fledge sent to PI Server is same. + verify_data_between_fledge_and_piwebapi(fledge_url, pi_host, pi_admin, pi_passwd, pi_db, AF_HIERARCHY_LEVEL, ASSET, ASSET_DICT[ASSET], verify_hierarchy_and_get_datapoints_from_pi_web_api, wait_time) + \ No newline at end of file From 5b9ecfed07f5575025c584c194aed2863f534a2a Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 24 Feb 2023 16:49:58 +0000 Subject: [PATCH 144/499] FOGL-7469 Fix issue with delay in delivering table updates (#986) * FOGL-7469 Fix issue with delay in delivering table updates Signed-off-by: Mark Riddoch * Update logging Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/services/storage/storage_registry.cpp | 33 ++++++++++++++++--------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp index 01e75c7420..c71fd386ab 100644 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -239,8 +239,8 @@ TableRegistration* StorageRegistry::parseTableSubscriptionPayload(const string& { if (!doc.HasMember("values") || !doc["values"].IsArray()) { - Logger::getLogger()->error("StorageRegistry::parseTableSubscriptionPayload(): subscription request" \ - " doesn't have a proper values field, payload=%s", payload.c_str()); + Logger::getLogger()->error("Subscription request" \ + " doesn't have a proper values field, payload=%s", payload.c_str()); delete reg; return NULL; } @@ -264,12 +264,13 @@ StorageRegistry::registerTable(const string& table, const string& payload) if (!reg) { - Logger::getLogger()->error("StorageRegistry::registerTable(): Unable to register invalid Registration entry for table %s, payload=%s", table.c_str(), payload.c_str()); + Logger::getLogger()->error("Unable to register invalid Registration entry for table %s, payload %s", + table.c_str(), payload.c_str()); return; } lock_guard guard(m_tableRegistrationsMutex); - Logger::getLogger()->info("*** StorageRegistry::registerTable(): Adding registration entry for table %s", table.c_str()); + Logger::getLogger()->info("Adding registration entry for table %s", table.c_str()); m_tableRegistrations.push_back(pair(new string(table), reg)); } @@ -286,15 +287,16 @@ StorageRegistry::unregisterTable(const string& table, const string& payload) if (!reg) { - Logger::getLogger()->info("StorageRegistry::unregisterTable(): Unable to unregister " \ - "invalid Registration entry for table %s, payload=%s", table.c_str(), payload.c_str()); + Logger::getLogger()->info("Invalid Registration entry for table %s, payload %s", + table.c_str(), payload.c_str()); return; } lock_guard guard(m_tableRegistrationsMutex); - Logger::getLogger()->info("StorageRegistry::unregisterTable(): m_tableRegistrations.size()=%d", m_tableRegistrations.size()); - for (auto it = m_tableRegistrations.begin(); it != m_tableRegistrations.end(); ) + Logger::getLogger()->info("%d entries registered interest in table operations", m_tableRegistrations.size()); + bool found = false; + for (auto it = m_tableRegistrations.begin(); found == false && it != m_tableRegistrations.end(); ) { TableRegistration *reg_it = it->second; if (table.compare(*(it->first)) == 0 && @@ -308,17 +310,24 @@ StorageRegistry::unregisterTable(const string& table, const string& payload) delete it->first; delete it->second; it = m_tableRegistrations.erase(it); - Logger::getLogger()->info("*** StorageRegistry::unregisterTable(): Removed registration for table %s and url %s", table, reg->key.c_str()); + Logger::getLogger()->info("Removed registration for table %s and url %s", table, reg->key.c_str()); + found = true; } else { ++it; - } + } } else { ++it; - } + } + } + if (!found) + { + Logger::getLogger()->warn( + "Failed to remove subscription for table '%s' using key '%s' with operation '%s' and url '%s'", + table.c_str(), reg->key.c_str(), reg->operation.c_str(), reg->url.c_str()); } delete reg; } @@ -340,7 +349,7 @@ StorageRegistry::run() #endif { unique_lock mlock(m_cvMutex); - while (m_queue.size() == 0 && m_tableInsertQueue.size() == 0) + while (m_queue.size() == 0 && m_tableInsertQueue.size() == 0 && m_tableUpdateQueue.size() == 0) { m_cv.wait_for(mlock, std::chrono::seconds(REGISTRY_SLEEP_TIME)); if (!m_running) From 183e71a4b5ebca3401b11d982146b3aa2bac0b51 Mon Sep 17 00:00:00 2001 From: nandan Date: Mon, 27 Feb 2023 14:45:49 +0530 Subject: [PATCH 145/499] FOGL-7353 : Improved exception handling and corrected typo Signed-off-by: nandan --- C/common/reading_set.cpp | 78 +++++++++++++------ .../unit/C/services/core/reading_set_copy.cpp | 4 +- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index d897fd7f60..ecefc793d8 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -198,38 +198,72 @@ ReadingSet::append(const vector& readings) bool ReadingSet::copy(const ReadingSet& src) { - vector readings; - bool copyResult = true; - try - { - // Iterate over all the readings in ReadingSet - for ( auto &reading : src.getAllReadings()) - { - std::string assetName = reading->getAssetName(); - std::vector dataPoints; - // Iterate over all the datapoints associated with one reading - for ( auto &dp : reading->getReadingData()) - { - std::string dataPointName = dp->getName(); - DatapointValue dv = dp->getData(); - dataPoints.emplace_back(new Datapoint(dataPointName, dv)); - - } - Reading *in = new Reading(assetName, dataPoints); - readings.emplace_back(in); + vector readings; + bool copyResult = true; + try + { + // Iterate over all the readings in ReadingSet + for ( auto &reading : src.getAllReadings()) + { + std::string assetName = reading->getAssetName(); + std::vector dataPoints; + try + { + // Iterate over all the datapoints associated with one reading + for ( auto &dp : reading->getReadingData()) + { + std::string dataPointName = dp->getName(); + DatapointValue dv = dp->getData(); + dataPoints.emplace_back(new Datapoint(dataPointName, dv)); + + } + } + // Catch exception while copying datapoints + catch(std::bad_alloc& ex) + { + Logger::getLogger()->error("Insufficient memory, failed while copying dataPoints from ReadingSet, %s ", ex.what()); + copyResult = false; + for (auto dp : dataPoints) + { + delete dp; + } + throw; + } + catch (std::exception& ex) + { + Logger::getLogger()->error("Unknown exception, failed while copying datapoint from ReadingSet, %s ", ex.what()); + copyResult = false; + for (auto dp : dataPoints) + { + delete dp; + } + throw; + } + + Reading *in = new Reading(assetName, dataPoints); + readings.emplace_back(in); } } + // Catch exception while copying readings catch (std::bad_alloc& ex) { Logger::getLogger()->error("Insufficient memory, failed while copying %d reading from ReadingSet, %s ",readings.size()+1, ex.what()); copyResult = false; + for (auto r : readings) + { + delete r; + } readings.clear(); } catch (std::exception& ex) { - Logger::getLogger()->error("Unknown exception, failed while copying %d reading from ReadingSet, %s ",readings.size()+1, ex.what()); - copyResult = false; - readings.clear(); + Logger::getLogger()->error("Unknown exception, failed while copying %d reading from ReadingSet, %s ",readings.size()+1, ex.what()); + copyResult = false; + for (auto r : readings) + { + delete r; + } + readings.clear(); } //Append if All elements have been copied successfully diff --git a/tests/unit/C/services/core/reading_set_copy.cpp b/tests/unit/C/services/core/reading_set_copy.cpp index 93c279b5c8..489e4c5fbd 100644 --- a/tests/unit/C/services/core/reading_set_copy.cpp +++ b/tests/unit/C/services/core/reading_set_copy.cpp @@ -50,7 +50,7 @@ TEST(READINGSET, DeepCopyCheckReadingFromNestedJSON) auto r1 = readingSet1->getAllReadings(); auto dp1 = r1[0]->getReadingData(); - // Fetch neted datapoints + // Fetch nested datapoints ASSERT_EQ(dp1[0]->getName(), "degrees"); ASSERT_EQ(dp1[0]->getData().toString(), "[200.65, 34.45, 500.36]"); ASSERT_EQ(dp1[1]->getName(), "pressure"); @@ -63,7 +63,7 @@ TEST(READINGSET, DeepCopyCheckReadingFromNestedJSON) ASSERT_EQ(dp2[1]->getName(), "pressure"); ASSERT_EQ(dp2[1]->getData().toString(), "{\"floor1\":30, \"floor2\":34, \"floor3\":36}"); - // Check the address od datapoints + // Check the address of datapoints ASSERT_NE(dp1[0], dp2[0]); ASSERT_NE(dp1[1], dp2[1]); From 292377d9e5ca7ebc73da6b206381a78b688eab28 Mon Sep 17 00:00:00 2001 From: nandan Date: Mon, 27 Feb 2023 16:02:44 +0530 Subject: [PATCH 146/499] FOGL-7353 : updated all the references to const refrence Signed-off-by: nandan --- C/common/reading_set.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index ecefc793d8..761257fa58 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -203,14 +203,14 @@ ReadingSet::copy(const ReadingSet& src) try { // Iterate over all the readings in ReadingSet - for ( auto &reading : src.getAllReadings()) + for (auto const &reading : src.getAllReadings()) { std::string assetName = reading->getAssetName(); std::vector dataPoints; try { // Iterate over all the datapoints associated with one reading - for ( auto &dp : reading->getReadingData()) + for (auto const &dp : reading->getReadingData()) { std::string dataPointName = dp->getName(); DatapointValue dv = dp->getData(); @@ -223,20 +223,22 @@ ReadingSet::copy(const ReadingSet& src) { Logger::getLogger()->error("Insufficient memory, failed while copying dataPoints from ReadingSet, %s ", ex.what()); copyResult = false; - for (auto dp : dataPoints) + for (auto const &dp : dataPoints) { delete dp; } + dataPoints.clear(); throw; } catch (std::exception& ex) { Logger::getLogger()->error("Unknown exception, failed while copying datapoint from ReadingSet, %s ", ex.what()); copyResult = false; - for (auto dp : dataPoints) + for (auto const &dp : dataPoints) { delete dp; } + dataPoints.clear(); throw; } @@ -249,7 +251,7 @@ ReadingSet::copy(const ReadingSet& src) { Logger::getLogger()->error("Insufficient memory, failed while copying %d reading from ReadingSet, %s ",readings.size()+1, ex.what()); copyResult = false; - for (auto r : readings) + for (auto const &r : readings) { delete r; } @@ -259,7 +261,7 @@ ReadingSet::copy(const ReadingSet& src) { Logger::getLogger()->error("Unknown exception, failed while copying %d reading from ReadingSet, %s ",readings.size()+1, ex.what()); copyResult = false; - for (auto r : readings) + for (auto const &r : readings) { delete r; } From ed1bb2f903e3a8bd00475d3f8365b30c1ddc9bc5 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Mon, 27 Feb 2023 14:22:59 +0000 Subject: [PATCH 147/499] FOGL-7518 Emoty statistics entry is created incorrecly when stats are (#989) turned off Signed-off-by: Mark Riddoch --- C/services/south/include/ingest.h | 3 +-- C/services/south/ingest.cpp | 33 ++++++++++++++++++++++--------- C/services/south/south.cpp | 4 +++- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/C/services/south/include/ingest.h b/C/services/south/include/ingest.h index 79f969c928..97494d6700 100644 --- a/C/services/south/include/ingest.h +++ b/C/services/south/include/ingest.h @@ -42,8 +42,6 @@ class Ingest : public ServiceHandler { public: Ingest(StorageClient& storage, - long timeout, - unsigned int threshold, const std::string& serviceName, const std::string& pluginName, ManagementClient *mgmtClient); @@ -51,6 +49,7 @@ class Ingest : public ServiceHandler { void ingest(const Reading& reading); void ingest(const std::vector *vec); + void start(long timeout, unsigned int threshold); bool running(); bool isStopping(); bool isRunning() { return !m_shutdown; }; diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 582be26a71..b4b3826c1f 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -56,6 +56,11 @@ static void statsThread(Ingest *ingest) */ int Ingest::createStatsDbEntry(const string& assetName) { + if (m_statisticsOption == STATS_SERVICE) + { + // No asset stats required + return 0; + } // Prepare fledge.statistics update string statistics_key = assetName; for (auto & c: statistics_key) c = toupper(c); @@ -105,6 +110,11 @@ int Ingest::createStatsDbEntry(const string& assetName) */ int Ingest::createServiceStatsDbEntry() { + if (m_statisticsOption == STATS_ASSET) + { + // No service stats required + return 0; + } // SELECT * FROM fledge.configuration WHERE key = categoryName const Condition conditionKey(Equals); Where *wKey = new Where("key", conditionKey, m_serviceName + INGEST_SUFFIX); @@ -259,21 +269,14 @@ void Ingest::updateStats() * storage layer based on time. This thread in created in * the constructor and will terminate when the destructor * is called. - * TODO - try to reduce the number of arguments in c'tor * * @param storage The storage client to use - * @param timeout Maximum time before sending a queue of readings in milliseconds - * @param threshold Length of queue before sending readings */ Ingest::Ingest(StorageClient& storage, - long timeout, - unsigned int threshold, const std::string& serviceName, const std::string& pluginName, ManagementClient *mgmtClient) : m_storage(storage), - m_timeout(timeout), - m_queueSizeThreshold(threshold), m_serviceName(serviceName), m_pluginName(pluginName), m_mgtClient(mgmtClient), @@ -285,8 +288,6 @@ Ingest::Ingest(StorageClient& storage, m_shutdown = false; m_running = true; m_queue = new vector(); - m_thread = new thread(ingestThread, this); - m_statsThread = new thread(statsThread, this); m_logger = Logger::getLogger(); m_data = NULL; m_discardedReadings = 0; @@ -302,6 +303,20 @@ Ingest::Ingest(StorageClient& storage, m_filterPipeline = NULL; } +/** + * Start the ingest threads + * + * @param timeout Maximum time before sending a queue of readings in milliseconds + * @param threshold Length of queue before sending readings + */ +void Ingest::start(long timeout, unsigned int threshold) +{ + m_timeout = timeout; + m_queueSizeThreshold = threshold; + m_thread = new thread(ingestThread, this); + m_statsThread = new thread(statsThread, this); +} + /** * Destructor for the Ingest class * diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index ce3a4509b6..45224dbc72 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -394,7 +394,7 @@ void SouthService::start(string& coreAddress, unsigned short corePort) { // Instantiate the Ingest class - Ingest ingest(storage, timeout, threshold, m_name, pluginName, m_mgtClient); + Ingest ingest(storage, m_name, pluginName, m_mgtClient); m_ingest = &ingest; if (m_configAdvanced.itemExists("statistics")) @@ -402,6 +402,8 @@ void SouthService::start(string& coreAddress, unsigned short corePort) m_ingest->setStatistics(m_configAdvanced.getValue("statistics")); } + m_ingest->start(timeout, threshold); // Start the ingest threads running + try { m_readingsPerSec = 1; if (m_configAdvanced.itemExists("readingsPerSec")) From 4a556ed44dd5dd48582c7b2b4f4e3355a42c64d4 Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Tue, 28 Feb 2023 01:39:44 +0530 Subject: [PATCH 148/499] forced https scheme and type checkfixed for services other than the notification Signed-off-by: Praveen Garg --- python/fledge/services/core/api/service.py | 27 ++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index b1a5d05fda..aebedf7131 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -593,12 +593,19 @@ async def update_service(request: web.Request) -> web.Response: name = request.match_info.get('name', None) try: _type = _type.lower() - # TODO: 5141 - once done we need to fix for dispatcher type as well - if _type != 'notification': - raise ValueError("Invalid service type. Must be 'notification'") + if _type in ('notification', 'dispatcher', 'bucket_storage', 'management'): + raise ValueError("Invalid service type.") + + # process_name for bucket storage service schedule is bucket_storage_c hence type here must be bucket_storage? + # process_name for management schedule is management and python based; Check added for schedule stuff + + # NOTE: `bucketstorage` repository name with `BucketStorage` type in service registry has package name *-`bucket`. + # URL: /fledge/service/bucket_storage/bucket/update ?! *EXTERNAL* + # URL: /fledge/service/management/management/update ?! *EXTERNAL* - # Check requested service name is installed or not + # Check requested service is installed or not installed_services = get_service_installed() + # TODO: Test for `bucket` for name in installed_services content if name not in installed_services: raise KeyError("{} service is not installed yet. Hence update is not possible.".format(name)) @@ -622,8 +629,10 @@ async def update_service(request: web.Request) -> web.Response: await storage_client.delete_from_tbl("packages", delete_payload) # process_name always ends with "_c" suffix - payload = PayloadBuilder().SELECT("id", "enabled", "schedule_name").WHERE(['process_name', '=', '{}_c'.format( - _type)]).payload() + _where_clause = ['process_name', '=', '{}_c'.format(_type)] + if _type == 'management': + _where_clause = ['process_name', '=', '{}'.format(_type)] + payload = PayloadBuilder().SELECT("id", "enabled", "schedule_name").WHERE(_where_clause).payload() result = await storage_client.query_tbl_with_payload('schedules', payload) sch_info = result['rows'] sch_list = [] @@ -641,6 +650,7 @@ async def update_service(request: web.Request) -> web.Response: result = await storage_client.insert_into_tbl("packages", insert_payload) if result['response'] == "inserted" and result['rows_affected'] == 1: pn = "{}-{}".format(action, name) + # Protocol is always http:// on core_management_port p = multiprocessing.Process(name=pn, target=do_update, args=(server.Server.is_rest_server_http_enabled, server.Server._host, server.Server.core_management_port, @@ -685,7 +695,10 @@ def do_update(http_enabled: bool, host: str, port: int, storage: connect, pkg_na stdout_file_path = common.create_log_file("update", pkg_name) pkg_mgt = 'yum' if utils.is_redhat_based() else 'apt' cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) - protocol = "HTTP" if http_enabled else "HTTPS" + + # Protocol is always http:// on core_management_port + protocol = "HTTP" # if http_enabled else "HTTPS" + if pkg_mgt == 'yum': cmd = "sudo {} check-update > {} 2>&1".format(pkg_mgt, stdout_file_path) ret_code = os.system(cmd) From d0aa986aa52a701da06e0aac91bfb92940aba2b5 Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Tue, 28 Feb 2023 01:41:30 +0530 Subject: [PATCH 149/499] forced https scheme and type checkfixed for services other than the notification Signed-off-by: Praveen Garg --- python/fledge/services/core/api/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index aebedf7131..d2f385037c 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -605,7 +605,7 @@ async def update_service(request: web.Request) -> web.Response: # Check requested service is installed or not installed_services = get_service_installed() - # TODO: Test for `bucket` for name in installed_services content + # TODO: Test for `bucket`/`management` for name in installed_services content if name not in installed_services: raise KeyError("{} service is not installed yet. Hence update is not possible.".format(name)) From 1e90e5c6def4443ae699a35494ea50a756b7563a Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Tue, 28 Feb 2023 17:27:19 +0530 Subject: [PATCH 150/499] removed iprpc test dependency which is already available with fledge; and was vulnerable version Signed-off-by: Praveen Garg --- tests/system/python/iprpc/README.rst | 9 --------- tests/system/python/iprpc/requirements-iprpc-test.txt | 1 - 2 files changed, 10 deletions(-) delete mode 100644 tests/system/python/iprpc/requirements-iprpc-test.txt diff --git a/tests/system/python/iprpc/README.rst b/tests/system/python/iprpc/README.rst index bee9a917ba..993f3458a7 100644 --- a/tests/system/python/iprpc/README.rst +++ b/tests/system/python/iprpc/README.rst @@ -72,15 +72,6 @@ While testing following settings can be present. Running Fledge System tests involving iprpc =========================================== -Test Prerequisites ------------------- - -To install the dependencies required to run python tests, run the following two commands from FLEDGE_ROOT -:: - - cd $FLEDGE_ROOT/tests/system/python/iprpc - python3 -m pip install -r requirements-iprpc-test.txt --user - Test Execution -------------- diff --git a/tests/system/python/iprpc/requirements-iprpc-test.txt b/tests/system/python/iprpc/requirements-iprpc-test.txt deleted file mode 100644 index ac495ece00..0000000000 --- a/tests/system/python/iprpc/requirements-iprpc-test.txt +++ /dev/null @@ -1 +0,0 @@ -numpy==1.19.4 \ No newline at end of file From 33ed30c011d2aa083e9a5298f62f6eefc02c9f76 Mon Sep 17 00:00:00 2001 From: nandan Date: Wed, 1 Mar 2023 12:52:59 +0530 Subject: [PATCH 151/499] Revert "Merge branch 'develop' into FOGL-7128" This reverts commit 9422fea656a752035d1c5ad7a251cf0e1b5ab75d, reversing changes made to 1f67cc76661170b89cf5122d1bbafbf14ea3f32e. Signed-off-by: nandan --- C/common/config_category.cpp | 2 +- C/plugins/north/OMF/plugin.cpp | 1 - .../common/include/readings_catalogue.h | 7 +- C/plugins/storage/sqlite/common/readings.cpp | 2 +- .../sqlite/common/readings_catalogue.cpp | 185 +++++++----------- 5 files changed, 78 insertions(+), 119 deletions(-) mode change 100644 => 100755 C/plugins/north/OMF/plugin.cpp diff --git a/C/common/config_category.cpp b/C/common/config_category.cpp index 99c982a72a..a3dffe5e10 100644 --- a/C/common/config_category.cpp +++ b/C/common/config_category.cpp @@ -4,7 +4,7 @@ * Copyright (c) 2018 Dianomic Systems * * Released under the Apache 2.0 Licence - + * * Author: Mark Riddoch, Massimiliano Pinto */ #include diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp old mode 100644 new mode 100755 index fa91a62b63..cfd1fb0945 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -2,7 +2,6 @@ * Fledge PI Server north plugin. * * Copyright (c) 2018-2022 Dianomic Systems - * * Released under the Apache 2.0 Licence * diff --git a/C/plugins/storage/sqlite/common/include/readings_catalogue.h b/C/plugins/storage/sqlite/common/include/readings_catalogue.h index 15eb07d06e..0bddc55863 100644 --- a/C/plugins/storage/sqlite/common/include/readings_catalogue.h +++ b/C/plugins/storage/sqlite/common/include/readings_catalogue.h @@ -131,11 +131,10 @@ class ReadingsCatalogue { void preallocateReadingsTables(int dbId); bool loadAssetReadingCatalogue(); - bool loadEmptyAssetReadingCatalogue(); bool latestDbUpdate(sqlite3 *dbHandle, int newDbId); void preallocateNewDbsRange(int dbIdStart, int dbIdEnd); - tyReadingReference getEmptyReadingTableReference(std::string& asset); + bool getEmptyReadingTableReference(tyReadingReference& emptyTableReference); tyReadingReference getReadingReference(Connection *connection, const char *asset_code); bool attachDbsToAllConnections(); std::string sqlConstructMultiDb(std::string &sqlCmdBase, std::vector &assetCodes, bool considerExclusion=false); @@ -224,10 +223,6 @@ class ReadingsCatalogue { // asset_code - reading Table Id, Db Id // {"", ,{1 ,1 }} }; - std::map > m_EmptyAssetReadingCatalogue={ // In memory structure to identify in which database/table an asset is empty - // asset_code - reading Table Id, Db Id - // {"", ,{1 ,1 }} - }; public: TransactionBoundary m_tx; diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index bd1721874b..5cb3a11aa1 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -2649,7 +2649,7 @@ sqlite3_stmt *stmt; sqlite3_free(zErrMsg); return 0; } - readCat->loadEmptyAssetReadingCatalogue(); + // Get numbwer of affected rows return (unsigned int)sqlite3_changes(dbHandle); } diff --git a/C/plugins/storage/sqlite/common/readings_catalogue.cpp b/C/plugins/storage/sqlite/common/readings_catalogue.cpp index b9215960db..984ab55ab2 100644 --- a/C/plugins/storage/sqlite/common/readings_catalogue.cpp +++ b/C/plugins/storage/sqlite/common/readings_catalogue.cpp @@ -849,7 +849,6 @@ void ReadingsCatalogue::multipleReadingsInit(STORAGE_CONFIGURATION &storageConfi preallocateReadingsTables(0); // on the last database evaluateGlobalId(); - loadEmptyAssetReadingCatalogue(); } catch (exception& e) { @@ -1840,9 +1839,8 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co AttachDbSync *attachSync = AttachDbSync::getInstance(); attachSync->lock(); - ReadingsCatalogue::tyReadingReference emptyTableReference = {-1, -1}; - std::string emptyAsset = {}; + auto item = m_AssetReadingCatalogue.find(asset_code); if (item != m_AssetReadingCatalogue.end()) { @@ -1851,60 +1849,53 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co } else { - + //# Allocate a new block of readings table if (! isReadingAvailable () ) { - //No Readding table available... Get empty reading table - emptyTableReference = getEmptyReadingTableReference(emptyAsset); - if ( !emptyAsset.empty() ) + Logger::getLogger()->debug("getReadingReference - allocate a new db, dbNAvailable :%d:", m_dbNAvailable); + + if (m_dbNAvailable > 0) { - ref = emptyTableReference; + // DBs already created are available + m_dbIdCurrent++; + m_dbNAvailable--; + m_nReadingsAvailable = getNReadingsAllocate(); + + Logger::getLogger()->debug("getReadingReference - allocate a new db, db already available - dbIdCurrent :%d: dbIdLast :%d: dbNAvailable :%d: nReadingsAvailable :%d: ", m_dbIdCurrent, m_dbIdLast, m_dbNAvailable, m_nReadingsAvailable); } - else + else { - //# Allocate a new block of readings table - Logger::getLogger()->debug("getReadingReference - allocate a new db, dbNAvailable :%d:", m_dbNAvailable); + // Allocates new DBs + int dbId, dbIdStart, dbIdEnd; - if (m_dbNAvailable > 0) - { - // DBs already created are available - m_dbIdCurrent++; - m_dbNAvailable--; - m_nReadingsAvailable = getNReadingsAllocate(); + dbIdStart = m_dbIdLast +1; + dbIdEnd = m_dbIdLast + m_storageConfigCurrent.nDbToAllocate; - Logger::getLogger()->debug("getReadingReference - allocate a new db, db already available - dbIdCurrent :%d: dbIdLast :%d: dbNAvailable :%d: nReadingsAvailable :%d: ", m_dbIdCurrent, m_dbIdLast, m_dbNAvailable, m_nReadingsAvailable); - } - else - { - // Allocates new DBs - int dbId, dbIdStart, dbIdEnd; + Logger::getLogger()->debug("getReadingReference - allocate a new db - create new db - dbIdCurrent :%d: dbIdStart :%d: dbIdEnd :%d:", m_dbIdCurrent, dbIdStart, dbIdEnd); - dbIdStart = m_dbIdLast +1; - dbIdEnd = m_dbIdLast + m_storageConfigCurrent.nDbToAllocate; + for (dbId = dbIdStart; dbId <= dbIdEnd; dbId++) + { + readingsAvailable = evaluateLastReadingAvailable(dbHandle, dbId - 1); - Logger::getLogger()->debug("getReadingReference - allocate a new db - create new db - dbIdCurrent :%d: dbIdStart :%d: dbIdEnd :%d:", m_dbIdCurrent, dbIdStart, dbIdEnd); + startReadingsId = 1; - for (dbId = dbIdStart; dbId <= dbIdEnd; dbId++) + if (!getEmptyReadingTableReference(emptyTableReference)) { - readingsAvailable = evaluateLastReadingAvailable(dbHandle, dbId - 1); - - startReadingsId = 1; - success = createNewDB(dbHandle, dbId, startReadingsId, NEW_DB_ATTACH_REQUEST); if (success) { Logger::getLogger()->debug("getReadingReference - allocate a new db - create new dbs - dbId :%d: startReadingsIdOnDB :%d:", dbId, startReadingsId); } } - m_dbIdLast = dbIdEnd; - m_dbIdCurrent++; - m_dbNAvailable = (m_dbIdLast - m_dbIdCurrent) - m_storageConfigCurrent.nDbLeftFreeBeforeAllocate; + } - - ref.tableId = -1; - ref.dbId = -1; + m_dbIdLast = dbIdEnd; + m_dbIdCurrent++; + m_dbNAvailable = (m_dbIdLast - m_dbIdCurrent) - m_storageConfigCurrent.nDbLeftFreeBeforeAllocate; } - + + ref.tableId = -1; + ref.dbId = -1; } if (success) @@ -1912,53 +1903,49 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co // Associate a reading table to the asset { // Associate the asset to the reading_id - if (emptyAsset.empty()) - { - ref.tableId = getMaxReadingsId(m_dbIdCurrent) + 1; - ref.dbId = m_dbIdCurrent; - } - { - m_EmptyAssetReadingCatalogue.erase(emptyAsset); - m_AssetReadingCatalogue.erase(emptyAsset); + if (emptyTableReference.tableId > 0) + { + ref.tableId = emptyTableReference.tableId; + ref.dbId = emptyTableReference.dbId; + } + else + { + ref.tableId = getMaxReadingsId(m_dbIdCurrent) + 1; + ref.dbId = m_dbIdCurrent; + } + auto newItem = make_pair(ref.tableId, ref.dbId); auto newMapValue = make_pair(asset_code, newItem); m_AssetReadingCatalogue.insert(newMapValue); } + Logger::getLogger()->debug("getReadingReference - allocate a new reading table for the asset :%s: db Id :%d: readings Id :%d: ", asset_code, ref.dbId, ref.tableId); + // Allocate the table in the reading catalogue - if (emptyAsset.empty()) { - sql_cmd = + if (emptyTableReference.tableId > 0) + { + + sql_cmd = " UPDATE " READINGS_DB ".asset_reading_catalogue SET asset_code ='" + string(asset_code) + "'" + + " WHERE db_id = " + to_string(emptyTableReference.dbId) + " AND table_id = " + to_string(emptyTableReference.tableId) + ";"; + } + else + { + sql_cmd = "INSERT INTO " READINGS_DB ".asset_reading_catalogue (table_id, db_id, asset_code) VALUES (" + to_string(ref.tableId) + "," + to_string(ref.dbId) + "," + "\"" + asset_code + "\")"; - - Logger::getLogger()->debug("getReadingReference - allocate a new reading table for the asset :%s: db Id :%d: readings Id :%d: ", asset_code, ref.dbId, ref.tableId); - - } - else - { - sql_cmd = " UPDATE " READINGS_DB ".asset_reading_catalogue SET asset_code ='" + string(asset_code) + "'" + - " WHERE db_id = " + to_string(ref.dbId) + " AND table_id = " + to_string(ref.tableId) + ";"; + } - Logger::getLogger()->debug("getReadingReference - Use empty table %readings_%d_%d: ",ref.dbId,ref.tableId); - } - - { rc = SQLExec(dbHandle, sql_cmd.c_str()); if (rc != SQLITE_OK) { msg = string(sqlite3_errmsg(dbHandle)) + " asset :" + asset_code + ":"; raiseError("asset_reading_catalogue update", msg.c_str()); } - - if (emptyAsset.empty()) - { - allocateReadingAvailable(); - } - + allocateReadingAvailable(); } } @@ -1973,73 +1960,52 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co } /** - * Loads the empty reading table catalogue + * Get Empty Reading Table + * + * @param emptyTableReference An empty reading table reference to be used for the given asset_code + * @return True of success, false on any error * */ -bool ReadingsCatalogue::loadEmptyAssetReadingCatalogue() +bool ReadingsCatalogue::getEmptyReadingTableReference(tyReadingReference &emptyTableReference) { + bool isEmptyTableAvailable = false; sqlite3 *dbHandle; string sql_cmd; sqlite3_stmt *stmt; + + // Disable functionality temporarily to avoid regression + return false; + ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); dbHandle = connection->getDbHandle(); - m_EmptyAssetReadingCatalogue.clear(); - + for (auto &item : m_AssetReadingCatalogue) { - string asset_name = item.first; // Asset - int tableId = item.second.first; // tableId; - int dbId = item.second.second; // dbId; - - sql_cmd = "SELECT COUNT(*) FROM readings_" + to_string(dbId) + ".readings_" + to_string(dbId) + "_" + to_string(tableId) + " ;"; + int tableId = item.second.first; + int dbId = item.second.second; + sql_cmd = "SELECT COUNT(*) FROM (SELECT 0 FROM readings_" + to_string(dbId) + ".readings_" + to_string(dbId) + "_" + to_string(tableId) + " LIMIT 1)"; + if (sqlite3_prepare_v2(dbHandle, sql_cmd.c_str(), -1, &stmt, NULL) != SQLITE_OK) { - sqlite3_finalize(stmt); - continue; + raiseError("getEmptyReadingTableReference", sqlite3_errmsg(dbHandle)); + return false; } if (SQLStep(stmt) == SQLITE_ROW) { if (sqlite3_column_int(stmt, 0) == 0) { - auto newItem = make_pair(tableId,dbId); - auto newMapValue = make_pair(asset_name,newItem); - m_EmptyAssetReadingCatalogue.insert(newMapValue); - + isEmptyTableAvailable = true; + emptyTableReference.dbId = dbId; + emptyTableReference.tableId = tableId; } } sqlite3_finalize(stmt); - } - manager->release(connection); - return true; -} -/** - * Get Empty Reading Table - * - * @param asset emptyAsset, copies value of asset for which empty table is found - * @return the reading id associated to the provided empty table - */ -ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getEmptyReadingTableReference(std::string& asset) -{ - ReadingsCatalogue::tyReadingReference emptyTableReference = {-1, -1}; - if (m_EmptyAssetReadingCatalogue.size() == 0) - { - loadEmptyAssetReadingCatalogue(); - } - - auto it = m_EmptyAssetReadingCatalogue.begin(); - if (it != m_EmptyAssetReadingCatalogue.end()) - { - asset = it->first; - emptyTableReference.tableId = it->second.first; - emptyTableReference.dbId = it->second.second; - - } - - return emptyTableReference; + manager->release(connection); + return isEmptyTableAvailable; } /** @@ -2189,7 +2155,6 @@ int ReadingsCatalogue::purgeAllReadings(sqlite3 *dbHandle, const char *sqlCmdBa } } - loadEmptyAssetReadingCatalogue(); return(rc); } From cb8f9c829ba0d7326bbf537e97fc6c766a14bd32 Mon Sep 17 00:00:00 2001 From: nandan Date: Wed, 1 Mar 2023 13:40:23 +0530 Subject: [PATCH 152/499] FOGL-7128: Reverted merge conflict changes Signed-off-by: nandan --- .../common/include/readings_catalogue.h | 7 +- C/plugins/storage/sqlite/common/readings.cpp | 2 +- .../sqlite/common/readings_catalogue.cpp | 185 +++++++++++------- 3 files changed, 117 insertions(+), 77 deletions(-) diff --git a/C/plugins/storage/sqlite/common/include/readings_catalogue.h b/C/plugins/storage/sqlite/common/include/readings_catalogue.h index 0bddc55863..15eb07d06e 100644 --- a/C/plugins/storage/sqlite/common/include/readings_catalogue.h +++ b/C/plugins/storage/sqlite/common/include/readings_catalogue.h @@ -131,10 +131,11 @@ class ReadingsCatalogue { void preallocateReadingsTables(int dbId); bool loadAssetReadingCatalogue(); + bool loadEmptyAssetReadingCatalogue(); bool latestDbUpdate(sqlite3 *dbHandle, int newDbId); void preallocateNewDbsRange(int dbIdStart, int dbIdEnd); - bool getEmptyReadingTableReference(tyReadingReference& emptyTableReference); + tyReadingReference getEmptyReadingTableReference(std::string& asset); tyReadingReference getReadingReference(Connection *connection, const char *asset_code); bool attachDbsToAllConnections(); std::string sqlConstructMultiDb(std::string &sqlCmdBase, std::vector &assetCodes, bool considerExclusion=false); @@ -223,6 +224,10 @@ class ReadingsCatalogue { // asset_code - reading Table Id, Db Id // {"", ,{1 ,1 }} }; + std::map > m_EmptyAssetReadingCatalogue={ // In memory structure to identify in which database/table an asset is empty + // asset_code - reading Table Id, Db Id + // {"", ,{1 ,1 }} + }; public: TransactionBoundary m_tx; diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index 5cb3a11aa1..bd1721874b 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -2649,7 +2649,7 @@ sqlite3_stmt *stmt; sqlite3_free(zErrMsg); return 0; } - + readCat->loadEmptyAssetReadingCatalogue(); // Get numbwer of affected rows return (unsigned int)sqlite3_changes(dbHandle); } diff --git a/C/plugins/storage/sqlite/common/readings_catalogue.cpp b/C/plugins/storage/sqlite/common/readings_catalogue.cpp index 984ab55ab2..b9215960db 100644 --- a/C/plugins/storage/sqlite/common/readings_catalogue.cpp +++ b/C/plugins/storage/sqlite/common/readings_catalogue.cpp @@ -849,6 +849,7 @@ void ReadingsCatalogue::multipleReadingsInit(STORAGE_CONFIGURATION &storageConfi preallocateReadingsTables(0); // on the last database evaluateGlobalId(); + loadEmptyAssetReadingCatalogue(); } catch (exception& e) { @@ -1839,8 +1840,9 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co AttachDbSync *attachSync = AttachDbSync::getInstance(); attachSync->lock(); + ReadingsCatalogue::tyReadingReference emptyTableReference = {-1, -1}; - + std::string emptyAsset = {}; auto item = m_AssetReadingCatalogue.find(asset_code); if (item != m_AssetReadingCatalogue.end()) { @@ -1849,53 +1851,60 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co } else { - //# Allocate a new block of readings table + if (! isReadingAvailable () ) { - Logger::getLogger()->debug("getReadingReference - allocate a new db, dbNAvailable :%d:", m_dbNAvailable); - - if (m_dbNAvailable > 0) + //No Readding table available... Get empty reading table + emptyTableReference = getEmptyReadingTableReference(emptyAsset); + if ( !emptyAsset.empty() ) { - // DBs already created are available - m_dbIdCurrent++; - m_dbNAvailable--; - m_nReadingsAvailable = getNReadingsAllocate(); - - Logger::getLogger()->debug("getReadingReference - allocate a new db, db already available - dbIdCurrent :%d: dbIdLast :%d: dbNAvailable :%d: nReadingsAvailable :%d: ", m_dbIdCurrent, m_dbIdLast, m_dbNAvailable, m_nReadingsAvailable); + ref = emptyTableReference; } - else + else { - // Allocates new DBs - int dbId, dbIdStart, dbIdEnd; - - dbIdStart = m_dbIdLast +1; - dbIdEnd = m_dbIdLast + m_storageConfigCurrent.nDbToAllocate; + //# Allocate a new block of readings table + Logger::getLogger()->debug("getReadingReference - allocate a new db, dbNAvailable :%d:", m_dbNAvailable); - Logger::getLogger()->debug("getReadingReference - allocate a new db - create new db - dbIdCurrent :%d: dbIdStart :%d: dbIdEnd :%d:", m_dbIdCurrent, dbIdStart, dbIdEnd); + if (m_dbNAvailable > 0) + { + // DBs already created are available + m_dbIdCurrent++; + m_dbNAvailable--; + m_nReadingsAvailable = getNReadingsAllocate(); - for (dbId = dbIdStart; dbId <= dbIdEnd; dbId++) + Logger::getLogger()->debug("getReadingReference - allocate a new db, db already available - dbIdCurrent :%d: dbIdLast :%d: dbNAvailable :%d: nReadingsAvailable :%d: ", m_dbIdCurrent, m_dbIdLast, m_dbNAvailable, m_nReadingsAvailable); + } + else { - readingsAvailable = evaluateLastReadingAvailable(dbHandle, dbId - 1); + // Allocates new DBs + int dbId, dbIdStart, dbIdEnd; - startReadingsId = 1; + dbIdStart = m_dbIdLast +1; + dbIdEnd = m_dbIdLast + m_storageConfigCurrent.nDbToAllocate; - if (!getEmptyReadingTableReference(emptyTableReference)) + Logger::getLogger()->debug("getReadingReference - allocate a new db - create new db - dbIdCurrent :%d: dbIdStart :%d: dbIdEnd :%d:", m_dbIdCurrent, dbIdStart, dbIdEnd); + + for (dbId = dbIdStart; dbId <= dbIdEnd; dbId++) { + readingsAvailable = evaluateLastReadingAvailable(dbHandle, dbId - 1); + + startReadingsId = 1; + success = createNewDB(dbHandle, dbId, startReadingsId, NEW_DB_ATTACH_REQUEST); if (success) { Logger::getLogger()->debug("getReadingReference - allocate a new db - create new dbs - dbId :%d: startReadingsIdOnDB :%d:", dbId, startReadingsId); } } - + m_dbIdLast = dbIdEnd; + m_dbIdCurrent++; + m_dbNAvailable = (m_dbIdLast - m_dbIdCurrent) - m_storageConfigCurrent.nDbLeftFreeBeforeAllocate; } - m_dbIdLast = dbIdEnd; - m_dbIdCurrent++; - m_dbNAvailable = (m_dbIdLast - m_dbIdCurrent) - m_storageConfigCurrent.nDbLeftFreeBeforeAllocate; - } - ref.tableId = -1; - ref.dbId = -1; + ref.tableId = -1; + ref.dbId = -1; + } + } if (success) @@ -1903,49 +1912,53 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co // Associate a reading table to the asset { // Associate the asset to the reading_id + if (emptyAsset.empty()) { - if (emptyTableReference.tableId > 0) - { - ref.tableId = emptyTableReference.tableId; - ref.dbId = emptyTableReference.dbId; - } - else - { - ref.tableId = getMaxReadingsId(m_dbIdCurrent) + 1; - ref.dbId = m_dbIdCurrent; - } - + ref.tableId = getMaxReadingsId(m_dbIdCurrent) + 1; + ref.dbId = m_dbIdCurrent; + } + + { + m_EmptyAssetReadingCatalogue.erase(emptyAsset); + m_AssetReadingCatalogue.erase(emptyAsset); auto newItem = make_pair(ref.tableId, ref.dbId); auto newMapValue = make_pair(asset_code, newItem); m_AssetReadingCatalogue.insert(newMapValue); } - Logger::getLogger()->debug("getReadingReference - allocate a new reading table for the asset :%s: db Id :%d: readings Id :%d: ", asset_code, ref.dbId, ref.tableId); - // Allocate the table in the reading catalogue + if (emptyAsset.empty()) { - if (emptyTableReference.tableId > 0) - { - - sql_cmd = " UPDATE " READINGS_DB ".asset_reading_catalogue SET asset_code ='" + string(asset_code) + "'" + - " WHERE db_id = " + to_string(emptyTableReference.dbId) + " AND table_id = " + to_string(emptyTableReference.tableId) + ";"; - } - else - { - sql_cmd = + sql_cmd = "INSERT INTO " READINGS_DB ".asset_reading_catalogue (table_id, db_id, asset_code) VALUES (" + to_string(ref.tableId) + "," + to_string(ref.dbId) + "," + "\"" + asset_code + "\")"; - } + + Logger::getLogger()->debug("getReadingReference - allocate a new reading table for the asset :%s: db Id :%d: readings Id :%d: ", asset_code, ref.dbId, ref.tableId); + + } + else + { + sql_cmd = " UPDATE " READINGS_DB ".asset_reading_catalogue SET asset_code ='" + string(asset_code) + "'" + + " WHERE db_id = " + to_string(ref.dbId) + " AND table_id = " + to_string(ref.tableId) + ";"; + Logger::getLogger()->debug("getReadingReference - Use empty table %readings_%d_%d: ",ref.dbId,ref.tableId); + } + + { rc = SQLExec(dbHandle, sql_cmd.c_str()); if (rc != SQLITE_OK) { msg = string(sqlite3_errmsg(dbHandle)) + " asset :" + asset_code + ":"; raiseError("asset_reading_catalogue update", msg.c_str()); } - allocateReadingAvailable(); + + if (emptyAsset.empty()) + { + allocateReadingAvailable(); + } + } } @@ -1960,52 +1973,73 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co } /** - * Get Empty Reading Table - * - * @param emptyTableReference An empty reading table reference to be used for the given asset_code - * @return True of success, false on any error + * Loads the empty reading table catalogue * */ -bool ReadingsCatalogue::getEmptyReadingTableReference(tyReadingReference &emptyTableReference) +bool ReadingsCatalogue::loadEmptyAssetReadingCatalogue() { - bool isEmptyTableAvailable = false; sqlite3 *dbHandle; string sql_cmd; sqlite3_stmt *stmt; - - // Disable functionality temporarily to avoid regression - return false; - ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); dbHandle = connection->getDbHandle(); - + m_EmptyAssetReadingCatalogue.clear(); + for (auto &item : m_AssetReadingCatalogue) { - int tableId = item.second.first; - int dbId = item.second.second; - sql_cmd = "SELECT COUNT(*) FROM (SELECT 0 FROM readings_" + to_string(dbId) + ".readings_" + to_string(dbId) + "_" + to_string(tableId) + " LIMIT 1)"; - + string asset_name = item.first; // Asset + int tableId = item.second.first; // tableId; + int dbId = item.second.second; // dbId; + + sql_cmd = "SELECT COUNT(*) FROM readings_" + to_string(dbId) + ".readings_" + to_string(dbId) + "_" + to_string(tableId) + " ;"; if (sqlite3_prepare_v2(dbHandle, sql_cmd.c_str(), -1, &stmt, NULL) != SQLITE_OK) { - raiseError("getEmptyReadingTableReference", sqlite3_errmsg(dbHandle)); - return false; + sqlite3_finalize(stmt); + continue; } if (SQLStep(stmt) == SQLITE_ROW) { if (sqlite3_column_int(stmt, 0) == 0) { - isEmptyTableAvailable = true; - emptyTableReference.dbId = dbId; - emptyTableReference.tableId = tableId; + auto newItem = make_pair(tableId,dbId); + auto newMapValue = make_pair(asset_name,newItem); + m_EmptyAssetReadingCatalogue.insert(newMapValue); + } } sqlite3_finalize(stmt); + } - manager->release(connection); - return isEmptyTableAvailable; + return true; +} + +/** + * Get Empty Reading Table + * + * @param asset emptyAsset, copies value of asset for which empty table is found + * @return the reading id associated to the provided empty table + */ +ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getEmptyReadingTableReference(std::string& asset) +{ + ReadingsCatalogue::tyReadingReference emptyTableReference = {-1, -1}; + if (m_EmptyAssetReadingCatalogue.size() == 0) + { + loadEmptyAssetReadingCatalogue(); + } + + auto it = m_EmptyAssetReadingCatalogue.begin(); + if (it != m_EmptyAssetReadingCatalogue.end()) + { + asset = it->first; + emptyTableReference.tableId = it->second.first; + emptyTableReference.dbId = it->second.second; + + } + + return emptyTableReference; } /** @@ -2155,6 +2189,7 @@ int ReadingsCatalogue::purgeAllReadings(sqlite3 *dbHandle, const char *sqlCmdBa } } + loadEmptyAssetReadingCatalogue(); return(rc); } From 0f316c1ee58dd35d5abaf575a4936bb7bbd87c9a Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 1 Mar 2023 13:40:43 +0530 Subject: [PATCH 153/499] inbuilt DataAvailability rule documentation added Signed-off-by: ashish-jabble --- .../images/data-availability.png | Bin 0 -> 24108 bytes docs/fledge-rule-DataAvailability/index.rst | 15 +++++++++++++++ docs/scripts/plugin_and_service_documentation | 5 +++++ 3 files changed, 20 insertions(+) create mode 100644 docs/fledge-rule-DataAvailability/images/data-availability.png create mode 100644 docs/fledge-rule-DataAvailability/index.rst diff --git a/docs/fledge-rule-DataAvailability/images/data-availability.png b/docs/fledge-rule-DataAvailability/images/data-availability.png new file mode 100644 index 0000000000000000000000000000000000000000..beb62aa7b5d99f56d7d4283d4c51764f39958912 GIT binary patch literal 24108 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 plugins/notify.rst << EOFNOTIFY @@ -153,6 +154,10 @@ sed -i -e 's/Naming_Scheme/Naming_Scheme_plugin/' -e 's/Linked_Types/Linked_Type mkdir plugins/fledge-rule-Threshold ln -s $(pwd)/fledge-rule-Threshold/images plugins/fledge-rule-Threshold/images echo '.. include:: ../../fledge-rule-Threshold/index.rst' > plugins/fledge-rule-Threshold/index.rst +# Create the DataAvailability rule documentation +mkdir plugins/fledge-rule-DataAvailability +ln -s $(pwd)/fledge-rule-DataAvailability/images plugins/fledge-rule-DataAvailability/images +echo '.. include:: ../../fledge-rule-DataAvailability/index.rst' > plugins/fledge-rule-DataAvailability/index.rst cat > services/index.rst << EOFSERVICES ******************* From 608d6f64ebc790dbf84ae593c3146ff723bce544 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 1 Mar 2023 15:25:04 +0530 Subject: [PATCH 154/499] variable name fixes Signed-off-by: ashish-jabble --- scripts/common/get_logs.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/common/get_logs.sh b/scripts/common/get_logs.sh index e682343fa0..c7482cc0bf 100755 --- a/scripts/common/get_logs.sh +++ b/scripts/common/get_logs.sh @@ -33,8 +33,8 @@ done sum=$(($offset + $limit)) -factor_search=${#keyword} -if [[ $factor_search -gt 0 ]]; then +keyword_len=${#keyword} +if [[ $keyword_len -gt 0 ]]; then factor_keyword="$sourceApp:$level:$keyword:" search_pattern="grep -a -E '${pattern}' | grep -F '$keyword'" else @@ -73,7 +73,7 @@ if [[ $script_runs -eq 0 ]]; then else if [ -f /tmp/fl_syslog_factor ]; then echo "Reading factor value from /tmp/fl_syslog_factor" >&2 - if [[ $factor_search -gt 0 ]]; then + if [[ $keyword_len -gt 0 ]]; then factor_value=$(grep "$factor_keyword" /tmp/fl_syslog_factor | rev | cut -d: -f1 | rev) else factor_value=$(grep "$factor_keyword[0-9]+$" /tmp/fl_syslog_factor | rev | cut -d: -f1 | rev) From cc9095e7a47ce8b6e141beb856f94360bb4689ed Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Wed, 1 Mar 2023 17:05:02 +0530 Subject: [PATCH 155/499] check fix Signed-off-by: Praveen Garg --- python/fledge/services/core/api/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index d2f385037c..a78a29da29 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -593,7 +593,7 @@ async def update_service(request: web.Request) -> web.Response: name = request.match_info.get('name', None) try: _type = _type.lower() - if _type in ('notification', 'dispatcher', 'bucket_storage', 'management'): + if _type not in ('notification', 'dispatcher', 'bucket_storage', 'management'): raise ValueError("Invalid service type.") # process_name for bucket storage service schedule is bucket_storage_c hence type here must be bucket_storage? From 1065455899dca3edec7d6ef5090ce343dcdb2511 Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Wed, 1 Mar 2023 17:09:04 +0530 Subject: [PATCH 156/499] clean up Signed-off-by: Praveen Garg --- python/fledge/services/core/api/service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index a78a29da29..eddcd1fb18 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -597,7 +597,7 @@ async def update_service(request: web.Request) -> web.Response: raise ValueError("Invalid service type.") # process_name for bucket storage service schedule is bucket_storage_c hence type here must be bucket_storage? - # process_name for management schedule is management and python based; Check added for schedule stuff + # process_name for management service schedule is management and python based; Check added for schedule stuff # NOTE: `bucketstorage` repository name with `BucketStorage` type in service registry has package name *-`bucket`. # URL: /fledge/service/bucket_storage/bucket/update ?! *EXTERNAL* @@ -605,7 +605,6 @@ async def update_service(request: web.Request) -> web.Response: # Check requested service is installed or not installed_services = get_service_installed() - # TODO: Test for `bucket`/`management` for name in installed_services content if name not in installed_services: raise KeyError("{} service is not installed yet. Hence update is not possible.".format(name)) From 2c6607736b7ac76ab9a7f4f2c37dee7b69f2c863 Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Wed, 1 Mar 2023 18:19:42 +0530 Subject: [PATCH 157/499] text fix for tests Signed-off-by: Praveen Garg --- tests/unit/python/fledge/services/core/api/test_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/python/fledge/services/core/api/test_service.py b/tests/unit/python/fledge/services/core/api/test_service.py index 794cbd03f5..e00a49e8b9 100644 --- a/tests/unit/python/fledge/services/core/api/test_service.py +++ b/tests/unit/python/fledge/services/core/api/test_service.py @@ -1233,7 +1233,7 @@ def q_result(*arg): async def test_bad_type_update_package(self, client, param): resp = await client.put('/fledge/service/{}/name/update'.format(param), data=None) assert 400 == resp.status - assert "Invalid service type. Must be 'notification'" == resp.reason + assert "Invalid service type.'" == resp.reason async def test_bad_update_package(self, client, _type="notification", name="notification"): svc_list = ["storage", "south"] From 84d3e01a0bba40a96f0c449c214d48a913c3a44f Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Wed, 1 Mar 2023 21:34:21 +0530 Subject: [PATCH 158/499] text fix for tests Signed-off-by: Praveen Garg --- tests/unit/python/fledge/services/core/api/test_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/python/fledge/services/core/api/test_service.py b/tests/unit/python/fledge/services/core/api/test_service.py index e00a49e8b9..b975bd2caf 100644 --- a/tests/unit/python/fledge/services/core/api/test_service.py +++ b/tests/unit/python/fledge/services/core/api/test_service.py @@ -1233,7 +1233,7 @@ def q_result(*arg): async def test_bad_type_update_package(self, client, param): resp = await client.put('/fledge/service/{}/name/update'.format(param), data=None) assert 400 == resp.status - assert "Invalid service type.'" == resp.reason + assert "Invalid service type." == resp.reason async def test_bad_update_package(self, client, _type="notification", name="notification"): svc_list = ["storage", "south"] From 58cef2a5496aa863094770f8f6aa9d84fbe2d4f4 Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Wed, 1 Mar 2023 21:37:59 +0530 Subject: [PATCH 159/499] old inline code comment cleanup Signed-off-by: Praveen Garg --- python/fledge/services/core/api/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index eddcd1fb18..150bf0f75f 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -627,10 +627,10 @@ async def update_service(request: web.Request) -> web.Response: ['name', '=', package_name]).payload() await storage_client.delete_from_tbl("packages", delete_payload) - # process_name always ends with "_c" suffix _where_clause = ['process_name', '=', '{}_c'.format(_type)] if _type == 'management': _where_clause = ['process_name', '=', '{}'.format(_type)] + payload = PayloadBuilder().SELECT("id", "enabled", "schedule_name").WHERE(_where_clause).payload() result = await storage_client.query_tbl_with_payload('schedules', payload) sch_info = result['rows'] From fddcc99dab92a1912d980bba3806f5e9454d9f28 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 2 Mar 2023 15:29:13 +0530 Subject: [PATCH 160/499] feedback comments Signed-off-by: ashish-jabble --- docs/fledge-rule-DataAvailability/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/fledge-rule-DataAvailability/index.rst b/docs/fledge-rule-DataAvailability/index.rst index 619ef94aff..6a169a527f 100644 --- a/docs/fledge-rule-DataAvailability/index.rst +++ b/docs/fledge-rule-DataAvailability/index.rst @@ -4,12 +4,12 @@ DataAvailability Rule ===================== -This is built in rule that triggers every time it receives data that matches the asset code or audit code is given. +This is a built in rule that triggers every time it receives data that matches an asset code or audit code those given in the configuration. +---------------------+ | |data-availability| | +---------------------+ - - **Audit Code**: Audit log code to monitor, Leave blank if not required or set to * for all codes. If we want to monitor several audit codes we could enter a comma separated list e.g. SRVRG, SRVUN + - **Audit Code**: Audit log code to monitor, Leave blank if not required or set to * for all codes. If we want to monitor several audit codes a comma separated list can be entered. E.g. SRVRG, SRVUN - **Asset Code**: Asset code to monitor. Leave blank if not required. From eff11c89eeaaeb97a4ed1d4b5857bc4dba1c80b2 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 2 Mar 2023 14:12:40 +0000 Subject: [PATCH 161/499] FOGL-7481 Fix issue with memory handlign in prepared statement (#997) Signed-off-by: Mark Riddoch Co-authored-by: Aman <40791522+AmandeepArora@users.noreply.github.com> --- C/plugins/storage/sqlitelb/common/readings.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index 0cd4d61316..80badfbe81 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -899,9 +899,9 @@ int sleep_time_ms = 0; if (stmt != NULL) { - sqlite3_bind_text(batch_stmt, varNo++, user_ts, -1, SQLITE_STATIC); - sqlite3_bind_text(batch_stmt, varNo++, asset_code, -1, SQLITE_STATIC); - sqlite3_bind_text(batch_stmt, varNo++, reading.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(batch_stmt, varNo++, user_ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(batch_stmt, varNo++, asset_code, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(batch_stmt, varNo++, reading.c_str(), -1, SQLITE_TRANSIENT); } } @@ -1021,9 +1021,9 @@ int sleep_time_ms = 0; if(stmt != NULL) { - sqlite3_bind_text(stmt, 1, user_ts ,-1, SQLITE_STATIC); - sqlite3_bind_text(stmt, 2, asset_code ,-1, SQLITE_STATIC); - sqlite3_bind_text(stmt, 3, reading.c_str(), -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 1, user_ts ,-1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, asset_code ,-1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, reading.c_str(), -1, SQLITE_TRANSIENT); retries =0; sleep_time_ms = 0; From 44c3e72fdb886b7a15efe59d032f3dadcb8db203 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 2 Mar 2023 19:48:17 +0530 Subject: [PATCH 162/499] Logger Singleton class addition in logger for the on fly level set for all child logger classes Signed-off-by: ashish-jabble --- python/fledge/common/logger.py | 110 +++++++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 18 deletions(-) diff --git a/python/fledge/common/logger.py b/python/fledge/common/logger.py index 242c519cf3..f2c644941a 100644 --- a/python/fledge/common/logger.py +++ b/python/fledge/common/logger.py @@ -7,7 +7,6 @@ """ Fledge Logger """ import os import subprocess -import sys import logging from logging.handlers import SysLogHandler @@ -26,9 +25,10 @@ """Send log entries to STDERR""" -FLEDGE_LOGS_DESTINATION='FLEDGE_LOGS_DESTINATION' # env variable +FLEDGE_LOGS_DESTINATION = 'FLEDGE_LOGS_DESTINATION' # env variable default_destination = SYSLOG # default for fledge + def set_default_destination(destination: int): """ set_default_destination - allow a global default to be set, once, for all fledge modules also, set env variable FLEDGE_LOGS_DESTINATION for communication with related, spawned @@ -42,7 +42,16 @@ def set_default_destination(destination: int): os.environ[FLEDGE_LOGS_DESTINATION] in [str(CONSOLE), str(SYSLOG)]: # inherit (valid) default from the environment set_default_destination(int(os.environ[FLEDGE_LOGS_DESTINATION])) - + + +def get_process_name() -> str: + # Example: ps -eaf | grep 5175 | grep -v grep | awk -F '--name=' '{print $2}' + pid = os.getpid() + cmd = "ps -eaf | grep {} | grep -v grep | awk -F '--name=' '{{print $2}}'| tr -d '\n'".format(pid) + read_process_name = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).stdout.readlines() + binary_to_string = [b.decode() for b in read_process_name] + pname = 'Fledge ' + binary_to_string[0] if binary_to_string else 'Fledge' + return pname def setup(logger_name: str = None, @@ -83,16 +92,6 @@ def setup(logger_name: str = None, .. _logging.getLogger: https://docs.python.org/3/library/logging.html#logging.getLogger """ - - def _get_process_name(): - # Example: ps -eaf | grep 5175 | grep -v grep | awk -F '--name=' '{print $2}' - pid = os.getpid() - cmd = "ps -eaf | grep {} | grep -v grep | awk -F '--name=' '{{print $2}}'| tr -d '\n'".format(pid) - read_process_name = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).stdout.readlines() - binary_to_string = [b.decode() for b in read_process_name] - pname = 'Fledge ' + binary_to_string[0] if binary_to_string else 'Fledge' - return pname - logger = logging.getLogger(logger_name) # if no destination is set, use the fledge default @@ -106,16 +105,91 @@ def _get_process_name(): else: raise ValueError("Invalid destination {}".format(destination)) - process_name = _get_process_name() # TODO: Consider using %r with message when using syslog .. \n looks better than # - fmt = '{}[%(process)d] %(levelname)s: %(module)s: %(name)s: %(message)s'.format(process_name) + fmt = '{}[%(process)d] %(levelname)s: %(module)s: %(name)s: %(message)s'.format(get_process_name()) formatter = logging.Formatter(fmt=fmt) - handler.setFormatter(formatter) if level is not None: logger.setLevel(level) - logger.addHandler(handler) - logger.propagate = propagate return logger + + +class Logger: + """ + Singleton Logger class. This class is only instantiated ONCE. It is to keep a consistent + criteria for the logger throughout the application if need to be called upon. + It serves as the criteria for initiating logger for modules. It creates child loggers. + It's important to note these are child loggers as any changes made to the root logger + can be done. + """ + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + process_name = get_process_name() + fmt = '{}[%(process)d] %(levelname)s: %(module)s: %(name)s: %(message)s'.format(process_name) + cls.formatter = logging.Formatter(fmt=fmt) + return cls._instance + + def get_syslog_handler(self): + """Defines a syslog handler + + Returns: + logging handler object : the syslog handler + """ + syslog_handler = SysLogHandler(address='/dev/log') + syslog_handler.setFormatter(self.formatter) + syslog_handler.name = "syslogHandler" + return syslog_handler + + def get_console_handler(self): + """Defines a console handler to come out on the console + + Returns: + logging handler object : the console handler + """ + console_handler = logging.StreamHandler() + console_handler.setFormatter(self.formatter) + console_handler.name = "consoleHandler" + return console_handler + + def add_handlers(self, logger, handler_list: list): + """Adds handlers to the logger, checks first if handlers exist to avoid + duplication + + Args: + logger: Logger to check handlers + handler_list: list of handlers to add + """ + existing_handler_names = [] + for existing_handler in logger.handlers: + existing_handler_names.append(existing_handler.name) + + for new_handler in handler_list: + if new_handler.name not in existing_handler_names: + logger.addHandler(new_handler) + + def get_logger(self, logger_name: str): + """Generates logger for use in the modules. + Args: + logger_name: name of the logger + + Returns: + logger: returns logger for module + """ + _logger = logging.getLogger(logger_name) + console_handler = self.get_console_handler() + syslog_handler = self.get_syslog_handler() + self.add_handlers(_logger, [syslog_handler, console_handler]) + _logger.propagate = False + return _logger + + def set_level(self, level_number: int): + """Sets the root logger level. That means all child loggers will inherit this feature from it. + Args: + level_number: Numeric logging level for the message + """ + logging.root.setLevel(level_number) From 74c656bf6af338267d7f11d1983946d2d5ccb968 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 2 Mar 2023 19:48:45 +0530 Subject: [PATCH 163/499] LOGGING Advanced category added for to maintain the logging level across all child logger classes Signed-off-by: ashish-jabble --- python/fledge/services/core/server.py | 208 ++++++++++++++++---------- 1 file changed, 128 insertions(+), 80 deletions(-) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index ce249705b4..0139846371 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -8,6 +8,7 @@ """Core server module""" import asyncio +import logging import os import subprocess import sys @@ -21,7 +22,7 @@ from datetime import datetime, timedelta import jwt -from fledge.common import logger +from fledge.common.logger import Logger from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager @@ -56,7 +57,6 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -_logger = logger.setup(__name__, level=20) # FLEDGE_ROOT env variable _FLEDGE_DATA = os.getenv("FLEDGE_DATA", default=None) @@ -278,6 +278,26 @@ class Server: 'order': '10' }, } + _LOGGING_DEFAULT_CONFIG = { + 'logLevel': { + 'description': 'Minimum logging level reported for Core server', + 'type': 'enumeration', + 'displayName': 'Minimum Log Level', + 'options': ['debug', 'info', 'warning', 'error', 'critical'], + 'default': 'info' + } + } + + _log_level = logging.WARNING + """ Numeric logging level for the message """ + + # TODO: Python core server should have INFO log level; we need to filtered out the logs for this file. + # from fledge.common import logger + + _logger = Logger().get_logger(__name__) + # FIXME: If LOGGING category exists then set level from the DB value + logging.root.setLevel(_log_level) + """ Create Logger singleton class instance and set the root level to WARNING """ _start_time = time.time() """ Start time of core process """ @@ -408,11 +428,11 @@ def get_certificates(cls): key = certs_dir + '/{}.key'.format(cls.cert_file_name) if not os.path.isfile(cert) or not os.path.isfile(key): - _logger.warning("%s certificate files are missing. Hence using default certificate.", cls.cert_file_name) + cls._logger.warning("%s certificate files are missing. Hence using default certificate.", cls.cert_file_name) cert = certs_dir + '/fledge.cert' key = certs_dir + '/fledge.key' if not os.path.isfile(cert) or not os.path.isfile(key): - _logger.error("Certificates are missing") + cls._logger.error("Certificates are missing") raise RuntimeError return cert, key @@ -433,19 +453,19 @@ async def rest_api_config(cls): try: cls.is_auth_required = True if config['authentication']['value'] == "mandatory" else False except KeyError: - _logger.error("error in retrieving authentication info") + cls._logger.error("error in retrieving authentication info") raise try: cls.auth_method = config['authMethod']['value'] except KeyError: - _logger.error("error in retrieving authentication method info") + cls._logger.error("error in retrieving authentication method info") raise try: cls.cert_file_name = config['certificateName']['value'] except KeyError: - _logger.error("error in retrieving certificateName info") + cls._logger.error("error in retrieving certificateName info") raise try: @@ -458,14 +478,14 @@ async def rest_api_config(cls): else config['httpsPort']['value'] cls.rest_server_port = int(port_from_config) except KeyError: - _logger.error("error in retrieving port info") + cls._logger.error("error in retrieving port info") raise except ValueError: - _logger.error("error in parsing port value, received %s with type %s", + cls._logger.error("error in parsing port value, received %s with type %s", port_from_config, type(port_from_config)) raise except Exception as ex: - _logger.exception(str(ex)) + cls._logger.exception(str(ex)) raise @classmethod @@ -478,7 +498,7 @@ async def service_config(cls): category = 'service' if cls._configuration_manager is None: - _logger.error("No configuration manager available") + cls._logger.error("No configuration manager available") await cls._configuration_manager.create_category(category, config, 'Fledge Service', True, display_name='Fledge Service') config = await cls._configuration_manager.get_category_all_items(category) @@ -491,7 +511,7 @@ async def service_config(cls): except KeyError: cls._service_description = 'Fledge REST Services' except Exception as ex: - _logger.exception(str(ex)) + cls._logger.exception(str(ex)) raise @classmethod @@ -504,7 +524,7 @@ async def installation_config(cls): category = 'Installation' if cls._configuration_manager is None: - _logger.error("No configuration manager available") + cls._logger.error("No configuration manager available") await cls._configuration_manager.create_category(category, config, 'Installation', True, display_name='Installation') await cls._configuration_manager.get_category_all_items(category) @@ -512,7 +532,33 @@ async def installation_config(cls): cls._package_cache_manager = {"update": {"last_accessed_time": ""}, "upgrade": {"last_accessed_time": ""}, "list": {"last_accessed_time": ""}} except Exception as ex: - _logger.exception(str(ex)) + cls._logger.exception(str(ex)) + raise + + @classmethod + async def log_config(cls): + """ Get the logging level configuration """ + try: + config = cls._LOGGING_DEFAULT_CONFIG + category = 'LOGGING' + description = "Logging Level of Core Server" + if cls._configuration_manager is None: + cls._logger.error("No configuration manager available.") + await cls._configuration_manager.create_category(category, config, description, True, + display_name='Logging') + config = await cls._configuration_manager.get_category_all_items(category) + log_level = config['logLevel']['value'] + if log_level == 'debug': + logging_level = logging.DEBUG + elif log_level == 'info': + logging_level = logging.INFO + elif log_level == 'warning': + logging_level = logging.WARNING + else: + logging_level = logging.ERROR + cls._log_level = logging_level + except Exception as ex: + cls._logger.exception(str(ex)) raise @staticmethod @@ -554,37 +600,36 @@ async def _start_service_monitor(cls): """Starts the micro-service monitor""" cls.service_monitor = Monitor() await cls.service_monitor.start() - _logger.info("Services monitoring started ...") + cls._logger.info("Services monitoring started ...") @classmethod async def stop_service_monitor(cls): """Stops the micro-service monitor""" await cls.service_monitor.stop() - _logger.info("Services monitoring stopped.") + cls._logger.info("Services monitoring stopped.") @classmethod async def _start_scheduler(cls): """Starts the scheduler""" - _logger.info("Starting scheduler ...") + cls._logger.info("Starting scheduler ...") cls.scheduler = Scheduler(cls._host, cls.core_management_port, cls.running_in_safe_mode) await cls.scheduler.start() @staticmethod def __start_storage(host, m_port): - _logger.info("Start storage, from directory %s", _SCRIPTS_DIR) - try: - cmd_with_args = ['./services/storage', '--address={}'.format(host), - '--port={}'.format(m_port)] - subprocess.call(cmd_with_args, cwd=_SCRIPTS_DIR) - except Exception as ex: - _logger.exception(str(ex)) + cmd_with_args = ['./services/storage', '--address={}'.format(host), '--port={}'.format(m_port)] + subprocess.call(cmd_with_args, cwd=_SCRIPTS_DIR) @classmethod async def _start_storage(cls, loop): if loop is None: loop = asyncio.get_event_loop() + try: # callback with args - loop.call_soon(cls.__start_storage, cls._host, cls.core_management_port) + cls._logger.info("Start storage, from directory {}".format(_SCRIPTS_DIR)) + loop.call_soon(cls.__start_storage, cls._host, cls.core_management_port) + except Exception as ex: + cls._logger.exception(str(ex)) @classmethod async def _get_storage_client(cls): @@ -637,9 +682,9 @@ def _remove_pid(cls): """ Remove PID file """ try: os.remove(cls._pidfile) - _logger.info("Fledge PID file [" + cls._pidfile + "] removed.") + cls._logger.info("Fledge PID file [" + cls._pidfile + "] removed.") except Exception as ex: - _logger.error("Fledge PID file remove error: [" + ex.__class__.__name__ + "], (" + format(str(ex)) + ")") + cls._logger.error("Fledge PID file remove error: [" + ex.__class__.__name__ + "], (" + format(str(ex)) + ")") @classmethod def _write_pid(cls, api_address, api_port): @@ -650,7 +695,7 @@ def _write_pid(cls, api_address, api_port): # Check for existing PID file and log a message """ if cls._pidfile_exists() is True: - _logger.warn("A Fledge PID file has been found: [" + \ + cls._logger.warn("A Fledge PID file has been found: [" + \ cls._pidfile + "] found, ignoring it.") # Get the running script PID @@ -663,15 +708,15 @@ def _write_pid(cls, api_address, api_port): except FileNotFoundError: try: os.makedirs(os.path.dirname(cls._pidfile)) - _logger.info("The PID directory [" + os.path.dirname(cls._pidfile) + "] has been created") + cls._logger.info("The PID directory [" + os.path.dirname(cls._pidfile) + "] has been created") fh = open(cls._pidfile, 'w+') except Exception as ex: errmsg = "PID dir create error: [" + ex.__class__.__name__ + "], (" + format(str(ex)) + ")" - _logger.error(errmsg) + cls._logger.error(errmsg) raise except Exception as ex: errmsg = "Fledge PID file create error: [" + ex.__class__.__name__ + "], (" + format(str(ex)) + ")" - _logger.error(errmsg) + cls._logger.error(errmsg) raise # Build the JSON object to write into PID file @@ -687,7 +732,7 @@ def _write_pid(cls, api_address, api_port): # Close the PID file fh.close() - _logger.info("PID [" + str(pid) + "] written in [" + cls._pidfile + "]") + cls._logger.info("PID [" + str(pid) + "] written in [" + cls._pidfile + "]") except Exception as e: sys.stderr.write('Error: ' + format(str(e)) + "\n") sys.exit(1) @@ -695,7 +740,7 @@ def _write_pid(cls, api_address, api_port): @classmethod def _reposition_streams_table(cls, loop): - _logger.info("'fledge.readings' is stored in memory and a restarted has occurred, " + cls._logger.info("'fledge.readings' is stored in memory and a restarted has occurred, " "force reset of 'fledge.streams' last_objects") configuration = loop.run_until_complete(cls._storage_client_async.query_tbl('configuration')) @@ -747,7 +792,7 @@ def _check_readings_table(cls, loop): if streams_row_exists: cls._reposition_streams_table(loop) else: - _logger.info("'fledge.readings' is not empty; 'fledge.streams' last_objects reset is not required") + cls._logger.info("'fledge.readings' is not empty; 'fledge.streams' last_objects reset is not required") @classmethod async def _config_parents(cls): @@ -756,22 +801,22 @@ async def _config_parents(cls): await cls._configuration_manager.create_category("General", {}, 'General', True) await cls._configuration_manager.create_child_category("General", ["service", "rest_api", "Installation"]) except KeyError: - _logger.error('Failed to create General parent configuration category for service') + cls._logger.error('Failed to create General parent configuration category for service') raise # Create the parent category for all advanced configuration categories try: await cls._configuration_manager.create_category("Advanced", {}, 'Advanced', True) - await cls._configuration_manager.create_child_category("Advanced", ["SMNTR", "SCHEDULER"]) + await cls._configuration_manager.create_child_category("Advanced", ["SMNTR", "SCHEDULER", "LOGGING"]) except KeyError: - _logger.error('Failed to create Advanced parent configuration category for service') + cls._logger.error('Failed to create Advanced parent configuration category for service') raise # Create the parent category for all Utilities configuration categories try: await cls._configuration_manager.create_category("Utilities", {}, "Utilities", True) except KeyError: - _logger.error('Failed to create Utilities parent configuration category for task') + cls._logger.error('Failed to create Utilities parent configuration category for task') raise @classmethod @@ -782,16 +827,16 @@ async def _start_asset_tracker(cls): @classmethod def _start_core(cls, loop=None): if cls.running_in_safe_mode: - _logger.info("Starting in SAFE MODE ...") + cls._logger.info("Starting in SAFE MODE ...") else: - _logger.info("Starting ...") + cls._logger.info("Starting ...") try: host = cls._host cls.core_app = cls._make_core_app() cls.core_server, cls.core_server_handler = cls._start_app(loop, cls.core_app, host, 0) address, cls.core_management_port = cls.core_server.sockets[0].getsockname() - _logger.info('Management API started on http://%s:%s', address, cls.core_management_port) + cls._logger.info('Management API started on http://%s:%s', address, cls.core_management_port) # see http://:/fledge/service for registered services # start storage loop.run_until_complete(cls._start_storage(loop)) @@ -807,6 +852,9 @@ def _start_core(cls, loop=None): cls._configuration_manager = ConfigurationManager(cls._storage_client_async) cls._interest_registry = InterestRegistry(cls._configuration_manager) + # Logging category + loop.run_until_complete(cls.log_config()) + # start scheduler # see scheduler.py start def FIXME # scheduler on start will wait for storage service registration @@ -826,7 +874,7 @@ def _start_core(cls, loop=None): ssl_ctx = None if not cls.is_rest_server_http_enabled: cert, key = cls.get_certificates() - _logger.info('Loading certificates %s and key %s', cert, key) + cls._logger.info('Loading certificates %s and key %s', cert, key) # Verification handling of a tls cert with open(cert, 'r') as tls_cert_content: @@ -834,16 +882,16 @@ def _start_core(cls, loop=None): SSLVerifier.set_user_cert(tls_cert) if SSLVerifier.is_expired(): msg = 'Certificate `{}` expired on {}'.format(cls.cert_file_name, SSLVerifier.get_enddate()) - _logger.error(msg) + cls._logger.error(msg) if cls.running_in_safe_mode: cls.is_rest_server_http_enabled = True # TODO: Should cls.rest_server_port be set to configured http port, as is_rest_server_http_enabled has been set to True? msg = "Running in safe mode withOUT https on port {}".format(cls.rest_server_port) - _logger.info(msg) + cls._logger.info(msg) else: msg = 'Start in safe-mode to fix this problem!' - _logger.warning(msg) + cls._logger.warning(msg) raise SSLVerifier.VerificationError(msg) else: ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) @@ -852,7 +900,7 @@ def _start_core(cls, loop=None): # Get the service data and advertise the management port of the core # to allow other microservices to find Fledge loop.run_until_complete(cls.service_config()) - _logger.info('Announce management API service') + cls._logger.info('Announce management API service') cls.management_announcer = ServiceAnnouncer("core-{}".format(cls._service_name), cls._MANAGEMENT_SERVICE, cls.core_management_port, ['The Fledge Core REST API']) @@ -862,7 +910,7 @@ def _start_core(cls, loop=None): # Write PID file with REST API details cls._write_pid(address, service_server_port) - _logger.info('REST API Server started on %s://%s:%s', 'http' if cls.is_rest_server_http_enabled else 'https', + cls._logger.info('REST API Server started on %s://%s:%s', 'http' if cls.is_rest_server_http_enabled else 'https', address, service_server_port) # All services are up so now we can advertise the Admin and User REST API's @@ -891,10 +939,10 @@ def _start_core(cls, loop=None): # b) found then check the status of its schedule and take action is_dispatcher = loop.run_until_complete(cls.is_dispatcher_running(cls._storage_client_async)) if not is_dispatcher: - _logger.info("Dispatcher service installation found on the system, but not in running state. " + cls._logger.info("Dispatcher service installation found on the system, but not in running state. " "Therefore, starting the service...") loop.run_until_complete(cls.add_and_enable_dispatcher()) - _logger.info("Dispatcher service started.") + cls._logger.info("Dispatcher service started.") # dryrun execution of all the tasks that are installed but have schedule type other than STARTUP schedule_list = loop.run_until_complete(cls.scheduler.get_schedules()) for sch in schedule_list: @@ -992,7 +1040,7 @@ async def stop_rest_server(cls): await cls.service_app.shutdown() await cls.service_server_handler.shutdown(60.0) await cls.service_app.cleanup() - _logger.info("Rest server stopped.") + cls._logger.info("Rest server stopped.") @classmethod async def stop_storage(cls): @@ -1005,7 +1053,7 @@ async def stop_storage(cls): svc = found_services[0] if svc is None: - _logger.info("Fledge Storage shut down requested, but could not be found.") + cls._logger.info("Fledge Storage shut down requested, but could not be found.") return await cls._request_microservice_shutdown(svc) @@ -1030,7 +1078,7 @@ async def stop_microservices(cls): services_to_stop.append(fs) if len(services_to_stop) == 0: - _logger.info("No service found except the core, and(or) storage.") + cls._logger.info("No service found except the core, and(or) storage.") return tasks = [cls._request_microservice_shutdown(svc) for svc in services_to_stop] @@ -1038,7 +1086,7 @@ async def stop_microservices(cls): except service_registry_exceptions.DoesNotExist: pass except Exception as ex: - _logger.exception(str(ex)) + cls._logger.exception(str(ex)) @classmethod async def _request_microservice_shutdown(cls, svc): @@ -1051,15 +1099,15 @@ async def _request_microservice_shutdown(cls, svc): result = await resp.text() status_code = resp.status if status_code in range(400, 500): - _logger.error("Bad request error code: %d, reason: %s", status_code, resp.reason) + cls._logger.error("Bad request error code: %d, reason: %s", status_code, resp.reason) raise web.HTTPBadRequest(reason=resp.reason) if status_code in range(500, 600): - _logger.error("Server error code: %d, reason: %s", status_code, resp.reason) + cls._logger.error("Server error code: %d, reason: %s", status_code, resp.reason) raise web.HTTPInternalServerError(reason=resp.reason) try: response = json.loads(result) response['message'] - _logger.info("Shutdown scheduled for %s service %s. %s", svc._type, svc._name, response['message']) + cls._logger.info("Shutdown scheduled for %s service %s. %s", svc._type, svc._name, response['message']) except KeyError: raise @@ -1086,15 +1134,15 @@ def get_process_id(name): continue services_to_stop.append(fs) if len(services_to_stop) == 0: - _logger.info("All microservices, except Core and Storage, have been shutdown.") + cls._logger.info("All microservices, except Core and Storage, have been shutdown.") return if shutdown_threshold > _service_shutdown_threshold: for fs in services_to_stop: pids = get_process_id(fs._name) for pid in pids: - _logger.error("Microservice:%s status: %s has NOT been shutdown. Killing it...", fs._name, fs._status) + cls._logger.error("Microservice:%s status: %s has NOT been shutdown. Killing it...", fs._name, fs._status) os.kill(pid, signal.SIGKILL) - _logger.info("KILLED Microservice:%s...", fs._name) + cls._logger.info("KILLED Microservice:%s...", fs._name) return await asyncio.sleep(2) shutdown_threshold += 2 @@ -1103,14 +1151,14 @@ def get_process_id(name): except service_registry_exceptions.DoesNotExist: pass except Exception as ex: - _logger.exception(str(ex)) + cls._logger.exception(str(ex)) @classmethod async def _stop_scheduler(cls): try: await cls.scheduler.stop() except TimeoutError as e: - _logger.exception('Unable to stop the scheduler') + cls._logger.exception('Unable to stop the scheduler') raise e @classmethod @@ -1155,7 +1203,7 @@ async def register(cls, request): if token is None and ServiceRegistry.getStartupToken(service_name) is not None: raise web.HTTPBadRequest(body=json.dumps({"message": 'Required registration token is missing.'})) - + # If token, then check single use token verification; if bad then return 4XX if token is not None: if not isinstance(token, str): @@ -1164,7 +1212,7 @@ async def register(cls, request): # Check startup token exists if ServiceRegistry.checkStartupToken(service_name, token) == False: - msg = 'Token for the service was not found' + msg = 'Token for the service was not found' raise web.HTTPBadRequest(reason=msg) try: @@ -1176,7 +1224,7 @@ async def register(cls, request): cls._audit = AuditLogger(cls._storage_client_async) await cls._audit.information('SRVRG', {'name': service_name}) except Exception as ex: - _logger.info("Failed to audit registration: %s", str(ex)) + cls._logger.info("Failed to audit registration: %s", str(ex)) except service_registry_exceptions.AlreadyExistsWithTheSameName: raise web.HTTPBadRequest(reason='A Service with the same name already exists') except service_registry_exceptions.AlreadyExistsWithTheSameAddressAndPort: @@ -1218,7 +1266,7 @@ async def register(cls, request): 'bearer_token': bearer_token } - _logger.debug("For service: {} SERVER RESPONSE: {}".format(service_name, _response)) + cls._logger.debug("For service: {} SERVER RESPONSE: {}".format(service_name, _response)) return web.json_response(_response) @@ -1249,7 +1297,7 @@ async def unregister(cls, request): cls._audit = AuditLogger(cls._storage_client_async) await cls._audit.information('SRVUN', {'name': services[0]._name}) except Exception as ex: - _logger.exception(str(ex)) + cls._logger.exception(str(ex)) _resp = {'id': str(service_id), 'message': 'Service unregistered'} @@ -1280,7 +1328,7 @@ async def restart_service(cls, request): cls._audit = AuditLogger(cls._storage_client_async) await cls._audit.information('SRVRS', {'name': services[0]._name}) except Exception as ex: - _logger.exception(str(ex)) + cls._logger.exception(str(ex)) _resp = {'id': str(service_id), 'message': 'Service restart requested'} @@ -1345,13 +1393,13 @@ async def get_service(cls, request): async def get_auth_token(cls, request: web.Request) -> web.Response: """ get auth token :Example: - curl -sX GET -H "{'Authorization': 'Bearer ..'}" http://localhost:/fledge/service/authtoken + curl -sX GET -H "{'Authorization': 'Bearer ..'}" http://localhost:/fledge/service/authtoken """ async def cert_login(ca_cert): certs_dir = _FLEDGE_DATA + '/etc/certs' if _FLEDGE_DATA else _FLEDGE_ROOT + "/data/etc/certs" ca_cert_file = "{}/{}.cert".format(certs_dir, ca_cert) SSLVerifier.set_ca_cert(ca_cert_file) - # FIXME: allow to supply content and any cert name as placed with configured CA sign + # FIXME: allow to supply content and any cert name as placed with configured CA sign with open('{}/{}'.format(certs_dir, "admin.cert"), 'r') as content_file: cert_content = content_file.read() SSLVerifier.set_user_cert(cert_content) @@ -1364,13 +1412,13 @@ async def cert_login(ca_cert): cfg_mgr = ConfigurationManager(cls._storage_client_async) category_info = await cfg_mgr.get_category_all_items('rest_api') is_auth_optional = True if category_info['authentication']['value'].lower() == 'optional' else False - + if is_auth_optional: raise api_exception.AuthenticationIsOptional - + auth_method = category_info['authMethod']['value'] - ca_cert_name = category_info['authCertificateName']['value'] - + ca_cert_name = category_info['authCertificateName']['value'] + try: auth_header = request.headers.get('Authorization', None) except: @@ -1444,7 +1492,7 @@ async def shutdown(cls, request): loop = request.loop # allow some time await asyncio.sleep(2.0, loop=loop) - _logger.info("Stopping the Fledge Core event loop. Good Bye!") + cls._logger.info("Stopping the Fledge Core event loop. Good Bye!") loop.stop() return web.json_response({'message': 'Fledge stopped successfully. ' @@ -1462,9 +1510,9 @@ async def restart(cls, request): loop = request.loop # allow some time await asyncio.sleep(2.0, loop=loop) - _logger.info("Stopping the Fledge Core event loop. Good Bye!") + cls._logger.info("Stopping the Fledge Core event loop. Good Bye!") loop.stop() - + if 'safe-mode' in sys.argv: sys.argv.remove('safe-mode') sys.argv.append('') @@ -1656,7 +1704,7 @@ async def add_track(cls, request): service=data.get("service"), event=data.get("event"), jsondata=data.get("data")) - + except (TypeError, StorageServerError) as ex: raise web.HTTPBadRequest(reason=str(ex)) except ValueError as ex: @@ -1776,7 +1824,7 @@ async def add_audit(cls, request): message = {'timestamp': str(timestamp), 'source': code, 'severity': level, - 'details': message + 'details': message } except (TypeError, StorageServerError) as ex: @@ -1867,7 +1915,7 @@ async def is_dispatcher_running(cls, storage): res = await storage.query_tbl_with_payload('schedules', payload) for sch in res['rows']: if sch['process_name'] == 'dispatcher_c' and sch['enabled'] == 'f': - _logger.info("Dispatcher service found but not in enabled state. " + cls._logger.info("Dispatcher service found but not in enabled state. " "Therefore, {} schedule name is enabled".format(sch['schedule_name'])) await cls.scheduler.enable_schedule(uuid.UUID(sch["id"])) return True @@ -1907,7 +1955,7 @@ def get_token_common(cls, request): if not "Bearer " in auth_header: msg = "Invalid Authorization token" - # FIXME: raise UNAUTHORISED here and among other places + # FIXME: raise UNAUTHORISED here and among other places # and JSON body to have message key raise web.HTTPBadRequest(reason=msg, body=json.dumps({"error": msg})) From a667c23eafd9be839fb462fe594d223e007eafa0 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 2 Mar 2023 19:49:44 +0530 Subject: [PATCH 164/499] LOGGING category callback handling addition Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index be5de04971..99b655dd4b 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- - # FLEDGE_BEGIN # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END @@ -13,6 +12,7 @@ import ipaddress import datetime import os +import logging from math import * import collections import ast @@ -31,8 +31,6 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -import logging - _logger = logger.setup(__name__) # MAKE UPPER_CASE @@ -191,11 +189,24 @@ async def _run_callbacks(self, category_name): 'Callback module %s run method must be a coroutine function', callback) raise AttributeError('Callback module {} run method must be a coroutine function'.format(callback)) await cb.run(category_name) + else: + if category_name == "LOGGING": + from fledge.services.core import server + from fledge.common.logger import Logger + log_level = self._cacheManager.cache[category_name]['value']['logLevel']['value'] + if log_level == 'debug': + logging_level = logging.DEBUG + elif log_level == 'info': + logging_level = logging.INFO + elif log_level == 'warning': + logging_level = logging.WARNING + else: + logging_level = logging.ERROR + server.Server._log_level = logging_level + Logger().set_level(logging_level) async def _run_callbacks_child(self, parent_category_name, child_category, operation): - callbacks = self._registered_interests_child.get(parent_category_name) - if callbacks is not None: for callback in callbacks: try: From e5679943cc66fe866f41d233ca6001c6ae6c16d8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 2 Mar 2023 19:50:29 +0530 Subject: [PATCH 165/499] scheduler logger from new Logger Singleton class Signed-off-by: ashish-jabble --- python/fledge/services/core/scheduler/scheduler.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index 91ee7ba0ec..2222e3389a 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -18,7 +18,7 @@ import signal from typing import List -from fledge.common import logger +from fledge.common.logger import Logger from fledge.common import utils as common_utils from fledge.common.audit_logger import AuditLogger from fledge.common.storage_client.exceptions import * @@ -135,8 +135,7 @@ def __init__(self, core_management_host=None, core_management_port=None, is_safe # Initialize class attributes if not cls._logger: - cls._logger = logger.setup(__name__, level=logging.INFO) - # cls._logger = logger.setup(__name__, level=logging.DEBUG) + cls._logger = Logger().get_logger(__name__) if not cls._core_management_port: cls._core_management_port = core_management_port if not cls._core_management_host: From 9feeb1c325034948e350323678a62d13fe013858 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 2 Mar 2023 14:50:29 +0000 Subject: [PATCH 166/499] FOGL-7423 Add audit logger and group documentation (#995) * FOGL-7423 Add audit logger and group documentation Signed-off-by: Mark Riddoch * Fix review comments Signed-off-by: Mark Riddoch * Fix remaining review comments Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- .../02_writing_plugins.rst | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/plugin_developers_guide/02_writing_plugins.rst b/docs/plugin_developers_guide/02_writing_plugins.rst index 69e8f8852a..2a309f8c1f 100644 --- a/docs/plugin_developers_guide/02_writing_plugins.rst +++ b/docs/plugin_developers_guide/02_writing_plugins.rst @@ -12,6 +12,12 @@ C++ Support Classes +.. |audit_trail| raw:: html + + Audit Trail + + + .. Links in new tabs .. ============================================= @@ -353,6 +359,8 @@ We have used the properties *type* and *default* to define properties of the con - Only used for enumeration type elements. This is a JSON array of string that contains the options in the enumeration. * - order - Used in the user interface to give an indication of how high up in the dialogue to place this item. + * - group + - Used to group related items together. The main use of this is within the GUI which will turn each group into a tab in the creation and edit screens. * - readonly - A boolean property that can be used to include items that can not be altered by the API. * - rule @@ -425,3 +433,22 @@ The code that connects to the device should then look at the *discovered* config The example here was written in C++, there is nothing that is specific to C++ however and the same approach can be taken in Python. One thing to note however, the *plugin_info* call is used in the display of available plugins, discovery code that is very slow will impact the performance of plugin selection. + +Writing Audit Trail +~~~~~~~~~~~~~~~~~~~ + +Plugins are able to write records to the audit trail. These records must use one of the predefined audit code that are support by the system. See |audit_trail| for details of the supported audit codes within the system. + +In C++ you use the *AuditLogger* class to write these audit trail entries, this is a singleton object that is access via the getLogger method. + +.. code-block:: C + + AuditLogger *audit = AuditLogger::getLogger(); + audit->audit("NHDWN", "INFORMATION"); + +There is also a convenience function that can be used if you not want to define a local pointer the AuditLogger + +.. code-block:: C + + AuditLogger::auditLog("NHAVL", "INFORMATION"); + From 281a659e6d73c0e9a403c657c833a73ae6ccd0e6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 2 Mar 2023 20:46:16 +0530 Subject: [PATCH 167/499] configuration manager logger from new Logger Singleton class & some other level fixes Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 99b655dd4b..dd1d2b1e95 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -21,7 +21,7 @@ from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.utils import Utils -from fledge.common import logger +from fledge.common.logger import Logger from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA from fledge.common.audit_logger import AuditLogger from fledge.common.acl_manager import ACLManager @@ -31,7 +31,7 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -_logger = logger.setup(__name__) +_logger = Logger().get_logger(__name__) # MAKE UPPER_CASE _valid_type_strings = sorted(['boolean', 'integer', 'float', 'string', 'IPv4', 'IPv6', 'X509 certificate', 'password', @@ -194,14 +194,15 @@ async def _run_callbacks(self, category_name): from fledge.services.core import server from fledge.common.logger import Logger log_level = self._cacheManager.cache[category_name]['value']['logLevel']['value'] + logging_level = logging.WARNING if log_level == 'debug': logging_level = logging.DEBUG elif log_level == 'info': logging_level = logging.INFO - elif log_level == 'warning': - logging_level = logging.WARNING - else: + elif log_level == 'error': logging_level = logging.ERROR + elif log_level == 'critical': + logging_level = logging.CRITICAL server.Server._log_level = logging_level Logger().set_level(logging_level) From fe350b365f54b20edb92d7c96e3d2c6542c50412 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 2 Mar 2023 20:53:56 +0530 Subject: [PATCH 168/499] middleware logger from new Logger Singleton class Signed-off-by: ashish-jabble --- python/fledge/common/web/middleware.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/fledge/common/web/middleware.py b/python/fledge/common/web/middleware.py index 24e5570c80..6c3676d2d2 100644 --- a/python/fledge/common/web/middleware.py +++ b/python/fledge/common/web/middleware.py @@ -13,14 +13,14 @@ import jwt from fledge.services.core.user_model import User -from fledge.common import logger +from fledge.common.logger import Logger __author__ = "Praveen Garg" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" __license__ = "Apache 2.0" __version__ = "${VERSION}" -_logger = logger.setup(__name__) +_logger = Logger().get_logger(__name__) async def error_middleware(app, handler): @@ -42,7 +42,7 @@ async def middleware_handler(request): async def optional_auth_middleware(app, handler): async def middleware(request): - _logger.info("Received %s request for %s", request.method, request.path) + _logger.debug("Received %s request for %s", request.method, request.path) request.is_auth_optional = True request.user = None return await handler(request) @@ -54,7 +54,7 @@ async def middleware(request): # if `rest_api` config has `authentication` set to mandatory then: # request must carry auth header, # actual value will be checked too and if bad then 401: unauthorized will be returned - _logger.info("Received %s request for %s", request.method, request.path) + _logger.debug("Received %s request for %s", request.method, request.path) request.is_auth_optional = False request.user = None From b6c7b7f9c2d904327cea65d2b65a830669abd599 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 2 Mar 2023 16:50:32 +0000 Subject: [PATCH 169/499] FOGL-7292 Update OMF documentation to reflect new tabbed look and feel. (#990) * FOGL-7292 Move Cloud region to the cloud group Signed-off-by: Mark Riddoch * FOGL-7292 Move to tabbed configuration Signed-off-by: Mark Riddoch * Fix images Signed-off-by: Mark Riddoch * Add missing blank line and statement about auto detect pre 1.2 endpoints Signed-off-by: Mark Riddoch * Fix typo Signed-off-by: Mark Riddoch * Fix typo Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/plugin.cpp | 1 + docs/OMF.rst | 338 +++++++++++++++++++-------------- docs/images/ADH_Regions.jpg | Bin 0 -> 13270 bytes docs/images/OMF_AF.jpg | Bin 0 -> 70822 bytes docs/images/OMF_Auth.jpg | Bin 0 -> 75919 bytes docs/images/OMF_Cloud.jpg | Bin 0 -> 70389 bytes docs/images/OMF_Connection.jpg | Bin 0 -> 63259 bytes docs/images/OMF_Default.jpg | Bin 0 -> 83230 bytes docs/images/OMF_Endpoints.jpg | Bin 0 -> 20629 bytes docs/images/OMF_Format.jpg | Bin 0 -> 57661 bytes 10 files changed, 192 insertions(+), 147 deletions(-) create mode 100644 docs/images/ADH_Regions.jpg create mode 100644 docs/images/OMF_AF.jpg create mode 100644 docs/images/OMF_Auth.jpg create mode 100644 docs/images/OMF_Cloud.jpg create mode 100644 docs/images/OMF_Connection.jpg create mode 100644 docs/images/OMF_Default.jpg create mode 100644 docs/images/OMF_Endpoints.jpg create mode 100644 docs/images/OMF_Format.jpg diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index cfd1fb0945..4f4ab2c8a9 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -167,6 +167,7 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( "options":["US-West", "EU-West", "Australia"], "default": "US-West", "order": "2", + "group" : "Cloud", "displayName": "Cloud Service Region", "validity" : "PIServerEndpoint == \"AVEVA Data Hub\" || PIServerEndpoint == \"OSIsoft Cloud Services\"" }, diff --git a/docs/OMF.rst b/docs/OMF.rst index e46868ba1c..b0ec8ec327 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -7,150 +7,48 @@ .. |omf_plugin_eds_config| image:: images/omf-plugin-eds.jpg .. |omf_plugin_ocs_config| image:: images/omf-plugin-ocs.jpg .. |omf_plugin_adh_config| image:: images/omf-plugin-adh.jpg +.. |OMF_AF| image:: images/OMF_AF.jpg +.. |OMF_Auth| image:: images/OMF_Auth.jpg +.. |OMF_Cloud| image:: images/OMF_Cloud.jpg +.. |OMF_Connection| image:: images/OMF_Connection.jpg +.. |OMF_Default| image:: images/OMF_Default.jpg +.. |OMF_Format| image:: images/OMF_Format.jpg +.. |OMF_Endpoints| image:: images/OMF_Endpoints.jpg +.. |ADH_Regions| image:: images/ADH_Regions.jpg .. Links .. |OMFHint filter plugin| raw:: html OMFHint filter plugin + +OMF End Points +-------------- + +The OMF Plugin within Fledge supports a number of different OMF Endpoints for sending data out of Fledge. + PI Web API OMF Endpoint ~~~~~~~~~~~~~~~~~~~~~~~ To use the PI Web API OMF endpoint first ensure the OMF option was included in your PI Server when it was installed. Now go to the Fledge user interface, create a new North instance and select the “OMF” plugin on the first screen. -The second screen will request the following information: - -+----------------------------+ -| |omf_plugin_pi_web_config| | -+----------------------------+ - -Select PI Web API from the Endpoint options. - -- Basic Information - - **Endpoint:** This is the type of OMF endpoint. In this case, choose PI Web API. - - **Send full structure:** Used to control if Asset Framework structure messages are sent to the PI Server. If this is turned off then the data will not be placed in the Asset Framework. - - **Naming scheme:** Defines the naming scheme to be used when creating the PI points in the PI Data Archive. See :ref:`Naming_Scheme`. - - **Server hostname:** The hostname or address of the PI Web API server. This is normally the same address as the PI Server. - - **Server port:** The port the PI Web API OMF endpoint is listening on. Leave as 0 if you are using the default port. - - **Data Source:** Defines which data is sent to the PI Server. Choices are: readings or statistics (that is, Fledge's internal statistics). - - **Static Data:** Data to include in every reading sent to PI. For example, you can use this to specify the location of the devices being monitored by the Fledge server. -- Asset Framework - - **Default Asset Framework Location:** The location in the Asset Framework hierarchy into which the data will be inserted. - All data will be inserted at this point in the Asset Framework hierarchy unless a later rule overrides this. - Note this field does not include the name of the target Asset Framework Database; - the target database is defined on the PI Web API server by the PI Web API Admin Utility. - - **Asset Framework Hierarchies Rules:** A set of rules that allow specific readings to be placed elsewhere in the Asset Framework. These rules can be based on the name of the asset itself or some metadata associated with the asset. See `Asset Framework Hierarchy Rules`_. -- PI Web API authentication - - **PI Web API Authentication Method:** The authentication method to be used: anonymous, basic or kerberos. - Anonymous equates to no authentication, basic authentication requires a user name and password, and Kerberos allows integration with your Single Sign-On environment. - - **PI Web API User Id:** For Basic authentication, the user name to authenticate with the PI Web API. - - **PI Web API Password:** For Basic authentication, the password of the user we are using to authenticate. - - **PI Web API Kerberos keytab file:** The Kerberos keytab file used to authenticate. -- Connection management (These should only be changed with guidance from support) - - **Sleep Time Retry:** Number of seconds to wait before retrying the HTTP connection (Fledge doubles this time after each failed attempt). - - **Maximum Retry:** Maximum number of times to retry connecting to the PI Server. - - **HTTP Timeout:** Number of seconds to wait before Fledge will time out an HTTP connection attempt. -- Other (Rarely changed) - - **Integer Format:** Used to match Fledge data types to the data type configured in PI. This defaults to int64 but may be set to any OMF data type compatible with integer data, e.g. int32. - - **Number Format:** Used to match Fledge data types to the data type configured in PI. The default is float64 but may be set to any OMF datatype that supports floating point values. - - **Compression:** Compress the readings data before sending them to the PI Web API OMF endpoint. - This setting is not related to data compression in the PI Data Archive. - - **Complex Types:** Used to force the plugin to send OMF data types as complex types rather than the newer linked types. Linked types are the default way to send data and allows assets to have different sets of data points in different readings. See :ref:`Linked_Types`. +In the second screen select the PI Web API as the OMF endpoint. + +AVEVA Data Hub +~~~~~~~~~~~~~~ + +The cloud service from AVEVA that allows you to store your data in the AVEVA cloud. + +.. _Edge_Data_Store: Edge Data Store OMF Endpoint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To use the OSIsoft Edge Data Store first install Edge Data Store on the same machine as your Fledge instance. It is a limitation of Edge Data Store that it must reside on the same host as any system that connects to it with OMF. -Now go to the Fledge user interface, create a new North instance and select the “OMF” plugin on the first screen. -The second screen will request the following information: - -+-------------------------+ -| |omf_plugin_eds_config| | -+-------------------------+ - -Select Edge Data Store from the Endpoint options. - -- Basic Information - - **Endpoint:** This is the type of OMF endpoint. In this case, choose Edge Data Store. - - **Naming scheme:** Defines the naming scheme to be used when creating the PI points within the PI Server. See :ref:`Naming_Scheme`. - - **Server hostname:** Normally the hostname or address of the OMF endpoint. For Edge Data Store, this must be *localhost*. - - **Server port:** The port the Edge Data Store is listening on. Leave as 0 if you are using the default port. - - **Data Source:** Defines which data is sent to the Edge Data Store. Choices are: readings or statistics (that is, Fledge's internal statistics). - - **Static Data:** Data to include in every reading sent to PI. For example, you can use this to specify the location of the devices being monitored by the Fledge server. -- Connection management (These should only be changed with guidance from support) - - **Sleep Time Retry:** Number of seconds to wait before retrying the HTTP connection (Fledge doubles this time after each failed attempt). - - **Maximum Retry:** Maximum number of times to retry connecting to the PI server. - - **HTTP Timeout:** Number of seconds to wait before Fledge will time out an HTTP connection attempt. -- Other (Rarely changed) - - **Integer Format:** Used to match Fledge data types to the data type configured in PI. This defaults to int64 but may be set to any OMF data type compatible with integer data, e.g. int32. - - **Number Format:** Used to match Fledge data types to the data type configured in PI. The default is float64 but may be set to any OMF datatype that supports floating point values. - - **Compression:** Compress the readings data before sending them to the Edge Data Store. - -AVEVA Data Hub OMF Endpoint -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Go to the Fledge user interface, create a new North instance and select the “OMF” plugin on the first screen. -The second screen will request the following information: - -+-------------------------+ -| |omf_plugin_adh_config| | -+-------------------------+ - -Select AVEVA Data Hub from the Endpoint options. - -- Basic Information - - **Endpoint:** This is the type of OMF endpoint. In this case, choose AVEVA Data Hub. - - **Naming scheme:** Defines the naming scheme to be used when creating the PI points within the PI Server. See :ref:`Naming_Scheme`. - - **Data Source:** Defines which data is sent to AVEVA Data Hub. Choices are: readings or statistics (that is, Fledge's internal statistics). - - **Static Data:** Data to include in every reading sent to AVEVA Data Hub. For example, you can use this to specify the location of the devices being monitored by the Fledge server. -- Authentication - - **Namespace:** Your namespace within the AVEVA Data Hub. - - **Tenant ID:** Your AVEVA Data Hub Tenant ID for your account. - - **Client ID:** Your AVEVA Data Hub Client ID for your account. - - **Client Secret:** Your AVEVA Data Hub Client Secret. -- Connection management (These should only be changed with guidance from support) - - **Sleep Time Retry:** Number of seconds to wait before retrying the HTTP connection (Fledge doubles this time after each failed attempt). - - **Maximum Retry:** Maximum number of times to retry connecting to the AVEVA Data Hub. - - **HTTP Timeout:** Number of seconds to wait before Fledge will time out an HTTP connection attempt. -- Other (Rarely changed) - - **Integer Format:** Used to match Fledge data types to the data type configured in AVEVA Data Hub. This defaults to int64 but may be set to any OMF data type compatible with integer data, e.g. int32. - - **Number Format:** Used to match Fledge data types to the data type configured in AVEVA Data Hub. The default is float64 but may be set to any OMF datatype that supports floating point values. - - **Compression:** Compress the readings data before sending them to AVEVA Data Hub. - - -OSIsoft Cloud Services OMF Endpoint -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Go to the Fledge user interface, create a new North instance and select the “OMF” plugin on the first screen. -The second screen will request the following information: - -+-------------------------+ -| |omf_plugin_ocs_config| | -+-------------------------+ - -Select OSIsoft Cloud Services from the Endpoint options. - -- Basic Information - - **Endpoint:** This is the type of OMF endpoint. In this case, choose OSIsoft Cloud Services. - - **Naming scheme:** Defines the naming scheme to be used when creating the PI points within the PI Server. See :ref:`Naming_Scheme`. - - **Data Source:** Defines which data is sent to OSIsoft Cloud Services. Choices are: readings or statistics (that is, Fledge's internal statistics). - - **Static Data:** Data to include in every reading sent to OSIsoft Cloud Services. For example, you can use this to specify the location of the devices being monitored by the Fledge server. -- Authentication - - **Namespace:** Your namespace within OSIsoft Cloud Services. - - **Tenant ID:** Your OSIsoft Cloud Services Tenant ID for your account. - - **Client ID:** Your OSIsoft Cloud Services Client ID for your account. - - **Client Secret:** Your OSIsoft Cloud Services Client Secret. -- Connection management (These should only be changed with guidance from support) - - **Sleep Time Retry:** Number of seconds to wait before retrying the HTTP connection (Fledge doubles this time after each failed attempt). - - **Maximum Retry:** Maximum number of times to retry connecting to the PI server. - - **HTTP Timeout:** Number of seconds to wait before Fledge will time out an HTTP connection attempt. -- Other (Rarely changed) - - **Integer Format:** Used to match Fledge data types to the data type configured in PI. This defaults to int64 but may be set to any OMF data type compatible with integer data, e.g. int32. - - **Number Format:** Used to match Fledge data types to the data type configured in PI. The default is float64 but may be set to any OMF datatype that supports floating point values. - - **Compression:** Compress the readings data before sending them to OSIsoft Cloud Services. +.. _Connector_Relay: PI Connector Relay ~~~~~~~~~~~~~~~~~~ @@ -179,28 +77,174 @@ Connect the new application to the PI Connector Relay by selecting the new Fledg Finally, select the new Fledge application. Click "More" at the bottom of the Configuration panel. Make note of the Producer Token and Relay Ingress URL. -Now go to the Fledge user interface, create a new North instance and select the “OMF” plugin on the first screen. -The second screen will request the following information: - -+-------------------------------------+ -| |omf_plugin_connector_relay_config| | -+-------------------------------------+ - -- Basic Information - - **Endpoint:** This is the type of OMF endpoint. In this case, choose Connector Relay. - - **Server hostname:** The hostname or address of the PI Connector Relay. - - **Server port:** The port the PI Connector Relay is listening on. Leave as 0 if you are using the default port. - - **Producer Token:** The Producer Token provided by the PI Relay Data Connection Manager. - - **Data Source:** Defines which data is sent to the PI Connector Relay. Choices are: readings or statistics (that is, Fledge's internal statistics). - - **Static Data:** Data to include in every reading sent to PI. For example, you can use this to specify the location of the devices being monitored by the Fledge server. -- Connection management (These should only be changed with guidance from support) - - **Sleep Time Retry:** Number of seconds to wait before retrying the HTTP connection (Fledge doubles this time after each failed attempt). - - **Maximum Retry:** Maximum number of times to retry connecting to the PI server. - - **HTTP Timeout:** Number of seconds to wait before Fledge will time out an HTTP connection attempt. -- Other (Rarely changed) - - **Integer Format:** Used to match Fledge data types to the data type configured in PI. This defaults to int64 but may be set to any OMF data type compatible with integer data, e.g. int32. - - **Number Format:** Used to match Fledge data types to the data type configured in PI. The default is float64 but may be set to any OMF datatype that supports floating point values. - - **Compression:** Compress the readings data before sending it to the PI System. +Now go to the Fledge user interface, create a new North instance and select the “OMF” plugin on the first screen. Continue with the configuration, choosing the connector relay as the end point to be connected. + +OSISoft Cloud Services +~~~~~~~~~~~~~~~~~~~~~~ + +The original cloud services from OSISoft, this has now been superseded by AVEVA Data Hub, and should only be used to support existing workloads. All new installations should use AVEVA Data Hub. + +Configuration +------------- + +The configuration of the plugin is split into a number of tabs in order to reduce the size of each set of values to enter. Each tab contains a set of related items. + + - **Default Configuration**: This tab contains the base set of configuration items that are most commonly changed. + + - **Asset Framework**: The configuration that impacts the location with the asset framework in which the data will be placed. + + - **Authentication**: The configuration required to authenticate with the OMF end point. + + - **Cloud**: Configuration specific to using the cloud end points for OCS and ADH. + + - **Connection**: This tab contains the configuration items that can be used to tune the connection to the OMF end point. + + - **Formats & Types**: The configuration relating to how types are used and formatted with the OMF data. + + - **Advanced Configuration**: Configuration of the service or task that is supporting the OMF plugin. + + - **Security Configuration**: The configuration options that impact the security of the service that is running OMF. + + - **Developer**: This tab is only visible if the developer features of Fledge have been enabled and will give access to the features aimed at a plugin or pipeline developer. + +Default Configuration +~~~~~~~~~~~~~~~~~~~~~ + +The *Default Configuration* tab contains the most commonly modified items + ++--------------+ +| |OMF_Deault| | ++--------------+ + + - **Endpoint**: The type of OMF end point we are connecting with. The options available are + + +-----------------+ + | |OMF_Endpoints| | + +-----------------+ + + - *PI Web API* - A connection to a PI Server that supports the OMF option of the PI Web API. This is the preferred mechanism for sending data to a PI Server. + + - *AVEVA Data Hub* - The AVEVA cloud service. + + - *Connector Relay* - The previous way to send data to a PI Server before PI Web API supported OMF. This should only be used for older PI Servers that do not have the support available within PI Web API. + + - *OSISoft Cloud Services* - The original OSISoft cloud service, this is currently being replaced with the AVEVA Data Hub. + + - *Edge Data Store* - The OSISoft Edge Data Store + + - **Send full structure**: Used to control if Asset Framework structure messages are sent to the PI Server. If this is turned off then the data will not be placed in the Asset Framework. + + - **Naming scheme**: Defines the naming scheme to be used when creating the PI points in the PI Data Archive. See :ref:`Naming_Scheme`. + + - **Server hostname**: The hostname or address of the OMF end point. This is only valid if the end point is a PI Server either with PI Web API or the Connector Relay. This is normally the same address as the PI Server. + + - **Server port**: The port the PI Web API OMF endpoint is listening on. Leave as 0 if you are using the default port. + + - **Data Source**: Defines which data is sent to the OMF end point. The options available are + + - *readings* - The data that has been ingested into Fledge via the South services. + + - *statistics* - Fledge's internal statistics. + + - **Static Data**: Data to include in every reading sent to OMF. For example, you can use this to specify the location of the devices being monitored by the Fledge server. + + +Asset Framework +~~~~~~~~~~~~~~~ + +The OMF plugins has the ability to interact with the PI Asset Framework and put data into the desired locations within the asset framework. It allows a default location to be specified and also a set of rules to be defined that will override that default location. + ++----------+ +| |OMF_AF| | ++----------+ + + - **Default Asset Framework Location**: The location in the Asset Framework hierarchy into which the data will be inserted. + All data will be inserted at this point in the Asset Framework hierarchy unless a later rule overrides this. + Note this field does not include the name of the target Asset Framework Database; + the target database is defined on the PI Web API server by the PI Web API Admin Utility. + + - **Asset Framework Hierarchies Rules**: A set of rules that allow specific readings to be placed elsewhere in the Asset Framework. These rules can be based on the name of the asset itself or some metadata associated with the asset. See `Asset Framework Hierarchy Rules`_. + +Authentication +~~~~~~~~~~~~~~ + +The *Authentication* tab allows the configuration of authentication between the OMF plugin and the OMF endpoint. + ++------------+ +| |OMF_Auth| | ++------------+ + + - **Producer Token**: The Producer Token provided by the PI Relay Data Connection Manager. This is only required when using the older Connector Relay end point for sending data to a PI Server. + + - **PI Web API Authentication Method**: The authentication method to be used: + + - *anonymous* - Anonymous equates to no authentication. + + - *basic* - basic authentication requires a user name and password + + - *kerberos* - Kerberos allows integration with your Single Sign-On environment. + + - **PI Web API User Id**: For Basic authentication, the user name to authenticate with the PI Web API. + + - **PI Web API Password**: For Basic authentication, the password of the user we are using to authenticate. + + - **PI Web API Kerberos keytab file**: The Kerberos keytab file used to authenticate. + +Cloud +~~~~~ + +The *Cloud* tab contains configuration items that are required if the chosen OMF end point is either AVEVA Data Hub or OSISoft Cloud Services. + ++-------------+ +| |OMF_Cloud| | ++-------------+ + + - **Cloud Service Region**: - The region in which your AVEVA Data Hub or OSISoft Cloud Services service is located. + + +---------------+ + | |ADH_Regions| | + +---------------+ + + - **Namespace**: Your namespace within the AVEVA Data Hub or OSISoft Cloud Service. + + - **Tenant ID**: Your AVEVA Data Hub or OSISoft Cloud Services Tenant ID for your account. + + - **Client ID**: Your AVEVA Data Hub or OSISoft Cloud Services Client ID for your account. + + - **Client Secret**: Your AVEVA Data Hub or OSISoft Cloud Services Client Secret. + +Connection +~~~~~~~~~~ + +The *Connection* tab allows a set of tuning parameters to be set for the connection from the OMF plugin to the OMF End point. + ++------------------+ +| |OMF_Connection| | ++------------------+ + + + - **Sleep Time Retry**: Number of seconds to wait before retrying the connection (Fledge doubles this time after each failed attempt). + + - **Maximum Retry**: Maximum number of times to retry connecting to the OMF Endpoint. + + - **HTTP Timeout**: Number of seconds to wait before Fledge will time out an HTTP connection attempt. + + - **Compression**: Compress the readings data before sending them to the OMF endpoint. + +Formats & Types +~~~~~~~~~~~~~~~ + +The *Formats & Types* tab provides a means to specify the detail types that will be used and the way complex assets are mapped to OMF types to also be configured. + ++--------------+ +| |OMF_Format| | ++--------------+ + + - **Integer Format**: Used to match Fledge data types to the data type configured in PI. This defaults to int64 but may be set to any OMF data type compatible with integer data, e.g. int32. + + - **Number Format**: Used to match Fledge data types to the data type configured in PI. The default is float64 but may be set to any OMF datatype that supports floating point values. + + - **Complex Types**: Versions of the OMF plugin prior to 2.1 support complex types in which each asset would have a corresponding OMF type created for it. With the introduction of OMF Version 1.2 support in version 2.1.0 of the plugin support has been added for linked types. These are more versatile and allow for asset structures to change dynamically. The linked types are now the default, however setting this option can force the older complex types to be used. See :ref:`Linked_Types`. Versions of the PI Server from 2020 or before will always use the complex types. The plugin will normally automatically detect this, however if the detection does not correctly enforce this setting then this option should be enabled by the user. .. _Naming_Scheme: diff --git a/docs/images/ADH_Regions.jpg b/docs/images/ADH_Regions.jpg new file mode 100644 index 0000000000000000000000000000000000000000..73c58866fd830007f8df040917b1aaa0542a1796 GIT binary patch literal 13270 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 Yd_n%%4|?l2e91rC-~N9%erxD|09VoLW&i*H literal 0 HcmV?d00001 diff --git a/docs/images/OMF_Cloud.jpg b/docs/images/OMF_Cloud.jpg new file mode 100644 index 0000000000000000000000000000000000000000..022591a95be3bf0928c2a59cd6dacf41a9f810be GIT binary patch literal 70389 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 P8}iEffBbp5^!ncb5I~bW literal 0 HcmV?d00001 diff --git a/docs/images/OMF_Connection.jpg b/docs/images/OMF_Connection.jpg new file mode 100644 index 0000000000000000000000000000000000000000..976b86cde699bec1670d8e3dfe5402a6fd444f7e GIT binary patch literal 63259 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 literal 0 HcmV?d00001 diff --git a/docs/images/OMF_Default.jpg b/docs/images/OMF_Default.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3d7b17ec09697a330ca91815e24f8e77d8183d10 GIT binary patch literal 83230 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 literal 0 HcmV?d00001 diff --git a/docs/images/OMF_Endpoints.jpg b/docs/images/OMF_Endpoints.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9dcbd70fac8ae1e8096c06a16a986a3b4cfe56c0 GIT binary patch literal 20629 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 G`u_kL(j|xh literal 0 HcmV?d00001 From 39eada09545a696bf8c0d6b50331360839b3cf35 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 3 Mar 2023 13:42:40 +0530 Subject: [PATCH 170/499] Undefined substitution reference fixes OMF_Default Signed-off-by: ashish-jabble --- docs/OMF.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/OMF.rst b/docs/OMF.rst index b0ec8ec327..6ffef9fd45 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -112,9 +112,9 @@ Default Configuration The *Default Configuration* tab contains the most commonly modified items -+--------------+ -| |OMF_Deault| | -+--------------+ ++---------------+ +| |OMF_Default| | ++---------------+ - **Endpoint**: The type of OMF end point we are connecting with. The options available are From 846652d8761e5e0da64a6710e4c683ba71ef7dc8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 3 Mar 2023 13:44:30 +0530 Subject: [PATCH 171/499] duplicate label fixes in OMF documentation Signed-off-by: ashish-jabble --- docs/scripts/plugin_and_service_documentation | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index 085adcbd8a..c345b29876 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -149,7 +149,7 @@ echo '.. include:: ../../fledge-north-OMF.rst' > plugins/fledge-north-OMF/index. # Append OMF.rst to the end of the file rather than including it so that we may # edit the links to prevent duplicates cat OMF.rst >> plugins/fledge-north-OMF/index.rst -sed -i -e 's/Naming_Scheme/Naming_Scheme_plugin/' -e 's/Linked_Types/Linked_Types_Plugin/' plugins/fledge-north-OMF/index.rst +sed -i -e 's/Naming_Scheme/Naming_Scheme_plugin/' -e 's/Linked_Types/Linked_Types_Plugin/' -e 's/Edge_Data_Store/Edge_Data_Store_OMF_Endpoint/' -e 's/_Connector_Relay/PI_Connector_Relay/' plugins/fledge-north-OMF/index.rst # Create the Threshold rule documentation mkdir plugins/fledge-rule-Threshold ln -s $(pwd)/fledge-rule-Threshold/images plugins/fledge-rule-Threshold/images From 4839ea47e0c37fd310decb9ab8e4a4fdfacdb493 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 3 Mar 2023 13:48:10 +0530 Subject: [PATCH 172/499] title underline short fixes when white-labelled Signed-off-by: ashish-jabble --- docs/troubleshooting_pi-server_integration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/troubleshooting_pi-server_integration.rst b/docs/troubleshooting_pi-server_integration.rst index 78ff9a027e..41a82e81ad 100644 --- a/docs/troubleshooting_pi-server_integration.rst +++ b/docs/troubleshooting_pi-server_integration.rst @@ -30,7 +30,7 @@ using Fledge version >= 1.9.1 and PI Web API 2019 SP1 1.13.0.6518 - `Possible solutions to common problems`_ Fledge 2.1.0 and later -====================== +======================= In version 2.1 of Fledge a major change was introduced to the OMF plugin in the form of support for OMF version 1.2. This provides for a different method of adding data to the OMF end points that greatly improves the flexibility and removes the need to create complex types in OMF to map onto the Fledge reading structure. When upgrading from a version prior to 2.1 where data had previously been sent to OMF, the plugin will continue to use the older, pre-OMF 1.2 method to add data. This ensures that data will continue to be written to the same tags within the PI Server or other OMF end points. New data, not previously sent to OMF will be written using the newer OMF 1.2 mechanism. From df90282d2b12e3b9a8ff12a29f4ef4398dc69546 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 3 Mar 2023 15:03:46 +0530 Subject: [PATCH 173/499] get numeric logger def added in common logger & other fixes in server and configuration modules Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 16 ++------ python/fledge/common/logger.py | 23 ++++++++++- python/fledge/services/core/server.py | 38 ++++++++----------- 3 files changed, 40 insertions(+), 37 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index dd1d2b1e95..47ec8e016c 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -81,7 +81,7 @@ def update(self, category_name, category_description, category_val, display_name display_name = category_name if display_name is None else display_name self.cache[category_name] = {'date_accessed': datetime.datetime.now(), 'description': category_description, 'value': category_val, 'displayName': display_name} - _logger.info("Updated Configuration Cache %s", self.cache) + _logger.debug("Updated Configuration Cache %s", self.cache) def remove_oldest(self): """Remove the entry that has the oldest accessed date""" @@ -194,15 +194,7 @@ async def _run_callbacks(self, category_name): from fledge.services.core import server from fledge.common.logger import Logger log_level = self._cacheManager.cache[category_name]['value']['logLevel']['value'] - logging_level = logging.WARNING - if log_level == 'debug': - logging_level = logging.DEBUG - elif log_level == 'info': - logging_level = logging.INFO - elif log_level == 'error': - logging_level = logging.ERROR - elif log_level == 'critical': - logging_level = logging.CRITICAL + logging_level = Logger().get_numeric_log_level(log_level) server.Server._log_level = logging_level Logger().set_level(logging_level) @@ -1430,13 +1422,13 @@ async def _delete_recursively(self, cat): payload = PayloadBuilder().WHERE(["child", "=", cat]).payload() result = await self._storage.delete_from_tbl("category_children", payload) if result['response'] == 'deleted': - _logger.info('Deleted parent in category_children: %s', cat) + _logger.info('Deleted parent in category_children: {}'.format(cat)) # Remove category. payload = PayloadBuilder().WHERE(["key", "=", cat]).payload() result = await self._storage.delete_from_tbl("configuration", payload) if result['response'] == 'deleted': - _logger.info('Deleted parent category from configuration: %s', cat) + _logger.info('Deleted parent category from configuration: {}'.format(cat)) audit = AuditLogger(self._storage) audit_details = {'categoryDeleted': cat} # FIXME: FOGL-2140 diff --git a/python/fledge/common/logger.py b/python/fledge/common/logger.py index f2c644941a..6538e20ca1 100644 --- a/python/fledge/common/logger.py +++ b/python/fledge/common/logger.py @@ -10,8 +10,8 @@ import logging from logging.handlers import SysLogHandler -__author__ = "Praveen Garg" -__copyright__ = "Copyright (c) 2017 OSIsoft, LLC" +__author__ = "Praveen Garg, Ashish Jabble" +__copyright__ = "Copyright (c) 2017-2023 OSIsoft, LLC" __license__ = "Apache 2.0" __version__ = "${VERSION}" @@ -193,3 +193,22 @@ def set_level(self, level_number: int): level_number: Numeric logging level for the message """ logging.root.setLevel(level_number) + + def get_numeric_log_level(self, level_name: str) -> int: + """Get the numeric value of log level + Args: + level_name: Log level name in string + Returns: + Log numeric value + """ + if level_name == 'debug': + log_level = logging.DEBUG + elif level_name == 'info': + log_level = logging.INFO + elif level_name == 'error': + log_level = logging.ERROR + elif level_name == 'critical': + log_level = logging.CRITICAL + else: + log_level = logging.WARNING + return log_level diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 0139846371..45c2a4b5d9 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -289,7 +289,7 @@ class Server: } _log_level = logging.WARNING - """ Numeric logging level for the message """ + """ Numeric logging level for Core """ # TODO: Python core server should have INFO log level; we need to filtered out the logs for this file. # from fledge.common import logger @@ -536,27 +536,18 @@ async def installation_config(cls): raise @classmethod - async def log_config(cls): + async def logger_config(cls): """ Get the logging level configuration """ try: config = cls._LOGGING_DEFAULT_CONFIG category = 'LOGGING' description = "Logging Level of Core Server" if cls._configuration_manager is None: - cls._logger.error("No configuration manager available.") + cls._configuration_manager = ConfigurationManager(cls._storage_client_async) await cls._configuration_manager.create_category(category, config, description, True, display_name='Logging') config = await cls._configuration_manager.get_category_all_items(category) - log_level = config['logLevel']['value'] - if log_level == 'debug': - logging_level = logging.DEBUG - elif log_level == 'info': - logging_level = logging.INFO - elif log_level == 'warning': - logging_level = logging.WARNING - else: - logging_level = logging.ERROR - cls._log_level = logging_level + cls._log_level = Logger().get_numeric_log_level(config['logLevel']['value']) except Exception as ex: cls._logger.exception(str(ex)) raise @@ -616,20 +607,21 @@ async def _start_scheduler(cls): await cls.scheduler.start() @staticmethod - def __start_storage(host, m_port): - cmd_with_args = ['./services/storage', '--address={}'.format(host), '--port={}'.format(m_port)] - subprocess.call(cmd_with_args, cwd=_SCRIPTS_DIR) + def __start_storage(host, m_port, log): + log.info("Start storage, from directory {}".format(_SCRIPTS_DIR)) + try: + cmd_with_args = ['./services/storage', '--address={}'.format(host), + '--port={}'.format(m_port)] + subprocess.call(cmd_with_args, cwd=_SCRIPTS_DIR) + except Exception as ex: + log.exception(str(ex)) @classmethod async def _start_storage(cls, loop): if loop is None: loop = asyncio.get_event_loop() - try: - # callback with args - cls._logger.info("Start storage, from directory {}".format(_SCRIPTS_DIR)) - loop.call_soon(cls.__start_storage, cls._host, cls.core_management_port) - except Exception as ex: - cls._logger.exception(str(ex)) + # callback with args + loop.call_soon(cls.__start_storage, cls._host, cls.core_management_port, cls._logger) @classmethod async def _get_storage_client(cls): @@ -853,7 +845,7 @@ def _start_core(cls, loop=None): cls._interest_registry = InterestRegistry(cls._configuration_manager) # Logging category - loop.run_until_complete(cls.log_config()) + loop.run_until_complete(cls.logger_config()) # start scheduler # see scheduler.py start def FIXME From 679b00db694b911ce80a339bf2faf9f57fa10a7c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 3 Mar 2023 18:05:30 +0530 Subject: [PATCH 174/499] core server logger without singleton Logger object & other fixes Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 3 +- python/fledge/common/logger.py | 15 +- python/fledge/services/core/server.py | 158 +++++++++--------- 3 files changed, 82 insertions(+), 94 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 47ec8e016c..44dcd9ad44 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -193,8 +193,7 @@ async def _run_callbacks(self, category_name): if category_name == "LOGGING": from fledge.services.core import server from fledge.common.logger import Logger - log_level = self._cacheManager.cache[category_name]['value']['logLevel']['value'] - logging_level = Logger().get_numeric_log_level(log_level) + logging_level = self._cacheManager.cache[category_name]['value']['logLevel']['value'] server.Server._log_level = logging_level Logger().set_level(logging_level) diff --git a/python/fledge/common/logger.py b/python/fledge/common/logger.py index 6538e20ca1..c99b7773b9 100644 --- a/python/fledge/common/logger.py +++ b/python/fledge/common/logger.py @@ -187,19 +187,10 @@ def get_logger(self, logger_name: str): _logger.propagate = False return _logger - def set_level(self, level_number: int): + def set_level(self, level_name: str): """Sets the root logger level. That means all child loggers will inherit this feature from it. Args: - level_number: Numeric logging level for the message - """ - logging.root.setLevel(level_number) - - def get_numeric_log_level(self, level_name: str) -> int: - """Get the numeric value of log level - Args: - level_name: Log level name in string - Returns: - Log numeric value + level_name: logging level """ if level_name == 'debug': log_level = logging.DEBUG @@ -211,4 +202,4 @@ def get_numeric_log_level(self, level_name: str) -> int: log_level = logging.CRITICAL else: log_level = logging.WARNING - return log_level + logging.root.setLevel(log_level) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 45c2a4b5d9..1f792bdf96 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -10,6 +10,7 @@ import asyncio import logging import os +import logging import subprocess import sys import ssl @@ -22,7 +23,7 @@ from datetime import datetime, timedelta import jwt -from fledge.common.logger import Logger +from fledge.common import logger from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager @@ -57,6 +58,7 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" +_logger = logger.setup(__name__, level=logging.INFO) # FLEDGE_ROOT env variable _FLEDGE_DATA = os.getenv("FLEDGE_DATA", default=None) @@ -278,26 +280,20 @@ class Server: 'order': '10' }, } + _LOGGING_DEFAULT_CONFIG = { - 'logLevel': { - 'description': 'Minimum logging level reported for Core server', - 'type': 'enumeration', - 'displayName': 'Minimum Log Level', - 'options': ['debug', 'info', 'warning', 'error', 'critical'], - 'default': 'info' - } + 'logLevel': { + 'description': 'Minimum logging level reported for Core server', + 'type': 'enumeration', + 'displayName': 'Minimum Log Level', + 'options': ['debug', 'info', 'warning', 'error', 'critical'], + 'default': 'warning', + 'order': '1' + } } - _log_level = logging.WARNING - """ Numeric logging level for Core """ - - # TODO: Python core server should have INFO log level; we need to filtered out the logs for this file. - # from fledge.common import logger - - _logger = Logger().get_logger(__name__) - # FIXME: If LOGGING category exists then set level from the DB value - logging.root.setLevel(_log_level) - """ Create Logger singleton class instance and set the root level to WARNING """ + _log_level = _LOGGING_DEFAULT_CONFIG['logLevel']['default'] + """ Common logging level for Core """ _start_time = time.time() """ Start time of core process """ @@ -428,11 +424,11 @@ def get_certificates(cls): key = certs_dir + '/{}.key'.format(cls.cert_file_name) if not os.path.isfile(cert) or not os.path.isfile(key): - cls._logger.warning("%s certificate files are missing. Hence using default certificate.", cls.cert_file_name) + _logger.warning("%s certificate files are missing. Hence using default certificate.", cls.cert_file_name) cert = certs_dir + '/fledge.cert' key = certs_dir + '/fledge.key' if not os.path.isfile(cert) or not os.path.isfile(key): - cls._logger.error("Certificates are missing") + _logger.error("Certificates are missing") raise RuntimeError return cert, key @@ -453,19 +449,19 @@ async def rest_api_config(cls): try: cls.is_auth_required = True if config['authentication']['value'] == "mandatory" else False except KeyError: - cls._logger.error("error in retrieving authentication info") + _logger.error("error in retrieving authentication info") raise try: cls.auth_method = config['authMethod']['value'] except KeyError: - cls._logger.error("error in retrieving authentication method info") + _logger.error("error in retrieving authentication method info") raise try: cls.cert_file_name = config['certificateName']['value'] except KeyError: - cls._logger.error("error in retrieving certificateName info") + _logger.error("error in retrieving certificateName info") raise try: @@ -478,14 +474,14 @@ async def rest_api_config(cls): else config['httpsPort']['value'] cls.rest_server_port = int(port_from_config) except KeyError: - cls._logger.error("error in retrieving port info") + _logger.error("error in retrieving port info") raise except ValueError: - cls._logger.error("error in parsing port value, received %s with type %s", + _logger.error("error in parsing port value, received %s with type %s", port_from_config, type(port_from_config)) raise except Exception as ex: - cls._logger.exception(str(ex)) + _logger.exception(str(ex)) raise @classmethod @@ -498,7 +494,7 @@ async def service_config(cls): category = 'service' if cls._configuration_manager is None: - cls._logger.error("No configuration manager available") + _logger.error("No configuration manager available") await cls._configuration_manager.create_category(category, config, 'Fledge Service', True, display_name='Fledge Service') config = await cls._configuration_manager.get_category_all_items(category) @@ -511,7 +507,7 @@ async def service_config(cls): except KeyError: cls._service_description = 'Fledge REST Services' except Exception as ex: - cls._logger.exception(str(ex)) + _logger.exception(str(ex)) raise @classmethod @@ -524,7 +520,7 @@ async def installation_config(cls): category = 'Installation' if cls._configuration_manager is None: - cls._logger.error("No configuration manager available") + _logger.error("No configuration manager available") await cls._configuration_manager.create_category(category, config, 'Installation', True, display_name='Installation') await cls._configuration_manager.get_category_all_items(category) @@ -532,11 +528,11 @@ async def installation_config(cls): cls._package_cache_manager = {"update": {"last_accessed_time": ""}, "upgrade": {"last_accessed_time": ""}, "list": {"last_accessed_time": ""}} except Exception as ex: - cls._logger.exception(str(ex)) + _logger.exception(str(ex)) raise @classmethod - async def logger_config(cls): + async def core_logger_setup(cls): """ Get the logging level configuration """ try: config = cls._LOGGING_DEFAULT_CONFIG @@ -547,9 +543,11 @@ async def logger_config(cls): await cls._configuration_manager.create_category(category, config, description, True, display_name='Logging') config = await cls._configuration_manager.get_category_all_items(category) - cls._log_level = Logger().get_numeric_log_level(config['logLevel']['value']) + cls._log_level = config['logLevel']['value'] + from fledge.common.logger import Logger + Logger().set_level(cls._log_level) except Exception as ex: - cls._logger.exception(str(ex)) + _logger.exception(str(ex)) raise @staticmethod @@ -591,18 +589,18 @@ async def _start_service_monitor(cls): """Starts the micro-service monitor""" cls.service_monitor = Monitor() await cls.service_monitor.start() - cls._logger.info("Services monitoring started ...") + _logger.info("Services monitoring started ...") @classmethod async def stop_service_monitor(cls): """Stops the micro-service monitor""" await cls.service_monitor.stop() - cls._logger.info("Services monitoring stopped.") + _logger.info("Services monitoring stopped.") @classmethod async def _start_scheduler(cls): """Starts the scheduler""" - cls._logger.info("Starting scheduler ...") + _logger.info("Starting scheduler ...") cls.scheduler = Scheduler(cls._host, cls.core_management_port, cls.running_in_safe_mode) await cls.scheduler.start() @@ -621,7 +619,7 @@ async def _start_storage(cls, loop): if loop is None: loop = asyncio.get_event_loop() # callback with args - loop.call_soon(cls.__start_storage, cls._host, cls.core_management_port, cls._logger) + loop.call_soon(cls.__start_storage, cls._host, cls.core_management_port, _logger) @classmethod async def _get_storage_client(cls): @@ -674,9 +672,9 @@ def _remove_pid(cls): """ Remove PID file """ try: os.remove(cls._pidfile) - cls._logger.info("Fledge PID file [" + cls._pidfile + "] removed.") + _logger.info("Fledge PID file [" + cls._pidfile + "] removed.") except Exception as ex: - cls._logger.error("Fledge PID file remove error: [" + ex.__class__.__name__ + "], (" + format(str(ex)) + ")") + _logger.error("Fledge PID file remove error: [" + ex.__class__.__name__ + "], (" + format(str(ex)) + ")") @classmethod def _write_pid(cls, api_address, api_port): @@ -687,7 +685,7 @@ def _write_pid(cls, api_address, api_port): # Check for existing PID file and log a message """ if cls._pidfile_exists() is True: - cls._logger.warn("A Fledge PID file has been found: [" + \ + _logger.warn("A Fledge PID file has been found: [" + \ cls._pidfile + "] found, ignoring it.") # Get the running script PID @@ -700,15 +698,15 @@ def _write_pid(cls, api_address, api_port): except FileNotFoundError: try: os.makedirs(os.path.dirname(cls._pidfile)) - cls._logger.info("The PID directory [" + os.path.dirname(cls._pidfile) + "] has been created") + _logger.info("The PID directory [" + os.path.dirname(cls._pidfile) + "] has been created") fh = open(cls._pidfile, 'w+') except Exception as ex: errmsg = "PID dir create error: [" + ex.__class__.__name__ + "], (" + format(str(ex)) + ")" - cls._logger.error(errmsg) + _logger.error(errmsg) raise except Exception as ex: errmsg = "Fledge PID file create error: [" + ex.__class__.__name__ + "], (" + format(str(ex)) + ")" - cls._logger.error(errmsg) + _logger.error(errmsg) raise # Build the JSON object to write into PID file @@ -724,7 +722,7 @@ def _write_pid(cls, api_address, api_port): # Close the PID file fh.close() - cls._logger.info("PID [" + str(pid) + "] written in [" + cls._pidfile + "]") + _logger.info("PID [" + str(pid) + "] written in [" + cls._pidfile + "]") except Exception as e: sys.stderr.write('Error: ' + format(str(e)) + "\n") sys.exit(1) @@ -732,7 +730,7 @@ def _write_pid(cls, api_address, api_port): @classmethod def _reposition_streams_table(cls, loop): - cls._logger.info("'fledge.readings' is stored in memory and a restarted has occurred, " + _logger.info("'fledge.readings' is stored in memory and a restarted has occurred, " "force reset of 'fledge.streams' last_objects") configuration = loop.run_until_complete(cls._storage_client_async.query_tbl('configuration')) @@ -784,7 +782,7 @@ def _check_readings_table(cls, loop): if streams_row_exists: cls._reposition_streams_table(loop) else: - cls._logger.info("'fledge.readings' is not empty; 'fledge.streams' last_objects reset is not required") + _logger.info("'fledge.readings' is not empty; 'fledge.streams' last_objects reset is not required") @classmethod async def _config_parents(cls): @@ -793,7 +791,7 @@ async def _config_parents(cls): await cls._configuration_manager.create_category("General", {}, 'General', True) await cls._configuration_manager.create_child_category("General", ["service", "rest_api", "Installation"]) except KeyError: - cls._logger.error('Failed to create General parent configuration category for service') + _logger.error('Failed to create General parent configuration category for service') raise # Create the parent category for all advanced configuration categories @@ -801,14 +799,14 @@ async def _config_parents(cls): await cls._configuration_manager.create_category("Advanced", {}, 'Advanced', True) await cls._configuration_manager.create_child_category("Advanced", ["SMNTR", "SCHEDULER", "LOGGING"]) except KeyError: - cls._logger.error('Failed to create Advanced parent configuration category for service') + _logger.error('Failed to create Advanced parent configuration category for service') raise # Create the parent category for all Utilities configuration categories try: await cls._configuration_manager.create_category("Utilities", {}, "Utilities", True) except KeyError: - cls._logger.error('Failed to create Utilities parent configuration category for task') + _logger.error('Failed to create Utilities parent configuration category for task') raise @classmethod @@ -819,16 +817,16 @@ async def _start_asset_tracker(cls): @classmethod def _start_core(cls, loop=None): if cls.running_in_safe_mode: - cls._logger.info("Starting in SAFE MODE ...") + _logger.info("Starting in SAFE MODE ...") else: - cls._logger.info("Starting ...") + _logger.info("Starting ...") try: host = cls._host cls.core_app = cls._make_core_app() cls.core_server, cls.core_server_handler = cls._start_app(loop, cls.core_app, host, 0) address, cls.core_management_port = cls.core_server.sockets[0].getsockname() - cls._logger.info('Management API started on http://%s:%s', address, cls.core_management_port) + _logger.info('Management API started on http://%s:%s', address, cls.core_management_port) # see http://:/fledge/service for registered services # start storage loop.run_until_complete(cls._start_storage(loop)) @@ -845,7 +843,7 @@ def _start_core(cls, loop=None): cls._interest_registry = InterestRegistry(cls._configuration_manager) # Logging category - loop.run_until_complete(cls.logger_config()) + loop.run_until_complete(cls.core_logger_setup()) # start scheduler # see scheduler.py start def FIXME @@ -866,7 +864,7 @@ def _start_core(cls, loop=None): ssl_ctx = None if not cls.is_rest_server_http_enabled: cert, key = cls.get_certificates() - cls._logger.info('Loading certificates %s and key %s', cert, key) + _logger.info('Loading certificates %s and key %s', cert, key) # Verification handling of a tls cert with open(cert, 'r') as tls_cert_content: @@ -874,16 +872,16 @@ def _start_core(cls, loop=None): SSLVerifier.set_user_cert(tls_cert) if SSLVerifier.is_expired(): msg = 'Certificate `{}` expired on {}'.format(cls.cert_file_name, SSLVerifier.get_enddate()) - cls._logger.error(msg) + _logger.error(msg) if cls.running_in_safe_mode: cls.is_rest_server_http_enabled = True # TODO: Should cls.rest_server_port be set to configured http port, as is_rest_server_http_enabled has been set to True? msg = "Running in safe mode withOUT https on port {}".format(cls.rest_server_port) - cls._logger.info(msg) + _logger.info(msg) else: msg = 'Start in safe-mode to fix this problem!' - cls._logger.warning(msg) + _logger.warning(msg) raise SSLVerifier.VerificationError(msg) else: ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) @@ -892,7 +890,7 @@ def _start_core(cls, loop=None): # Get the service data and advertise the management port of the core # to allow other microservices to find Fledge loop.run_until_complete(cls.service_config()) - cls._logger.info('Announce management API service') + _logger.info('Announce management API service') cls.management_announcer = ServiceAnnouncer("core-{}".format(cls._service_name), cls._MANAGEMENT_SERVICE, cls.core_management_port, ['The Fledge Core REST API']) @@ -902,7 +900,7 @@ def _start_core(cls, loop=None): # Write PID file with REST API details cls._write_pid(address, service_server_port) - cls._logger.info('REST API Server started on %s://%s:%s', 'http' if cls.is_rest_server_http_enabled else 'https', + _logger.info('REST API Server started on %s://%s:%s', 'http' if cls.is_rest_server_http_enabled else 'https', address, service_server_port) # All services are up so now we can advertise the Admin and User REST API's @@ -931,10 +929,10 @@ def _start_core(cls, loop=None): # b) found then check the status of its schedule and take action is_dispatcher = loop.run_until_complete(cls.is_dispatcher_running(cls._storage_client_async)) if not is_dispatcher: - cls._logger.info("Dispatcher service installation found on the system, but not in running state. " + _logger.info("Dispatcher service installation found on the system, but not in running state. " "Therefore, starting the service...") loop.run_until_complete(cls.add_and_enable_dispatcher()) - cls._logger.info("Dispatcher service started.") + _logger.info("Dispatcher service started.") # dryrun execution of all the tasks that are installed but have schedule type other than STARTUP schedule_list = loop.run_until_complete(cls.scheduler.get_schedules()) for sch in schedule_list: @@ -1032,7 +1030,7 @@ async def stop_rest_server(cls): await cls.service_app.shutdown() await cls.service_server_handler.shutdown(60.0) await cls.service_app.cleanup() - cls._logger.info("Rest server stopped.") + _logger.info("Rest server stopped.") @classmethod async def stop_storage(cls): @@ -1045,7 +1043,7 @@ async def stop_storage(cls): svc = found_services[0] if svc is None: - cls._logger.info("Fledge Storage shut down requested, but could not be found.") + _logger.info("Fledge Storage shut down requested, but could not be found.") return await cls._request_microservice_shutdown(svc) @@ -1070,7 +1068,7 @@ async def stop_microservices(cls): services_to_stop.append(fs) if len(services_to_stop) == 0: - cls._logger.info("No service found except the core, and(or) storage.") + _logger.info("No service found except the core, and(or) storage.") return tasks = [cls._request_microservice_shutdown(svc) for svc in services_to_stop] @@ -1078,7 +1076,7 @@ async def stop_microservices(cls): except service_registry_exceptions.DoesNotExist: pass except Exception as ex: - cls._logger.exception(str(ex)) + _logger.exception(str(ex)) @classmethod async def _request_microservice_shutdown(cls, svc): @@ -1091,15 +1089,15 @@ async def _request_microservice_shutdown(cls, svc): result = await resp.text() status_code = resp.status if status_code in range(400, 500): - cls._logger.error("Bad request error code: %d, reason: %s", status_code, resp.reason) + _logger.error("Bad request error code: %d, reason: %s", status_code, resp.reason) raise web.HTTPBadRequest(reason=resp.reason) if status_code in range(500, 600): - cls._logger.error("Server error code: %d, reason: %s", status_code, resp.reason) + _logger.error("Server error code: %d, reason: %s", status_code, resp.reason) raise web.HTTPInternalServerError(reason=resp.reason) try: response = json.loads(result) response['message'] - cls._logger.info("Shutdown scheduled for %s service %s. %s", svc._type, svc._name, response['message']) + _logger.info("Shutdown scheduled for %s service %s. %s", svc._type, svc._name, response['message']) except KeyError: raise @@ -1126,15 +1124,15 @@ def get_process_id(name): continue services_to_stop.append(fs) if len(services_to_stop) == 0: - cls._logger.info("All microservices, except Core and Storage, have been shutdown.") + _logger.info("All microservices, except Core and Storage, have been shutdown.") return if shutdown_threshold > _service_shutdown_threshold: for fs in services_to_stop: pids = get_process_id(fs._name) for pid in pids: - cls._logger.error("Microservice:%s status: %s has NOT been shutdown. Killing it...", fs._name, fs._status) + _logger.error("Microservice:%s status: %s has NOT been shutdown. Killing it...", fs._name, fs._status) os.kill(pid, signal.SIGKILL) - cls._logger.info("KILLED Microservice:%s...", fs._name) + _logger.info("KILLED Microservice:%s...", fs._name) return await asyncio.sleep(2) shutdown_threshold += 2 @@ -1143,14 +1141,14 @@ def get_process_id(name): except service_registry_exceptions.DoesNotExist: pass except Exception as ex: - cls._logger.exception(str(ex)) + _logger.exception(str(ex)) @classmethod async def _stop_scheduler(cls): try: await cls.scheduler.stop() except TimeoutError as e: - cls._logger.exception('Unable to stop the scheduler') + _logger.exception('Unable to stop the scheduler') raise e @classmethod @@ -1216,7 +1214,7 @@ async def register(cls, request): cls._audit = AuditLogger(cls._storage_client_async) await cls._audit.information('SRVRG', {'name': service_name}) except Exception as ex: - cls._logger.info("Failed to audit registration: %s", str(ex)) + _logger.info("Failed to audit registration: %s", str(ex)) except service_registry_exceptions.AlreadyExistsWithTheSameName: raise web.HTTPBadRequest(reason='A Service with the same name already exists') except service_registry_exceptions.AlreadyExistsWithTheSameAddressAndPort: @@ -1258,7 +1256,7 @@ async def register(cls, request): 'bearer_token': bearer_token } - cls._logger.debug("For service: {} SERVER RESPONSE: {}".format(service_name, _response)) + _logger.debug("For service: {} SERVER RESPONSE: {}".format(service_name, _response)) return web.json_response(_response) @@ -1289,7 +1287,7 @@ async def unregister(cls, request): cls._audit = AuditLogger(cls._storage_client_async) await cls._audit.information('SRVUN', {'name': services[0]._name}) except Exception as ex: - cls._logger.exception(str(ex)) + _logger.exception(str(ex)) _resp = {'id': str(service_id), 'message': 'Service unregistered'} @@ -1320,7 +1318,7 @@ async def restart_service(cls, request): cls._audit = AuditLogger(cls._storage_client_async) await cls._audit.information('SRVRS', {'name': services[0]._name}) except Exception as ex: - cls._logger.exception(str(ex)) + _logger.exception(str(ex)) _resp = {'id': str(service_id), 'message': 'Service restart requested'} @@ -1484,7 +1482,7 @@ async def shutdown(cls, request): loop = request.loop # allow some time await asyncio.sleep(2.0, loop=loop) - cls._logger.info("Stopping the Fledge Core event loop. Good Bye!") + _logger.info("Stopping the Fledge Core event loop. Good Bye!") loop.stop() return web.json_response({'message': 'Fledge stopped successfully. ' @@ -1502,7 +1500,7 @@ async def restart(cls, request): loop = request.loop # allow some time await asyncio.sleep(2.0, loop=loop) - cls._logger.info("Stopping the Fledge Core event loop. Good Bye!") + _logger.info("Stopping the Fledge Core event loop. Good Bye!") loop.stop() if 'safe-mode' in sys.argv: @@ -1907,7 +1905,7 @@ async def is_dispatcher_running(cls, storage): res = await storage.query_tbl_with_payload('schedules', payload) for sch in res['rows']: if sch['process_name'] == 'dispatcher_c' and sch['enabled'] == 'f': - cls._logger.info("Dispatcher service found but not in enabled state. " + _logger.info("Dispatcher service found but not in enabled state. " "Therefore, {} schedule name is enabled".format(sch['schedule_name'])) await cls.scheduler.enable_schedule(uuid.UUID(sch["id"])) return True From b50b4e1c542c68960d3f8da74875b42122b6eca7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 3 Mar 2023 19:35:21 +0530 Subject: [PATCH 175/499] aiohttp web server logging set to WARNING always; but middleware logs will override as per core current logger level set Signed-off-by: ashish-jabble --- python/fledge/services/core/server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 1f792bdf96..1b8213a5fc 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -8,7 +8,6 @@ """Core server module""" import asyncio -import logging import os import logging import subprocess @@ -571,6 +570,8 @@ def _make_app(auth_required=True, auth_method='any'): mwares.append(middleware.auth_middleware) app = web.Application(middlewares=mwares, client_max_size=AIOHTTP_CLIENT_MAX_SIZE) + # aiohttp web server logging level always set to warning + web.access_logger.setLevel(logging.WARNING) admin_routes.setup(app) return app @@ -581,6 +582,8 @@ def _make_core_app(cls): :rtype: web.Application """ app = web.Application(middlewares=[middleware.error_middleware], client_max_size=AIOHTTP_CLIENT_MAX_SIZE) + # aiohttp web server logging level always set to warning + web.access_logger.setLevel(logging.WARNING) management_routes.setup(app, cls, True) return app From c1b5678d94a704caff5563a20e352ce5857b5720 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 3 Mar 2023 19:42:52 +0530 Subject: [PATCH 176/499] start storage def fixes Signed-off-by: ashish-jabble --- python/fledge/services/core/server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 1b8213a5fc..d34e8417d8 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -608,21 +608,21 @@ async def _start_scheduler(cls): await cls.scheduler.start() @staticmethod - def __start_storage(host, m_port, log): - log.info("Start storage, from directory {}".format(_SCRIPTS_DIR)) + def __start_storage(host, m_port): + _logger.info("Start storage, from directory {}".format(_SCRIPTS_DIR)) try: cmd_with_args = ['./services/storage', '--address={}'.format(host), '--port={}'.format(m_port)] subprocess.call(cmd_with_args, cwd=_SCRIPTS_DIR) except Exception as ex: - log.exception(str(ex)) + _logger.exception(str(ex)) @classmethod async def _start_storage(cls, loop): if loop is None: loop = asyncio.get_event_loop() # callback with args - loop.call_soon(cls.__start_storage, cls._host, cls.core_management_port, _logger) + loop.call_soon(cls.__start_storage, cls._host, cls.core_management_port) @classmethod async def _get_storage_client(cls): From 8f82dd4dd2e3175e6ed8d21bb845de453ce7e892 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 6 Mar 2023 11:56:31 +0530 Subject: [PATCH 177/499] Python core logger class renamed Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 7 +++---- python/fledge/common/logger.py | 4 ++-- python/fledge/common/web/middleware.py | 4 ++-- python/fledge/services/core/scheduler/scheduler.py | 6 +++--- python/fledge/services/core/server.py | 7 +++---- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 44dcd9ad44..28c0b62e97 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -21,7 +21,7 @@ from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.utils import Utils -from fledge.common.logger import Logger +from fledge.common.logger import FLCoreLogger from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA from fledge.common.audit_logger import AuditLogger from fledge.common.acl_manager import ACLManager @@ -31,7 +31,7 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -_logger = Logger().get_logger(__name__) +_logger = FLCoreLogger().get_logger(__name__) # MAKE UPPER_CASE _valid_type_strings = sorted(['boolean', 'integer', 'float', 'string', 'IPv4', 'IPv6', 'X509 certificate', 'password', @@ -192,10 +192,9 @@ async def _run_callbacks(self, category_name): else: if category_name == "LOGGING": from fledge.services.core import server - from fledge.common.logger import Logger logging_level = self._cacheManager.cache[category_name]['value']['logLevel']['value'] server.Server._log_level = logging_level - Logger().set_level(logging_level) + FLCoreLogger().set_level(logging_level) async def _run_callbacks_child(self, parent_category_name, child_category, operation): callbacks = self._registered_interests_child.get(parent_category_name) diff --git a/python/fledge/common/logger.py b/python/fledge/common/logger.py index c99b7773b9..6939f36ed6 100644 --- a/python/fledge/common/logger.py +++ b/python/fledge/common/logger.py @@ -116,9 +116,9 @@ def setup(logger_name: str = None, return logger -class Logger: +class FLCoreLogger: """ - Singleton Logger class. This class is only instantiated ONCE. It is to keep a consistent + Singleton FLCoreLogger class. This class is only instantiated ONCE. It is to keep a consistent criteria for the logger throughout the application if need to be called upon. It serves as the criteria for initiating logger for modules. It creates child loggers. It's important to note these are child loggers as any changes made to the root logger diff --git a/python/fledge/common/web/middleware.py b/python/fledge/common/web/middleware.py index 6c3676d2d2..ce421c02cf 100644 --- a/python/fledge/common/web/middleware.py +++ b/python/fledge/common/web/middleware.py @@ -13,14 +13,14 @@ import jwt from fledge.services.core.user_model import User -from fledge.common.logger import Logger +from fledge.common.logger import FLCoreLogger __author__ = "Praveen Garg" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" __license__ = "Apache 2.0" __version__ = "${VERSION}" -_logger = Logger().get_logger(__name__) +_logger = FLCoreLogger().get_logger(__name__) async def error_middleware(app, handler): diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index 2222e3389a..ba505d5c20 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -18,13 +18,13 @@ import signal from typing import List -from fledge.common.logger import Logger from fledge.common import utils as common_utils from fledge.common.audit_logger import AuditLogger +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.exceptions import * from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.storage_client import StorageClientAsync -from fledge.common.configuration_manager import ConfigurationManager from fledge.services.core.scheduler.entities import * from fledge.services.core.scheduler.exceptions import * from fledge.services.core.service_registry.service_registry import ServiceRegistry @@ -135,7 +135,7 @@ def __init__(self, core_management_host=None, core_management_port=None, is_safe # Initialize class attributes if not cls._logger: - cls._logger = Logger().get_logger(__name__) + cls._logger = FLCoreLogger().get_logger(__name__) if not cls._core_management_port: cls._core_management_port = core_management_port if not cls._core_management_host: diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index d34e8417d8..7a4fed0298 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -25,11 +25,10 @@ from fledge.common import logger from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager - -from fledge.common.web import middleware from fledge.common.storage_client.exceptions import * from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.storage_client.storage_client import ReadingsStorageClientAsync +from fledge.common.web import middleware from fledge.services.core import routes as admin_routes from fledge.services.core.api import configuration as conf_api @@ -543,8 +542,8 @@ async def core_logger_setup(cls): display_name='Logging') config = await cls._configuration_manager.get_category_all_items(category) cls._log_level = config['logLevel']['value'] - from fledge.common.logger import Logger - Logger().set_level(cls._log_level) + from fledge.common.logger import FLCoreLogger + FLCoreLogger().set_level(cls._log_level) except Exception as ex: _logger.exception(str(ex)) raise From 0f75afb0dd1dc917a9a8da01d9c2ea62d27529d8 Mon Sep 17 00:00:00 2001 From: nandan Date: Mon, 6 Mar 2023 13:23:50 +0530 Subject: [PATCH 178/499] FOGL-7493: Modfied north task not to start in case of dryrun Signed-off-by: nandan --- C/tasks/north/sending_process/sending.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/C/tasks/north/sending_process/sending.cpp b/C/tasks/north/sending_process/sending.cpp index a50548c390..e17effed82 100755 --- a/C/tasks/north/sending_process/sending.cpp +++ b/C/tasks/north/sending_process/sending.cpp @@ -283,6 +283,11 @@ SendingProcess::SendingProcess(int argc, char** argv) : FledgeProcess(argc, argv // Init plugin with merged configuration from Fledge API this->m_plugin->init(config); + + if(m_dryRun) + { + return; + } if (this->m_plugin->m_plugin_data) { From f3ef1edae91919cf91e3e1df50c0f697140d918e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 6 Mar 2023 15:25:50 +0530 Subject: [PATCH 179/499] Python Core logger usage in api; except common ping Signed-off-by: ashish-jabble --- .../fledge/services/core/api/asset_tracker.py | 11 +- python/fledge/services/core/api/audit.py | 7 +- python/fledge/services/core/api/auth.py | 133 +++++++++--------- .../services/core/api/backup_restore.py | 8 +- python/fledge/services/core/api/browser.py | 12 +- .../services/core/api/certificate_store.py | 10 +- .../fledge/services/core/api/configuration.py | 6 +- .../api/control_service/acl_management.py | 8 +- .../api/control_service/script_management.py | 10 +- python/fledge/services/core/api/filters.py | 106 +++++++------- python/fledge/services/core/api/health.py | 21 ++- python/fledge/services/core/api/north.py | 11 +- .../fledge/services/core/api/notification.py | 28 ++-- .../fledge/services/core/api/package_log.py | 15 +- .../services/core/api/plugins/common.py | 16 ++- .../fledge/services/core/api/plugins/data.py | 5 +- .../services/core/api/plugins/discovery.py | 7 +- .../services/core/api/plugins/install.py | 28 ++-- .../services/core/api/plugins/remove.py | 19 ++- .../services/core/api/plugins/update.py | 17 ++- .../services/core/api/python_packages.py | 7 +- .../services/core/api/repos/configure.py | 12 +- python/fledge/services/core/api/service.py | 5 +- python/fledge/services/core/api/support.py | 6 +- python/fledge/services/core/api/task.py | 25 ++-- python/fledge/services/core/api/update.py | 6 +- python/fledge/services/core/api/utils.py | 16 +-- 27 files changed, 274 insertions(+), 281 deletions(-) diff --git a/python/fledge/services/core/api/asset_tracker.py b/python/fledge/services/core/api/asset_tracker.py index 5f1828c2f0..50e063e606 100644 --- a/python/fledge/services/core/api/asset_tracker.py +++ b/python/fledge/services/core/api/asset_tracker.py @@ -4,20 +4,17 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END import json -import logging from aiohttp import web import urllib.parse from fledge.common import utils as common_utils +from fledge.common.audit_logger import AuditLogger +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect -from fledge.common.audit_logger import AuditLogger -from fledge.common import logger - - __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2018 OSIsoft, LLC" __license__ = "Apache 2.0" @@ -30,7 +27,7 @@ ----------------------------------------------------------------------------------------- """ -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) async def get_asset_tracker_events(request: web.Request) -> web.Response: @@ -124,7 +121,7 @@ async def deprecate_asset_track_entry(request: web.Request) -> web.Response: audit_details = {'asset': asset_name, 'service': svc_name, 'event': audit_event_name} await audit.information('ASTDP', audit_details) except: - _logger.warning("Failed to log the audit entry for {} deprecation.".format(asset_name)) + _logger.info("Failed to log the audit entry for {} deprecation.".format(asset_name)) pass else: raise StorageServerError diff --git a/python/fledge/services/core/api/audit.py b/python/fledge/services/core/api/audit.py index 4647d9ed51..9a492fab21 100644 --- a/python/fledge/services/core/api/audit.py +++ b/python/fledge/services/core/api/audit.py @@ -10,11 +10,11 @@ from aiohttp import web import json +from fledge.common.audit_logger import AuditLogger +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError from fledge.services.core import connect -from fledge.common.audit_logger import AuditLogger -from fledge.common import logger __author__ = "Amarendra K. Sinha, Ashish Jabble, Massimiliano Pinto" __copyright__ = "Copyright (c) 2017-2018 OSIsoft, LLC" @@ -32,7 +32,7 @@ ------------------------------------------------------------------------------- """ -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) class Severity(IntEnum): @@ -119,7 +119,6 @@ async def create_audit_entry(request): except AttributeError as e: # Return error for wrong severity method err_msg = "severity type {} is not supported".format(severity) - _logger.error("Error in create_audit_entry(): %s | %s", err_msg, str(e)) raise web.HTTPNotFound(reason=err_msg, body=json.dumps({"message": err_msg})) except StorageServerError as ex: if int(ex.code) in range(400, 500): diff --git a/python/fledge/services/core/api/auth.py b/python/fledge/services/core/api/auth.py index 0ddf7907c2..d7e0b06f68 100644 --- a/python/fledge/services/core/api/auth.py +++ b/python/fledge/services/core/api/auth.py @@ -10,20 +10,19 @@ import json from collections import OrderedDict import jwt -import logging - from aiohttp import web -from fledge.services.core.user_model import User + +from fledge.common.logger import FLCoreLogger from fledge.common.web.middleware import has_permission -from fledge.common import logger from fledge.common.web.ssl_wrapper import SSLVerifier +from fledge.services.core.user_model import User __author__ = "Praveen Garg, Ashish Jabble, Amarendra K Sinha" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" __license__ = "Apache 2.0" __version__ = "${VERSION}" -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) _help = """ ------------------------------------------------------------------------------------ @@ -79,8 +78,8 @@ def __remove_ott_for_user(user_id): """Helper function that removes given user_id from OTT_MAP if the user exists in the map.""" try: _user_id = int(user_id) - except ValueError as ex: - _logger.info("User id given is not an integer.") + except ValueError: + _logger.error("User id given is not an integer.") return for k, v in OTT.OTT_MAP.items(): if v[0] == _user_id: @@ -161,7 +160,7 @@ async def login(request): password = _data.get('password') if not username or not password: - _logger.warning("Username and password are required to login") + _logger.warning("Username and password are required to login.") raise web.HTTPBadRequest(reason="Username or password is missing") username = str(username).lower() @@ -179,7 +178,7 @@ async def login(request): # delete all user token for this user await User.Objects.delete_user_tokens(str(ex)) - msg = 'Your password has been expired. Please set your password again' + msg = 'Your password has been expired. Please set your password again.' _logger.warning(msg) raise web.HTTPUnauthorized(reason=msg) @@ -256,11 +255,11 @@ async def logout_me(request): result = await User.Objects.delete_token(request.token) if not result['rows_affected']: - _logger.warning("Logout requested with bad user token") + _logger.error("Logout requested with bad user token.") raise web.HTTPNotFound() __remove_ott_for_token(request.token) - _logger.info("User has been logged out successfully") + _logger.info("User has been logged out successfully.") return web.json_response({"logout": True}) @@ -281,13 +280,13 @@ async def logout(request): result = await User.Objects.delete_user_tokens(user_id) if not result['rows_affected']: - _logger.warning("Logout requested with bad user") + _logger.error("Logout requested with bad user.") raise web.HTTPNotFound() # Remove OTT token for this user if there. __remove_ott_for_user(user_id) - _logger.info("User with id:<{}> has been logged out successfully".format(int(user_id))) + _logger.info("User with id:<{}> has been logged out successfully.".format(int(user_id))) else: # requester is not an admin but trying to take action for another user raise web.HTTPUnauthorized(reason="admin privileges are required to logout other user") @@ -323,7 +322,7 @@ async def get_user(request): if user_id <= 0: raise ValueError except ValueError: - _logger.warning("Get user requested with bad user id") + _logger.error("Get user requested with bad user id.") raise web.HTTPBadRequest(reason="Bad user id") if 'username' in request.query and request.query['username'] != '': @@ -388,33 +387,33 @@ async def create_user(request): description = data.get('description', '') if not username: - msg = "Username is required to create user" + msg = "Username is required to create user." _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if not isinstance(username, str) or not isinstance(access_method, str) or not isinstance(real_name, str) \ or not isinstance(description, str): - msg = "Values should be passed in string" + msg = "Values should be passed in string." _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) username = username.lower().strip().replace(" ", "") if len(username) < MIN_USERNAME_LENGTH: - msg = "Username should be of minimum 4 characters" + msg = "Username should be of minimum 4 characters." _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if not re.match(USERNAME_REGEX_PATTERN, username): - msg = "Dot, hyphen, underscore special characters are allowed for username" + msg = "Dot, hyphen, underscore special characters are allowed for username." _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if access_method.lower() not in ['any', 'cert', 'pwd']: - msg = "Invalid access method. Must be 'any' or 'cert' or 'pwd'" + msg = "Invalid access method. Must be 'any' or 'cert' or 'pwd'." _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if access_method == 'pwd' and not password: - msg = "Password should not be an empty" + msg = "Password should not be an empty." _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) @@ -425,7 +424,7 @@ async def create_user(request): raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG, body=json.dumps({"message": PASSWORD_ERROR_MSG})) if not (await is_valid_role(role_id)): - msg = "Invalid role id" + msg = "Invalid role id." _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) try: @@ -433,7 +432,7 @@ async def create_user(request): except User.DoesNotExist: pass else: - msg = "Username already exists" + msg = "Username already exists." _logger.warning(msg) raise web.HTTPConflict(reason=msg, body=json.dumps({"message": msg})) @@ -458,7 +457,7 @@ async def create_user(request): msg = str(exc) _logger.exception(str(exc)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) - msg = "{} user has been created successfully".format(username) + msg = "{} user has been created successfully.".format(username) _logger.info(msg) return web.json_response({'message': msg, 'user': u}) @@ -478,7 +477,7 @@ async def update_me(request): real_name = data.get('real_name', '') if 'real_name' in data: if len(real_name.strip()) == 0: - msg = "Real Name should not be empty" + msg = "Real Name should not be empty." raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) else: from fledge.services.core import connect @@ -490,14 +489,14 @@ async def update_me(request): raise User.DoesNotExist payload = PayloadBuilder().SET(real_name=real_name.strip()).WHERE(['id', '=', result['rows'][0]['user_id']]).payload() - message = "Something went wrong" + message = "Something went wrong." try: result = await storage_client.update_tbl("users", payload) if result['response'] == 'updated': # TODO: FOGL-1226 At the moment only real name can update message = "Real name has been updated successfully!" except User.DoesNotExist: - msg = "User does not exist" + msg = "User does not exist." raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except ValueError as err: msg = str(err) @@ -526,7 +525,7 @@ async def update_user(request): user_id = request.match_info.get('user_id') if int(user_id) == 1: - msg = "Restricted for Super Admin user" + msg = "Restricted for Super Admin user." _logger.warning(msg) raise web.HTTPNotAcceptable(reason=msg, body=json.dumps({"message": msg})) @@ -591,32 +590,32 @@ async def update_password(request): try: int(user_id) except ValueError: - msg = "User id should be in integer" + msg = "User id should be in integer." raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) data = await request.json() current_password = data.get('current_password') new_password = data.get('new_password') if not current_password or not new_password: - msg = "Current or new password is missing" - _logger.warning(msg) + msg = "Current or new password is missing." + _logger.error(msg) raise web.HTTPBadRequest(reason=msg) if new_password and not isinstance(new_password, str): - _logger.warning(PASSWORD_ERROR_MSG) + _logger.error(PASSWORD_ERROR_MSG) raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG) if new_password and not re.match(PASSWORD_REGEX_PATTERN, new_password): - _logger.warning(PASSWORD_ERROR_MSG) + _logger.error(PASSWORD_ERROR_MSG) raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG) if current_password == new_password: - msg = "New password should not be same as current password" - _logger.warning(msg) + msg = "New password should not be same as current password." + _logger.error(msg) raise web.HTTPBadRequest(reason=msg) user_id = await User.Objects.is_user_exists(user_id, current_password) if not user_id: - msg = 'Invalid current password' + msg = 'Invalid current password.' _logger.warning(msg) raise web.HTTPNotFound(reason=msg) @@ -627,23 +626,23 @@ async def update_password(request): __remove_ott_for_user(user_id) except ValueError as ex: - _logger.warning(str(ex)) + _logger.error(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) except User.DoesNotExist: - msg = "User with id:<{}> does not exist".format(int(user_id)) - _logger.warning(msg) + msg = "User with id:<{}> does not exist.".format(int(user_id)) + _logger.error(msg) raise web.HTTPNotFound(reason=msg) except User.PasswordAlreadyUsed: - msg = "The new password should be different from previous 3 used" + msg = "The new password should be different from previous 3 used." _logger.warning(msg) raise web.HTTPBadRequest(reason=msg) except Exception as exc: _logger.exception(str(exc)) raise web.HTTPInternalServerError(reason=str(exc)) - _logger.info("Password has been updated successfully for user id:<{}>".format(int(user_id))) - - return web.json_response({'message': 'Password has been updated successfully for user id:<{}>'.format(int(user_id))}) + msg = "Password has been updated successfully for user id:<{}>.".format(int(user_id)) + _logger.info(msg) + return web.json_response({'message': msg}) @has_permission("admin") @@ -659,7 +658,7 @@ async def enable_user(request): user_id = request.match_info.get('user_id') if int(user_id) == 1: - msg = "Restricted for Super Admin user" + msg = "Restricted for Super Admin user." _logger.warning(msg) raise web.HTTPNotAcceptable(reason=msg, body=json.dumps({"message": msg})) @@ -689,16 +688,16 @@ async def enable_user(request): if len(result['rows']) == 0: raise User.DoesNotExist else: - raise ValueError('Something went wrong during update. Check Syslogs') + raise ValueError('Something went wrong during update. Check Syslogs.') else: - raise ValueError('Accepted values are True/False only') + raise ValueError('Accepted values are True/False only.') else: - raise ValueError('Nothing to enable user update') + raise ValueError('Nothing to enable user update.') except ValueError as err: msg = str(err) raise web.HTTPBadRequest(reason=str(err), body=json.dumps({"message": msg})) except User.DoesNotExist: - msg = "User with id:<{}> does not exist".format(int(user_id)) + msg = "User with id:<{}> does not exist.".format(int(user_id)) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) @@ -720,7 +719,7 @@ async def reset(request): user_id = request.match_info.get('user_id') if int(user_id) == 1: - msg = "Restricted for Super Admin user" + msg = "Restricted for Super Admin user." _logger.warning(msg) raise web.HTTPNotAcceptable(reason=msg) @@ -729,20 +728,20 @@ async def reset(request): role_id = data.get('role_id') if not role_id and not password: - msg = "Nothing to update the user" + msg = "Nothing to update the user." _logger.warning(msg) raise web.HTTPBadRequest(reason=msg) if role_id and not (await is_valid_role(role_id)): - msg = "Invalid or bad role id" - _logger.warning(msg) + msg = "Invalid or bad role id." + _logger.error(msg) return web.HTTPBadRequest(reason=msg) if password and not isinstance(password, str): - _logger.warning(PASSWORD_ERROR_MSG) + _logger.error(PASSWORD_ERROR_MSG) raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG) if password and not re.match(PASSWORD_REGEX_PATTERN, password): - _logger.warning(PASSWORD_ERROR_MSG) + _logger.error(PASSWORD_ERROR_MSG) raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG) user_data = {} @@ -758,23 +757,23 @@ async def reset(request): __remove_ott_for_user(user_id) except ValueError as ex: - _logger.warning(str(ex)) + _logger.error(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) except User.DoesNotExist: - msg = "User with id:<{}> does not exist".format(int(user_id)) - _logger.warning(msg) + msg = "User with id:<{}> does not exist.".format(int(user_id)) + _logger.error(msg) raise web.HTTPNotFound(reason=msg) except User.PasswordAlreadyUsed: - msg = "The new password should be different from previous 3 used" + msg = "The new password should be different from previous 3 used." _logger.warning(msg) raise web.HTTPBadRequest(reason=msg) except Exception as exc: _logger.exception(str(exc)) raise web.HTTPInternalServerError(reason=str(exc)) - _logger.info("User with id:<{}> has been updated successfully".format(int(user_id))) - - return web.json_response({'message': 'User with id:<{}> has been updated successfully'.format(user_id)}) + msg = "User with id:<{}> has been updated successfully.".format(int(user_id)) + _logger.info(msg) + return web.json_response({'message': msg}) @has_permission("admin") @@ -792,17 +791,17 @@ async def delete_user(request): try: user_id = int(request.match_info.get('user_id')) except ValueError as ex: - _logger.warning(str(ex)) + _logger.error(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) if user_id == 1: - msg = "Super admin user can not be deleted" + msg = "Super admin user can not be deleted." _logger.warning(msg) raise web.HTTPNotAcceptable(reason=msg) # Requester should not be able to delete her/himself if user_id == request.user["id"]: - msg = "You can not delete your own account" + msg = "You can not delete your own account." _logger.warning(msg) raise web.HTTPBadRequest(reason=msg) @@ -815,11 +814,11 @@ async def delete_user(request): __remove_ott_for_user(user_id) except ValueError as ex: - _logger.warning(str(ex)) + _logger.error(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) except User.DoesNotExist: - msg = "User with id:<{}> does not exist".format(int(user_id)) - _logger.warning(msg) + msg = "User with id:<{}> does not exist.".format(int(user_id)) + _logger.error(msg) raise web.HTTPNotFound(reason=msg) except Exception as exc: _logger.exception(str(exc)) @@ -827,7 +826,7 @@ async def delete_user(request): _logger.info("User with id:<{}> has been deleted successfully.".format(int(user_id))) - return web.json_response({'message': "User has been deleted successfully"}) + return web.json_response({'message': "User has been deleted successfully."}) async def is_valid_role(role_id): diff --git a/python/fledge/services/core/api/backup_restore.py b/python/fledge/services/core/api/backup_restore.py index c712d09db2..b271c2670b 100644 --- a/python/fledge/services/core/api/backup_restore.py +++ b/python/fledge/services/core/api/backup_restore.py @@ -5,7 +5,6 @@ # FLEDGE_END """Backup and Restore Rest API support""" -import logging import os import sys import tarfile @@ -15,9 +14,10 @@ from enum import IntEnum from collections import OrderedDict -from fledge.common import logger -from fledge.common.audit_logger import AuditLogger from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA +from fledge.common.audit_logger import AuditLogger +from fledge.common.logger import FLCoreLogger + from fledge.common.storage_client import payload_builder from fledge.plugins.storage.common import exceptions from fledge.services.core import connect @@ -47,7 +47,7 @@ ----------------------------------------------------------------------------------- """ -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) class Status(IntEnum): diff --git a/python/fledge/services/core/api/browser.py b/python/fledge/services/core/api/browser.py index 56f49ffa0d..c1d5635aed 100644 --- a/python/fledge/services/core/api/browser.py +++ b/python/fledge/services/core/api/browser.py @@ -43,11 +43,11 @@ from aiohttp import web +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect -from fledge.common import logger -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) __author__ = "Mark Riddoch, Ashish Jabble, Massimiliano Pinto" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -849,11 +849,11 @@ async def asset_purge_all(request): curl -sX DELETE http://localhost:8081/fledge/asset """ try: - from fledge.common.audit_logger import AuditLogger + _logger.warning("Manual purge of all assets has been requested.") # Call storage service - _logger.warning("Manual purge of all assets has been requested") _readings = connect.get_readings_async() # Get AuditLogger + from fledge.common.audit_logger import AuditLogger _audit = AuditLogger(_readings) start_time = time.strftime('%Y-%m-%d %H:%M:%S.%s', time.localtime(time.time())) @@ -886,13 +886,13 @@ async def asset_purge(request): curl -sX DELETE http://localhost:8081/fledge/asset/fogbench_humidity """ asset_code = request.match_info.get('asset_code', '') - _logger.warning("Manual purge of '%s' asset has been requested", asset_code) + _logger.warning("Manual purge of '{}' asset has been requested.".format(asset_code)) try: - from fledge.common.audit_logger import AuditLogger # Call storage service _readings = connect.get_readings_async() # Get AuditLogger + from fledge.common.audit_logger import AuditLogger _audit = AuditLogger(_readings) start_time = time.strftime('%Y-%m-%d %H:%M:%S.%s', time.localtime(time.time())) diff --git a/python/fledge/services/core/api/certificate_store.py b/python/fledge/services/core/api/certificate_store.py index 4fe92df1ca..5c1d840c64 100644 --- a/python/fledge/services/core/api/certificate_store.py +++ b/python/fledge/services/core/api/certificate_store.py @@ -9,11 +9,11 @@ from aiohttp import web -from fledge.common import logger +from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.web.middleware import has_permission from fledge.services.core import connect -from fledge.common.configuration_manager import ConfigurationManager -from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -27,8 +27,8 @@ | DELETE | /fledge/certificate/{name} | ------------------------------------------------------------------------------- """ -FORBIDDEN_MSG = 'Resource you were trying to reach is absolutely forbidden for some reason' -_logger = logger.setup(__name__) +FORBIDDEN_MSG = 'Resource you were trying to reach is absolutely forbidden for some reason.' +_logger = FLCoreLogger().get_logger(__name__) async def get_certs(request): diff --git a/python/fledge/services/core/api/configuration.py b/python/fledge/services/core/api/configuration.py index 3835bf2622..9ab5e94dc5 100644 --- a/python/fledge/services/core/api/configuration.py +++ b/python/fledge/services/core/api/configuration.py @@ -12,10 +12,10 @@ from typing import Dict from aiohttp import web -from fledge.common import logger from fledge.common.audit_logger import AuditLogger from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA from fledge.common.configuration_manager import ConfigurationManager, _optional_items +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect @@ -39,7 +39,7 @@ """ script_dir = _FLEDGE_DATA + '/scripts/' if _FLEDGE_DATA else _FLEDGE_ROOT + "/data/scripts/" -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) ################################# # Configuration Manager @@ -327,8 +327,6 @@ async def update_configuration_item_bulk(request): WHEN: if non-admin user is trying to update THEN: 403 Forbidden case """ - - if hasattr(request, "user"): config_items = [k for k, v in data.items() if k == 'authentication'] if request.user and (category_name == 'rest_api' and config_items): diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index 87cdc9a62f..ecbad2a902 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -5,18 +5,16 @@ # FLEDGE_END import json -import logging from aiohttp import web - -from fledge.common import logger +from fledge.common.acl_manager import ACLManager from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.web.middleware import has_permission from fledge.services.core import connect from fledge.services.core.api.control_service.exceptions import * -from fledge.common.acl_manager import ACLManager __author__ = "Ashish Jabble, Massimiliano Pinto" @@ -33,7 +31,7 @@ -------------------------------------------------------------- """ -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) async def get_all_acls(request: web.Request) -> web.Response: diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index 2b4c46b445..b31c60a70d 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -5,22 +5,20 @@ # FLEDGE_END import json -import logging import datetime import uuid from aiohttp import web -from fledge.common import logger +from fledge.common.acl_manager import ACLManager from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.web.middleware import has_permission -from fledge.services.core import connect -from fledge.services.core import server +from fledge.services.core import connect, server from fledge.services.core.scheduler.entities import Schedule, ManualSchedule from fledge.services.core.api.control_service.exceptions import * -from fledge.common.acl_manager import ACLManager __author__ = "Ashish Jabble" @@ -36,7 +34,7 @@ ----------------------------------------------------------------------- """ -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) def setup(app): diff --git a/python/fledge/services/core/api/filters.py b/python/fledge/services/core/api/filters.py index 1506a838b9..4a011ac5b0 100644 --- a/python/fledge/services/core/api/filters.py +++ b/python/fledge/services/core/api/filters.py @@ -10,14 +10,16 @@ from aiohttp import web from typing import List, Dict, Tuple +from fledge.common import utils +from fledge.common.common import _FLEDGE_ROOT from fledge.common.configuration_manager import ConfigurationManager -from fledge.services.core import connect -from fledge.services.core.api import utils as apiutils -from fledge.common import logger, utils +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.storage_client import StorageClientAsync -from fledge.common.common import _FLEDGE_ROOT + +from fledge.services.core import connect +from fledge.services.core.api import utils as apiutils from fledge.services.core.api.plugins import common __author__ = "Massimiliano Pinto, Amarendra K Sinha" @@ -32,8 +34,7 @@ | GET DELETE | /fledge/filter/{filter_name} | --------------------------------------------------------------------------- """ - -_LOGGER = logger.setup("filter") +_LOGGER = FLCoreLogger().get_logger(__name__) async def create_filter(request: web.Request) -> web.Response: @@ -128,22 +129,25 @@ async def create_filter(request: web.Request) -> web.Response: # Fetch the new created filter: get category items category_info = await cf_mgr.get_category_all_items(category_name=filter_name) if category_info is None: - raise ValueError("No such '{}' filter found".format(filter_name)) + raise ValueError("No such '{}' filter found.".format(filter_name)) else: return web.json_response({'filter': filter_name, 'description': filter_desc, 'value': category_info}) - except ValueError as ex: - _LOGGER.exception("Add filter, caught exception: " + str(ex)) - raise web.HTTPNotFound(reason=str(ex)) - except TypeError as ex: - _LOGGER.exception("Add filter, caught exception: " + str(ex)) - raise web.HTTPBadRequest(reason=str(ex)) + except ValueError as err: + msg = str(err) + _LOGGER.error("Add filter, caught value error exception: {}".format(msg)) + raise web.HTTPNotFound(reason=msg) + except TypeError as err: + msg = str(err) + _LOGGER.error("Add filter, caught type error exception: {}".format(msg)) + raise web.HTTPBadRequest(reason=msg) except StorageServerError as ex: await _delete_configuration_category(storage, filter_name) # Revert configuration entry - _LOGGER.exception("Failed to create filter. %s", ex.error) + _LOGGER.exception("Failed to create filter with: {}".format(ex.error)) raise web.HTTPInternalServerError(reason='Failed to create filter.') except Exception as ex: - _LOGGER.exception("Add filter, caught exception: %s", str(ex)) - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _LOGGER.exception("Add filter, caught exception: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg) async def add_filters_pipeline(request: web.Request) -> web.Response: @@ -267,18 +271,22 @@ async def add_filters_pipeline(request: web.Request) -> web.Response: await cf_mgr.create_child_category(user_name, filter_list) return web.json_response( {'result': "Filter pipeline {} updated successfully".format(json.loads(result['value']))}) - except ValueError as ex: - _LOGGER.exception("Add filters pipeline, caught exception: %s", str(ex)) - raise web.HTTPNotFound(reason=str(ex)) - except TypeError as ex: - _LOGGER.exception("Add filters pipeline, caught exception: %s", str(ex)) - raise web.HTTPBadRequest(reason=ex) + except ValueError as err: + msg = str(err) + _LOGGER.error("Add filters pipeline, caught value error: {}".format(msg)) + raise web.HTTPNotFound(reason=msg) + except TypeError as err: + msg = str(err) + _LOGGER.error("Add filters pipeline, caught type error: {}".format(msg)) + raise web.HTTPBadRequest(reason=msg) except StorageServerError as ex: - _LOGGER.exception("Add filters pipeline, caught exception: %s", str(ex.error)) - raise web.HTTPInternalServerError(reason=str(ex.error)) + msg = str(ex.error) + _LOGGER.error("Add filters pipeline, caught storage error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg) except Exception as ex: - _LOGGER.exception("Add filters pipeline, caught exception: %s", str(ex)) - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _LOGGER.exception("Add filters pipeline, caught exception: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg) async def get_filter(request: web.Request) -> web.Response: @@ -315,14 +323,14 @@ async def get_filter(request: web.Request) -> web.Response: users.append(row["user"]) filter_detail.update({"users": users}) except StorageServerError as ex: - _LOGGER.exception("Get filter: %s, caught exception: %s", filter_name, str(ex.error)) + _LOGGER.error("Get {} filter, caught exception: {}".format(filter_name, str(ex.error))) raise web.HTTPInternalServerError(reason=str(ex.error)) - except ValueError as ex: - raise web.HTTPNotFound(reason=ex) - except TypeError as ex: - raise web.HTTPBadRequest(reason=ex) + except ValueError as err: + raise web.HTTPNotFound(reason=str(err)) + except TypeError as err: + raise web.HTTPBadRequest(reason=str(err)) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + raise web.HTTPInternalServerError(reason=str(ex)) else: return web.json_response({'filter': filter_detail}) @@ -338,10 +346,10 @@ async def get_filters(request: web.Request) -> web.Response: result = await storage.query_tbl("filters") filters = result["rows"] except StorageServerError as ex: - _LOGGER.exception("Get filters, caught exception: %s", str(ex.error)) + _LOGGER.error("Get all filters, caught exception: {}".format(str(ex.error))) raise web.HTTPInternalServerError(reason=str(ex.error)) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + raise web.HTTPInternalServerError(reason=str(ex)) else: return web.json_response({'filters': filters}) @@ -364,16 +372,16 @@ async def get_filter_pipeline(request: web.Request) -> web.Response: filter_value_from_storage = json.loads(category_info['filter']['value']) except KeyError: - msg = "No filter pipeline exists for {}".format(user_name) - _LOGGER.info(msg) + msg = "No filter pipeline exists for {}.".format(user_name) + _LOGGER.error(msg) raise web.HTTPNotFound(reason=msg) except StorageServerError as ex: - _LOGGER.exception("Get pipeline: %s, caught exception: %s", user_name, str(ex.error)) + _LOGGER.exception("Get {} filter pipeline, caught exception: {}".format(user_name, str(ex.error))) raise web.HTTPInternalServerError(reason=str(ex.error)) - except ValueError as ex: - raise web.HTTPNotFound(reason=ex) + except ValueError as err: + raise web.HTTPNotFound(reason=str(err)) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + raise web.HTTPInternalServerError(reason=str(ex)) else: return web.json_response({'result': filter_value_from_storage}) @@ -427,16 +435,16 @@ async def delete_filter(request: web.Request) -> web.Response: ['plugin', '=', filter_name]).payload() await storage.update_tbl("asset_tracker", update_payload) except StorageServerError as ex: - _LOGGER.exception("Delete filter: %s, caught exception: %s", filter_name, str(ex.error)) + _LOGGER.exception("Delete {} filter, caught exception: {}".format(filter_name, str(ex.error))) raise web.HTTPInternalServerError(reason=str(ex.error)) - except ValueError as ex: - raise web.HTTPNotFound(reason=ex) - except TypeError as ex: - raise web.HTTPBadRequest(reason=ex) + except ValueError as err: + raise web.HTTPNotFound(reason=str(err)) + except TypeError as err: + raise web.HTTPBadRequest(reason=str(err)) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + raise web.HTTPInternalServerError(reason=str(ex)) else: - return web.json_response({'result': "Filter {} deleted successfully".format(filter_name)}) + return web.json_response({'result': "Filter {} deleted successfully.".format(filter_name)}) async def delete_filter_pipeline(request: web.Request) -> web.Response: @@ -454,8 +462,8 @@ async def delete_filter_pipeline(request: web.Request) -> web.Response: status_code = resp.status jdoc = await resp.text() if status_code not in range(200, 209): - _LOGGER.error("Error code: %d, reason: %s, details: %s, url: %s", resp.status, resp.reason, jdoc, - put_url) + _LOGGER.error("Delete {} filter pipeline; Error code: {}, reason: {}, details: {}, url: {}" + "".format(user_name, resp.status, resp.reason, jdoc, put_url)) raise StorageServerError(code=resp.status, reason=resp.reason, error=jdoc) except Exception: raise diff --git a/python/fledge/services/core/api/health.py b/python/fledge/services/core/api/health.py index 08f0267217..71668c6f9e 100644 --- a/python/fledge/services/core/api/health.py +++ b/python/fledge/services/core/api/health.py @@ -4,13 +4,12 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END -import logging import asyncio import json from aiohttp import web -from fledge.common import logger from fledge.common.common import _FLEDGE_DATA, _FLEDGE_ROOT +from fledge.common.logger import FLCoreLogger __author__ = "Deepanshu Yadav" @@ -24,7 +23,7 @@ | GET | /fledge/health/logging | ---------------------------------------------------------- """ -_LOGGER = logger.setup(__name__, level=logging.INFO) +_LOGGER = FLCoreLogger().get_logger(__name__) async def get_disk_usage(given_dir): @@ -39,7 +38,7 @@ async def get_disk_usage(given_dir): stdout, stderr = await disk_check_process.communicate() if disk_check_process.returncode != 0: stderr = stderr.decode("utf-8") - msg = "Failed to get disk stats! {}".format(str(stderr)) + msg = "Failed to get disk stats of {} directory due to {}".format(given_dir, str(stderr)) _LOGGER.error(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -109,8 +108,8 @@ async def get_logging_health(request: web.Request) -> web.Response: response["levels"] = log_levels except Exception as ex: - msg = "Could not fetch service information.{}".format(str(ex)) - _LOGGER.error(msg) + msg = "Could not fetch service information due to {}".format(str(ex)) + _LOGGER.exception(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) try: @@ -122,8 +121,8 @@ async def get_logging_health(request: web.Request) -> web.Response: response['disk']['available'] = available except Exception as ex: - msg = "Failed to get disk stats for /var/log !{}".format(str(ex)) - _LOGGER.error(msg) + msg = "Failed to get disk stats for /var/log due to {}".format(str(ex)) + _LOGGER.exception(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -189,7 +188,7 @@ async def get_storage_health(request: web.Request) -> web.Response: except Exception as ex: msg = str(ex) - _LOGGER.error("Could not ping Storage due to {}".format(msg)) + _LOGGER.error("Could not ping Storage due to {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) try: @@ -208,8 +207,8 @@ async def get_storage_health(request: web.Request) -> web.Response: response['disk']['available'] = available response['disk']['status'] = status except Exception as ex: - msg = "Failed to get disk stats! {}".format(str(ex)) - _LOGGER.error(msg) + msg = "Failed to get disk stats for storage service due to {}".format(str(ex)) + _LOGGER.exception(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) diff --git a/python/fledge/services/core/api/north.py b/python/fledge/services/core/api/north.py index e0b7eb7621..94b583fd29 100644 --- a/python/fledge/services/core/api/north.py +++ b/python/fledge/services/core/api/north.py @@ -8,16 +8,15 @@ from functools import lru_cache from aiohttp import web -from fledge.services.core import server from fledge.common.configuration_manager import ConfigurationManager -from fledge.common.storage_client.payload_builder import PayloadBuilder +from fledge.common.logger import FLCoreLogger from fledge.common.plugin_discovery import PluginDiscovery -from fledge.services.core import connect -from fledge.services.core.scheduler.entities import Task from fledge.common.service_record import ServiceRecord +from fledge.common.storage_client.payload_builder import PayloadBuilder +from fledge.services.core import connect, server +from fledge.services.core.scheduler.entities import Task from fledge.services.core.service_registry.service_registry import ServiceRegistry from fledge.services.core.service_registry.exceptions import DoesNotExist -from fledge.common import logger __author__ = "Praveen Garg" __copyright__ = "Copyright (c) 2018 OSIsoft, LLC" @@ -29,7 +28,7 @@ | GET | /fledge/north | ------------------------------------------------------------------------------- """ -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) async def _get_sent_stats(storage_client, north_schedules): diff --git a/python/fledge/services/core/api/notification.py b/python/fledge/services/core/api/notification.py index 04c3da7bb7..515c0acffc 100644 --- a/python/fledge/services/core/api/notification.py +++ b/python/fledge/services/core/api/notification.py @@ -10,14 +10,14 @@ from aiohttp import web from fledge.common import utils -from fledge.common import logger +from fledge.common.audit_logger import AuditLogger +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.service_record import ServiceRecord from fledge.common.storage_client.exceptions import StorageServerError -from fledge.common.configuration_manager import ConfigurationManager from fledge.services.core import connect from fledge.services.core.service_registry.service_registry import ServiceRegistry from fledge.services.core.service_registry import exceptions as service_registry_exceptions -from fledge.common.audit_logger import AuditLogger __author__ = "Amarendra K Sinha" __copyright__ = "Copyright (c) 2018 Dianomic Systems" @@ -32,8 +32,8 @@ | GET DELETE | /fledge/notification/{notification_name}/delivery/{channel_name} | ----------------------------------------------------------------------------------------------------- """ +_logger = FLCoreLogger().get_logger(__name__) -_logger = logger.setup() NOTIFICATION_TYPE = ["one shot", "retriggered", "toggled"] @@ -488,8 +488,8 @@ async def _hit_get_url(get_url, token=None): status_code = resp.status jdoc = await resp.text() if status_code not in range(200, 209): - _logger.error("Error code: %d, reason: %s, details: %s, url: %s", resp.status, resp.reason, jdoc, - get_url) + _logger.error("Error code: {}, reason: {}, details: {}, url: {}".format( + resp.status, resp.reason, jdoc, get_url)) raise StorageServerError(code=resp.status, reason=resp.reason, error=jdoc) except Exception: raise @@ -504,8 +504,8 @@ async def _hit_post_url(post_url, data=None): status_code = resp.status jdoc = await resp.text() if status_code not in range(200, 209): - _logger.error("Error code: %d, reason: %s, details: %s, url: %s", resp.status, resp.reason, jdoc, - post_url) + _logger.error("Error code: {}, reason: {}, details: {}, url: {}".format( + resp.status, resp.reason, jdoc, post_url)) raise StorageServerError(code=resp.status, reason=resp.reason, error=jdoc) except Exception: raise @@ -527,8 +527,9 @@ async def _update_configurations(config_mgr, name, notification_config, rule_con category_name = "delivery{}".format(name) await config_mgr.update_configuration_item_bulk(category_name, delivery_config) except Exception as ex: - _logger.exception("Failed to update notification configuration. %s", str(ex)) - raise web.HTTPInternalServerError(reason='Failed to update notification configuration. {}'.format(ex)) + msg = "Failed to update notification configuration due to {}".format(str(ex)) + _logger.exception(msg) + raise web.HTTPInternalServerError(reason=msg) async def _hit_delete_url(delete_url, data=None): @@ -538,11 +539,8 @@ async def _hit_delete_url(delete_url, data=None): status_code = resp.status jdoc = await resp.text() if status_code not in range(200, 209): - _logger.error("Error code: %d, reason: %s, details: %s, url: %s", - resp.status, - resp.reason, - jdoc, - delete_url) + _logger.error("Error code: {}, reason: {}, details: {}, url: {}".format( + resp.status, resp.reason, jdoc, delete_url)) raise StorageServerError(code=resp.status, reason=resp.reason, error=jdoc) diff --git a/python/fledge/services/core/api/package_log.py b/python/fledge/services/core/api/package_log.py index 78f17c8db4..8f182f1904 100644 --- a/python/fledge/services/core/api/package_log.py +++ b/python/fledge/services/core/api/package_log.py @@ -5,7 +5,6 @@ # FLEDGE_END import os -import logging import json from datetime import datetime @@ -13,7 +12,7 @@ from aiohttp import web from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA -from fledge.common import logger +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect @@ -31,7 +30,7 @@ """ valid_extension = '.log' valid_actions = ('list', 'install', 'purge', 'update') -_LOGGER = logger.setup(__name__, level=logging.INFO) +_LOGGER = FLCoreLogger().get_logger(__name__) async def get_logs(request: web.Request) -> web.Response: @@ -136,10 +135,12 @@ async def get_package_status(request: web.Request) -> web.Response: tmp['logFileURI'] = r['log_file_uri'] del tmp['log_file_uri'] result.append(tmp) - except ValueError as err_msg: - raise web.HTTPBadRequest(reason=err_msg, body=json.dumps({"message": str(err_msg)})) - except KeyError as err_msg: - raise web.HTTPNotFound(reason=err_msg, body=json.dumps({"message": str(err_msg)})) + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: raise web.HTTPInternalServerError(reason=str(exc)) else: diff --git a/python/fledge/services/core/api/plugins/common.py b/python/fledge/services/core/api/plugins/common.py index 149b4e54bc..4a7be9165c 100644 --- a/python/fledge/services/core/api/plugins/common.py +++ b/python/fledge/services/core/api/plugins/common.py @@ -7,7 +7,6 @@ """Common Definitions""" import sys import types -import logging import os import json import glob @@ -16,8 +15,9 @@ from datetime import datetime from functools import lru_cache -from fledge.common import logger, utils as common_utils +from fledge.common import utils as common_utils from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA, _FLEDGE_PLUGIN_PATH +from fledge.common.logger import FLCoreLogger from fledge.services.core.api import utils from fledge.services.core.api.plugins.exceptions import * @@ -27,7 +27,7 @@ __version__ = "${VERSION}" -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) _NO_OF_FILES_TO_RETAIN = 10 @@ -135,9 +135,11 @@ def load_and_fetch_c_hybrid_plugin_info(plugin_name: str, is_config: bool, plugi if is_config: plugin_info.update({'config': temp}) else: - _logger.warning("{} hybrid plugin is not installed which is required for {}".format(connection_name, plugin_name)) + _logger.warning("{} hybrid plugin is not installed which is required for {}".format( + connection_name, plugin_name)) else: - _logger.warning("{} hybrid plugin is not installed which is required for {}".format(connection_name, plugin_name)) + _logger.warning("{} hybrid plugin is not installed which is required for {}".format( + connection_name, plugin_name)) else: raise Exception('Required {} keys are missing for json file'.format(json_file_keys)) return plugin_info @@ -182,7 +184,7 @@ async def fetch_available_packages(package_type: str = "") -> tuple: # If max update per day is set to 1, then an update can not occurs until 24 hours after the last accessed update. # If set to 2 then this drops to 12 hours between updates, 3 would result in 8 hours between calls and so on. if duration_in_sec > (24 / int(max_update_cat_item['value'])) * 60 * 60 or not last_accessed_time: - _logger.info("Attempting update on {}".format(now)) + _logger.info("Attempting update on {}...".format(now)) cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) if pkg_mgt == 'yum': cmd = "sudo {} check-update > {} 2>&1".format(pkg_mgt, stdout_file_path) @@ -192,7 +194,7 @@ async def fetch_available_packages(package_type: str = "") -> tuple: # fetch available package caching always clear on every update request _get_available_packages.cache_clear() else: - _logger.warning("Maximum update exceeds the limit for the day") + _logger.warning("Maximum update exceeds the limit for the day.") ttl_cat_item_val = int(category['listAvailablePackagesCacheTTL']['value']) if ttl_cat_item_val > 0: last_accessed_time = pkg_cache_mgr['list']['last_accessed_time'] diff --git a/python/fledge/services/core/api/plugins/data.py b/python/fledge/services/core/api/plugins/data.py index ba0c632367..fae669db61 100644 --- a/python/fledge/services/core/api/plugins/data.py +++ b/python/fledge/services/core/api/plugins/data.py @@ -4,12 +4,11 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END -import logging import json import urllib.parse from aiohttp import web -from fledge.common import logger +from fledge.common.logger import FLCoreLogger from fledge.common.plugin_discovery import PluginDiscovery from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.plugins.common import utils as common_utils @@ -29,7 +28,7 @@ --------------------------------------------------------------------------------------- """ FORBIDDEN_MSG = "Resource you were trying to reach is absolutely forbidden!" -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) async def get_persist_plugins(request: web.Request) -> web.Response: diff --git a/python/fledge/services/core/api/plugins/discovery.py b/python/fledge/services/core/api/plugins/discovery.py index c997dfd990..82b92cbffa 100644 --- a/python/fledge/services/core/api/plugins/discovery.py +++ b/python/fledge/services/core/api/plugins/discovery.py @@ -4,13 +4,13 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END -import logging import json from aiohttp import web + +from fledge.common.logger import FLCoreLogger from fledge.common.plugin_discovery import PluginDiscovery from fledge.services.core.api.plugins import common -from fledge.common import logger from fledge.services.core.api.plugins.exceptions import * __author__ = "Amarendra K Sinha, Ashish Jabble" @@ -25,7 +25,8 @@ | GET | /fledge/plugins/available | ------------------------------------------------------------------------------- """ -_logger = logger.setup(__name__, level=logging.INFO) + +_logger = FLCoreLogger().get_logger(__name__) async def get_plugins_installed(request): diff --git a/python/fledge/services/core/api/plugins/install.py b/python/fledge/services/core/api/plugins/install.py index f7d0593459..4407228715 100644 --- a/python/fledge/services/core/api/plugins/install.py +++ b/python/fledge/services/core/api/plugins/install.py @@ -6,7 +6,6 @@ import os import subprocess -import logging import asyncio import tarfile import hashlib @@ -20,15 +19,15 @@ from typing import Dict from datetime import datetime -from fledge.common import logger, utils -from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA +from fledge.common import utils from fledge.common.audit_logger import AuditLogger +from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.plugin_discovery import PluginDiscovery from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError -from fledge.services.core import connect -from fledge.services.core import server +from fledge.services.core import connect, server from fledge.services.core.api.plugins import common from fledge.services.core.api.plugins.exceptions import * @@ -46,7 +45,7 @@ _TIME_OUT = 120 _CHUNK_SIZE = 1024 _PATH = _FLEDGE_DATA + '/plugins/' if _FLEDGE_DATA else _FLEDGE_ROOT + '/data/plugins/' -_LOGGER = logger.setup(__name__, level=logging.INFO) +_LOGGER = FLCoreLogger().get_logger(__name__) async def add_plugin(request: web.Request) -> web.Response: @@ -228,15 +227,15 @@ def install_package(file_name: str, pkg_mgt: str) -> tuple: pkg_file_path = "/data/plugins/{}".format(file_name) stdout_file_path = "/data/plugins/output.txt" cmd = "sudo {} -y install {} > {} 2>&1".format(pkg_mgt, _FLEDGE_ROOT + pkg_file_path, _FLEDGE_ROOT + stdout_file_path) - _LOGGER.debug("CMD....{}".format(cmd)) + _LOGGER.debug("Install Package with command: {}".format(cmd)) ret_code = os.system(cmd) - _LOGGER.debug("Return Code....{}".format(ret_code)) + _LOGGER.debug("Package install return code: {}".format(ret_code)) msg = "" with open("{}".format(_FLEDGE_ROOT + stdout_file_path), 'r') as fh: for line in fh: line = line.rstrip("\n") msg += line - _LOGGER.debug("Message.....{}".format(msg)) + _LOGGER.debug("Package install message: {}".format(msg)) # Remove stdout file cmd = "{}/extras/C/cmdutil rm {}".format(_FLEDGE_ROOT, stdout_file_path) subprocess.run([cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) @@ -257,8 +256,9 @@ def copy_file_install_requirement(dir_files: list, plugin_type: str, file_name: if so_1_file: if not so_file: - _LOGGER.error("Symlink file is missing") - raise FileNotFoundError("Symlink file is missing") + err_msg = "Symlink file is missing." + _LOGGER.error(err_msg) + raise FileNotFoundError(err_msg) _dir = [] for s in dir_files: _dir.append(s.split("/")[-1]) @@ -325,7 +325,7 @@ def install_package_from_repo(name: str, pkg_mgt: str, version: str, uid: uuid, # If max upgrade per day is set to 1, then an upgrade can not occurs until 24 hours after the last accessed upgrade. # If set to 2 then this drops to 12 hours between upgrades, 3 would result in 8 hours between calls and so on. if duration_in_sec > (24 / int(max_upgrade_cat_item['value'])) * 60 * 60 or not last_accessed_time: - _LOGGER.info("Attempting upgrade on {}".format(now)) + _LOGGER.info("Attempting upgrade on {}...".format(now)) cmd = "sudo {} -y upgrade".format(pkg_mgt) if pkg_mgt == 'apt' else "sudo {} -y update".format(pkg_mgt) ret_code = os.system(cmd + " > {} 2>&1".format(stdout_file_path)) if ret_code != 0: @@ -336,7 +336,7 @@ def install_package_from_repo(name: str, pkg_mgt: str, version: str, uid: uuid, else: pkg_cache_mgr['upgrade']['last_accessed_time'] = now else: - _LOGGER.warning("Maximum upgrade exceeds the limit for the day") + _LOGGER.warning("Maximum upgrade exceeds the limit for the day.") msg = "updated" cmd = "sudo {} -y install {}".format(pkg_mgt, name) if version: @@ -352,7 +352,7 @@ def install_package_from_repo(name: str, pkg_mgt: str, version: str, uid: uuid, audit_detail = {'packageName': name} log_code = 'PKGUP' if msg == 'updated' else 'PKGIN' loop.run_until_complete(audit.information(log_code, audit_detail)) - _LOGGER.info('{} plugin {} successfully'.format(name, msg)) + _LOGGER.info('{} plugin {} successfully.'.format(name, msg)) async def check_upgrade_on_install() -> Dict: diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index b2b972cd9b..e15281ab6e 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -6,17 +6,17 @@ import aiohttp import os -import logging import json import asyncio import uuid import multiprocessing from aiohttp import web -from fledge.common import logger, utils +from fledge.common import utils from fledge.common.audit_logger import AuditLogger from fledge.common.common import _FLEDGE_ROOT from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.plugin_discovery import PluginDiscovery from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.payload_builder import PayloadBuilder @@ -36,8 +36,7 @@ ------------------------------------------------------------------------------- """ -_logger = logger.setup(__name__, level=logging.INFO) - +_logger = FLCoreLogger().get_logger(__name__) valid_plugin_types = ['north', 'south', 'filter', 'notify', 'rule'] PYTHON_PLUGIN_PATH = _FLEDGE_ROOT+'/python/fledge/plugins/' C_PLUGINS_PATH = _FLEDGE_ROOT+'/plugins/' @@ -75,7 +74,7 @@ async def remove_plugin(request: web.Request) -> web.Response: if plugin_type in ['notify', 'rule']: notification_instances_plugin_used_in = await _check_plugin_usage_in_notification_instances(name) if notification_instances_plugin_used_in: - err_msg = "{} cannot be removed. This is being used by {} instances".format( + err_msg = "{} cannot be removed. This is being used by {} instances.".format( name, notification_instances_plugin_used_in) _logger.error(err_msg) raise RuntimeError(err_msg) @@ -88,7 +87,7 @@ async def remove_plugin(request: web.Request) -> web.Response: raise RuntimeError(e) else: _logger.info("No entry found for {name} plugin in asset tracker; or " - "{name} plugin may have been added in disabled state & never used".format(name=name)) + "{name} plugin may have been added in disabled state & never used.".format(name=name)) # Check Pre-conditions from Packages table # if status is -1 (Already in progress) then return as rejected request action = 'purge' @@ -220,11 +219,11 @@ async def _put_refresh_cache(protocol: str, host: int, port: int) -> None: result = await resp.text() status_code = resp.status if status_code in range(400, 500): - _logger.error("Bad request error code: %d, reason: %s when refresh cache", status_code, resp.reason) + _logger.error("Bad request error code: {}, reason: {} when refresh cache".format(status_code, resp.reason)) if status_code in range(500, 600): - _logger.error("Server error code: %d, reason: %s when refresh cache", status_code, resp.reason) + _logger.error("Server error code: {}, reason: {} when refresh cache".format(status_code, resp.reason)) response = json.loads(result) - _logger.debug("PUT Refresh Cache response: %s", response) + _logger.debug("PUT Refresh Cache response: {}".format(response)) def purge_plugin(plugin_type: str, name: str, uid: uuid, storage: connect) -> tuple: @@ -281,7 +280,7 @@ def purge_plugin(plugin_type: str, name: str, uid: uuid, storage: connect) -> tu audit = AuditLogger(storage) audit_detail = {'package_name': "fledge-{}-{}".format(plugin_type, name)} loop.run_until_complete(audit.information('PKGRM', audit_detail)) - _logger.info('{} plugin purged successfully'.format(name)) + _logger.info('{} plugin purged successfully.'.format(name)) except KeyError: # This case is for non-package installation - python plugin path will be tried first and then C _logger.info("Trying removal of manually installed plugin...") diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index c88919aa68..83bae49f91 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -7,20 +7,19 @@ import aiohttp import asyncio import os -import logging import uuid import multiprocessing import json from aiohttp import web -from fledge.common import logger, utils +from fledge.common import utils from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.plugin_discovery import PluginDiscovery from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.payload_builder import PayloadBuilder -from fledge.services.core import connect -from fledge.services.core import server +from fledge.services.core import connect, server from fledge.services.core.api.plugins import common @@ -34,7 +33,7 @@ | PUT | /fledge/plugin/{type}/{name}/update | ------------------------------------------------------------------------------- """ -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) async def update_plugin(request: web.Request) -> web.Response: @@ -200,11 +199,11 @@ async def _put_schedule(protocol: str, host: str, port: int, sch_id: uuid, is_en result = await resp.text() status_code = resp.status if status_code in range(400, 500): - _logger.error("Bad request error code: %d, reason: %s when PUT schedule", status_code, resp.reason) + _logger.error("Bad request error code: {}, reason: {} when PUT schedule".format(status_code, resp.reason)) if status_code in range(500, 600): - _logger.error("Server error code: %d, reason: %s when PUT schedule", status_code, resp.reason) + _logger.error("Server error code: {}, reason: {} when PUT schedule".format(status_code, resp.reason)) response = json.loads(result) - _logger.debug("PUT Schedule response: %s", response) + _logger.debug("PUT Schedule response: {}".format(response)) def _update_repo_sources_and_plugin(_type: str, name: str) -> tuple: @@ -249,7 +248,7 @@ def do_update(http_enabled: bool, host: str, port: int, storage: connect, _type: audit = AuditLogger(storage) audit_detail = {'packageName': "fledge-{}-{}".format(_type, name.replace("_", "-"))} loop.run_until_complete(audit.information('PKGUP', audit_detail)) - _logger.info('{} plugin updated successfully'.format(name)) + _logger.info('{} plugin updated successfully.'.format(name)) # Restart the services which were disabled before plugin update for sch in schedules: diff --git a/python/fledge/services/core/api/python_packages.py b/python/fledge/services/core/api/python_packages.py index e834df349d..fcdff9ef34 100644 --- a/python/fledge/services/core/api/python_packages.py +++ b/python/fledge/services/core/api/python_packages.py @@ -4,15 +4,14 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END -import logging import asyncio import json from typing import List import pkg_resources from aiohttp import web -from fledge.common import logger from fledge.common.audit_logger import AuditLogger +from fledge.common.logger import FLCoreLogger from fledge.services.core import connect __author__ = "Himanshu Vimal" @@ -26,7 +25,7 @@ | POST | /fledge/python/package | ---------------------------------------------------------- """ -_LOGGER = logger.setup(__name__, level=logging.INFO) +_LOGGER = FLCoreLogger().get_logger(__name__) def get_packages_installed() -> List: @@ -95,7 +94,7 @@ def get_installed_package_info(input_package): stdout, stderr = await pip_process.communicate() if pip_process.returncode == 0: - _LOGGER.info("Package: {} successfully installed", format(input_package_name)) + _LOGGER.info("Package: {} successfully installed.", format(input_package_name)) try: # Audit log entry: PIPIN storage_client = connect.get_storage_async() diff --git a/python/fledge/services/core/api/repos/configure.py b/python/fledge/services/core/api/repos/configure.py index 13c885d9e3..6f4ef94eb0 100644 --- a/python/fledge/services/core/api/repos/configure.py +++ b/python/fledge/services/core/api/repos/configure.py @@ -6,13 +6,13 @@ import os import platform -import logging import json from aiohttp import web +from fledge.common import utils from fledge.common.common import _FLEDGE_ROOT -from fledge.common import logger, utils +from fledge.common.logger import FLCoreLogger __author__ = "Ashish Jabble" @@ -25,7 +25,7 @@ | POST | /fledge/repository | ------------------------------------------------------------------------------- """ -_LOGGER = logger.setup(__name__, level=logging.INFO) +_LOGGER = FLCoreLogger().get_logger(__name__) async def add_package_repo(request: web.Request) -> web.Response: @@ -105,7 +105,7 @@ async def add_package_repo(request: web.Request) -> web.Response: cmd = "sudo rpm --import {}/RPM-GPG-KEY-fledge > {} 2>&1".format(url, stdout_file_path) else: cmd = "wget -q -O - {}/KEY.gpg | sudo apt-key add - > {} 2>&1".format(url, stdout_file_path) - _LOGGER.debug("CMD-1....{}".format(cmd)) + _LOGGER.debug("Add the key that is used to verify the package with command: {}".format(cmd)) ret_code = os.system(cmd) if ret_code != 0: raise RuntimeError("See logs in {}".format(stdout_file_path)) @@ -115,7 +115,7 @@ async def add_package_repo(request: web.Request) -> web.Response: else: cmd = "echo \"deb {}/ /\" | sudo tee /etc/apt/sources.list.d/fledge.list >> {} 2>&1".format( full_url, stdout_file_path) - _LOGGER.debug("CMD-2....{}".format(cmd)) + _LOGGER.debug("Edit the sources list with command: {}".format(cmd)) ret_code = os.system(cmd) if ret_code != 0: raise RuntimeError("See logs in {}".format(stdout_file_path)) @@ -123,7 +123,7 @@ async def add_package_repo(request: web.Request) -> web.Response: cmd = "{} >> {} 2>&1".format(extra_commands, stdout_file_path) else: cmd = "sudo {} -y update >> {} 2>&1".format(pkg_mgt, stdout_file_path) - _LOGGER.debug("CMD-3....{}".format(cmd)) + _LOGGER.debug("Fetch the list of packages with command: {}".format(cmd)) ret_code = os.system(cmd) if ret_code != 0: raise RuntimeError("See logs in {}".format(stdout_file_path)) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index b1a5d05fda..2ebf930862 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -15,7 +15,7 @@ from typing import Dict, List from fledge.common import utils -from fledge.common import logger +from fledge.common.logger import FLCoreLogger from fledge.common.service_record import ServiceRecord from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError @@ -49,8 +49,7 @@ | POST | /fledge/service/{service_name}/otp | ------------------------------------------------------------------------------ """ - -_logger = logger.setup() +_logger = FLCoreLogger().get_logger(__name__) ################################# # Service diff --git a/python/fledge/services/core/api/support.py b/python/fledge/services/core/api/support.py index 6149c1ef7d..d55a020a1b 100644 --- a/python/fledge/services/core/api/support.py +++ b/python/fledge/services/core/api/support.py @@ -7,15 +7,15 @@ import os import subprocess import json -import logging import datetime import urllib.parse from pathlib import Path from aiohttp import web -from fledge.common import logger, utils +from fledge.common import utils from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA +from fledge.common.logger import FLCoreLogger from fledge.services.core.support import SupportBuilder @@ -24,7 +24,7 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) _SYSLOG_FILE = '/var/log/messages' if utils.is_redhat_based() else '/var/log/syslog' _SCRIPTS_DIR = "{}/scripts".format(_FLEDGE_ROOT) diff --git a/python/fledge/services/core/api/task.py b/python/fledge/services/core/api/task.py index 18860e549e..4bb7989adf 100644 --- a/python/fledge/services/core/api/task.py +++ b/python/fledge/services/core/api/task.py @@ -10,8 +10,8 @@ from aiohttp import web from fledge.common import utils -from fledge.common import logger from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError @@ -34,7 +34,7 @@ ------------------------------------------------------------------------------- """ -_logger = logger.setup() +_logger = FLCoreLogger().get_logger(__name__) async def add_task(request): @@ -163,29 +163,30 @@ async def add_task(request): # Checking for C-type plugins plugin_info = apiutils.get_plugin_info(plugin, dir=task_type) if not plugin_info: - msg = "Plugin {} does not appear to be a valid plugin".format(plugin) + msg = "Plugin {} does not appear to be a valid plugin.".format(plugin) _logger.error(msg) return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) valid_c_plugin_info_keys = ['name', 'version', 'type', 'interface', 'flag', 'config'] for k in valid_c_plugin_info_keys: if k not in list(plugin_info.keys()): - msg = "Plugin info does not appear to be a valid for {} plugin. '{}' item not found".format( + msg = "Plugin info does not appear to be a valid for {} plugin. '{}' item not found.".format( plugin, k) _logger.error(msg) return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if plugin_info['type'] != task_type: - msg = "Plugin of {} type is not supported".format(plugin_info['type']) + msg = "Plugin of {} type is not supported.".format(plugin_info['type']) _logger.error(msg) return web.HTTPBadRequest(reason=msg) plugin_config = plugin_info['config'] if not plugin_config: - _logger.exception("Plugin %s import problem from path %s. %s", plugin, plugin_module_path, str(ex)) + _logger.exception("Plugin {} import problem from path {} due to {}".format(plugin, + plugin_module_path, str(ex))) raise web.HTTPNotFound(reason='Plugin "{}" import problem from path "{}"'.format(plugin, plugin_module_path)) except TypeError as ex: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: - _logger.exception("Failed to fetch plugin configuration. %s", str(ex)) + _logger.exception("Failed to fetch plugin configuration due to {}".format(str(ex))) raise web.HTTPInternalServerError(reason='Failed to fetch plugin configuration.') storage = connect.get_storage_async() @@ -225,10 +226,10 @@ async def add_task(request): try: res = await storage.insert_into_tbl("scheduled_processes", payload) except StorageServerError as ex: - _logger.exception("Failed to create scheduled process. %s", ex.error) + _logger.error("Failed to create scheduled process due to {}".format(str(ex))) raise web.HTTPInternalServerError(reason='Failed to create north instance.') except Exception as ex: - _logger.exception("Failed to create scheduled process. %s", ex) + _logger.exception("Failed to create scheduled process due to {}".format(str(ex))) raise web.HTTPInternalServerError(reason='Failed to create north instance.') # If successful then create a configuration entry from plugin configuration @@ -251,7 +252,7 @@ async def add_task(request): await config_mgr.set_category_item_value_entry(name, k, v['value']) except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.exception("Failed to create plugin configuration. %s", str(ex)) + _logger.exception("Failed to create plugin configuration due to {}".format(str(ex))) raise web.HTTPInternalServerError(reason='Failed to create plugin configuration. {}'.format(ex)) # If all successful then lastly add a schedule to run the new task at startup @@ -276,11 +277,11 @@ async def add_task(request): schedule = await server.Server.scheduler.get_schedule_by_name(name) except StorageServerError as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.exception("Failed to create schedule. %s", ex.error) + _logger.error("Failed to create north instance due to {}".format(ex.error)) raise web.HTTPInternalServerError(reason='Failed to create north instance.') except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.exception("Failed to create schedule. %s", str(ex)) + _logger.error("Failed to create north instance due to {}".format(str(ex))) raise web.HTTPInternalServerError(reason='Failed to create north instance.') except ValueError as e: diff --git a/python/fledge/services/core/api/update.py b/python/fledge/services/core/api/update.py index 382fae3e60..68fa168587 100644 --- a/python/fledge/services/core/api/update.py +++ b/python/fledge/services/core/api/update.py @@ -14,12 +14,12 @@ import asyncio import re -from fledge.common import logger, utils +from fledge.common import utils +from fledge.common.logger import FLCoreLogger from fledge.services.core import server from fledge.services.core.scheduler.entities import ManualSchedule -_LOG_LEVEL = 20 -_logger = logger.setup(__name__, level=_LOG_LEVEL) +_logger = FLCoreLogger().get_logger(__name__) __author__ = "Massimiliano Pinto" __copyright__ = "Copyright (c) 2018 OSIsoft, LLC" diff --git a/python/fledge/services/core/api/utils.py b/python/fledge/services/core/api/utils.py index 8d57dc650e..059e8d4251 100644 --- a/python/fledge/services/core/api/utils.py +++ b/python/fledge/services/core/api/utils.py @@ -9,10 +9,10 @@ import os import json -from fledge.common import logger from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_PLUGIN_PATH +from fledge.common.logger import FLCoreLogger -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) _lib_path = _FLEDGE_ROOT + "/" + "plugins" @@ -28,19 +28,19 @@ def get_plugin_info(name, dir): res = out.decode("utf-8") jdoc = json.loads(res) except OSError as err: - _logger.error("%s C plugin get info failed due to %s", name, str(err)) + _logger.error("{} C plugin get info failed due to {}".format(name, str(err))) return {} except subprocess.CalledProcessError as err: if err.output is not None: - _logger.error("%s C plugin get info failed '%s' due to %s", name, err.output, str(err)) + _logger.error("{} C plugin get info failed '{}' due to {}".format(name, err.output, str(err))) else: - _logger.error("%s C plugin get info failed due to %s", name, str(err)) + _logger.error("{} C plugin get info failed due to {}".format(name, str(err))) return {} except ValueError as err: _logger.error(str(err)) return {} except Exception as ex: - _logger.exception("%s C plugin get info failed due to %s", name, str(ex)) + _logger.error("{} C plugin get info failed due to {}".format(name, str(ex))) return {} else: return jdoc @@ -97,7 +97,7 @@ def _find_plugins_from_env(_plugin_path: list) -> list: if subdirs[0]: _plugin_path.append(l) else: - _logger.warning("{} subdir type not found".format(l)) + _logger.warning("{} subdir type not found.".format(l)) else: - _logger.warning("{} dir path not found".format(l)) + _logger.warning("{} dir path not found.".format(l)) return _plugin_path From d225afb0da2ae0fd4b64bd4732815bdc431dce47 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Mon, 6 Mar 2023 17:28:00 +0530 Subject: [PATCH 180/499] FOGL-7415: Fix a storage service crash when Notification instance is disabled Signed-off-by: Amandeep Singh Arora --- C/services/storage/include/storage_registry.h | 1 + C/services/storage/storage_registry.cpp | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/C/services/storage/include/storage_registry.h b/C/services/storage/include/storage_registry.h index 5117098c46..1bb207f9b9 100644 --- a/C/services/storage/include/storage_registry.h +++ b/C/services/storage/include/storage_registry.h @@ -60,6 +60,7 @@ class StorageRegistry { std::queue m_tableUpdateQueue; std::mutex m_qMutex; + std::mutex m_registrationsMutex; std::mutex m_tableRegistrationsMutex; std::thread *m_thread; std::condition_variable m_cv; diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp index c71fd386ab..ce5bb55120 100644 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -171,6 +171,7 @@ StorageRegistry::processTableUpdate(const string& tableName, const string& paylo void StorageRegistry::registerAsset(const string& asset, const string& url) { + lock_guard guard(m_registrationsMutex); m_registrations.push_back(pair(new string(asset), new string(url))); } @@ -183,6 +184,7 @@ StorageRegistry::registerAsset(const string& asset, const string& url) void StorageRegistry::unregisterAsset(const string& asset, const string& url) { + lock_guard guard(m_registrationsMutex); for (auto it = m_registrations.begin(); it != m_registrations.end(); ) { if (asset.compare(*(it->first)) == 0 && url.compare(*(it->second)) == 0) @@ -444,6 +446,8 @@ StorageRegistry::processPayload(char *payload) { bool allDone = true; + lock_guard guard(m_registrationsMutex); + // First of all deal with those that registered for all assets for (REGISTRY::const_iterator it = m_registrations.cbegin(); it != m_registrations.cend(); it++) { From 477d3ac3e1aa51a7a0c38cb08e028ddd87c9b71d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 6 Mar 2023 19:40:34 +0530 Subject: [PATCH 181/499] unit tests updated as per new logging mechanism Signed-off-by: ashish-jabble --- .../services/core/api/test_auth_mandatory.py | 147 +++++++++--------- .../services/core/api/test_auth_optional.py | 32 ++-- .../core/api/test_certificate_store.py | 60 +++---- .../services/core/api/test_common_ping.py | 16 +- .../services/core/scheduler/test_scheduler.py | 16 +- 5 files changed, 139 insertions(+), 132 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py index d454ac655d..eb5fe4d611 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py @@ -58,12 +58,11 @@ async def auth_token_fixture(self, mocker, is_admin=True): _rv1 = asyncio.ensure_future(mock_coro(user['id'])) _rv2 = asyncio.ensure_future(mock_coro(None)) _rv3 = asyncio.ensure_future(mock_coro(user)) - patch_logger_info = mocker.patch.object(middleware._logger, 'info') + patch_logger_debug = mocker.patch.object(middleware._logger, 'debug') patch_validate_token = mocker.patch.object(User.Objects, 'validate_token', return_value=_rv1) patch_refresh_token = mocker.patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) patch_user_get = mocker.patch.object(User.Objects, 'get', return_value=_rv3) - - return patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get + return patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get @pytest.mark.parametrize("payload, msg", [ ({}, "Username is required to create user"), @@ -105,7 +104,7 @@ async def auth_token_fixture(self, mocker, is_admin=True): ]) async def test_create_bad_user(self, client, mocker, payload, msg): ret_val = [{'id': '1'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -122,7 +121,7 @@ async def test_create_bad_user(self, client, mocker, payload, msg): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') @pytest.mark.parametrize("request_data", [ {"username": "AdMin", "password": "F0gl@mp", "role_id": -3}, @@ -130,7 +129,7 @@ async def test_create_bad_user(self, client, mocker, payload, msg): ]) async def test_create_user_with_bad_role(self, client, mocker, request_data): msg = "Invalid role id" - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -155,7 +154,7 @@ async def test_create_user_with_bad_role(self, client, mocker, request_data): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') async def test_create_dupe_user_name(self, client): msg = "Username already exists" @@ -177,7 +176,7 @@ async def test_create_dupe_user_name(self, client): _se1 = asyncio.ensure_future(mock_coro(valid_user)) _se2 = asyncio.ensure_future(mock_coro({'role_id': '2', 'uname': 'ajtest', 'id': '2'})) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'validate_token', return_value=_rv1) as patch_validate_token: with patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) as patch_refresh_token: with patch.object(User.Objects, 'get', side_effect=[_se1, _se2]) as patch_user_get: @@ -201,7 +200,7 @@ async def test_create_dupe_user_name(self, client): assert {'username': request_data['username']} == kwargs patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') async def test_create_user(self, client): request_data = {"username": "aj123", "password": "F0gl@mp"} @@ -229,7 +228,7 @@ async def test_create_user(self, client): _rv5 = asyncio.ensure_future(mock_coro(ret_val)) _se1 = asyncio.ensure_future(mock_coro(valid_user)) _se2 = asyncio.ensure_future(mock_coro(data)) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'validate_token', return_value=_rv1) as patch_validate_token: with patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) as patch_refresh_token: with patch.object(User.Objects, 'get', side_effect=[_se1, User.DoesNotExist, _se2]) as patch_user_get: @@ -261,7 +260,7 @@ async def test_create_user(self, client): assert {'username': expected['uname']} == kwargs patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') async def test_create_user_unknown_exception(self, client): request_data = {"username": "ajtest", "password": "F0gl@mp"} @@ -282,7 +281,7 @@ async def test_create_user_unknown_exception(self, client): _rv4 = asyncio.ensure_future(mock_coro(True)) _se1 = asyncio.ensure_future(mock_coro(valid_user)) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'validate_token', return_value=_rv1) as patch_validate_token: with patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) as patch_refresh_token: with patch.object(User.Objects, 'get', side_effect=[_se1, User.DoesNotExist]) as patch_user_get: @@ -309,7 +308,7 @@ async def test_create_user_unknown_exception(self, client): assert {'username': request_data['username']} == kwargs patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') async def test_create_user_value_error(self, client): valid_user = {'id': 1, 'uname': 'admin', 'role_id': '1'} @@ -330,7 +329,7 @@ async def test_create_user_value_error(self, client): _rv4 = asyncio.ensure_future(mock_coro(True)) _se1 = asyncio.ensure_future(mock_coro(valid_user)) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'validate_token', return_value=_rv1) as patch_validate_token: with patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) as patch_refresh_token: with patch.object(User.Objects, 'get', side_effect=[_se1, User.DoesNotExist]) as patch_user_get: @@ -357,7 +356,7 @@ async def test_create_user_value_error(self, client): assert {'username': request_data['username']} == kwargs patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') @pytest.mark.parametrize("payload, status_reason", [ ({"realname": "dd"}, 'Nothing to update'), @@ -365,7 +364,7 @@ async def test_create_user_value_error(self, client): ({"real_name": " "}, 'Real Name should not be empty') ]) async def test_bad_update_me(self, client, mocker, payload, status_reason): - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) user_info = {'role_id': '1', 'id': '2', 'uname': 'user', 'access_method': 'any', 'real_name': 'Sat', 'description': 'Normal User'} @@ -379,13 +378,13 @@ async def test_bad_update_me(self, client, mocker, payload, status_reason): patch_get_user.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user') @pytest.mark.parametrize("payload", [ {"real_name": "AJ"}, {"real_name": " AJ "}, {"real_name": "AJ "}, {"real_name": " AJ"} ]) async def test_update_me(self, client, mocker, payload): - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) user_info = {'role_id': '1', 'id': '2', 'uname': 'user', 'access_method': 'any', 'real_name': 'AJ', 'description': 'Normal User'} @@ -418,7 +417,7 @@ async def test_update_me(self, client, mocker, payload): patch_get_user.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user') @pytest.mark.parametrize("payload, status_reason", [ ({"realname": "dd"}, 'Nothing to update'), @@ -429,7 +428,7 @@ async def test_update_me(self, client, mocker, payload): ]) async def test_bad_update_user(self, client, mocker, payload, status_reason): uid = 2 - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) user_info = {'role_id': '1', 'id': str(uid), 'uname': 'user', 'access_method': 'any', 'real_name': 'Sat', 'description': 'Normal User'} @@ -453,7 +452,7 @@ async def test_bad_update_user(self, client, mocker, payload, status_reason): patch_role_id.assert_called_once_with('admin') patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/{}'.format(uid)) + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/{}'.format(uid)) @pytest.mark.parametrize("payload, exp_result", [ ({"real_name": "Sat"}, {'role_id': '2', 'id': '2', 'uname': 'user', 'access_method': 'any', @@ -469,7 +468,7 @@ async def test_bad_update_user(self, client, mocker, payload, status_reason): ]) async def test_update_user(self, client, mocker, payload, exp_result): uid = 2 - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -493,7 +492,7 @@ async def test_update_user(self, client, mocker, payload, exp_result): patch_role_id.assert_called_once_with('admin') patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/{}'.format( + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/{}'.format( uid)) @pytest.mark.parametrize("request_data, msg", [ @@ -510,13 +509,13 @@ async def test_update_user(self, client, mocker, payload, exp_result): ]) async def test_update_password_with_bad_data(self, client, request_data, msg): uid = 2 - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) assert 400 == resp.status assert msg == resp.reason patch_logger_warning.assert_called_once_with(msg) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' .format(uid)) async def test_update_password_with_invalid_current_password(self, client): @@ -526,7 +525,7 @@ async def test_update_password_with_invalid_current_password(self, client): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(None) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(None)) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'is_user_exists', return_value=_rv) as patch_user_exists: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) @@ -534,7 +533,7 @@ async def test_update_password_with_invalid_current_password(self, client): assert msg == resp.reason patch_logger_warning.assert_called_once_with(msg) patch_user_exists.assert_called_once_with(str(uid), request_data['current_password']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password'.format(uid)) @pytest.mark.parametrize("exception_name, status_code, msg", [ @@ -547,7 +546,7 @@ async def test_update_password_exceptions(self, client, exception_name, status_c uid = 2 # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(uid) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(uid)) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'is_user_exists', return_value=_rv) as patch_user_exists: with patch.object(User.Objects, 'update', side_effect=exception_name(msg)) as patch_update: with patch.object(auth._logger, 'warning') as patch_logger_warning: @@ -557,7 +556,7 @@ async def test_update_password_exceptions(self, client, exception_name, status_c patch_logger_warning.assert_called_once_with(msg) patch_update.assert_called_once_with(2, {'password': request_data['new_password']}) patch_user_exists.assert_called_once_with(str(uid), request_data['current_password']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' .format(uid)) async def test_update_password_unknown_exception(self, client): @@ -566,7 +565,7 @@ async def test_update_password_unknown_exception(self, client): msg = 'Something went wrong' # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(uid) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(uid)) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'is_user_exists', return_value=_rv) as patch_user_exists: with patch.object(User.Objects, 'update', side_effect=Exception(msg)) as patch_update: with patch.object(auth._logger, 'exception') as patch_logger_exception: @@ -576,7 +575,7 @@ async def test_update_password_unknown_exception(self, client): patch_logger_exception.assert_called_once_with(msg) patch_update.assert_called_once_with(2, {'password': request_data['new_password']}) patch_user_exists.assert_called_once_with(str(uid), request_data['current_password']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' .format(uid)) async def test_update_password(self, client): @@ -594,7 +593,7 @@ async def test_update_password(self, client): _rv1 = asyncio.ensure_future(mock_coro(user_id)) _rv2 = asyncio.ensure_future(mock_coro(ret_val)) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'is_user_exists', return_value=_rv1) as patch_user_exists: with patch.object(User.Objects, 'update', return_value=_rv2) as patch_update: with patch.object(auth._logger, 'info') as patch_auth_logger_info: @@ -606,7 +605,7 @@ async def test_update_password(self, client): patch_auth_logger_info.assert_called_once_with(msg) patch_update.assert_called_once_with(user_id, {'password': request_data['new_password']}) patch_user_exists.assert_called_once_with(str(user_id), request_data['current_password']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' .format(user_id)) @pytest.mark.parametrize("request_data", [ @@ -616,7 +615,7 @@ async def test_update_password(self, client): async def test_delete_bad_user(self, client, mocker, request_data): msg = "invalid literal for int() with base 10: '{}'".format(request_data) ret_val = [{'id': '1'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -630,13 +629,13 @@ async def test_delete_bad_user(self, client, mocker, request_data): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/{}/delete'.format(request_data)) async def test_delete_admin_user(self, client, mocker): msg = "Super admin user can not be deleted" ret_val = [{'id': '1'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -650,12 +649,12 @@ async def test_delete_admin_user(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/1/delete') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/1/delete') async def test_delete_own_account(self, client, mocker): msg = "You can not delete your own account" ret_val = [{'id': '2'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker, is_admin=False) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -669,12 +668,12 @@ async def test_delete_own_account(self, client, mocker): patch_user_get.assert_called_once_with(uid=2) patch_refresh_token.assert_called_once_with(NORMAL_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(NORMAL_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') async def test_delete_invalid_user(self, client, mocker): ret_val = {"response": "deleted", "rows_affected": 0} msg = 'User with id:<2> does not exist' - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -696,11 +695,11 @@ async def test_delete_invalid_user(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') async def test_delete_user(self, client, mocker): ret_val = {"response": "deleted", "rows_affected": 1} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -723,7 +722,7 @@ async def test_delete_user(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') @pytest.mark.parametrize("exception_name, code, msg", [ (ValueError, 400, 'None'), @@ -731,7 +730,7 @@ async def test_delete_user(self, client, mocker): ]) async def test_delete_user_exceptions(self, client, mocker, exception_name, code, msg): ret_val = [{'id': '1'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -747,12 +746,12 @@ async def test_delete_user_exceptions(self, client, mocker, exception_name, code patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') async def test_delete_user_unknown_exception(self, client, mocker): msg = 'Something went wrong' ret_val = [{'id': '1'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -768,11 +767,11 @@ async def test_delete_user_unknown_exception(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/2/delete') async def test_logout(self, client, mocker): ret_val = {'response': 'deleted', 'rows_affected': 1} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -787,12 +786,12 @@ async def test_logout(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/2/logout') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/2/logout') async def test_logout_with_bad_user(self, client, mocker): ret_val = {'response': 'deleted', 'rows_affected': 0} user_id = 111 - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -809,7 +808,7 @@ async def test_logout_with_bad_user(self, client, mocker): async def test_logout_me(self, client, mocker): ret_val = {'response': 'deleted', 'rows_affected': 1} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -824,11 +823,11 @@ async def test_logout_me(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/logout') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/logout') async def test_logout_me_with_bad_token(self, client, mocker): ret_val = {'response': 'deleted', 'rows_affected': 0} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -841,12 +840,12 @@ async def test_logout_me_with_bad_token(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/logout') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/logout') async def test_enable_with_super_admin_user(self, client, mocker): msg = 'Restricted for Super Admin user' ret_val = [{'id': '1'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -863,7 +862,7 @@ async def test_enable_with_super_admin_user(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1/enable') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1/enable') @pytest.mark.parametrize("request_data, msg", [ ({}, "Nothing to enable user update"), @@ -872,7 +871,7 @@ async def test_enable_with_super_admin_user(self, client, mocker): ]) async def test_enable_with_bad_data(self, client, mocker, request_data, msg): ret_val = [{'id': '1'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -887,7 +886,7 @@ async def test_enable_with_bad_data(self, client, mocker, request_data, msg): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/enable') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/enable') @pytest.mark.parametrize("request_data", [ {"enabled": 'true'}, {"enabled": 'True'}, {"enabled": 'TRUE'}, {"enabled": 'tRUe'}, @@ -904,7 +903,7 @@ async def test_enable_user(self, client, mocker, request_data): '"where": {"column": "id", "condition": "=", "value": "2"}}') \ if str(request_data['enabled']).lower() == 'true' else ( 'disabled', 'f', '{"values": {"enabled": "f"}, "where": {"column": "id", "condition": "=", "value": "2"}}') - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) storage_client_mock = MagicMock(StorageClientAsync) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -942,12 +941,12 @@ async def test_enable_user(self, client, mocker, request_data): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/enable') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/enable') async def test_reset_super_admin(self, client, mocker): msg = 'Restricted for Super Admin user' ret_val = [{'id': '1'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -962,7 +961,7 @@ async def test_reset_super_admin(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1/reset') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1/reset') @pytest.mark.parametrize("request_data, msg", [ ({}, "Nothing to update the user"), @@ -972,7 +971,7 @@ async def test_reset_super_admin(self, client, mocker): ]) async def test_reset_with_bad_data(self, client, mocker, request_data, msg): ret_val = [{'id': '1'}] - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) @@ -987,12 +986,12 @@ async def test_reset_with_bad_data(self, client, mocker, request_data, msg): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') async def test_reset_with_bad_role(self, client, mocker): request_data = {"role_id": "blah"} msg = "Invalid or bad role id" - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -1015,7 +1014,7 @@ async def test_reset_with_bad_role(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') @pytest.mark.parametrize("exception_name, status_code, msg", [ (ValueError, 400, 'None'), @@ -1025,7 +1024,7 @@ async def test_reset_with_bad_role(self, client, mocker): async def test_reset_exceptions(self, client, mocker, exception_name, status_code, msg): request_data = {'role_id': '2'} user_id = 2 - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -1050,13 +1049,13 @@ async def test_reset_exceptions(self, client, mocker, exception_name, status_cod patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') async def test_reset_unknown_exception(self, client, mocker): request_data = {'role_id': '2'} user_id = 2 msg = 'Something went wrong' - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -1081,14 +1080,14 @@ async def test_reset_unknown_exception(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') async def test_reset_role_and_password(self, client, mocker): request_data = {'role_id': '2', 'password': 'Test@123'} user_id = 2 msg = 'User with id:<{}> has been updated successfully'.format(user_id) ret_val = {'response': 'updated', 'rows_affected': 1} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -1117,7 +1116,7 @@ async def test_reset_role_and_password(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') @pytest.mark.parametrize("auth_method, request_data, ret_val", [ ("certificate", "-----BEGIN CERTIFICATE----- Test -----END CERTIFICATE-----", (2, "token2", False)) @@ -1163,7 +1162,7 @@ async def async_get_user(): async def test_login_auth_exception1(self, client, auth_method, request_data, ret_val, expected): async def async_mock(): return ret_val - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'info') as patch_logger_debug: with patch.object(server.Server, "auth_method", auth_method) as patch_auth_method: req_data = json.dumps(request_data) if isinstance(request_data, dict) else request_data resp = await client.post('/fledge/login', data=req_data) @@ -1180,7 +1179,7 @@ async def test_login_auth_exception2(self, client, auth_method, request_data, re async def async_mock(): return ret_val - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'info') as patch_logger_debug: with patch.object(server.Server, "auth_method", auth_method) as patch_auth_method: req_data = request_data resp = await client.post('/fledge/login', data=req_data, headers=TEXT_HEADER) diff --git a/tests/unit/python/fledge/services/core/api/test_auth_optional.py b/tests/unit/python/fledge/services/core/api/test_auth_optional.py index 69d7554b8e..1707d35990 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_optional.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_optional.py @@ -52,7 +52,7 @@ async def test_get_roles(self, client): else: _rv = asyncio.ensure_future(mock_coro([])) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(User.Objects, 'get_roles', return_value=_rv) as patch_user_obj: resp = await client.get('/fledge/user/role') assert 200 == resp.status @@ -74,7 +74,7 @@ async def test_get_all_users(self, client, ret_val, exp_result): else: _rv = asyncio.ensure_future(mock_coro(ret_val)) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(User.Objects, 'all', return_value=_rv) as patch_user_obj: resp = await client.get('/fledge/user') assert 200 == resp.status @@ -100,7 +100,7 @@ async def test_get_user_by_param(self, client, request_params, exp_result, arg1, else: _rv = asyncio.ensure_future(mock_coro(result)) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(User.Objects, 'get', return_value=_rv) as patch_user_obj: resp = await client.get('/fledge/user{}'.format(request_params)) assert 200 == resp.status @@ -120,7 +120,7 @@ async def test_get_user_by_param(self, client, request_params, exp_result, arg1, ('?username=blah', 'User with name: does not exist', None, 'blah') ]) async def test_get_user_exception_by_param(self, client, request_params, error_msg, arg1, arg2): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(User.Objects, 'get', side_effect=User.DoesNotExist(error_msg)) as patch_user_get: with patch.object(auth._logger, 'warning') as patch_logger: resp = await client.get('/fledge/user{}'.format(request_params)) @@ -132,7 +132,7 @@ async def test_get_user_exception_by_param(self, client, request_params, error_m @pytest.mark.parametrize("request_params", ['?id=0', '?id=blah', '?id=-1']) async def test_get_bad_user_id_param_exception(self, client, request_params): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_logger: resp = await client.get('/fledge/user{}'.format(request_params)) assert 400 == resp.status @@ -151,7 +151,7 @@ async def test_get_bad_user_id_param_exception(self, client, request_params): {"uname": "blah", "password": "blah"}, ]) async def test_bad_login(self, client, request_data): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_logger: resp = await client.post('/fledge/login', data=json.dumps(request_data)) assert 400 == resp.status @@ -174,7 +174,7 @@ async def test_login_exception(self, client, request_data, status_code, exceptio else: _rv = asyncio.ensure_future(mock_coro([])) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(User.Objects, 'login', side_effect=exception_name(msg)) as patch_user_login: with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv) as patch_delete_token: with patch.object(auth._logger, 'warning') as patch_logger: @@ -205,7 +205,7 @@ async def async_mock(): else: _rv = asyncio.ensure_future(async_mock()) - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(User.Objects, 'login', return_value=_rv) as patch_user_login: with patch.object(auth._logger, 'info') as patch_logger: resp = await client.post('/fledge/login', data=json.dumps(request_data)) @@ -224,7 +224,7 @@ async def async_mock(): patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/login') async def test_logout(self, client): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/2/logout') assert 403 == resp.status @@ -233,7 +233,7 @@ async def test_logout(self, client): patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/2/logout') async def test_update_password(self, client): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/user/1/password') assert 403 == resp.status @@ -242,7 +242,7 @@ async def test_update_password(self, client): patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/1/password') async def test_update_me(self, client): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/user') assert 403 == resp.status @@ -251,7 +251,7 @@ async def test_update_me(self, client): patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user') async def test_update_user(self, client): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/admin/1') assert 403 == resp.status @@ -260,7 +260,7 @@ async def test_update_user(self, client): patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1') async def test_delete_user(self, client): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_auth_logger_warn: resp = await client.delete('/fledge/admin/1/delete') assert 403 == resp.status @@ -270,7 +270,7 @@ async def test_delete_user(self, client): async def test_create_user(self, client): request_data = {"username": "ajtest", "password": "F0gl@mp"} - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.post('/fledge/admin/user', data=json.dumps(request_data)) assert 403 == resp.status @@ -279,7 +279,7 @@ async def test_create_user(self, client): patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') async def test_enable_user(self, client): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/admin/2/enable') assert 403 == resp.status @@ -288,7 +288,7 @@ async def test_enable_user(self, client): patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/enable') async def test_reset(self, client): - with patch.object(middleware._logger, 'info') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/admin/2/reset') assert 403 == resp.status diff --git a/tests/unit/python/fledge/services/core/api/test_certificate_store.py b/tests/unit/python/fledge/services/core/api/test_certificate_store.py index f32a1cf9c0..1f30bb1551 100644 --- a/tests/unit/python/fledge/services/core/api/test_certificate_store.py +++ b/tests/unit/python/fledge/services/core/api/test_certificate_store.py @@ -284,16 +284,16 @@ async def auth_token_fixture(self, mocker, is_admin=True): _rv1 = asyncio.ensure_future(mock_coro(user['id'])) _rv2 = asyncio.ensure_future(mock_coro(None)) _rv3 = asyncio.ensure_future(mock_coro(user)) - patch_logger_info = mocker.patch.object(middleware._logger, 'info') + patch_logger_debug = mocker.patch.object(middleware._logger, 'debug') patch_validate_token = mocker.patch.object(User.Objects, 'validate_token', return_value=_rv1) patch_refresh_token = mocker.patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) patch_user_get = mocker.patch.object(User.Objects, 'get', return_value=_rv3) - return patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get + return patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get async def test_bad_upload_when_admin_role_is_required(self, client, certs_path, mocker): files = {'key': open(str(certs_path / 'certs/fledge.key'), 'rb'), 'cert': open(str(certs_path / 'certs/fledge.cert'), 'rb')} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker, is_admin=False) msg = 'admin role permissions required to overwrite the default installed auth/TLS certificates.' with patch.object(certificate_store._logger, 'warning') as patch_logger: @@ -307,11 +307,11 @@ async def test_bad_upload_when_admin_role_is_required(self, client, certs_path, patch_user_get.assert_called_once_with(uid=2) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') async def test_bad_upload_when_cert_in_use_and_with_non_admin_role(self, client, certs_path, mocker): files = {'cert': open(str(certs_path / 'certs/test.cer'), 'rb')} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker, is_admin=False) msg = 'Certificate with name test.cer is configured to be used, ' \ 'An `admin` role permissions required to add/overwrite.' @@ -335,12 +335,12 @@ async def test_bad_upload_when_cert_in_use_and_with_non_admin_role(self, client, patch_user_get.assert_called_once_with(uid=2) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') async def test_upload_as_admin(self, client, certs_path, mocker): files = {'key': open(str(certs_path / 'certs/fledge.key'), 'rb'), 'cert': open(str(certs_path / 'certs/fledge.cert'), 'rb')} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) with patch.object(certificate_store, '_get_certs_dir', return_value=certs_path / 'certs'): with patch.object(certificate_store, '_find_file', return_value=[]) as patch_find_file: @@ -357,12 +357,12 @@ async def test_upload_as_admin(self, client, certs_path, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') @pytest.mark.parametrize("filename", ["fledge.pem", "fledge.cert", "test.cer", "test.crt"]) async def test_upload_with_cert_only(self, client, certs_path, mocker, filename): files = {'cert': open(str(certs_path / 'certs/{}'.format(filename)), 'rb')} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) with patch.object(certificate_store, '_get_certs_dir', return_value=certs_path / 'certs/pem'): with patch.object(certificate_store, '_find_file', return_value=[]) as patch_find_file: @@ -377,13 +377,13 @@ async def test_upload_with_cert_only(self, client, certs_path, mocker, filename) patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') async def test_file_upload_with_overwrite(self, client, certs_path, mocker): files = {'key': open(str(certs_path / 'certs/fledge.key'), 'rb'), 'cert': open(str(certs_path / 'certs/fledge.cert'), 'rb'), 'overwrite': '1'} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) with patch.object(certificate_store, '_get_certs_dir', return_value=certs_path / 'certs'): with patch.object(certificate_store, '_find_file', return_value=[]) as patch_find_file: @@ -400,13 +400,13 @@ async def test_file_upload_with_overwrite(self, client, certs_path, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') async def test_bad_extension_key_file_upload(self, client, certs_path, mocker): key_valid_extensions = ('.key', '.pem') files = {'cert': open(str(certs_path / 'certs/fledge.cert'), 'rb'), 'key': open(str(certs_path / 'certs/fledge.txt'), 'rb')} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) resp = await client.post('/fledge/certificate', data=files, headers=self.AUTH_HEADER) assert 400 == resp.status @@ -414,12 +414,12 @@ async def test_bad_extension_key_file_upload(self, client, certs_path, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') async def test_upload_with_existing_and_no_overwrite(self, client, certs_path, mocker): files = {'key': open(str(certs_path / 'certs/fledge.key'), 'rb'), 'cert': open(str(certs_path / 'certs/fledge.cert'), 'rb')} - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) with patch.object(certificate_store, '_get_certs_dir', return_value=certs_path / 'certs'): with patch.object(certificate_store, '_find_file', return_value=["v"]) as patch_file: @@ -433,7 +433,7 @@ async def test_upload_with_existing_and_no_overwrite(self, client, certs_path, m patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/certificate') @pytest.allure.feature("unit") @@ -460,11 +460,11 @@ async def auth_token_fixture(self, mocker, is_admin=True): _rv1 = asyncio.ensure_future(mock_coro(user['id'])) _rv2 = asyncio.ensure_future(mock_coro(None)) _rv3 = asyncio.ensure_future(mock_coro(user)) - patch_logger_info = mocker.patch.object(middleware._logger, 'info') + patch_logger_debug = mocker.patch.object(middleware._logger, 'debug') patch_validate_token = mocker.patch.object(User.Objects, 'validate_token', return_value=_rv1) patch_refresh_token = mocker.patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) patch_user_get = mocker.patch.object(User.Objects, 'get', return_value=_rv3) - return patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get + return patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get @pytest.mark.parametrize("cert_name, actual_code, actual_reason", [ ('root.pem', 404, "Certificate with name root.pem does not exist"), @@ -473,7 +473,7 @@ async def auth_token_fixture(self, mocker, is_admin=True): async def test_bad_delete_cert_with_invalid_filename(self, client, mocker, cert_name, actual_code, actual_reason): storage_client_mock = MagicMock(StorageClientAsync) c_mgr = ConfigurationManager(storage_client_mock) - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -496,7 +496,7 @@ async def test_bad_delete_cert_with_invalid_filename(self, client, mocker, cert_ patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/certificate/{}'.format(cert_name)) @pytest.mark.parametrize("cert_name, actual_code, actual_reason", [ @@ -504,7 +504,7 @@ async def test_bad_delete_cert_with_invalid_filename(self, client, mocker, cert_ "('.cert', '.cer', '.csr', '.crl', '.crt', '.der', '.json', '.key', '.pem', '.p12', '.pfx')") ]) async def test_bad_delete_cert(self, client, mocker, cert_name, actual_code, actual_reason): - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _payload = [{'id': '1'}] @@ -519,7 +519,7 @@ async def test_bad_delete_cert(self, client, mocker, cert_name, actual_code, act patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/certificate/{}'.format(cert_name)) async def test_delete_cert_if_configured_to_use(self, client, mocker): @@ -528,7 +528,7 @@ async def test_delete_cert_if_configured_to_use(self, client, mocker): cert_name = 'fledge.cert' msg = 'Certificate with name {} is configured for use, you can not delete but overwrite if required.'.format( cert_name) - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -553,7 +553,7 @@ async def test_delete_cert_if_configured_to_use(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/certificate/{}'.format(cert_name)) async def test_bad_type_delete_cert(self, client, mocker): @@ -561,7 +561,7 @@ async def test_bad_type_delete_cert(self, client, mocker): c_mgr = ConfigurationManager(storage_client_mock) cert_name = 'server.cert' msg = 'Only cert and key are allowed for the value of type param' - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -586,7 +586,7 @@ async def test_bad_type_delete_cert(self, client, mocker): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/certificate/{}'.format(cert_name)) @pytest.mark.parametrize("cert_name, param", [ @@ -599,7 +599,7 @@ async def test_bad_type_delete_cert(self, client, mocker): async def test_delete_cert_with_type(self, client, mocker, cert_name, param): storage_client_mock = MagicMock(StorageClientAsync) c_mgr = ConfigurationManager(storage_client_mock) - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. cat_info = {'certificateName': {'value': 'foo'}, 'authCertificateName': {'value': 'ca'}} @@ -626,13 +626,13 @@ async def test_delete_cert_with_type(self, client, mocker, cert_name, param): patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/certificate/{}'.format(cert_name)) async def test_delete_cert(self, client, mocker, certs_path, cert_name='server.cert'): storage_client_mock = MagicMock(StorageClientAsync) c_mgr = ConfigurationManager(storage_client_mock) - patch_logger_info, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -662,7 +662,7 @@ async def test_delete_cert(self, client, mocker, certs_path, cert_name='server.c patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/certificate/{}'.format(cert_name)) diff --git a/tests/unit/python/fledge/services/core/api/test_common_ping.py b/tests/unit/python/fledge/services/core/api/test_common_ping.py index fcd7cf2811..f957e8839e 100644 --- a/tests/unit/python/fledge/services/core/api/test_common_ping.py +++ b/tests/unit/python/fledge/services/core/api/test_common_ping.py @@ -85,7 +85,7 @@ async def mock_coro(*args, **kwargs): host_name, ip_addresses = get_machine_detail attrs = {"query_tbl_with_payload.return_value": await mock_coro()} mock_storage_client_async = MagicMock(spec=StorageClientAsync, **attrs) - with patch.object(middleware._logger, 'info') as logger_info: + with patch.object(middleware._logger, 'debug') as logger_info: with patch.object(connect, 'get_storage_async', return_value=mock_storage_client_async): with patch.object(mock_storage_client_async, 'query_tbl_with_payload', return_value=_rv) as query_patch: app = web.Application(loop=loop, middlewares=[middleware.optional_auth_middleware]) @@ -144,7 +144,7 @@ async def mock_coro(*args, **kwargs): host_name, ip_addresses = get_machine_detail mock_storage_client_async = MagicMock(StorageClientAsync) - with patch.object(middleware._logger, 'info') as logger_info: + with patch.object(middleware._logger, 'debug') as logger_info: with patch.object(connect, 'get_storage_async', return_value=mock_storage_client_async): with patch.object(mock_storage_client_async, 'query_tbl_with_payload', return_value=_rv) as query_patch: app = web.Application(loop=loop, middlewares=[middleware.optional_auth_middleware]) @@ -207,7 +207,7 @@ async def mock_get_category_item(): host_name, ip_addresses = get_machine_detail mock_storage_client_async = MagicMock(StorageClientAsync) - with patch.object(middleware._logger, 'info') as logger_info: + with patch.object(middleware._logger, 'debug') as logger_info: with patch.object(connect, 'get_storage_async', return_value=mock_storage_client_async): with patch.object(mock_storage_client_async, 'query_tbl_with_payload', return_value=_rv1) as query_patch: with patch.object(ConfigurationManager, "get_category_item", return_value=_rv2) as mock_get_cat: @@ -270,7 +270,7 @@ async def mock_get_category_item(): _rv2 = asyncio.ensure_future(mock_get_category_item()) mock_storage_client_async = MagicMock(StorageClientAsync) - with patch.object(middleware._logger, 'info') as logger_info: + with patch.object(middleware._logger, 'debug') as logger_info: with patch.object(connect, 'get_storage_async', return_value=mock_storage_client_async): with patch.object(mock_storage_client_async, 'query_tbl_with_payload', return_value=_rv1) as query_patch: with patch.object(ConfigurationManager, "get_category_item", return_value=_rv2) as mock_get_cat: @@ -319,7 +319,7 @@ def mock_coro(*args, **kwargs): host_name, ip_addresses = get_machine_detail mock_storage_client_async = MagicMock(StorageClientAsync) - with patch.object(middleware._logger, 'info') as logger_info: + with patch.object(middleware._logger, 'debug') as logger_info: with patch.object(connect, 'get_storage_async', return_value=mock_storage_client_async): with patch.object(mock_storage_client_async, 'query_tbl_with_payload', return_value=_rv) as query_patch: app = web.Application(loop=loop, middlewares=[middleware.optional_auth_middleware]) @@ -390,7 +390,7 @@ def mock_coro(*args, **kwargs): host_name, ip_addresses = get_machine_detail mock_storage_client_async = MagicMock(StorageClientAsync) - with patch.object(middleware._logger, 'info') as logger_info: + with patch.object(middleware._logger, 'debug') as logger_info: with patch.object(connect, 'get_storage_async', return_value=mock_storage_client_async): with patch.object(mock_storage_client_async, 'query_tbl_with_payload', return_value=_rv) as query_patch: app = web.Application(loop=loop, middlewares=[middleware.optional_auth_middleware]) @@ -460,7 +460,7 @@ async def mock_get_category_item(): host_name, ip_addresses = get_machine_detail mock_storage_client_async = MagicMock(StorageClientAsync) - with patch.object(middleware._logger, 'info') as logger_info: + with patch.object(middleware._logger, 'debug') as logger_info: with patch.object(connect, 'get_storage_async', return_value=mock_storage_client_async): with patch.object(mock_storage_client_async, 'query_tbl_with_payload', return_value=_rv1) as query_patch: with patch.object(ConfigurationManager, "get_category_item", return_value=_rv2) as mock_get_cat: @@ -535,7 +535,7 @@ async def mock_get_category_item(): _rv2 = asyncio.ensure_future(mock_get_category_item()) mock_storage_client_async = MagicMock(StorageClientAsync) - with patch.object(middleware._logger, 'info') as logger_info: + with patch.object(middleware._logger, 'debug') as logger_info: with patch.object(connect, 'get_storage_async', return_value=mock_storage_client_async): with patch.object(mock_storage_client_async, 'query_tbl_with_payload', return_value=_rv1) as query_patch: with patch.object(ConfigurationManager, "get_category_item", return_value=_rv2) as mock_get_cat: diff --git a/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py b/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py index 7c9ab84756..a1fd5c01d2 100644 --- a/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py +++ b/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py @@ -6,6 +6,7 @@ import asyncio import datetime +import logging import uuid import time import json @@ -28,6 +29,7 @@ async def mock_task(): return "" + async def mock_process(): m = MagicMock() m.pid = 9999 @@ -38,6 +40,7 @@ async def mock_process(): @pytest.allure.feature("unit") @pytest.allure.story("scheduler") class TestScheduler: + async def scheduler_fixture(self, mocker): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -46,6 +49,7 @@ async def scheduler_fixture(self, mocker): _rv = asyncio.ensure_future(mock_process()) scheduler = Scheduler() + scheduler._logger.level = logging.INFO scheduler._storage = MockStorage(core_management_host=None, core_management_port=None) scheduler._storage_async = MockStorageAsync(core_management_host=None, core_management_port=None) mocker.patch.object(scheduler, '_schedule_first_task') @@ -253,6 +257,7 @@ async def test__check_schedules(self, mocker): # TODO: Mandatory - Add negative tests for full code coverage # GIVEN scheduler = Scheduler() + scheduler._logger.level = logging.INFO scheduler._storage = MockStorage(core_management_host=None, core_management_port=None) scheduler._storage_async = MockStorageAsync(core_management_host=None, core_management_port=None) log_info = mocker.patch.object(scheduler._logger, "info") @@ -754,7 +759,6 @@ def mock_coro(): audit_logger = mocker.patch.object(AuditLogger, 'information', return_value=asyncio.ensure_future(mock_task())) first_task = mocker.patch.object(scheduler, '_schedule_first_task') resume_sch = mocker.patch.object(scheduler, '_resume_check_schedules') - log_info = mocker.patch.object(scheduler._logger, "info") enable_schedule = mocker.patch.object(scheduler, "enable_schedule", return_value=mock_coro()) disable_schedule = mocker.patch.object(scheduler, "disable_schedule", return_value=mock_coro()) @@ -804,7 +808,6 @@ def mock_coro(): audit_logger = mocker.patch.object(AuditLogger, 'information', return_value=asyncio.ensure_future(mock_task())) first_task = mocker.patch.object(scheduler, '_schedule_first_task') resume_sch = mocker.patch.object(scheduler, '_resume_check_schedules') - log_info = mocker.patch.object(scheduler._logger, "info") schedule_id = uuid.UUID("2b614d26-760f-11e7-b5a5-be2e44b06b34") # OMF to PI North schedule_row = scheduler._ScheduleRow( id=schedule_id, @@ -848,7 +851,6 @@ def mock_coro(): audit_logger = mocker.patch.object(AuditLogger, 'information', return_value=asyncio.ensure_future(mock_task())) first_task = mocker.patch.object(scheduler, '_schedule_first_task') resume_sch = mocker.patch.object(scheduler, '_resume_check_schedules') - log_info = mocker.patch.object(scheduler._logger, "info") schedule_id = uuid.UUID("2b614d26-760f-11e7-b5a5-be2e44b06b34") # OMF to PI North schedule_row = scheduler._ScheduleRow( id=schedule_id, @@ -1091,7 +1093,6 @@ async def test_queue_task(self, mocker): scheduler._storage = MockStorage(core_management_host=None, core_management_port=None) scheduler._storage_async = MockStorageAsync(core_management_host=None, core_management_port=None) mocker.patch.object(scheduler, '_schedule_first_task') - # log_info = mocker.patch.object(scheduler._logger, "info") await scheduler._get_schedules() sch_id = uuid.UUID("cea17db8-6ccc-11e7-907b-a6006ad3dba0") # backup @@ -1437,6 +1438,12 @@ async def test_not_ready_and_paused(self, mocker): async def test__terminate_child_processes(self, mocker): pass + @pytest.mark.asyncio + async def test_cleanup(self): + scheduler = Scheduler() + scheduler._logger.level = logging.WARNING + + class MockStorage(StorageClientAsync): def __init__(self, core_management_host=None, core_management_port=None): super().__init__(core_management_host, core_management_port) @@ -1452,6 +1459,7 @@ def _get_storage_service(self, host, port): "protocol": "http" } + class MockStorageAsync(StorageClientAsync): schedules = [ { From e7e276afe06b3aaf2ac53dbe49bcca2d02666041 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 7 Mar 2023 11:13:18 +0000 Subject: [PATCH 182/499] FOGL-7482 Memory leak fixes (#980) * FOGL--7482 Finalize prepared statement on error Signed-off-by: Mark Riddoch * FOGL-7482 Fix more memory leaks Signed-off-by: Mark Riddoch * Reset prepared statement between stepo retries Signed-off-by: Mark Riddoch * Added comment Signed-off-by: Mark Riddoch * FOGL-7482 Memory leak fixes Signed-off-by: Mark Riddoch * FOGL-7482 More memory leak issues resolved Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- .../storage/sqlite/common/connection.cpp | 51 +++++++++++++++++-- .../sqlite/common/connection_manager.cpp | 5 ++ C/plugins/storage/sqlite/common/readings.cpp | 7 ++- .../sqlite/common/readings_catalogue.cpp | 16 +++--- C/services/storage/configuration.cpp | 5 +- C/services/storage/include/storage_service.h | 1 + C/services/storage/storage.cpp | 15 ++++-- C/services/storage/storage_registry.cpp | 9 +++- scripts/services/north_C | 25 ++++++++- scripts/services/south_c | 25 ++++++++- scripts/services/storage | 9 +++- 11 files changed, 145 insertions(+), 23 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index 0b7ddb4253..ef846c0328 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -1149,7 +1149,7 @@ vector asset_codes; const char *query = sql.coalesce(); char *zErrMsg = NULL; int rc; - sqlite3_stmt *stmt; + sqlite3_stmt *stmt = NULL; logSQL("CommonRetrive", query); @@ -1161,6 +1161,10 @@ vector asset_codes; raiseError("retrieve", sqlite3_errmsg(dbHandle)); Logger::getLogger()->error("SQL statement: %s", query); delete[] query; + if (stmt) + { + sqlite3_finalize(stmt); + } return false; } @@ -1199,7 +1203,7 @@ int Connection::insert(const string& schema, const string& table, const string& { Document document; ostringstream convert; -sqlite3_stmt *stmt; +sqlite3_stmt *stmt = NULL; int rc; std::size_t arr = data.find("inserts"); @@ -1286,10 +1290,16 @@ std::size_t arr = data.find("inserts"); rc = sqlite3_prepare_v2(dbHandle, query, -1, &stmt, NULL); if (rc != SQLITE_OK) { + if (stmt) + { + sqlite3_finalize(stmt); + } raiseError("insert", sqlite3_errmsg(dbHandle)); Logger::getLogger()->error("SQL statement: %s", query); + delete[] query; return -1; } + delete[] query; // Bind columns with prepared sql query int columID = 1; @@ -1336,6 +1346,12 @@ std::size_t arr = data.find("inserts"); if (sqlite3_exec(dbHandle, "BEGIN TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) { + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); + if (stmt) + { + sqlite3_finalize(stmt); + } raiseError("insert", sqlite3_errmsg(dbHandle)); return -1; } @@ -1356,6 +1372,9 @@ std::size_t arr = data.find("inserts"); failedInsertCount++; raiseError("insert", sqlite3_errmsg(dbHandle)); Logger::getLogger()->error("SQL statement: %s", sqlite3_expanded_sql(stmt)); + + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); // transaction is still open, do rollback if (sqlite3_get_autocommit(dbHandle) == 0) @@ -1372,11 +1391,14 @@ std::size_t arr = data.find("inserts"); if (sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) { + if (stmt) + { + sqlite3_finalize(stmt); + } raiseError("insert", sqlite3_errmsg(dbHandle)); return -1; } - delete[] query; } // Increment row count ins++; @@ -3088,10 +3110,16 @@ int Connection::SQLexec(sqlite3 *db, const char *sql, int (*callback)(void*,int, { int retries = 0, rc; + *errmsg = NULL; do { #if DO_PROFILE ProfileItem *prof = new ProfileItem(sql); #endif + if (*errmsg) + { + sqlite3_free(*errmsg); + *errmsg = NULL; + } rc = sqlite3_exec(db, sql, callback, cbArg, errmsg); #if DO_PROFILE prof->complete(); @@ -3175,6 +3203,15 @@ int retries = 0, rc; } #endif +/** + * Execute a step command on a prepared statement but add the ability to retry on error. + * + * It is assumed that binding has already taken place and that those bound + * vaiables are maintained for all retries. + * + * @param statement The prepared statement to step + * @return int The status of the final sqlite3_step that was issued + */ int Connection::SQLstep(sqlite3_stmt *statement) { int retries = 0, rc; @@ -3183,6 +3220,10 @@ int retries = 0, rc; #if DO_PROFILE ProfileItem *prof = new ProfileItem(sqlite3_sql(statement)); #endif + if (retries) + { + sqlite3_reset(statement); + } rc = sqlite3_step(statement); #if DO_PROFILE prof->complete(); @@ -3466,7 +3507,7 @@ SQLBuffer sql; const char *query = sql.coalesce(); char *zErrMsg = NULL; int rc; - sqlite3_stmt *stmt; + sqlite3_stmt *stmt = NULL; logSQL("GetTableSnapshots", query); @@ -3477,6 +3518,8 @@ SQLBuffer sql; { raiseError("get_table_snapshots", sqlite3_errmsg(dbHandle)); Logger::getLogger()->error("SQL statement: %s", query); + if (stmt) + sqlite3_finalize(stmt); delete[] query; return false; } diff --git a/C/plugins/storage/sqlite/common/connection_manager.cpp b/C/plugins/storage/sqlite/common/connection_manager.cpp index ee417de40f..3acc222dcf 100644 --- a/C/plugins/storage/sqlite/common/connection_manager.cpp +++ b/C/plugins/storage/sqlite/common/connection_manager.cpp @@ -391,6 +391,11 @@ int ConnectionManager::SQLExec(sqlite3 *dbHandle, const char *sqlCmd, char **err } else { + if (*errMsg) + { + sqlite3_free(*errMsg); + *errMsg = NULL; + } rc = sqlite3_exec(dbHandle, sqlCmd, NULL, NULL, errMsg); Logger::getLogger()->debug("SQLExec: rc :%d: ", rc); } diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index 5cb3a11aa1..1fb09eedb9 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -1307,8 +1307,9 @@ vector asset_codes; threadId << std::this_thread::get_id(); ReadingsCatalogue *readCatalogue = ReadingsCatalogue::getInstance(); + if (readCatalogue) { - // Attaches the needed databases if the queue is not empty + // Attaches the required databases if the queue is not empty AttachDbSync *attachSync = AttachDbSync::getInstance(); attachSync->lock(); @@ -1318,6 +1319,10 @@ vector asset_codes; } attachSync->unlock(); } + else + { + Logger::getLogger()->error("Readings catalogue not avialable"); + } try { if (dbHandle == NULL) diff --git a/C/plugins/storage/sqlite/common/readings_catalogue.cpp b/C/plugins/storage/sqlite/common/readings_catalogue.cpp index 984ab55ab2..9c146d80f1 100644 --- a/C/plugins/storage/sqlite/common/readings_catalogue.cpp +++ b/C/plugins/storage/sqlite/common/readings_catalogue.cpp @@ -2427,18 +2427,20 @@ int ReadingsCatalogue::SQLExec(sqlite3 *dbHandle, const char *sqlCmd, char **err { int retries = 0, rc; + if (errMsg) + { + *errMsg = NULL; + } Logger::getLogger()->debug("SQLExec: cmd :%s: ", sqlCmd); do { - if (errMsg == NULL) - { - rc = sqlite3_exec(dbHandle, sqlCmd, NULL, NULL, NULL); - } - else + if (errMsg && *errMsg) { - rc = sqlite3_exec(dbHandle, sqlCmd, NULL, NULL, errMsg); - Logger::getLogger()->debug("SQLExec: rc :%d: ", rc); + sqlite3_free(*errMsg); + *errMsg = NULL; } + rc = sqlite3_exec(dbHandle, sqlCmd, NULL, NULL, errMsg); + Logger::getLogger()->debug("SQLExec: rc :%d: ", rc); retries++; if (rc == SQLITE_LOCKED || rc == SQLITE_BUSY) diff --git a/C/services/storage/configuration.cpp b/C/services/storage/configuration.cpp index 8a7ea2b627..0b1efc876f 100644 --- a/C/services/storage/configuration.cpp +++ b/C/services/storage/configuration.cpp @@ -332,8 +332,9 @@ bool forceUpdate = false; { rval = "Use main plugin"; } - rp["default"].SetString(strdup(rval), strlen(rval)); - rp["value"].SetString(strdup(rval), strlen(rval)); + char *ncrval = strdup(rval); + rp["default"].SetString(ncrval, strlen(rval)); + rp["value"].SetString(ncrval, strlen(rval)); logger->info("Storage configuration cache is up to date"); return; } diff --git a/C/services/storage/include/storage_service.h b/C/services/storage/include/storage_service.h index 56a9cd44b1..6309208716 100644 --- a/C/services/storage/include/storage_service.h +++ b/C/services/storage/include/storage_service.h @@ -28,6 +28,7 @@ class StorageService : public ServiceHandler { public: StorageService(const string& name); + ~StorageService(); void start(std::string& coreAddress, unsigned short corePort); void stop(); void shutdown(); diff --git a/C/services/storage/storage.cpp b/C/services/storage/storage.cpp index d1d312303e..55361855f5 100644 --- a/C/services/storage/storage.cpp +++ b/C/services/storage/storage.cpp @@ -140,19 +140,19 @@ string logLevel = "warning"; exit(1); } - StorageService *service = new StorageService(myName); + StorageService service(myName); Logger::getLogger()->setMinLevel(logLevel); if (returnPlugin) { - cout << service->getPluginName() << " " << service->getPluginManagedStatus() << endl; + cout << service.getPluginName() << " " << service.getPluginManagedStatus() << endl; } else if (returnReadingsPlugin) { - cout << service->getReadingPluginName() << " " << service->getPluginManagedStatus() << endl; + cout << service.getReadingPluginName() << " " << service.getPluginManagedStatus() << endl; } else { - service->start(coreAddress, corePort); + service.start(coreAddress, corePort); } return 0; } @@ -239,6 +239,13 @@ unsigned short servicePort; api = new StorageApi(servicePort, threads); } +/** + * Storage Service destructor + */ +StorageService::~StorageService() +{ +} + /** * Start the storage service */ diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp index ce5bb55120..cb56133b57 100644 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -53,7 +53,7 @@ static void worker(StorageRegistry *registry) * the storage layer is minimally impacted by the registration and * delivery of these messages to interested microservices. */ -StorageRegistry::StorageRegistry() +StorageRegistry::StorageRegistry() : m_thread(NULL) { m_thread = new thread(worker, this); } @@ -64,7 +64,12 @@ StorageRegistry::StorageRegistry() StorageRegistry::~StorageRegistry() { m_running = false; - m_thread->join(); + if (m_thread) + { + m_thread->join(); + delete m_thread; + m_thread = NULL; + } } /** diff --git a/scripts/services/north_C b/scripts/services/north_C index b2dbbda0d1..379ca765f5 100755 --- a/scripts/services/north_C +++ b/scripts/services/north_C @@ -10,5 +10,28 @@ if [ ! -d "${FLEDGE_ROOT}" ]; then exit 1 fi +runvalgrind=n +if [ "$VALGRIND_NORTH" != "" ]; then + for i in "$@"; do + case $i in + --name=*) + name=i"`echo $i | sed -e s/--name=//`" + ;; + esac + done + services=$(echo $VALGRIND_NORTH | tr ";" "\n") + for service in $services; do + if [ "$service" = "$name" ]; then + runvalgrind=y + fi + done +fi + cd "${FLEDGE_ROOT}/services" -./fledge.services.north "$@" +if [ "$runvalgrind" = "y" ]; then + file=$HOME/north.${name}.valgrind.out + rm -f $file + valgrind --leak-check=full --trace-children=yes --log-file=$file ./fledge.services.north "$@" +else + ./fledge.services.north "$@" +fi diff --git a/scripts/services/south_c b/scripts/services/south_c index a3c0bfa5a8..6f3d2c13e9 100755 --- a/scripts/services/south_c +++ b/scripts/services/south_c @@ -11,5 +11,28 @@ fi cd "${FLEDGE_ROOT}/services" -./fledge.services.south "$@" +runvalgrind=n +if [ "$VALGRIND_SOUTH" != "" ]; then + for i in "$@"; do + case $i in + --name=*) + name=`echo $i | sed -e s/--name=//` + ;; + esac + done + services=$(echo $VALGRIND_SOUTH | tr ";" "\n") + for service in $services; do + if [ "$service" = "$name" ]; then + runvalgrind=y + fi + done +fi + +if [ "$runvalgrind" = "y" ]; then + file="$HOME/south.${name}.valgrind.out" + rm -f "$file" + valgrind --leak-check=full --trace-children=yes --log-file="$file" ./fledge.services.south "$@" +else + ./fledge.services.south "$@" +fi diff --git a/scripts/services/storage b/scripts/services/storage index 05131671ed..2fa276098c 100755 --- a/scripts/services/storage +++ b/scripts/services/storage @@ -74,5 +74,12 @@ if [[ "$1" != "--readingsPlugin" ]]; then fi # Run storage service -${storageExec} "$@" +if [[ "$VALGRIND_STORAGE" = "y" ]]; then + if [[ -f "$HOME/storage.valgrind.out" ]]; then + rm $HOME/storage.valgrind.out + fi + valgrind --leak-check=full --trace-children=yes --log-file=$HOME/storage.valgrind.out ${storageExec} "$@" +else + ${storageExec} "$@" +fi exit 0 From c9a344e0deb21739d6f2092711ee61f9db59a8fe Mon Sep 17 00:00:00 2001 From: Daniel Lazaro Date: Wed, 8 Mar 2023 09:17:34 -0800 Subject: [PATCH 183/499] Update CONTRIBUTING.md (#1000) Correcting typo Co-authored-by: Mark Riddoch --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 238488f1b4..e75e5ca9f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ the [fledge-iot Organization](https://github.com/fledge-iot) on GitHub. To give us feedback or make suggestions use the [Fledge Slack Channel](https://lfedge.slack.com/archives/CLJ7CNCAX). -If you find a security vulnerability within Fledge or any of it's plugins then we request that you inform us via email rather than by opening an issue in GitHub. This allows us to act on it without giving information that others might exploit. Any security vulnerability will be discussed at the project TCS and user will be informed of the need to upgrade via the Fledge slack channel. The email address to which vulnerabilities should be reported is security@dianomic.com. +If you find a security vulnerability within Fledge or any of it's plugins then we request that you inform us via email rather than by opening an issue in GitHub. This allows us to act on it without giving information that others might exploit. Any security vulnerability will be discussed at the project TSC and user will be informed of the need to upgrade via the Fledge slack channel. The email address to which vulnerabilities should be reported is security@dianomic.com. ## Pull requests From 0cd63aa700348e7504734c174e7851a7e6b00df1 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 9 Mar 2023 11:19:22 +0000 Subject: [PATCH 184/499] FOGL-7547 Add warning about unresolvable server hostname (#1007) Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/plugin.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 4f4ab2c8a9..2902578a84 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -529,6 +529,10 @@ PLUGIN_HANDLE plugin_init(ConfigCategory* configData) string PIServerEndpoint = configData->getValue("PIServerEndpoint"); string ADHRegions = configData->getValue("ADHRegions"); string ServerHostname = configData->getValue("ServerHostname"); + if (gethostbyname(ServerHostname.c_str()) == NULL) + { + Logger::getLogger()->warn("Unable to resolve server hostname '%s'. This should be a valid hostname or IP Address.", ServerHostname.c_str()); + } string ServerPort = configData->getValue("ServerPort"); string url; string NamingScheme = configData->getValue("NamingScheme"); From 67dbe4f9d00dfd52f49fcaf1224c40c0d9159387 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 10 Mar 2023 13:30:14 +0530 Subject: [PATCH 185/499] core api unit tests updated Signed-off-by: ashish-jabble --- python/fledge/services/core/api/auth.py | 15 +- python/fledge/services/core/api/filters.py | 4 +- .../services/core/api/plugins/remove.py | 2 +- python/fledge/services/core/api/task.py | 4 +- .../services/core/api/plugins/test_remove.py | 8 +- .../services/core/api/test_api_utils.py | 11 +- .../fledge/services/core/api/test_audit.py | 11 +- .../services/core/api/test_auth_mandatory.py | 227 ++++++++---------- .../services/core/api/test_auth_optional.py | 23 +- .../fledge/services/core/api/test_filters.py | 214 ++++++++++------- .../fledge/services/core/api/test_task.py | 40 ++- 11 files changed, 288 insertions(+), 271 deletions(-) diff --git a/python/fledge/services/core/api/auth.py b/python/fledge/services/core/api/auth.py index d7e0b06f68..73563f6ab1 100644 --- a/python/fledge/services/core/api/auth.py +++ b/python/fledge/services/core/api/auth.py @@ -53,7 +53,7 @@ USERNAME_REGEX_PATTERN = '^[a-zA-Z0-9_.-]+$' PASSWORD_REGEX_PATTERN = '((?=.*\d)(?=.*[A-Z])(?=.*\W).{6,}$)' PASSWORD_ERROR_MSG = 'Password must contain at least one digit, one lowercase, one uppercase & one special character ' \ - 'and length of minimum 6 characters' + 'and length of minimum 6 characters.' FORBIDDEN_MSG = 'Resource you were trying to reach is absolutely forbidden for some reason' @@ -505,7 +505,7 @@ async def update_me(request): msg = str(exc) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: - msg = "Nothing to update" + msg = "Nothing to update." raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"message": message}) @@ -536,24 +536,24 @@ async def update_user(request): user_data = {} if 'real_name' in data: if len(real_name.strip()) == 0: - msg = "Real Name should not be empty" + msg = "Real Name should not be empty." raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) else: user_data.update({"real_name": real_name.strip()}) if 'access_method' in data: if len(access_method.strip()) == 0: - msg = "Access method should not be empty" + msg = "Access method should not be empty." raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) else: valid_access_method = ('any', 'pwd', 'cert') if access_method not in valid_access_method: - msg = "Accepted access method values are {}".format(valid_access_method) + msg = "Accepted access method values are {}.".format(valid_access_method) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) user_data.update({"access_method": access_method.strip()}) if 'description' in data: user_data.update({"description": description.strip()}) if not user_data: - msg = "Nothing to update" + msg = "Nothing to update." raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) try: user = await User.Objects.update(user_id, user_data) @@ -624,7 +624,6 @@ async def update_password(request): # Remove OTT token for this user if there. __remove_ott_for_user(user_id) - except ValueError as ex: _logger.error(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) @@ -702,7 +701,7 @@ async def enable_user(request): except Exception as exc: msg = str(exc) raise web.HTTPInternalServerError(reason=str(exc), body=json.dumps({"message": msg})) - return web.json_response({'message': 'User with id:<{}> has been {} successfully'.format(int(user_id), _text)}) + return web.json_response({'message': 'User with id:<{}> has been {} successfully.'.format(int(user_id), _text)}) @has_permission("admin") diff --git a/python/fledge/services/core/api/filters.py b/python/fledge/services/core/api/filters.py index 4a011ac5b0..269ca1ec13 100644 --- a/python/fledge/services/core/api/filters.py +++ b/python/fledge/services/core/api/filters.py @@ -134,11 +134,11 @@ async def create_filter(request: web.Request) -> web.Response: return web.json_response({'filter': filter_name, 'description': filter_desc, 'value': category_info}) except ValueError as err: msg = str(err) - _LOGGER.error("Add filter, caught value error exception: {}".format(msg)) + _LOGGER.error("Add filter, caught value error: {}".format(msg)) raise web.HTTPNotFound(reason=msg) except TypeError as err: msg = str(err) - _LOGGER.error("Add filter, caught type error exception: {}".format(msg)) + _LOGGER.error("Add filter, caught type error: {}".format(msg)) raise web.HTTPBadRequest(reason=msg) except StorageServerError as ex: await _delete_configuration_category(storage, filter_name) # Revert configuration entry diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index e15281ab6e..a421b17d75 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -81,7 +81,7 @@ async def remove_plugin(request: web.Request) -> web.Response: else: get_tracked_plugins = await _check_plugin_usage(plugin_type, name) if get_tracked_plugins: - e = "{} cannot be removed. This is being used by {} instances".\ + e = "{} cannot be removed. This is being used by {} instances.".\ format(name, get_tracked_plugins[0]['service_list']) _logger.error(e) raise RuntimeError(e) diff --git a/python/fledge/services/core/api/task.py b/python/fledge/services/core/api/task.py index 4bb7989adf..8a1d98e07e 100644 --- a/python/fledge/services/core/api/task.py +++ b/python/fledge/services/core/api/task.py @@ -203,7 +203,7 @@ async def add_task(request): if result['count'] >= 1: msg = 'Unable to reuse name {0}, already used by a previous task.'.format(name) - _logger.exception(msg) + _logger.error(msg) raise web.HTTPBadRequest(reason=msg) # Check whether category name already exists @@ -226,7 +226,7 @@ async def add_task(request): try: res = await storage.insert_into_tbl("scheduled_processes", payload) except StorageServerError as ex: - _logger.error("Failed to create scheduled process due to {}".format(str(ex))) + _logger.error("Failed to create scheduled process due to {}".format(ex.error)) raise web.HTTPInternalServerError(reason='Failed to create north instance.') except Exception as ex: _logger.exception("Failed to create scheduled process due to {}".format(str(ex))) diff --git a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py index 3068360b01..2390e13ad5 100644 --- a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py +++ b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py @@ -101,7 +101,7 @@ async def async_mock(return_value): with patch.object(plugins_remove._logger, "error") as log_err_patch: resp = await client.delete('/fledge/plugins/{}/{}'.format(_type, name), data=None) assert 400 == resp.status - expected_msg = "{} cannot be removed. This is being used by {} instances".format(name, svc_list) + expected_msg = "{} cannot be removed. This is being used by {} instances.".format(name, svc_list) assert expected_msg == resp.reason result = await resp.text() response = json.loads(result) @@ -139,7 +139,7 @@ async def async_mock(return_value): resp = await client.delete('/fledge/plugins/{}/{}'.format(plugin_type, plugin_installed_dirname), data=None) assert 400 == resp.status - expected_msg = "{} cannot be removed. This is being used by {} instances".format( + expected_msg = "{} cannot be removed. This is being used by {} instances.".format( plugin_installed_dirname, notify_instances_list) assert expected_msg == resp.reason result = await resp.text() @@ -203,7 +203,7 @@ async def async_mock(return_value): assert 1 == log_info_patch.call_count log_info_patch.assert_called_once_with( 'No entry found for http_south plugin in asset tracker; ' - 'or {} plugin may have been added in disabled state & never used'.format(name)) + 'or {} plugin may have been added in disabled state & never used.'.format(name)) plugin_usage_patch.assert_called_once_with(_type, name) plugin_installed_patch.assert_called_once_with(_type, False) @@ -295,6 +295,6 @@ async def async_mock(return_value): assert 1 == log_info_patch.call_count log_info_patch.assert_called_once_with( 'No entry found for http_south plugin in asset tracker; ' - 'or {} plugin may have been added in disabled state & never used'.format(name)) + 'or {} plugin may have been added in disabled state & never used.'.format(name)) plugin_usage_patch.assert_called_once_with(_type, name) plugin_installed_patch.assert_called_once_with(_type, False) diff --git a/tests/unit/python/fledge/services/core/api/test_api_utils.py b/tests/unit/python/fledge/services/core/api/test_api_utils.py index 229a437623..67e278cc47 100644 --- a/tests/unit/python/fledge/services/core/api/test_api_utils.py +++ b/tests/unit/python/fledge/services/core/api/test_api_utils.py @@ -45,8 +45,12 @@ def test_get_plugin_info_value_error(self): patch_lib.assert_called_once_with(plugin_name, 'south') patch_util.assert_called_once_with('get_plugin_info') - @pytest.mark.parametrize("exc_name", [Exception, OSError, subprocess.CalledProcessError]) - def test_get_plugin_info_exception(self, exc_name): + @pytest.mark.parametrize("exc_name, msg", [ + (Exception, ""), + (OSError, ""), + (subprocess.CalledProcessError, "__init__() missing 2 required positional arguments: 'returncode' and 'cmd'") + ]) + def test_get_plugin_info_exception(self, exc_name, msg): plugin_name = 'OMF' plugin_lib_path = 'fledge/plugins/north/{}/lib{}'.format(plugin_name, plugin_name) with patch.object(utils, '_find_c_util', return_value='plugins/utils/get_plugin_info') as patch_util: @@ -56,8 +60,7 @@ def test_get_plugin_info_exception(self, exc_name): assert {} == utils.get_plugin_info(plugin_name, dir='south') assert 1 == patch_logger.call_count args, kwargs = patch_logger.call_args - assert '%s C plugin get info failed due to %s' == args[0] - assert plugin_name == args[1] + assert '{} C plugin get info failed due to {}'.format(plugin_name, msg) == args[0] patch_lib.assert_called_once_with(plugin_name, 'south') patch_util.assert_called_once_with('get_plugin_info') diff --git a/tests/unit/python/fledge/services/core/api/test_audit.py b/tests/unit/python/fledge/services/core/api/test_audit.py index 8975e6a5e0..63cf97ea94 100644 --- a/tests/unit/python/fledge/services/core/api/test_audit.py +++ b/tests/unit/python/fledge/services/core/api/test_audit.py @@ -225,13 +225,10 @@ async def test_create_audit_entry_with_bad_data(self, client, request_data, expe async def test_create_audit_entry_with_attribute_error(self, client): request_data = {"source": "LMTR", "severity": "blah", "details": {"message": "Engine oil pressure low"}} - with patch.object(audit._logger, "error", return_value=None) as audit_logger_patch: - with patch.object(AuditLogger, "__init__", return_value=None): - resp = await client.post('/fledge/audit', data=json.dumps(request_data)) - assert 404 == resp.status - assert 'severity type blah is not supported' == resp.reason - args, kwargs = audit_logger_patch.call_args - assert ('Error in create_audit_entry(): %s | %s', 'severity type blah is not supported', "'AuditLogger' object has no attribute 'blah'") == args + with patch.object(AuditLogger, "__init__", return_value=None): + resp = await client.post('/fledge/audit', data=json.dumps(request_data)) + assert 404 == resp.status + assert 'severity type blah is not supported' == resp.reason async def test_create_audit_entry_with_exception(self, client): request_data = {"source": "LMTR", "severity": "blah", "details": {"message": "Engine oil pressure low"}} diff --git a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py index eb5fe4d611..b5e0d32742 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py @@ -27,7 +27,8 @@ ADMIN_USER_HEADER = {'content-type': 'application/json', 'Authorization': 'admin_user_token'} NORMAL_USER_HEADER = {'content-type': 'application/json', 'Authorization': 'normal_user_token'} - +PASSWORD_ERROR_MSG = 'Password must contain at least one digit, one lowercase, one uppercase & one special character ' \ + 'and length of minimum 6 characters.' async def mock_coro(*args, **kwargs): return None if len(args) == 0 else args[0] @@ -65,42 +66,27 @@ async def auth_token_fixture(self, mocker, is_admin=True): return patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get @pytest.mark.parametrize("payload, msg", [ - ({}, "Username is required to create user"), - ({"username": 1}, "Values should be passed in string"), - ({"username": "bla"}, "Username should be of minimum 4 characters"), - ({"username": " b"}, "Username should be of minimum 4 characters"), - ({"username": "b "}, "Username should be of minimum 4 characters"), - ({"username": " b la"}, "Username should be of minimum 4 characters"), - ({"username": "b l A "}, "Username should be of minimum 4 characters"), - ({"username": "Bla"}, "Username should be of minimum 4 characters"), - ({"username": "BLA"}, "Username should be of minimum 4 characters"), - ({"username": "aj!aj"}, "Dot, hyphen, underscore special characters are allowed for username"), - ({"username": "aj.aj", "access_method": "PEM"}, "Invalid access method. Must be 'any' or 'cert' or 'pwd'"), - ({"username": "aj.aj", "access_method": 1}, "Values should be passed in string"), - ({"username": "aj.aj", "access_method": 'pwd'}, "Password should not be an empty"), - ({"username": "aj_123!"}, "Dot, hyphen, underscore special characters are allowed for username"), - ({"username": "aj_123", "password": 1}, "Password must contain at least one digit, one lowercase, one uppercase" - " & one special character and length of minimum 6 characters"), - ({"username": "12-aj", "password": "blah"}, "Password must contain at least one digit, one lowercase, one " - "uppercase & one special character and length of minimum 6 " - "characters"), - ({"username": "12-aj", "password": "12B l"}, "Password must contain at least one digit, one lowercase, one " - "uppercase & one special character and length of minimum 6 " - "characters"), - ({"username": "aj.123", "password": "a!23"}, "Password must contain at least one digit, one lowercase, " - "one uppercase & one special character and length of minimum 6 " - "characters"), - ({"username": "aj.123", "password": "A!23"}, "Password must contain at least one digit, one lowercase, " - "one uppercase & one special character and length of minimum 6 " - "characters"), - ({"username": "aj.aj", "access_method": "any", "password": "blah"}, "Password must contain at least " - "one digit, one lowercase, one uppercase " - "& one special character and length " - "of minimum 6 characters"), - ({"username": "aj.aj", "access_method": "pwd", "password": "blah"}, "Password must contain at least one digit," - " one lowercase, one uppercase & one " - "special character and length of minimum " - "6 characters") + ({}, "Username is required to create user."), + ({"username": 1}, "Values should be passed in string."), + ({"username": "bla"}, "Username should be of minimum 4 characters."), + ({"username": " b"}, "Username should be of minimum 4 characters."), + ({"username": "b "}, "Username should be of minimum 4 characters."), + ({"username": " b la"}, "Username should be of minimum 4 characters."), + ({"username": "b l A "}, "Username should be of minimum 4 characters."), + ({"username": "Bla"}, "Username should be of minimum 4 characters."), + ({"username": "BLA"}, "Username should be of minimum 4 characters."), + ({"username": "aj!aj"}, "Dot, hyphen, underscore special characters are allowed for username."), + ({"username": "aj.aj", "access_method": "PEM"}, "Invalid access method. Must be 'any' or 'cert' or 'pwd'."), + ({"username": "aj.aj", "access_method": 1}, "Values should be passed in string."), + ({"username": "aj.aj", "access_method": 'pwd'}, "Password should not be an empty."), + ({"username": "aj_123!"}, "Dot, hyphen, underscore special characters are allowed for username."), + ({"username": "aj_123", "password": 1}, PASSWORD_ERROR_MSG), + ({"username": "12-aj", "password": "blah"}, PASSWORD_ERROR_MSG), + ({"username": "12-aj", "password": "12B l"}, PASSWORD_ERROR_MSG), + ({"username": "aj.123", "password": "a!23"}, PASSWORD_ERROR_MSG), + ({"username": "aj.123", "password": "A!23"}, PASSWORD_ERROR_MSG), + ({"username": "aj.aj", "access_method": "any", "password": "blah"}, PASSWORD_ERROR_MSG), + ({"username": "aj.aj", "access_method": "pwd", "password": "blah"}, PASSWORD_ERROR_MSG) ]) async def test_create_bad_user(self, client, mocker, payload, msg): ret_val = [{'id': '1'}] @@ -128,7 +114,7 @@ async def test_create_bad_user(self, client, mocker, payload, msg): {"username": "aj.aj", "password": "F0gl@mp", "role_id": "blah"} ]) async def test_create_user_with_bad_role(self, client, mocker, request_data): - msg = "Invalid role id" + msg = "Invalid role id." patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -157,7 +143,7 @@ async def test_create_user_with_bad_role(self, client, mocker, request_data): patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') async def test_create_dupe_user_name(self, client): - msg = "Username already exists" + msg = "Username already exists." request_data = {"username": "ajtest", "password": "F0gl@mp"} valid_user = {'id': 1, 'uname': 'admin', 'role_id': '1'} # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -209,7 +195,7 @@ async def test_create_user(self, client): expected = {} expected.update(data) ret_val = {"response": "inserted", "rows_affected": 1} - msg = '{} user has been created successfully'.format(request_data['username']) + msg = '{} user has been created successfully.'.format(request_data['username']) valid_user = {'id': 1, 'uname': 'admin', 'role_id': '1'} # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -359,9 +345,9 @@ async def test_create_user_value_error(self, client): patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') @pytest.mark.parametrize("payload, status_reason", [ - ({"realname": "dd"}, 'Nothing to update'), - ({"real_name": ""}, 'Real Name should not be empty'), - ({"real_name": " "}, 'Real Name should not be empty') + ({"realname": "dd"}, 'Nothing to update.'), + ({"real_name": ""}, 'Real Name should not be empty.'), + ({"real_name": " "}, 'Real Name should not be empty.') ]) async def test_bad_update_me(self, client, mocker, payload, status_reason): patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( @@ -420,11 +406,11 @@ async def test_update_me(self, client, mocker, payload): patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user') @pytest.mark.parametrize("payload, status_reason", [ - ({"realname": "dd"}, 'Nothing to update'), - ({"real_name": ""}, 'Real Name should not be empty'), - ({"real_name": " "}, 'Real Name should not be empty'), - ({"access_method": ""}, 'Access method should not be empty'), - ({"access_method": "blah"}, "Accepted access method values are ('any', 'pwd', 'cert')") + ({"realname": "dd"}, 'Nothing to update.'), + ({"real_name": ""}, 'Real Name should not be empty.'), + ({"real_name": " "}, 'Real Name should not be empty.'), + ({"access_method": ""}, 'Access method should not be empty.'), + ({"access_method": "blah"}, "Accepted access method values are ('any', 'pwd', 'cert').") ]) async def test_bad_update_user(self, client, mocker, payload, status_reason): uid = 2 @@ -496,32 +482,32 @@ async def test_update_user(self, client, mocker, payload, exp_result): uid)) @pytest.mark.parametrize("request_data, msg", [ - ({}, "Current or new password is missing"), - ({"invalid": 1}, "Current or new password is missing"), - ({"current_password": 1}, "Current or new password is missing"), - ({"current_password": "fledge"}, "Current or new password is missing"), - ({"new_password": 1}, "Current or new password is missing"), - ({"new_password": "fledge"}, "Current or new password is missing"), - ({"current_pwd": "fledge", "new_pwd": "fledge1"}, "Current or new password is missing"), - ({"current_password": "F0gl@mp", "new_password": "F0gl@mp"}, "New password should not be same as current password"), - ({"current_password": "F0gl@mp", "new_password": "fledge"}, "Password must contain at least one digit, one lowercase, one uppercase & one special character and length of minimum 6 characters"), - ({"current_password": "F0gl@mp", "new_password": 1}, "Password must contain at least one digit, one lowercase, one uppercase & one special character and length of minimum 6 characters") + ({}, "Current or new password is missing."), + ({"invalid": 1}, "Current or new password is missing."), + ({"current_password": 1}, "Current or new password is missing."), + ({"current_password": "fledge"}, "Current or new password is missing."), + ({"new_password": 1}, "Current or new password is missing."), + ({"new_password": "fledge"}, "Current or new password is missing."), + ({"current_pwd": "fledge", "new_pwd": "fledge1"}, "Current or new password is missing."), + ({"current_password": "F0gl@mp", "new_password": "F0gl@mp"}, "New password should not be same as current password."), + ({"current_password": "F0gl@mp", "new_password": "fledge"}, PASSWORD_ERROR_MSG), + ({"current_password": "F0gl@mp", "new_password": 1}, PASSWORD_ERROR_MSG) ]) async def test_update_password_with_bad_data(self, client, request_data, msg): uid = 2 with patch.object(middleware._logger, 'debug') as patch_logger_debug: - with patch.object(auth._logger, 'warning') as patch_logger_warning: + with patch.object(auth._logger, 'error') as patch_logger_error: resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) assert 400 == resp.status assert msg == resp.reason - patch_logger_warning.assert_called_once_with(msg) - patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' - .format(uid)) + patch_logger_error.assert_called_once_with(msg) + patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', + '/fledge/user/{}/password'.format(uid)) async def test_update_password_with_invalid_current_password(self, client): request_data = {"current_password": "blah", "new_password": "F0gl@mp"} uid = 2 - msg = 'Invalid current password' + msg = 'Invalid current password.' # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(None) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(None)) @@ -536,12 +522,12 @@ async def test_update_password_with_invalid_current_password(self, client): patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password'.format(uid)) - @pytest.mark.parametrize("exception_name, status_code, msg", [ - (ValueError, 400, 'None'), - (User.DoesNotExist, 404, 'User with id:<2> does not exist'), - (User.PasswordAlreadyUsed, 400, 'The new password should be different from previous 3 used') + @pytest.mark.parametrize("exception_name, status_code, msg, log_level", [ + (ValueError, 400, 'None', "error"), + (User.DoesNotExist, 404, 'User with id:<2> does not exist.', "error"), + (User.PasswordAlreadyUsed, 400, 'The new password should be different from previous 3 used.', "warning") ]) - async def test_update_password_exceptions(self, client, exception_name, status_code, msg): + async def test_update_password_exceptions(self, client, exception_name, status_code, msg, log_level): request_data = {"current_password": "fledge", "new_password": "F0gl@mp"} uid = 2 # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -549,11 +535,11 @@ async def test_update_password_exceptions(self, client, exception_name, status_c with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'is_user_exists', return_value=_rv) as patch_user_exists: with patch.object(User.Objects, 'update', side_effect=exception_name(msg)) as patch_update: - with patch.object(auth._logger, 'warning') as patch_logger_warning: + with patch.object(auth._logger, log_level) as patch_auth_logger: resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) assert status_code == resp.status assert msg == resp.reason - patch_logger_warning.assert_called_once_with(msg) + patch_auth_logger.assert_called_once_with(msg) patch_update.assert_called_once_with(2, {'password': request_data['new_password']}) patch_user_exists.assert_called_once_with(str(uid), request_data['current_password']) patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' @@ -583,7 +569,7 @@ async def test_update_password(self, client): ret_val = {'response': 'updated', 'rows_affected': 1} uname = 'aj' user_id = 2 - msg = "Password has been updated successfully for user id:<{}>".format(user_id) + msg = "Password has been updated successfully for user id:<{}>.".format(user_id) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -608,10 +594,7 @@ async def test_update_password(self, client): patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' .format(user_id)) - @pytest.mark.parametrize("request_data", [ - 'blah', - '123blah' - ]) + @pytest.mark.parametrize("request_data", ['blah', '123blah']) async def test_delete_bad_user(self, client, mocker, request_data): msg = "invalid literal for int() with base 10: '{}'".format(request_data) ret_val = [{'id': '1'}] @@ -620,20 +603,20 @@ async def test_delete_bad_user(self, client, mocker, request_data): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: - with patch.object(auth._logger, 'warning') as patch_auth_logger_warn: + with patch.object(auth._logger, 'error') as patch_auth_logger: resp = await client.delete('/fledge/admin/{}/delete'.format(request_data), headers=ADMIN_USER_HEADER) assert 400 == resp.status assert msg == resp.reason - patch_auth_logger_warn.assert_called_once_with(msg) + patch_auth_logger.assert_called_once_with(msg) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', - '/fledge/admin/{}/delete'.format(request_data)) + patch_logger_debug.assert_called_once_with('Received %s request for %s', + 'DELETE', '/fledge/admin/{}/delete'.format(request_data)) async def test_delete_admin_user(self, client, mocker): - msg = "Super admin user can not be deleted" + msg = "Super admin user can not be deleted." ret_val = [{'id': '1'}] patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) @@ -652,7 +635,7 @@ async def test_delete_admin_user(self, client, mocker): patch_logger_debug.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/1/delete') async def test_delete_own_account(self, client, mocker): - msg = "You can not delete your own account" + msg = "You can not delete your own account." ret_val = [{'id': '2'}] patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker, is_admin=False) @@ -672,7 +655,7 @@ async def test_delete_own_account(self, client, mocker): async def test_delete_invalid_user(self, client, mocker): ret_val = {"response": "deleted", "rows_affected": 0} - msg = 'User with id:<2> does not exist' + msg = 'User with id:<2> does not exist.' patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -684,13 +667,13 @@ async def test_delete_invalid_user(self, client, mocker): _rv2 = asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv1) as patch_role_id: - with patch.object(auth._logger, 'warning') as patch_auth_logger_warning: + with patch.object(auth._logger, 'error') as patch_auth_logger: with patch.object(User.Objects, 'delete', return_value=_rv2) as patch_user_delete: resp = await client.delete('/fledge/admin/2/delete', headers=ADMIN_USER_HEADER) assert 404 == resp.status assert msg == resp.reason patch_user_delete.assert_called_once_with(2) - patch_auth_logger_warning.assert_called_once_with(msg) + patch_auth_logger.assert_called_once_with(msg) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -715,7 +698,7 @@ async def test_delete_user(self, client, mocker): resp = await client.delete('/fledge/admin/2/delete', headers=ADMIN_USER_HEADER) assert 200 == resp.status r = await resp.text() - assert {'message': 'User has been deleted successfully'} == json.loads(r) + assert {'message': 'User has been deleted successfully.'} == json.loads(r) patch_user_delete.assert_called_once_with(2) patch_auth_logger_info.assert_called_once_with('User with id:<2> has been deleted successfully.') patch_role_id.assert_called_once_with('admin') @@ -726,7 +709,7 @@ async def test_delete_user(self, client, mocker): @pytest.mark.parametrize("exception_name, code, msg", [ (ValueError, 400, 'None'), - (User.DoesNotExist, 404, 'User with id:<2> does not exist') + (User.DoesNotExist, 404, 'User with id:<2> does not exist.') ]) async def test_delete_user_exceptions(self, client, mocker, exception_name, code, msg): ret_val = [{'id': '1'}] @@ -735,13 +718,13 @@ async def test_delete_user_exceptions(self, client, mocker, exception_name, code # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: - with patch.object(auth._logger, 'warning') as patch_auth_logger_warn: + with patch.object(auth._logger, 'error') as patch_auth_logger: with patch.object(User.Objects, 'delete', side_effect=exception_name(msg)) as patch_user_delete: resp = await client.delete('/fledge/admin/2/delete', headers=ADMIN_USER_HEADER) assert code == resp.status assert msg == resp.reason patch_user_delete.assert_called_once_with(2) - patch_auth_logger_warn.assert_called_once_with(msg) + patch_auth_logger.assert_called_once_with(msg) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -782,7 +765,7 @@ async def test_logout(self, client, mocker): r = await resp.text() assert {'logout': True} == json.loads(r) patch_delete_user_token.assert_called_once_with("2") - patch_auth_logger_info.assert_called_once_with('User with id:<2> has been logged out successfully') + patch_auth_logger_info.assert_called_once_with('User with id:<2> has been logged out successfully.') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -796,11 +779,11 @@ async def test_logout_with_bad_user(self, client, mocker): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv) as patch_delete_user_token: - with patch.object(auth._logger, 'warning') as patch_logger: + with patch.object(auth._logger, 'error') as patch_auth_logger: resp = await client.put('/fledge/{}/logout'.format(user_id), headers=ADMIN_USER_HEADER) assert 404 == resp.status assert 'Not Found' == resp.reason - patch_logger.assert_called_once_with('Logout requested with bad user') + patch_auth_logger.assert_called_once_with('Logout requested with bad user.') patch_delete_user_token.assert_called_once_with(str(user_id)) patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -819,7 +802,7 @@ async def test_logout_me(self, client, mocker): r = await resp.text() assert {'logout': True} == json.loads(r) patch_delete_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_auth_logger_info.assert_called_once_with('User has been logged out successfully') + patch_auth_logger_info.assert_called_once_with('User has been logged out successfully.') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -831,19 +814,19 @@ async def test_logout_me_with_bad_token(self, client, mocker): mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) - with patch.object(auth._logger, 'warning') as patch_auth_logger_warn: + with patch.object(auth._logger, 'error') as patch_auth_logger: with patch.object(User.Objects, 'delete_token', return_value=_rv) as patch_delete_token: resp = await client.put('/fledge/logout', headers=ADMIN_USER_HEADER) assert 404 == resp.status patch_delete_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_auth_logger_warn.assert_called_once_with('Logout requested with bad user token') + patch_auth_logger.assert_called_once_with('Logout requested with bad user token.') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/logout') async def test_enable_with_super_admin_user(self, client, mocker): - msg = 'Restricted for Super Admin user' + msg = 'Restricted for Super Admin user.' ret_val = [{'id': '1'}] patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) @@ -865,9 +848,9 @@ async def test_enable_with_super_admin_user(self, client, mocker): patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1/enable') @pytest.mark.parametrize("request_data, msg", [ - ({}, "Nothing to enable user update"), - ({"enable": 1}, "Nothing to enable user update"), - ({"enabled": 1}, "Accepted values are True/False only"), + ({}, "Nothing to enable user update."), + ({"enable": 1}, "Nothing to enable user update."), + ({"enabled": 1}, "Accepted values are True/False only."), ]) async def test_enable_with_bad_data(self, client, mocker, request_data, msg): ret_val = [{'id': '1'}] @@ -928,7 +911,7 @@ async def test_enable_user(self, client, mocker, request_data): headers=ADMIN_USER_HEADER) assert 200 == resp.status r = await resp.text() - assert {"message": "User with id:<2> has been {} successfully".format(_text)} == json.loads(r) + assert {"message": "User with id:<2> has been {} successfully.".format(_text)} == json.loads(r) update_tbl_patch.assert_called_once_with('users', _payload) assert 2 == q_tbl_patch.call_count args, kwargs = q_tbl_patch.call_args_list[0] @@ -944,7 +927,7 @@ async def test_enable_user(self, client, mocker, request_data): patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/enable') async def test_reset_super_admin(self, client, mocker): - msg = 'Restricted for Super Admin user' + msg = 'Restricted for Super Admin user.' ret_val = [{'id': '1'}] patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) @@ -963,25 +946,25 @@ async def test_reset_super_admin(self, client, mocker): patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1/reset') - @pytest.mark.parametrize("request_data, msg", [ - ({}, "Nothing to update the user"), - ({"invalid": 1}, "Nothing to update the user"), - ({"password": "fledge"}, "Password must contain at least one digit, one lowercase, one uppercase & one special character and length of minimum 6 characters"), - ({"password": 1}, "Password must contain at least one digit, one lowercase, one uppercase & one special character and length of minimum 6 characters") + @pytest.mark.parametrize("request_data, msg, log_level", [ + ({}, "Nothing to update the user.", "warning"), + ({"invalid": 1}, "Nothing to update the user.", "warning"), + ({"password": "fledge"}, PASSWORD_ERROR_MSG, "error"), + ({"password": 1}, PASSWORD_ERROR_MSG, "error") ]) - async def test_reset_with_bad_data(self, client, mocker, request_data, msg): + async def test_reset_with_bad_data(self, client, mocker, request_data, msg, log_level): ret_val = [{'id': '1'}] patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: - with patch.object(auth._logger, 'warning') as patch_logger_warning: + with patch.object(auth._logger, log_level) as patch_auth_logger: resp = await client.put('/fledge/admin/2/reset', data=json.dumps(request_data), headers=ADMIN_USER_HEADER) assert 400 == resp.status assert msg == resp.reason - patch_logger_warning.assert_called_once_with(msg) + patch_auth_logger.assert_called_once_with(msg) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -990,7 +973,7 @@ async def test_reset_with_bad_data(self, client, mocker, request_data, msg): async def test_reset_with_bad_role(self, client, mocker): request_data = {"role_id": "blah"} - msg = "Invalid or bad role id" + msg = "Invalid or bad role id." patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -1003,12 +986,12 @@ async def test_reset_with_bad_role(self, client, mocker): with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv1) as patch_role_id: with patch.object(auth, 'is_valid_role', return_value=_rv2) as patch_role: - with patch.object(auth._logger, 'warning') as patch_logger_warning: + with patch.object(auth._logger, 'error') as patch_logger_error: resp = await client.put('/fledge/admin/2/reset', data=json.dumps(request_data), headers=ADMIN_USER_HEADER) assert 400 == resp.status assert msg == resp.reason - patch_logger_warning.assert_called_once_with(msg) + patch_logger_error.assert_called_once_with(msg) patch_role.assert_called_once_with(request_data['role_id']) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) @@ -1016,12 +999,12 @@ async def test_reset_with_bad_role(self, client, mocker): patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') - @pytest.mark.parametrize("exception_name, status_code, msg", [ - (ValueError, 400, 'None'), - (User.DoesNotExist, 404, 'User with id:<2> does not exist'), - (User.PasswordAlreadyUsed, 400, 'The new password should be different from previous 3 used') + @pytest.mark.parametrize("exception_name, status_code, msg, log_level", [ + (ValueError, 400, 'None', "error"), + (User.DoesNotExist, 404, 'User with id:<2> does not exist.', "error"), + (User.PasswordAlreadyUsed, 400, 'The new password should be different from previous 3 used.', "warning") ]) - async def test_reset_exceptions(self, client, mocker, exception_name, status_code, msg): + async def test_reset_exceptions(self, client, mocker, exception_name, status_code, msg, log_level): request_data = {'role_id': '2'} user_id = 2 patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( @@ -1037,12 +1020,12 @@ async def test_reset_exceptions(self, client, mocker, exception_name, status_cod with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv1) as patch_role_id: with patch.object(auth, 'is_valid_role', return_value=_rv2) as patch_role: with patch.object(User.Objects, 'update', side_effect=exception_name(msg)) as patch_update: - with patch.object(auth._logger, 'warning') as patch_logger_warning: + with patch.object(auth._logger, log_level) as patch_auth_logger: resp = await client.put('/fledge/admin/{}/reset'.format(user_id), data=json.dumps(request_data), headers=ADMIN_USER_HEADER) assert status_code == resp.status assert msg == resp.reason - patch_logger_warning.assert_called_once_with(msg) + patch_auth_logger.assert_called_once_with(msg) patch_update.assert_called_once_with(str(user_id), request_data) patch_role.assert_called_once_with(request_data['role_id']) patch_role_id.assert_called_once_with('admin') @@ -1085,7 +1068,7 @@ async def test_reset_unknown_exception(self, client, mocker): async def test_reset_role_and_password(self, client, mocker): request_data = {'role_id': '2', 'password': 'Test@123'} user_id = 2 - msg = 'User with id:<{}> has been updated successfully'.format(user_id) + msg = 'User with id:<{}> has been updated successfully.'.format(user_id) ret_val = {'response': 'updated', 'rows_affected': 1} patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) @@ -1157,7 +1140,8 @@ async def async_get_user(): @pytest.mark.skip(reason="Request mock required") @pytest.mark.parametrize("auth_method, request_data, ret_val, expected", [ - ("certificate", {"username": "admin", "password": "fledge"}, (1, "token1", True), "Invalid authentication method, use certificate instead."), + ("certificate", {"username": "admin", "password": "fledge"}, (1, "token1", True), + "Invalid authentication method, use certificate instead."), ]) async def test_login_auth_exception1(self, client, auth_method, request_data, ret_val, expected): async def async_mock(): @@ -1172,7 +1156,8 @@ async def async_mock(): @pytest.mark.skip(reason="Request mock required") @pytest.mark.parametrize("auth_method, request_data, ret_val, expected", [ - ("password", "-----BEGIN CERTIFICATE----- Test -----END CERTIFICATE-----", (2, "token2", False), "Invalid authentication method, use password instead.") + ("password", "-----BEGIN CERTIFICATE----- Test -----END CERTIFICATE-----", + (2, "token2", False), "Invalid authentication method, use password instead.") ]) async def test_login_auth_exception2(self, client, auth_method, request_data, ret_val, expected): TEXT_HEADER = {'content-type': 'text/plain'} diff --git a/tests/unit/python/fledge/services/core/api/test_auth_optional.py b/tests/unit/python/fledge/services/core/api/test_auth_optional.py index 1707d35990..e6a7d55ecb 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_optional.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_optional.py @@ -122,22 +122,22 @@ async def test_get_user_by_param(self, client, request_params, exp_result, arg1, async def test_get_user_exception_by_param(self, client, request_params, error_msg, arg1, arg2): with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(User.Objects, 'get', side_effect=User.DoesNotExist(error_msg)) as patch_user_get: - with patch.object(auth._logger, 'warning') as patch_logger: + with patch.object(auth._logger, 'warning') as patch_auth_logger: resp = await client.get('/fledge/user{}'.format(request_params)) assert 404 == resp.status assert error_msg == resp.reason - patch_logger.assert_called_once_with(error_msg) + patch_auth_logger.assert_called_once_with(error_msg) patch_user_get.assert_called_once_with(arg1, arg2) patch_logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') @pytest.mark.parametrize("request_params", ['?id=0', '?id=blah', '?id=-1']) async def test_get_bad_user_id_param_exception(self, client, request_params): with patch.object(middleware._logger, 'debug') as patch_logger_info: - with patch.object(auth._logger, 'warning') as patch_logger: + with patch.object(auth._logger, 'error') as patch_auth_logger: resp = await client.get('/fledge/user{}'.format(request_params)) assert 400 == resp.status assert 'Bad user id' == resp.reason - patch_logger.assert_called_once_with('Get user requested with bad user id') + patch_auth_logger.assert_called_once_with('Get user requested with bad user id.') patch_logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') @pytest.mark.parametrize("request_data", [ @@ -152,11 +152,11 @@ async def test_get_bad_user_id_param_exception(self, client, request_params): ]) async def test_bad_login(self, client, request_data): with patch.object(middleware._logger, 'debug') as patch_logger_info: - with patch.object(auth._logger, 'warning') as patch_logger: + with patch.object(auth._logger, 'warning') as patch_auth_logger: resp = await client.post('/fledge/login', data=json.dumps(request_data)) assert 400 == resp.status assert 'Username or password is missing' == resp.reason - patch_logger.assert_called_once_with('Username and password are required to login') + patch_auth_logger.assert_called_once_with('Username and password are required to login.') patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/login') @pytest.mark.parametrize("request_data, status_code, exception_name, msg", [ @@ -164,7 +164,8 @@ async def test_bad_login(self, client, request_data): ({"username": "admin", "password": "blah"}, 404, User.PasswordDoesNotMatch, 'Username or Password do not match'), ({"username": "admin", "password": 123}, 404, User.PasswordDoesNotMatch, 'Username or Password do not match'), ({"username": 1, "password": 1}, 404, ValueError, 'Username should be a valid string'), - ({"username": "user", "password": "fledge"}, 401, User.PasswordExpired, 'Your password has been expired. Please set your password again') + ({"username": "user", "password": "fledge"}, 401, User.PasswordExpired, + 'Your password has been expired. Please set your password again.') ]) async def test_login_exception(self, client, request_data, status_code, exception_name, msg): @@ -177,11 +178,11 @@ async def test_login_exception(self, client, request_data, status_code, exceptio with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(User.Objects, 'login', side_effect=exception_name(msg)) as patch_user_login: with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv) as patch_delete_token: - with patch.object(auth._logger, 'warning') as patch_logger: + with patch.object(auth._logger, 'warning') as patch_auth_logger: resp = await client.post('/fledge/login', data=json.dumps(request_data)) assert status_code == resp.status assert msg == resp.reason - patch_logger.assert_called_once_with(msg) + patch_auth_logger.assert_called_once_with(msg) if status_code == 401: patch_delete_token.assert_called_once_with(msg) # TODO: host arg patch transport.request.extra_info @@ -207,7 +208,7 @@ async def async_mock(): with patch.object(middleware._logger, 'debug') as patch_logger_info: with patch.object(User.Objects, 'login', return_value=_rv) as patch_user_login: - with patch.object(auth._logger, 'info') as patch_logger: + with patch.object(auth._logger, 'info') as patch_auth_logger: resp = await client.post('/fledge/login', data=json.dumps(request_data)) assert 200 == resp.status r = await resp.text() @@ -215,7 +216,7 @@ async def async_mock(): assert ret_val[0] == actual['uid'] assert ret_val[1] == actual['token'] assert ret_val[2] == actual['admin'] - patch_logger.assert_called_once_with('User with username:<{}> logged in successfully.'.format(request_data['username'])) + patch_auth_logger.assert_called_once_with('User with username:<{}> logged in successfully.'.format(request_data['username'])) # TODO: host arg patch transport.request.extra_info args, kwargs = patch_user_login.call_args assert request_data['username'] == args[0] diff --git a/tests/unit/python/fledge/services/core/api/test_filters.py b/tests/unit/python/fledge/services/core/api/test_filters.py index 5eff7d9180..e24ca8d9ee 100644 --- a/tests/unit/python/fledge/services/core/api/test_filters.py +++ b/tests/unit/python/fledge/services/core/api/test_filters.py @@ -64,13 +64,15 @@ async def get_filters(): async def test_get_filters_storage_exception(self, client): storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'query_tbl', side_effect=StorageServerError(None, None, error='something went wrong')) as query_tbl_patch: - with patch.object(_LOGGER, 'exception') as log_exc: + with patch.object(storage_client_mock, 'query_tbl', side_effect=StorageServerError( + None, None, error='something went wrong')) as query_tbl_patch: + with patch.object(_LOGGER, 'error') as patch_logger: resp = await client.get('/fledge/filter') assert 500 == resp.status assert "something went wrong" == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Get filters, caught exception: %s', 'something went wrong') + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with('Get all filters, caught exception: {}'.format( + 'something went wrong')) query_tbl_patch.assert_called_once_with('filters') async def test_get_filters_exception(self, client): @@ -181,13 +183,15 @@ async def test_get_filter_by_name_storage_error(self, client): storage_client_mock = MagicMock(StorageClientAsync) cf_mgr = ConfigurationManager(storage_client_mock) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(cf_mgr, 'get_category_all_items', side_effect=StorageServerError(None, None, error='something went wrong')) as get_cat_info_patch: - with patch.object(_LOGGER, 'exception') as log_exc: + with patch.object(cf_mgr, 'get_category_all_items', side_effect=StorageServerError( + None, None, error='something went wrong')) as get_cat_info_patch: + with patch.object(_LOGGER, 'error') as patch_logger: resp = await client.get('/fledge/filter/{}'.format(filter_name)) assert 500 == resp.status assert "something went wrong" == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Get filter: %s, caught exception: %s', filter_name, 'something went wrong') + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with('Get {} filter, caught exception: {}'.format( + filter_name, 'something went wrong')) get_cat_info_patch.assert_called_once_with(filter_name) async def test_get_filter_by_name_type_error(self, client): @@ -220,12 +224,13 @@ async def test_get_filter_by_name_exception(self, client): {"blah": "blah"} ]) async def test_bad_create_filter(self, client, data): - with patch.object(_LOGGER, 'exception') as log_exc: + msg = "Filter name, plugin name are mandatory." + with patch.object(_LOGGER, 'error') as log_exc: resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps(data)) assert 400 == resp.status - assert 'Filter name, plugin name are mandatory.' == resp.reason + assert msg == resp.reason assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filter, caught exception: Filter name, plugin name are mandatory.') + log_exc.assert_called_once_with('Add filter, caught type error: {}'.format(msg)) async def test_create_filter_value_error_1(self, client): storage_client_mock = MagicMock(StorageClientAsync) @@ -236,15 +241,16 @@ async def test_create_filter_value_error_1(self, client): _rv = await self.async_mock({"result": "test"}) else: _rv = asyncio.ensure_future(self.async_mock({"result": "test"})) - + msg = "This 'test' filter already exists" with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps({"name": "test", "plugin": "benchmark"})) + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( + {"name": "test", "plugin": "benchmark"})) assert 404 == resp.status - assert "This 'test' filter already exists" == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with("Add filter, caught exception: This 'test' filter already exists") + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with("Add filter, caught value error: {}".format(msg)) get_cat_info_patch.assert_called_once_with(category_name='test') async def test_create_filter_value_error_2(self, client): @@ -257,16 +263,16 @@ async def test_create_filter_value_error_2(self, client): _rv = await self.async_mock(None) else: _rv = asyncio.ensure_future(self.async_mock(None)) - + msg = "Can not get 'plugin_info' detail from plugin '{}'".format(plugin_name) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: with patch.object(apiutils, 'get_plugin_info', return_value=None) as api_utils_patch: - with patch.object(_LOGGER, 'exception') as log_exc: + with patch.object(_LOGGER, 'error') as patch_logger: resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps({"name": "test", "plugin": plugin_name})) assert 404 == resp.status - assert "Can not get 'plugin_info' detail from plugin '{}'".format(plugin_name) == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with("Add filter, caught exception: Can not get 'plugin_info' detail from plugin '{}'".format(plugin_name)) + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with("Add filter, caught value error: {}".format(msg)) api_utils_patch.assert_called_once_with(plugin_name, dir='filter') get_cat_info_patch.assert_called_once_with(category_name='test') @@ -280,16 +286,20 @@ async def test_create_filter_value_error_3(self, client): _rv = await self.async_mock(None) else: _rv = asyncio.ensure_future(self.async_mock(None)) - + msg = "Loaded plugin 'python35', type 'south', doesn't match the specified one '{}', type 'filter'".format( + plugin_name) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(apiutils, 'get_plugin_info', return_value={"config": {'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', 'default': 'python35'}}, "type": "south"}) as api_utils_patch: - with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps({"name": "test", "plugin": plugin_name})) + with patch.object(apiutils, 'get_plugin_info', return_value= + {"config": {'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', + 'default': 'python35'}}, "type": "south"}) as api_utils_patch: + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( + {"name": "test", "plugin": plugin_name})) assert 404 == resp.status - assert "Loaded plugin 'python35', type 'south', doesn't match the specified one '{}', type 'filter'".format(plugin_name) == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with("Add filter, caught exception: Loaded plugin 'python35', type 'south', doesn't match the specified one '{}', type 'filter'".format(plugin_name)) + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with("Add filter, caught value error: {}".format(msg)) api_utils_patch.assert_called_once_with(plugin_name, dir='filter') get_cat_info_patch.assert_called_once_with(category_name='test') @@ -305,20 +315,26 @@ async def test_create_filter_value_error_4(self, client): else: _rv1 = asyncio.ensure_future(self.async_mock(None)) _rv2 = asyncio.ensure_future(self.async_mock({'count': 0, 'rows': []})) - + msg = "filter_config must be a JSON object" with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv1) as get_cat_info_patch: with patch.object(apiutils, 'get_plugin_info', return_value={"config": {'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', 'default': 'filter'}}, "type": "filter"}) as api_utils_patch: with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=_rv2) as query_tbl_patch: with patch.object(storage_client_mock, 'insert_into_tbl', return_value=_rv1) as insert_tbl_patch: with patch.object(cf_mgr, 'create_category', return_value=_rv1) as create_cat_patch: - with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps({"name": "test", "plugin": plugin_name, "filter_config": "blah"})) + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( + {"name": "test", "plugin": plugin_name, "filter_config": "blah"})) assert 404 == resp.status - assert "filter_config must be a JSON object" == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with("Add filter, caught exception: filter_config must be a JSON object") - create_cat_patch.assert_called_once_with(category_description="Configuration of 'test' filter for plugin 'filter'", category_name='test', category_value={'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', 'default': 'filter'}}, keep_original_items=True) + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with( + "Add filter, caught value error: {}".format(msg)) + create_cat_patch.assert_called_once_with( + category_description="Configuration of 'test' filter for plugin 'filter'", + category_name='test', category_value= + {'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', + 'default': 'filter'}}, keep_original_items=True) args, kwargs = insert_tbl_patch.call_args_list[0] assert 'filters' == args[0] assert {"name": "test", "plugin": "filter"} == json.loads(args[1]) @@ -344,15 +360,20 @@ async def test_create_filter_storage_error(self, client): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(apiutils, 'get_plugin_info', return_value={"config": {'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', 'default': 'filter'}}, "type": "filter"}) as api_utils_patch: - with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=StorageServerError(None, None, error='something went wrong')): + with patch.object(apiutils, 'get_plugin_info', return_value={ + "config": {'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', + 'default': 'filter'}}, "type": "filter"}) as api_utils_patch: + with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=StorageServerError( + None, None, error='something went wrong')): with patch.object(filters, '_delete_configuration_category', return_value=_rv) as _delete_cfg_patch: with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps({"name": name, "plugin": plugin_name})) + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( + {"name": name, "plugin": plugin_name})) assert 500 == resp.status assert 'Failed to create filter.' == resp.reason assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Failed to create filter. %s', 'something went wrong') + log_exc.assert_called_once_with('Failed to create filter with: {}'.format( + 'something went wrong')) args, kwargs = _delete_cfg_patch.call_args assert name == args[1] api_utils_patch.assert_called_once_with(plugin_name, dir='filter') @@ -366,11 +387,12 @@ async def test_create_filter_exception(self, client): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', side_effect=Exception) as get_cat_info_patch: with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps({"name": name, "plugin": plugin_name})) + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( + {"name": name, "plugin": plugin_name})) assert 500 == resp.status assert resp.reason is '' assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filter, caught exception: %s', '') + log_exc.assert_called_once_with('Add filter, caught exception: ') get_cat_info_patch.assert_called_once_with(category_name=name) async def test_create_filter(self, client): @@ -459,12 +481,13 @@ def q_result(*args): assert 200 == resp.status r = await resp.text() json_response = json.loads(r) - assert {'result': 'Filter AssetFilter deleted successfully'} == json_response + assert {'result': 'Filter AssetFilter deleted successfully.'} == json_response args, kwargs = update_tbl_patch.call_args assert 'asset_tracker' == args[0] args, kwargs = delete_cfg_patch.call_args assert filter_name == args[1] - delete_tbl_patch.assert_called_once_with('filters', '{"where": {"column": "name", "condition": "=", "value": "AssetFilter"}}') + delete_tbl_patch.assert_called_once_with( + 'filters', '{"where": {"column": "name", "condition": "=", "value": "AssetFilter"}}') async def test_delete_filter_value_error(self, client): storage_client_mock = MagicMock(StorageClientAsync) @@ -504,22 +527,27 @@ async def test_delete_filter_storage_error(self, client): storage_client_mock = MagicMock(StorageClientAsync) filter_name = "AssetFilter" with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=StorageServerError(None, None, error='something went wrong')) as get_cat_info_patch: - with patch.object(_LOGGER, 'exception') as log_exc: + with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=StorageServerError( + None, None, error='something went wrong')) as get_cat_info_patch: + with patch.object(_LOGGER, 'exception') as patch_logger: resp = await client.delete('/fledge/filter/{}'.format(filter_name)) assert 500 == resp.status assert "something went wrong" == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Delete filter: %s, caught exception: %s', filter_name, 'something went wrong') - get_cat_info_patch.assert_called_once_with('filters', '{"where": {"column": "name", "condition": "=", "value": "AssetFilter"}}') + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with('Delete {} filter, caught exception: {}'.format( + filter_name, 'something went wrong')) + get_cat_info_patch.assert_called_once_with( + 'filters', '{"where": {"column": "name", "condition": "=", "value": "AssetFilter"}}') async def test_add_filter_pipeline_type_error(self, client): - with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.put('/fledge/filter/{}/pipeline'.format("bench"), data=json.dumps({"pipeline": "AssetFilter"})) + msg = "Pipeline must be a list of filters or an empty value" + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.put('/fledge/filter/{}/pipeline'.format("bench"), data=json.dumps( + {"pipeline": "AssetFilter"})) assert 400 == resp.status - assert "Pipeline must be a list of filters or an empty value" == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filters pipeline, caught exception: %s', 'Pipeline must be a list of filters or an empty value') + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with('Add filters pipeline, caught type error: {}'.format(msg)) @pytest.mark.parametrize("request_param, param, val", [ ('?append_filter=T', 'append_filter', 't'), @@ -537,11 +565,12 @@ async def test_add_filter_pipeline_type_error(self, client): ]) async def test_add_filter_pipeline_bad_request_param_val(self, client, request_param, param, val): user = "bench" - with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.put('/fledge/filter/{}/pipeline{}'.format(user, request_param), data=json.dumps({"pipeline": ["AssetFilter"]})) + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.put('/fledge/filter/{}/pipeline{}'.format(user, request_param), data=json.dumps( + {"pipeline": ["AssetFilter"]})) assert 404 == resp.status assert "Only 'true' and 'false' are allowed for {}. {} given.".format(param, val) == resp.reason - assert 1 == log_exc.call_count + assert 1 == patch_logger.call_count async def test_add_filter_pipeline_value_error_1(self, client): user = "bench" @@ -553,14 +582,17 @@ async def test_add_filter_pipeline_value_error_1(self, client): _rv = await self.async_mock(None) else: _rv = asyncio.ensure_future(self.async_mock(None)) - + msg = "No such '{}' category found.".format(user) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps({"pipeline": ["AssetFilter"]})) + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps( + {"pipeline": ["AssetFilter"]})) assert 404 == resp.status - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filters pipeline, caught exception: %s', "No such '{}' category found.".format(user)) + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with( + 'Add filters pipeline, caught value error: {}'.format(msg)) get_cat_info_patch.assert_called_once_with(category_name=user) async def test_add_filter_pipeline_value_error_2(self, client): @@ -573,14 +605,17 @@ async def test_add_filter_pipeline_value_error_2(self, client): _rv = await self.async_mock(None) else: _rv = asyncio.ensure_future(self.async_mock(None)) - + msg = "No such '{}' category found.".format(user) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps({"pipeline": ["AssetFilter"]})) + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps( + {"pipeline": ["AssetFilter"]})) assert 404 == resp.status - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filters pipeline, caught exception: %s', "No such '{}' category found.".format(user)) + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with( + 'Add filters pipeline, caught value error: {}'.format(msg)) get_cat_info_patch.assert_called_once_with(category_name=user) async def test_add_filter_pipeline_value_error_3(self, client): @@ -598,16 +633,18 @@ async def test_add_filter_pipeline_value_error_3(self, client): else: _rv1 = asyncio.ensure_future(self.async_mock(cat_info)) _rv2 = asyncio.ensure_future(self.async_mock({'count': 1, 'rows': []})) - + msg = "No such 'AssetFilter' filter found in filters table." with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv1) as get_cat_info_patch: with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=_rv2) as query_tbl_patch: - with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps({"pipeline": ["AssetFilter"]})) + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps( + {"pipeline": ["AssetFilter"]})) assert 404 == resp.status - assert "No such 'AssetFilter' filter found in filters table." == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filters pipeline, caught exception: %s', "No such 'AssetFilter' filter found in filters table.") + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with( + 'Add filters pipeline, caught value error: {}'.format(msg)) query_tbl_patch.assert_called_once_with('filters', '{"where": {"column": "name", "condition": "=", "value": "AssetFilter"}}') get_cat_info_patch.assert_called_once_with(category_name=user) @@ -632,7 +669,7 @@ async def test_add_filter_pipeline_value_error_4(self, client): _rv1 = asyncio.ensure_future(self.async_mock(cat_info)) _rv2 = asyncio.ensure_future(self.async_mock(query_tbl_payload_res)) _rv3 = asyncio.ensure_future(self.async_mock(None)) - + msg = 'No detail found for user: {} and filter: filter'.format(user) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv1) as get_cat_info_patch: @@ -645,13 +682,14 @@ async def test_add_filter_pipeline_value_error_4(self, client): with patch.object(filters, '_add_child_filters', return_value=_rv3) as _add_child_patch: with patch.object(cf_mgr, 'get_category_item', return_value=_rv3) as get_cat_item_patch: - with patch.object(_LOGGER, 'exception') as log_exc: + with patch.object(_LOGGER, 'error') as patch_logger: resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps({"pipeline": ["AssetFilter"]})) assert 404 == resp.status - assert 'No detail found for user: {} and filter: filter'.format(user) == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filters pipeline, caught exception: %s', 'No detail found for user: bench and filter: filter') + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with( + 'Add filters pipeline, caught value error: {}'.format(msg)) get_cat_item_patch.assert_called_once_with(user, 'filter') args, kwargs = _add_child_patch.call_args assert user == args[2] @@ -682,12 +720,13 @@ async def test_add_filter_pipeline_storage_error(self, client): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=StorageServerError(None, None, error='something went wrong')): - with patch.object(_LOGGER, 'exception') as log_exc: + with patch.object(_LOGGER, 'error') as patch_logger: resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps({"pipeline": ["AssetFilter"]})) assert 500 == resp.status assert "something went wrong" == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filters pipeline, caught exception: %s', 'something went wrong') + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with('Add filters pipeline, caught storage error: {}'.format( + 'something went wrong')) get_cat_info_patch.assert_called_once_with(category_name=user) async def test_add_filter_pipeline(self, client): @@ -858,15 +897,15 @@ async def test_get_filter_pipeline_key_error(self, client): _rv = await self.async_mock({}) else: _rv = asyncio.ensure_future(self.async_mock({})) - + msg = "No filter pipeline exists for {}.".format(user) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(_LOGGER, 'info') as log_exc: + with patch.object(_LOGGER, 'error') as patch_logger: resp = await client.get('/fledge/filter/{}/pipeline'.format(user)) assert 404 == resp.status - assert "No filter pipeline exists for {}".format(user) == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('No filter pipeline exists for {}'.format(user)) + assert msg == resp.reason + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with(msg) get_cat_info_patch.assert_called_once_with(category_name=user) async def test_get_filter_pipeline_storage_error(self, client): @@ -880,7 +919,8 @@ async def test_get_filter_pipeline_storage_error(self, client): assert 500 == resp.status assert "something went wrong" == resp.reason assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Get pipeline: %s, caught exception: %s', user, 'something went wrong') + log_exc.assert_called_once_with('Get {} filter pipeline, caught exception: {}'.format( + user, 'something went wrong')) get_cat_info_patch.assert_called_once_with(category_name=user) async def test_get_filter_pipeline_exception(self, client): diff --git a/tests/unit/python/fledge/services/core/api/test_task.py b/tests/unit/python/fledge/services/core/api/test_task.py index 5e765cf217..067f1bc4c3 100644 --- a/tests/unit/python/fledge/services/core/api/test_task.py +++ b/tests/unit/python/fledge/services/core/api/test_task.py @@ -22,7 +22,7 @@ from fledge.common.configuration_manager import ConfigurationManager from fledge.services.core.api import task from fledge.services.core.api.plugins import common -from fledge.services.core.api.service import _logger +from fledge.services.core.api.task import _logger __author__ = "Amarendra K Sinha" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -107,7 +107,7 @@ def q_result(*arg): with patch.object(common, 'load_and_fetch_python_plugin_info', side_effect=[mock_plugin_info]): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(_logger, 'exception') as ex_logger: + with patch.object(_logger, 'exception') as patch_logger: with patch.object(c_mgr, 'get_category_all_items', return_value=_rv) as patch_get_cat_info: with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result): @@ -115,7 +115,7 @@ def q_result(*arg): resp = await client.post('/fledge/scheduled/task', data=json.dumps(data)) assert 500 == resp.status assert 'Failed to create north instance.' == resp.reason - assert 1 == ex_logger.call_count + assert 1 == patch_logger.call_count patch_get_cat_info.assert_called_once_with(category_name=data['name']) async def test_dupe_category_name_add_task(self, client): @@ -304,20 +304,11 @@ async def q_result(*arg): assert p['script'] == '["tasks/north_c"]' patch_get_cat_info.assert_called_once_with(category_name=data['name']) - @pytest.mark.parametrize( - "expected_count," - "expected_http_code," - "expected_message", - [ - ( 1, 400, '400: Unable to reuse name north bound, already used by a previous task.'), - (10, 400, '400: Unable to reuse name north bound, already used by a previous task.') - ] - ) - async def test_add_task_twice(self, - client, - expected_count, - expected_http_code, - expected_message): + @pytest.mark.parametrize("expected_count, expected_http_code, expected_message", [ + (1, 400, '400: Unable to reuse name north bound, already used by a previous task.'), + (10, 400, '400: Unable to reuse name north bound, already used by a previous task.') + ]) + async def test_add_task_twice(self, client, expected_count, expected_http_code, expected_message): @asyncio.coroutine def q_result(*arg): @@ -351,15 +342,16 @@ def q_result(*arg): } storage_client_mock = MagicMock(StorageClientAsync) - with patch.object(_logger, 'exception') as ex_logger: + with patch.object(_logger, 'error') as patch_logger: with patch.object(common, 'load_and_fetch_python_plugin_info', side_effect=[mock_plugin_info]): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result): - resp = await client.post('/fledge/scheduled/task', data=json.dumps(data)) - result = await resp.text() - assert resp.status == expected_http_code - assert result == expected_message - assert 1 == ex_logger.call_count + with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result): + resp = await client.post('/fledge/scheduled/task', data=json.dumps(data)) + result = await resp.text() + assert resp.status == expected_http_code + assert result == expected_message + print(expected_message) + assert 1 == patch_logger.call_count async def test_add_task_with_config(self, client): async def async_mock_get_schedule(): From 1e801cea19e2d37097ca5501a6defdc69934a018 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 10 Mar 2023 15:00:25 +0530 Subject: [PATCH 186/499] package Name for installed plugins from external .Package file if exists Signed-off-by: ashish-jabble --- python/fledge/common/plugin_discovery.py | 30 ++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/python/fledge/common/plugin_discovery.py b/python/fledge/common/plugin_discovery.py index e332fd78f6..264f305fcf 100644 --- a/python/fledge/common/plugin_discovery.py +++ b/python/fledge/common/plugin_discovery.py @@ -149,8 +149,11 @@ def fetch_c_plugins_installed(cls, plugin_type, is_config, installed_dir_name): 'description': jdoc['config']['plugin']['description'], 'version': jdoc['version'], 'installedDirectory': '{}/{}'.format(installed_dir_name, name), - 'packageName': pkg_name - } + 'packageName': get_package_name( + "fledge-{}-".format(plugin_type), + "{}/plugins/{}/{}/.Package".format(utils._FLEDGE_ROOT, installed_dir_name, name), + pkg_name) + } if is_config: plugin_config.update({'config': jdoc['config']}) configs.append(plugin_config) @@ -185,14 +188,14 @@ def get_plugin_config(cls, plugin_dir, plugin_type, installed_dir_name, is_confi # Only OMF is an inbuilt plugin if name.lower() != 'omf': pkg_name = 'fledge-{}-{}'.format(plugin_type, name.lower().replace("_", "-")) - plugin_config = { 'name': plugin_info['config']['plugin']['default'], 'type': plugin_type, 'description': plugin_info['config']['plugin']['description'], 'version': plugin_info['version'], 'installedDirectory': '{}/{}'.format(installed_dir_name, name), - 'packageName': pkg_name + 'packageName': get_package_name("fledge-{}-".format(plugin_type), + "{}/.Package".format(plugin_dir), pkg_name) } else: _logger.warning("Plugin {} is discarded due to invalid type".format(plugin_dir)) @@ -208,3 +211,22 @@ def get_plugin_config(cls, plugin_dir, plugin_type, installed_dir_name, is_confi return plugin_config + +def get_package_name(prefix: str, filepath: str, internal_name: str) -> str: + """ Get Package name on the basis of .Package file + Args: + prefix: package prefix which is used for file content matching + filepath: Check .Package file in given path + internal_name: If .Package file is missing then use old internal way + """ + try: + # open file in read mode + with open(filepath, 'r') as read_obj: + line = read_obj.read().strip('\n') + except Exception: + # If .Package file not found then return internal package name + # which is most likely a case of non-package environment setup + return internal_name + else: + # if Package file content is empty then return internal package name Else Package file content + return internal_name if prefix not in line else line From e2a5fb5b8d63b069c382f23e1c70d9ba8d9d787f Mon Sep 17 00:00:00 2001 From: Ashish Jabble Date: Mon, 13 Mar 2023 18:01:59 +0530 Subject: [PATCH 187/499] added wait time (#996) Signed-off-by: ashish-jabble Co-authored-by: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> --- .../python/e2e/test_e2e_notification_service_with_plugins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py b/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py index 78c6f6f0cb..dc433ccd14 100644 --- a/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py +++ b/tests/system/python/e2e/test_e2e_notification_service_with_plugins.py @@ -324,6 +324,7 @@ def test_sent_and_receive_notification(self, fledge_url, start_south, wait_time) class TestStartStopNotificationService: def test_shutdown_service_with_schedule_disable(self, fledge_url, disable_schedule, wait_time): disable_schedule(fledge_url, SERVICE_NAME) + pause_for_x_seconds(x=wait_time) _verify_service(fledge_url, status='shutdown') pause_for_x_seconds(x=wait_time) # After shutdown there should be 1 entry for NTFSD (shutdown) From 3358b77fe8212196a1284ba2954b1b66a5d40478 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 14 Mar 2023 18:35:22 +0530 Subject: [PATCH 188/499] logger message fixes in API directory only Signed-off-by: ashish-jabble --- .../fledge/services/core/api/asset_tracker.py | 5 +- python/fledge/services/core/api/audit.py | 14 +++- python/fledge/services/core/api/auth.py | 82 +++++++------------ .../services/core/api/backup_restore.py | 26 ++++-- python/fledge/services/core/api/browser.py | 24 +++++- python/fledge/services/core/api/common.py | 19 +++-- .../fledge/services/core/api/configuration.py | 20 +++-- .../api/control_service/acl_management.py | 10 ++- .../api/control_service/script_management.py | 6 ++ python/fledge/services/core/api/filters.py | 60 ++++++++------ python/fledge/services/core/api/health.py | 6 +- python/fledge/services/core/api/north.py | 1 + .../fledge/services/core/api/notification.py | 35 ++++++-- .../fledge/services/core/api/package_log.py | 4 +- .../services/core/api/plugins/common.py | 4 +- .../fledge/services/core/api/plugins/data.py | 4 + .../services/core/api/plugins/discovery.py | 4 +- .../services/core/api/plugins/install.py | 10 ++- .../services/core/api/plugins/remove.py | 8 +- .../services/core/api/plugins/update.py | 4 +- .../services/core/api/python_packages.py | 4 +- .../services/core/api/repos/configure.py | 4 +- python/fledge/services/core/api/service.py | 9 +- python/fledge/services/core/api/support.py | 5 +- python/fledge/services/core/api/task.py | 26 +++--- python/fledge/services/core/api/update.py | 4 +- python/fledge/services/core/api/utils.py | 1 - 27 files changed, 236 insertions(+), 163 deletions(-) diff --git a/python/fledge/services/core/api/asset_tracker.py b/python/fledge/services/core/api/asset_tracker.py index 50e063e606..0ac511971c 100644 --- a/python/fledge/services/core/api/asset_tracker.py +++ b/python/fledge/services/core/api/asset_tracker.py @@ -69,6 +69,7 @@ async def get_asset_tracker_events(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Get asset tracker events failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'track': response}) @@ -121,7 +122,7 @@ async def deprecate_asset_track_entry(request: web.Request) -> web.Response: audit_details = {'asset': asset_name, 'service': svc_name, 'event': audit_event_name} await audit.information('ASTDP', audit_details) except: - _logger.info("Failed to log the audit entry for {} deprecation.".format(asset_name)) + _logger.warning("Failed to log the audit entry for {} deprecation.".format(asset_name)) pass else: raise StorageServerError @@ -143,6 +144,7 @@ async def deprecate_asset_track_entry(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Deprecate asset entry failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "For {} event, {} asset record entry has been deprecated.".format(event_name, asset_name) @@ -236,6 +238,7 @@ async def get_datapoint_usage(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=err_response, body=json.dumps({"message": err_response})) except Exception as ex: msg = str(ex) + _logger.error("Get asset tracker store datapoints failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) diff --git a/python/fledge/services/core/api/audit.py b/python/fledge/services/core/api/audit.py index 9a492fab21..fdbf95849f 100644 --- a/python/fledge/services/core/api/audit.py +++ b/python/fledge/services/core/api/audit.py @@ -119,16 +119,20 @@ async def create_audit_entry(request): except AttributeError as e: # Return error for wrong severity method err_msg = "severity type {} is not supported".format(severity) + _logger.warning(err_msg) raise web.HTTPNotFound(reason=err_msg, body=json.dumps({"message": err_msg})) except StorageServerError as ex: if int(ex.code) in range(400, 500): err_msg = 'Audit entry cannot be logged. {}'.format(ex.error['message']) - raise web.HTTPBadRequest(body=json.dumps({"message": err_msg})) + _logger.warning(err_msg) + raise web.HTTPBadRequest(reason=err_msg, body=json.dumps({"message": err_msg})) else: err_msg = 'Failed to log audit entry. {}'.format(ex.error['message']) - raise web.HTTPInternalServerError(body=json.dumps({"message": err_msg})) + raise web.HTTPInternalServerError(reason=err_msg, body=json.dumps({"message": err_msg})) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex), body=json.dumps({"message": str(ex)})) + msg = str(ex) + _logger.error("Audit log entry failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(message) @@ -266,7 +270,9 @@ async def get_audit_entries(request): r["timestamp"] = row["timestamp"] res.append(r) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Get Audit log entry failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'audit': res, 'totalCount': total_count}) diff --git a/python/fledge/services/core/api/auth.py b/python/fledge/services/core/api/auth.py index 73563f6ab1..943200de25 100644 --- a/python/fledge/services/core/api/auth.py +++ b/python/fledge/services/core/api/auth.py @@ -79,7 +79,6 @@ def __remove_ott_for_user(user_id): try: _user_id = int(user_id) except ValueError: - _logger.error("User id given is not an integer.") return for k, v in OTT.OTT_MAP.items(): if v[0] == _user_id: @@ -160,7 +159,6 @@ async def login(request): password = _data.get('password') if not username or not password: - _logger.warning("Username and password are required to login.") raise web.HTTPBadRequest(reason="Username or password is missing") username = str(username).lower() @@ -172,12 +170,10 @@ async def login(request): try: uid, token, is_admin = await User.Objects.login(username, password, host) except (User.DoesNotExist, User.PasswordDoesNotMatch, ValueError) as ex: - _logger.warning(str(ex)) raise web.HTTPNotFound(reason=str(ex)) except User.PasswordExpired as ex: # delete all user token for this user await User.Objects.delete_user_tokens(str(ex)) - msg = 'Your password has been expired. Please set your password again.' _logger.warning(msg) raise web.HTTPUnauthorized(reason=msg) @@ -219,7 +215,9 @@ async def get_ott(request): if int(result_role['rows'][0]['role_id']) == 1: is_admin = True except Exception as ex: - raise web.HTTPBadRequest(reason="The request failed due to {}".format(ex)) + msg = str(ex) + _logger.error("OTT token failed. Found error: {}".format(msg)) + raise web.HTTPBadRequest(reason="The request failed due to {}".format(msg)) else: now_time = datetime.datetime.now() p = {'uid': user_id, 'exp': now_time} @@ -286,7 +284,7 @@ async def logout(request): # Remove OTT token for this user if there. __remove_ott_for_user(user_id) - _logger.info("User with id:<{}> has been logged out successfully.".format(int(user_id))) + _logger.info("User with ID:<{}> has been logged out successfully.".format(int(user_id))) else: # requester is not an admin but trying to take action for another user raise web.HTTPUnauthorized(reason="admin privileges are required to logout other user") @@ -322,8 +320,7 @@ async def get_user(request): if user_id <= 0: raise ValueError except ValueError: - _logger.error("Get user requested with bad user id.") - raise web.HTTPBadRequest(reason="Bad user id") + raise web.HTTPBadRequest(reason="Bad user ID") if 'username' in request.query and request.query['username'] != '': user_name = request.query['username'].lower() @@ -341,7 +338,6 @@ async def get_user(request): result = u except User.DoesNotExist as ex: msg = str(ex) - _logger.warning(msg) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) else: users = await User.Objects.all() @@ -388,44 +384,36 @@ async def create_user(request): if not username: msg = "Username is required to create user." - _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if not isinstance(username, str) or not isinstance(access_method, str) or not isinstance(real_name, str) \ or not isinstance(description, str): msg = "Values should be passed in string." - _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) username = username.lower().strip().replace(" ", "") if len(username) < MIN_USERNAME_LENGTH: msg = "Username should be of minimum 4 characters." - _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if not re.match(USERNAME_REGEX_PATTERN, username): msg = "Dot, hyphen, underscore special characters are allowed for username." - _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if access_method.lower() not in ['any', 'cert', 'pwd']: msg = "Invalid access method. Must be 'any' or 'cert' or 'pwd'." - _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if access_method == 'pwd' and not password: msg = "Password should not be an empty." - _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if access_method != 'cert': if password is not None: if not re.match(PASSWORD_REGEX_PATTERN, str(password)): - _logger.error(PASSWORD_ERROR_MSG) raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG, body=json.dumps({"message": PASSWORD_ERROR_MSG})) if not (await is_valid_role(role_id)): - msg = "Invalid role id." - _logger.error(msg) + msg = "Invalid role ID." raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) try: await User.Objects.get(username=username) @@ -451,11 +439,10 @@ async def create_user(request): u["description"] = user.pop('description') except ValueError as err: msg = str(err) - _logger.error(msg) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.exception(str(exc)) + _logger.error("Create user failed. Found error:{}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) msg = "{} user has been created successfully.".format(username) _logger.info(msg) @@ -503,6 +490,7 @@ async def update_me(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Update profile user failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "Nothing to update." @@ -568,10 +556,11 @@ async def update_user(request): msg = str(err) raise web.HTTPBadRequest(reason=str(err), body=json.dumps({"message": msg})) except User.DoesNotExist: - msg = "User with id:<{}> does not exist".format(int(user_id)) + msg = "User with ID:<{}> does not exist".format(int(user_id)) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Update user by admin failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=str(exc), body=json.dumps({"message": msg})) return web.json_response({'user_info': user_info}) @@ -590,7 +579,7 @@ async def update_password(request): try: int(user_id) except ValueError: - msg = "User id should be in integer." + msg = "User ID should be in integer." raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) data = await request.json() @@ -598,25 +587,20 @@ async def update_password(request): new_password = data.get('new_password') if not current_password or not new_password: msg = "Current or new password is missing." - _logger.error(msg) raise web.HTTPBadRequest(reason=msg) if new_password and not isinstance(new_password, str): - _logger.error(PASSWORD_ERROR_MSG) raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG) if new_password and not re.match(PASSWORD_REGEX_PATTERN, new_password): - _logger.error(PASSWORD_ERROR_MSG) raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG) if current_password == new_password: msg = "New password should not be same as current password." - _logger.error(msg) raise web.HTTPBadRequest(reason=msg) user_id = await User.Objects.is_user_exists(user_id, current_password) if not user_id: msg = 'Invalid current password.' - _logger.warning(msg) raise web.HTTPNotFound(reason=msg) try: @@ -625,21 +609,19 @@ async def update_password(request): # Remove OTT token for this user if there. __remove_ott_for_user(user_id) except ValueError as ex: - _logger.error(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) except User.DoesNotExist: - msg = "User with id:<{}> does not exist.".format(int(user_id)) - _logger.error(msg) + msg = "User with ID:<{}> does not exist.".format(int(user_id)) raise web.HTTPNotFound(reason=msg) except User.PasswordAlreadyUsed: msg = "The new password should be different from previous 3 used." - _logger.warning(msg) raise web.HTTPBadRequest(reason=msg) except Exception as exc: - _logger.exception(str(exc)) - raise web.HTTPInternalServerError(reason=str(exc)) + msg = str(exc) + _logger.error("Update user failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg) - msg = "Password has been updated successfully for user id:<{}>.".format(int(user_id)) + msg = "Password has been updated successfully for user ID:<{}>.".format(int(user_id)) _logger.info(msg) return web.json_response({'message': msg}) @@ -696,12 +678,13 @@ async def enable_user(request): msg = str(err) raise web.HTTPBadRequest(reason=str(err), body=json.dumps({"message": msg})) except User.DoesNotExist: - msg = "User with id:<{}> does not exist.".format(int(user_id)) + msg = "User with ID:<{}> does not exist.".format(int(user_id)) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Enable/Disable user failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=str(exc), body=json.dumps({"message": msg})) - return web.json_response({'message': 'User with id:<{}> has been {} successfully.'.format(int(user_id), _text)}) + return web.json_response({'message': 'User with ID:<{}> has been {} successfully.'.format(int(user_id), _text)}) @has_permission("admin") @@ -728,19 +711,15 @@ async def reset(request): if not role_id and not password: msg = "Nothing to update the user." - _logger.warning(msg) raise web.HTTPBadRequest(reason=msg) if role_id and not (await is_valid_role(role_id)): msg = "Invalid or bad role id." - _logger.error(msg) return web.HTTPBadRequest(reason=msg) if password and not isinstance(password, str): - _logger.error(PASSWORD_ERROR_MSG) raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG) if password and not re.match(PASSWORD_REGEX_PATTERN, password): - _logger.error(PASSWORD_ERROR_MSG) raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG) user_data = {} @@ -756,21 +735,20 @@ async def reset(request): __remove_ott_for_user(user_id) except ValueError as ex: - _logger.error(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) except User.DoesNotExist: - msg = "User with id:<{}> does not exist.".format(int(user_id)) - _logger.error(msg) + msg = "User with ID:<{}> does not exist.".format(int(user_id)) raise web.HTTPNotFound(reason=msg) except User.PasswordAlreadyUsed: msg = "The new password should be different from previous 3 used." _logger.warning(msg) raise web.HTTPBadRequest(reason=msg) except Exception as exc: - _logger.exception(str(exc)) - raise web.HTTPInternalServerError(reason=str(exc)) + msg = str(exc) + _logger.error("Reset user failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg) - msg = "User with id:<{}> has been updated successfully.".format(int(user_id)) + msg = "User with ID:<{}> has been updated successfully.".format(int(user_id)) _logger.info(msg) return web.json_response({'message': msg}) @@ -790,7 +768,6 @@ async def delete_user(request): try: user_id = int(request.match_info.get('user_id')) except ValueError as ex: - _logger.error(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) if user_id == 1: @@ -813,17 +790,16 @@ async def delete_user(request): __remove_ott_for_user(user_id) except ValueError as ex: - _logger.error(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) except User.DoesNotExist: - msg = "User with id:<{}> does not exist.".format(int(user_id)) - _logger.error(msg) + msg = "User with ID:<{}> does not exist.".format(int(user_id)) raise web.HTTPNotFound(reason=msg) except Exception as exc: - _logger.exception(str(exc)) - raise web.HTTPInternalServerError(reason=str(exc)) + msg = str(exc) + _logger.error("Delete user failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg) - _logger.info("User with id:<{}> has been deleted successfully.".format(int(user_id))) + _logger.info("User with ID:<{}> has been deleted successfully.".format(int(user_id))) return web.json_response({'message': "User has been deleted successfully."}) diff --git a/python/fledge/services/core/api/backup_restore.py b/python/fledge/services/core/api/backup_restore.py index b271c2670b..bd61426a61 100644 --- a/python/fledge/services/core/api/backup_restore.py +++ b/python/fledge/services/core/api/backup_restore.py @@ -114,10 +114,10 @@ async def get_backups(request): r["date"] = row["ts"] r["status"] = _get_status(int(row["status"])) res.append(r) - except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) - + msg = str(ex) + _logger.error("Get all backups failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"backups": res}) @@ -130,8 +130,9 @@ async def create_backup(request): backup = Backup(connect.get_storage_async()) status = await backup.create_backup() except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) - + msg = str(ex) + _logger.error("Create backup failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"status": status}) @@ -156,8 +157,9 @@ async def get_backup_details(request): except exceptions.DoesNotExist: raise web.HTTPNotFound(reason='Backup id {} does not exist'.format(backup_id)) except Exception as ex: - raise web.HTTPInternalServerError(reason=(str(ex))) - + msg = str(ex) + _logger.error("Get backup detail failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response(resp) @@ -204,6 +206,7 @@ async def get_backup_download(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Backup download failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.FileResponse(path=gz_path) @@ -225,7 +228,9 @@ async def delete_backup(request): except exceptions.DoesNotExist: raise web.HTTPNotFound(reason='Backup id {} does not exist'.format(backup_id)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Delete backup failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) async def restore_backup(request): @@ -246,7 +251,9 @@ async def restore_backup(request): except exceptions.DoesNotExist: raise web.HTTPNotFound(reason='Backup with {} does not exist'.format(backup_id)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Restore backup failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) async def get_backup_status(request): @@ -350,6 +357,7 @@ async def upload_backup(request: web.Request) -> web.Response: raise web.HTTPNotImplemented(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Upload backup failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "{} backup uploaded successfully.".format(file_name) diff --git a/python/fledge/services/core/api/browser.py b/python/fledge/services/core/api/browser.py index c1d5635aed..4f7ab4ac41 100644 --- a/python/fledge/services/core/api/browser.py +++ b/python/fledge/services/core/api/browser.py @@ -154,6 +154,7 @@ async def asset_counts(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get all assets failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(asset_json) @@ -219,6 +220,7 @@ async def asset(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get asset by asset code failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -245,6 +247,7 @@ async def asset_latest(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get latest asset failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -313,6 +316,7 @@ async def asset_reading(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get asset reading failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -388,6 +392,7 @@ async def asset_all_readings_summary(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get asset all readings summary failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -453,6 +458,7 @@ async def asset_summary(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get asset summary failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({reading: response}) @@ -554,6 +560,7 @@ async def asset_averages(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get asset average readings failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -686,8 +693,10 @@ async def asset_datapoints_with_bucket_size(request: web.Request) -> web.Respons raise web.HTTPNotFound(reason=e) except (TypeError, ValueError) as e: raise web.HTTPBadRequest(reason=e) - except Exception as e: - raise web.HTTPInternalServerError(reason=str(e)) + except Exception as ex: + msg = str(ex) + _logger.error("Get asset datapoints with bucket size failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -763,8 +772,10 @@ async def asset_readings_with_bucket_size(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=e) except (TypeError, ValueError) as e: raise web.HTTPBadRequest(reason=e) - except Exception as e: - raise web.HTTPInternalServerError(reason=str(e)) + except Exception as ex: + msg = str(ex) + _logger.error("Get asset readings with bucket size failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -831,6 +842,7 @@ async def asset_structure(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Get asset structure failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(asset_json) @@ -872,6 +884,7 @@ async def asset_purge_all(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get purge all assets failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(results) @@ -912,6 +925,7 @@ async def asset_purge(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get purge a particular asset failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(results) @@ -940,6 +954,7 @@ async def asset_timespan(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get timespan of buffered readings for each asset failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -970,6 +985,7 @@ async def asset_reading_timespan(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) + _logger.error("Get timespan of buffered readings for given asset failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) diff --git a/python/fledge/services/core/api/common.py b/python/fledge/services/core/api/common.py index 0cd6333d11..cbfa72a786 100644 --- a/python/fledge/services/core/api/common.py +++ b/python/fledge/services/core/api/common.py @@ -157,9 +157,11 @@ async def shutdown(request): return web.json_response({'message': 'Fledge shutdown has been scheduled. ' 'Wait for few seconds for process cleanup.'}) except TimeoutError as err: - raise web.HTTPInternalServerError(reason=str(err)) + raise web.HTTPRequestTimeout(reason=str(err)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Error while stopping Fledge server: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) def do_shutdown(request): @@ -168,7 +170,7 @@ def do_shutdown(request): loop = request.loop asyncio.ensure_future(server.Server.shutdown(request), loop=loop) except RuntimeError as e: - _logger.exception("Error while stopping Fledge server: {}".format(str(e))) + _logger.error("Error while stopping Fledge server: {}".format(str(e))) raise @@ -182,9 +184,10 @@ async def restart(request): _logger.info("Executing controlled shutdown and start") asyncio.ensure_future(server.Server.restart(request), loop=request.loop) return web.json_response({'message': 'Fledge restart has been scheduled.'}) - except TimeoutError as e: - _logger.exception("Error while stopping Fledge server: %s", e) - raise web.HTTPInternalServerError(reason=e) + except TimeoutError as err: + msg = str(err) + raise web.HTTPRequestTimeout(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: - _logger.exception("Error while stopping Fledge server: %s", ex) - raise web.HTTPInternalServerError(reason=ex) + msg = str(ex) + _logger.error("Error while stopping Fledge server: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) diff --git a/python/fledge/services/core/api/configuration.py b/python/fledge/services/core/api/configuration.py index 9ab5e94dc5..b086f79578 100644 --- a/python/fledge/services/core/api/configuration.py +++ b/python/fledge/services/core/api/configuration.py @@ -165,7 +165,9 @@ async def create_category(request): except LookupError as ex: raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Create category failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response(result) @@ -189,7 +191,9 @@ async def delete_category(request): except (ValueError, TypeError) as ex: raise web.HTTPBadRequest(reason=ex) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + msg = str(ex) + _logger.error("Delete category failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': 'Category {} deleted successfully.'.format(category_name)}) @@ -354,7 +358,9 @@ async def update_configuration_item_bulk(request): except (ValueError, TypeError) as ex: raise web.HTTPBadRequest(reason=ex) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + msg = str(ex) + _logger.error("Bulk update category failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: cat = await cf_mgr.get_category_all_items(category_name) try: @@ -430,7 +436,9 @@ async def add_configuration_item(request): except NameError as ex: raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Create config item failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"message": "{} config item has been saved for {} category".format(new_config_item, category_name)}) @@ -656,7 +664,9 @@ async def upload_script(request): except Exception as ex: os.remove(script_file_path) - raise web.HTTPBadRequest(reason=ex) + msg = str(ex) + _logger.error("Upload script for a config item failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: result = await cf_mgr.get_category_item(category_name, config_item) return web.json_response(result) diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index ecbad2a902..3248e6d067 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -73,6 +73,7 @@ async def get_acl(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Get ACL failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(acl_info) @@ -139,6 +140,7 @@ async def add_acl(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("ACL create failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(result) @@ -204,6 +206,7 @@ async def update_acl(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=message, body=json.dumps({"message": message})) except Exception as ex: message = str(ex) + _logger.error("ACL update failed. Found error: {}".format(message)) raise web.HTTPInternalServerError(reason=message, body=json.dumps({"message": message})) else: # Fetch service name associated with acl @@ -261,6 +264,7 @@ async def delete_acl(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("ACL delete failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) @@ -273,8 +277,8 @@ async def attach_acl_to_service(request: web.Request) -> web.Response: :Example: curl -H "authorization: $AUTH_TOKEN" -sX PUT http://localhost:8081/fledge/service/Sine/ACL -d '{"acl_name": "testACL"}' """ + svc_name = request.match_info.get('service_name', None) try: - svc_name = request.match_info.get('service_name', None) storage = connect.get_storage_async() payload = PayloadBuilder().SELECT(["id", "enabled"]).WHERE(['schedule_name', '=', svc_name]).payload() # check service name existence @@ -348,6 +352,7 @@ async def attach_acl_to_service(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Attach ACL to {} service failed. Found error: {}".format(svc_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: # Call service security endpoint with attachACL = acl_name @@ -365,8 +370,8 @@ async def detach_acl_from_service(request: web.Request) -> web.Response: :Example: curl -H "authorization: $AUTH_TOKEN" -sX DELETE http://localhost:8081/fledge/service/Sine/ACL """ + svc_name = request.match_info.get('service_name', None) try: - svc_name = request.match_info.get('service_name', None) storage = connect.get_storage_async() payload = PayloadBuilder().SELECT(["id", "enabled"]).WHERE(['schedule_name', '=', svc_name]).payload() # check service name existence @@ -423,6 +428,7 @@ async def detach_acl_from_service(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Detach ACL from {} service failed. Found error: {}".format(svc_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index b31c60a70d..526160e430 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -141,6 +141,7 @@ async def add_schedule_and_configuration(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Automation script schedule task failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "Schedule and configuration is created for an automation script with name {}".format(name) @@ -190,6 +191,7 @@ async def get_all(request: web.Request) -> web.Response: scripts.append(row) except Exception as ex: msg = str(ex) + _logger.error("Get Control script failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"scripts": scripts}) @@ -246,6 +248,7 @@ async def get_by_name(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Get Control script by name failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(rows) @@ -331,6 +334,7 @@ async def add(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Control script create failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(result) @@ -422,6 +426,7 @@ async def update(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Control script update failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) @@ -481,6 +486,7 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Control script delete failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) diff --git a/python/fledge/services/core/api/filters.py b/python/fledge/services/core/api/filters.py index 269ca1ec13..7bb14e141e 100644 --- a/python/fledge/services/core/api/filters.py +++ b/python/fledge/services/core/api/filters.py @@ -134,20 +134,19 @@ async def create_filter(request: web.Request) -> web.Response: return web.json_response({'filter': filter_name, 'description': filter_desc, 'value': category_info}) except ValueError as err: msg = str(err) - _LOGGER.error("Add filter, caught value error: {}".format(msg)) raise web.HTTPNotFound(reason=msg) except TypeError as err: msg = str(err) - _LOGGER.error("Add filter, caught type error: {}".format(msg)) raise web.HTTPBadRequest(reason=msg) except StorageServerError as ex: + msg = ex.error await _delete_configuration_category(storage, filter_name) # Revert configuration entry - _LOGGER.exception("Failed to create filter with: {}".format(ex.error)) - raise web.HTTPInternalServerError(reason='Failed to create filter.') + _LOGGER.exception("Failed to create filter with: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _LOGGER.exception("Add filter, caught exception: {}".format(msg)) - raise web.HTTPInternalServerError(reason=msg) + _LOGGER.error("Add filter, caught exception: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) async def add_filters_pipeline(request: web.Request) -> web.Response: @@ -273,20 +272,18 @@ async def add_filters_pipeline(request: web.Request) -> web.Response: {'result': "Filter pipeline {} updated successfully".format(json.loads(result['value']))}) except ValueError as err: msg = str(err) - _LOGGER.error("Add filters pipeline, caught value error: {}".format(msg)) raise web.HTTPNotFound(reason=msg) except TypeError as err: msg = str(err) - _LOGGER.error("Add filters pipeline, caught type error: {}".format(msg)) raise web.HTTPBadRequest(reason=msg) except StorageServerError as ex: - msg = str(ex.error) - _LOGGER.error("Add filters pipeline, caught storage error: {}".format(msg)) - raise web.HTTPInternalServerError(reason=msg) + msg = ex.error + _LOGGER.exception("Add filters pipeline, caught storage error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _LOGGER.exception("Add filters pipeline, caught exception: {}".format(msg)) - raise web.HTTPInternalServerError(reason=msg) + _LOGGER.error("Add filters pipeline, caught exception: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) async def get_filter(request: web.Request) -> web.Response: @@ -323,14 +320,17 @@ async def get_filter(request: web.Request) -> web.Response: users.append(row["user"]) filter_detail.update({"users": users}) except StorageServerError as ex: - _LOGGER.error("Get {} filter, caught exception: {}".format(filter_name, str(ex.error))) - raise web.HTTPInternalServerError(reason=str(ex.error)) + msg = ex.error + _LOGGER.exception("Get {} filter, caught storage exception: {}".format(filter_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except ValueError as err: raise web.HTTPNotFound(reason=str(err)) except TypeError as err: raise web.HTTPBadRequest(reason=str(err)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _LOGGER.error("Get {} filter, caught exception: {}".format(filter_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'filter': filter_detail}) @@ -346,10 +346,13 @@ async def get_filters(request: web.Request) -> web.Response: result = await storage.query_tbl("filters") filters = result["rows"] except StorageServerError as ex: - _LOGGER.error("Get all filters, caught exception: {}".format(str(ex.error))) - raise web.HTTPInternalServerError(reason=str(ex.error)) + msg = ex.error + _LOGGER.exception("Get all filters, caught storage exception: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _LOGGER.error("Get all filters, caught exception: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'filters': filters}) @@ -373,15 +376,17 @@ async def get_filter_pipeline(request: web.Request) -> web.Response: filter_value_from_storage = json.loads(category_info['filter']['value']) except KeyError: msg = "No filter pipeline exists for {}.".format(user_name) - _LOGGER.error(msg) raise web.HTTPNotFound(reason=msg) except StorageServerError as ex: - _LOGGER.exception("Get {} filter pipeline, caught exception: {}".format(user_name, str(ex.error))) - raise web.HTTPInternalServerError(reason=str(ex.error)) + msg = ex.error + _LOGGER.exception("Get {} filter pipeline, caught storage exception: {}".format(user_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except ValueError as err: raise web.HTTPNotFound(reason=str(err)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _LOGGER.error("Get filter pipeline, caught exception: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': filter_value_from_storage}) @@ -435,14 +440,17 @@ async def delete_filter(request: web.Request) -> web.Response: ['plugin', '=', filter_name]).payload() await storage.update_tbl("asset_tracker", update_payload) except StorageServerError as ex: - _LOGGER.exception("Delete {} filter, caught exception: {}".format(filter_name, str(ex.error))) - raise web.HTTPInternalServerError(reason=str(ex.error)) + msg = ex.error + _LOGGER.exception("Delete {} filter, caught storage exception: {}".format(filter_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except ValueError as err: raise web.HTTPNotFound(reason=str(err)) except TypeError as err: raise web.HTTPBadRequest(reason=str(err)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _LOGGER.error("Delete {} filter, caught exception: {}".format(filter_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "Filter {} deleted successfully.".format(filter_name)}) diff --git a/python/fledge/services/core/api/health.py b/python/fledge/services/core/api/health.py index 71668c6f9e..73621f06ed 100644 --- a/python/fledge/services/core/api/health.py +++ b/python/fledge/services/core/api/health.py @@ -109,7 +109,7 @@ async def get_logging_health(request: web.Request) -> web.Response: except Exception as ex: msg = "Could not fetch service information due to {}".format(str(ex)) - _LOGGER.exception(msg) + _LOGGER.error(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) try: @@ -122,7 +122,7 @@ async def get_logging_health(request: web.Request) -> web.Response: except Exception as ex: msg = "Failed to get disk stats for /var/log due to {}".format(str(ex)) - _LOGGER.exception(msg) + _LOGGER.error(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -208,7 +208,7 @@ async def get_storage_health(request: web.Request) -> web.Response: response['disk']['status'] = status except Exception as ex: msg = "Failed to get disk stats for storage service due to {}".format(str(ex)) - _LOGGER.exception(msg) + _LOGGER.error(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) diff --git a/python/fledge/services/core/api/north.py b/python/fledge/services/core/api/north.py index 94b583fd29..24a4debbf5 100644 --- a/python/fledge/services/core/api/north.py +++ b/python/fledge/services/core/api/north.py @@ -185,6 +185,7 @@ async def get_north_schedules(request): return web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Get north schedules failed. Found error: {}".format(msg)) return web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(north_schedules) diff --git a/python/fledge/services/core/api/notification.py b/python/fledge/services/core/api/notification.py index 515c0acffc..77f8ba2a39 100644 --- a/python/fledge/services/core/api/notification.py +++ b/python/fledge/services/core/api/notification.py @@ -56,7 +56,9 @@ async def get_plugin(request): url = 'http://{}:{}/notification/delivery'.format(_address, _port) delivery_plugins = json.loads(await _hit_get_url(url)) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + msg = str(ex) + _logger.error("Get notification plugin list failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'rules': rule_plugins, 'delivery': delivery_plugins}) @@ -111,7 +113,9 @@ async def get_notification(request): except ValueError as ex: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + msg = str(ex) + _logger.error("Get notification instance info failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'notification': notification}) @@ -147,7 +151,9 @@ async def get_notifications(request): notifications.append(notification) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + msg = str(ex) + _logger.error("Get notification instances list failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'notifications': notifications}) @@ -281,8 +287,10 @@ async def post_notification(request): await audit.information('NTFAD', {"name": name}) except ValueError as ex: raise web.HTTPBadRequest(reason=str(ex)) - except Exception as e: - raise web.HTTPInternalServerError(reason=str(e)) + except Exception as ex: + msg = str(ex) + _logger.error("Notification instance create failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "Notification {} created successfully".format(name)}) @@ -436,7 +444,9 @@ async def put_notification(request): except NotFoundError as e: raise web.HTTPNotFound(reason=str(e)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Notification instance update failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: # TODO: Start notification after update return web.json_response({'result': "Notification {} updated successfully".format(notif)}) @@ -475,7 +485,9 @@ async def delete_notification(request): except ValueError as ex: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Notification instance delete failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': 'Notification {} deleted successfully.'.format(notif)}) @@ -528,8 +540,8 @@ async def _update_configurations(config_mgr, name, notification_config, rule_con await config_mgr.update_configuration_item_bulk(category_name, delivery_config) except Exception as ex: msg = "Failed to update notification configuration due to {}".format(str(ex)) - _logger.exception(msg) - raise web.HTTPInternalServerError(reason=msg) + _logger.error(msg) + return web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) async def _hit_delete_url(delete_url, data=None): @@ -627,6 +639,7 @@ async def get_delivery_channels(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Get delivery channels failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"channels": channels}) @@ -687,6 +700,7 @@ async def post_delivery_channel(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Delivery channel create failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"category": channel_name, "description": channel_description, @@ -719,6 +733,7 @@ async def get_delivery_channel_configuration(request: web.Request) -> web.Respon raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Get delivery channel configuration failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"config": channel_config}) @@ -773,10 +788,12 @@ async def delete_delivery_channel(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Delivery channel delete failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"channels": channels}) + async def _get_all_delivery_channels(cfg_mgr: ConfigurationManager, notify_instance: str) -> dict: """ Remove all delivery channels in the form of array of dicts: diff --git a/python/fledge/services/core/api/package_log.py b/python/fledge/services/core/api/package_log.py index 8f182f1904..75bb09791c 100644 --- a/python/fledge/services/core/api/package_log.py +++ b/python/fledge/services/core/api/package_log.py @@ -142,6 +142,8 @@ async def get_package_status(request: web.Request) -> web.Response: msg = str(err) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: - raise web.HTTPInternalServerError(reason=str(exc)) + msg = str(exc) + _LOGGER.error("Get Package log status failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"packageStatus": result}) diff --git a/python/fledge/services/core/api/plugins/common.py b/python/fledge/services/core/api/plugins/common.py index 4a7be9165c..ef64671102 100644 --- a/python/fledge/services/core/api/plugins/common.py +++ b/python/fledge/services/core/api/plugins/common.py @@ -184,7 +184,7 @@ async def fetch_available_packages(package_type: str = "") -> tuple: # If max update per day is set to 1, then an update can not occurs until 24 hours after the last accessed update. # If set to 2 then this drops to 12 hours between updates, 3 would result in 8 hours between calls and so on. if duration_in_sec > (24 / int(max_update_cat_item['value'])) * 60 * 60 or not last_accessed_time: - _logger.info("Attempting update on {}...".format(now)) + _logger.info("Attempting {} update on {}...".format(pkg_mgt, now)) cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) if pkg_mgt == 'yum': cmd = "sudo {} check-update > {} 2>&1".format(pkg_mgt, stdout_file_path) @@ -194,7 +194,7 @@ async def fetch_available_packages(package_type: str = "") -> tuple: # fetch available package caching always clear on every update request _get_available_packages.cache_clear() else: - _logger.warning("Maximum update exceeds the limit for the day.") + _logger.warning("Maximum {} update exceeds the limit for the day.".format(pkg_mgt)) ttl_cat_item_val = int(category['listAvailablePackagesCacheTTL']['value']) if ttl_cat_item_val > 0: last_accessed_time = pkg_cache_mgr['list']['last_accessed_time'] diff --git a/python/fledge/services/core/api/plugins/data.py b/python/fledge/services/core/api/plugins/data.py index fae669db61..97fe7b97e9 100644 --- a/python/fledge/services/core/api/plugins/data.py +++ b/python/fledge/services/core/api/plugins/data.py @@ -72,6 +72,7 @@ async def get_persist_plugins(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Get persist plugins failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'persistent': plugins}) @@ -106,6 +107,7 @@ async def get(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Get plugin data failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'data': data}) @@ -152,6 +154,7 @@ async def add(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Plugin data create failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "{} key added successfully.".format(key)}) @@ -191,6 +194,7 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Plugin data delete failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "{} deleted successfully.".format(key)}) diff --git a/python/fledge/services/core/api/plugins/discovery.py b/python/fledge/services/core/api/plugins/discovery.py index 82b92cbffa..dde3b05174 100644 --- a/python/fledge/services/core/api/plugins/discovery.py +++ b/python/fledge/services/core/api/plugins/discovery.py @@ -84,6 +84,8 @@ async def get_plugins_available(request: web.Request) -> web.Response: msg = "Fetch available plugins package request failed" raise web.HTTPBadRequest(body=json.dumps({"message": msg, "link": str(e)}), reason=msg) except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + msg = str(ex) + _logger.error("Get Plugins available is failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"plugins": plugins, "link": log_path}) diff --git a/python/fledge/services/core/api/plugins/install.py b/python/fledge/services/core/api/plugins/install.py index 4407228715..6f2b52b71e 100644 --- a/python/fledge/services/core/api/plugins/install.py +++ b/python/fledge/services/core/api/plugins/install.py @@ -189,7 +189,9 @@ async def add_plugin(request: web.Request) -> web.Response: except (TypeError, ValueError) as ex: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _LOGGER.error("Install Plugin failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) @@ -257,7 +259,7 @@ def copy_file_install_requirement(dir_files: list, plugin_type: str, file_name: if so_1_file: if not so_file: err_msg = "Symlink file is missing." - _LOGGER.error(err_msg) + _LOGGER.debug(err_msg) raise FileNotFoundError(err_msg) _dir = [] for s in dir_files: @@ -325,7 +327,7 @@ def install_package_from_repo(name: str, pkg_mgt: str, version: str, uid: uuid, # If max upgrade per day is set to 1, then an upgrade can not occurs until 24 hours after the last accessed upgrade. # If set to 2 then this drops to 12 hours between upgrades, 3 would result in 8 hours between calls and so on. if duration_in_sec > (24 / int(max_upgrade_cat_item['value'])) * 60 * 60 or not last_accessed_time: - _LOGGER.info("Attempting upgrade on {}...".format(now)) + _LOGGER.info("Attempting {} upgrade on {}...".format(pkg_mgt, now)) cmd = "sudo {} -y upgrade".format(pkg_mgt) if pkg_mgt == 'apt' else "sudo {} -y update".format(pkg_mgt) ret_code = os.system(cmd + " > {} 2>&1".format(stdout_file_path)) if ret_code != 0: @@ -336,7 +338,7 @@ def install_package_from_repo(name: str, pkg_mgt: str, version: str, uid: uuid, else: pkg_cache_mgr['upgrade']['last_accessed_time'] = now else: - _LOGGER.warning("Maximum upgrade exceeds the limit for the day.") + _LOGGER.warning("Maximum {} upgrade exceeds the limit for the day.".format(pkg_mgt)) msg = "updated" cmd = "sudo {} -y install {}".format(pkg_mgt, name) if version: diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index a421b17d75..01e3f8362f 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -76,14 +76,14 @@ async def remove_plugin(request: web.Request) -> web.Response: if notification_instances_plugin_used_in: err_msg = "{} cannot be removed. This is being used by {} instances.".format( name, notification_instances_plugin_used_in) - _logger.error(err_msg) + _logger.warning(err_msg) raise RuntimeError(err_msg) else: get_tracked_plugins = await _check_plugin_usage(plugin_type, name) if get_tracked_plugins: e = "{} cannot be removed. This is being used by {} instances.".\ format(name, get_tracked_plugins[0]['service_list']) - _logger.error(e) + _logger.warning(e) raise RuntimeError(e) else: _logger.info("No entry found for {name} plugin in asset tracker; or " @@ -136,7 +136,9 @@ async def remove_plugin(request: web.Request) -> web.Response: msg = str(err) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex), body=json.dumps({'message': str(ex)})) + msg = str(ex) + _logger.error("Remove Plugin failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index 83bae49f91..fa2cb17f11 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -164,7 +164,9 @@ async def update_plugin(request: web.Request) -> web.Response: msg = str(err) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Update Plugin failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) diff --git a/python/fledge/services/core/api/python_packages.py b/python/fledge/services/core/api/python_packages.py index fcdff9ef34..c3b16ce80c 100644 --- a/python/fledge/services/core/api/python_packages.py +++ b/python/fledge/services/core/api/python_packages.py @@ -83,7 +83,7 @@ def get_installed_package_info(input_package): if installed_package: # Package already exists - _LOGGER.info("Package: {} Version: {} already installed.".format(installed_package, installed_version)) + _LOGGER.warning("Package: {} Version: {} already installed.".format(installed_package, installed_version)) return web.HTTPConflict(reason="Package already installed.", body=json.dumps({"message": "Package {} version {} already installed." .format(installed_package, installed_version)})) @@ -104,7 +104,7 @@ def get_installed_package_info(input_package): audit_message["version"] = input_package_version await pip_audit_log.information('PIPIN', audit_message) except: - _LOGGER.exception("Failed to log the audit entry for PIPIN, for package {} install", format( + _LOGGER.error("Failed to log the audit entry for PIPIN, for package {} install", format( input_package_name)) response = "Package {} version {} installed successfully.".format(input_package_name, input_package_version) diff --git a/python/fledge/services/core/api/repos/configure.py b/python/fledge/services/core/api/repos/configure.py index 6f4ef94eb0..386ebd0223 100644 --- a/python/fledge/services/core/api/repos/configure.py +++ b/python/fledge/services/core/api/repos/configure.py @@ -136,7 +136,9 @@ async def add_package_repo(request: web.Request) -> web.Response: raise web.HTTPBadRequest(body=json.dumps({"message": "Failed to configure package repository", "output_log": msg}), reason=msg) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _LOGGER.error("Add Package repo failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": "Package repository configured successfully.", "output_log": stdout_file_path}) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index 2ebf930862..5786141012 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -364,10 +364,9 @@ async def add_service(request): _logger.exception("{} Detailed error logs are: {}".format(msg, str(ex))) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except TypeError as ex: - _logger.exception(str(ex)) raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: - _logger.exception("Failed to fetch plugin configuration. %s", str(ex)) + _logger.error("Failed to fetch plugin configuration. %s", str(ex)) raise web.HTTPInternalServerError(reason='Failed to fetch plugin configuration') elif service_type == 'notification': if not os.path.exists(_FLEDGE_ROOT + "/services/fledge.services.{}".format(service_type)): @@ -421,7 +420,7 @@ async def add_service(request): _logger.exception("Failed to create scheduled process. %s", ex.error) raise web.HTTPInternalServerError(reason='Failed to create service.') except Exception as ex: - _logger.exception("Failed to create scheduled process. %s", str(ex)) + _logger.error("Failed to create scheduled process. %s", str(ex)) raise web.HTTPInternalServerError(reason='Failed to create service.') # check that notification service is not already registered, right now notification service LIMIT to 1 @@ -474,7 +473,7 @@ async def add_service(request): except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.exception("Failed to create plugin configuration. %s", str(ex)) + _logger.error("Failed to create plugin configuration. %s", str(ex)) raise web.HTTPInternalServerError(reason='Failed to create plugin configuration. {}'.format(ex)) # If all successful then lastly add a schedule to run the new service at startup @@ -496,7 +495,7 @@ async def add_service(request): raise web.HTTPInternalServerError(reason='Failed to create service.') except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.exception("Failed to create service. %s", str(ex)) + _logger.error("Failed to create service. %s", str(ex)) raise web.HTTPInternalServerError(reason='Failed to create service.') except ValueError as err: msg = str(err) diff --git a/python/fledge/services/core/api/support.py b/python/fledge/services/core/api/support.py index d55a020a1b..c740853d19 100644 --- a/python/fledge/services/core/api/support.py +++ b/python/fledge/services/core/api/support.py @@ -109,7 +109,9 @@ async def create_support_bundle(request): try: bundle_name = await SupportBuilder(support_dir).build() except Exception as ex: - raise web.HTTPInternalServerError(reason='Support bundle could not be created. {}'.format(str(ex))) + msg = 'Support bundle create failed. Found error: {}'.format(str(ex)) + _logger.error(msg) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"bundle created": bundle_name}) @@ -219,6 +221,7 @@ async def get_syslog_entries(request): raise web.HTTPBadRequest(body=json.dumps({"message": msg}), reason=msg) except (OSError, Exception) as ex: msg = str(ex) + _logger.error("Get syslog entries failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(body=json.dumps({"message": msg}), reason=msg) return web.json_response(response) diff --git a/python/fledge/services/core/api/task.py b/python/fledge/services/core/api/task.py index 8a1d98e07e..ad0b6eef2a 100644 --- a/python/fledge/services/core/api/task.py +++ b/python/fledge/services/core/api/task.py @@ -164,29 +164,24 @@ async def add_task(request): plugin_info = apiutils.get_plugin_info(plugin, dir=task_type) if not plugin_info: msg = "Plugin {} does not appear to be a valid plugin.".format(plugin) - _logger.error(msg) return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) valid_c_plugin_info_keys = ['name', 'version', 'type', 'interface', 'flag', 'config'] for k in valid_c_plugin_info_keys: if k not in list(plugin_info.keys()): msg = "Plugin info does not appear to be a valid for {} plugin. '{}' item not found.".format( plugin, k) - _logger.error(msg) return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if plugin_info['type'] != task_type: msg = "Plugin of {} type is not supported.".format(plugin_info['type']) - _logger.error(msg) return web.HTTPBadRequest(reason=msg) plugin_config = plugin_info['config'] if not plugin_config: - _logger.exception("Plugin {} import problem from path {} due to {}".format(plugin, - plugin_module_path, str(ex))) - raise web.HTTPNotFound(reason='Plugin "{}" import problem from path "{}"'.format(plugin, - plugin_module_path)) + raise web.HTTPNotFound(reason='Plugin "{}" import problem from path "{}"'.format( + plugin, plugin_module_path)) except TypeError as ex: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: - _logger.exception("Failed to fetch plugin configuration due to {}".format(str(ex))) + _logger.error("Failed to fetch plugin configuration due to {}".format(str(ex))) raise web.HTTPInternalServerError(reason='Failed to fetch plugin configuration.') storage = connect.get_storage_async() @@ -203,7 +198,7 @@ async def add_task(request): if result['count'] >= 1: msg = 'Unable to reuse name {0}, already used by a previous task.'.format(name) - _logger.error(msg) + _logger.warning(msg) raise web.HTTPBadRequest(reason=msg) # Check whether category name already exists @@ -226,10 +221,10 @@ async def add_task(request): try: res = await storage.insert_into_tbl("scheduled_processes", payload) except StorageServerError as ex: - _logger.error("Failed to create scheduled process due to {}".format(ex.error)) + _logger.exception("Failed to create scheduled process due to {}".format(ex.error)) raise web.HTTPInternalServerError(reason='Failed to create north instance.') except Exception as ex: - _logger.exception("Failed to create scheduled process due to {}".format(str(ex))) + _logger.error("Failed to create scheduled process due to {}".format(str(ex))) raise web.HTTPInternalServerError(reason='Failed to create north instance.') # If successful then create a configuration entry from plugin configuration @@ -252,7 +247,7 @@ async def add_task(request): await config_mgr.set_category_item_value_entry(name, k, v['value']) except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.exception("Failed to create plugin configuration due to {}".format(str(ex))) + _logger.error("Failed to create plugin configuration due to {}".format(str(ex))) raise web.HTTPInternalServerError(reason='Failed to create plugin configuration. {}'.format(ex)) # If all successful then lastly add a schedule to run the new task at startup @@ -277,7 +272,7 @@ async def add_task(request): schedule = await server.Server.scheduler.get_schedule_by_name(name) except StorageServerError as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.error("Failed to create north instance due to {}".format(ex.error)) + _logger.exception("Failed to create north instance due to {}".format(ex.error)) raise web.HTTPInternalServerError(reason='Failed to create north instance.') except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) @@ -325,9 +320,10 @@ async def delete_task(request): await delete_plugin_data(storage, north_instance) # update deprecated timestamp in asset_tracker await update_deprecated_ts_in_asset_tracker(storage, north_instance) - except Exception as ex: - raise web.HTTPInternalServerError(reason=ex) + msg = str(ex) + _logger.error("Delete task failed. Found error: {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': 'North instance {} deleted successfully.'.format(north_instance)}) diff --git a/python/fledge/services/core/api/update.py b/python/fledge/services/core/api/update.py index 68fa168587..570bf5bff8 100644 --- a/python/fledge/services/core/api/update.py +++ b/python/fledge/services/core/api/update.py @@ -76,7 +76,6 @@ async def update_package(request): manual_schedule = ManualSchedule() if not manual_schedule: - _logger.error(error_message) raise ValueError(error_message) # Set schedule fields manual_schedule.name = _FLEDGE_MANUAL_UPDATE_SCHEDULE @@ -106,6 +105,7 @@ async def update_package(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) + _logger.error("Update Package failed. Found error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"status": "Running", "message": status_message}) @@ -150,7 +150,7 @@ async def get_updates(request: web.Request) -> web.Response: return web.json_response({'updates': upgradable_packages}) try: process_output = stdout.decode("utf-8") - _logger.info(process_output) + _logger.debug(process_output) # split on new-line word_list = re.split(r"\n+", process_output) diff --git a/python/fledge/services/core/api/utils.py b/python/fledge/services/core/api/utils.py index 059e8d4251..e5001eb60f 100644 --- a/python/fledge/services/core/api/utils.py +++ b/python/fledge/services/core/api/utils.py @@ -37,7 +37,6 @@ def get_plugin_info(name, dir): _logger.error("{} C plugin get info failed due to {}".format(name, str(err))) return {} except ValueError as err: - _logger.error(str(err)) return {} except Exception as ex: _logger.error("{} C plugin get info failed due to {}".format(name, str(ex))) From 17da1f88b8dee63b856441fedc95c6144f8afa33 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 15 Mar 2023 09:29:46 +0000 Subject: [PATCH 189/499] FOGL-7365 North service performance enhancements (#1008) * FOGL-7365 Improve north service data handling Signed-off-by: Mark Riddoch * Add description and warning regarding const status of argument Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch Co-authored-by: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> --- C/common/reading_set.cpp | 10 ++++++++-- C/common/storage_client.cpp | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 761257fa58..fc9d39195e 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -62,7 +62,13 @@ ReadingSet::ReadingSet(const vector* readings) : m_last_id(0) /** * Construct a reading set from a JSON document returned from - * the Fledge storage service query or notification. + * the Fledge storage service query or notification. The JSON + * is parsed using the in-situ RapidJSON parser in order to + * reduce overhead on what is most likely a large JSON document. + * + * WARNING: Although the string passed in is defiend as const + * this call is destructive to this string and the conntents + * of the string should not be used after making this call. * * @param json The JSON document (as string) with readings data */ @@ -70,7 +76,7 @@ ReadingSet::ReadingSet(const std::string& json) : m_last_id(0) { unsigned long rows = 0; Document doc; - doc.Parse(json.c_str()); + doc.ParseInsitu((char *)json.c_str()); // Cast away const in order to use in-situ if (doc.HasParseError()) { throw new ReadingSetException("Unable to parse results json document"); diff --git a/C/common/storage_client.cpp b/C/common/storage_client.cpp index 5360326f89..dd8317ad06 100644 --- a/C/common/storage_client.cpp +++ b/C/common/storage_client.cpp @@ -324,7 +324,7 @@ ReadingSet *StorageClient::readingFetch(const unsigned long readingId, const uns { ostringstream resultPayload; resultPayload << res->content.rdbuf(); - ReadingSet *result = new ReadingSet(resultPayload.str().c_str()); + ReadingSet *result = new ReadingSet(resultPayload.str()); return result; } ostringstream resultPayload; From cc9a5e6593ff08431b59a837f7b6ad1e79adff87 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Wed, 15 Mar 2023 16:50:07 +0530 Subject: [PATCH 190/499] FOGL-7444 Lab script is failing on jenkins due to issue in cleanup of PI Server (#1012) * Fixed issue regarding cleanup of PI Server Signed-off-by: Mohit Singh Tomar * updated the wait time for checking North service is up or not Signed-off-by: Mohit Singh Tomar * Refactored Code Signed-off-by: Mohit Singh Tomar * Made Af Heirarchy Unique for OMf/PI Server Signed-off-by: Mohit Singh Tomar * Minor Fix Signed-off-by: Mohit Singh Tomar * Minor Fix-2 Signed-off-by: Mohit Singh Tomar * Fixed AF Hierarchy path in verify_clean_pi.py file Signed-off-by: Mohit Singh Tomar * Feedback changes Signed-off-by: Mohit Singh Tomar --------- Signed-off-by: Mohit Singh Tomar --- tests/system/lab/test | 62 ++++++++++++++++++++++------- tests/system/lab/test.config | 2 + tests/system/lab/verify_clean_pi.py | 4 +- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/tests/system/lab/test b/tests/system/lab/test index 137630d483..1be8aad3d7 100755 --- a/tests/system/lab/test +++ b/tests/system/lab/test @@ -12,6 +12,8 @@ CRESET="${CPFX}0m" . ./test.config LAB_ASSET_NAME="PILAB-sinusoid" +AF_HIERARCHY_LEVEL="/$(date +%F | tr - _)_PIlabSinelvl1/PIlabSinelvl2/PIlabSinelvl3" + rm -f err.txt touch err.txt @@ -133,7 +135,7 @@ setup_north_pi_egress () { "value": "true" }, "DefaultAFLocation": { - "value": "/PIlabSinelvl1/PIlabSinelvl2/PIlabSinelvl3" + "value": "'"${AF_HIERARCHY_LEVEL}"'" }, "Legacy": { "value": "false" @@ -176,7 +178,7 @@ setup_north_pi_egress () { "value": "true" }, "DefaultAFLocation": { - "value": "/PIlabSinelvl1/PIlabSinelvl2/PIlabSinelvl3" + "value": "'"${AF_HIERARCHY_LEVEL}"'" }, "Legacy": { "value": "false" @@ -186,7 +188,22 @@ setup_north_pi_egress () { fi echo - + + # Wait for OMF to start up properly + for LOOP in $(seq ${MAX_RETRIES}); do + RESULT=$(curl -sX GET "$URL/service" | jq '.services[] | select (.name == "PI Server")| .status' | tr -d \") + if [[ "${RESULT}" -eq "running" ]];then + break + fi + done + if [[ ${LOOP} -eq ${MAX_RETRIES} ]] + then + display_and_collect_err "TIMEOUT! Unable to start North Service" + if [[ ${EXIT_EARLY} -eq 1 ]]; then exit 1; fi + else + echo "---- North Service is started properly ----" + fi + for LOOP in $(seq ${MAX_RETRIES}); do RESULT=`curl -sX GET "$URL/north"` # echo ${RESULT} @@ -233,15 +250,6 @@ setup_north_pi_egress () { } -if [[ ${VERIFY_EGRESS_TO_PI} == 1 ]] - then - setup_north_pi_egress - echo "---- Verify and clean the data sent to PI server ----" - python3 verify_clean_pi.py --pi-admin=${PI_USER} --pi-passwd=${PI_PASSWORD} --pi-host=${PI_IP} --pi-port=443 --pi-db=foglamp --asset-name=${LAB_ASSET_NAME} - else - echo "======================= SKIPPED PI EGRESS =======================" -fi - square_filter_config=$(cat < Date: Thu, 16 Mar 2023 13:32:39 +0530 Subject: [PATCH 191/499] message text fixes in core api directory Signed-off-by: ashish-jabble --- .../fledge/services/core/api/asset_tracker.py | 7 +-- python/fledge/services/core/api/audit.py | 7 ++- python/fledge/services/core/api/auth.py | 44 +++++++++---------- .../services/core/api/backup_restore.py | 14 +++--- python/fledge/services/core/api/browser.py | 29 ++++++------ .../fledge/services/core/api/configuration.py | 32 ++++++++++---- .../api/control_service/acl_management.py | 12 ++--- .../api/control_service/script_management.py | 14 +++--- python/fledge/services/core/api/filters.py | 8 ++-- python/fledge/services/core/api/health.py | 10 ++--- python/fledge/services/core/api/north.py | 2 +- .../fledge/services/core/api/notification.py | 25 ++++++----- .../fledge/services/core/api/package_log.py | 2 +- .../fledge/services/core/api/plugins/data.py | 8 ++-- .../services/core/api/plugins/discovery.py | 4 +- .../services/core/api/plugins/install.py | 2 +- .../services/core/api/plugins/remove.py | 2 +- .../services/core/api/plugins/update.py | 2 +- .../services/core/api/repos/configure.py | 2 +- python/fledge/services/core/api/service.py | 8 ++-- python/fledge/services/core/api/support.py | 4 +- python/fledge/services/core/api/task.py | 4 +- python/fledge/services/core/api/update.py | 2 +- 23 files changed, 130 insertions(+), 114 deletions(-) diff --git a/python/fledge/services/core/api/asset_tracker.py b/python/fledge/services/core/api/asset_tracker.py index 0ac511971c..facb90c181 100644 --- a/python/fledge/services/core/api/asset_tracker.py +++ b/python/fledge/services/core/api/asset_tracker.py @@ -69,7 +69,7 @@ async def get_asset_tracker_events(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get asset tracker events failed. Found error: {}".format(msg)) + _logger.error("Failed to get asset tracker events. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'track': response}) @@ -144,7 +144,8 @@ async def deprecate_asset_track_entry(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Deprecate asset entry failed. Found error: {}".format(msg)) + _logger.error("Deprecate {} asset entry failed for {} service with {} event. {}".format( + asset_name, svc_name, event_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "For {} event, {} asset record entry has been deprecated.".format(event_name, asset_name) @@ -238,7 +239,7 @@ async def get_datapoint_usage(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=err_response, body=json.dumps({"message": err_response})) except Exception as ex: msg = str(ex) - _logger.error("Get asset tracker store datapoints failed. Found error: {}".format(msg)) + _logger.error("Failed to get asset tracker store datapoints. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) diff --git a/python/fledge/services/core/api/audit.py b/python/fledge/services/core/api/audit.py index fdbf95849f..6a9b1b3bc0 100644 --- a/python/fledge/services/core/api/audit.py +++ b/python/fledge/services/core/api/audit.py @@ -119,19 +119,18 @@ async def create_audit_entry(request): except AttributeError as e: # Return error for wrong severity method err_msg = "severity type {} is not supported".format(severity) - _logger.warning(err_msg) raise web.HTTPNotFound(reason=err_msg, body=json.dumps({"message": err_msg})) except StorageServerError as ex: if int(ex.code) in range(400, 500): err_msg = 'Audit entry cannot be logged. {}'.format(ex.error['message']) - _logger.warning(err_msg) raise web.HTTPBadRequest(reason=err_msg, body=json.dumps({"message": err_msg})) else: err_msg = 'Failed to log audit entry. {}'.format(ex.error['message']) + _logger.warning(err_msg) raise web.HTTPInternalServerError(reason=err_msg, body=json.dumps({"message": err_msg})) except Exception as ex: msg = str(ex) - _logger.error("Audit log entry failed. Found error: {}".format(msg)) + _logger.error("Failed to log audit entry. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(message) @@ -271,7 +270,7 @@ async def get_audit_entries(request): res.append(r) except Exception as ex: msg = str(ex) - _logger.error("Get Audit log entry failed. Found error: {}".format(msg)) + _logger.error("Get Audit log entry failed. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'audit': res, 'totalCount': total_count}) diff --git a/python/fledge/services/core/api/auth.py b/python/fledge/services/core/api/auth.py index 943200de25..f6e8976ce0 100644 --- a/python/fledge/services/core/api/auth.py +++ b/python/fledge/services/core/api/auth.py @@ -216,7 +216,7 @@ async def get_ott(request): is_admin = True except Exception as ex: msg = str(ex) - _logger.error("OTT token failed. Found error: {}".format(msg)) + _logger.error("OTT token failed. {}".format(msg)) raise web.HTTPBadRequest(reason="The request failed due to {}".format(msg)) else: now_time = datetime.datetime.now() @@ -253,7 +253,6 @@ async def logout_me(request): result = await User.Objects.delete_token(request.token) if not result['rows_affected']: - _logger.error("Logout requested with bad user token.") raise web.HTTPNotFound() __remove_ott_for_token(request.token) @@ -278,7 +277,6 @@ async def logout(request): result = await User.Objects.delete_user_tokens(user_id) if not result['rows_affected']: - _logger.error("Logout requested with bad user.") raise web.HTTPNotFound() # Remove OTT token for this user if there. @@ -442,7 +440,7 @@ async def create_user(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Create user failed. Found error:{}".format(msg)) + _logger.error("Failed to create user. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) msg = "{} user has been created successfully.".format(username) _logger.info(msg) @@ -469,15 +467,15 @@ async def update_me(request): else: from fledge.services.core import connect from fledge.common.storage_client.payload_builder import PayloadBuilder - payload = PayloadBuilder().SELECT("user_id").WHERE(['token', '=', request.token]).payload() - storage_client = connect.get_storage_async() - result = await storage_client.query_tbl_with_payload("user_logins", payload) - if len(result['rows']) == 0: - raise User.DoesNotExist - payload = PayloadBuilder().SET(real_name=real_name.strip()).WHERE(['id', '=', - result['rows'][0]['user_id']]).payload() - message = "Something went wrong." try: + payload = PayloadBuilder().SELECT("user_id").WHERE(['token', '=', request.token]).payload() + storage_client = connect.get_storage_async() + result = await storage_client.query_tbl_with_payload("user_logins", payload) + if len(result['rows']) == 0: + raise User.DoesNotExist + user_id = result['rows'][0]['user_id'] + payload = PayloadBuilder().SET(real_name=real_name.strip()).WHERE(['id', '=', user_id]).payload() + message = "Something went wrong." result = await storage_client.update_tbl("users", payload) if result['response'] == 'updated': # TODO: FOGL-1226 At the moment only real name can update @@ -490,7 +488,7 @@ async def update_me(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Update profile user failed. Found error: {}".format(msg)) + _logger.error("Failed to update the user <{}> profile. {}".format(int(user_id), msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "Nothing to update." @@ -515,7 +513,7 @@ async def update_user(request): if int(user_id) == 1: msg = "Restricted for Super Admin user." _logger.warning(msg) - raise web.HTTPNotAcceptable(reason=msg, body=json.dumps({"message": msg})) + raise web.HTTPForbidden(reason=msg, body=json.dumps({"message": msg})) data = await request.json() access_method = data.get('access_method', '') @@ -560,7 +558,7 @@ async def update_user(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Update user by admin failed. Found error: {}".format(msg)) + _logger.error("Failed to update the user ID:<{}>. {}".format(user_id, msg)) raise web.HTTPInternalServerError(reason=str(exc), body=json.dumps({"message": msg})) return web.json_response({'user_info': user_info}) @@ -595,7 +593,7 @@ async def update_password(request): raise web.HTTPBadRequest(reason=PASSWORD_ERROR_MSG) if current_password == new_password: - msg = "New password should not be same as current password." + msg = "New password should not be the same as current password." raise web.HTTPBadRequest(reason=msg) user_id = await User.Objects.is_user_exists(user_id, current_password) @@ -618,7 +616,7 @@ async def update_password(request): raise web.HTTPBadRequest(reason=msg) except Exception as exc: msg = str(exc) - _logger.error("Update user failed. Found error: {}".format(msg)) + _logger.error("Failed to update the user ID:<{}>. {}".format(user_id, msg)) raise web.HTTPInternalServerError(reason=msg) msg = "Password has been updated successfully for user ID:<{}>.".format(int(user_id)) @@ -641,7 +639,7 @@ async def enable_user(request): if int(user_id) == 1: msg = "Restricted for Super Admin user." _logger.warning(msg) - raise web.HTTPNotAcceptable(reason=msg, body=json.dumps({"message": msg})) + raise web.HTTPForbidden(reason=msg, body=json.dumps({"message": msg})) data = await request.json() enabled = data.get('enabled') @@ -682,7 +680,7 @@ async def enable_user(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Enable/Disable user failed. Found error: {}".format(msg)) + _logger.error("Failed to enable/disable user ID:<{}>. {}".format(user_id, msg)) raise web.HTTPInternalServerError(reason=str(exc), body=json.dumps({"message": msg})) return web.json_response({'message': 'User with ID:<{}> has been {} successfully.'.format(int(user_id), _text)}) @@ -703,7 +701,7 @@ async def reset(request): if int(user_id) == 1: msg = "Restricted for Super Admin user." _logger.warning(msg) - raise web.HTTPNotAcceptable(reason=msg) + raise web.HTTPForbidden(reason=msg, body=json.dumps({"message": msg})) data = await request.json() password = data.get('password') @@ -745,7 +743,7 @@ async def reset(request): raise web.HTTPBadRequest(reason=msg) except Exception as exc: msg = str(exc) - _logger.error("Reset user failed. Found error: {}".format(msg)) + _logger.error("Failed to reset the user ID:<{}>. {}".format(user_id, msg)) raise web.HTTPInternalServerError(reason=msg) msg = "User with ID:<{}> has been updated successfully.".format(int(user_id)) @@ -773,7 +771,7 @@ async def delete_user(request): if user_id == 1: msg = "Super admin user can not be deleted." _logger.warning(msg) - raise web.HTTPNotAcceptable(reason=msg) + raise web.HTTPForbidden(reason=msg, body=json.dumps({"message": msg})) # Requester should not be able to delete her/himself if user_id == request.user["id"]: @@ -796,7 +794,7 @@ async def delete_user(request): raise web.HTTPNotFound(reason=msg) except Exception as exc: msg = str(exc) - _logger.error("Delete user failed. Found error: {}".format(msg)) + _logger.error("Failed to delete the user ID:<{}>. {}".format(user_id, msg)) raise web.HTTPInternalServerError(reason=msg) _logger.info("User with ID:<{}> has been deleted successfully.".format(int(user_id))) diff --git a/python/fledge/services/core/api/backup_restore.py b/python/fledge/services/core/api/backup_restore.py index bd61426a61..02a7b625c2 100644 --- a/python/fledge/services/core/api/backup_restore.py +++ b/python/fledge/services/core/api/backup_restore.py @@ -116,7 +116,7 @@ async def get_backups(request): res.append(r) except Exception as ex: msg = str(ex) - _logger.error("Get all backups failed. Found error: {}".format(msg)) + _logger.error("Get all backups failed. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"backups": res}) @@ -131,7 +131,7 @@ async def create_backup(request): status = await backup.create_backup() except Exception as ex: msg = str(ex) - _logger.error("Create backup failed. Found error: {}".format(msg)) + _logger.error("Failed to create Backup. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"status": status}) @@ -158,7 +158,7 @@ async def get_backup_details(request): raise web.HTTPNotFound(reason='Backup id {} does not exist'.format(backup_id)) except Exception as ex: msg = str(ex) - _logger.error("Get backup detail failed. Found error: {}".format(msg)) + _logger.error("Failed to fetch backup details for ID: <{}>. {}".format(backup_id, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response(resp) @@ -206,7 +206,7 @@ async def get_backup_download(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Backup download failed. Found error: {}".format(msg)) + _logger.error("Failed to backup download for ID:<{}>. {}".format(backup_id, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.FileResponse(path=gz_path) @@ -229,7 +229,7 @@ async def delete_backup(request): raise web.HTTPNotFound(reason='Backup id {} does not exist'.format(backup_id)) except Exception as ex: msg = str(ex) - _logger.error("Delete backup failed. Found error: {}".format(msg)) + _logger.error("Failed to delete Backup ID:<{}>. {}".format(backup_id, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -252,7 +252,7 @@ async def restore_backup(request): raise web.HTTPNotFound(reason='Backup with {} does not exist'.format(backup_id)) except Exception as ex: msg = str(ex) - _logger.error("Restore backup failed. Found error: {}".format(msg)) + _logger.error("Failed to restore Backup ID:<{}>. {}".format(backup_id, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -357,7 +357,7 @@ async def upload_backup(request: web.Request) -> web.Response: raise web.HTTPNotImplemented(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Upload backup failed. Found error: {}".format(msg)) + _logger.error("Failed to upload Backup. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "{} backup uploaded successfully.".format(file_name) diff --git a/python/fledge/services/core/api/browser.py b/python/fledge/services/core/api/browser.py index 4f7ab4ac41..ee56cb6908 100644 --- a/python/fledge/services/core/api/browser.py +++ b/python/fledge/services/core/api/browser.py @@ -154,7 +154,7 @@ async def asset_counts(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get all assets failed. Found error: {}".format(msg)) + _logger.error("Failed to get all assets. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(asset_json) @@ -220,7 +220,7 @@ async def asset(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get asset by asset code failed. Found error: {}".format(msg)) + _logger.error("Failed to get {} asset. {}".format(asset_code, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -247,7 +247,7 @@ async def asset_latest(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get latest asset failed. Found error: {}".format(msg)) + _logger.error("Failed to get latest {} asset. {}".format(asset_code, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -316,7 +316,7 @@ async def asset_reading(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get asset reading failed. Found error: {}".format(msg)) + _logger.error("Failed to get {} asset for {} reading. {}".format(asset_code, reading, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -392,7 +392,7 @@ async def asset_all_readings_summary(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get asset all readings summary failed. Found error: {}".format(msg)) + _logger.error("Failed to get {} asset readings summary. {}".format(asset_code, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -458,7 +458,7 @@ async def asset_summary(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get asset summary failed. Found error: {}".format(msg)) + _logger.error("Failed to get {} asset {} reading summary. {}".format(asset_code, reading, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({reading: response}) @@ -560,7 +560,7 @@ async def asset_averages(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get asset average readings failed. Found error: {}".format(msg)) + _logger.error("Failed to get average of {} readings for {} asset. {}".format(reading, asset_code, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -695,7 +695,7 @@ async def asset_datapoints_with_bucket_size(request: web.Request) -> web.Respons raise web.HTTPBadRequest(reason=e) except Exception as ex: msg = str(ex) - _logger.error("Get asset datapoints with bucket size failed. Found error: {}".format(msg)) + _logger.error("Failed to get {} asset datapoints with {} bucket size. {}".format(asset_code, bucket_size, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -774,7 +774,8 @@ async def asset_readings_with_bucket_size(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=e) except Exception as ex: msg = str(ex) - _logger.error("Get asset readings with bucket size failed. Found error: {}".format(msg)) + _logger.error("Failed to get {} readings of {} asset with {} bucket size. {}".format( + reading, asset_code, bucket_size, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -842,7 +843,7 @@ async def asset_structure(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get asset structure failed. Found error: {}".format(msg)) + _logger.error("Failed to get assets structure. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(asset_json) @@ -884,7 +885,7 @@ async def asset_purge_all(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get purge all assets failed. Found error: {}".format(msg)) + _logger.error("Failed to purge all assets. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(results) @@ -925,7 +926,7 @@ async def asset_purge(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get purge a particular asset failed. Found error: {}".format(msg)) + _logger.error("Failed to purge {} asset. {}".format(asset_code, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(results) @@ -954,7 +955,7 @@ async def asset_timespan(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get timespan of buffered readings for each asset failed. Found error: {}".format(msg)) + _logger.error("Failed to get timespan of buffered readings for assets. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -985,7 +986,7 @@ async def asset_reading_timespan(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Get timespan of buffered readings for given asset failed. Found error: {}".format(msg)) + _logger.error("Failed to get timespan of buffered readings for {} asset. {}".format(asset_code, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) diff --git a/python/fledge/services/core/api/configuration.py b/python/fledge/services/core/api/configuration.py index b086f79578..24ff08ddd0 100644 --- a/python/fledge/services/core/api/configuration.py +++ b/python/fledge/services/core/api/configuration.py @@ -166,7 +166,7 @@ async def create_category(request): raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Create category failed. Found error: {}".format(msg)) + _logger.error("Failed to create category. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response(result) @@ -192,7 +192,7 @@ async def delete_category(request): raise web.HTTPBadRequest(reason=ex) except Exception as ex: msg = str(ex) - _logger.error("Delete category failed. Found error: {}".format(msg)) + _logger.error("Failed to delete {} category. {}".format(category_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': 'Category {} deleted successfully.'.format(category_name)}) @@ -359,7 +359,7 @@ async def update_configuration_item_bulk(request): raise web.HTTPBadRequest(reason=ex) except Exception as ex: msg = str(ex) - _logger.error("Bulk update category failed. Found error: {}".format(msg)) + _logger.error("Failed to bulk update {} category. {}".format(category_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: cat = await cf_mgr.get_category_all_items(category_name) @@ -437,7 +437,7 @@ async def add_configuration_item(request): raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Create config item failed. Found error: {}".format(msg)) + _logger.error("Failed to create {} config item for {} category. {}".format(new_config_item, category_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"message": "{} config item has been saved for {} category".format(new_config_item, category_name)}) @@ -515,7 +515,10 @@ async def get_child_category(request): children = await cf_mgr.get_category_child(category_name) except ValueError as ex: raise web.HTTPNotFound(reason=str(ex)) - + except Exception as ex: + msg = str(ex) + _logger.error("Failed to get the child {} category. {}".format(category_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"categories": children}) @@ -546,7 +549,10 @@ async def create_child_category(request): raise web.HTTPBadRequest(reason=str(ex)) except ValueError as ex: raise web.HTTPNotFound(reason=str(ex)) - + except Exception as ex: + msg = str(ex) + _logger.error("Failed to create the child relationship for {} category. {}".format(category_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response(r) @@ -574,7 +580,11 @@ async def delete_child_category(request): raise web.HTTPBadRequest(reason=str(ex)) except ValueError as ex: raise web.HTTPNotFound(reason=str(ex)) - + except Exception as ex: + msg = str(ex) + _logger.error("Failed to delete the {} child of {} category. {}".format( + child_category, category_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"children": result}) @@ -600,7 +610,10 @@ async def delete_parent_category(request): raise web.HTTPBadRequest(reason=str(ex)) except ValueError as ex: raise web.HTTPNotFound(reason=str(ex)) - + except Exception as ex: + msg = str(ex) + _logger.error("Failed to delete the parent-child relationship of {} category. {}".format(category_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"message": "Parent-child relationship for the parent-{} is deleted".format(category_name)}) @@ -665,7 +678,8 @@ async def upload_script(request): except Exception as ex: os.remove(script_file_path) msg = str(ex) - _logger.error("Upload script for a config item failed. Found error: {}".format(msg)) + _logger.error("Failed to upload script for {} config item of {} category. {}".format( + config_item, category_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: result = await cf_mgr.get_category_item(category_name, config_item) diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index 3248e6d067..fdc622c1bf 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -73,7 +73,7 @@ async def get_acl(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get ACL failed. Found error: {}".format(msg)) + _logger.error("Failed to get {} ACL. {}".format(name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(acl_info) @@ -140,7 +140,7 @@ async def add_acl(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("ACL create failed. Found error: {}".format(msg)) + _logger.error("Failed to create ACL. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(result) @@ -206,7 +206,7 @@ async def update_acl(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=message, body=json.dumps({"message": message})) except Exception as ex: message = str(ex) - _logger.error("ACL update failed. Found error: {}".format(message)) + _logger.error("Failed to update {} ACL. {}".format(name, message)) raise web.HTTPInternalServerError(reason=message, body=json.dumps({"message": message})) else: # Fetch service name associated with acl @@ -264,7 +264,7 @@ async def delete_acl(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("ACL delete failed. Found error: {}".format(msg)) + _logger.error("Failed to delete {} ACL. {}".format(name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) @@ -352,7 +352,7 @@ async def attach_acl_to_service(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Attach ACL to {} service failed. Found error: {}".format(svc_name, msg)) + _logger.error("Attach ACL to {} service failed. {}".format(svc_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: # Call service security endpoint with attachACL = acl_name @@ -428,7 +428,7 @@ async def detach_acl_from_service(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Detach ACL from {} service failed. Found error: {}".format(svc_name, msg)) + _logger.error("Detach ACL from {} service failed. {}".format(svc_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index 526160e430..acea219365 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -141,10 +141,10 @@ async def add_schedule_and_configuration(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Automation script schedule task failed. Found error: {}".format(msg)) + _logger.error("Failed to add schedule task for control script {}. {}".format(name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: - msg = "Schedule and configuration is created for an automation script with name {}".format(name) + msg = "Schedule and configuration is created for control script {}".format(name) return web.json_response({"message": msg}) @@ -191,7 +191,7 @@ async def get_all(request: web.Request) -> web.Response: scripts.append(row) except Exception as ex: msg = str(ex) - _logger.error("Get Control script failed. Found error: {}".format(msg)) + _logger.error("Get Control script failed. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"scripts": scripts}) @@ -248,7 +248,7 @@ async def get_by_name(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get Control script by name failed. Found error: {}".format(msg)) + _logger.error("Get Control script by name failed. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(rows) @@ -334,7 +334,7 @@ async def add(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Control script create failed. Found error: {}".format(msg)) + _logger.error("Control script create failed. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(result) @@ -426,7 +426,7 @@ async def update(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Control script update failed. Found error: {}".format(msg)) + _logger.error("Control script update failed. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) @@ -486,7 +486,7 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Control script delete failed. Found error: {}".format(msg)) + _logger.error("Control script delete failed. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) diff --git a/python/fledge/services/core/api/filters.py b/python/fledge/services/core/api/filters.py index 7bb14e141e..6c2a8d4c31 100644 --- a/python/fledge/services/core/api/filters.py +++ b/python/fledge/services/core/api/filters.py @@ -276,8 +276,8 @@ async def add_filters_pipeline(request: web.Request) -> web.Response: except TypeError as err: msg = str(err) raise web.HTTPBadRequest(reason=msg) - except StorageServerError as ex: - msg = ex.error + except StorageServerError as e: + msg = e.error _LOGGER.exception("Add filters pipeline, caught storage error: {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: @@ -321,7 +321,7 @@ async def get_filter(request: web.Request) -> web.Response: filter_detail.update({"users": users}) except StorageServerError as ex: msg = ex.error - _LOGGER.exception("Get {} filter, caught storage exception: {}".format(filter_name, msg)) + _LOGGER.exception("Failed to get filter name: {}. Storage error occurred: {}".format(filter_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except ValueError as err: raise web.HTTPNotFound(reason=str(err)) @@ -379,7 +379,7 @@ async def get_filter_pipeline(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg) except StorageServerError as ex: msg = ex.error - _LOGGER.exception("Get {} filter pipeline, caught storage exception: {}".format(user_name, msg)) + _LOGGER.exception("Failed to delete filter pipeline {}. Storage error occurred: {}".format(user_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except ValueError as err: raise web.HTTPNotFound(reason=str(err)) diff --git a/python/fledge/services/core/api/health.py b/python/fledge/services/core/api/health.py index 73621f06ed..01fb14fe5c 100644 --- a/python/fledge/services/core/api/health.py +++ b/python/fledge/services/core/api/health.py @@ -38,7 +38,7 @@ async def get_disk_usage(given_dir): stdout, stderr = await disk_check_process.communicate() if disk_check_process.returncode != 0: stderr = stderr.decode("utf-8") - msg = "Failed to get disk stats of {} directory due to {}".format(given_dir, str(stderr)) + msg = "Failed to get disk stats of {} directory. {}".format(given_dir, str(stderr)) _LOGGER.error(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -108,7 +108,7 @@ async def get_logging_health(request: web.Request) -> web.Response: response["levels"] = log_levels except Exception as ex: - msg = "Could not fetch service information due to {}".format(str(ex)) + msg = "Could not fetch service information. {}".format(str(ex)) _LOGGER.error(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -121,7 +121,7 @@ async def get_logging_health(request: web.Request) -> web.Response: response['disk']['available'] = available except Exception as ex: - msg = "Failed to get disk stats for /var/log due to {}".format(str(ex)) + msg = "Failed to get disk stats for /var/log. {}".format(str(ex)) _LOGGER.error(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: @@ -188,7 +188,7 @@ async def get_storage_health(request: web.Request) -> web.Response: except Exception as ex: msg = str(ex) - _LOGGER.error("Could not ping Storage due to {}".format(msg)) + _LOGGER.error("Could not ping the Storage service. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) try: @@ -207,7 +207,7 @@ async def get_storage_health(request: web.Request) -> web.Response: response['disk']['available'] = available response['disk']['status'] = status except Exception as ex: - msg = "Failed to get disk stats for storage service due to {}".format(str(ex)) + msg = "Failed to get disk stats for Storage service. {}".format(str(ex)) _LOGGER.error(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: diff --git a/python/fledge/services/core/api/north.py b/python/fledge/services/core/api/north.py index 24a4debbf5..09e00b5a09 100644 --- a/python/fledge/services/core/api/north.py +++ b/python/fledge/services/core/api/north.py @@ -185,7 +185,7 @@ async def get_north_schedules(request): return web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get north schedules failed. Found error: {}".format(msg)) + _logger.error("Failed to get the north schedules. {}".format(msg)) return web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(north_schedules) diff --git a/python/fledge/services/core/api/notification.py b/python/fledge/services/core/api/notification.py index 77f8ba2a39..0964de004e 100644 --- a/python/fledge/services/core/api/notification.py +++ b/python/fledge/services/core/api/notification.py @@ -57,7 +57,7 @@ async def get_plugin(request): delivery_plugins = json.loads(await _hit_get_url(url)) except Exception as ex: msg = str(ex) - _logger.error("Get notification plugin list failed. Found error: {}".format(msg)) + _logger.error("Failed to get notification plugin list. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'rules': rule_plugins, 'delivery': delivery_plugins}) @@ -114,7 +114,7 @@ async def get_notification(request): raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Get notification instance info failed. Found error: {}".format(msg)) + _logger.error("Failed to get {} notification instance. {}".format(notif, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'notification': notification}) @@ -152,7 +152,7 @@ async def get_notifications(request): notifications.append(notification) except Exception as ex: msg = str(ex) - _logger.error("Get notification instances list failed. Found error: {}".format(msg)) + _logger.error("Failed to get notification instances. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'notifications': notifications}) @@ -289,7 +289,7 @@ async def post_notification(request): raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Notification instance create failed. Found error: {}".format(msg)) + _logger.error("Failed to create notification instance. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "Notification {} created successfully".format(name)}) @@ -445,7 +445,7 @@ async def put_notification(request): raise web.HTTPNotFound(reason=str(e)) except Exception as ex: msg = str(ex) - _logger.error("Notification instance update failed. Found error: {}".format(msg)) + _logger.error("Failed to update {} notification instance. {}".format(notif, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: # TODO: Start notification after update @@ -486,7 +486,7 @@ async def delete_notification(request): raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Notification instance delete failed. Found error: {}".format(msg)) + _logger.error("Failed to delete {} notification instance. {}".format(notif, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': 'Notification {} deleted successfully.'.format(notif)}) @@ -539,7 +539,7 @@ async def _update_configurations(config_mgr, name, notification_config, rule_con category_name = "delivery{}".format(name) await config_mgr.update_configuration_item_bulk(category_name, delivery_config) except Exception as ex: - msg = "Failed to update notification configuration due to {}".format(str(ex)) + msg = "Failed to update {} notification configuration. {}".format(name, str(ex)) _logger.error(msg) return web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -639,7 +639,7 @@ async def get_delivery_channels(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get delivery channels failed. Found error: {}".format(msg)) + _logger.error("Failed to get delivery channels of {} notification. {}".format(notification_instance_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"channels": channels}) @@ -700,7 +700,8 @@ async def post_delivery_channel(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Delivery channel create failed. Found error: {}".format(msg)) + _logger.error("Failed to create delivery channel of {} notification. {}".format( + notification_instance_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"category": channel_name, "description": channel_description, @@ -733,7 +734,8 @@ async def get_delivery_channel_configuration(request: web.Request) -> web.Respon raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get delivery channel configuration failed. Found error: {}".format(msg)) + _logger.error("Failed to get delivery channel configuration of {} notification. {}".format( + notification_instance_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"config": channel_config}) @@ -788,7 +790,8 @@ async def delete_delivery_channel(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Delivery channel delete failed. Found error: {}".format(msg)) + _logger.error("Failed to delete delivery channel of {} notification. {}".format( + notification_instance_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"channels": channels}) diff --git a/python/fledge/services/core/api/package_log.py b/python/fledge/services/core/api/package_log.py index 75bb09791c..6dbc1a1aff 100644 --- a/python/fledge/services/core/api/package_log.py +++ b/python/fledge/services/core/api/package_log.py @@ -143,7 +143,7 @@ async def get_package_status(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _LOGGER.error("Get Package log status failed. Found error: {}".format(msg)) + _LOGGER.error("Failed tp get package log status for {} action. {}".format(action, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"packageStatus": result}) diff --git a/python/fledge/services/core/api/plugins/data.py b/python/fledge/services/core/api/plugins/data.py index 97fe7b97e9..2b99f5ce7b 100644 --- a/python/fledge/services/core/api/plugins/data.py +++ b/python/fledge/services/core/api/plugins/data.py @@ -72,7 +72,7 @@ async def get_persist_plugins(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get persist plugins failed. Found error: {}".format(msg)) + _logger.error("Failed to get persist plugins. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'persistent': plugins}) @@ -107,7 +107,7 @@ async def get(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get plugin data failed. Found error: {}".format(msg)) + _logger.error("Failed to get {} plugin data for {} service. {}".format(plugin, service, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'data': data}) @@ -154,7 +154,7 @@ async def add(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Plugin data create failed. Found error: {}".format(msg)) + _logger.error("Failed to create plugin data. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "{} key added successfully.".format(key)}) @@ -194,7 +194,7 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Plugin data delete failed. Found error: {}".format(msg)) + _logger.error("Failed to delete {} plugin data for {} service. {}".format(plugin, service, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "{} deleted successfully.".format(key)}) diff --git a/python/fledge/services/core/api/plugins/discovery.py b/python/fledge/services/core/api/plugins/discovery.py index dde3b05174..8412d7110c 100644 --- a/python/fledge/services/core/api/plugins/discovery.py +++ b/python/fledge/services/core/api/plugins/discovery.py @@ -81,11 +81,11 @@ async def get_plugins_available(request: web.Request) -> web.Response: except ValueError as e: raise web.HTTPBadRequest(reason=e) except PackageError as e: - msg = "Fetch available plugins package request failed" + msg = "Fetch available plugins package request failed." raise web.HTTPBadRequest(body=json.dumps({"message": msg, "link": str(e)}), reason=msg) except Exception as ex: msg = str(ex) - _logger.error("Get Plugins available is failed. Found error: {}".format(msg)) + _logger.error("Failed to get plugins available list. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"plugins": plugins, "link": log_path}) diff --git a/python/fledge/services/core/api/plugins/install.py b/python/fledge/services/core/api/plugins/install.py index 6f2b52b71e..2fefb60ec7 100644 --- a/python/fledge/services/core/api/plugins/install.py +++ b/python/fledge/services/core/api/plugins/install.py @@ -190,7 +190,7 @@ async def add_plugin(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: msg = str(ex) - _LOGGER.error("Install Plugin failed. Found error: {}".format(msg)) + _LOGGER.error("Failed to install plugin. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index 01e3f8362f..509e0b8fe2 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -137,7 +137,7 @@ async def remove_plugin(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: msg = str(ex) - _logger.error("Remove Plugin failed. Found error: {}".format(msg)) + _logger.error("Failed to remove {} plugin. {}".format(name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index fa2cb17f11..326364d567 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -165,7 +165,7 @@ async def update_plugin(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: msg = str(ex) - _logger.error("Update Plugin failed. Found error: {}".format(msg)) + _logger.error("Failed to update {} plugin. {}".format(name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) diff --git a/python/fledge/services/core/api/repos/configure.py b/python/fledge/services/core/api/repos/configure.py index 386ebd0223..e4903e1abf 100644 --- a/python/fledge/services/core/api/repos/configure.py +++ b/python/fledge/services/core/api/repos/configure.py @@ -137,7 +137,7 @@ async def add_package_repo(request: web.Request) -> web.Response: "output_log": msg}), reason=msg) except Exception as ex: msg = str(ex) - _LOGGER.error("Add Package repo failed. Found error: {}".format(msg)) + _LOGGER.error("Failed to configure archive package repository setup. {}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": "Package repository configured successfully.", diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index 5786141012..d108aab0b5 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -366,7 +366,7 @@ async def add_service(request): except TypeError as ex: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: - _logger.error("Failed to fetch plugin configuration. %s", str(ex)) + _logger.error("Failed to fetch plugin info config item. {}".format(str(ex))) raise web.HTTPInternalServerError(reason='Failed to fetch plugin configuration') elif service_type == 'notification': if not os.path.exists(_FLEDGE_ROOT + "/services/fledge.services.{}".format(service_type)): @@ -470,11 +470,11 @@ async def add_service(request): raise ValueError('Config must be a JSON object') for k, v in config.items(): await config_mgr.set_category_item_value_entry(name, k, v['value']) - except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.error("Failed to create plugin configuration. %s", str(ex)) - raise web.HTTPInternalServerError(reason='Failed to create plugin configuration. {}'.format(ex)) + msg = "Failed to create plugin configuration while adding service. {}".format(str(ex)) + _logger.error(msg) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) # If all successful then lastly add a schedule to run the new service at startup try: diff --git a/python/fledge/services/core/api/support.py b/python/fledge/services/core/api/support.py index c740853d19..9c358f761f 100644 --- a/python/fledge/services/core/api/support.py +++ b/python/fledge/services/core/api/support.py @@ -109,7 +109,7 @@ async def create_support_bundle(request): try: bundle_name = await SupportBuilder(support_dir).build() except Exception as ex: - msg = 'Support bundle create failed. Found error: {}'.format(str(ex)) + msg = 'Failed to create support bundle. {}'.format(str(ex)) _logger.error(msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -221,7 +221,7 @@ async def get_syslog_entries(request): raise web.HTTPBadRequest(body=json.dumps({"message": msg}), reason=msg) except (OSError, Exception) as ex: msg = str(ex) - _logger.error("Get syslog entries failed. Found error: {}".format(msg)) + _logger.error("Failed to get syslog entries. {}".format(msg)) raise web.HTTPInternalServerError(body=json.dumps({"message": msg}), reason=msg) return web.json_response(response) diff --git a/python/fledge/services/core/api/task.py b/python/fledge/services/core/api/task.py index ad0b6eef2a..292af6b9d6 100644 --- a/python/fledge/services/core/api/task.py +++ b/python/fledge/services/core/api/task.py @@ -291,8 +291,8 @@ async def delete_task(request): :Example: curl -X DELETE http://localhost:8081/fledge/scheduled/task/ """ + north_instance = request.match_info.get('task_name', None) try: - north_instance = request.match_info.get('task_name', None) storage = connect.get_storage_async() result = await get_schedule(storage, north_instance) @@ -322,7 +322,7 @@ async def delete_task(request): await update_deprecated_ts_in_asset_tracker(storage, north_instance) except Exception as ex: msg = str(ex) - _logger.error("Delete task failed. Found error: {}".format(msg)) + _logger.error("Failed to delete {} north task. {}".format(north_instance, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': 'North instance {} deleted successfully.'.format(north_instance)}) diff --git a/python/fledge/services/core/api/update.py b/python/fledge/services/core/api/update.py index 570bf5bff8..18a9bd5c7f 100644 --- a/python/fledge/services/core/api/update.py +++ b/python/fledge/services/core/api/update.py @@ -105,7 +105,7 @@ async def update_package(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Update Package failed. Found error: {}".format(msg)) + _logger.error("Failed to update Fledge package.{}".format(msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"status": "Running", "message": status_message}) From 441824f2faafbe1a2a1023cd2ce05f225458c623 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 16 Mar 2023 17:00:16 +0530 Subject: [PATCH 192/499] core API tests updated Signed-off-by: ashish-jabble --- .../control_service/test_script_management.py | 2 +- .../core/api/plugins/test_discovery.py | 2 +- .../services/core/api/plugins/test_remove.py | 12 +- .../services/core/api/test_api_utils.py | 6 +- .../core/api/test_asset_tracker_api.py | 6 +- .../fledge/services/core/api/test_audit.py | 16 +- .../services/core/api/test_auth_mandatory.py | 233 ++++++++---------- .../services/core/api/test_auth_optional.py | 94 ++++--- .../services/core/api/test_backup_restore.py | 58 +++-- .../services/core/api/test_configuration.py | 42 ++-- .../fledge/services/core/api/test_filters.py | 221 ++++++++--------- .../fledge/services/core/api/test_service.py | 4 +- .../fledge/services/core/api/test_support.py | 23 +- .../fledge/services/core/api/test_task.py | 15 +- 14 files changed, 361 insertions(+), 373 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py index 3267ea86ee..1427e52702 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py @@ -722,7 +722,7 @@ async def test_schedule_configuration_for_script(self, client): query_payload = {"return": ["name", "steps", "acl"], "where": {"column": "name", "condition": "=", "value": script_name}} - message = "Schedule and configuration is created for an automation script with name {}".format(script_name) + message = "Schedule and configuration is created for control script {}".format(script_name) storage_client_mock = MagicMock(StorageClientAsync) c_mgr = ConfigurationManager(storage_client_mock) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): diff --git a/tests/unit/python/fledge/services/core/api/plugins/test_discovery.py b/tests/unit/python/fledge/services/core/api/plugins/test_discovery.py index 591f10c82e..eac44ae114 100644 --- a/tests/unit/python/fledge/services/core/api/plugins/test_discovery.py +++ b/tests/unit/python/fledge/services/core/api/plugins/test_discovery.py @@ -170,7 +170,7 @@ async def test_bad_type_get_plugins_available(self, client): async def test_bad_get_plugins_available(self, client): log_path = "log/190801-12-01-05.log" - msg = "Fetch available plugins package request failed" + msg = "Fetch available plugins package request failed." with patch.object(common, 'fetch_available_packages', side_effect=PackageError(log_path)) as patch_fetch_available_package: resp = await client.get('/fledge/plugins/available') assert 400 == resp.status diff --git a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py index 2390e13ad5..73aaeaa2f7 100644 --- a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py +++ b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py @@ -98,7 +98,7 @@ async def async_mock(return_value): with patch.object(PluginDiscovery, 'get_plugins_installed', return_value=plugin_installed ) as plugin_installed_patch: with patch.object(plugins_remove, '_check_plugin_usage', return_value=_rv) as plugin_usage_patch: - with patch.object(plugins_remove._logger, "error") as log_err_patch: + with patch.object(plugins_remove._logger, "warning") as patch_logger: resp = await client.delete('/fledge/plugins/{}/{}'.format(_type, name), data=None) assert 400 == resp.status expected_msg = "{} cannot be removed. This is being used by {} instances.".format(name, svc_list) @@ -106,8 +106,8 @@ async def async_mock(return_value): result = await resp.text() response = json.loads(result) assert {'message': expected_msg} == response - assert 1 == log_err_patch.call_count - log_err_patch.assert_called_once_with(expected_msg) + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with(expected_msg) plugin_usage_patch.assert_called_once_with(_type, name) plugin_installed_patch.assert_called_once_with(_type, False) @@ -135,7 +135,7 @@ async def async_mock(return_value): with patch.object(PluginDiscovery, 'get_plugins_installed', return_value=plugin_installed ) as plugin_installed_patch: with patch.object(plugins_remove, '_check_plugin_usage_in_notification_instances', return_value=_rv) as plugin_usage_patch: - with patch.object(plugins_remove._logger, "error") as log_err_patch: + with patch.object(plugins_remove._logger, "warning") as patch_logger: resp = await client.delete('/fledge/plugins/{}/{}'.format(plugin_type, plugin_installed_dirname), data=None) assert 400 == resp.status @@ -145,8 +145,8 @@ async def async_mock(return_value): result = await resp.text() response = json.loads(result) assert {'message': expected_msg} == response - assert 1 == log_err_patch.call_count - log_err_patch.assert_called_once_with(expected_msg) + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with(expected_msg) plugin_usage_patch.assert_called_once_with(plugin_installed_dirname) plugin_installed_patch.assert_called_once_with(plugin_type_installed_dir, False) diff --git a/tests/unit/python/fledge/services/core/api/test_api_utils.py b/tests/unit/python/fledge/services/core/api/test_api_utils.py index 67e278cc47..340f4f299e 100644 --- a/tests/unit/python/fledge/services/core/api/test_api_utils.py +++ b/tests/unit/python/fledge/services/core/api/test_api_utils.py @@ -37,11 +37,7 @@ def test_get_plugin_info_value_error(self): plugin_name = 'Random' with patch.object(utils, '_find_c_util', return_value='plugins/utils/get_plugin_info') as patch_util: with patch.object(utils, '_find_c_lib', return_value=None) as patch_lib: - with patch.object(utils._logger, 'error') as patch_logger: - assert {} == utils.get_plugin_info(plugin_name, dir='south') - assert 1 == patch_logger.call_count - args, kwargs = patch_logger.call_args - assert 'The plugin {} does not exist'.format(plugin_name) == args[0] + assert {} == utils.get_plugin_info(plugin_name, dir='south') patch_lib.assert_called_once_with(plugin_name, 'south') patch_util.assert_called_once_with('get_plugin_info') diff --git a/tests/unit/python/fledge/services/core/api/test_asset_tracker_api.py b/tests/unit/python/fledge/services/core/api/test_asset_tracker_api.py index 9914b115cf..c1f55dcc14 100644 --- a/tests/unit/python/fledge/services/core/api/test_asset_tracker_api.py +++ b/tests/unit/python/fledge/services/core/api/test_asset_tracker_api.py @@ -86,8 +86,10 @@ async def test_bad_deprecate_entry(self, client): storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=_rv): - resp = await client.put('/fledge/track/service/XXX/asset/XXX/event/XXXX') - assert 500 == resp.status + with patch.object(_logger, 'error') as patch_logger: + resp = await client.put('/fledge/track/service/XXX/asset/XXX/event/XXXX') + assert 500 == resp.status + assert 1 == patch_logger.call_count async def test_deprecate_entry_not_found(self, client): result = {"count": 0, "rows": []} diff --git a/tests/unit/python/fledge/services/core/api/test_audit.py b/tests/unit/python/fledge/services/core/api/test_audit.py index 63cf97ea94..5417fe0ab3 100644 --- a/tests/unit/python/fledge/services/core/api/test_audit.py +++ b/tests/unit/python/fledge/services/core/api/test_audit.py @@ -180,9 +180,11 @@ async def async_mock_log(): async def test_get_audit_http_exception(self, client): msg = 'Internal Server Error' with patch.object(connect, 'get_storage_async', side_effect=Exception(msg)): - resp = await client.get('/fledge/audit') - assert 500 == resp.status - assert msg == resp.reason + with patch.object(audit._logger, 'error') as patch_logger: + resp = await client.get('/fledge/audit') + assert 500 == resp.status + assert msg == resp.reason + assert 1 == patch_logger.call_count async def test_create_audit_entry(self, client, loop): request_data = {"source": "LMTR", "severity": "warning", "details": {"message": "Engine oil pressure low"}} @@ -233,6 +235,8 @@ async def test_create_audit_entry_with_attribute_error(self, client): async def test_create_audit_entry_with_exception(self, client): request_data = {"source": "LMTR", "severity": "blah", "details": {"message": "Engine oil pressure low"}} with patch.object(AuditLogger, "__init__", return_value=""): - resp = await client.post('/fledge/audit', data=json.dumps(request_data)) - assert 500 == resp.status - assert "__init__() should return None, not 'str'" == resp.reason + with patch.object(audit._logger, 'error') as patch_logger: + resp = await client.post('/fledge/audit', data=json.dumps(request_data)) + assert 500 == resp.status + assert "__init__() should return None, not 'str'" == resp.reason + assert 1 == patch_logger.call_count diff --git a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py index b5e0d32742..64115233d5 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py @@ -95,14 +95,12 @@ async def test_create_bad_user(self, client, mocker, payload, msg): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: - with patch.object(auth._logger, 'error') as patch_logger_error: - resp = await client.post('/fledge/admin/user', data=json.dumps(payload), headers=ADMIN_USER_HEADER) - assert 400 == resp.status - assert msg == resp.reason - result = await resp.text() - json_response = json.loads(result) - assert {"message": msg} == json_response - patch_logger_error.assert_called_once_with(msg) + resp = await client.post('/fledge/admin/user', data=json.dumps(payload), headers=ADMIN_USER_HEADER) + assert 400 == resp.status + assert msg == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": msg} == json_response patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -114,7 +112,7 @@ async def test_create_bad_user(self, client, mocker, payload, msg): {"username": "aj.aj", "password": "F0gl@mp", "role_id": "blah"} ]) async def test_create_user_with_bad_role(self, client, mocker, request_data): - msg = "Invalid role id." + msg = "Invalid role ID." patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -126,15 +124,12 @@ async def test_create_user_with_bad_role(self, client, mocker, request_data): _rv2 = asyncio.ensure_future(mock_coro(False)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv1) as patch_role_id: with patch.object(auth, 'is_valid_role', return_value=_rv2) as patch_role: - with patch.object(auth._logger, 'error') as patch_logger_err: - resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), - headers=ADMIN_USER_HEADER) - assert 400 == resp.status - assert msg == resp.reason - result = await resp.text() - json_response = json.loads(result) - assert {"message": msg} == json_response - patch_logger_err.assert_called_once_with(msg) + resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), headers=ADMIN_USER_HEADER) + assert 400 == resp.status + assert msg == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": msg} == json_response patch_role.assert_called_once_with(request_data['role_id']) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) @@ -273,8 +268,9 @@ async def test_create_user_unknown_exception(self, client): with patch.object(User.Objects, 'get', side_effect=[_se1, User.DoesNotExist]) as patch_user_get: with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: - with patch.object(User.Objects, 'create', side_effect=Exception(exc_msg)) as patch_create_user: - with patch.object(auth._logger, 'exception') as patch_audit_logger_exc: + with patch.object(User.Objects, 'create', side_effect=Exception( + exc_msg)) as patch_create_user: + with patch.object(auth._logger, 'error') as patch_audit_logger_exc: resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), headers=ADMIN_USER_HEADER) assert 500 == resp.status @@ -282,7 +278,8 @@ async def test_create_user_unknown_exception(self, client): result = await resp.text() json_response = json.loads(result) assert {"message": exc_msg} == json_response - patch_audit_logger_exc.assert_called_once_with(exc_msg) + patch_audit_logger_exc.assert_called_once_with('Failed to create user. {}'.format( + exc_msg)) patch_create_user.assert_called_once_with(request_data['username'], request_data['password'], 2, 'any', '', '') patch_role.assert_called_once_with(2) @@ -321,16 +318,15 @@ async def test_create_user_value_error(self, client): with patch.object(User.Objects, 'get', side_effect=[_se1, User.DoesNotExist]) as patch_user_get: with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: - with patch.object(User.Objects, 'create', side_effect=ValueError(exc_msg)) as patch_create_user: - with patch.object(auth._logger, 'error') as patch_audit_logger_error: - resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), - headers=ADMIN_USER_HEADER) - assert 400 == resp.status - assert exc_msg == resp.reason - result = await resp.text() - json_response = json.loads(result) - assert {"message": exc_msg} == json_response - patch_audit_logger_error.assert_called_once_with(exc_msg) + with patch.object(User.Objects, 'create', side_effect=ValueError( + exc_msg)) as patch_create_user: + resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), + headers=ADMIN_USER_HEADER) + assert 400 == resp.status + assert exc_msg == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": exc_msg} == json_response patch_create_user.assert_called_once_with(request_data['username'], request_data['password'], 2, 'any', '', '') patch_role.assert_called_once_with(2) @@ -489,18 +485,17 @@ async def test_update_user(self, client, mocker, payload, exp_result): ({"new_password": 1}, "Current or new password is missing."), ({"new_password": "fledge"}, "Current or new password is missing."), ({"current_pwd": "fledge", "new_pwd": "fledge1"}, "Current or new password is missing."), - ({"current_password": "F0gl@mp", "new_password": "F0gl@mp"}, "New password should not be same as current password."), + ({"current_password": "F0gl@mp", "new_password": "F0gl@mp"}, + "New password should not be the same as current password."), ({"current_password": "F0gl@mp", "new_password": "fledge"}, PASSWORD_ERROR_MSG), ({"current_password": "F0gl@mp", "new_password": 1}, PASSWORD_ERROR_MSG) ]) async def test_update_password_with_bad_data(self, client, request_data, msg): uid = 2 with patch.object(middleware._logger, 'debug') as patch_logger_debug: - with patch.object(auth._logger, 'error') as patch_logger_error: - resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) - assert 400 == resp.status - assert msg == resp.reason - patch_logger_error.assert_called_once_with(msg) + resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) + assert 400 == resp.status + assert msg == resp.reason patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password'.format(uid)) @@ -513,21 +508,19 @@ async def test_update_password_with_invalid_current_password(self, client): _rv = await mock_coro(None) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(None)) with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'is_user_exists', return_value=_rv) as patch_user_exists: - with patch.object(auth._logger, 'warning') as patch_logger_warning: - resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) - assert 404 == resp.status - assert msg == resp.reason - patch_logger_warning.assert_called_once_with(msg) + resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) + assert 404 == resp.status + assert msg == resp.reason patch_user_exists.assert_called_once_with(str(uid), request_data['current_password']) - patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', - '/fledge/user/{}/password'.format(uid)) + patch_logger_debug.assert_called_once_with('Received %s request for %s', + 'PUT', '/fledge/user/{}/password'.format(uid)) - @pytest.mark.parametrize("exception_name, status_code, msg, log_level", [ - (ValueError, 400, 'None', "error"), - (User.DoesNotExist, 404, 'User with id:<2> does not exist.', "error"), - (User.PasswordAlreadyUsed, 400, 'The new password should be different from previous 3 used.', "warning") + @pytest.mark.parametrize("exception_name, status_code, msg", [ + (ValueError, 400, 'None'), + (User.DoesNotExist, 404, 'User with ID:<2> does not exist.'), + (User.PasswordAlreadyUsed, 400, 'The new password should be different from previous 3 used.') ]) - async def test_update_password_exceptions(self, client, exception_name, status_code, msg, log_level): + async def test_update_password_exceptions(self, client, exception_name, status_code, msg): request_data = {"current_password": "fledge", "new_password": "F0gl@mp"} uid = 2 # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -535,41 +528,40 @@ async def test_update_password_exceptions(self, client, exception_name, status_c with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'is_user_exists', return_value=_rv) as patch_user_exists: with patch.object(User.Objects, 'update', side_effect=exception_name(msg)) as patch_update: - with patch.object(auth._logger, log_level) as patch_auth_logger: - resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) - assert status_code == resp.status - assert msg == resp.reason - patch_auth_logger.assert_called_once_with(msg) + resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) + assert status_code == resp.status + assert msg == resp.reason patch_update.assert_called_once_with(2, {'password': request_data['new_password']}) patch_user_exists.assert_called_once_with(str(uid), request_data['current_password']) - patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' - .format(uid)) + patch_logger_debug.assert_called_once_with('Received %s request for %s', + 'PUT', '/fledge/user/{}/password'.format(uid)) async def test_update_password_unknown_exception(self, client): request_data = {"current_password": "fledge", "new_password": "F0gl@mp"} uid = 2 msg = 'Something went wrong' + logger_msg = 'Failed to update the user ID:<{}>. {}'.format(uid, msg) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(uid) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(uid)) with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'is_user_exists', return_value=_rv) as patch_user_exists: with patch.object(User.Objects, 'update', side_effect=Exception(msg)) as patch_update: - with patch.object(auth._logger, 'exception') as patch_logger_exception: + with patch.object(auth._logger, 'error') as patch_logger: resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) assert 500 == resp.status assert msg == resp.reason - patch_logger_exception.assert_called_once_with(msg) + patch_logger.assert_called_once_with(logger_msg) patch_update.assert_called_once_with(2, {'password': request_data['new_password']}) patch_user_exists.assert_called_once_with(str(uid), request_data['current_password']) - patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' - .format(uid)) + patch_logger_debug.assert_called_once_with('Received %s request for %s', + 'PUT', '/fledge/user/{}/password'.format(uid)) async def test_update_password(self, client): request_data = {"current_password": "fledge", "new_password": "F0gl@mp"} ret_val = {'response': 'updated', 'rows_affected': 1} uname = 'aj' user_id = 2 - msg = "Password has been updated successfully for user id:<{}>.".format(user_id) + msg = "Password has been updated successfully for user ID:<{}>.".format(user_id) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: @@ -591,8 +583,8 @@ async def test_update_password(self, client): patch_auth_logger_info.assert_called_once_with(msg) patch_update.assert_called_once_with(user_id, {'password': request_data['new_password']}) patch_user_exists.assert_called_once_with(str(user_id), request_data['current_password']) - patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/{}/password' - .format(user_id)) + patch_logger_debug.assert_called_once_with('Received %s request for %s', + 'PUT', '/fledge/user/{}/password'.format(user_id)) @pytest.mark.parametrize("request_data", ['blah', '123blah']) async def test_delete_bad_user(self, client, mocker, request_data): @@ -603,11 +595,9 @@ async def test_delete_bad_user(self, client, mocker, request_data): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: - with patch.object(auth._logger, 'error') as patch_auth_logger: - resp = await client.delete('/fledge/admin/{}/delete'.format(request_data), headers=ADMIN_USER_HEADER) - assert 400 == resp.status - assert msg == resp.reason - patch_auth_logger.assert_called_once_with(msg) + resp = await client.delete('/fledge/admin/{}/delete'.format(request_data), headers=ADMIN_USER_HEADER) + assert 400 == resp.status + assert msg == resp.reason patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -625,7 +615,7 @@ async def test_delete_admin_user(self, client, mocker): with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: with patch.object(auth._logger, 'warning') as patch_auth_logger_warn: resp = await client.delete('/fledge/admin/1/delete', headers=ADMIN_USER_HEADER) - assert 406 == resp.status + assert 403 == resp.status assert msg == resp.reason patch_auth_logger_warn.assert_called_once_with(msg) patch_role_id.assert_called_once_with('admin') @@ -655,7 +645,7 @@ async def test_delete_own_account(self, client, mocker): async def test_delete_invalid_user(self, client, mocker): ret_val = {"response": "deleted", "rows_affected": 0} - msg = 'User with id:<2> does not exist.' + msg = 'User with ID:<2> does not exist.' patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -667,13 +657,11 @@ async def test_delete_invalid_user(self, client, mocker): _rv2 = asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv1) as patch_role_id: - with patch.object(auth._logger, 'error') as patch_auth_logger: - with patch.object(User.Objects, 'delete', return_value=_rv2) as patch_user_delete: - resp = await client.delete('/fledge/admin/2/delete', headers=ADMIN_USER_HEADER) - assert 404 == resp.status - assert msg == resp.reason - patch_user_delete.assert_called_once_with(2) - patch_auth_logger.assert_called_once_with(msg) + with patch.object(User.Objects, 'delete', return_value=_rv2) as patch_user_delete: + resp = await client.delete('/fledge/admin/2/delete', headers=ADMIN_USER_HEADER) + assert 404 == resp.status + assert msg == resp.reason + patch_user_delete.assert_called_once_with(2) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -700,7 +688,7 @@ async def test_delete_user(self, client, mocker): r = await resp.text() assert {'message': 'User has been deleted successfully.'} == json.loads(r) patch_user_delete.assert_called_once_with(2) - patch_auth_logger_info.assert_called_once_with('User with id:<2> has been deleted successfully.') + patch_auth_logger_info.assert_called_once_with('User with ID:<2> has been deleted successfully.') patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -709,7 +697,7 @@ async def test_delete_user(self, client, mocker): @pytest.mark.parametrize("exception_name, code, msg", [ (ValueError, 400, 'None'), - (User.DoesNotExist, 404, 'User with id:<2> does not exist.') + (User.DoesNotExist, 404, 'User with ID:<2> does not exist.') ]) async def test_delete_user_exceptions(self, client, mocker, exception_name, code, msg): ret_val = [{'id': '1'}] @@ -718,13 +706,11 @@ async def test_delete_user_exceptions(self, client, mocker, exception_name, code # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: - with patch.object(auth._logger, 'error') as patch_auth_logger: - with patch.object(User.Objects, 'delete', side_effect=exception_name(msg)) as patch_user_delete: - resp = await client.delete('/fledge/admin/2/delete', headers=ADMIN_USER_HEADER) - assert code == resp.status - assert msg == resp.reason - patch_user_delete.assert_called_once_with(2) - patch_auth_logger.assert_called_once_with(msg) + with patch.object(User.Objects, 'delete', side_effect=exception_name(msg)) as patch_user_delete: + resp = await client.delete('/fledge/admin/2/delete', headers=ADMIN_USER_HEADER) + assert code == resp.status + assert msg == resp.reason + patch_user_delete.assert_called_once_with(2) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -739,13 +725,13 @@ async def test_delete_user_unknown_exception(self, client, mocker): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: - with patch.object(auth._logger, 'exception') as patch_auth_logger_exc: + with patch.object(auth._logger, 'error') as patch_auth_logger_exc: with patch.object(User.Objects, 'delete', side_effect=Exception(msg)) as patch_user_delete: resp = await client.delete('/fledge/admin/2/delete', headers=ADMIN_USER_HEADER) assert 500 == resp.status assert msg == resp.reason patch_user_delete.assert_called_once_with(2) - patch_auth_logger_exc.assert_called_once_with(msg) + patch_auth_logger_exc.assert_called_once_with('Failed to delete the user ID:<2>. {}'.format(msg)) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -765,7 +751,7 @@ async def test_logout(self, client, mocker): r = await resp.text() assert {'logout': True} == json.loads(r) patch_delete_user_token.assert_called_once_with("2") - patch_auth_logger_info.assert_called_once_with('User with id:<2> has been logged out successfully.') + patch_auth_logger_info.assert_called_once_with('User with ID:<2> has been logged out successfully.') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -779,11 +765,9 @@ async def test_logout_with_bad_user(self, client, mocker): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv) as patch_delete_user_token: - with patch.object(auth._logger, 'error') as patch_auth_logger: - resp = await client.put('/fledge/{}/logout'.format(user_id), headers=ADMIN_USER_HEADER) - assert 404 == resp.status - assert 'Not Found' == resp.reason - patch_auth_logger.assert_called_once_with('Logout requested with bad user.') + resp = await client.put('/fledge/{}/logout'.format(user_id), headers=ADMIN_USER_HEADER) + assert 404 == resp.status + assert 'Not Found' == resp.reason patch_delete_user_token.assert_called_once_with(str(user_id)) patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -819,7 +803,6 @@ async def test_logout_me_with_bad_token(self, client, mocker): resp = await client.put('/fledge/logout', headers=ADMIN_USER_HEADER) assert 404 == resp.status patch_delete_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) - patch_auth_logger.assert_called_once_with('Logout requested with bad user token.') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -836,7 +819,7 @@ async def test_enable_with_super_admin_user(self, client, mocker): with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/admin/1/enable', data=json.dumps({'role_id': 2}), headers=ADMIN_USER_HEADER) - assert 406 == resp.status + assert 403 == resp.status assert msg == resp.reason r = await resp.text() assert {'message': msg} == json.loads(r) @@ -911,7 +894,7 @@ async def test_enable_user(self, client, mocker, request_data): headers=ADMIN_USER_HEADER) assert 200 == resp.status r = await resp.text() - assert {"message": "User with id:<2> has been {} successfully.".format(_text)} == json.loads(r) + assert {"message": "User with ID:<2> has been {} successfully.".format(_text)} == json.loads(r) update_tbl_patch.assert_called_once_with('users', _payload) assert 2 == q_tbl_patch.call_count args, kwargs = q_tbl_patch.call_args_list[0] @@ -937,7 +920,7 @@ async def test_reset_super_admin(self, client, mocker): with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/admin/1/reset', data=json.dumps({'role_id': 2}), headers=ADMIN_USER_HEADER) - assert 406 == resp.status + assert 403 == resp.status assert msg == resp.reason patch_logger_warning.assert_called_once_with(msg) patch_role_id.assert_called_once_with('admin') @@ -946,25 +929,22 @@ async def test_reset_super_admin(self, client, mocker): patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1/reset') - @pytest.mark.parametrize("request_data, msg, log_level", [ - ({}, "Nothing to update the user.", "warning"), - ({"invalid": 1}, "Nothing to update the user.", "warning"), - ({"password": "fledge"}, PASSWORD_ERROR_MSG, "error"), - ({"password": 1}, PASSWORD_ERROR_MSG, "error") + @pytest.mark.parametrize("request_data, msg", [ + ({}, "Nothing to update the user."), + ({"invalid": 1}, "Nothing to update the user."), + ({"password": "fledge"}, PASSWORD_ERROR_MSG), + ({"password": 1}, PASSWORD_ERROR_MSG) ]) - async def test_reset_with_bad_data(self, client, mocker, request_data, msg, log_level): + async def test_reset_with_bad_data(self, client, mocker, request_data, msg): ret_val = [{'id': '1'}] patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: - with patch.object(auth._logger, log_level) as patch_auth_logger: - resp = await client.put('/fledge/admin/2/reset', data=json.dumps(request_data), - headers=ADMIN_USER_HEADER) - assert 400 == resp.status - assert msg == resp.reason - patch_auth_logger.assert_called_once_with(msg) + resp = await client.put('/fledge/admin/2/reset', data=json.dumps(request_data), headers=ADMIN_USER_HEADER) + assert 400 == resp.status + assert msg == resp.reason patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -986,12 +966,10 @@ async def test_reset_with_bad_role(self, client, mocker): with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv1) as patch_role_id: with patch.object(auth, 'is_valid_role', return_value=_rv2) as patch_role: - with patch.object(auth._logger, 'error') as patch_logger_error: - resp = await client.put('/fledge/admin/2/reset', data=json.dumps(request_data), - headers=ADMIN_USER_HEADER) - assert 400 == resp.status - assert msg == resp.reason - patch_logger_error.assert_called_once_with(msg) + resp = await client.put('/fledge/admin/2/reset', data=json.dumps(request_data), + headers=ADMIN_USER_HEADER) + assert 400 == resp.status + assert msg == resp.reason patch_role.assert_called_once_with(request_data['role_id']) patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) @@ -999,12 +977,12 @@ async def test_reset_with_bad_role(self, client, mocker): patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_logger_debug.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') - @pytest.mark.parametrize("exception_name, status_code, msg, log_level", [ - (ValueError, 400, 'None', "error"), - (User.DoesNotExist, 404, 'User with id:<2> does not exist.', "error"), - (User.PasswordAlreadyUsed, 400, 'The new password should be different from previous 3 used.', "warning") + @pytest.mark.parametrize("exception_name, status_code, msg", [ + (ValueError, 400, 'None'), + (User.DoesNotExist, 404, 'User with ID:<2> does not exist.'), + (User.PasswordAlreadyUsed, 400, 'The new password should be different from previous 3 used.') ]) - async def test_reset_exceptions(self, client, mocker, exception_name, status_code, msg, log_level): + async def test_reset_exceptions(self, client, mocker, exception_name, status_code, msg): request_data = {'role_id': '2'} user_id = 2 patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( @@ -1020,12 +998,13 @@ async def test_reset_exceptions(self, client, mocker, exception_name, status_cod with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv1) as patch_role_id: with patch.object(auth, 'is_valid_role', return_value=_rv2) as patch_role: with patch.object(User.Objects, 'update', side_effect=exception_name(msg)) as patch_update: - with patch.object(auth._logger, log_level) as patch_auth_logger: + with patch.object(auth._logger, 'warning') as patch_logger: resp = await client.put('/fledge/admin/{}/reset'.format(user_id), data=json.dumps(request_data), headers=ADMIN_USER_HEADER) assert status_code == resp.status assert msg == resp.reason - patch_auth_logger.assert_called_once_with(msg) + if exception_name == User.PasswordAlreadyUsed: + patch_logger.assert_called_once_with(msg) patch_update.assert_called_once_with(str(user_id), request_data) patch_role.assert_called_once_with(request_data['role_id']) patch_role_id.assert_called_once_with('admin') @@ -1038,6 +1017,8 @@ async def test_reset_unknown_exception(self, client, mocker): request_data = {'role_id': '2'} user_id = 2 msg = 'Something went wrong' + logger_msg = 'Failed to reset the user ID:<{}>. {}'.format(user_id, msg) + patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -1051,12 +1032,12 @@ async def test_reset_unknown_exception(self, client, mocker): with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv1) as patch_role_id: with patch.object(auth, 'is_valid_role', return_value=_rv2) as patch_role: with patch.object(User.Objects, 'update', side_effect=Exception(msg)) as patch_update: - with patch.object(auth._logger, 'exception') as patch_logger_exception: + with patch.object(auth._logger, 'error') as patch_logger: resp = await client.put('/fledge/admin/{}/reset'.format(user_id), data=json.dumps(request_data), headers=ADMIN_USER_HEADER) assert 500 == resp.status assert msg == resp.reason - patch_logger_exception.assert_called_once_with(msg) + patch_logger.assert_called_once_with(logger_msg) patch_update.assert_called_once_with(str(user_id), request_data) patch_role.assert_called_once_with(request_data['role_id']) patch_role_id.assert_called_once_with('admin') @@ -1068,7 +1049,7 @@ async def test_reset_unknown_exception(self, client, mocker): async def test_reset_role_and_password(self, client, mocker): request_data = {'role_id': '2', 'password': 'Test@123'} user_id = 2 - msg = 'User with id:<{}> has been updated successfully.'.format(user_id) + msg = 'User with ID:<{}> has been updated successfully.'.format(user_id) ret_val = {'response': 'updated', 'rows_affected': 1} patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) diff --git a/tests/unit/python/fledge/services/core/api/test_auth_optional.py b/tests/unit/python/fledge/services/core/api/test_auth_optional.py index e6a7d55ecb..f6fb0e83c6 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_optional.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_optional.py @@ -52,14 +52,14 @@ async def test_get_roles(self, client): else: _rv = asyncio.ensure_future(mock_coro([])) - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(User.Objects, 'get_roles', return_value=_rv) as patch_user_obj: resp = await client.get('/fledge/user/role') assert 200 == resp.status r = await resp.text() assert {'roles': []} == json.loads(r) patch_user_obj.assert_called_once_with() - patch_logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user/role') + patch_logger.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user/role') @pytest.mark.parametrize("ret_val, exp_result", [ ([], []), @@ -74,14 +74,14 @@ async def test_get_all_users(self, client, ret_val, exp_result): else: _rv = asyncio.ensure_future(mock_coro(ret_val)) - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(User.Objects, 'all', return_value=_rv) as patch_user_obj: resp = await client.get('/fledge/user') assert 200 == resp.status r = await resp.text() assert {'users': exp_result} == json.loads(r) patch_user_obj.assert_called_once_with() - patch_logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') + patch_logger.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') @pytest.mark.parametrize("request_params, exp_result, arg1, arg2", [ ('?id=1', {'uname': 'admin', 'role_id': '1', 'id': '1', 'access_method': 'any', 'real_name': 'Admin', 'description': 'Admin user'}, 1, None), @@ -100,7 +100,7 @@ async def test_get_user_by_param(self, client, request_params, exp_result, arg1, else: _rv = asyncio.ensure_future(mock_coro(result)) - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(User.Objects, 'get', return_value=_rv) as patch_user_obj: resp = await client.get('/fledge/user{}'.format(request_params)) assert 200 == resp.status @@ -113,32 +113,28 @@ async def test_get_user_by_param(self, client, request_params, exp_result, arg1, assert actual['realName'] == exp_result['real_name'] assert actual['description'] == exp_result['description'] patch_user_obj.assert_called_once_with(arg1, arg2) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') + patch_logger.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') @pytest.mark.parametrize("request_params, error_msg, arg1, arg2", [ ('?id=10', 'User with id:<10> does not exist', 10, None), ('?username=blah', 'User with name: does not exist', None, 'blah') ]) async def test_get_user_exception_by_param(self, client, request_params, error_msg, arg1, arg2): - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(User.Objects, 'get', side_effect=User.DoesNotExist(error_msg)) as patch_user_get: - with patch.object(auth._logger, 'warning') as patch_auth_logger: - resp = await client.get('/fledge/user{}'.format(request_params)) - assert 404 == resp.status - assert error_msg == resp.reason - patch_auth_logger.assert_called_once_with(error_msg) + resp = await client.get('/fledge/user{}'.format(request_params)) + assert 404 == resp.status + assert error_msg == resp.reason patch_user_get.assert_called_once_with(arg1, arg2) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') + patch_logger.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') @pytest.mark.parametrize("request_params", ['?id=0', '?id=blah', '?id=-1']) async def test_get_bad_user_id_param_exception(self, client, request_params): - with patch.object(middleware._logger, 'debug') as patch_logger_info: - with patch.object(auth._logger, 'error') as patch_auth_logger: - resp = await client.get('/fledge/user{}'.format(request_params)) - assert 400 == resp.status - assert 'Bad user id' == resp.reason - patch_auth_logger.assert_called_once_with('Get user requested with bad user id.') - patch_logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') + with patch.object(middleware._logger, 'debug') as patch_logger: + resp = await client.get('/fledge/user{}'.format(request_params)) + assert 400 == resp.status + assert 'Bad user ID' == resp.reason + patch_logger.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/user') @pytest.mark.parametrize("request_data", [ {}, @@ -151,13 +147,11 @@ async def test_get_bad_user_id_param_exception(self, client, request_params): {"uname": "blah", "password": "blah"}, ]) async def test_bad_login(self, client, request_data): - with patch.object(middleware._logger, 'debug') as patch_logger_info: - with patch.object(auth._logger, 'warning') as patch_auth_logger: - resp = await client.post('/fledge/login', data=json.dumps(request_data)) - assert 400 == resp.status - assert 'Username or password is missing' == resp.reason - patch_auth_logger.assert_called_once_with('Username and password are required to login.') - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/login') + with patch.object(middleware._logger, 'debug') as patch_logger: + resp = await client.post('/fledge/login', data=json.dumps(request_data)) + assert 400 == resp.status + assert 'Username or password is missing' == resp.reason + patch_logger.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/login') @pytest.mark.parametrize("request_data, status_code, exception_name, msg", [ ({"username": "blah", "password": "blah"}, 404, User.DoesNotExist, 'User does not exist'), @@ -175,14 +169,15 @@ async def test_login_exception(self, client, request_data, status_code, exceptio else: _rv = asyncio.ensure_future(mock_coro([])) - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(User.Objects, 'login', side_effect=exception_name(msg)) as patch_user_login: with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv) as patch_delete_token: with patch.object(auth._logger, 'warning') as patch_auth_logger: resp = await client.post('/fledge/login', data=json.dumps(request_data)) assert status_code == resp.status assert msg == resp.reason - patch_auth_logger.assert_called_once_with(msg) + if status_code == 401: + patch_auth_logger.assert_called_once_with(msg) if status_code == 401: patch_delete_token.assert_called_once_with(msg) # TODO: host arg patch transport.request.extra_info @@ -190,7 +185,7 @@ async def test_login_exception(self, client, request_data, status_code, exceptio assert str(request_data['username']) == args[0] assert request_data['password'] == args[1] # patch_user_login.assert_called_once_with() - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/login') + patch_logger.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/login') @pytest.mark.parametrize("request_data, ret_val", [ ({"username": "admin", "password": "fledge"}, (1, "token1", True)), @@ -206,7 +201,7 @@ async def async_mock(): else: _rv = asyncio.ensure_future(async_mock()) - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(User.Objects, 'login', return_value=_rv) as patch_user_login: with patch.object(auth._logger, 'info') as patch_auth_logger: resp = await client.post('/fledge/login', data=json.dumps(request_data)) @@ -216,86 +211,87 @@ async def async_mock(): assert ret_val[0] == actual['uid'] assert ret_val[1] == actual['token'] assert ret_val[2] == actual['admin'] - patch_auth_logger.assert_called_once_with('User with username:<{}> logged in successfully.'.format(request_data['username'])) + patch_auth_logger.assert_called_once_with('User with username:<{}> logged in successfully.'.format( + request_data['username'])) # TODO: host arg patch transport.request.extra_info args, kwargs = patch_user_login.call_args assert request_data['username'] == args[0] assert request_data['password'] == args[1] # patch_user_login.assert_called_once_with() - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/login') + patch_logger.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/login') async def test_logout(self, client): - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/2/logout') assert 403 == resp.status assert FORBIDDEN == resp.reason patch_logger_warning.assert_called_once_with(WARN_MSG) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/2/logout') + patch_logger.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/2/logout') async def test_update_password(self, client): - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/user/1/password') assert 403 == resp.status assert FORBIDDEN == resp.reason patch_logger_warning.assert_called_once_with(WARN_MSG) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/1/password') + patch_logger.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user/1/password') async def test_update_me(self, client): - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/user') assert 403 == resp.status assert FORBIDDEN == resp.reason patch_logger_warning.assert_called_once_with(WARN_MSG) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user') + patch_logger.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/user') async def test_update_user(self, client): - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/admin/1') assert 403 == resp.status assert FORBIDDEN == resp.reason patch_logger_warning.assert_called_once_with(WARN_MSG) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1') + patch_logger.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/1') async def test_delete_user(self, client): - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(auth._logger, 'warning') as patch_auth_logger_warn: resp = await client.delete('/fledge/admin/1/delete') assert 403 == resp.status assert FORBIDDEN == resp.reason patch_auth_logger_warn.assert_called_once_with(WARN_MSG) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/1/delete') + patch_logger.assert_called_once_with('Received %s request for %s', 'DELETE', '/fledge/admin/1/delete') async def test_create_user(self, client): request_data = {"username": "ajtest", "password": "F0gl@mp"} - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.post('/fledge/admin/user', data=json.dumps(request_data)) assert 403 == resp.status assert FORBIDDEN == resp.reason patch_logger_warning.assert_called_once_with(WARN_MSG) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') + patch_logger.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') async def test_enable_user(self, client): - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/admin/2/enable') assert 403 == resp.status assert FORBIDDEN == resp.reason patch_logger_warning.assert_called_once_with(WARN_MSG) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/enable') + patch_logger.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/enable') async def test_reset(self, client): - with patch.object(middleware._logger, 'debug') as patch_logger_info: + with patch.object(middleware._logger, 'debug') as patch_logger: with patch.object(auth._logger, 'warning') as patch_logger_warning: resp = await client.put('/fledge/admin/2/reset') assert 403 == resp.status assert FORBIDDEN == resp.reason patch_logger_warning.assert_called_once_with(WARN_MSG) - patch_logger_info.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') + patch_logger.assert_called_once_with('Received %s request for %s', 'PUT', '/fledge/admin/2/reset') @pytest.mark.parametrize("role_id, expected", [ (1, True), diff --git a/tests/unit/python/fledge/services/core/api/test_backup_restore.py b/tests/unit/python/fledge/services/core/api/test_backup_restore.py index 990a7e4a7e..b27c529b8c 100644 --- a/tests/unit/python/fledge/services/core/api/test_backup_restore.py +++ b/tests/unit/python/fledge/services/core/api/test_backup_restore.py @@ -111,9 +111,11 @@ async def test_get_backups_bad_data(self, client, request_params, response_code, async def test_get_backups_exceptions(self, client): msg = "Internal Server Error" with patch.object(connect, 'get_storage_async', side_effect=Exception(msg)): - resp = await client.get('/fledge/backup') - assert 500 == resp.status - assert msg == resp.reason + with patch.object(backup_restore._logger, 'error') as patch_logger: + resp = await client.get('/fledge/backup') + assert 500 == resp.status + assert msg == resp.reason + assert 1 == patch_logger.call_count async def test_create_backup(self, client): async def mock_create(): @@ -135,9 +137,11 @@ async def mock_create(): async def test_create_backup_exception(self, client): msg = "Internal Server Error" with patch.object(connect, 'get_storage_async', side_effect=Exception(msg)): - resp = await client.post('/fledge/backup') - assert 500 == resp.status - assert msg == resp.reason + with patch.object(backup_restore._logger, 'error') as patch_logger: + resp = await client.post('/fledge/backup') + assert 500 == resp.status + assert msg == resp.reason + assert 1 == patch_logger.call_count async def test_get_backup_details(self, client): storage_client_mock = MagicMock(StorageClientAsync) @@ -167,9 +171,12 @@ async def test_get_backup_details_exceptions(self, client, input_exception, resp storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(Backup, 'get_backup_details', side_effect=input_exception): - resp = await client.get('/fledge/backup/{}'.format(8)) - assert response_code == resp.status - assert response_message == resp.reason + with patch.object(backup_restore._logger, 'error') as patch_logger: + resp = await client.get('/fledge/backup/{}'.format(8)) + assert response_code == resp.status + assert response_message == resp.reason + if response_code == 500: + assert 1 == patch_logger.call_count async def test_get_backup_details_bad_data(self, client): resp = await client.get('/fledge/backup/{}'.format('BLA')) @@ -201,9 +208,12 @@ async def test_delete_backup_exceptions(self, client, input_exception, response_ storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(Backup, 'delete_backup', side_effect=input_exception): - resp = await client.delete('/fledge/backup/{}'.format(8)) - assert response_code == resp.status - assert response_message == resp.reason + with patch.object(backup_restore._logger, 'error') as patch_logger: + resp = await client.delete('/fledge/backup/{}'.format(8)) + assert response_code == resp.status + assert response_message == resp.reason + if response_code == 500: + assert 1 == patch_logger.call_count async def test_delete_backup_bad_data(self, client): resp = await client.delete('/fledge/backup/{}'.format('BLA')) @@ -235,12 +245,15 @@ async def test_get_backup_download_exceptions(self, client, input_exception, res with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(Backup, 'get_backup_details', side_effect=input_exception): with patch('os.path.isfile', return_value=False): - resp = await client.get('/fledge/backup/{}/download'.format(8)) - assert response_code == resp.status - assert response_message == resp.reason - result = await resp.text() - json_response = json.loads(result) - assert {"message": response_message} == json_response + with patch.object(backup_restore._logger, 'error') as patch_logger: + resp = await client.get('/fledge/backup/{}/download'.format(8)) + assert response_code == resp.status + assert response_message == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": response_message} == json_response + if response_code == 500: + assert 1 == patch_logger.call_count async def test_get_backup_download(self, client): # FIXME: py3.9 fails to recognise this in default installed mimetypes known-file @@ -308,6 +321,9 @@ async def test_restore_backup_exceptions(self, client, backup_id, input_exceptio storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(Restore, 'restore_backup', side_effect=input_exception): - resp = await client.put('/fledge/backup/{}/restore'.format(backup_id)) - assert code == resp.status - assert message == resp.reason + with patch.object(backup_restore._logger, 'error') as patch_logger: + resp = await client.put('/fledge/backup/{}/restore'.format(backup_id)) + assert code == resp.status + assert message == resp.reason + if code == 500: + assert 1 == patch_logger.call_count diff --git a/tests/unit/python/fledge/services/core/api/test_configuration.py b/tests/unit/python/fledge/services/core/api/test_configuration.py index 8872a0f901..92cf71a21d 100644 --- a/tests/unit/python/fledge/services/core/api/test_configuration.py +++ b/tests/unit/python/fledge/services/core/api/test_configuration.py @@ -13,11 +13,11 @@ import pytest from fledge.common.audit_logger import AuditLogger -from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.configuration_manager import ConfigurationManager, ConfigurationManagerSingleton, _logger +from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.web import middleware -from fledge.services.core import routes -from fledge.services.core import connect +from fledge.services.core import connect, routes +from fledge.services.core.api import configuration __author__ = "Ashish Jabble" @@ -569,8 +569,8 @@ async def async_mock(return_value): ({"key": "test", "description": "des"}, "\"'value' param required to create a category\""), ({"key": "test", "value": "val"}, "\"'description' param required to create a category\""), ({"description": "desc", "value": "val"}, "\"'key' param required to create a category\""), - ({"key":"test", "description":"test", "value": {"test1": {"type": "string", "description": "d", "default": "", - "mandatory": "true"}}}, + ({"key": "test", "description": "test", "value": + {"test1": {"type": "string", "description": "d", "default": "", "mandatory": "true"}}}, "For test category, A default value must be given for test1"), ({"key": "", "description": "test", "value": "val"}, "Key should not be empty"), ({"key": " ", "description": "test", "value": "val"}, "Key should not be empty") @@ -578,9 +578,10 @@ async def async_mock(return_value): async def test_create_category_bad_request(self, client, payload, message): storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - resp = await client.post('/fledge/category', data=json.dumps(payload)) - assert 400 == resp.status - assert message == resp.reason + with patch.object(_logger, 'error'): + resp = await client.post('/fledge/category', data=json.dumps(payload)) + assert 400 == resp.status + assert message == resp.reason @pytest.mark.parametrize("payload, hide_password", [ ({"key": "T1", "description": "Test"}, False), @@ -682,9 +683,11 @@ async def test_create_category_http_exception(self, client, name="test_cat", des payload = {"key": name, "description": desc, "value": info} msg = 'Something went wrong' with patch.object(connect, 'get_storage_async', side_effect=Exception(msg)): - resp = await client.post('/fledge/category', data=json.dumps(payload)) - assert 500 == resp.status - assert msg == resp.reason + with patch.object(configuration._logger, 'error') as patch_logger: + resp = await client.post('/fledge/category', data=json.dumps(payload)) + assert 500 == resp.status + assert msg == resp.reason + assert 1 == patch_logger.call_count @pytest.mark.parametrize("payload, message", [ # FIXME: keys order mismatch assertion @@ -813,9 +816,11 @@ async def test_unknown_exception_for_add_config_item(self, client): data = {"default": "d", "description": "Test description", "type": "boolean"} msg = 'Internal Server Error' with patch.object(connect, 'get_storage_async', side_effect=Exception(msg)): - resp = await client.post('/fledge/category/{}/{}'.format("blah", "blah"), data=json.dumps(data)) - assert 500 == resp.status - assert msg == resp.reason + with patch.object(configuration._logger, 'error') as patch_logger: + resp = await client.post('/fledge/category/{}/{}'.format("blah", "blah"), data=json.dumps(data)) + assert 500 == resp.status + assert msg == resp.reason + assert 1 == patch_logger.call_count async def test_get_child_category(self, client): @asyncio.coroutine @@ -972,9 +977,12 @@ async def test_update_bulk_config_exception(self, client, code, exception_name, c_mgr = ConfigurationManager(storage_client_mock) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(c_mgr, 'get_category_item', side_effect=exception_name) as patch_get_cat_item: - resp = await client.put('/fledge/category/{}'.format(category_name), data=json.dumps(payload)) - assert code == resp.status - assert resp.reason is '' + with patch.object(configuration._logger, 'error') as patch_logger: + resp = await client.put('/fledge/category/{}'.format(category_name), data=json.dumps(payload)) + assert code == resp.status + assert resp.reason is '' + if code == 500: + assert 1 == patch_logger.call_count patch_get_cat_item.assert_called_once_with(category_name, config_item_name) async def test_update_bulk_config_item_not_found(self, client, category_name='rest_api'): diff --git a/tests/unit/python/fledge/services/core/api/test_filters.py b/tests/unit/python/fledge/services/core/api/test_filters.py index e24ca8d9ee..a3cdd21833 100644 --- a/tests/unit/python/fledge/services/core/api/test_filters.py +++ b/tests/unit/python/fledge/services/core/api/test_filters.py @@ -11,14 +11,13 @@ import pytest import sys -from fledge.services.core import routes -from fledge.services.core import connect -from fledge.common.storage_client.storage_client import StorageClientAsync +from fledge.common.configuration_manager import ConfigurationManager from fledge.common.storage_client.exceptions import StorageServerError -from fledge.services.core.api import filters +from fledge.common.storage_client.storage_client import StorageClientAsync +from fledge.services.core import connect, routes +from fledge.services.core.api import filters, utils as apiutils from fledge.services.core.api.filters import _LOGGER -from fledge.common.configuration_manager import ConfigurationManager -from fledge.services.core.api import utils as apiutils +from fledge.services.core.api.plugins import common __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2018 OSIsoft, LLC" @@ -71,8 +70,8 @@ async def test_get_filters_storage_exception(self, client): assert 500 == resp.status assert "something went wrong" == resp.reason assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with('Get all filters, caught exception: {}'.format( - 'something went wrong')) + args, kwargs = patch_logger.call_args + assert 'Get all filters, caught storage exception: {}'.format('something went wrong') in args[0] query_tbl_patch.assert_called_once_with('filters') async def test_get_filters_exception(self, client): @@ -89,9 +88,11 @@ async def get_filters(): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(storage_client_mock, 'query_tbl', return_value=_rv) as query_tbl_patch: - resp = await client.get('/fledge/filter') - assert 500 == resp.status - assert "'rows'" == resp.reason + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.get('/fledge/filter') + assert 500 == resp.status + assert "'rows'" == resp.reason + assert 1 == patch_logger.call_count query_tbl_patch.assert_called_once_with('filters') async def test_get_filter_by_name(self, client): @@ -190,8 +191,9 @@ async def test_get_filter_by_name_storage_error(self, client): assert 500 == resp.status assert "something went wrong" == resp.reason assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with('Get {} filter, caught exception: {}'.format( - filter_name, 'something went wrong')) + args, kwargs = patch_logger.call_args + assert 'Failed to get filter name: {}. Storage error occurred: {}'.format( + filter_name, 'something went wrong') in args[0] get_cat_info_patch.assert_called_once_with(filter_name) async def test_get_filter_by_name_type_error(self, client): @@ -212,9 +214,11 @@ async def test_get_filter_by_name_exception(self, client): cf_mgr = ConfigurationManager(storage_client_mock) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', side_effect=Exception) as get_cat_info_patch: - resp = await client.get('/fledge/filter/{}'.format(filter_name)) - assert 500 == resp.status - assert resp.reason is '' + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.get('/fledge/filter/{}'.format(filter_name)) + assert 500 == resp.status + assert resp.reason is '' + assert 1 == patch_logger.call_count get_cat_info_patch.assert_called_once_with(filter_name) @pytest.mark.parametrize("data", [ @@ -225,12 +229,9 @@ async def test_get_filter_by_name_exception(self, client): ]) async def test_bad_create_filter(self, client, data): msg = "Filter name, plugin name are mandatory." - with patch.object(_LOGGER, 'error') as log_exc: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps(data)) - assert 400 == resp.status - assert msg == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filter, caught type error: {}'.format(msg)) + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps(data)) + assert 400 == resp.status + assert msg == resp.reason async def test_create_filter_value_error_1(self, client): storage_client_mock = MagicMock(StorageClientAsync) @@ -244,13 +245,10 @@ async def test_create_filter_value_error_1(self, client): msg = "This 'test' filter already exists" with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(_LOGGER, 'error') as patch_logger: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( - {"name": "test", "plugin": "benchmark"})) - assert 404 == resp.status - assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with("Add filter, caught value error: {}".format(msg)) + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( + {"name": "test", "plugin": "benchmark"})) + assert 404 == resp.status + assert msg == resp.reason get_cat_info_patch.assert_called_once_with(category_name='test') async def test_create_filter_value_error_2(self, client): @@ -267,12 +265,12 @@ async def test_create_filter_value_error_2(self, client): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: with patch.object(apiutils, 'get_plugin_info', return_value=None) as api_utils_patch: - with patch.object(_LOGGER, 'error') as patch_logger: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps({"name": "test", "plugin": plugin_name})) + with patch.object(common._logger, 'warning') as patch_logger: + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( + {"name": "test", "plugin": plugin_name})) assert 404 == resp.status assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with("Add filter, caught value error: {}".format(msg)) + assert 2 == patch_logger.call_count api_utils_patch.assert_called_once_with(plugin_name, dir='filter') get_cat_info_patch.assert_called_once_with(category_name='test') @@ -288,18 +286,17 @@ async def test_create_filter_value_error_3(self, client): _rv = asyncio.ensure_future(self.async_mock(None)) msg = "Loaded plugin 'python35', type 'south', doesn't match the specified one '{}', type 'filter'".format( plugin_name) + ret_val = {"config": {'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', + 'default': 'python35'}}, "type": "south"} with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(apiutils, 'get_plugin_info', return_value= - {"config": {'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', - 'default': 'python35'}}, "type": "south"}) as api_utils_patch: - with patch.object(_LOGGER, 'error') as patch_logger: + with patch.object(apiutils, 'get_plugin_info', return_value=ret_val) as api_utils_patch: + with patch.object(common._logger, 'warning') as patch_logger: resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( {"name": "test", "plugin": plugin_name})) assert 404 == resp.status assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with("Add filter, caught value error: {}".format(msg)) + assert 2 == patch_logger.call_count api_utils_patch.assert_called_once_with(plugin_name, dir='filter') get_cat_info_patch.assert_called_once_with(category_name='test') @@ -322,14 +319,12 @@ async def test_create_filter_value_error_4(self, client): with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=_rv2) as query_tbl_patch: with patch.object(storage_client_mock, 'insert_into_tbl', return_value=_rv1) as insert_tbl_patch: with patch.object(cf_mgr, 'create_category', return_value=_rv1) as create_cat_patch: - with patch.object(_LOGGER, 'error') as patch_logger: + with patch.object(common._logger, 'warning') as patch_logger: resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( {"name": "test", "plugin": plugin_name, "filter_config": "blah"})) assert 404 == resp.status assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with( - "Add filter, caught value error: {}".format(msg)) + assert 2 == patch_logger.call_count create_cat_patch.assert_called_once_with( category_description="Configuration of 'test' filter for plugin 'filter'", category_name='test', category_value= @@ -366,14 +361,14 @@ async def test_create_filter_storage_error(self, client): with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=StorageServerError( None, None, error='something went wrong')): with patch.object(filters, '_delete_configuration_category', return_value=_rv) as _delete_cfg_patch: - with patch.object(_LOGGER, 'exception') as log_exc: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( - {"name": name, "plugin": plugin_name})) - assert 500 == resp.status - assert 'Failed to create filter.' == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Failed to create filter with: {}'.format( - 'something went wrong')) + with patch.object(_LOGGER, 'error') as patch_logger: + with patch.object(common._logger, 'warning') as patch_logger2: + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( + {"name": name, "plugin": plugin_name})) + assert 500 == resp.status + assert 'something went wrong' == resp.reason + assert 2 == patch_logger2.call_count + assert 1 == patch_logger.call_count args, kwargs = _delete_cfg_patch.call_args assert name == args[1] api_utils_patch.assert_called_once_with(plugin_name, dir='filter') @@ -386,13 +381,13 @@ async def test_create_filter_exception(self, client): name = 'test' with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', side_effect=Exception) as get_cat_info_patch: - with patch.object(_LOGGER, 'exception') as log_exc: + with patch.object(_LOGGER, 'error') as patch_logger: resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( {"name": name, "plugin": plugin_name})) assert 500 == resp.status assert resp.reason is '' - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Add filter, caught exception: ') + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with('Add filter, caught exception: ') get_cat_info_patch.assert_called_once_with(category_name=name) async def test_create_filter(self, client): @@ -420,11 +415,14 @@ async def test_create_filter(self, client): with patch.object(storage_client_mock, 'insert_into_tbl', return_value=_rv1) as insert_tbl_patch: with patch.object(cf_mgr, 'create_category', return_value=_rv1) as create_cat_patch: with patch.object(cf_mgr, 'update_configuration_item_bulk', return_value=_rv1) as update_cfg_bulk_patch: - resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps({"name": name, "plugin": plugin_name, "filter_config": {}})) - assert 200 == resp.status - r = await resp.text() - json_response = json.loads(r) - assert {'filter': name, 'description': "Configuration of 'test' filter for plugin 'filter'", 'value': {}} == json_response + with patch.object(common._logger, 'warning') as patch_logger2: + resp = await client.post('/fledge/filter'.format("bench"), data=json.dumps( + {"name": name, "plugin": plugin_name, "filter_config": {}})) + assert 200 == resp.status + r = await resp.text() + json_response = json.loads(r) + assert {'filter': name, 'description': "Configuration of 'test' filter for plugin 'filter'", 'value': {}} == json_response + assert 2 == patch_logger2.call_count update_cfg_bulk_patch.assert_called_once_with(name, {}) create_cat_patch.assert_called_once_with(category_description="Configuration of 'test' filter for plugin 'filter'", category_name='test', category_value={'plugin': {'description': 'Python 3.5 filter plugin', 'type': 'string', 'default': 'filter'}}, keep_original_items=True) args, kwargs = insert_tbl_patch.call_args_list[0] @@ -534,20 +532,17 @@ async def test_delete_filter_storage_error(self, client): assert 500 == resp.status assert "something went wrong" == resp.reason assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with('Delete {} filter, caught exception: {}'.format( + patch_logger.assert_called_once_with('Delete {} filter, caught storage exception: {}'.format( filter_name, 'something went wrong')) get_cat_info_patch.assert_called_once_with( 'filters', '{"where": {"column": "name", "condition": "=", "value": "AssetFilter"}}') async def test_add_filter_pipeline_type_error(self, client): msg = "Pipeline must be a list of filters or an empty value" - with patch.object(_LOGGER, 'error') as patch_logger: - resp = await client.put('/fledge/filter/{}/pipeline'.format("bench"), data=json.dumps( - {"pipeline": "AssetFilter"})) - assert 400 == resp.status - assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with('Add filters pipeline, caught type error: {}'.format(msg)) + resp = await client.put('/fledge/filter/{}/pipeline'.format("bench"), data=json.dumps( + {"pipeline": "AssetFilter"})) + assert 400 == resp.status + assert msg == resp.reason @pytest.mark.parametrize("request_param, param, val", [ ('?append_filter=T', 'append_filter', 't'), @@ -565,12 +560,10 @@ async def test_add_filter_pipeline_type_error(self, client): ]) async def test_add_filter_pipeline_bad_request_param_val(self, client, request_param, param, val): user = "bench" - with patch.object(_LOGGER, 'error') as patch_logger: - resp = await client.put('/fledge/filter/{}/pipeline{}'.format(user, request_param), data=json.dumps( - {"pipeline": ["AssetFilter"]})) - assert 404 == resp.status - assert "Only 'true' and 'false' are allowed for {}. {} given.".format(param, val) == resp.reason - assert 1 == patch_logger.call_count + resp = await client.put('/fledge/filter/{}/pipeline{}'.format(user, request_param), data=json.dumps( + {"pipeline": ["AssetFilter"]})) + assert 404 == resp.status + assert "Only 'true' and 'false' are allowed for {}. {} given.".format(param, val) == resp.reason async def test_add_filter_pipeline_value_error_1(self, client): user = "bench" @@ -585,14 +578,10 @@ async def test_add_filter_pipeline_value_error_1(self, client): msg = "No such '{}' category found.".format(user) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(_LOGGER, 'error') as patch_logger: - resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps( - {"pipeline": ["AssetFilter"]})) - assert 404 == resp.status - assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with( - 'Add filters pipeline, caught value error: {}'.format(msg)) + resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps( + {"pipeline": ["AssetFilter"]})) + assert 404 == resp.status + assert msg == resp.reason get_cat_info_patch.assert_called_once_with(category_name=user) async def test_add_filter_pipeline_value_error_2(self, client): @@ -608,14 +597,10 @@ async def test_add_filter_pipeline_value_error_2(self, client): msg = "No such '{}' category found.".format(user) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(_LOGGER, 'error') as patch_logger: - resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps( - {"pipeline": ["AssetFilter"]})) - assert 404 == resp.status - assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with( - 'Add filters pipeline, caught value error: {}'.format(msg)) + resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps( + {"pipeline": ["AssetFilter"]})) + assert 404 == resp.status + assert msg == resp.reason get_cat_info_patch.assert_called_once_with(category_name=user) async def test_add_filter_pipeline_value_error_3(self, client): @@ -637,15 +622,12 @@ async def test_add_filter_pipeline_value_error_3(self, client): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv1) as get_cat_info_patch: with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=_rv2) as query_tbl_patch: - with patch.object(_LOGGER, 'error') as patch_logger: - resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps( - {"pipeline": ["AssetFilter"]})) - assert 404 == resp.status - assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with( - 'Add filters pipeline, caught value error: {}'.format(msg)) - query_tbl_patch.assert_called_once_with('filters', '{"where": {"column": "name", "condition": "=", "value": "AssetFilter"}}') + resp = await client.put('/fledge/filter/{}/pipeline'.format(user), data=json.dumps( + {"pipeline": ["AssetFilter"]})) + assert 404 == resp.status + assert msg == resp.reason + query_tbl_patch.assert_called_once_with( + 'filters', '{"where": {"column": "name", "condition": "=", "value": "AssetFilter"}}') get_cat_info_patch.assert_called_once_with(category_name=user) async def test_add_filter_pipeline_value_error_4(self, client): @@ -682,14 +664,10 @@ async def test_add_filter_pipeline_value_error_4(self, client): with patch.object(filters, '_add_child_filters', return_value=_rv3) as _add_child_patch: with patch.object(cf_mgr, 'get_category_item', return_value=_rv3) as get_cat_item_patch: - with patch.object(_LOGGER, 'error') as patch_logger: - resp = await client.put('/fledge/filter/{}/pipeline'.format(user), - data=json.dumps({"pipeline": ["AssetFilter"]})) - assert 404 == resp.status - assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with( - 'Add filters pipeline, caught value error: {}'.format(msg)) + resp = await client.put('/fledge/filter/{}/pipeline'.format(user), + data=json.dumps({"pipeline": ["AssetFilter"]})) + assert 404 == resp.status + assert msg == resp.reason get_cat_item_patch.assert_called_once_with(user, 'filter') args, kwargs = _add_child_patch.call_args assert user == args[2] @@ -725,8 +703,6 @@ async def test_add_filter_pipeline_storage_error(self, client): assert 500 == resp.status assert "something went wrong" == resp.reason assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with('Add filters pipeline, caught storage error: {}'.format( - 'something went wrong')) get_cat_info_patch.assert_called_once_with(category_name=user) async def test_add_filter_pipeline(self, client): @@ -900,12 +876,9 @@ async def test_get_filter_pipeline_key_error(self, client): msg = "No filter pipeline exists for {}.".format(user) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', return_value=_rv) as get_cat_info_patch: - with patch.object(_LOGGER, 'error') as patch_logger: - resp = await client.get('/fledge/filter/{}/pipeline'.format(user)) - assert 404 == resp.status - assert msg == resp.reason - assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with(msg) + resp = await client.get('/fledge/filter/{}/pipeline'.format(user)) + assert 404 == resp.status + assert msg == resp.reason get_cat_info_patch.assert_called_once_with(category_name=user) async def test_get_filter_pipeline_storage_error(self, client): @@ -913,14 +886,16 @@ async def test_get_filter_pipeline_storage_error(self, client): cf_mgr = ConfigurationManager(storage_client_mock) user = "Random" with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(cf_mgr, 'get_category_all_items', side_effect=StorageServerError(None, None, error='something went wrong')) as get_cat_info_patch: - with patch.object(_LOGGER, 'exception') as log_exc: + with patch.object(cf_mgr, 'get_category_all_items', side_effect=StorageServerError( + None, None, error='something went wrong')) as get_cat_info_patch: + with patch.object(_LOGGER, 'error') as patch_logger: resp = await client.get('/fledge/filter/{}/pipeline'.format(user)) assert 500 == resp.status assert "something went wrong" == resp.reason - assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Get {} filter pipeline, caught exception: {}'.format( - user, 'something went wrong')) + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with( + 'Failed to delete filter pipeline {}. Storage error occurred: {}'.format( + user, 'something went wrong'), exc_info=True) get_cat_info_patch.assert_called_once_with(category_name=user) async def test_get_filter_pipeline_exception(self, client): @@ -929,9 +904,11 @@ async def test_get_filter_pipeline_exception(self, client): cf_mgr = ConfigurationManager(storage_client_mock) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(cf_mgr, 'get_category_all_items', side_effect=Exception) as get_cat_info_patch: - resp = await client.get('/fledge/filter/{}/pipeline'.format(user)) - assert 500 == resp.status - assert resp.reason is '' + with patch.object(_LOGGER, 'error') as patch_logger: + resp = await client.get('/fledge/filter/{}/pipeline'.format(user)) + assert 500 == resp.status + assert resp.reason is '' + assert 1 == patch_logger.call_count get_cat_info_patch.assert_called_once_with(category_name=user) @pytest.mark.skip(reason='Incomplete') diff --git a/tests/unit/python/fledge/services/core/api/test_service.py b/tests/unit/python/fledge/services/core/api/test_service.py index 794cbd03f5..6f55ab7db3 100644 --- a/tests/unit/python/fledge/services/core/api/test_service.py +++ b/tests/unit/python/fledge/services/core/api/test_service.py @@ -266,7 +266,7 @@ def q_result(*arg): _rv = asyncio.ensure_future(self.async_mock(None)) with patch.object(common, 'load_and_fetch_python_plugin_info', side_effect=[mock_plugin_info]): - with patch.object(service._logger, 'exception') as ex_logger: + with patch.object(service._logger, 'error') as patch_logger: with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(c_mgr, 'get_category_all_items', return_value=_rv) as patch_get_cat_info: @@ -284,7 +284,7 @@ def q_result(*arg): 'value': '[\"services/south_c\"]'}} } == p2 patch_get_cat_info.assert_called_once_with(category_name=data['name']) - assert 1 == ex_logger.call_count + assert 1 == patch_logger.call_count async def test_dupe_category_name_add_service(self, client): mock_plugin_info = { diff --git a/tests/unit/python/fledge/services/core/api/test_support.py b/tests/unit/python/fledge/services/core/api/test_support.py index 2257a81709..a25b935b81 100644 --- a/tests/unit/python/fledge/services/core/api/test_support.py +++ b/tests/unit/python/fledge/services/core/api/test_support.py @@ -133,11 +133,14 @@ async def mock_build(): assert {"bundle created": "support-180301-13-35-23.tar.gz"} == jdict async def test_create_support_bundle_exception(self, client): + msg = "Failed to create support bundle. blah" with patch.object(SupportBuilder, "__init__", return_value=None): with patch.object(SupportBuilder, "build", side_effect=RuntimeError("blah")): - resp = await client.post('/fledge/support') - assert 500 == resp.status - assert "Support bundle could not be created. blah" == resp.reason + with patch.object(support._logger, "error") as patch_logger: + resp = await client.post('/fledge/support') + assert 500 == resp.status + assert msg == resp.reason + assert 1 == patch_logger.call_count async def test_get_syslog_entries_all_ok(self, client): def mock_syslog(): @@ -253,12 +256,14 @@ async def test_bad_limit_and_offset_in_get_syslog_entries(self, client, param, m async def test_get_syslog_entries_cmd_exception(self, client): msg = 'Internal Server Error' with patch.object(subprocess, "Popen", side_effect=Exception(msg)): - resp = await client.get('/fledge/syslog') - assert 500 == resp.status - assert msg == resp.reason - res = await resp.text() - jdict = json.loads(res) - assert {"message": msg} == jdict + with patch.object(support._logger, "error") as patch_logger: + resp = await client.get('/fledge/syslog') + assert 500 == resp.status + assert msg == resp.reason + res = await resp.text() + jdict = json.loads(res) + assert {"message": msg} == jdict + assert 1 == patch_logger.call_count async def test_get_syslog_entries_from_name(self, client): def mock_syslog(): diff --git a/tests/unit/python/fledge/services/core/api/test_task.py b/tests/unit/python/fledge/services/core/api/test_task.py index 067f1bc4c3..96f4171263 100644 --- a/tests/unit/python/fledge/services/core/api/test_task.py +++ b/tests/unit/python/fledge/services/core/api/test_task.py @@ -107,7 +107,7 @@ def q_result(*arg): with patch.object(common, 'load_and_fetch_python_plugin_info', side_effect=[mock_plugin_info]): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(_logger, 'exception') as patch_logger: + with patch.object(_logger, 'error') as patch_logger: with patch.object(c_mgr, 'get_category_all_items', return_value=_rv) as patch_get_cat_info: with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result): @@ -115,8 +115,8 @@ def q_result(*arg): resp = await client.post('/fledge/scheduled/task', data=json.dumps(data)) assert 500 == resp.status assert 'Failed to create north instance.' == resp.reason - assert 1 == patch_logger.call_count patch_get_cat_info.assert_called_once_with(category_name=data['name']) + assert 1 == patch_logger.call_count async def test_dupe_category_name_add_task(self, client): @@ -342,7 +342,7 @@ def q_result(*arg): } storage_client_mock = MagicMock(StorageClientAsync) - with patch.object(_logger, 'error') as patch_logger: + with patch.object(_logger, 'warning') as patch_logger: with patch.object(common, 'load_and_fetch_python_plugin_info', side_effect=[mock_plugin_info]): with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result): @@ -572,9 +572,12 @@ async def test_delete_task_exception(self, mocker, client): mocker.patch.object(connect, 'get_storage_async') mocker.patch.object(task, "get_schedule", side_effect=Exception) - resp = await client.delete("/fledge/scheduled/task/Test") - assert 500 == resp.status - assert resp.reason is '' + with patch.object(_logger, 'error') as patch_logger: + resp = await client.delete("/fledge/scheduled/task/Test") + assert 500 == resp.status + assert resp.reason is '' + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with('Failed to delete Test north task. ') async def mock_bad_result(): return {"count": 0, "rows": []} From 13afacc63347d8375f8f8f5acffe1247241d6cc0 Mon Sep 17 00:00:00 2001 From: YashTatkondawar Date: Thu, 16 Mar 2023 07:30:28 -0500 Subject: [PATCH 193/499] Added the config for fogbench in system test (#999) * Added the config for fogbench * Minor fixes --------- Co-authored-by: Praveen Garg Co-authored-by: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> --- tests/system/python/conftest.py | 15 +++++++++++++++ tests/system/python/packages/test_pi_webapi.py | 16 ++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index a0247f305f..f26e1a5194 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -798,6 +798,12 @@ def pytest_addoption(parser): parser.addoption("--start-north-as-service", action="store", type=bool, default=True, help="Whether start the north as a service.") + # Fogbench Config + parser.addoption("--fogbench-host", action="store", default="localhost", + help="FogBench Destination Host Address") + + parser.addoption("--fogbench-port", action="store", default="5683", type=int, + help="FogBench Destination Port") @pytest.fixture def storage_plugin(request): @@ -1116,3 +1122,12 @@ def pytest_configure(): pytest.OS_PLATFORM_DETAILS = read_os_release() pytest.IS_REDHAT = is_redhat_based() pytest.PKG_MGR = 'yum' if pytest.IS_REDHAT else 'apt' + +@pytest.fixture +def fogbench_host(request): + return request.config.getoption("--fogbench-host") + + +@pytest.fixture +def fogbench_port(request): + return request.config.getoption("--fogbench-port") diff --git a/tests/system/python/packages/test_pi_webapi.py b/tests/system/python/packages/test_pi_webapi.py index b2be96c9e1..a4180a8298 100644 --- a/tests/system/python/packages/test_pi_webapi.py +++ b/tests/system/python/packages/test_pi_webapi.py @@ -165,7 +165,7 @@ def start_south_north(add_south, start_north_task_omf_web_api, remove_data_file, class TestPackagesCoAP_PI_WebAPI: def test_omf_task(self, clean_setup_fledge_packages, reset_fledge, start_south_north, read_data_from_pi_web_api, - fledge_url, pi_host, pi_admin, pi_passwd, pi_db, + fledge_url, pi_host, pi_admin, pi_passwd, pi_db, fogbench_host, fogbench_port, wait_time, retries, skip_verify_north_interface, asset_name=ASSET): """ Test that data is inserted in Fledge and sent to PI start_south_north: Fixture that add south and north instance @@ -179,8 +179,10 @@ def test_omf_task(self, clean_setup_fledge_packages, reset_fledge, start_south_n data received from PI is same as data sent""" conn = http.client.HTTPConnection(fledge_url) + # Time to get CoAP service started + time.sleep(2) subprocess.run( - ["cd $FLEDGE_ROOT/extras/python; python3 -m fogbench -t ../../data/{}; cd -".format(TEMPLATE_NAME)], + ["cd $FLEDGE_ROOT/extras/python; python3 -m fogbench -t ../../data/{} --host {} --port {}; cd -".format(TEMPLATE_NAME, fogbench_host, fogbench_port)], shell=True, check=True) time.sleep(wait_time) @@ -195,7 +197,7 @@ def test_omf_task(self, clean_setup_fledge_packages, reset_fledge, start_south_n asset_name) def test_omf_task_with_reconfig(self, reset_fledge, start_south_north, read_data_from_pi_web_api, - skip_verify_north_interface, fledge_url, + skip_verify_north_interface, fledge_url, fogbench_host, fogbench_port, wait_time, retries, pi_host, pi_port, pi_admin, pi_passwd, pi_db, asset_name=ASSET): """ Test OMF as a North task by reconfiguring it. @@ -210,8 +212,10 @@ def test_omf_task_with_reconfig(self, reset_fledge, start_south_north, read_data on endpoint GET /fledge/track""" conn = http.client.HTTPConnection(fledge_url) + # Time to get CoAP service started + time.sleep(2) subprocess.run( - ["cd $FLEDGE_ROOT/extras/python; python3 -m fogbench -t ../../data/{}; cd -".format(TEMPLATE_NAME)], + ["cd $FLEDGE_ROOT/extras/python; python3 -m fogbench -t ../../data/{} --host {} --port {}; cd -".format(TEMPLATE_NAME, fogbench_host, fogbench_port)], shell=True, check=True) time.sleep(wait_time) @@ -231,7 +235,7 @@ def test_omf_task_with_reconfig(self, reset_fledge, start_south_north, read_data conn = http.client.HTTPConnection(fledge_url) subprocess.run( - ["cd $FLEDGE_ROOT/extras/python; python3 -m fogbench -t ../../data/{}; cd -".format(TEMPLATE_NAME)], + ["cd $FLEDGE_ROOT/extras/python; python3 -m fogbench -t ../../data/{} --host {} --port {}; cd -".format(TEMPLATE_NAME, fogbench_host, fogbench_port)], shell=True, check=True) # Wait for the OMF schedule to run. @@ -257,7 +261,7 @@ def test_omf_task_with_reconfig(self, reset_fledge, start_south_north, read_data conn = http.client.HTTPConnection(fledge_url) subprocess.run( - ["cd $FLEDGE_ROOT/extras/python; python3 -m fogbench -t ../../data/{}; cd -".format(TEMPLATE_NAME)], + ["cd $FLEDGE_ROOT/extras/python; python3 -m fogbench -t ../../data/{} --host {} --port {}; cd -".format(TEMPLATE_NAME, fogbench_host, fogbench_port)], shell=True, check=True) # Wait for the OMF schedule to run. From 48bed0d6934a1535a9ac300728031104cba4c59c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 17 Mar 2023 18:02:14 +0530 Subject: [PATCH 194/499] other python files updated as per new singleton Logger class Signed-off-by: ashish-jabble --- python/fledge/common/acl_manager.py | 6 ++--- python/fledge/common/audit_logger.py | 8 +++---- python/fledge/common/jqfilter.py | 4 ++-- python/fledge/common/plugin_discovery.py | 7 +++--- python/fledge/common/statistics.py | 5 ++--- .../core/asset_tracker/asset_tracker.py | 8 +++---- python/fledge/services/core/connect.py | 22 +++++++------------ python/fledge/services/core/proxy.py | 5 ++--- python/fledge/services/core/snapshot.py | 4 ++-- python/fledge/services/core/support.py | 8 +++---- python/fledge/services/core/user_model.py | 10 ++++----- .../tasks/automation_script/__main__.py | 5 ++--- python/fledge/tasks/north/sending_process.py | 4 ++-- python/fledge/tasks/purge/__main__.py | 5 +++-- python/fledge/tasks/purge/purge.py | 9 ++++---- python/fledge/tasks/statistics/__main__.py | 5 +++-- .../tasks/statistics/statistics_history.py | 8 +++---- .../python/fledge/common/test_jqfilter.py | 6 ++--- .../unit/python/fledge/common/test_process.py | 9 +++++--- .../core/service_registry/test_monitor.py | 11 +++++++--- .../fledge/services/core/test_connect.py | 5 ++--- .../fledge/services/core/test_server.py | 18 ++++++++++----- .../python/fledge/tasks/purge/test_purge.py | 20 ++++++++--------- .../statistics/test_statistics_history.py | 12 +++++----- 24 files changed, 102 insertions(+), 102 deletions(-) diff --git a/python/fledge/common/acl_manager.py b/python/fledge/common/acl_manager.py index 207e161361..465b7e6380 100644 --- a/python/fledge/common/acl_manager.py +++ b/python/fledge/common/acl_manager.py @@ -4,11 +4,9 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END -import logging - +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError -from fledge.common import logger __author__ = "Deepanshu Yadav" @@ -16,7 +14,7 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) class ACLManagerSingleton(object): diff --git a/python/fledge/common/audit_logger.py b/python/fledge/common/audit_logger.py index 6da16a99f9..450770bbe2 100644 --- a/python/fledge/common/audit_logger.py +++ b/python/fledge/common/audit_logger.py @@ -4,19 +4,17 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.storage_client.exceptions import StorageServerError -from fledge.common import logger - __author__ = "Mark Riddoch" __copyright__ = "Copyright (c) 2018 OSIsoft, LLC" __license__ = "Apache 2.0" __version__ = "${VERSION}" - -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) class AuditLoggerSingleton(object): @@ -62,7 +60,7 @@ async def _log(self, level, code, log): await self._storage.insert_into_tbl("log", payload) except (StorageServerError, Exception) as ex: - _logger.exception("Failed to log audit trail entry '%s': %s", code, str(ex)) + _logger.error("Failed to log audit trail entry '{}': {}".format(code, str(ex))) raise ex async def success(self, code, log): diff --git a/python/fledge/common/jqfilter.py b/python/fledge/common/jqfilter.py index 0f61d6080f..7127fe0e54 100644 --- a/python/fledge/common/jqfilter.py +++ b/python/fledge/common/jqfilter.py @@ -9,7 +9,7 @@ import pyjq -from fledge.common import logger +from fledge.common.logger import FLCoreLogger __author__ = "Vaibhav Singhal" __copyright__ = "Copyright (c) 2017 OSI Soft, LLC" @@ -25,7 +25,7 @@ class JQFilter: def __init__(self): """Initialise the JQFilter""" - self._logger = logger.setup("JQFilter") + self._logger = FLCoreLogger().get_logger("JQFilter") def transform(self, reading_block, filter_string): """ diff --git a/python/fledge/common/plugin_discovery.py b/python/fledge/common/plugin_discovery.py index 264f305fcf..18f048b039 100644 --- a/python/fledge/common/plugin_discovery.py +++ b/python/fledge/common/plugin_discovery.py @@ -7,10 +7,10 @@ """Common Plugin Discovery Class""" import os -from fledge.common import logger +from fledge.common.logger import FLCoreLogger +from fledge.plugins.common import utils as common_utils from fledge.services.core.api import utils from fledge.services.core.api.plugins import common -from fledge.plugins.common import utils as common_utils __author__ = "Amarendra K Sinha, Ashish Jabble" @@ -18,8 +18,7 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" - -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) class PluginDiscovery(object): diff --git a/python/fledge/common/statistics.py b/python/fledge/common/statistics.py index 53f1a05ad0..306c480b5f 100644 --- a/python/fledge/common/statistics.py +++ b/python/fledge/common/statistics.py @@ -5,7 +5,7 @@ # FLEDGE_END import json -from fledge.common import logger +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.storage_client import StorageClientAsync @@ -15,8 +15,7 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" - -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) async def create_statistics(storage=None): diff --git a/python/fledge/services/core/asset_tracker/asset_tracker.py b/python/fledge/services/core/asset_tracker/asset_tracker.py index d8098b49da..14f209c53e 100644 --- a/python/fledge/services/core/asset_tracker/asset_tracker.py +++ b/python/fledge/services/core/asset_tracker/asset_tracker.py @@ -4,20 +4,18 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END -from fledge.common import logger +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.storage_client.exceptions import StorageServerError -from fledge.common.configuration_manager import ConfigurationManager - __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2018 OSIsoft, LLC" __license__ = "Apache 2.0" __version__ = "${VERSION}" - -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) class AssetTracker(object): diff --git a/python/fledge/services/core/connect.py b/python/fledge/services/core/connect.py index 7305a61dfc..47e6ac49db 100644 --- a/python/fledge/services/core/connect.py +++ b/python/fledge/services/core/connect.py @@ -4,19 +4,16 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END - -from fledge.services.core.service_registry.service_registry import ServiceRegistry +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.storage_client import StorageClientAsync, ReadingsStorageClientAsync -from fledge.common import logger +from fledge.services.core.service_registry.service_registry import ServiceRegistry __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" __license__ = "Apache 2.0" __version__ = "${VERSION}" - -# _logger = logger.setup(__name__, level=20) -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) # TODO: Needs refactoring or better way to allow global discovery in core process @@ -25,24 +22,21 @@ def get_storage_async(): try: services = ServiceRegistry.get(name="Fledge Storage") storage_svc = services[0] - _storage = StorageClientAsync(core_management_host=None, core_management_port=None, - svc=storage_svc) - # _logger.info(type(_storage)) + _storage = StorageClientAsync(core_management_host=None, core_management_port=None, svc=storage_svc) except Exception as ex: - _logger.exception(str(ex)) + _logger.error(str(ex)) raise return _storage + # TODO: Needs refactoring or better way to allow global discovery in core process def get_readings_async(): """ Storage Object """ try: services = ServiceRegistry.get(name="Fledge Storage") storage_svc = services[0] - _readings = ReadingsStorageClientAsync(core_mgt_host=None, core_mgt_port=None, - svc=storage_svc) - # _logger.info(type(_storage)) + _readings = ReadingsStorageClientAsync(core_mgt_host=None, core_mgt_port=None, svc=storage_svc) except Exception as ex: - _logger.exception(str(ex)) + _logger.error(str(ex)) raise return _readings diff --git a/python/fledge/services/core/proxy.py b/python/fledge/services/core/proxy.py index 3285bd0e4e..8d4d40b478 100644 --- a/python/fledge/services/core/proxy.py +++ b/python/fledge/services/core/proxy.py @@ -5,12 +5,11 @@ # FLEDGE_END import json -import logging import urllib.parse import aiohttp from aiohttp import web -from fledge.common import logger +from fledge.common.logger import FLCoreLogger from fledge.services.core import server from fledge.services.core.service_registry.service_registry import ServiceRegistry from fledge.services.core.service_registry import exceptions as service_registry_exceptions @@ -20,7 +19,7 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -_logger = logger.setup(__name__, level=logging.INFO) +_logger = FLCoreLogger().get_logger(__name__) def setup(app): diff --git a/python/fledge/services/core/snapshot.py b/python/fledge/services/core/snapshot.py index a72d9fdc9b..bc397cb37a 100644 --- a/python/fledge/services/core/snapshot.py +++ b/python/fledge/services/core/snapshot.py @@ -15,8 +15,8 @@ import time from collections import OrderedDict -from fledge.common import logger from fledge.common.common import _FLEDGE_ROOT +from fledge.common.logger import FLCoreLogger __author__ = "Amarendra K Sinha" @@ -24,9 +24,9 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -_LOGGER = logger.setup(__name__) _NO_OF_FILES_TO_RETAIN = 3 SNAPSHOT_PREFIX = "snapshot-plugin" +_LOGGER = FLCoreLogger().get_logger(__name__) class SnapshotPluginBuilder: diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index 30bfbd82fe..dd8841c31b 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -6,8 +6,6 @@ """ Provides utility functions to build a Fledge Support bundle. """ - -import logging import datetime import os from os.path import basename @@ -19,9 +17,10 @@ import fnmatch import subprocess -from fledge.common import logger, utils +from fledge.common import utils from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.plugin_discovery import PluginDiscovery from fledge.common.storage_client import payload_builder from fledge.services.core.api.python_packages import get_packages_installed @@ -34,7 +33,8 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -_LOGGER = logger.setup(__name__, level=logging.INFO) +_LOGGER = FLCoreLogger().get_logger(__name__) + _NO_OF_FILES_TO_RETAIN = 3 _SYSLOG_FILE = '/var/log/messages' if utils.is_redhat_based() else '/var/log/syslog' _PATH = _FLEDGE_DATA if _FLEDGE_DATA else _FLEDGE_ROOT + '/data' diff --git a/python/fledge/services/core/user_model.py b/python/fledge/services/core/user_model.py index 289526766c..5ca6dcb35a 100644 --- a/python/fledge/services/core/user_model.py +++ b/python/fledge/services/core/user_model.py @@ -12,13 +12,13 @@ from datetime import datetime, timedelta import jwt -from fledge.services.core import connect +from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError -from fledge.common.configuration_manager import ConfigurationManager -from fledge.common import logger from fledge.common.web.ssl_wrapper import SSLVerifier -from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA +from fledge.services.core import connect __author__ = "Praveen Garg, Ashish Jabble, Amarendra K Sinha" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -32,7 +32,7 @@ ERROR_MSG = 'Something went wrong' USED_PASSWORD_HISTORY_COUNT = 3 -_logger = logger.setup(__name__) +_logger = FLCoreLogger().get_logger(__name__) class User: diff --git a/python/fledge/tasks/automation_script/__main__.py b/python/fledge/tasks/automation_script/__main__.py index 3d6b35c35b..a789f3ddd8 100755 --- a/python/fledge/tasks/automation_script/__main__.py +++ b/python/fledge/tasks/automation_script/__main__.py @@ -8,12 +8,11 @@ """Automation script starter""" import sys -import logging import json import http.client import argparse -from fledge.common import logger +from fledge.common.logger import FLCoreLogger __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2022 Dianomic Systems Inc." @@ -22,7 +21,7 @@ if __name__ == '__main__': - _logger = logger.setup("Automation Script", level=logging.INFO) + _logger = FLCoreLogger().get_logger("Control Script") parser = argparse.ArgumentParser() parser.add_argument("--name", required=True) parser.add_argument("--address", required=True) diff --git a/python/fledge/tasks/north/sending_process.py b/python/fledge/tasks/north/sending_process.py index e49fbb3ac7..90c02adb4b 100644 --- a/python/fledge/tasks/north/sending_process.py +++ b/python/fledge/tasks/north/sending_process.py @@ -33,8 +33,8 @@ from fledge.common import statistics from fledge.common.jqfilter import JQFilter from fledge.common.audit_logger import AuditLogger +from fledge.common.logger import FLCoreLogger from fledge.common.process import FledgeProcess -from fledge.common import logger from fledge.common.common import _FLEDGE_ROOT from fledge.services.core.api.plugins import common @@ -93,7 +93,7 @@ } """ Messages used for Information, Warning and Error notice """ -_LOGGER = logger.setup(__name__) +_LOGGER = FLCoreLogger().get_logger(__name__) _event_loop = "" _log_performance = False """ Enable/Disable performance logging, enabled using a command line parameter""" diff --git a/python/fledge/tasks/purge/__main__.py b/python/fledge/tasks/purge/__main__.py index 6168c060cd..6c343aa266 100755 --- a/python/fledge/tasks/purge/__main__.py +++ b/python/fledge/tasks/purge/__main__.py @@ -8,8 +8,9 @@ """Purge process starter""" import asyncio +from fledge.common.logger import FLCoreLogger from fledge.tasks.purge.purge import Purge -from fledge.common import logger + __author__ = "Terris Linenbach, Vaibhav Singhal" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -17,7 +18,7 @@ __version__ = "${VERSION}" if __name__ == '__main__': - _logger = logger.setup("Purge") + _logger = FLCoreLogger().get_logger("Purge") loop = asyncio.get_event_loop() purge_process = Purge() loop.run_until_complete(purge_process.run()) diff --git a/python/fledge/tasks/purge/purge.py b/python/fledge/tasks/purge/purge.py index 91ef7ca5f1..fa2272c97d 100644 --- a/python/fledge/tasks/purge/purge.py +++ b/python/fledge/tasks/purge/purge.py @@ -31,13 +31,14 @@ import time from datetime import datetime, timedelta +from fledge.common import statistics from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager -from fledge.common import statistics +from fledge.common.logger import FLCoreLogger +from fledge.common.process import FledgeProcess from fledge.common.storage_client.payload_builder import PayloadBuilder -from fledge.common import logger from fledge.common.storage_client.exceptions import * -from fledge.common.process import FledgeProcess + __author__ = "Ori Shadmon, Vaibhav Singhal, Mark Riddoch, Amarendra K Sinha" __copyright__ = "Copyright (c) 2017 OSI Soft, LLC" @@ -94,7 +95,7 @@ class Purge(FledgeProcess): def __init__(self): super().__init__() - self._logger = logger.setup("Data Purge") + self._logger = FLCoreLogger().get_logger("Data Purge") self._audit = AuditLogger(self._storage_async) async def write_statistics(self, total_purged, unsent_purged): diff --git a/python/fledge/tasks/statistics/__main__.py b/python/fledge/tasks/statistics/__main__.py index 9e92502393..cf5df676a7 100755 --- a/python/fledge/tasks/statistics/__main__.py +++ b/python/fledge/tasks/statistics/__main__.py @@ -8,8 +8,9 @@ """Statistics history process starter""" import asyncio +from fledge.common.logger import FLCoreLogger from fledge.tasks.statistics.statistics_history import StatisticsHistory -from fledge.common import logger + __author__ = "Terris Linenbach, Vaibhav Singhal" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -17,7 +18,7 @@ __version__ = "${VERSION}" if __name__ == '__main__': - _logger = logger.setup("StatisticsHistory") + _logger = FLCoreLogger().get_logger("StatisticsHistory") statistics_history_process = StatisticsHistory() loop = asyncio.get_event_loop() loop.run_until_complete(statistics_history_process.run()) diff --git a/python/fledge/tasks/statistics/statistics_history.py b/python/fledge/tasks/statistics/statistics_history.py index db19f64407..ea34c00755 100644 --- a/python/fledge/tasks/statistics/statistics_history.py +++ b/python/fledge/tasks/statistics/statistics_history.py @@ -12,10 +12,10 @@ """ import json -from fledge.common.storage_client.payload_builder import PayloadBuilder -from fledge.common import logger -from fledge.common.process import FledgeProcess from fledge.common import utils as common_utils +from fledge.common.logger import FLCoreLogger +from fledge.common.process import FledgeProcess +from fledge.common.storage_client.payload_builder import PayloadBuilder __author__ = "Ori Shadmon, Ashish Jabble" __copyright__ = "Copyright (c) 2017 OSI Soft, LLC" @@ -29,7 +29,7 @@ class StatisticsHistory(FledgeProcess): def __init__(self): super().__init__() - self._logger = logger.setup("StatisticsHistory") + self._logger = FLCoreLogger().get_logger("StatisticsHistory") async def _bulk_update_previous_value(self, payload): """ UPDATE previous_value of column to have the same value as snapshot diff --git a/tests/unit/python/fledge/common/test_jqfilter.py b/tests/unit/python/fledge/common/test_jqfilter.py index 5cab91b79d..d0da1ec080 100644 --- a/tests/unit/python/fledge/common/test_jqfilter.py +++ b/tests/unit/python/fledge/common/test_jqfilter.py @@ -10,7 +10,7 @@ from unittest.mock import patch import pytest import pyjq -from fledge.common import logger +from fledge.common.logger import FLCoreLogger from fledge.common.jqfilter import JQFilter __author__ = "Vaibhav Singhal" @@ -23,9 +23,9 @@ @pytest.allure.story("common", "jqfilter") class TestJQFilter: def test_init(self): - with patch.object(logger, "setup") as log: + with patch.object(FLCoreLogger, "get_logger") as log: jqfilter_instance = JQFilter() - assert isinstance(jqfilter_instance, JQFilter) + assert isinstance(jqfilter_instance, JQFilter) log.assert_called_once_with("JQFilter") @pytest.mark.parametrize("input_filter_string, input_reading_block, expected_return", [ diff --git a/tests/unit/python/fledge/common/test_process.py b/tests/unit/python/fledge/common/test_process.py index 3176709baf..c979c02fdd 100644 --- a/tests/unit/python/fledge/common/test_process.py +++ b/tests/unit/python/fledge/common/test_process.py @@ -5,6 +5,7 @@ from unittest.mock import patch +from fledge.common import process from fledge.common.storage_client.storage_client import ReadingsStorageClientAsync, StorageClientAsync from fledge.common.process import FledgeProcess, ArgumentParserError from fledge.common.microservice_management_client.microservice_management_client import MicroserviceManagementClient @@ -39,9 +40,11 @@ def run(self): pass with patch.object(sys, 'argv', argslist): with pytest.raises(ArgumentParserError) as excinfo: - fp = FledgeProcessImp() - assert '' in str( - excinfo.value) + with patch.object(process._logger, "error") as patch_logger: + fp = FledgeProcessImp() + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with() + assert '' in str(excinfo.value) def test_constructor_good(self): class FledgeProcessImp(FledgeProcess): diff --git a/tests/unit/python/fledge/services/core/service_registry/test_monitor.py b/tests/unit/python/fledge/services/core/service_registry/test_monitor.py index 5c4f843788..3cf251b85a 100644 --- a/tests/unit/python/fledge/services/core/service_registry/test_monitor.py +++ b/tests/unit/python/fledge/services/core/service_registry/test_monitor.py @@ -94,7 +94,6 @@ class TestMonitorException(Exception): """ print(ServiceRegistry.get(idx=s_id_1)[0]._status) - @pytest.mark.asyncio async def test__monitor_exceed_attempts(self, mocker): class AsyncSessionContextManagerMock(MagicMock): @@ -113,8 +112,14 @@ class TestMonitorException(Exception): pass # register a service - s_id_1 = ServiceRegistry.register( - 'sname1', 'Storage', 'saddress1', 1, 1, 'protocol1') + with patch.object(ServiceRegistry._logger, 'info') as log_info: + s_id_1 = ServiceRegistry.register( + 'sname1', 'Storage', 'saddress1', 1, 1, 'protocol1') + assert 1 == log_info.call_count + args, kwargs = log_info.call_args + assert args[0].startswith('Registered service instance id=') + assert args[0].endswith(': ') monitor = Monitor() monitor._sleep_interval = Monitor._DEFAULT_SLEEP_INTERVAL monitor._max_attempts = Monitor._DEFAULT_MAX_ATTEMPTS diff --git a/tests/unit/python/fledge/services/core/test_connect.py b/tests/unit/python/fledge/services/core/test_connect.py index 840689d88c..c9b983c10a 100644 --- a/tests/unit/python/fledge/services/core/test_connect.py +++ b/tests/unit/python/fledge/services/core/test_connect.py @@ -44,7 +44,7 @@ def test_exception_when_no_storage(self, mock_logger): with pytest.raises(DoesNotExist) as excinfo: connect.get_storage_async() assert str(excinfo).endswith('DoesNotExist') - mock_logger.exception.assert_called_once_with('') + mock_logger.error.assert_called_once_with('') @patch('fledge.services.core.connect._logger') def test_exception_when_non_fledge_storage(self, mock_logger): @@ -55,8 +55,7 @@ def test_exception_when_non_fledge_storage(self, mock_logger): assert args[0].startswith('Registered service instance id=') assert args[0].endswith(': ') - with pytest.raises(DoesNotExist) as excinfo: connect.get_storage_async() assert str(excinfo).endswith('DoesNotExist') - mock_logger.exception.assert_called_once_with('') + mock_logger.error.assert_called_once_with('') diff --git a/tests/unit/python/fledge/services/core/test_server.py b/tests/unit/python/fledge/services/core/test_server.py index c7c151daf8..07f92eb9d2 100644 --- a/tests/unit/python/fledge/services/core/test_server.py +++ b/tests/unit/python/fledge/services/core/test_server.py @@ -632,16 +632,19 @@ async def test_service_not_registered(self, client): assert (request_data['name'], request_data['type'], request_data['address'], request_data['service_port'], request_data['management_port'], 'http', None) == args async def test_register_service(self, client): - async def async_mock(return_value): - return return_value + async def async_mock(): + return "" Server._storage_client = MagicMock(StorageClientAsync) Server._storage_client_async = MagicMock(StorageClientAsync) - request_data = {"type": "Storage", "name": "Storage Services", "address": "127.0.0.1", "service_port": 8090, "management_port": 1090} + request_data = {"type": "Storage", "name": "Storage Services", "address": "127.0.0.1", "service_port": 8090, + "management_port": 1090} + _rv = await async_mock() if sys.version_info.major == 3 and sys.version_info.minor >= 8 else \ + asyncio.ensure_future(async_mock()) with patch.object(ServiceRegistry, 'getStartupToken', return_value=None): with patch.object(ServiceRegistry, 'register', return_value='1') as patch_register: with patch.object(AuditLogger, '__init__', return_value=None): - with patch.object(AuditLogger, 'information', return_value=(await async_mock(None))) as audit_info_patch: + with patch.object(AuditLogger, 'information', return_value=_rv) as audit_info_patch: resp = await client.post('/fledge/service', data=json.dumps(request_data)) assert 200 == resp.status r = await resp.text() @@ -651,7 +654,8 @@ async def async_mock(return_value): assert 'SRVRG' == args[0] assert {'name': request_data['name']} == args[1] args, _ = patch_register.call_args - assert (request_data['name'], request_data['type'], request_data['address'], request_data['service_port'], request_data['management_port'], 'http', None) == args + assert (request_data['name'], request_data['type'], request_data['address'], + request_data['service_port'], request_data['management_port'], 'http', None) == args async def test_service_not_found_when_unregister(self, client): with patch.object(ServiceRegistry, 'get', side_effect=service_registry_exceptions.DoesNotExist) as patch_unregister: @@ -677,10 +681,12 @@ async def async_mock(): data.append(record) Server._storage_client = MagicMock(StorageClientAsync) Server._storage_client_async = MagicMock(StorageClientAsync) + _rv = await async_mock() if sys.version_info.major == 3 and sys.version_info.minor >= 8 else\ + asyncio.ensure_future(async_mock()) with patch.object(ServiceRegistry, 'get', return_value=data) as patch_get_unregister: with patch.object(ServiceRegistry, 'unregister') as patch_unregister: with patch.object(AuditLogger, '__init__', return_value=None): - with patch.object(AuditLogger, 'information', return_value=(await async_mock())) as audit_info_patch: + with patch.object(AuditLogger, 'information', return_value=_rv) as audit_info_patch: resp = await client.delete('/fledge/service/{}'.format(service_id)) assert 200 == resp.status r = await resp.text() diff --git a/tests/unit/python/fledge/tasks/purge/test_purge.py b/tests/unit/python/fledge/tasks/purge/test_purge.py index 1fd5282096..7defae79b2 100644 --- a/tests/unit/python/fledge/tasks/purge/test_purge.py +++ b/tests/unit/python/fledge/tasks/purge/test_purge.py @@ -9,14 +9,14 @@ import asyncio import sys from unittest.mock import patch, call, MagicMock -from fledge.common import logger +from fledge.common.audit_logger import AuditLogger +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger +from fledge.common.process import FledgeProcess +from fledge.common.storage_client.exceptions import * from fledge.common.storage_client.storage_client import StorageClientAsync, ReadingsStorageClientAsync from fledge.common.statistics import Statistics from fledge.tasks.purge.purge import Purge -from fledge.common.process import FledgeProcess -from fledge.common.configuration_manager import ConfigurationManager -from fledge.common.audit_logger import AuditLogger -from fledge.common.storage_client.exceptions import * __author__ = "Vaibhav Singhal" @@ -49,7 +49,7 @@ def test_init(self): mock_storage_client_async = MagicMock(spec=StorageClientAsync) mock_audit_logger = AuditLogger(mock_storage_client_async) with patch.object(FledgeProcess, "__init__") as mock_process: - with patch.object(logger, "setup") as log: + with patch.object(FLCoreLogger, "get_logger") as log: with patch.object(mock_audit_logger, "__init__", return_value=None): p = Purge() assert isinstance(p, Purge) @@ -146,7 +146,7 @@ async def test_purge_data(self, conf, expected_return, expected_calls): with patch.object(FledgeProcess, '__init__'): with patch.object(mock_audit_logger, "__init__", return_value=None): p = Purge() - p._logger = logger + p._logger = FLCoreLogger p._logger.info = MagicMock() p._logger.error = MagicMock() p._logger.debug = MagicMock() @@ -190,7 +190,7 @@ async def test_purge_data_no_data_purged(self, conf, expected_return): with patch.object(FledgeProcess, '__init__'): with patch.object(mock_audit_logger, "__init__", return_value=None): p = Purge() - p._logger = logger + p._logger = FLCoreLogger p._logger.info = MagicMock() p._logger.error = MagicMock() p._storage_async = MagicMock(spec=StorageClientAsync) @@ -225,7 +225,7 @@ async def test_purge_error_storage_response(self, conf, expected_return): with patch.object(FledgeProcess, '__init__'): with patch.object(mock_audit_logger, "__init__", return_value=None): p = Purge() - p._logger = logger + p._logger = FLCoreLogger p._logger.info = MagicMock() p._logger.error = MagicMock() p._storage_async = MagicMock(spec=StorageClientAsync) @@ -260,7 +260,7 @@ async def test_purge_data_invalid_conf(self, conf, expected_error_key): with patch.object(FledgeProcess, '__init__'): with patch.object(mock_audit_logger, "__init__", return_value=None): p = Purge() - p._logger = logger + p._logger = FLCoreLogger p._logger.info = MagicMock() p._logger.error = MagicMock() p._storage_async = MagicMock(spec=StorageClientAsync) diff --git a/tests/unit/python/fledge/tasks/statistics/test_statistics_history.py b/tests/unit/python/fledge/tasks/statistics/test_statistics_history.py index 22f4064bec..2f77c0d9e4 100644 --- a/tests/unit/python/fledge/tasks/statistics/test_statistics_history.py +++ b/tests/unit/python/fledge/tasks/statistics/test_statistics_history.py @@ -12,10 +12,10 @@ import sys import ast -from fledge.common import logger -from fledge.common.storage_client.storage_client import StorageClientAsync -from fledge.tasks.statistics.statistics_history import StatisticsHistory +from fledge.common.logger import FLCoreLogger from fledge.common.process import FledgeProcess +from fledge.tasks.statistics.statistics_history import StatisticsHistory +from fledge.common.storage_client.storage_client import StorageClientAsync __author__ = "Vaibhav Singhal" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -42,7 +42,7 @@ class TestStatisticsHistory: async def test_init(self): """Test that creating an instance of StatisticsHistory calls init of FledgeProcess and creates loggers""" with patch.object(FledgeProcess, "__init__") as mock_process: - with patch.object(logger, "setup") as log: + with patch.object(FLCoreLogger, "get_logger") as log: sh = StatisticsHistory() assert isinstance(sh, StatisticsHistory) log.assert_called_once_with("StatisticsHistory") @@ -56,7 +56,7 @@ async def test_update_previous_value(self): _rv = asyncio.ensure_future(mock_coro(None)) with patch.object(FledgeProcess, '__init__'): - with patch.object(logger, "setup"): + with patch.object(FLCoreLogger, "get_logger"): sh = StatisticsHistory() sh._storage_async = MagicMock(spec=StorageClientAsync) payload = {'updates': [{'where': {'value': 'Bla', 'condition': '=', 'column': 'key'}, 'values': {'previous_value': 1}}]} @@ -70,7 +70,7 @@ async def test_update_previous_value(self): async def test_run(self): with patch.object(FledgeProcess, '__init__'): - with patch.object(logger, "setup"): + with patch.object(FLCoreLogger, "get_logger"): sh = StatisticsHistory() sh._storage_async = MagicMock(spec=StorageClientAsync) retval = {'count': 2, From 6739b022ee8bfc5df604388c37e93f224c182b00 Mon Sep 17 00:00:00 2001 From: Innovative-ashwin <99904321+Innovative-ashwin@users.noreply.github.com> Date: Fri, 17 Mar 2023 18:14:19 +0530 Subject: [PATCH 195/499] Statistics history task enhancement (#964) * Statistics history task enhancement Signed-off-by: Innovative-ashwin * modified logs Signed-off-by: Innovative-ashwin * changes for updateTable Signed-off-by: Innovative-ashwin * added code for statistics update Signed-off-by: Innovative-ashwin * added docygen comments Signed-off-by: Innovative-ashwin * indentation Signed-off-by: Innovative-ashwin * indentation Signed-off-by: Innovative-ashwin * replaced python instances with C Signed-off-by: Innovative-ashwin * including debug logs Signed-off-by: Innovative-ashwin * added insert table function Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * removed logs Signed-off-by: Innovative-ashwin * modified comments Signed-off-by: Innovative-ashwin * added getTime function Signed-off-by: Innovative-ashwin * changed UTC time to localtime Signed-off-by: Innovative-ashwin * FOGL-7356 Signed-off-by: Innovative-ashwin * FOGL-7356 Signed-off-by: Innovative-ashwin * removed unwanted logs Signed-off-by: Innovative-ashwin * added comments Signed-off-by: Innovative-ashwin * added comments Signed-off-by: Innovative-ashwin * fixed code review comments Signed-off-by: Innovative-ashwin * C/common/include/storage_client.h Signed-off-by: Innovative-ashwin * added doxygen comment Signed-off-by: Innovative-ashwin --------- Signed-off-by: Innovative-ashwin Co-authored-by: gnandan <111729765+gnandan@users.noreply.github.com> --- C/common/include/storage_client.h | 12 ++ C/common/storage_client.cpp | 159 ++++++++++++++++++ C/tasks/statistics_history/CMakeLists.txt | 2 +- .../include/stats_history.h | 9 +- C/tasks/statistics_history/stats_history.cpp | 145 +++++++++++----- CMakeLists.txt | 1 + Makefile | 9 +- scripts/tasks/statistics | 14 +- 8 files changed, 299 insertions(+), 52 deletions(-) diff --git a/C/common/include/storage_client.h b/C/common/include/storage_client.h index 4b5ab7f59a..902999acc5 100644 --- a/C/common/include/storage_client.h +++ b/C/common/include/storage_client.h @@ -49,6 +49,12 @@ class StorageClient { ResultSet *queryTable(const std::string& tablename, const Query& query); ReadingSet *queryTableToReadings(const std::string& tableName, const Query& query); int insertTable(const std::string& schema, const std::string& tableName, const InsertValues& values); + int insertTable(const std::string& schema, const std::string& tableName, + const std::vector& values); + int insertTable(const std::string& tableName, const std::vector& values); + + + int updateTable(const std::string& schema, const std::string& tableName, const InsertValues& values, const Where& where, const UpdateModifier *modifier = NULL); int updateTable(const std::string& schema, const std::string& tableName, const JSONProperties& json, @@ -73,6 +79,12 @@ class StorageClient { const UpdateModifier *modifier = NULL); int updateTable(const std::string& tableName, const InsertValues& values, const ExpressionValues& expressions, const Where& where, const UpdateModifier *modifier = NULL); + int updateTable(const std::string& schema, const std::string& tableName, + std::vector > &updates, const UpdateModifier *modifier); + + int updateTable(const std::string& tableName, std::vector >& updates, + const UpdateModifier *modifier = NULL); + int deleteTable(const std::string& tableName, const Query& query); bool readingAppend(Reading& reading); bool readingAppend(const std::vector & readings); diff --git a/C/common/storage_client.cpp b/C/common/storage_client.cpp index dd8317ad06..529571bcb6 100644 --- a/C/common/storage_client.cpp +++ b/C/common/storage_client.cpp @@ -1742,3 +1742,162 @@ bool StorageClient::createSchema(const std::string& payload) } return false; } + +/** + * Update data into an arbitrary table + * + * @param schema The name of the schema into which data will be added + * @param tableName The name of the table into which data will be added + * @param updates The values and condition pairs to update in the table + * @param modifier Optional update modifier + * @return int The number of rows updated + */ +int StorageClient::updateTable(const string& schema, const string& tableName, std::vector >& updates, const UpdateModifier *modifier) +{ + static HttpClient *httpClient = this->getHttpClient(); // to initialize m_seqnum_map[thread_id] for this thread + try { + std::thread::id thread_id = std::this_thread::get_id(); + ostringstream ss; + sto_mtx_client_map.lock(); + m_seqnum_map[thread_id].fetch_add(1); + ss << m_pid << "#" << thread_id << "_" << m_seqnum_map[thread_id].load(); + sto_mtx_client_map.unlock(); + + SimpleWeb::CaseInsensitiveMultimap headers = {{"SeqNum", ss.str()}}; + + ostringstream convert; + convert << "{ \"updates\" : [ "; + + for (vector>::const_iterator it = updates.cbegin(); + it != updates.cend(); ++it) + { + if (it != updates.cbegin()) + { + convert << ", "; + } + convert << "{ "; + if (modifier) + { + convert << "\"modifiers\" : [ \"" << modifier->toJSON() << "\" ], "; + } + convert << "\"where\" : "; + convert << it->second->toJSON(); + convert << ", \"values\" : "; + convert << " { " << it->first->toJSON() << " } "; + convert << " }"; + } + convert << " ] }"; + + char url[128]; + snprintf(url, sizeof(url), "/storage/schema/%s/table/%s", schema.c_str(), tableName.c_str()); + auto res = this->getHttpClient()->request("PUT", url, convert.str(), headers); + + if (res->status_code.compare("200 OK") == 0) + { + ostringstream resultPayload; + resultPayload << res->content.rdbuf(); + Document doc; + doc.Parse(resultPayload.str().c_str()); + if (doc.HasParseError()) + { + m_logger->info("PUT result %s.", res->status_code.c_str()); + m_logger->error("Failed to parse result of updateTable. %s", + GetParseError_En(doc.GetParseError())); + return -1; + } + else if (doc.HasMember("message")) + { + m_logger->error("Failed to update table data: %s", + doc["message"].GetString()); + return -1; + } + return doc["rows_affected"].GetInt(); + } + ostringstream resultPayload; + resultPayload << res->content.rdbuf(); + handleUnexpectedResponse("Update table", tableName, res->status_code, resultPayload.str()); + } catch (exception& ex) { + handleException(ex, "update table %s", tableName.c_str()); + throw; + } + return -1; +} + +/** + * Update data into an arbitrary table + * + * @param tableName The name of the table into which data will be added + * @param updates The values to insert into the table + * @param modifier Optional storage modifier + * @return int The number of rows updated + */ + +int StorageClient::updateTable(const string& tableName, std::vector >& updates, const UpdateModifier *modifier) +{ + return updateTable(DEFAULT_SCHEMA, tableName, updates, modifier); +} + +/** + * Insert data into an arbitrary table + * + * @param tableName The name of the table into which data will be added + * @param values The values to insert into the table + * @return int The number of rows inserted + */ +int StorageClient::insertTable(const string& tableName, const std::vector& values) +{ + return insertTable(DEFAULT_SCHEMA, tableName, values); +} +/** + * Insert data into an arbitrary table + * + * @param schema The name of the schema to insert into + * @param tableName The name of the table into which data will be added + * @param values The values to insert into the table + * @return int The number of rows inserted + */ +int StorageClient::insertTable(const string& schema, const string& tableName, const std::vector& values) +{ + try { + ostringstream convert; + for (std::vector::const_iterator it = values.cbegin(); + it != values.cend(); ++it) + { + if (it != values.cbegin()) + { + convert << ", "; + } + convert << it->toJSON() ; + } + + char url[1000]; + snprintf(url, sizeof(url), "/storage/schema/%s/table/%s", schema.c_str(), tableName.c_str()); + + auto res = this->getHttpClient()->request("POST", url, convert.str()); + ostringstream resultPayload; + resultPayload << res->content.rdbuf(); + if (res->status_code.compare("200 OK") == 0 || res->status_code.compare("201 Created") == 0) + { + + Document doc; + doc.Parse(resultPayload.str().c_str()); + if (doc.HasParseError()) + { + m_logger->info("POST result %s.", res->status_code.c_str()); + m_logger->error("Failed to parse result of insertTable. %s. Document is %s", + GetParseError_En(doc.GetParseError()), + resultPayload.str().c_str()); + return -1; + } + else if (doc.HasMember("rows_affected")) + { + return doc["rows_affected"].GetInt(); + } + } + handleUnexpectedResponse("Insert table", res->status_code, resultPayload.str()); + } catch (exception& ex) { + handleException(ex, "insert into table %s", tableName.c_str()); + throw; + } + return 0; +} diff --git a/C/tasks/statistics_history/CMakeLists.txt b/C/tasks/statistics_history/CMakeLists.txt index b6924b3e71..893f5c91b1 100644 --- a/C/tasks/statistics_history/CMakeLists.txt +++ b/C/tasks/statistics_history/CMakeLists.txt @@ -4,7 +4,7 @@ project (statistics_history) set(CMAKE_CXX_FLAGS_DEBUG "-O0 -ggdb") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -Wall -Wextra -Wsign-conversion") set(UUIDLIB -luuid) -set(COMMON_LIB common-lib) +set(COMMON_LIB common-lib services-common-lib) include_directories(. include ../../thirdparty/Simple-Web-Server ../../thirdparty/rapidjson/include ../../common/include) diff --git a/C/tasks/statistics_history/include/stats_history.h b/C/tasks/statistics_history/include/stats_history.h index 2409016a80..0973c5b584 100644 --- a/C/tasks/statistics_history/include/stats_history.h +++ b/C/tasks/statistics_history/include/stats_history.h @@ -12,6 +12,9 @@ */ #include +#include +#include +#include /** @@ -28,7 +31,11 @@ class StatsHistory : public FledgeProcess void run() const; private: - void processKey(const std::string& key) const; + void processKey(const std::string& key, std::vector &historyValues, + std::vector > &updateValues, std::string dateTimeStr, int val , int prev) const; + std::string getTime(void) const; + + }; #endif diff --git a/C/tasks/statistics_history/stats_history.cpp b/C/tasks/statistics_history/stats_history.cpp index cbc114114a..cba16a848a 100644 --- a/C/tasks/statistics_history/stats_history.cpp +++ b/C/tasks/statistics_history/stats_history.cpp @@ -10,7 +10,12 @@ #include #include +#include +#include +#define DATETIME_MAX_LEN 52 +#define MICROSECONDS_FORMAT_LEN 10 +#define DATETIME_FORMAT_DEFAULT "%Y-%m-%d %H:%M:%S" using namespace std; @@ -45,6 +50,7 @@ StatsHistory::~StatsHistory() */ void StatsHistory::run() const { + // We handle these signals, add more if needed std::signal(SIGINT, signalHandler); std::signal(SIGSTOP, signalHandler); @@ -56,61 +62,124 @@ void StatsHistory::run() const // Get the set of distinct statistics keys Query query(new Returns("key")); query.distinct(); - ResultSet *keySet = getStorageClient()->queryTable("statistics", query); + query.returns(new Returns("value")); + query.returns(new Returns("previous_value")); + ResultSet *keySet = getStorageClient()->queryTable("statistics", query); ResultSet::RowIterator rowIter = keySet->firstRow(); + std::vector historyValues; + vector> updateValues; + + std::string dateTimeStr = getTime(); - do { + while (keySet->hasNextRow(rowIter) || keySet->isLastRow(rowIter) ) + { string key = (*rowIter)->getColumn("key")->getString(); + int val = (*rowIter)->getColumn("value")->getInteger(); + int prev = (*rowIter)->getColumn("previous_value")->getInteger(); + try { - processKey(key); + processKey(key, historyValues, updateValues, dateTimeStr, val, prev); } catch (exception e) { getLogger()->error("Failed to process statisitics key %s, %s", key, e.what()); } - rowIter = keySet->nextRow(rowIter); - } while (keySet->hasNextRow(rowIter)); + if (!keySet->isLastRow(rowIter)) + rowIter = keySet->nextRow(rowIter); + else + break; + } + + int n_rows; + if ((n_rows = getStorageClient()->insertTable("statistics_history", historyValues)) < 1) + { + getLogger()->error("Failed to insert rows to statistics history table "); + } + + if (getStorageClient()->updateTable("statistics", updateValues) < 1) + { + getLogger()->error("Failed to update rows to statistics table"); + } + + for (auto it = updateValues.begin(); it != updateValues.end() ; ++it) + { + InsertValue *updateValue = it->first; + if (updateValue) + { + delete updateValue; + updateValue=nullptr; + } + Where *wKey = it->second; + if(wKey) + { + delete wKey; + wKey = nullptr; + } + } delete keySet; } /** - * Process a single statistics key + * Process statistics keys * - * @param key The statistics key to process + * @param key The statistics key to process + * @param historyValues Values to be inserted in statistics_history + * @param updateValues Values to be updated in statistics + * @param dateTimeStr Local time with microseconds precision + * @param val int + * @param prev int + * @return void */ -void StatsHistory::processKey(const string& key) const +void StatsHistory::processKey(const std::string& key, std::vector &historyValues, std::vector > &updateValues, std::string dateTimeStr, int val, int prev) const { -Query query(new Where("key", Equals, key)); + InsertValues iValue; - // Fetch the current and previous valaues for the key - query.returns(new Returns("value")); - query.returns(new Returns("previous_value")); - ResultSet *values = getStorageClient()->queryTable("statistics", query); - if (values->rowCount() != 1) - { - getLogger()->error("Internal error, failed to get statisitics for key %s", key.c_str()); - return; - } - int val = ((*values)[0])->getColumn("value")->getInteger(); - int prev = ((*values)[0])->getColumn("previous_value")->getInteger(); - delete values; - - // Insert the row into the configuration history - InsertValues historyValues; - historyValues.push_back(InsertValue("key", key.c_str())); - historyValues.push_back(InsertValue("value", val - prev)); - historyValues.push_back(InsertValue("history_ts", "now()")); - int n_rows; - if ((n_rows = getStorageClient()->insertTable("statistics_history", historyValues)) != 1) - { - getLogger()->error("Failed to insert single row to statisitics history table for key %s", key.c_str()); - } + // Insert the row into the statistics history + // create an object of InsertValues and push in historyValues vector + // for batch insertion + iValue.push_back(InsertValue("key", key.c_str())); + iValue.push_back(InsertValue("value", val - prev)); + iValue.push_back(InsertValue("history_ts", dateTimeStr)); + + historyValues.push_back(iValue); // Update the previous value in the statistics row - InsertValues updateValues; - updateValues.push_back(InsertValue("previous_value", val)); - if (getStorageClient()->updateTable("statistics", updateValues, Where("key", Equals, key)) != 1) - { - getLogger()->error("Failed to update single row to statisitics table for key %s", key.c_str()); - } + // create an object of InsertValue and push in updateValues vector + // for batch updation + InsertValue *updateValue = new InsertValue("previous_value", val); + Where *wKey = new Where("key", Equals, key); + updateValues.emplace_back(updateValue, wKey); } + +/** + * getTime() function returns the localTime with microseconds precision + * + * @param void + * @return std::string localTime + */ + +std::string StatsHistory::getTime(void) const +{ + struct timeval tv ; + struct tm* timeinfo; + gettimeofday(&tv, NULL); + timeinfo = localtime(&tv.tv_sec); + char date_time[DATETIME_MAX_LEN]; + // Create datetime with seconds + strftime(date_time, + sizeof(date_time), + DATETIME_FORMAT_DEFAULT, + timeinfo); + + std::string dateTimeLocal = date_time; + char micro_s[MICROSECONDS_FORMAT_LEN]; + // Add microseconds + snprintf(micro_s, + sizeof(micro_s), + ".%06lu", + tv.tv_usec); + + dateTimeLocal.append(micro_s); + return dateTimeLocal; +} + diff --git a/CMakeLists.txt b/CMakeLists.txt index 875e677949..8ab13abe33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,7 @@ add_subdirectory(C/services/filter-plugin-interfaces/python/filter_ingest_pymodu add_subdirectory(C/services/north-plugin-interfaces/python) add_subdirectory(C/tasks/north) add_subdirectory(C/tasks/purge_system) +add_subdirectory(C/tasks/statistics_history) add_subdirectory(C/plugins/utils) add_subdirectory(C/plugins/north/OMF) diff --git a/Makefile b/Makefile index c1c80d666b..3ce471339c 100644 --- a/Makefile +++ b/Makefile @@ -61,6 +61,7 @@ CMAKE_SOUTH_BINARY := $(CMAKE_SERVICES_DIR)/south/fledge.services.sou CMAKE_NORTH_SERVICE_BINARY := $(CMAKE_SERVICES_DIR)/north/fledge.services.north CMAKE_NORTH_BINARY := $(CMAKE_TASKS_DIR)/north/sending_process/sending_process CMAKE_PURGE_SYSTEM_BINARY := $(CMAKE_TASKS_DIR)/purge_system/purge_system +CMAKE_STATISTICS_BINARY := $(CMAKE_TASKS_DIR)/statistics_history/statistics_history CMAKE_PLUGINS_DIR := $(CURRENT_DIR)/$(CMAKE_BUILD_DIR)/C/plugins DEV_SERVICES_DIR := $(CURRENT_DIR)/services DEV_TASKS_DIR := $(CURRENT_DIR)/tasks @@ -70,6 +71,7 @@ SYMLINK_SOUTH_BINARY := $(DEV_SERVICES_DIR)/fledge.services.south SYMLINK_NORTH_SERVICE_BINARY := $(DEV_SERVICES_DIR)/fledge.services.north SYMLINK_NORTH_BINARY := $(DEV_TASKS_DIR)/sending_process SYMLINK_PURGE_SYSTEM_BINARY := $(DEV_TASKS_DIR)/purge_system +SYMLINK_STATISTICS_BINARY := $(DEV_TASKS_DIR)/statistics_history ASYNC_INGEST_PYMODULE := $(CURRENT_DIR)/python/async_ingest.so* FILTER_INGEST_PYMODULE := $(CURRENT_DIR)/python/filter_ingest.so* @@ -166,7 +168,7 @@ PACKAGE_NAME=Fledge # generally prepare the development tree to allow for core to be run default : apply_version \ generate_selfcertificate \ - c_build $(SYMLINK_STORAGE_BINARY) $(SYMLINK_SOUTH_BINARY) $(SYMLINK_NORTH_SERVICE_BINARY) $(SYMLINK_NORTH_BINARY) $(SYMLINK_PURGE_SYSTEM_BINARY) $(SYMLINK_PLUGINS_DIR) \ + c_build $(SYMLINK_STORAGE_BINARY) $(SYMLINK_SOUTH_BINARY) $(SYMLINK_NORTH_SERVICE_BINARY) $(SYMLINK_NORTH_BINARY) $(SYMLINK_PURGE_SYSTEM_BINARY) $(SYMLINK_STATISTICS_BINARY) $(SYMLINK_PLUGINS_DIR) \ python_build python_requirements_user apply_version : @@ -289,6 +291,11 @@ $(SYMLINK_NORTH_BINARY) : $(DEV_TASKS_DIR) $(SYMLINK_PURGE_SYSTEM_BINARY) : $(DEV_TASKS_DIR) $(LN) $(CMAKE_PURGE_SYSTEM_BINARY) $(SYMLINK_PURGE_SYSTEM_BINARY) +# create symlink to purge_system binary +$(SYMLINK_STATISTICS_BINARY) : $(DEV_TASKS_DIR) + $(LN) $(CMAKE_STATISTICS_BINARY) $(SYMLINK_STATISTICS_BINARY) + + # create tasks dir $(DEV_TASKS_DIR) : $(MKDIR_PATH) $(DEV_TASKS_DIR) diff --git a/scripts/tasks/statistics b/scripts/tasks/statistics index 1206e6fe6a..b1e2b1e9a4 100755 --- a/scripts/tasks/statistics +++ b/scripts/tasks/statistics @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Run a Fledge south service written in Python +# Run a Fledge task written in C if [ "${FLEDGE_ROOT}" = "" ]; then FLEDGE_ROOT=/usr/local/fledge fi @@ -9,20 +9,12 @@ if [ ! -d "${FLEDGE_ROOT}" ]; then exit 1 fi -if [ ! -d "${FLEDGE_ROOT}/python" ]; then - logger "Fledge home directory is missing the Python installation" - exit 1 -fi - -# We run the Python code from the python directory -cd "${FLEDGE_ROOT}/python" - os_name=`(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g')` if [[ $os_name == *"Raspbian"* ]]; then - cpulimit -l 40 -- python3 -m fledge.tasks.statistics "$@" + cpulimit -l 40 -- $FLEDGE_ROOT/tasks/statistics_history "$@" else # Standard execution on other platforms - python3 -m fledge.tasks.statistics "$@" + $FLEDGE_ROOT/tasks/statistics_history "$@" fi From 052c71aae0ffda734eed99c1e6692d299fdceea3 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 17 Mar 2023 18:53:14 +0530 Subject: [PATCH 196/499] API system tests updated Signed-off-by: ashish-jabble --- tests/system/python/api/test_audit.py | 31 ++++++++++--------- .../system/python/api/test_authentication.py | 18 +++++------ tests/system/python/api/test_configuration.py | 6 ++++ ...est_endpoints_with_different_user_types.py | 4 +-- 4 files changed, 34 insertions(+), 25 deletions(-) diff --git a/tests/system/python/api/test_audit.py b/tests/system/python/api/test_audit.py index 40740fb908..630859148e 100644 --- a/tests/system/python/api/test_audit.py +++ b/tests/system/python/api/test_audit.py @@ -18,6 +18,8 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" +DEFAULT_AUDIT_COUNT = 14 + class TestAudit: @@ -55,16 +57,16 @@ def test_get_severity(self, fledge_url): assert Counter([0, 1, 2, 4]) == Counter(index) @pytest.mark.parametrize("request_params, total_count, audit_count", [ - ('', 13, 13), - ('?limit=1', 13, 1), - ('?skip=4', 13, 9), - ('?limit=1&skip=8', 13, 1), + ('', DEFAULT_AUDIT_COUNT, DEFAULT_AUDIT_COUNT), + ('?limit=1', DEFAULT_AUDIT_COUNT, 1), + ('?skip=4', DEFAULT_AUDIT_COUNT, 10), + ('?limit=1&skip=8', DEFAULT_AUDIT_COUNT, 1), ('?source=START', 1, 1), - ('?source=CONAD', 12, 12), - ('?source=CONAD&limit=1', 12, 1), - ('?source=CONAD&skip=1', 12, 11), - ('?source=CONAD&skip=6&limit=1', 12, 1), - ('?severity=INFORMATION', 13, 13), + ('?source=CONAD', 13, 13), + ('?source=CONAD&limit=1', 13, 1), + ('?source=CONAD&skip=1', 13, 12), + ('?source=CONAD&skip=6&limit=1', 13, 1), + ('?severity=INFORMATION', DEFAULT_AUDIT_COUNT, DEFAULT_AUDIT_COUNT), ('?severity=failure', 0, 0), ('?source=CONAD&severity=failure', 0, 0), ('?source=START&severity=INFORMATION', 1, 1), @@ -84,14 +86,15 @@ def test_default_get_audit(self, fledge_url, wait_time, request_params, total_co jdoc = json.loads(r) elems = jdoc['audit'] assert len(jdoc), "No data found" + print(jdoc['totalCount'], len(elems)) assert total_count == jdoc['totalCount'] assert audit_count == len(elems) @pytest.mark.parametrize("payload, total_count", [ - ({"source": "LOGGN", "severity": "warning", "details": {"message": "Engine oil pressure low"}}, 14), - ({"source": "NHCOM", "severity": "success", "details": {}}, 15), - ({"source": "START", "severity": "information", "details": {"message": "fledge started"}}, 16), - ({"source": "CONCH", "severity": "failure", "details": {"message": "Scheduler configuration failed"}}, 17) + ({"source": "LOGGN", "severity": "warning", "details": {"message": "Engine oil pressure low"}}, 1), + ({"source": "NHCOM", "severity": "success", "details": {}}, 2), + ({"source": "START", "severity": "information", "details": {"message": "fledge started"}}, 3), + ({"source": "CONCH", "severity": "failure", "details": {"message": "Scheduler configuration failed"}}, 4) ]) def test_create_audit_entry(self, fledge_url, payload, total_count): conn = http.client.HTTPConnection(fledge_url) @@ -112,7 +115,7 @@ def test_create_audit_entry(self, fledge_url, payload, total_count): r = r.read().decode() jdoc = json.loads(r) assert len(jdoc), "No data found" - assert total_count == jdoc['totalCount'] + assert DEFAULT_AUDIT_COUNT + total_count == jdoc['totalCount'] @pytest.mark.parametrize("payload", [ ({"source": "LOGGN_X", "severity": "warning", "details": {"message": "Engine oil pressure low"}}), diff --git a/tests/system/python/api/test_authentication.py b/tests/system/python/api/test_authentication.py index 3eedb9d5bd..7985a39eaf 100644 --- a/tests/system/python/api/test_authentication.py +++ b/tests/system/python/api/test_authentication.py @@ -120,23 +120,23 @@ def test_get_roles(self, fledge_url): @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, {'user': {'userName': 'any1', 'userId': 3, 'roleId': 2, 'accessMethod': 'any', 'realName': 'AJ', - 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully'}), + 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully.'}), ({"username": "admin1", "password": "F0gl@mp!", "role_id": 1}, {'user': {'userName': 'admin1', 'userId': 4, 'roleId': 1, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'admin1 user has been created successfully'}), + 'description': ''}, 'message': 'admin1 user has been created successfully.'}), ({"username": "bogus", "password": "Fl3dG$", "role_id": 2}, {'user': {'userName': 'bogus', 'userId': 5, 'roleId': 2, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'bogus user has been created successfully'}), + 'description': ''}, 'message': 'bogus user has been created successfully.'}), ({"username": "view", "password": "V!3w@1", "role_id": 3, "real_name": "View", "description": "Only to view the configuration"}, {'user': { 'userName': 'view', 'userId': 6, 'roleId': 3, 'accessMethod': 'any', 'realName': 'View', - 'description': 'Only to view the configuration'}, 'message': 'view user has been created successfully'}), + 'description': 'Only to view the configuration'}, 'message': 'view user has been created successfully.'}), ({"username": "dataView", "password": "DV!3w@1", "role_id": 4, "real_name": "DataView", "description": "Only read the data in buffer"}, {'user': { 'userName': 'dataview', 'userId': 7, 'roleId': 4, 'accessMethod': 'any', 'realName': 'DataView', - 'description': 'Only read the data in buffer'}, 'message': 'dataview user has been created successfully'}) + 'description': 'Only read the data in buffer'}, 'message': 'dataview user has been created successfully.'}) ]) def test_create_user(self, fledge_url, form_data, expected_values): conn = http.client.HTTPConnection(fledge_url) @@ -157,7 +157,7 @@ def test_update_password(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'Password has been updated successfully for user id:<{}>'.format(uid)} == jdoc + assert {'message': 'Password has been updated successfully for user ID:<{}>.'.format(uid)} == jdoc def test_update_user(self, fledge_url): uid = 5 @@ -213,7 +213,7 @@ def test_enable_user(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<{}> has been disabled successfully'.format(uid)} == jdoc + assert {'message': 'User with ID:<{}> has been disabled successfully.'.format(uid)} == jdoc # Fetch users list again and check disabled user does not exist in the response conn.request("GET", "/fledge/user", headers={"authorization": TOKEN}) @@ -234,7 +234,7 @@ def test_reset_user(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<{}> has been updated successfully'.format(uid)} == jdoc + assert {'message': 'User with ID:<{}> has been updated successfully.'.format(uid)} == jdoc def test_delete_user(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -243,7 +243,7 @@ def test_delete_user(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': "User has been deleted successfully"} == jdoc + assert {'message': "User has been deleted successfully."} == jdoc def test_logout_all(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) diff --git a/tests/system/python/api/test_configuration.py b/tests/system/python/api/test_configuration.py index 8662e31911..7c36dc4d97 100644 --- a/tests/system/python/api/test_configuration.py +++ b/tests/system/python/api/test_configuration.py @@ -90,6 +90,12 @@ def test_default(self, fledge_url, reset_and_start_fledge, wait_time, storage_pl 'description': 'Scheduler configuration', 'displayName': 'Scheduler', 'children': [] + }, + { + "key": "LOGGING", + "description": "Logging Level of Core Server", + "displayName": "Logging", + 'children': [] } ] }, diff --git a/tests/system/python/api/test_endpoints_with_different_user_types.py b/tests/system/python/api/test_endpoints_with_different_user_types.py index 084c6fe79d..f273399a42 100644 --- a/tests/system/python/api/test_endpoints_with_different_user_types.py +++ b/tests/system/python/api/test_endpoints_with_different_user_types.py @@ -65,7 +65,7 @@ def test_setup(reset_and_start_fledge, change_to_auth_mandatory, fledge_url, wai assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert "{} user has been created successfully".format(VIEW_USERNAME) == jdoc["message"] + assert "{} user has been created successfully.".format(VIEW_USERNAME) == jdoc["message"] # Create Data view user data_view_payload = {"username": DATA_VIEW_USERNAME, "password": DATA_VIEW_PWD, "role_id": 4, @@ -76,7 +76,7 @@ def test_setup(reset_and_start_fledge, change_to_auth_mandatory, fledge_url, wai assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert "{} user has been created successfully".format(DATA_VIEW_USERNAME) == jdoc["message"] + assert "{} user has been created successfully.".format(DATA_VIEW_USERNAME) == jdoc["message"] class TestAPIEndpointsWithViewUserType: From d1d6ce38a46318b634964a88030c28493da19d10 Mon Sep 17 00:00:00 2001 From: pintomax Date: Fri, 17 Mar 2023 20:30:13 +0100 Subject: [PATCH 197/499] FOGL-7503: JSON string fix for special chars (#1011) FOGL-7503: JSON string fix for special chars Unescape strings after getting value Unit tests updated Fix for update in Postgres plugin --- C/common/config_category.cpp | 23 +++++++++++-------- C/plugins/storage/postgres/connection.cpp | 7 +++++- tests/unit/C/common/test_config_category.cpp | 14 +++++++++++ .../C/common/test_default_config_category.cpp | 16 +++++++++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/C/common/config_category.cpp b/C/common/config_category.cpp index a3dffe5e10..9746d4d70c 100644 --- a/C/common/config_category.cpp +++ b/C/common/config_category.cpp @@ -1152,13 +1152,14 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, // Item "value" is just a string else if (item.HasMember("value") && item["value"].IsString()) { + // Get content of script type item as is + rapidjson::StringBuffer strbuf; + rapidjson::Writer writer(strbuf); + item["value"].Accept(writer); + if (m_itemType == ScriptItem || m_itemType == CodeItem) { - // Get content of script type item as is - rapidjson::StringBuffer strbuf; - rapidjson::Writer writer(strbuf); - item["value"].Accept(writer); m_value = strbuf.GetString(); if (m_value.empty()) { @@ -1167,7 +1168,8 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, } else { - m_value = item["value"].GetString(); + m_value = JSONunescape(strbuf.GetString()); + if (m_options.size() == 0) m_itemType = StringItem; else @@ -1255,13 +1257,14 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, // Item "default" is just a string else if (item.HasMember("default") && item["default"].IsString()) { + // Get content of script type item as is + rapidjson::StringBuffer strbuf; + rapidjson::Writer writer(strbuf); + item["default"].Accept(writer); if (m_itemType == ScriptItem || m_itemType == CodeItem) { - // Get content of script type item as is - rapidjson::StringBuffer strbuf; - rapidjson::Writer writer(strbuf); - item["default"].Accept(writer); + m_default = strbuf.GetString(); if (m_default.empty()) { m_default = "\"\""; @@ -1269,7 +1272,7 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, } else { - m_default = item["default"].GetString(); + m_default = JSONunescape(strbuf.GetString()); if (m_options.size() == 0) m_itemType = StringItem; else diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index baa6656e93..80c84b4291 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -28,6 +28,8 @@ #include #include +#include "json_utils.h" + #include #include #include @@ -1234,8 +1236,11 @@ SQLBuffer sql; } else { + StringBuffer buffer; + Writer writer(buffer); + value.Accept(writer); sql.append("'\""); - sql.append(escape_double_quotes(escape(str))); + sql.append(escape_double_quotes(escape(JSONunescape(buffer.GetString())))); sql.append("\"'"); } } diff --git a/tests/unit/C/common/test_config_category.cpp b/tests/unit/C/common/test_config_category.cpp index 8ccda4287b..ed582bce8c 100644 --- a/tests/unit/C/common/test_config_category.cpp +++ b/tests/unit/C/common/test_config_category.cpp @@ -47,6 +47,8 @@ const char *myCategory_quoted = "{\"description\": {" "\"default\": {\"first\" : \"Fledge\", \"second\" : \"json\" }," "\"description\": \"A JSON configuration parameter\"}}"; +const char *myCategory_quotedSpecial = R"QQ({"description": { "value": "The \"Fledge\" admini\\strative API", "type": "string", "default": "The \"Fledge\" administra\tive API", "description": "The description of this \"Fledge\" service"}, "name": { "value": "\"Fledge\"", "type": "string", "default": "\"Fledge\"", "description": "The name of this \"Fledge\" service"}, "complex": { "value": { "first" : "Fledge", "second" : "json" }, "type": "json", "default": {"first" : "Fledge", "second" : "json" }, "description": "A JSON configuration parameter"} })QQ"; + const char *myCategoryDisplayName = "{\"description\": {" "\"value\": \"The Fledge administrative API\"," "\"type\": \"string\"," @@ -320,6 +322,8 @@ const char *optionals = "\"order\": \"10\"} " "}"; +const char *json_quotedSpecial = R"QS({ "key" : "test \"a\"", "description" : "Test \"description\"", "value" : {"description" : { "description" : "The description of this \"Fledge\" service", "type" : "string", "value" : "The \"Fledge\" admini\\strative API", "default" : "The \"Fledge\" administra\tive API" }, "name" : { "description" : "The name of this \"Fledge\" service", "type" : "string", "value" : "\"Fledge\"", "default" : "\"Fledge\"" }, "complex" : { "description" : "A JSON configuration parameter", "type" : "json", "value" : {"first":"Fledge","second":"json"}, "default" : {"first":"Fledge","second":"json"} }} })QS"; + TEST(CategoriesTest, Count) { ConfigCategories confCategories(categories); @@ -640,3 +644,13 @@ TEST(CategoryTest, optionalItems) ASSERT_EQ(0, category.getItemAttribute("item1", ConfigCategory::DISPLAY_NAME_ATTR).compare("Item1")); } +/** + * Special quotes for \\s and \\t + */ + +TEST(CategoryTestQuoted, toJSONQuotedSpecial) +{ + ConfigCategory confCategory("test \"a\"", myCategory_quotedSpecial); + confCategory.setDescription("Test \"description\""); + ASSERT_EQ(0, confCategory.toJSON().compare(json_quotedSpecial)); +} diff --git a/tests/unit/C/common/test_default_config_category.cpp b/tests/unit/C/common/test_default_config_category.cpp index cb54dd6450..bc3caba683 100644 --- a/tests/unit/C/common/test_default_config_category.cpp +++ b/tests/unit/C/common/test_default_config_category.cpp @@ -46,6 +46,9 @@ const char *default_myCategory_quoted = "{\"description\": {" "\"value\": {\"first\" : \"Fledge\", \"second\" : \"json\" }," "\"default\": {\"first\" : \"Fledge\", \"second\" : \"json\" }," "\"description\": \"A JSON configuration parameter\"}}"; + +const char *default_myCategory_quotedSpecial = R"DQS({ "description": { "type": "string", "value": "The \"Fledge\" administra\tive API", "default": "The \"Fledge\" admini\\strative API", "description": "The description of this \"Fledge\" service"}, "name": { "type": "string", "value": "\"Fledge\"", "default": "\"Fledge\"", "description": "The name of this \"Fledge\" service"}, "complex": { "type": "json", "value": {"first" : "Fledge", "second" : "json" }, "default": {"first" : "Fledge", "second" : "json" }, "description": "A JSON configuration parameter"}})DQS"; + /** * The JSON output from DefaulltCategory::toJSON has "default" values olny */ @@ -141,6 +144,8 @@ const char *myDefCategoryRemoveItems = "{" \ "}"; +const char *default_json_quotedSpecial = R"SDQ({ "key" : "test \"a\"", "description" : "Test \"description\"", "value" : {"description" : { "description" : "The description of this \"Fledge\" service", "type" : "string", "default" : "The \"Fledge\" admini\\strative API" }, "name" : { "description" : "The name of this \"Fledge\" service", "type" : "string", "default" : "\"Fledge\"" }, "complex" : { "description" : "A JSON configuration parameter", "type" : "json", "default" : "{\"first\":\"Fledge\",\"second\":\"json\"}" }} })SDQ"; + TEST(DefaultCategoriesTest, Count) { ConfigCategories confCategories(default_categories); @@ -325,4 +330,15 @@ TEST(DefaultCategoryTest, removeItemsType) } +/** + * Test special quoted chars + */ + +TEST(DefaultCategoryTestQuoted, toJSONQuotedSpecial) +{ + DefaultConfigCategory confCategory("test \"a\"", default_myCategory_quotedSpecial); + confCategory.setDescription("Test \"description\""); + // Only "default" value in the output + ASSERT_EQ(0, confCategory.toJSON().compare(default_json_quotedSpecial)); +} From cee31d8f42e3d9990175c8b82cd8664371d140f7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 20 Mar 2023 11:52:25 +0530 Subject: [PATCH 198/499] audit API system tests updated for postgres storage engine Signed-off-by: ashish-jabble --- tests/system/python/api/test_audit.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/system/python/api/test_audit.py b/tests/system/python/api/test_audit.py index 630859148e..0a324fe2b3 100644 --- a/tests/system/python/api/test_audit.py +++ b/tests/system/python/api/test_audit.py @@ -86,7 +86,6 @@ def test_default_get_audit(self, fledge_url, wait_time, request_params, total_co jdoc = json.loads(r) elems = jdoc['audit'] assert len(jdoc), "No data found" - print(jdoc['totalCount'], len(elems)) assert total_count == jdoc['totalCount'] assert audit_count == len(elems) @@ -125,12 +124,12 @@ def test_create_nonexistent_log_code_audit_entry(self, fledge_url, payload, stor if storage_plugin == 'sqlite': pytest.skip('TODO: FOGL-2124 Enable foreign key constraint in SQLite') + msg = "Audit entry cannot be logged." conn = http.client.HTTPConnection(fledge_url) conn.request('POST', '/fledge/audit', body=json.dumps(payload)) r = conn.getresponse() assert 400 == r.status - assert 'Bad Request' in r.reason + assert msg in r.reason r = r.read().decode() jdoc = json.loads(r) - print(jdoc) - assert "Audit entry cannot be logged" in jdoc['message'] + assert msg in jdoc['message'] From eb4157a4d7c20a7a9485fdd3118bbc5f32e36ae5 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 20 Mar 2023 17:40:47 +0530 Subject: [PATCH 199/499] Additional query parameter is added in asset graph API Signed-off-by: ashish-jabble --- python/fledge/services/core/api/browser.py | 28 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/api/browser.py b/python/fledge/services/core/api/browser.py index 56f49ffa0d..b416c02084 100644 --- a/python/fledge/services/core/api/browser.py +++ b/python/fledge/services/core/api/browser.py @@ -177,11 +177,20 @@ async def asset(request): curl -sX GET "http://localhost:8081/fledge/asset/fogbench_humidity?limit=1&skip=1&order=desc curl -sX GET http://localhost:8081/fledge/asset/fogbench_humidity?seconds=60 curl -sX GET http://localhost:8081/fledge/asset/fogbench_humidity?seconds=60&previous=600 + curl -sX GET "http://localhost:8081/fledge/asset/fogbench_humidity?additional=sinusoid,random&seconds=600" """ asset_code = request.match_info.get('asset_code', '') - _select = PayloadBuilder().SELECT(("reading", "user_ts")).ALIAS("return", ("user_ts", "timestamp")).chain_payload() - - _where = PayloadBuilder(_select).WHERE(["asset_code", "=", asset_code]).chain_payload() + # A comma separated list of additional assets to generate the readings to display multiple graphs in GUI + if 'additional' in request.query: + additional_assets = "{},{}".format(asset_code, request.query['additional']) + additional_asset_codes = additional_assets.split(',') + _select = PayloadBuilder().SELECT(("asset_code", "reading", "user_ts")).ALIAS( + "return", ("user_ts", "timestamp")).chain_payload() + _where = PayloadBuilder(_select).WHERE(["asset_code", "in", additional_asset_codes]).chain_payload() + else: + _select = PayloadBuilder().SELECT(("reading", "user_ts")).ALIAS( + "return", ("user_ts", "timestamp")).chain_payload() + _where = PayloadBuilder(_select).WHERE(["asset_code", "=", asset_code]).chain_payload() if 'previous' in request.query and ( 'seconds' in request.query or 'minutes' in request.query or 'hours' in request.query): _and_where = where_window(request, _where) @@ -213,7 +222,18 @@ async def asset(request): for item_name2, item_val2 in item_val.items(): if isinstance(item_val2, str) and item_val2.startswith(tuple(DATAPOINT_TYPES)): data[item_name][item_name2] = IMAGE_PLACEHOLDER if is_image_excluded(request) else item_val2 - response = rows + # Group the readings value by asset_code in case of additional multiple assets + if 'additional' in request.query: + response_by_asset_code = {} + for aacl in additional_asset_codes: + response_by_asset_code[aacl] = [] + for r in rows: + if r['asset_code'] in additional_asset_codes: + response_by_asset_code[r['asset_code']].extend([r]) + r.pop('asset_code') + response = response_by_asset_code + else: + response = rows except KeyError: msg = results['message'] raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) From ffe1eb20b04c0925aa5100e3575d211d1b84b853 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 21 Mar 2023 13:26:56 +0530 Subject: [PATCH 200/499] package name handling in Plugins PUT API Signed-off-by: ashish-jabble --- .../services/core/api/plugins/update.py | 83 ++++++++++--------- .../services/core/api/plugins/test_update.py | 69 +++++++-------- 2 files changed, 73 insertions(+), 79 deletions(-) diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index c88919aa68..b68db2ee5a 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -19,8 +19,7 @@ from fledge.common.plugin_discovery import PluginDiscovery from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.payload_builder import PayloadBuilder -from fledge.services.core import connect -from fledge.services.core import server +from fledge.services.core import connect, server from fledge.services.core.api.plugins import common @@ -60,11 +59,23 @@ async def update_plugin(request: web.Request) -> web.Response: else: installed_dir_name = _type + # Check requested plugin name is installed or not + installed_plugins = PluginDiscovery.get_plugins_installed(installed_dir_name, False) + plugin_info = [(_plugin["name"], _plugin["packageName"]) for _plugin in installed_plugins] + package_name = "fledge-{}-{}".format(_type, name.lower().replace('_', '-')) + plugin_found = False + for p in plugin_info: + if p[0] == name: + package_name = p[1] + plugin_found = True + break + if not plugin_found: + raise KeyError("{} plugin is not yet installed. So update is not possible.".format(name)) + # Check Pre-conditions from Packages table # if status is -1 (Already in progress) then return as rejected request result_payload = {} action = 'update' - package_name = "fledge-{}-{}".format(_type, name.lower().replace('_', '-')) storage_client = connect.get_storage_async() select_payload = PayloadBuilder().SELECT("status").WHERE(['action', '=', action]).AND_WHERE( ['name', '=', package_name]).payload() @@ -73,21 +84,15 @@ async def update_plugin(request: web.Request) -> web.Response: if response: exit_code = response[0]['status'] if exit_code == -1: - msg = "{} package {} already in progress".format(package_name, action) + msg = "{} package {} already in progress.".format(package_name, action) return web.HTTPTooManyRequests(reason=msg, body=json.dumps({"message": msg})) # Remove old entry from table for other cases delete_payload = PayloadBuilder().WHERE(['action', '=', action]).AND_WHERE( ['name', '=', package_name]).payload() await storage_client.delete_from_tbl("packages", delete_payload) - # Check requested plugin name is installed or not - installed_plugins = PluginDiscovery.get_plugins_installed(installed_dir_name, False) - installed_plugin_name = [p_name["name"] for p_name in installed_plugins] - if name not in installed_plugin_name: - raise KeyError("{} plugin is not yet installed. So update is not possible.".format(name)) - - sch_list = [] - notification_list = [] + schedules = [] + notifications = [] if _type in ['notify', 'rule']: # Check Notification service is enabled or not payload = PayloadBuilder().SELECT("id", "enabled", "schedule_name").WHERE(['process_name', '=', @@ -109,8 +114,10 @@ async def update_plugin(request: web.Request) -> web.Response: _logger.warning("Disabling {} notification instance, as {} {} plugin is being updated...".format( notification_name, name, _type)) await config_mgr.set_category_item_value_entry(notification_name, "enable", "false") - notification_list.append(notification_name) + notifications.append(notification_name) else: + # FIXME: if any south/north service or task doesnot have tracked by Fledge; + # then we need to handle the case to disable the service or task if enabled # Tracked plugins from asset tracker tracked_plugins = await _get_plugin_and_sch_name_from_asset_tracker(_type) filters_used_by = [] @@ -131,7 +138,7 @@ async def update_plugin(request: web.Request) -> web.Response: if status: _logger.warning("Disabling {} {} instance, as {} plugin is being updated...".format( p['service'], _type, name)) - sch_list.append(sch_info[0]['id']) + schedules.append(sch_info[0]['id']) # Insert record into Packages table insert_payload = PayloadBuilder().INSERT(id=str(uuid.uuid4()), name=package_name, action=action, status=-1, log_file_uri="").payload() @@ -145,11 +152,12 @@ async def update_plugin(request: web.Request) -> web.Response: if response: pn = "{}-{}".format(action, name) uid = response[0]['id'] - p = multiprocessing.Process(name=pn, target=do_update, args=(server.Server.is_rest_server_http_enabled, - server.Server._host, - server.Server.core_management_port, - storage_client, _type, name, uid, sch_list, - notification_list)) + p = multiprocessing.Process(name=pn, + target=do_update, + args=(server.Server.is_rest_server_http_enabled, + server.Server._host, server.Server.core_management_port, + storage_client, installed_dir_name, _type, name, package_name, uid, + schedules, notifications)) p.daemon = True p.start() msg = "{} {} started.".format(package_name, action) @@ -173,10 +181,13 @@ async def update_plugin(request: web.Request) -> web.Response: async def _get_plugin_and_sch_name_from_asset_tracker(_type: str) -> list: if _type == "south": event_name = "Ingest" - elif _type == 'filter': + elif _type == "filter": event_name = "Filter" - else: + elif _type == "north": event_name = "Egress" + else: + # Return empty if _type is different + return [] storage_client = connect.get_storage_async() payload = PayloadBuilder().SELECT("plugin", "service").WHERE(['event', '=', event_name]).payload() result = await storage_client.query_tbl_with_payload('asset_tracker', payload) @@ -207,16 +218,8 @@ async def _put_schedule(protocol: str, host: str, port: int, sch_id: uuid, is_en _logger.debug("PUT Schedule response: %s", response) -def _update_repo_sources_and_plugin(_type: str, name: str) -> tuple: - # Below check is needed for python plugins - # For Example: installed_plugin_dir=wind_turbine; package_name=wind-turbine - name = name.replace("_", "-") - - # For endpoint curl -X GET http://localhost:8081/fledge/plugins/available we used - # sudo apt list command internal so package name always returns in lowercase, - # irrespective of package name defined in the configured repo. - name = "fledge-{}-{}".format(_type, name.lower()) - stdout_file_path = common.create_log_file(action="update", plugin_name=name) +def _update_repo_sources_and_plugin(pkg_name: str) -> tuple: + stdout_file_path = common.create_log_file(action="update", plugin_name=pkg_name) pkg_mgt = 'apt' cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) if utils.is_redhat_based(): @@ -225,7 +228,7 @@ def _update_repo_sources_and_plugin(_type: str, name: str) -> tuple: ret_code = os.system(cmd) # sudo apt/yum -y install only happens when update is without any error if ret_code == 0: - cmd = "sudo {} -y install {} >> {} 2>&1".format(pkg_mgt, name, stdout_file_path) + cmd = "sudo {} -y install {} >> {} 2>&1".format(pkg_mgt, pkg_name, stdout_file_path) ret_code = os.system(cmd) # relative log file link @@ -233,11 +236,11 @@ def _update_repo_sources_and_plugin(_type: str, name: str) -> tuple: return ret_code, link -def do_update(http_enabled: bool, host: str, port: int, storage: connect, _type: str, name: str, uid: str, - schedules: list, notifications: list) -> None: - _logger.info("{} plugin update started...".format(name)) +def do_update(http_enabled: bool, host: str, port: int, storage: connect, dir_name: str, _type: str, plugin_name: str, + pkg_name: str, uid: str, schedules: list, notifications: list) -> None: + _logger.info("{} package update started...".format(pkg_name)) protocol = "HTTP" if http_enabled else "HTTPS" - code, link = _update_repo_sources_and_plugin(_type, name) + code, link = _update_repo_sources_and_plugin(pkg_name) # Update record in Packages table payload = PayloadBuilder().SET(status=code, log_file_uri=link).WHERE(['id', '=', uid]).payload() @@ -247,9 +250,13 @@ def do_update(http_enabled: bool, host: str, port: int, storage: connect, _type: if code == 0: # Audit info audit = AuditLogger(storage) - audit_detail = {'packageName': "fledge-{}-{}".format(_type, name.replace("_", "-"))} + installed_plugins = PluginDiscovery.get_plugins_installed(dir_name, False) + version = [p["version"] for p in installed_plugins if p['name'] == plugin_name] + audit_detail = {'packageName': pkg_name} + if version: + audit_detail['version'] = version[0] loop.run_until_complete(audit.information('PKGUP', audit_detail)) - _logger.info('{} plugin updated successfully'.format(name)) + _logger.info('{} package updated successfully.'.format(pkg_name)) # Restart the services which were disabled before plugin update for sch in schedules: diff --git a/tests/unit/python/fledge/services/core/api/plugins/test_update.py b/tests/unit/python/fledge/services/core/api/plugins/test_update.py index 301058ef64..cdaac1b655 100644 --- a/tests/unit/python/fledge/services/core/api/plugins/test_update.py +++ b/tests/unit/python/fledge/services/core/api/plugins/test_update.py @@ -70,7 +70,7 @@ async def async_mock(return_value): "status": -1, "log_file_uri": "" }]} - msg = '{} package update already in progress'.format(pkg_name) + msg = '{} package update already in progress.'.format(pkg_name) storage_client_mock = MagicMock(StorageClientAsync) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -78,57 +78,44 @@ async def async_mock(return_value): _rv = await async_mock(select_row_resp) else: _rv = asyncio.ensure_future(async_mock(select_row_resp)) - - with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'query_tbl_with_payload', - return_value=_rv) as query_tbl_patch: - resp = await client.put('/fledge/plugins/{}/{}/update'.format(_type, plugin_installed_dirname), - data=None) - assert 429 == resp.status - assert msg == resp.reason - r = await resp.text() - actual = json.loads(r) - assert {'message': msg} == actual - args, kwargs = query_tbl_patch.call_args_list[0] - assert 'packages' == args[0] - assert payload == json.loads(args[1]) + + plugin_installed = [{"name": plugin_installed_dirname, "type": _type, "description": "{} plugin".format(_type), + "version": "2.1.0", "installedDirectory": "{}/{}".format(_type, plugin_installed_dirname), + "packageName": pkg_name}] + with patch.object(PluginDiscovery, 'get_plugins_installed', + return_value=plugin_installed) as plugin_installed_patch: + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl_with_payload', + return_value=_rv) as query_tbl_patch: + resp = await client.put('/fledge/plugins/{}/{}/update'.format(_type, plugin_installed_dirname), + data=None) + assert 429 == resp.status + assert msg == resp.reason + r = await resp.text() + actual = json.loads(r) + assert {'message': msg} == actual + args, kwargs = query_tbl_patch.call_args_list[0] + assert 'packages' == args[0] + assert payload == json.loads(args[1]) + plugin_installed_patch.assert_called_once_with(_type, False) @pytest.mark.parametrize("_type, plugin_installed_dirname", [ ('south', 'Random'), ('north', 'http_north') ]) async def test_plugin_not_found(self, client, _type, plugin_installed_dirname): - async def async_mock(return_value): - return return_value - plugin_name = 'sinusoid' pkg_name = "fledge-{}-{}".format(_type, plugin_installed_dirname.lower().replace("_", "-")) - payload = {"return": ["status"], "where": {"column": "action", "condition": "=", "value": "update", - "and": {"column": "name", "condition": "=", "value": pkg_name}}} plugin_installed = [{"name": plugin_name, "type": _type, "description": "{} plugin".format(_type), "version": "1.8.1", "installedDirectory": "{}/{}".format(_type, plugin_name), "packageName": pkg_name}] - storage_client_mock = MagicMock(StorageClientAsync) - - # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. - if sys.version_info.major == 3 and sys.version_info.minor >= 8: - _rv = await async_mock({'count': 0, 'rows': []}) - else: - _rv = asyncio.ensure_future(async_mock({'count': 0, 'rows': []})) - - with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=_rv) as query_tbl_patch: - with patch.object(PluginDiscovery, 'get_plugins_installed', return_value=plugin_installed - ) as plugin_installed_patch: - resp = await client.put('/fledge/plugins/{}/{}/update'.format(_type, plugin_installed_dirname), - data=None) - assert 404 == resp.status - assert "'{} plugin is not yet installed. So update is not possible.'".format( - plugin_installed_dirname) == resp.reason - plugin_installed_patch.assert_called_once_with(_type, False) - args, kwargs = query_tbl_patch.call_args_list[0] - assert 'packages' == args[0] - assert payload == json.loads(args[1]) + with patch.object(PluginDiscovery, 'get_plugins_installed', + return_value=plugin_installed) as plugin_installed_patch: + resp = await client.put('/fledge/plugins/{}/{}/update'.format(_type, plugin_installed_dirname), data=None) + assert 404 == resp.status + assert "'{} plugin is not yet installed. So update is not possible.'".format( + plugin_installed_dirname) == resp.reason + plugin_installed_patch.assert_called_once_with(_type, False) @pytest.mark.parametrize("_type, plugin_installed_dirname", [ ('south', 'Random'), From c2b599ed247266471c54cfdcc400948ad9c8a0c4 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 21 Mar 2023 16:07:56 +0530 Subject: [PATCH 201/499] PackageName handling added in Plugins DELETE API endpoint Signed-off-by: ashish-jabble --- .../services/core/api/plugins/remove.py | 86 +++++++++++-------- .../services/core/api/plugins/test_remove.py | 40 +++++---- 2 files changed, 72 insertions(+), 54 deletions(-) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index b2b972cd9b..4db55f1b7d 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -25,8 +25,8 @@ from fledge.services.core.api.plugins.exceptions import * -__author__ = "Rajesh Kumar" -__copyright__ = "Copyright (c) 2020, Dianomic Systems Inc." +__author__ = "Rajesh Kumar, Ashish Jabble" +__copyright__ = "Copyright (c) 2020-2023, Dianomic Systems Inc." __license__ = "Apache 2.0" __version__ = "${VERSION}" @@ -62,6 +62,9 @@ async def remove_plugin(request: web.Request) -> web.Response: plugin_type = str(plugin_type).lower() if plugin_type not in valid_plugin_types: raise ValueError("Invalid plugin type. Please provide valid type: {}".format(valid_plugin_types)) + # only OMF is an inbuilt plugin + if name.lower() == 'omf': + raise ValueError("Cannot delete an inbuilt {} plugin.".format(name.upper())) if plugin_type == 'notify': installed_dir_name = 'notificationDelivery' elif plugin_type == 'rule': @@ -69,30 +72,40 @@ async def remove_plugin(request: web.Request) -> web.Response: else: installed_dir_name = plugin_type result_payload = {} - installed_plugin = PluginDiscovery.get_plugins_installed(installed_dir_name, False) - if name not in [plugin['name'] for plugin in installed_plugin]: - raise KeyError("Invalid plugin name {} or plugin is not installed".format(name)) + installed_plugins = PluginDiscovery.get_plugins_installed(installed_dir_name, False) + plugin_info = [(_plugin["name"], _plugin["packageName"], _plugin["version"]) for _plugin in installed_plugins] + package_name = "fledge-{}-{}".format(plugin_type, name.lower().replace("_", "-")) + plugin_found = False + plugin_version = None + for p in plugin_info: + if p[0] == name: + package_name = p[1] + plugin_version = p[2] + plugin_found = True + break + if not plugin_found: + raise KeyError("Invalid plugin name {} or plugin is not installed.".format(name)) + if plugin_type in ['notify', 'rule']: notification_instances_plugin_used_in = await _check_plugin_usage_in_notification_instances(name) if notification_instances_plugin_used_in: - err_msg = "{} cannot be removed. This is being used by {} instances".format( + err_msg = "{} cannot be removed. This is being used by {} instances.".format( name, notification_instances_plugin_used_in) - _logger.error(err_msg) + _logger.warning(err_msg) raise RuntimeError(err_msg) else: get_tracked_plugins = await _check_plugin_usage(plugin_type, name) if get_tracked_plugins: - e = "{} cannot be removed. This is being used by {} instances".\ + e = "{} cannot be removed. This is being used by {} instances.".\ format(name, get_tracked_plugins[0]['service_list']) - _logger.error(e) + _logger.warning(e) raise RuntimeError(e) else: _logger.info("No entry found for {name} plugin in asset tracker; or " - "{name} plugin may have been added in disabled state & never used".format(name=name)) + "{name} plugin may have been added in disabled state & never used.".format(name=name)) # Check Pre-conditions from Packages table # if status is -1 (Already in progress) then return as rejected request action = 'purge' - package_name = "fledge-{}-{}".format(plugin_type, name.lower().replace("_", "-")) storage = connect.get_storage_async() select_payload = PayloadBuilder().SELECT("status").WHERE(['action', '=', action]).AND_WHERE( ['name', '=', package_name]).payload() @@ -101,7 +114,7 @@ async def remove_plugin(request: web.Request) -> web.Response: if response: exit_code = response[0]['status'] if exit_code == -1: - msg = "{} package purge already in progress".format(package_name) + msg = "{} package purge already in progress.".format(package_name) return web.HTTPTooManyRequests(reason=msg, body=json.dumps({"message": msg})) # Remove old entry from table for other cases delete_payload = PayloadBuilder().WHERE(['action', '=', action]).AND_WHERE( @@ -121,10 +134,13 @@ async def remove_plugin(request: web.Request) -> web.Response: if response: pn = "{}-{}".format(action, name) uid = response[0]['id'] - p = multiprocessing.Process(name=pn, target=purge_plugin, args=(plugin_type, name, uid, storage)) + p = multiprocessing.Process(name=pn, + target=purge_plugin, + args=(plugin_type, name, package_name, plugin_version, uid, storage) + ) p.daemon = True p.start() - msg = "{} plugin purge started.".format(name) + msg = "{} plugin remove started.".format(name) status_link = "fledge/package/{}/status?id={}".format(action, uid) result_payload = {"message": msg, "id": uid, "statusLink": status_link} else: @@ -133,11 +149,13 @@ async def remove_plugin(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=str(err), body=json.dumps({'message': str(err)})) except KeyError as err: raise web.HTTPNotFound(reason=str(err), body=json.dumps({'message': str(err)})) - except StorageServerError as err: - msg = str(err) + except StorageServerError as e: + msg = e.error raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex), body=json.dumps({'message': str(ex)})) + msg = str(ex) + _logger.error("Failed to remove {} plugin. {}".format(name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) @@ -227,45 +245,38 @@ async def _put_refresh_cache(protocol: str, host: int, port: int) -> None: _logger.debug("PUT Refresh Cache response: %s", response) -def purge_plugin(plugin_type: str, name: str, uid: uuid, storage: connect) -> tuple: +def purge_plugin(plugin_type: str, plugin_name: str, pkg_name: str, version: str, uid: uuid, storage: connect) -> tuple: from fledge.services.core.server import Server - # FIXME: non-package removal - _logger.info("{} plugin purge started...".format(name)) + _logger.info("{} plugin remove started...".format(pkg_name)) is_package = True stdout_file_path = '' - original_name = name - # Special case handling - installed directory name Vs package name - # For example: Plugins like http_south Vs http-south - name = name.replace('_', '-').lower() - plugin_name = 'fledge-{}-{}'.format(plugin_type, name) - try: if utils.is_redhat_based(): rpm_list = os.popen('rpm -qa | grep fledge*').read() _logger.debug("rpm list : {}".format(rpm_list)) if len(rpm_list): - f = rpm_list.find(plugin_name) + f = rpm_list.find(pkg_name) if f == -1: raise KeyError else: raise KeyError - stdout_file_path = common.create_log_file(action='remove', plugin_name=plugin_name) + stdout_file_path = common.create_log_file(action='remove', plugin_name=pkg_name) link = "log/" + stdout_file_path.split("/")[-1] - cmd = "sudo yum -y remove {} > {} 2>&1".format(plugin_name, stdout_file_path) + cmd = "sudo yum -y remove {} > {} 2>&1".format(pkg_name, stdout_file_path) else: dpkg_list = os.popen('dpkg --list "fledge*" 2>/dev/null') ls_output = dpkg_list.read() _logger.debug("dpkg list output: {}".format(ls_output)) if len(ls_output): - f = ls_output.find(plugin_name) + f = ls_output.find(pkg_name) if f == -1: raise KeyError else: raise KeyError - stdout_file_path = common.create_log_file(action='remove', plugin_name=plugin_name) + stdout_file_path = common.create_log_file(action='remove', plugin_name=pkg_name) link = "log/" + stdout_file_path.split("/")[-1] - cmd = "sudo apt -y purge {} > {} 2>&1".format(plugin_name, stdout_file_path) + cmd = "sudo apt -y purge {} > {} 2>&1".format(pkg_name, stdout_file_path) code = os.system(cmd) # Update record in Packages table @@ -279,9 +290,9 @@ def purge_plugin(plugin_type: str, name: str, uid: uuid, storage: connect) -> tu Server._host, Server.core_management_port)) # Audit info audit = AuditLogger(storage) - audit_detail = {'package_name': "fledge-{}-{}".format(plugin_type, name)} + audit_detail = {'package_name': pkg_name, 'version': version} loop.run_until_complete(audit.information('PKGRM', audit_detail)) - _logger.info('{} plugin purged successfully'.format(name)) + _logger.info('{} plugin removed successfully.'.format(pkg_name)) except KeyError: # This case is for non-package installation - python plugin path will be tried first and then C _logger.info("Trying removal of manually installed plugin...") @@ -289,16 +300,17 @@ def purge_plugin(plugin_type: str, name: str, uid: uuid, storage: connect) -> tu if plugin_type in ['notify', 'rule']: plugin_type = 'notificationDelivery' if plugin_type == 'notify' else 'notificationRule' try: - path = PYTHON_PLUGIN_PATH+'{}/{}'.format(plugin_type, original_name) + path = PYTHON_PLUGIN_PATH+'{}/{}'.format(plugin_type, plugin_name) if not os.path.isdir(path): - path = C_PLUGINS_PATH + '{}/{}'.format(plugin_type, original_name) + path = C_PLUGINS_PATH + '{}/{}'.format(plugin_type, plugin_name) rm_cmd = 'rm -rv {}'.format(path) if os.path.exists("{}/bin".format(_FLEDGE_ROOT)) and os.path.exists("{}/bin/fledge".format(_FLEDGE_ROOT)): rm_cmd = 'sudo rm -rv {}'.format(path) code = os.system(rm_cmd) if code != 0: - raise OSError("While deleting, invalid plugin path found for {}".format(original_name)) + raise OSError("While deleting, invalid plugin path found for {}".format(plugin_name)) except Exception as ex: code = 1 _logger.error("Error in removing plugin: {}".format(str(ex))) + _logger.info('{} plugin removed successfully.'.format(plugin_name)) return code, stdout_file_path, is_package diff --git a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py index 3068360b01..b89037efda 100644 --- a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py +++ b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py @@ -48,6 +48,12 @@ async def test_bad_type_plugin(self, client, _type): assert "Invalid plugin type. Please provide valid type: ['north', 'south', 'filter', 'notify', 'rule']" == \ resp.reason + @pytest.mark.parametrize("name", ["OMF", "omf", "Omf"]) + async def test_bad_update_of_inbuilt_plugin(self, client, name): + resp = await client.delete('/fledge/plugins/north/{}'.format(name), data=None) + assert 400 == resp.status + assert "Cannot delete an inbuilt OMF plugin." == resp.reason + @pytest.mark.parametrize("name", [ "http-south", "random" @@ -67,7 +73,7 @@ async def test_bad_name_plugin(self, client, name): ) as plugin_installed_patch: resp = await client.delete('/fledge/plugins/south/{}'.format(name), data=None) assert 404 == resp.status - expected_msg = "'Invalid plugin name {} or plugin is not installed'".format(name) + expected_msg = "'Invalid plugin name {} or plugin is not installed.'".format(name) assert expected_msg == resp.reason result = await resp.text() response = json.loads(result) @@ -98,16 +104,16 @@ async def async_mock(return_value): with patch.object(PluginDiscovery, 'get_plugins_installed', return_value=plugin_installed ) as plugin_installed_patch: with patch.object(plugins_remove, '_check_plugin_usage', return_value=_rv) as plugin_usage_patch: - with patch.object(plugins_remove._logger, "error") as log_err_patch: + with patch.object(plugins_remove._logger, "warning") as patch_logger: resp = await client.delete('/fledge/plugins/{}/{}'.format(_type, name), data=None) assert 400 == resp.status - expected_msg = "{} cannot be removed. This is being used by {} instances".format(name, svc_list) + expected_msg = "{} cannot be removed. This is being used by {} instances.".format(name, svc_list) assert expected_msg == resp.reason result = await resp.text() response = json.loads(result) assert {'message': expected_msg} == response - assert 1 == log_err_patch.call_count - log_err_patch.assert_called_once_with(expected_msg) + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with(expected_msg) plugin_usage_patch.assert_called_once_with(_type, name) plugin_installed_patch.assert_called_once_with(_type, False) @@ -135,18 +141,18 @@ async def async_mock(return_value): with patch.object(PluginDiscovery, 'get_plugins_installed', return_value=plugin_installed ) as plugin_installed_patch: with patch.object(plugins_remove, '_check_plugin_usage_in_notification_instances', return_value=_rv) as plugin_usage_patch: - with patch.object(plugins_remove._logger, "error") as log_err_patch: + with patch.object(plugins_remove._logger, "warning") as patch_logger: resp = await client.delete('/fledge/plugins/{}/{}'.format(plugin_type, plugin_installed_dirname), data=None) assert 400 == resp.status - expected_msg = "{} cannot be removed. This is being used by {} instances".format( + expected_msg = "{} cannot be removed. This is being used by {} instances.".format( plugin_installed_dirname, notify_instances_list) assert expected_msg == resp.reason result = await resp.text() response = json.loads(result) assert {'message': expected_msg} == response - assert 1 == log_err_patch.call_count - log_err_patch.assert_called_once_with(expected_msg) + assert 1 == patch_logger.call_count + patch_logger.assert_called_once_with(expected_msg) plugin_usage_patch.assert_called_once_with(plugin_installed_dirname) plugin_installed_patch.assert_called_once_with(plugin_type_installed_dir, False) @@ -156,7 +162,7 @@ async def async_mock(return_value): _type = "south" name = 'http_south' - pkg_name = "fledge-south-http-south" + pkg_name = "fledge-south-http" payload = {"return": ["status"], "where": {"column": "action", "condition": "=", "value": "purge", "and": {"column": "name", "condition": "=", "value": pkg_name}}} select_row_resp = {'count': 1, 'rows': [{ @@ -166,14 +172,14 @@ async def async_mock(return_value): "status": -1, "log_file_uri": "" }]} - expected_msg = '{} package purge already in progress'.format(pkg_name) + expected_msg = '{} package purge already in progress.'.format(pkg_name) storage_client_mock = MagicMock(StorageClientAsync) plugin_installed = [{"name": "sinusoid", "type": _type, "description": "Sinusoid Poll Plugin", "version": "1.8.1", "installedDirectory": "{}/{}".format(_type, name), "packageName": "fledge-{}-sinusoid".format(_type)}, {"name": name, "type": _type, "description": "HTTP Listener South Plugin", "version": "1.8.1", "installedDirectory": "{}/{}".format(_type, name), - "packageName": "fledge-{}-{}".format(_type, name)} + "packageName": pkg_name} ] # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -203,7 +209,7 @@ async def async_mock(return_value): assert 1 == log_info_patch.call_count log_info_patch.assert_called_once_with( 'No entry found for http_south plugin in asset tracker; ' - 'or {} plugin may have been added in disabled state & never used'.format(name)) + 'or {} plugin may have been added in disabled state & never used.'.format(name)) plugin_usage_patch.assert_called_once_with(_type, name) plugin_installed_patch.assert_called_once_with(_type, False) @@ -214,7 +220,7 @@ async def async_mock(return_value): _type = "south" name = 'http_south' - pkg_name = "fledge-south-http-south" + pkg_name = "fledge-south-http" payload = {"return": ["status"], "where": {"column": "action", "condition": "=", "value": "purge", "and": {"column": "name", "condition": "=", "value": pkg_name}}} select_row_resp = {'count': 1, 'rows': [{ @@ -241,7 +247,7 @@ async def async_mock(return_value): "packageName": "fledge-{}-sinusoid".format(_type)}, {"name": name, "type": _type, "description": "HTTP Listener South Plugin", "version": "1.8.1", "installedDirectory": "{}/{}".format(_type, name), - "packageName": "fledge-{}-{}".format(_type, name)} + "packageName": pkg_name} ] # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -276,7 +282,7 @@ async def async_mock(return_value): result = await resp.text() response = json.loads(result) assert 'id' in response - assert '{} plugin purge started.'.format(name) == response['message'] + assert '{} plugin remove started.'.format(name) == response['message'] assert response['statusLink'].startswith('fledge/package/purge/status?id=') args, kwargs = insert_tbl_patch.call_args_list[0] assert 'packages' == args[0] @@ -295,6 +301,6 @@ async def async_mock(return_value): assert 1 == log_info_patch.call_count log_info_patch.assert_called_once_with( 'No entry found for http_south plugin in asset tracker; ' - 'or {} plugin may have been added in disabled state & never used'.format(name)) + 'or {} plugin may have been added in disabled state & never used.'.format(name)) plugin_usage_patch.assert_called_once_with(_type, name) plugin_installed_patch.assert_called_once_with(_type, False) From ea7dc1541daae776ba32c145763f879aa813ce37 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 21 Mar 2023 16:08:20 +0530 Subject: [PATCH 202/499] restricted inbuilt plugin update & other fixes Signed-off-by: ashish-jabble --- python/fledge/services/core/api/plugins/update.py | 13 +++++++++---- .../fledge/services/core/api/plugins/test_update.py | 6 ++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index b68db2ee5a..84151c97af 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -24,7 +24,7 @@ __author__ = "Ashish Jabble" -__copyright__ = "Copyright (c) 2019, Dianomic Systems Inc." +__copyright__ = "Copyright (c) 2019-2023, Dianomic Systems Inc." __license__ = "Apache 2.0" __version__ = "${VERSION}" @@ -52,6 +52,9 @@ async def update_plugin(request: web.Request) -> web.Response: _type = _type.lower() if _type not in ['north', 'south', 'filter', 'notify', 'rule']: raise ValueError("Invalid plugin type. Must be one of 'south' , north', 'filter', 'notify' or 'rule'") + # only OMF is an inbuilt plugin + if name.lower() == 'omf': + raise ValueError("Cannot update an inbuilt {} plugin.".format(name.upper())) if _type == 'notify': installed_dir_name = 'notificationDelivery' elif _type == 'rule': @@ -169,11 +172,13 @@ async def update_plugin(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=str(ex)) except ValueError as ex: raise web.HTTPBadRequest(reason=str(ex)) - except StorageServerError as err: - msg = str(err) + except StorageServerError as e: + msg = e.error raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: - raise web.HTTPInternalServerError(reason=str(ex)) + msg = str(ex) + _logger.error("Failed to update {} plugin. {}".format(name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) diff --git a/tests/unit/python/fledge/services/core/api/plugins/test_update.py b/tests/unit/python/fledge/services/core/api/plugins/test_update.py index cdaac1b655..d0576aacaf 100644 --- a/tests/unit/python/fledge/services/core/api/plugins/test_update.py +++ b/tests/unit/python/fledge/services/core/api/plugins/test_update.py @@ -51,6 +51,12 @@ async def test_bad_type_plugin(self, client, param): assert 400 == resp.status assert "Invalid plugin type. Must be one of 'south' , north', 'filter', 'notify' or 'rule'" == resp.reason + @pytest.mark.parametrize("name", ["OMF", "omf", "Omf"]) + async def test_bad_update_of_inbuilt_plugin(self, client, name): + resp = await client.put('/fledge/plugins/north/{}/update'.format(name), data=None) + assert 400 == resp.status + assert "Cannot update an inbuilt OMF plugin." == resp.reason + @pytest.mark.parametrize("_type, plugin_installed_dirname", [ ('south', 'Random'), ('north', 'http_north') From e7fd794b9149748d4ce121abcb97be3cc5ddb51a Mon Sep 17 00:00:00 2001 From: nandan Date: Tue, 21 Mar 2023 17:51:05 +0530 Subject: [PATCH 203/499] FOGL-7562: Modified to get correct JSONtoDatapoints for nested objects Signed-off-by: nandan --- C/common/reading.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/C/common/reading.cpp b/C/common/reading.cpp index b1d730deb9..33db1a16d9 100755 --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -586,8 +586,8 @@ vector *values = new vector; else if (itr->value.IsObject()) { // Map objects as nested datapoints - vector *values = JSONtoDatapoints(itr->value); - DatapointValue dpv(values, true); + vector *nestedValues = JSONtoDatapoints(itr->value); + DatapointValue dpv(nestedValues, true); values->push_back(new Datapoint(name, dpv)); } } From 05e089396b88bf12a7f9761232b3e8c03505e558 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Tue, 21 Mar 2023 19:04:40 +0530 Subject: [PATCH 204/499] FOGL-6393 Create system tests with more than 200 assets (#1006) * Increase wait time Signed-off-by: Mohit Singh Tomar * Fixed typos for verifying data from PI Web API Signed-off-by: Mohit Singh Tomar * Added functionality for passing value of total number of asset on which test have to be executed. Signed-off-by: Mohit Singh Tomar * Feedback changes Signed-off-by: Mohit Singh Tomar * Feedback changes-2 Signed-off-by: Mohit Singh Tomar * Refactored code Signed-off-by: Mohit Singh Tomar * Added Todo for removing WAIT_FIX Signed-off-by: Mohit Singh Tomar * Added for check whether fledge is restarted or not * Updated m code for checking restart Signed-off-by: Mohit Singh Tomar * Updated code of wait-fix Signed-off-by: Mohit Singh Tomar * Feedback changes Signed-off-by: Mohit Singh Tomar * Added comments --------- Signed-off-by: Mohit Singh Tomar --- tests/system/python/conftest.py | 12 +- .../python/packages/test_multiple_assets.py | 176 ++++++++++-------- 2 files changed, 113 insertions(+), 75 deletions(-) diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index f26e1a5194..c71caf924f 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -679,6 +679,8 @@ def pytest_addoption(parser): help="use pip cache is requirement is available") parser.addoption("--wait-time", action="store", default=5, type=int, help="Generic wait time between processes to run") + parser.addoption("--wait-fix", action="store", default=0, type=int, + help="Extra wait time required for process to run") parser.addoption("--retries", action="store", default=3, type=int, help="Number of tries for polling") # TODO: Temporary fixture, to be used with value False for environments where PI Web API is not stable @@ -703,6 +705,7 @@ def pytest_addoption(parser): help="Name of the South Service") parser.addoption("--asset-name", action="store", default="SystemTest", help="Name of asset") + parser.addoption("--num-assets", action="store", default=300, type=int, help="Total No. of Assets to be created") # Filter Args parser.addoption("--filter-branch", action="store", default="develop", help="Filter plugin repo branch") @@ -805,6 +808,10 @@ def pytest_addoption(parser): parser.addoption("--fogbench-port", action="store", default="5683", type=int, help="FogBench Destination Port") +@pytest.fixture +def num_assets(request): + return request.config.getoption("--num-assets") + @pytest.fixture def storage_plugin(request): return request.config.getoption("--storage-plugin") @@ -894,7 +901,10 @@ def fledge_url(request): def wait_time(request): return request.config.getoption("--wait-time") - +@pytest.fixture +def wait_fix(request): + return request.config.getoption("--wait-fix") + @pytest.fixture def retries(request): return request.config.getoption("--retries") diff --git a/tests/system/python/packages/test_multiple_assets.py b/tests/system/python/packages/test_multiple_assets.py index 5090005120..04b42c2891 100644 --- a/tests/system/python/packages/test_multiple_assets.py +++ b/tests/system/python/packages/test_multiple_assets.py @@ -29,10 +29,8 @@ SCRIPTS_DIR_ROOT = "{}/tests/system/python/packages/data/".format(PROJECT_ROOT) FLEDGE_ROOT = os.environ.get('FLEDGE_ROOT') BENCHMARK_SOUTH_SVC_NAME = "BenchMark #" -ASSET_NAME = "random_multiple_assets" -PER_BENCHMARK_ASSET_COUNT = 150 -AF_HIERARCHY_LEVEL = "multipleassets/multipleassetslvl2/multipleassetslvl3" - +ASSET_NAME = "{}_random_multiple_assets".format(time.strftime("%Y%m%d")) +AF_HIERARCHY_LEVEL = "{0}_multipleassets/{0}_multipleassetslvl2/{0}_multipleassetslvl3".format(time.strftime("%Y%m%d")) @pytest.fixture def reset_fledge(wait_time): @@ -67,7 +65,7 @@ def remove_and_add_pkgs(package_build_version): @pytest.fixture -def start_north(start_north_omf_as_a_service, fledge_url, +def start_north(start_north_omf_as_a_service, fledge_url, num_assets, pi_host, pi_port, pi_admin, pi_passwd, clear_pi_system_through_pi_web_api, pi_db): global north_schedule_id @@ -78,8 +76,10 @@ def start_north(start_north_omf_as_a_service, fledge_url, asset_dict = {} no_of_services = 6 + num_assets_per_service=(num_assets//no_of_services) + # Creates assets dictionary for PI server cleanup for service_count in range(no_of_services): - for asst_count in range(PER_BENCHMARK_ASSET_COUNT): + for asst_count in range(num_assets_per_service): asset_name = ASSET_NAME + "-{}{}".format(service_count + 1, asst_count + 1) asset_dict[asset_name] = dp_list @@ -93,7 +93,7 @@ def start_north(start_north_omf_as_a_service, fledge_url, yield start_north -def add_benchmark(fledge_url, name, count): +def add_benchmark(fledge_url, name, count, num_assets_per_service): data = { "name": name, "type": "south", @@ -104,13 +104,21 @@ def add_benchmark(fledge_url, name, count): "value": "{}-{}".format(ASSET_NAME, count) }, "numAssets": { - "value": "{}".format(PER_BENCHMARK_ASSET_COUNT) + "value": "{}".format(num_assets_per_service) } } } post_url = "/fledge/service" utils.post_request(fledge_url, post_url, data) +def verify_restart(fledge_url, retries): + for i in range(retries): + time.sleep(30) + get_url = '/fledge/ping' + ping_result = utils.get_request(fledge_url, get_url) + if ping_result['uptime'] > 0: + return + assert ping_result['uptime'] > 0 def verify_service_added(fledge_url, name): get_url = "/fledge/south" @@ -118,7 +126,6 @@ def verify_service_added(fledge_url, name): assert len(result["services"]) assert name in [s["name"] for s in result["services"]] - def verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries): get_url = "/fledge/ping" ping_result = utils.get_request(fledge_url, get_url) @@ -143,10 +150,19 @@ def verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries): return ping_result -def verify_asset(fledge_url, total_assets): - get_url = "/fledge/asset" - result = utils.get_request(fledge_url, get_url) - assert len(result), "No asset found" +def verify_asset(fledge_url, total_assets, count, wait_time): + # Check whether "total_assets" are created or not by calling "/fledge/asset" endpoint for "count" number of iterations + # In each iteration sleep for wait_time * 6, i.e., 60 seconds.. + for i in range(count): + get_url = "/fledge/asset" + result = utils.get_request(fledge_url, get_url) + asset_created = len(result) + if (total_assets == asset_created): + print("Total {} asset created".format(asset_created)) + return + # Fledge takes 60 seconds to create 100 assets. + # Added sleep for "wait_time * 6", So that we can changes sleep time by changing value of wait_time from the jenkins job in future if required. + time.sleep(wait_time * 6) assert total_assets == len(result) @@ -163,16 +179,16 @@ def verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_serv assert "Benchmark" in [s["plugin"] for s in tracking_details["track"]] -def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries, total_benchmark_services): +def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries, total_benchmark_services, num_assets_per_service): af_hierarchy_level_list = AF_HIERARCHY_LEVEL.split("/") type_id = 1 - for s in range(1,total_benchmark_services+1): - for a in range(1,PER_BENCHMARK_ASSET_COUNT+1): + for s in range(1, total_benchmark_services+1): + for a in range(1, num_assets_per_service+1): retry_count = 0 data_from_pi = None - asset_name = "random-" + str(s) + str(a) + asset_name = "random_multiple_assets-" + str(s) + str(a) print(asset_name) recorded_datapoint = "{}".format(asset_name) # Name of asset in the PI server @@ -191,8 +207,8 @@ def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_d class TestMultiAssets: def test_multiple_assets_with_restart(self, remove_and_add_pkgs, reset_fledge, start_north, read_data_from_pi_web_api, - skip_verify_north_interface, fledge_url, - wait_time, retries, pi_host, pi_port, pi_admin, pi_passwd, pi_db): + skip_verify_north_interface, fledge_url, num_assets, wait_time, retries, pi_host, + pi_port, pi_admin, pi_passwd, pi_db): """ Test multiple benchmark services with multiple assets are created in fledge, also verifies assets after restarting fledge. remove_and_add_pkgs: Fixture to remove and install latest fledge packages @@ -203,33 +219,37 @@ def test_multiple_assets_with_restart(self, remove_and_add_pkgs, reset_fledge, s on endpoint GET /fledge/asset""" total_benchmark_services = 6 - total_assets = PER_BENCHMARK_ASSET_COUNT * total_benchmark_services + num_assets_per_service = (num_assets//total_benchmark_services) + # Total number of assets that would be created, total_assets variable is used instead of num_assets to handle case where num_assets is not divisible by 3 or 6. Since we are creating 3 or 6 services and each service should create equal num ber of aasets. + total_assets = num_assets_per_service * total_benchmark_services for count in range(total_benchmark_services): service_name = BENCHMARK_SOUTH_SVC_NAME + "{}".format(count + 1) - add_benchmark(fledge_url, service_name, count + 1) + add_benchmark(fledge_url, service_name, count + 1, num_assets_per_service) verify_service_added(fledge_url, service_name) - - # Wait until total_assets are created - time.sleep(PER_BENCHMARK_ASSET_COUNT + 2 * wait_time) + + # Sleep for few seconds, So that data from south service can be ingested into the Fledge + time.sleep(wait_time * 3) + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - verify_asset(fledge_url, total_assets) + # num//assets return integer value that passes to count + verify_asset(fledge_url, total_assets, num_assets//100, wait_time) put_url = "/fledge/restart" utils.put_request(fledge_url, urllib.parse.quote(put_url)) # Wait for fledge to restart - time.sleep(wait_time * 2) + verify_restart(fledge_url, retries) verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - verify_asset(fledge_url, total_assets) - - verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services, PER_BENCHMARK_ASSET_COUNT) + # num//assets return integer value that passes to count + verify_asset(fledge_url, total_assets, num_assets//100, wait_time) + verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services, num_assets_per_service) old_ping_result = verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - # Wait for read and sent readings to increase - time.sleep(wait_time) - + # Sleep for few seconds to verify data ingestion into Fledge is increasing or not after restart. + time.sleep(wait_time * 3) + new_ping_result = verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) # Verifies whether Read and Sent readings are increasing after restart assert old_ping_result['dataRead'] < new_ping_result['dataRead'] @@ -237,13 +257,13 @@ def test_multiple_assets_with_restart(self, remove_and_add_pkgs, reset_fledge, s if not skip_verify_north_interface: assert old_ping_result['dataSent'] < new_ping_result['dataSent'] _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries, - total_benchmark_services) + total_benchmark_services, num_assets_per_service) # FIXME: If sleep is removed then the next test fails time.sleep(wait_time * 2) def test_add_multiple_assets_before_after_restart(self, reset_fledge, start_north, read_data_from_pi_web_api, - skip_verify_north_interface, fledge_url, + skip_verify_north_interface, fledge_url, num_assets, wait_time, retries, pi_host, pi_port, pi_admin, pi_passwd, pi_db): """ Test addition of multiple assets before and after restarting fledge. reset_fledge: Fixture to reset fledge @@ -253,45 +273,47 @@ def test_add_multiple_assets_before_after_restart(self, reset_fledge, start_nort on endpoint GET /fledge/asset""" total_benchmark_services = 3 - # Total number of assets that would be created - total_assets = PER_BENCHMARK_ASSET_COUNT * total_benchmark_services - + num_assets_per_service = (num_assets//(total_benchmark_services*2)) + # Total number of assets that would be created, total_assets variable is used instead of num_assets to handle case where num_assets is not divisible by 3 or 6. Since we are creating 3 or 6 services and each service should create equal num ber of aasets. + total_assets = num_assets_per_service * total_benchmark_services + for count in range(total_benchmark_services): service_name = BENCHMARK_SOUTH_SVC_NAME + "{}".format(count + 1) - add_benchmark(fledge_url, service_name, count + 1) + add_benchmark(fledge_url, service_name, count + 1, num_assets_per_service) verify_service_added(fledge_url, service_name) - # Wait until total_assets are created - time.sleep(PER_BENCHMARK_ASSET_COUNT + 2 * wait_time) + + # Sleep for few seconds, So that data from south service can be ingested into the Fledge. + time.sleep(wait_time * 3) + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - verify_asset(fledge_url, total_assets) + # num//assets return integer value that passes to count + verify_asset(fledge_url, total_assets, num_assets//100, wait_time) - verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services, PER_BENCHMARK_ASSET_COUNT) + verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services, num_assets_per_service) put_url = "/fledge/restart" utils.put_request(fledge_url, urllib.parse.quote(put_url)) # Wait for fledge to restart - time.sleep(wait_time * 3) + verify_restart(fledge_url, retries) # We are adding more total_assets number of assets total_assets = total_assets * 2 for count in range(total_benchmark_services): service_name = BENCHMARK_SOUTH_SVC_NAME + "{}".format(count + 4) - add_benchmark(fledge_url, service_name, count + 4) + add_benchmark(fledge_url, service_name, count + 4, num_assets_per_service) verify_service_added(fledge_url, service_name) - # Wait until total_assets are created - time.sleep(PER_BENCHMARK_ASSET_COUNT + 2 * wait_time) verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - verify_asset(fledge_url, total_assets) - - verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services * 2, PER_BENCHMARK_ASSET_COUNT) + # num//assets return integer value that passes to count + verify_asset(fledge_url, total_assets, num_assets//100, wait_time) + verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services * 2, num_assets_per_service) old_ping_result = verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - # Wait for read and sent readings to increase - time.sleep(wait_time) + # Sleep for few seconds to verify data ingestion into Fledge is increasing or not after adding more services. + time.sleep(wait_time * 3) new_ping_result = verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) # Verifies whether Read and Sent readings are increasing after restart @@ -299,11 +321,12 @@ def test_add_multiple_assets_before_after_restart(self, reset_fledge, start_nort if not skip_verify_north_interface: assert old_ping_result['dataSent'] < new_ping_result['dataSent'] + # Initially total_benchmark_services is 3 but after the restart the 3 more south services are added. So, total_benchmark_services * 2 is 6 _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries, - total_benchmark_services) - - def test_multiple_assets_with_reconfig(self, reset_fledge, start_north, read_data_from_pi_web_api, skip_verify_north_interface, - fledge_url, + total_benchmark_services * 2, num_assets_per_service) + + def test_multiple_assets_with_reconfig(self, reset_fledge, start_north, read_data_from_pi_web_api, + skip_verify_north_interface, fledge_url, num_assets, wait_fix, wait_time, retries, pi_host, pi_port, pi_admin, pi_passwd, pi_db): """ Test addition of multiple assets with reconfiguration of south service. reset_fledge: Fixture to reset fledge @@ -313,24 +336,27 @@ def test_multiple_assets_with_reconfig(self, reset_fledge, start_north, read_dat on endpoint GET /fledge/asset""" total_benchmark_services = 3 - num_assets = 2 * PER_BENCHMARK_ASSET_COUNT - # Total number of assets that would be created - total_assets = PER_BENCHMARK_ASSET_COUNT * total_benchmark_services + num_assets_per_service = (num_assets//(total_benchmark_services*2)) + # total_assets variable is used instead of num_assets to handle case where num_assets is not divisible by 3 or 6. Since we are creating 3 or 6 services and each service should create equal num ber of aasets. + # Number of assets that would be created initially + total_assets = num_assets_per_service * total_benchmark_services for count in range(total_benchmark_services): service_name = BENCHMARK_SOUTH_SVC_NAME + "{}".format(count + 1) - add_benchmark(fledge_url, service_name, count + 1) + add_benchmark(fledge_url, service_name, count + 1, num_assets_per_service) verify_service_added(fledge_url, service_name) - # Wait until total_assets are created - time.sleep(PER_BENCHMARK_ASSET_COUNT + 2 * wait_time) + # Sleep for few seconds, So that data from south service can be ingested into the Fledge + time.sleep(wait_time * 3) + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - verify_asset(fledge_url, total_assets) - - verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services, PER_BENCHMARK_ASSET_COUNT) + # num//assets return integer value that passes to count + verify_asset(fledge_url, total_assets, num_assets//100, wait_time) + verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services, num_assets_per_service) # With reconfig, number of assets are doubled in each south service - payload = {"numAssets": "{}".format(num_assets)} + num_assets_per_service = 2 * num_assets_per_service + payload = {"numAssets": "{}".format(num_assets_per_service)} for count in range(total_benchmark_services): service_name = BENCHMARK_SOUTH_SVC_NAME + "{}".format(count + 1) put_url = "/fledge/category/{}".format(service_name) @@ -339,17 +365,19 @@ def test_multiple_assets_with_reconfig(self, reset_fledge, start_north, read_dat # In reconfig number of assets are doubled total_assets = total_assets * 2 - # Wait until total_assets are created - time.sleep(num_assets + 2 * wait_time) verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - verify_asset(fledge_url, total_assets) - - verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services, num_assets) + + # FIX ME: FOGL-7567, Due to increase in timing of asset creation, time spent in reconfig asset also increases. + # Remove the WAIT_FIX, Once FOGL-7567 is resolved. + WAIT_FIX = wait_fix + num_assets//100 + + verify_asset(fledge_url, total_assets, WAIT_FIX, wait_time) + verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services, num_assets_per_service) old_ping_result = verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - - # Wait for read and sent readings to increase - time.sleep(wait_time) + + # Sleep for few seconds to verify data ingestion into Fledge is increasing or not after reconfig of south services. + time.sleep(wait_time * 3) new_ping_result = verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) # Verifies whether Read and Sent readings are increasing after restart @@ -358,4 +386,4 @@ def test_multiple_assets_with_reconfig(self, reset_fledge, start_north, read_dat if not skip_verify_north_interface: assert old_ping_result['dataSent'] < new_ping_result['dataSent'] _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries, - total_benchmark_services) + total_benchmark_services, num_assets_per_service) From b923ecde37b5b157186bb5f7e1166c3e84e082f2 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Tue, 21 Mar 2023 20:36:44 +0530 Subject: [PATCH 205/499] FOGL-7533 Updated Lab Script for testing with combination of plugins e.g. SQLIite + PostgreSQL (#1015) * Added code for testing with sqlite as config db and postgres as reading db * Refactored code --- tests/system/lab/reset | 29 +++++++++++++++++++++++------ tests/system/lab/test.config | 3 ++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/tests/system/lab/reset b/tests/system/lab/reset index bc920e9df4..61fa279334 100755 --- a/tests/system/lab/reset +++ b/tests/system/lab/reset @@ -2,9 +2,13 @@ FLEDGE_ROOT="/usr/local/fledge" +install_postgres() { + sudo apt install -y postgresql + sudo -u postgres createuser -d "$(whoami)" +} + _postgres() { - sudo apt install -y postgresql - sudo -u postgres createuser -d "$(whoami)" + install_postgres [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "postgres" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true } @@ -24,20 +28,33 @@ _sqlitelb () { [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true } +_config_reading_db () { + if [[ "postgres" == @($1|$2) ]] + then + install_postgres + fi + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "${1}" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true + [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "${2}" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true +} + # check for storage plugin . ./test.config -if [[ ${STORAGE} == "postgres" ]] +if [[ ${STORAGE} == "postgres" && ${READING_PLUGIN_DB} == "Use main plugin" ]] then _postgres -elif [[ ${STORAGE} == "sqlite-in-memory" ]] +elif [[ ${STORAGE} == "sqlite-in-memory" && ${READING_PLUGIN_DB} == "Use main plugin" ]] then _sqliteinmemory -elif [[ ${STORAGE} == "sqlitelb" ]] +elif [[ ${STORAGE} == "sqlitelb" && ${READING_PLUGIN_DB} == "Use main plugin" ]] then _sqlitelb -else +elif [[ ${STORAGE} == "sqlite" && ${READING_PLUGIN_DB} == "Use main plugin" ]] +then _sqlite +elif [[ ${STORAGE} == @(sqlite|postgres|sqlitelb) && ${READING_PLUGIN_DB} != "Use main plugin" ]] +then + _config_reading_db ${STORAGE} ${READING_PLUGIN_DB} fi echo "Stopping Fledge using systemctl ..." diff --git a/tests/system/lab/test.config b/tests/system/lab/test.config index fd9c3cdfdb..525b856155 100755 --- a/tests/system/lab/test.config +++ b/tests/system/lab/test.config @@ -9,4 +9,5 @@ SLEEP_FIX=10 # Time to sleep to fix bugs. This should be zero. EXIT_EARLY=0 ADD_NORTH_AS_SERVICE=true VERIFY_EGRESS_TO_PI=1 -STORAGE=sqlite # postgres, sqlite-in-memory, sqlitelb \ No newline at end of file +STORAGE=sqlite # postgres, sqlite-in-memory, sqlitelb +READING_PLUGIN_DB="Use main plugin" From ea8308c3f622bcb49743c2ca754559959b1d6104 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 22 Mar 2023 15:17:53 +0530 Subject: [PATCH 206/499] Plugins PUT & DELETE endpoint changed as per 2.1.0next version and also added backward compatibility check" Signed-off-by: ashish-jabble --- python/fledge/services/core/routes.py | 30 ++++++++------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/python/fledge/services/core/routes.py b/python/fledge/services/core/routes.py index a135e3a525..7fe2488195 100644 --- a/python/fledge/services/core/routes.py +++ b/python/fledge/services/core/routes.py @@ -5,38 +5,21 @@ # FLEDGE_END from fledge.services.core import proxy - -from fledge.services.core.api import auth +from fledge.services.core.api import asset_tracker, auth, backup_restore, browser, certificate_store, filters, health, notification, north, package_log, python_packages, south, support, service, task, update from fledge.services.core.api import audit as api_audit -from fledge.services.core.api import browser from fledge.services.core.api import common as api_common from fledge.services.core.api import configuration as api_configuration from fledge.services.core.api import scheduler as api_scheduler from fledge.services.core.api import statistics as api_statistics -from fledge.services.core.api import backup_restore -from fledge.services.core.api import update -from fledge.services.core.api import service -from fledge.services.core.api import certificate_store -from fledge.services.core.api import support -from fledge.services.core.api import task -from fledge.services.core.api import asset_tracker -from fledge.services.core.api import south -from fledge.services.core.api import north -from fledge.services.core.api import filters -from fledge.services.core.api import notification from fledge.services.core.api.plugins import data as plugin_data from fledge.services.core.api.plugins import install as plugins_install, discovery as plugins_discovery from fledge.services.core.api.plugins import update as plugins_update from fledge.services.core.api.plugins import remove as plugins_remove from fledge.services.core.api.snapshot import plugins as snapshot_plugins from fledge.services.core.api.snapshot import table as snapshot_table -from fledge.services.core.api import package_log from fledge.services.core.api.repos import configure as configure_repo from fledge.services.core.api.control_service import script_management from fledge.services.core.api.control_service import acl_management -from fledge.services.core.api import python_packages -from fledge.services.core.api import health - __author__ = "Ashish Jabble, Praveen Garg, Massimiliano Pinto, Amarendra K Sinha" @@ -193,9 +176,14 @@ def setup(app): app.router.add_route('GET', '/fledge/plugins/installed', plugins_discovery.get_plugins_installed) app.router.add_route('GET', '/fledge/plugins/available', plugins_discovery.get_plugins_available) app.router.add_route('POST', '/fledge/plugins', plugins_install.add_plugin) - app.router.add_route('PUT', '/fledge/plugins/{type}/{name}/update', plugins_update.update_plugin) - app.router.add_route('DELETE', '/fledge/plugins/{type}/{name}', plugins_remove.remove_plugin) - + if api_common.get_version() <= "2.1.0": + """Note: This is only for to maintain the backward compatibility. (having core version<=2.1.0) + Plugin Update & Delete routes on the basis of type & installed name""" + app.router.add_route('PUT', '/fledge/plugins/{type}/{name}/update', plugins_update.update_plugin) + app.router.add_route('DELETE', '/fledge/plugins/{type}/{name}', plugins_remove.remove_plugin) + else: + #app.router.add_route('PUT', '/fledge/plugins/{package_name}', plugins_update.update_package) + app.router.add_route('DELETE', '/fledge/plugins/{package_name}', plugins_remove.remove_package) # plugin data app.router.add_route('GET', '/fledge/service/{service_name}/persist', plugin_data.get_persist_plugins) app.router.add_route('GET', '/fledge/service/{service_name}/plugin/{plugin_name}/data', plugin_data.get) From 07378848a29d1430c17ca138465eaaf5967fe409 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 22 Mar 2023 15:18:49 +0530 Subject: [PATCH 207/499] new DELETE package API added Signed-off-by: ashish-jabble --- .../services/core/api/plugins/remove.py | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index 4db55f1b7d..dcafe6fe01 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -43,6 +43,147 @@ C_PLUGINS_PATH = _FLEDGE_ROOT+'/plugins/' +# only work with above version of core 2.1.0 version +async def remove_package(request: web.Request) -> web.Response: + """Remove installed Package + + package_name: package name of plugin + + Example: + curl -sX DELETE http://localhost:8081/fledge/plugins/fledge-south-modbus + curl -sX DELETE http://localhost:8081/fledge/plugins/fledge-north-http-north + curl -sX DELETE http://localhost:8081/fledge/plugins/fledge-filter-scale + curl -sX DELETE http://localhost:8081/fledge/plugins/fledge-notify-alexa + curl -sX DELETE http://localhost:8081/fledge/plugins/fledge-rule-watchdog + """ + try: + package_name = request.match_info.get('package_name', "fledge-") + package_name = package_name.replace(" ", "") + response = {} + if not package_name.startswith("fledge-"): + raise ValueError("Package name should start with 'fledge-' prefix.") + plugin_type = package_name.split("-", 2)[1] + if not plugin_type: + raise ValueError('Invalid Package name. Check and verify the package name in plugins installed.') + if plugin_type not in valid_plugin_types: + raise ValueError("Invalid plugin type. Please provide valid type: {}".format(valid_plugin_types)) + installed_plugins = PluginDiscovery.get_plugins_installed(plugin_type, False) + plugin_info = [(_plugin["name"], _plugin["version"]) for _plugin in installed_plugins + if _plugin["packageName"] == package_name] + if not plugin_info: + raise KeyError("{} package not found. Either package is not installed or missing in plugins installed." + "".format(package_name)) + plugin_name = plugin_info[0][0] + plugin_version = plugin_info[0][1] + if plugin_type in ['notify', 'rule']: + notification_instances_plugin_used_in = await _check_plugin_usage_in_notification_instances(plugin_name) + if notification_instances_plugin_used_in: + err_msg = "{} cannot be removed. This is being used by {} instances.".format( + plugin_name, notification_instances_plugin_used_in) + _logger.warning(err_msg) + raise RuntimeError(err_msg) + else: + get_tracked_plugins = await _check_plugin_usage(plugin_type, plugin_name) + if get_tracked_plugins: + e = "{} cannot be removed. This is being used by {} instances.". \ + format(plugin_name, get_tracked_plugins[0]['service_list']) + _logger.warning(e) + raise RuntimeError(e) + else: + _logger.info("No entry found for {name} plugin in asset tracker; " + "or {name} plugin may have been added in disabled state & never used." + "".format(name=plugin_name)) + # Check Pre-conditions from Packages table + # if status is -1 (Already in progress) then return as rejected request + action = 'purge' + storage = connect.get_storage_async() + select_payload = PayloadBuilder().SELECT("status").WHERE(['action', '=', action]).AND_WHERE( + ['name', '=', package_name]).payload() + result = await storage.query_tbl_with_payload('packages', select_payload) + response = result['rows'] + if response: + exit_code = response[0]['status'] + if exit_code == -1: + msg = "{} package purge already in progress.".format(package_name) + return web.HTTPTooManyRequests(reason=msg, body=json.dumps({"message": msg})) + # Remove old entry from table for other cases + delete_payload = PayloadBuilder().WHERE(['action', '=', action]).AND_WHERE( + ['name', '=', package_name]).payload() + await storage.delete_from_tbl("packages", delete_payload) + + # Insert record into Packages table + insert_payload = PayloadBuilder().INSERT(id=str(uuid.uuid4()), name=package_name, action=action, + status=-1, log_file_uri="").payload() + result = await storage.insert_into_tbl("packages", insert_payload) + response = result['response'] + if response: + select_payload = PayloadBuilder().SELECT("id").WHERE(['action', '=', action]).AND_WHERE( + ['name', '=', package_name]).payload() + result = await storage.query_tbl_with_payload('packages', select_payload) + response = result['rows'] + if response: + pn = "{}-{}".format(action, plugin_name) + uid = response[0]['id'] + p = multiprocessing.Process(name=pn, + target=_uninstall, + args=(package_name, plugin_version, uid, storage) + ) + p.daemon = True + p.start() + msg = "{} plugin remove started.".format(plugin_name) + status_link = "fledge/package/{}/status?id={}".format(action, uid) + response = {"message": msg, "id": uid, "statusLink": status_link} + else: + raise StorageServerError + except (ValueError, RuntimeError) as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({'message': msg})) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({'message': msg})) + except StorageServerError as e: + msg = e.error + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) + except Exception as ex: + msg = str(ex) + _logger.error("Failed to delete {} package. {}".format(package_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) + else: + return web.json_response(response) + + +def _uninstall(pkg_name: str, version: str, uid: uuid, storage: connect) -> tuple: + from fledge.services.core.server import Server + _logger.info("{} package removal started...".format(pkg_name)) + stdout_file_path = '' + try: + stdout_file_path = common.create_log_file(action='remove', plugin_name=pkg_name) + link = "log/" + stdout_file_path.split("/")[-1] + if utils.is_redhat_based(): + cmd = "sudo yum -y remove {} > {} 2>&1".format(pkg_name, stdout_file_path) + else: + cmd = "sudo apt -y purge {} > {} 2>&1".format(pkg_name, stdout_file_path) + code = os.system(cmd) + # Update record in Packages table + payload = PayloadBuilder().SET(status=code, log_file_uri=link).WHERE(['id', '=', uid]).payload() + loop = asyncio.new_event_loop() + loop.run_until_complete(storage.update_tbl("packages", payload)) + if code == 0: + # Clear internal cache + loop.run_until_complete(_put_refresh_cache(Server.is_rest_server_http_enabled, + Server._host, Server.core_management_port)) + # Audit logger + audit = AuditLogger(storage) + audit_detail = {'package_name': pkg_name, 'version': version} + loop.run_until_complete(audit.information('PKGRM', audit_detail)) + _logger.info('{} removed successfully.'.format(pkg_name)) + except Exception: + # Non-Zero integer - Case of fail + code = 1 + return code, stdout_file_path + + +# only work with lesser or equal to version of core 2.1.0 version async def remove_plugin(request: web.Request) -> web.Response: """ Remove installed plugin from fledge From fe12e588007a937b59111036defb4d9a1f3a61d3 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 22 Mar 2023 15:55:57 +0530 Subject: [PATCH 208/499] Plugin API remove tests updated Signed-off-by: ashish-jabble --- .../services/core/api/plugins/test_remove.py | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py index b89037efda..20eb202dfa 100644 --- a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py +++ b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py @@ -12,12 +12,12 @@ from aiohttp import web -from fledge.services.core import routes -from fledge.services.core import connect +from fledge.common.plugin_discovery import PluginDiscovery +from fledge.common.storage_client.storage_client import StorageClientAsync +from fledge.services.core import connect, routes +from fledge.services.core.api import common from fledge.services.core.api.plugins import remove as plugins_remove from fledge.services.core.api.plugins.exceptions import * -from fledge.common.storage_client.storage_client import StorageClientAsync -from fledge.common.plugin_discovery import PluginDiscovery __author__ = "Ashish Jabble" @@ -36,28 +36,25 @@ def client(self, loop, test_client): routes.setup(app) return loop.run_until_complete(test_client(app)) - @pytest.mark.parametrize("_type", [ - "blah", - 1, - "notificationDelivery" - "notificationRule" - ]) + RUN_TESTS_BEFORE_210_VERSION = False if common.get_version() <= "2.1.0" else True + + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") + @pytest.mark.parametrize("_type", ["blah", 1, "notificationDelivery", "notificationRule"]) async def test_bad_type_plugin(self, client, _type): resp = await client.delete('/fledge/plugins/{}/name'.format(_type), data=None) assert 400 == resp.status assert "Invalid plugin type. Please provide valid type: ['north', 'south', 'filter', 'notify', 'rule']" == \ resp.reason + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") @pytest.mark.parametrize("name", ["OMF", "omf", "Omf"]) async def test_bad_update_of_inbuilt_plugin(self, client, name): resp = await client.delete('/fledge/plugins/north/{}'.format(name), data=None) assert 400 == resp.status assert "Cannot delete an inbuilt OMF plugin." == resp.reason - @pytest.mark.parametrize("name", [ - "http-south", - "random" - ]) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") + @pytest.mark.parametrize("name", ["http-south", "random"]) async def test_bad_name_plugin(self, client, name): plugin_installed = [{"name": "sinusoid", "type": "south", "description": "Sinusoid Poll Plugin", "version": "1.8.1", "installedDirectory": "south/sinusoid", @@ -80,6 +77,7 @@ async def test_bad_name_plugin(self, client, name): assert {'message': expected_msg} == response plugin_installed_patch.assert_called_once_with('south', False) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") async def test_plugin_in_use(self, client): async def async_mock(return_value): return return_value @@ -117,6 +115,7 @@ async def async_mock(return_value): plugin_usage_patch.assert_called_once_with(_type, name) plugin_installed_patch.assert_called_once_with(_type, False) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") async def test_notify_plugin_in_use(self, client): async def async_mock(return_value): return return_value @@ -156,6 +155,7 @@ async def async_mock(return_value): plugin_usage_patch.assert_called_once_with(plugin_installed_dirname) plugin_installed_patch.assert_called_once_with(plugin_type_installed_dir, False) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") async def test_package_already_in_progress(self, client): async def async_mock(return_value): return return_value @@ -213,6 +213,7 @@ async def async_mock(return_value): plugin_usage_patch.assert_called_once_with(_type, name) plugin_installed_patch.assert_called_once_with(_type, False) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") async def test_package_when_not_in_use(self, client): async def async_mock(return_value): From ea67540361245b0877aaccb6f8c561cca4c7adbf Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 22 Mar 2023 17:01:18 +0530 Subject: [PATCH 209/499] new PUT package API added --- .../services/core/api/plugins/update.py | 156 ++++++++++++++++-- python/fledge/services/core/routes.py | 2 +- 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index 84151c97af..6b761733f3 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -36,6 +36,147 @@ _logger = logger.setup(__name__, level=logging.INFO) +async def update_package(request: web.Request) -> web.Response: + """ Update Package + + package_name: package name of plugin + + Example: + curl -sX PUT http://localhost:8081/fledge/plugins/fledge-south-modbus + curl -sX PUT http://localhost:8081/fledge/plugins/fledge-north-http-north + curl -sX PUT http://localhost:8081/fledge/plugins/fledge-filter-scale + curl -sX PUT http://localhost:8081/fledge/plugins/fledge-notify-alexa + curl -sX PUT http://localhost:8081/fledge/plugins/fledge-rule-watchdog + """ + + try: + valid_plugin_types = ['north', 'south', 'filter', 'notify', 'rule'] + package_name = request.match_info.get('package_name', "fledge-") + package_name = package_name.replace(" ", "") + final_response = {} + if not package_name.startswith("fledge-"): + raise ValueError("Package name should start with 'fledge-' prefix.") + plugin_type = package_name.split("-", 2)[1] + if not plugin_type: + raise ValueError('Invalid Package name. Check and verify the package name in plugins installed.') + if plugin_type not in valid_plugin_types: + raise ValueError("Invalid plugin type. Please provide valid type: {}".format(valid_plugin_types)) + installed_plugins = PluginDiscovery.get_plugins_installed(plugin_type, False) + plugin_info = [_plugin["name"] for _plugin in installed_plugins if _plugin["packageName"] == package_name] + if not plugin_info: + raise KeyError("{} package not found. Either package is not installed or missing in plugins installed." + "".format(package_name)) + plugin_name = plugin_info[0] + # Check Pre-conditions from Packages table + # if status is -1 (Already in progress) then return as rejected request + action = 'update' + storage_client = connect.get_storage_async() + select_payload = PayloadBuilder().SELECT("status").WHERE(['action', '=', action]).AND_WHERE( + ['name', '=', package_name]).payload() + result = await storage_client.query_tbl_with_payload('packages', select_payload) + response = result['rows'] + if response: + exit_code = response[0]['status'] + if exit_code == -1: + msg = "{} package {} already in progress.".format(package_name, action) + return web.HTTPTooManyRequests(reason=msg, body=json.dumps({"message": msg})) + # Remove old entry from table for other cases + delete_payload = PayloadBuilder().WHERE(['action', '=', action]).AND_WHERE( + ['name', '=', package_name]).payload() + await storage_client.delete_from_tbl("packages", delete_payload) + + schedules = [] + notifications = [] + if plugin_type in ['notify', 'rule']: + # Check Notification service is enabled or not + payload = PayloadBuilder().SELECT("id", "enabled", "schedule_name").WHERE(['process_name', '=', + 'notification_c']).payload() + result = await storage_client.query_tbl_with_payload('schedules', payload) + sch_info = result['rows'] + if sch_info and sch_info[0]['enabled'] == 't': + # Find notification instances which are used by requested plugin name + # If its config item 'enable' is true then update to false + config_mgr = ConfigurationManager(storage_client) + all_notifications = await config_mgr._read_all_child_category_names("Notifications") + for notification in all_notifications: + notification_config = await config_mgr._read_category_val(notification['child']) + notification_name = notification_config['name']['value'] + channel = notification_config['channel']['value'] + rule = notification_config['rule']['value'] + is_enabled = True if notification_config['enable']['value'] == 'true' else False + if (channel == plugin_name and is_enabled) or (rule == plugin_name and is_enabled): + _logger.warning( + "Disabling {} notification instance, as {} {} plugin is being updated...".format( + notification_name, plugin_name, plugin_type)) + await config_mgr.set_category_item_value_entry(notification_name, "enable", "false") + notifications.append(notification_name) + else: + # FIXME: if any south/north service or task doesnot have tracked by Fledge; + # then we need to handle the case to disable the service or task if enabled + # Tracked plugins from asset tracker + tracked_plugins = await _get_plugin_and_sch_name_from_asset_tracker(plugin_type) + filters_used_by = [] + if plugin_type == 'filter': + # In case of filter, for asset_tracker table we are inserting filter category_name in plugin column + # instead of filter plugin name by Design + # Hence below query is required to get actual plugin name from filters table + storage_client = connect.get_storage_async() + payload = PayloadBuilder().SELECT("name").WHERE(['plugin', '=', plugin_name]).payload() + result = await storage_client.query_tbl_with_payload('filters', payload) + filters_used_by = [r['name'] for r in result['rows']] + for p in tracked_plugins: + if (plugin_name == p['plugin'] and not plugin_type == 'filter') or ( + p['plugin'] in filters_used_by and plugin_type == 'filter'): + sch_info = await _get_sch_id_and_enabled_by_name(p['service']) + if sch_info[0]['enabled'] == 't': + status, reason = await server.Server.scheduler.disable_schedule(uuid.UUID(sch_info[0]['id'])) + if status: + _logger.warning("Disabling {} {} instance, as {} plugin is being updated...".format( + p['service'], plugin_type, plugin_name)) + schedules.append(sch_info[0]['id']) + # Insert record into Packages table + insert_payload = PayloadBuilder().INSERT(id=str(uuid.uuid4()), name=package_name, action=action, status=-1, + log_file_uri="").payload() + result = await storage_client.insert_into_tbl("packages", insert_payload) + response = result['response'] + if response: + select_payload = PayloadBuilder().SELECT("id").WHERE(['action', '=', action]).AND_WHERE( + ['name', '=', package_name]).payload() + result = await storage_client.query_tbl_with_payload('packages', select_payload) + response = result['rows'] + if response: + pn = "{}-{}".format(action, package_name) + uid = response[0]['id'] + p = multiprocessing.Process(name=pn, + target=do_update, + args=(server.Server.is_rest_server_http_enabled, + server.Server._host, server.Server.core_management_port, + storage_client, plugin_type, plugin_name, package_name, uid, + schedules, notifications)) + p.daemon = True + p.start() + msg = "{} {} started.".format(package_name, action) + status_link = "fledge/package/{}/status?id={}".format(action, uid) + final_response = {"message": msg, "id": uid, "statusLink": status_link} + else: + raise StorageServerError + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({'message': msg})) + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({'message': msg})) + except StorageServerError as e: + msg = e.error + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) + except Exception as ex: + msg = str(ex) + _logger.error("Failed to update {} package. {}".format(package_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) + else: + return web.json_response(final_response) + + async def update_plugin(request: web.Request) -> web.Response: """ update plugin @@ -55,15 +196,8 @@ async def update_plugin(request: web.Request) -> web.Response: # only OMF is an inbuilt plugin if name.lower() == 'omf': raise ValueError("Cannot update an inbuilt {} plugin.".format(name.upper())) - if _type == 'notify': - installed_dir_name = 'notificationDelivery' - elif _type == 'rule': - installed_dir_name = 'notificationRule' - else: - installed_dir_name = _type - # Check requested plugin name is installed or not - installed_plugins = PluginDiscovery.get_plugins_installed(installed_dir_name, False) + installed_plugins = PluginDiscovery.get_plugins_installed(_type, False) plugin_info = [(_plugin["name"], _plugin["packageName"]) for _plugin in installed_plugins] package_name = "fledge-{}-{}".format(_type, name.lower().replace('_', '-')) plugin_found = False @@ -159,7 +293,7 @@ async def update_plugin(request: web.Request) -> web.Response: target=do_update, args=(server.Server.is_rest_server_http_enabled, server.Server._host, server.Server.core_management_port, - storage_client, installed_dir_name, _type, name, package_name, uid, + storage_client, _type, name, package_name, uid, schedules, notifications)) p.daemon = True p.start() @@ -241,7 +375,7 @@ def _update_repo_sources_and_plugin(pkg_name: str) -> tuple: return ret_code, link -def do_update(http_enabled: bool, host: str, port: int, storage: connect, dir_name: str, _type: str, plugin_name: str, +def do_update(http_enabled: bool, host: str, port: int, storage: connect, _type: str, plugin_name: str, pkg_name: str, uid: str, schedules: list, notifications: list) -> None: _logger.info("{} package update started...".format(pkg_name)) protocol = "HTTP" if http_enabled else "HTTPS" @@ -255,7 +389,7 @@ def do_update(http_enabled: bool, host: str, port: int, storage: connect, dir_na if code == 0: # Audit info audit = AuditLogger(storage) - installed_plugins = PluginDiscovery.get_plugins_installed(dir_name, False) + installed_plugins = PluginDiscovery.get_plugins_installed(_type, False) version = [p["version"] for p in installed_plugins if p['name'] == plugin_name] audit_detail = {'packageName': pkg_name} if version: diff --git a/python/fledge/services/core/routes.py b/python/fledge/services/core/routes.py index 7fe2488195..c052d1ce5f 100644 --- a/python/fledge/services/core/routes.py +++ b/python/fledge/services/core/routes.py @@ -182,7 +182,7 @@ def setup(app): app.router.add_route('PUT', '/fledge/plugins/{type}/{name}/update', plugins_update.update_plugin) app.router.add_route('DELETE', '/fledge/plugins/{type}/{name}', plugins_remove.remove_plugin) else: - #app.router.add_route('PUT', '/fledge/plugins/{package_name}', plugins_update.update_package) + app.router.add_route('PUT', '/fledge/plugins/{package_name}', plugins_update.update_package) app.router.add_route('DELETE', '/fledge/plugins/{package_name}', plugins_remove.remove_package) # plugin data app.router.add_route('GET', '/fledge/service/{service_name}/persist', plugin_data.get_persist_plugins) From 6996398ae07bc0ad8049cc6de13cec5fc3be643f Mon Sep 17 00:00:00 2001 From: nandan Date: Wed, 22 Mar 2023 17:10:12 +0530 Subject: [PATCH 210/499] FOGL-7567:Removed logic to check for empty table periodically Signed-off-by: nandan --- .../common/include/readings_catalogue.h | 6 +- .../sqlite/common/readings_catalogue.cpp | 56 ++++++++----------- .../python/packages/test_multiple_assets.py | 8 +-- 3 files changed, 29 insertions(+), 41 deletions(-) diff --git a/C/plugins/storage/sqlite/common/include/readings_catalogue.h b/C/plugins/storage/sqlite/common/include/readings_catalogue.h index 15eb07d06e..ecde9053e3 100644 --- a/C/plugins/storage/sqlite/common/include/readings_catalogue.h +++ b/C/plugins/storage/sqlite/common/include/readings_catalogue.h @@ -131,11 +131,10 @@ class ReadingsCatalogue { void preallocateReadingsTables(int dbId); bool loadAssetReadingCatalogue(); - bool loadEmptyAssetReadingCatalogue(); + bool loadEmptyAssetReadingCatalogue(bool clean = true); bool latestDbUpdate(sqlite3 *dbHandle, int newDbId); void preallocateNewDbsRange(int dbIdStart, int dbIdEnd); - tyReadingReference getEmptyReadingTableReference(std::string& asset); tyReadingReference getReadingReference(Connection *connection, const char *asset_code); bool attachDbsToAllConnections(); std::string sqlConstructMultiDb(std::string &sqlCmdBase, std::vector &assetCodes, bool considerExclusion=false); @@ -177,7 +176,7 @@ class ReadingsCatalogue { } tyReadingsAvailable; - ReadingsCatalogue(){}; + ReadingsCatalogue() { }; bool createNewDB(sqlite3 *dbHandle, int newDbId, int startId, NEW_DB_OPERATION attachAllDb); int getUsedTablesDbId(int dbId); @@ -228,6 +227,7 @@ class ReadingsCatalogue { // asset_code - reading Table Id, Db Id // {"", ,{1 ,1 }} }; + std::mutex m_emptyReadingTableMutex; public: TransactionBoundary m_tx; diff --git a/C/plugins/storage/sqlite/common/readings_catalogue.cpp b/C/plugins/storage/sqlite/common/readings_catalogue.cpp index 40238e6fac..e885553c36 100644 --- a/C/plugins/storage/sqlite/common/readings_catalogue.cpp +++ b/C/plugins/storage/sqlite/common/readings_catalogue.cpp @@ -849,7 +849,8 @@ void ReadingsCatalogue::multipleReadingsInit(STORAGE_CONFIGURATION &storageConfi preallocateReadingsTables(0); // on the last database evaluateGlobalId(); - loadEmptyAssetReadingCatalogue(); + std::thread th(&ReadingsCatalogue::loadEmptyAssetReadingCatalogue,this,true); + th.detach(); } catch (exception& e) { @@ -1855,7 +1856,14 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co if (! isReadingAvailable () ) { //No Readding table available... Get empty reading table - emptyTableReference = getEmptyReadingTableReference(emptyAsset); + auto it = m_EmptyAssetReadingCatalogue.begin(); + if (it != m_EmptyAssetReadingCatalogue.end()) + { + emptyAsset = it->first; + emptyTableReference.tableId = it->second.first; + emptyTableReference.dbId = it->second.second; + } + if ( !emptyAsset.empty() ) { ref = emptyTableReference; @@ -1976,16 +1984,25 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co * Loads the empty reading table catalogue * */ -bool ReadingsCatalogue::loadEmptyAssetReadingCatalogue() +bool ReadingsCatalogue::loadEmptyAssetReadingCatalogue(bool clean) { + std::lock_guard guard(m_emptyReadingTableMutex); sqlite3 *dbHandle; string sql_cmd; sqlite3_stmt *stmt; ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); dbHandle = connection->getDbHandle(); - m_EmptyAssetReadingCatalogue.clear(); - + + if (clean) + { + m_EmptyAssetReadingCatalogue.clear(); + } + + // Do not populate m_EmptyAssetReadingCatalogue if data is already there + if (m_EmptyAssetReadingCatalogue.size()) + return true; + for (auto &item : m_AssetReadingCatalogue) { string asset_name = item.first; // Asset @@ -2016,32 +2033,6 @@ bool ReadingsCatalogue::loadEmptyAssetReadingCatalogue() return true; } -/** - * Get Empty Reading Table - * - * @param asset emptyAsset, copies value of asset for which empty table is found - * @return the reading id associated to the provided empty table - */ -ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getEmptyReadingTableReference(std::string& asset) -{ - ReadingsCatalogue::tyReadingReference emptyTableReference = {-1, -1}; - if (m_EmptyAssetReadingCatalogue.size() == 0) - { - loadEmptyAssetReadingCatalogue(); - } - - auto it = m_EmptyAssetReadingCatalogue.begin(); - if (it != m_EmptyAssetReadingCatalogue.end()) - { - asset = it->first; - emptyTableReference.tableId = it->second.first; - emptyTableReference.dbId = it->second.second; - - } - - return emptyTableReference; -} - /** * Retrieve the maximum readings id for the provided database id * @@ -2189,7 +2180,8 @@ int ReadingsCatalogue::purgeAllReadings(sqlite3 *dbHandle, const char *sqlCmdBa } } - loadEmptyAssetReadingCatalogue(); + std::thread th(&ReadingsCatalogue::loadEmptyAssetReadingCatalogue,this,false); + th.detach(); return(rc); } diff --git a/tests/system/python/packages/test_multiple_assets.py b/tests/system/python/packages/test_multiple_assets.py index 04b42c2891..097c67aba8 100644 --- a/tests/system/python/packages/test_multiple_assets.py +++ b/tests/system/python/packages/test_multiple_assets.py @@ -326,7 +326,7 @@ def test_add_multiple_assets_before_after_restart(self, reset_fledge, start_nort total_benchmark_services * 2, num_assets_per_service) def test_multiple_assets_with_reconfig(self, reset_fledge, start_north, read_data_from_pi_web_api, - skip_verify_north_interface, fledge_url, num_assets, wait_fix, + skip_verify_north_interface, fledge_url, num_assets, wait_time, retries, pi_host, pi_port, pi_admin, pi_passwd, pi_db): """ Test addition of multiple assets with reconfiguration of south service. reset_fledge: Fixture to reset fledge @@ -367,11 +367,7 @@ def test_multiple_assets_with_reconfig(self, reset_fledge, start_north, read_dat verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) - # FIX ME: FOGL-7567, Due to increase in timing of asset creation, time spent in reconfig asset also increases. - # Remove the WAIT_FIX, Once FOGL-7567 is resolved. - WAIT_FIX = wait_fix + num_assets//100 - - verify_asset(fledge_url, total_assets, WAIT_FIX, wait_time) + verify_asset(fledge_url, total_assets, num_assets//100, wait_time) verify_asset_tracking_details(fledge_url, total_assets, total_benchmark_services, num_assets_per_service) old_ping_result = verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) From b8c72e3676f55ca6da557d9eaf8e49f6dd95d870 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 22 Mar 2023 17:01:44 +0530 Subject: [PATCH 211/499] update package API tests updated as per core version Signed-off-by: ashish-jabble --- .../services/core/api/plugins/remove.py | 14 ++++---- .../services/core/api/plugins/update.py | 8 +++-- python/fledge/services/core/routes.py | 1 + .../services/core/api/plugins/test_update.py | 34 +++++++++++-------- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index dcafe6fe01..0675ff4373 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -31,9 +31,9 @@ __version__ = "${VERSION}" _help = """ - ------------------------------------------------------------------------------- - | DELETE | /fledge/plugins/{plugin-type}/{plugin-name} | - ------------------------------------------------------------------------------- + -------------------------------------------------------------------- + | DELETE | /fledge/plugins/{package_name} | + -------------------------------------------------------------------- """ _logger = logger.setup(__name__, level=logging.INFO) @@ -43,7 +43,7 @@ C_PLUGINS_PATH = _FLEDGE_ROOT+'/plugins/' -# only work with above version of core 2.1.0 version +# only work with core 2.1.0 onwards version async def remove_package(request: web.Request) -> web.Response: """Remove installed Package @@ -59,7 +59,7 @@ async def remove_package(request: web.Request) -> web.Response: try: package_name = request.match_info.get('package_name', "fledge-") package_name = package_name.replace(" ", "") - response = {} + final_response = {} if not package_name.startswith("fledge-"): raise ValueError("Package name should start with 'fledge-' prefix.") plugin_type = package_name.split("-", 2)[1] @@ -132,7 +132,7 @@ async def remove_package(request: web.Request) -> web.Response: p.start() msg = "{} plugin remove started.".format(plugin_name) status_link = "fledge/package/{}/status?id={}".format(action, uid) - response = {"message": msg, "id": uid, "statusLink": status_link} + final_response = {"message": msg, "id": uid, "statusLink": status_link} else: raise StorageServerError except (ValueError, RuntimeError) as err: @@ -149,7 +149,7 @@ async def remove_package(request: web.Request) -> web.Response: _logger.error("Failed to delete {} package. {}".format(package_name, msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: - return web.json_response(response) + return web.json_response(final_response) def _uninstall(pkg_name: str, version: str, uid: uuid, storage: connect) -> tuple: diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index 6b761733f3..3437a3605f 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -29,13 +29,14 @@ __version__ = "${VERSION}" _help = """ - ------------------------------------------------------------------------------- - | PUT | /fledge/plugin/{type}/{name}/update | - ------------------------------------------------------------------------------- + ------------------------------------------------------------------------ + | PUT | /fledge/plugins/{package_name} | + ------------------------------------------------------------------------ """ _logger = logger.setup(__name__, level=logging.INFO) +# only work with core 2.1.0 onwards version async def update_package(request: web.Request) -> web.Response: """ Update Package @@ -177,6 +178,7 @@ async def update_package(request: web.Request) -> web.Response: return web.json_response(final_response) +# only work with lesser or equal to version of core 2.1.0 version async def update_plugin(request: web.Request) -> web.Response: """ update plugin diff --git a/python/fledge/services/core/routes.py b/python/fledge/services/core/routes.py index c052d1ce5f..5084e96b0a 100644 --- a/python/fledge/services/core/routes.py +++ b/python/fledge/services/core/routes.py @@ -182,6 +182,7 @@ def setup(app): app.router.add_route('PUT', '/fledge/plugins/{type}/{name}/update', plugins_update.update_plugin) app.router.add_route('DELETE', '/fledge/plugins/{type}/{name}', plugins_remove.remove_plugin) else: + # routes available 2.1.0 onwards app.router.add_route('PUT', '/fledge/plugins/{package_name}', plugins_update.update_package) app.router.add_route('DELETE', '/fledge/plugins/{package_name}', plugins_remove.remove_package) # plugin data diff --git a/tests/unit/python/fledge/services/core/api/plugins/test_update.py b/tests/unit/python/fledge/services/core/api/plugins/test_update.py index d0576aacaf..d5a7fb22a5 100644 --- a/tests/unit/python/fledge/services/core/api/plugins/test_update.py +++ b/tests/unit/python/fledge/services/core/api/plugins/test_update.py @@ -13,15 +13,14 @@ from aiohttp import web -from fledge.services.core import routes -from fledge.services.core import server -from fledge.services.core import connect +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.plugin_discovery import PluginDiscovery +from fledge.common.storage_client.storage_client import StorageClientAsync +from fledge.services.core import connect, routes, server +from fledge.services.core.api import common from fledge.services.core.api.plugins import update as plugins_update from fledge.services.core.api.plugins.exceptions import * from fledge.services.core.scheduler.scheduler import Scheduler -from fledge.common.storage_client.storage_client import StorageClientAsync -from fledge.common.plugin_discovery import PluginDiscovery -from fledge.common.configuration_manager import ConfigurationManager __author__ = "Ashish Jabble" @@ -40,23 +39,23 @@ def client(self, loop, test_client): routes.setup(app) return loop.run_until_complete(test_client(app)) - @pytest.mark.parametrize("param", [ - "blah", - 1, - "notificationDelivery" - "notificationRule" - ]) + RUN_TESTS_BEFORE_210_VERSION = False if common.get_version() <= "2.1.0" else True + + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") + @pytest.mark.parametrize("param", ["blah", 1, "notificationDelivery", "notificationRule"]) async def test_bad_type_plugin(self, client, param): resp = await client.put('/fledge/plugins/{}/name/update'.format(param), data=None) assert 400 == resp.status assert "Invalid plugin type. Must be one of 'south' , north', 'filter', 'notify' or 'rule'" == resp.reason + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") @pytest.mark.parametrize("name", ["OMF", "omf", "Omf"]) async def test_bad_update_of_inbuilt_plugin(self, client, name): resp = await client.put('/fledge/plugins/north/{}/update'.format(name), data=None) assert 400 == resp.status assert "Cannot update an inbuilt OMF plugin." == resp.reason + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") @pytest.mark.parametrize("_type, plugin_installed_dirname", [ ('south', 'Random'), ('north', 'http_north') @@ -105,6 +104,7 @@ async def async_mock(return_value): assert payload == json.loads(args[1]) plugin_installed_patch.assert_called_once_with(_type, False) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") @pytest.mark.parametrize("_type, plugin_installed_dirname", [ ('south', 'Random'), ('north', 'http_north') @@ -123,6 +123,7 @@ async def test_plugin_not_found(self, client, _type, plugin_installed_dirname): plugin_installed_dirname) == resp.reason plugin_installed_patch.assert_called_once_with(_type, False) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") @pytest.mark.parametrize("_type, plugin_installed_dirname", [ ('south', 'Random'), ('north', 'http_north') @@ -201,6 +202,7 @@ async def async_mock(return_value): assert 'packages' == args[0] assert payload == json.loads(args[1]) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") @pytest.mark.parametrize("_type, plugin_installed_dirname", [ ('south', 'Random'), ('north', 'http_north') @@ -304,6 +306,7 @@ async def async_mock(return_value): assert 'packages' == args[0] assert payload == json.loads(args[1]) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") async def test_filter_plugin_update_when_not_in_use(self, client, _type='filter', plugin_installed_dirname='delta'): async def async_mock(return_value): return return_value @@ -382,6 +385,7 @@ async def async_mock(return_value): assert 'packages' == args[0] assert payload == json.loads(args[1]) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") async def test_filter_update_when_in_use(self, client, _type='filter', plugin_installed_dirname='delta'): async def async_mock(return_value): return return_value @@ -485,6 +489,7 @@ async def async_mock(return_value): assert 'packages' == args[0] assert payload == json.loads(args[1]) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") @pytest.mark.parametrize("_type, plugin_installed_dirname", [ ('notify', 'Telegram'), ('rule', 'OutOfBound') @@ -549,11 +554,12 @@ async def async_mock(return_value): assert 'update' == actual['action'] assert -1 == actual['status'] assert '' == actual['log_file_uri'] - plugin_installed_patch.assert_called_once_with(plugin_type_installed_dir, False) + plugin_installed_patch.assert_called_once_with(_type, False) args, kwargs = query_tbl_patch.call_args_list[0] assert 'packages' == args[0] assert payload == json.loads(args[1]) + @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") @pytest.mark.parametrize("_type, plugin_installed_dirname", [ ('notify', 'alexa'), ('rule', 'OutOfBound') @@ -655,7 +661,7 @@ async def async_mock(return_value): notification_name, plugin_installed_dirname, _type)) cat_value_patch.assert_called_once_with(notification_name) child_cat_patch.assert_called_once_with(parent_name) - plugin_installed_patch.assert_called_once_with(plugin_type_installed_dir, False) + plugin_installed_patch.assert_called_once_with(_type, False) args, kwargs = query_tbl_patch.call_args_list[0] assert 'packages' == args[0] assert payload == json.loads(args[1]) From 2d7fe2bfbf218009a36035724fcfe70710b6861f Mon Sep 17 00:00:00 2001 From: Innovative-ashwin <99904321+Innovative-ashwin@users.noreply.github.com> Date: Wed, 22 Mar 2023 17:29:49 +0530 Subject: [PATCH 212/499] FOGL-7584 (#1021) Signed-off-by: Innovative-ashwin --- C/common/storage_client.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/C/common/storage_client.cpp b/C/common/storage_client.cpp index 529571bcb6..d4b6cdc505 100644 --- a/C/common/storage_client.cpp +++ b/C/common/storage_client.cpp @@ -1860,6 +1860,7 @@ int StorageClient::insertTable(const string& schema, const string& tableName, co { try { ostringstream convert; + convert << "{ \"inserts\": [" ; for (std::vector::const_iterator it = values.cbegin(); it != values.cend(); ++it) { @@ -1869,6 +1870,7 @@ int StorageClient::insertTable(const string& schema, const string& tableName, co } convert << it->toJSON() ; } + convert << "]}"; char url[1000]; snprintf(url, sizeof(url), "/storage/schema/%s/table/%s", schema.c_str(), tableName.c_str()); From 2e728d92dd9eb53b1727ad579cfc9be3838120c5 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Wed, 22 Mar 2023 17:13:56 -0400 Subject: [PATCH 213/499] Added EDS version check Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/omf.cpp | 3 +- C/plugins/north/OMF/plugin.cpp | 150 +++++++++++++++++++++++++++------ 2 files changed, 126 insertions(+), 27 deletions(-) diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index fa4b826bdd..3f6a7f5d8b 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -1459,7 +1459,8 @@ uint32_t OMF::sendToServer(const vector& readings, */ // Create header for Readings data - vector> readingData = OMF::createMessageHeader("Data", "update"); + std::string action = (this->m_OMFVersion.compare("1.2") == 0) ? "update" : "create"; + vector> readingData = OMF::createMessageHeader("Data", action); if (compression) readingData.push_back(pair("compression", "gzip")); diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 2902578a84..b57f355337 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -431,7 +431,7 @@ typedef struct string prefixAFAsset; // Prefix to generate unique asste id string PIWebAPIProductTitle; - string PIWebAPIVersion; + string RestServerVersion; string PIWebAPIAuthMethod; // Authentication method to be used with the PI Web API. string PIWebAPICredentials; // Credentials is the base64 encoding of id and password joined by a single colon (:) string KerberosKeytab; // Kerberos authentication keytab file @@ -471,6 +471,7 @@ string AuthBasicCredentialsGenerate (string& userId, string& password); void AuthKerberosSetup (string& keytabFile, string& keytabFileName); string OCSRetrieveAuthToken (CONNECTOR_INFO* connInfo); int PIWebAPIGetVersion (CONNECTOR_INFO* connInfo, bool logMessage = true); +int EDSGetVersion (CONNECTOR_INFO* connInfo); double GetElapsedTime (struct timeval *startTime); bool IsPIWebAPIConnected (CONNECTOR_INFO* connInfo); void SetOMFVersion (CONNECTOR_INFO* connInfo); @@ -875,7 +876,7 @@ void plugin_start(const PLUGIN_HANDLE handle, { SetOMFVersion(connInfo); Logger::getLogger()->info("%s connected to %s OMF Version: %s", - connInfo->PIWebAPIVersion.c_str(), connInfo->hostAndPort.c_str(), connInfo->omfversion.c_str()); + connInfo->RestServerVersion.c_str(), connInfo->hostAndPort.c_str(), connInfo->omfversion.c_str()); s_connected = true; } else @@ -883,6 +884,12 @@ void plugin_start(const PLUGIN_HANDLE handle, s_connected = false; } } + else if (connInfo->PIServerEndpoint == ENDPOINT_EDS) + { + EDSGetVersion(connInfo); + SetOMFVersion(connInfo); + Logger::getLogger()->info("Edge Data Store %s OMF Version: %s", connInfo->RestServerVersion.c_str(), connInfo->omfversion.c_str()); + } else { SetOMFVersion(connInfo); @@ -986,18 +993,6 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, connInfo->omf->setPIServerEndpoint(connInfo->PIServerEndpoint); connInfo->omf->setDefaultAFLocation(connInfo->DefaultAFLocation); connInfo->omf->setAFMap(connInfo->AFMap); -#ifdef EDS_OMF_VERSION - if (connInfo->PIServerEndpoint == ENDPOINT_EDS) - { - connInfo->omfversion = EDS_OMF_VERSION; - } -#endif - - // Version for Connector Relay is 1.0 only. - if (connInfo->PIServerEndpoint == ENDPOINT_CR) - { - connInfo->omfversion = CR_OMF_VERSION; - } connInfo->omf->setOMFVersion(connInfo->omfversion); @@ -1553,30 +1548,98 @@ int PIWebAPIGetVersion(CONNECTOR_INFO* connInfo, bool logMessage) _PIWebAPI->setAuthMethod (connInfo->PIWebAPIAuthMethod); _PIWebAPI->setAuthBasicCredentials(connInfo->PIWebAPICredentials); - int httpCode = _PIWebAPI->GetVersion(connInfo->hostAndPort, connInfo->PIWebAPIVersion, logMessage); + int httpCode = _PIWebAPI->GetVersion(connInfo->hostAndPort, connInfo->RestServerVersion, logMessage); delete _PIWebAPI; return httpCode; } +static std::string ParseEDSProductInformation(std::string json) +{ + std::string version; + + Document doc; + + if (!doc.Parse(json.c_str()).HasParseError()) + { + try + { + if (doc.HasMember("Edge Data Store")) + { + const rapidjson::Value &EDS = doc["Edge Data Store"]; + version = EDS.GetString(); + } + } + catch (...) + { + } + } + + Logger::getLogger()->debug("Edge Data Store Version: %s JSON: %s", version.c_str(), json.c_str()); + return version; +} + +int EDSGetVersion(CONNECTOR_INFO *connInfo) +{ + int res; + + HttpSender *endPoint = new SimpleHttp(connInfo->hostAndPort, + connInfo->timeout, + connInfo->timeout, + connInfo->retrySleepTime, + connInfo->maxRetry); + + try + { + string path = "http://" + connInfo->hostAndPort + "/api/v1/diagnostics/productinformation"; + vector> headers; + connInfo->RestServerVersion.clear(); + + res = endPoint->sendRequest("GET", path, headers, std::string("")); + if (res >= 200 && res <= 299) + { + connInfo->RestServerVersion = ParseEDSProductInformation(endPoint->getHTTPResponse()); + } + } + catch (const BadRequest &ex) + { + Logger::getLogger()->error("Edge Data Store productinformation BadRequest exception: %s", ex.what()); + res = 400; + } + catch (const std::exception &ex) + { + Logger::getLogger()->error("Edge Data Store productinformation exception: %s", ex.what()); + res = 400; + } + catch (...) + { + Logger::getLogger()->error("Edge Data Store productinformation generic exception"); + res = 400; + } + + delete endPoint; + return res; +} + /** * Set the supported OMF Version for the OMF endpoint * * @param connInfo The CONNECTOR_INFO data structure */ -void SetOMFVersion(CONNECTOR_INFO* connInfo) +void SetOMFVersion(CONNECTOR_INFO *connInfo) { - if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) + switch (connInfo->PIServerEndpoint) { - if (connInfo->PIWebAPIVersion.find("2019") != std::string::npos) + case ENDPOINT_PIWEB_API: + if (connInfo->RestServerVersion.find("2019") != std::string::npos) { connInfo->omfversion = "1.0"; } - else if (connInfo->PIWebAPIVersion.find("2020") != std::string::npos) + else if (connInfo->RestServerVersion.find("2020") != std::string::npos) { connInfo->omfversion = "1.1"; } - else if (connInfo->PIWebAPIVersion.find("2021") != std::string::npos) + else if (connInfo->RestServerVersion.find("2021") != std::string::npos) { connInfo->omfversion = "1.2"; } @@ -1584,11 +1647,46 @@ void SetOMFVersion(CONNECTOR_INFO* connInfo) { connInfo->omfversion = "1.2"; } - } - else - { - // Assume all other OMF endpoint types support OMF Version 1.2 - connInfo->omfversion = "1.2"; + break; + case ENDPOINT_EDS: + // Edge Data Store versions with supported OMF versions: + // EDS 2020 (1.0.0.609) OMF 1.0, 1.1 + // EDS 2023 (1.1.1.46) OMF 1.0, 1.1, 1.2 + // EDS 2023 Patch 1 (1.1.3.2) OMF 1.0, 1.1, 1.2 + { + int major = 0; + int minor = 0; + size_t last = 0; + size_t next = connInfo->RestServerVersion.find(".", last); + if (next != string::npos) + { + major = atoi(connInfo->RestServerVersion.substr(last, next - last).c_str()); + last = next + 1; + next = connInfo->RestServerVersion.find(".", last); + if (next != string::npos) + { + minor = atoi(connInfo->RestServerVersion.substr(last, next - last).c_str()); + } + } + + if ((major > 1) || (major == 1 && minor > 0)) + { + connInfo->omfversion = "1.2"; + } + else + { + connInfo->omfversion = EDS_OMF_VERSION; + } + } + break; + case ENDPOINT_CR: + connInfo->omfversion = CR_OMF_VERSION; + break; + case ENDPOINT_OCS: + case ENDPOINT_ADH: + default: + connInfo->omfversion = "1.2"; // assume cloud service OMF endpoint types support OMF 1.2 + break; } } @@ -1776,7 +1874,7 @@ bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo) s_connected = true; SetOMFVersion(connInfo); Logger::getLogger()->info("%s reconnected to %s OMF Version: %s", - connInfo->PIWebAPIVersion.c_str(), connInfo->hostAndPort.c_str(), connInfo->omfversion.c_str()); + connInfo->RestServerVersion.c_str(), connInfo->hostAndPort.c_str(), connInfo->omfversion.c_str()); if (reported == true || reportedState == false) { reportedState = true; From abbc356a7679f6f89ed8cd92f90679a5f1e35175 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 23 Mar 2023 11:45:41 +0530 Subject: [PATCH 214/499] logger level fixes for common utils services for ping and common backup in creation Signed-off-by: ashish-jabble --- .../fledge/plugins/storage/common/backup.py | 2 +- python/fledge/services/common/utils.py | 5 +-- .../common/test_services_common_utils.py | 32 ++++++++----------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/python/fledge/plugins/storage/common/backup.py b/python/fledge/plugins/storage/common/backup.py index 4c22f2492b..94519c3cc2 100644 --- a/python/fledge/plugins/storage/common/backup.py +++ b/python/fledge/plugins/storage/common/backup.py @@ -211,7 +211,7 @@ async def create_backup(self): try: await server.Server.scheduler.queue_task(uuid.UUID(Backup._SCHEDULE_BACKUP_ON_DEMAND)) _message = self._MESSAGES_LIST["i000003"] - Backup._logger.info("{0}".format(_message)) + Backup._logger.debug("{0}".format(_message)) status = "running" except Exception as _ex: _message = self._MESSAGES_LIST["e000004"].format(_ex) diff --git a/python/fledge/services/common/utils.py b/python/fledge/services/common/utils.py index 327334870f..745ccd9d47 100644 --- a/python/fledge/services/common/utils.py +++ b/python/fledge/services/common/utils.py @@ -8,6 +8,7 @@ import aiohttp import asyncio +import logging from fledge.common import logger __author__ = "Amarendra Kumar Sinha" @@ -16,7 +17,7 @@ __version__ = "${VERSION}" -_logger = logger.setup(__name__, level=20) +_logger = logger.setup(__name__, level=logging.INFO) _MAX_ATTEMPTS = 15 """Number of max attempts for finding a heartbeat of service""" @@ -41,7 +42,7 @@ async def ping_service(service, loop=None): attempt_count += 1 await asyncio.sleep(1.5, loop=loop) if attempt_count <= _MAX_ATTEMPTS: - _logger.info('Ping received for Service %s id %s at url %s', service._name, service._id, url_ping) + _logger.debug('Ping received for Service %s id %s at url %s', service._name, service._id, url_ping) return True _logger.error('Ping not received for Service %s id %s at url %s attempt_count %s', service._name, service._id, url_ping, attempt_count) diff --git a/tests/unit/python/fledge/services/common/test_services_common_utils.py b/tests/unit/python/fledge/services/common/test_services_common_utils.py index 0112e7f73b..338eea8679 100644 --- a/tests/unit/python/fledge/services/common/test_services_common_utils.py +++ b/tests/unit/python/fledge/services/common/test_services_common_utils.py @@ -57,15 +57,14 @@ async def test_ping_service_pass(self, aiohttp_server, loop): await server.start_server(loop=loop) # WHEN the service is pinged with a valid URL - with patch.object(utils._logger, "info") as log: + with patch.object(utils._logger, "debug") as patch_logger: service = ServiceRecord("d", "test", "Southbound", "http", server.host, 1, server.port) url_ping = "{}://{}:{}/fledge/service/ping".format(service._protocol, service._address, service._management_port) log_params = 'Ping received for Service %s id %s at url %s', service._name, service._id, url_ping resp = await utils.ping_service(service, loop=loop) - - # THEN ping response is received - assert resp is True - log.assert_called_once_with(*log_params) + # THEN ping response is received + assert resp is True + patch_logger.assert_called_once_with(*log_params) async def test_ping_service_fail_bad_url(self, aiohttp_server, loop): # GIVEN a service is running at a given URL @@ -86,9 +85,8 @@ async def test_ping_service_fail_bad_url(self, aiohttp_server, loop): log_params = 'Ping not received for Service %s id %s at url %s attempt_count %s', service._name, service._id, \ url_ping, utils._MAX_ATTEMPTS+1 resp = await utils.ping_service(service, loop=loop) - - # THEN ping response is NOT received - assert resp is False + # THEN ping response is NOT received + assert resp is False log.assert_called_once_with(*log_params) async def test_shutdown_service_pass(self, aiohttp_server, loop): @@ -107,14 +105,12 @@ async def test_shutdown_service_pass(self, aiohttp_server, loop): service = ServiceRecord("d", "test", "Southbound", "http", server.host, 1, server.port) url_shutdown = "{}://{}:{}/fledge/service/shutdown".format(service._protocol, service._address, service._management_port) - log_params1 = "Shutting down the %s service %s ...", service._type, service._name - log_params2 = 'Service %s, id %s at url %s successfully shutdown', service._name, service._id, url_shutdown + log_params = 'Service %s, id %s at url %s successfully shutdown', service._name, service._id, url_shutdown resp = await utils.shutdown_service(service, loop=loop) - - # THEN shutdown returns success - assert resp is True - log.assert_called_with(*log_params2) + # THEN shutdown returns success + assert resp is True assert 2 == log.call_count + log.assert_called_with(*log_params) async def test_shutdown_service_fail_bad_url(self, aiohttp_server, loop): # GIVEN a service is running at a given URL @@ -133,8 +129,8 @@ async def test_shutdown_service_fail_bad_url(self, aiohttp_server, loop): service = ServiceRecord("d", "test", "Southbound", "http", server.host, 1, server.port+1) log_params1 = "Shutting down the %s service %s ...", service._type, service._name resp = await utils.shutdown_service(service, loop=loop) - - # THEN shutdown fails - assert resp is False + # THEN shutdown fails + assert resp is False + assert log2.called is True + assert log1.called is True log1.assert_called_with(*log_params1) - assert log2.called is True From 201a5ef8812bc0f6665c16ed395190dd0d3b4016 Mon Sep 17 00:00:00 2001 From: nandan Date: Thu, 23 Mar 2023 17:00:44 +0530 Subject: [PATCH 215/499] FOGL-7578: Added logic to show status of all C based tasks Signed-off-by: nandan --- scripts/fledge | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/fledge b/scripts/fledge index 74af95db0f..a27f075e21 100755 --- a/scripts/fledge +++ b/scripts/fledge @@ -534,7 +534,9 @@ fledge_status() { ps -ef | grep -v 'cpulimit*' | grep -o 'python3 -m fledge.tasks.*' | grep -o 'fledge.tasks.*' | grep -v 'fledge.tasks\.\*' || true # Show Tasks in C code - ps -ef | grep './tasks.' | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/sending_process "; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true + ps -ef | grep 'fledge/tasks/sending_process' | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/sending_process "; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true + ps -ef | grep 'fledge/tasks/purge_system' | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/purge_system "; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true + ps -ef | grep 'fledge/tasks/statistics_history' | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/statistics_history "; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true fi ;; *) From 5a6a3e3a4e53357b1bbcfa18d790e20f3a46fb7a Mon Sep 17 00:00:00 2001 From: nandan Date: Thu, 23 Mar 2023 17:22:10 +0530 Subject: [PATCH 216/499] FOGL-7578: further optimized by removing hard coded task names Signed-off-by: nandan --- scripts/fledge | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/fledge b/scripts/fledge index a27f075e21..362c9574d1 100755 --- a/scripts/fledge +++ b/scripts/fledge @@ -534,9 +534,10 @@ fledge_status() { ps -ef | grep -v 'cpulimit*' | grep -o 'python3 -m fledge.tasks.*' | grep -o 'fledge.tasks.*' | grep -v 'fledge.tasks\.\*' || true # Show Tasks in C code - ps -ef | grep 'fledge/tasks/sending_process' | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/sending_process "; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true - ps -ef | grep 'fledge/tasks/purge_system' | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/purge_system "; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true - ps -ef | grep 'fledge/tasks/statistics_history' | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/statistics_history "; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true + for task_name in `ls ${FLEDGE_ROOT}/tasks` + do + ps -ef | grep 'fledge/tasks/$task_name' | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/$task_name "; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true + done fi ;; *) From 8939a8920cd2e336e087376b1d7fc8832f2ca017 Mon Sep 17 00:00:00 2001 From: nandan Date: Thu, 23 Mar 2023 20:23:31 +0530 Subject: [PATCH 217/499] FOGL-7578: Fixed review comment Signed-off-by: nandan --- scripts/fledge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/fledge b/scripts/fledge index 362c9574d1..7ae3b776a5 100755 --- a/scripts/fledge +++ b/scripts/fledge @@ -536,7 +536,7 @@ fledge_status() { # Show Tasks in C code for task_name in `ls ${FLEDGE_ROOT}/tasks` do - ps -ef | grep 'fledge/tasks/$task_name' | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/$task_name "; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true + ps -ef | grep "fledge/tasks/$task_name" | grep -v python3 | grep -v grep | grep -v awk | awk -v tn="tasks/$task_name " '{printf tn ; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true done fi ;; From ba52c62bbbf685f7c604cd4689e6ee5dcd42f01a Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Thu, 23 Mar 2023 23:04:09 +0530 Subject: [PATCH 218/499] cleanup Signed-off-by: Praveen Garg --- python/fledge/services/core/api/service.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index e4a2819966..1eb41e8118 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -594,12 +594,8 @@ async def update_service(request: web.Request) -> web.Response: if _type not in ('notification', 'dispatcher', 'bucket_storage', 'management'): raise ValueError("Invalid service type.") - # process_name for bucket storage service schedule is bucket_storage_c hence type here must be bucket_storage? - # process_name for management service schedule is management and python based; Check added for schedule stuff - # NOTE: `bucketstorage` repository name with `BucketStorage` type in service registry has package name *-`bucket`. - # URL: /fledge/service/bucket_storage/bucket/update ?! *EXTERNAL* - # URL: /fledge/service/management/management/update ?! *EXTERNAL* + # URL: /fledge/service/bucket_storage/bucket/update # Check requested service is installed or not installed_services = get_service_installed() @@ -647,7 +643,7 @@ async def update_service(request: web.Request) -> web.Response: result = await storage_client.insert_into_tbl("packages", insert_payload) if result['response'] == "inserted" and result['rows_affected'] == 1: pn = "{}-{}".format(action, name) - # Protocol is always http:// on core_management_port + # Scheme is always http:// on core_management_port p = multiprocessing.Process(name=pn, target=do_update, args=(server.Server.is_rest_server_http_enabled, server.Server._host, server.Server.core_management_port, @@ -694,7 +690,7 @@ def do_update(http_enabled: bool, host: str, port: int, storage: connect, pkg_na cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) # Protocol is always http:// on core_management_port - protocol = "HTTP" # if http_enabled else "HTTPS" + protocol = "HTTP" if pkg_mgt == 'yum': cmd = "sudo {} check-update > {} 2>&1".format(pkg_mgt, stdout_file_path) From 6124e377b6e1f366b843fa091003083754df362f Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 24 Mar 2023 16:07:48 +0530 Subject: [PATCH 219/499] FOGL-7578: Updated search pattern for task names Signed-off-by: nandan --- scripts/fledge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/fledge b/scripts/fledge index 7ae3b776a5..8eb9fed76f 100755 --- a/scripts/fledge +++ b/scripts/fledge @@ -536,7 +536,7 @@ fledge_status() { # Show Tasks in C code for task_name in `ls ${FLEDGE_ROOT}/tasks` do - ps -ef | grep "fledge/tasks/$task_name" | grep -v python3 | grep -v grep | grep -v awk | awk -v tn="tasks/$task_name " '{printf tn ; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true + ps -ef | grep "./tasks/$task_name" | grep -v python3 | grep -v grep | grep -v awk | awk '{printf "tasks/'$task_name' " ; for(i=9;i<=NF;++i) printf $i FS; printf "\n"}' || true done fi ;; From 5e73c289130d2bc5ecd877fcb65f1a0bd5ffaa2a Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 24 Mar 2023 18:25:31 +0530 Subject: [PATCH 220/499] Fledge packages update API modified Signed-off-by: ashish-jabble --- scripts/extras/update_task.apt | 77 ++++++++++++++++------------------ 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/scripts/extras/update_task.apt b/scripts/extras/update_task.apt index 0c3f06fc1a..8bf577d488 100755 --- a/scripts/extras/update_task.apt +++ b/scripts/extras/update_task.apt @@ -39,13 +39,13 @@ trap "" 1 2 3 6 15 # Check availability of FLEDGE_ROOT directory if [ ! -d "${FLEDGE_ROOT}" ]; then - write_log "" "$0" "err" "home directory missing or incorrectly set environment" "logonly" + write_log "" "$0" "err" "home directory missing or incorrectly set environment." "logonly" exit 1 fi # Check availability of FLEDGE_DATA directory if [ ! -d "${FLEDGE_DATA}" ]; then - write_log "" "$0" "err" "Data directory is missing or incorrectly set environment" "logonly" + write_log "" "$0" "err" "Data directory is missing or incorrectly set environment." "logonly" exit 1 fi @@ -60,7 +60,7 @@ fledge_stop() { STOP_FLEDGE_CMD_STATUS=`$STOP_FLEDGE_CMD` sleep 15 if [ "${STOP_FLEDGE_CMD_STATUS}" = "" ]; then - write_log "" "$0" "err" "cannot run \"${STOP_FLEDGE_CMD}\" command" "logonly" + write_log "" "$0" "err" "cannot run \"${STOP_FLEDGE_CMD}\" command." "logonly" exit 1 fi } @@ -69,7 +69,7 @@ fledge_stop() { run_update() { # Download and update the package information from all of the configured sources UPDATE_CMD="sudo apt -y update" - write_log "" "$0" "info" "Executing ${UPDATE_CMD} command..." "logonly" + write_log "" "$0" "debug" "Executing ${UPDATE_CMD} ..." "logonly" UPDATE_CMD_OUT=`$UPDATE_CMD` UPDATE_CMD_STATUS="$?" if [ "$UPDATE_CMD_STATUS" != "0" ]; then @@ -79,23 +79,19 @@ run_update() { } run_upgrade() { - # Check GUI package is installed or not - if ! dpkg -l | grep fledge-gui; then - UPGRADE_CMD="sudo apt-get -y install fledge" - PKG_NAME="fledge" - else - PKG_NAME="fledge, fledge-gui" - UPGRADE_CMD="sudo apt-get -y install fledge fledge-gui" - fi - - # Upgrade Package - write_log "" "$0" "info" "Executing ${UPGRADE_CMD} command..." "logonly" - UPGRADE_CMD_OUT=`$UPGRADE_CMD` + # Upgrade Packages + PACKAGES_LIST=$(cat ${FLEDGE_DATA}/.upgradable) + UPGRADE_CMD="sudo apt -y upgrade $PACKAGES_LIST" + write_log "" "$0" "debug" "Executing ${UPGRADE_CMD} ..." "logonly" + UPGRADE_CMD_OUT=$($UPGRADE_CMD) UPGRADE_CMD_STATUS="$?" if [ "$UPGRADE_CMD_STATUS" != "0" ]; then + $(rm -rf ${FLEDGE_DATA}/.upgradable) write_log "" "$0" "err" "Failed on $UPGRADE_CMD. Exit: $UPGRADE_CMD_STATUS. Out: $UPGRADE_CMD_OUT" "all" "pretty" exit 1 fi + msg="'$PACKAGES_LIST' packages upgraded successfully!" + write_log "" "$0" "info" "$msg" "all" "pretty" UPGRADE_DONE="Y" } @@ -108,9 +104,6 @@ fledge_start() { write_log "" "$0" "err" "Failed on $START_FLEDGE_CMD. Exit: $START_FLEDGE_CMD_STATUS. Out: $START_FLEDGE_CMD_OUT" "all" "pretty" exit 1 fi - - msg="'${PKG_NAME}' package updated successfully!" - write_log "" "$0" "info" "$msg" "all" "pretty" } # Find the local timestamp @@ -123,15 +116,10 @@ print (varDateTime) END } -# Find the REST API URL -get_rest_api_url () { - PID_FILE=${FLEDGE_DATA}/var/run/fledge.core.pid - REST_API_URL=`cat ${PID_FILE} | python3 ${FLEDGE_ROOT}/scripts/common/json_parse.py get_rest_api_url_from_pid` -} - # CREATE Audit trail entry for update package audit_trail_entry () { - SQL_DATA="log(code, level, log) VALUES('PKGUP', 4, '{\"packageName\": \"${PKG_NAME}\"}');" + AUDIT_PACKAGES_LIST=$(echo $PACKAGES_LIST | sed -e 's/ /, /g') + SQL_DATA="log(code, level, log) VALUES('PKGUP', 4, '{\"packageName\": \"${AUDIT_PACKAGES_LIST}\"}');" # Find storage engine value STORAGE=`${FLEDGE_ROOT}/services/fledge.services.storage --plugin | awk '{print $1}'` if [ "${STORAGE}" = "postgres" ]; then @@ -147,11 +135,12 @@ audit_trail_entry () { ADD_AUDIT_LOG_STATUS="$?" if [ "$ADD_AUDIT_LOG_STATUS" != "0" ]; then + $(rm -rf ${FLEDGE_DATA}/.upgradable) write_log "" "$0" "err" "Failed on execution of ${INSERT_SQL}. Exit: ${ADD_AUDIT_LOG_STATUS}." "all" "pretty" exit 1 else - msg="Audit trail entry created for '${PKG_NAME}' package update!" - write_log "" "$0" "info" "$msg" "all" "pretty" + $(rm -rf ${FLEDGE_DATA}/.upgradable) + msg="Audit trail entry created for '${AUDIT_PACKAGES_LIST}' packages upgrade!" fi } @@ -181,32 +170,40 @@ update_task() { write_log "" "$0" "err" "Failed on execution of ${UPDATE_SQL_QUERY} in engine '${STORAGE}'. Exit: $UPDATE_TASK_STATUS." "all" "pretty" exit 1 else - msg="'$SCHEDULE_NAME' task state updated successfully" - write_log "" "$0" "debug" "$msg" "all" "pretty" + msg="'$SCHEDULE_NAME' task state updated successfully." + write_log "" "$0" "info" "$msg" "all" "pretty" fi } # Upgrade check upgrade_check() { - # System update request - run_update - - UPGRADE_CHECK="sudo apt list --upgradable" - write_log "" "$0" "info" "Executing ${UPGRADE_CHECK} command..." "logonly" - UPGRADE_CMD_OUT=`$UPGRADE_CHECK 2> /dev/null | grep 'fledge/'` + # Find the upgradable list of fledge packages + UPGRADABLE_LIST="sudo apt list --upgradable | grep ^fledge" + write_log "" "$0" "debug" "Executing $UPGRADABLE_LIST ..." "logonly" + UPGRADE_CMD_OUT=$(eval $UPGRADABLE_LIST) UPGRADE_CMD_STATUS="$?" write_log "" "$0" "debug" "Upgrade check result [$UPGRADE_CMD_OUT], retcode $UPGRADE_CMD_STATUS" "all" "pretty" if [ "$UPGRADE_CMD_STATUS" != "0" ]; then - write_log "" "$0" "info" "No new Fledge package to upgrade" "all" "pretty" - echo 0 + write_log "" "$0" "info" "No new Fledge packages to upgrade." "all" "pretty" + echo 0 else - echo 1 + while IFS= read -r line + do + if [[ "$line" == fledge* ]]; then + pkg=$(echo $line | cut -d "/" -f 1) + PACKAGES_LIST+=" $pkg" + fi + done < <(printf '%s\n' "$UPGRADE_CMD_OUT") + echo $PACKAGES_LIST > ${FLEDGE_DATA}/.upgradable + echo 1 fi } # Main -DO_UPGRADE=`upgrade_check` +DO_UPDATE=$(run_update) + +DO_UPGRADE=$(upgrade_check) if [ "$DO_UPGRADE" = "1" ]; then # Stop Fledge From aaa41ca4170064599938dba74551091468ba7beb Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Fri, 24 Mar 2023 10:01:20 -0400 Subject: [PATCH 221/499] Fixed typos in OMF base types JSON Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/include/basetypes.h | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/C/plugins/north/OMF/include/basetypes.h b/C/plugins/north/OMF/include/basetypes.h index df6e33ee6a..48395acfae 100644 --- a/C/plugins/north/OMF/include/basetypes.h +++ b/C/plugins/north/OMF/include/basetypes.h @@ -52,7 +52,7 @@ static const char *baseOMFTypes = QUOTE( "properties":{ "Integer16":{ "type":["integer","null"], - "format":"int16", + "format":"int16" }, "Time":{ "type":"string", @@ -68,7 +68,7 @@ static const char *baseOMFTypes = QUOTE( "properties":{ "Integer32":{ "type":["integer","null"], - "format":"int32", + "format":"int32" }, "Time":{ "type":"string", @@ -84,7 +84,7 @@ static const char *baseOMFTypes = QUOTE( "properties":{ "Integer64":{ "type":["integer","null"], - "format":"int64", + "format":"int64" }, "Time":{ "type":"string", @@ -100,7 +100,7 @@ static const char *baseOMFTypes = QUOTE( "properties":{ "UInteger16":{ "type":["integer","null"], - "format":"uint16", + "format":"uint16" }, "Time":{ "type":"string", @@ -116,7 +116,7 @@ static const char *baseOMFTypes = QUOTE( "properties":{ "UInteger32":{ "type":["integer","null"], - "format":"uint32", + "format":"uint32" }, "Time":{ "type":"string", @@ -132,7 +132,7 @@ static const char *baseOMFTypes = QUOTE( "properties":{ "UInteger64":{ "type":["integer","null"], - "format":"uint64", + "format":"uint64" }, "Time":{ "type":"string", From ec0246cf8f827c14f2e9136a78e54829be491cd6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 27 Mar 2023 12:15:57 +0530 Subject: [PATCH 222/499] FledgeUpdater schedule process name exclusion to avoid dryrun on core startup Signed-off-by: ashish-jabble --- python/fledge/services/core/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 7a4fed0298..a0ef881d72 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -938,8 +938,8 @@ def _start_core(cls, loop=None): # dryrun execution of all the tasks that are installed but have schedule type other than STARTUP schedule_list = loop.run_until_complete(cls.scheduler.get_schedules()) for sch in schedule_list: - # STARTUP type exclusion - if int(sch.schedule_type) != 1: + # STARTUP type schedules and special FledgeUpdater schedule process name exclusion to avoid dryrun + if int(sch.schedule_type) != 1 and sch.process_name != "FledgeUpdater": schedule_row = cls.scheduler._ScheduleRow( id=sch.schedule_id, name=sch.name, From 7530248f115d8a0a29e651c394111ca1093236c0 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Mon, 27 Mar 2023 14:29:18 +0530 Subject: [PATCH 223/499] FOGL-7533_patch - Improvements for Lab Script (#1020) --- tests/system/lab/README.rst | 37 +++++++++++++++++++++++++---- tests/system/lab/reset | 40 ++++---------------------------- tests/system/lab/run | 2 +- tests/system/lab/run_until_fails | 8 ++----- tests/system/lab/test.config | 2 +- 5 files changed, 42 insertions(+), 47 deletions(-) diff --git a/tests/system/lab/README.rst b/tests/system/lab/README.rst index 0fd6bf416c..80596b28e5 100644 --- a/tests/system/lab/README.rst +++ b/tests/system/lab/README.rst @@ -18,10 +18,39 @@ To run the test for required (say 10) iterations or until it fails - execute `./ **`run` and `run_until_fails` use the following scripts in its execution:** -**remove**: apt removes all fledge packages; deletes /usr/local/fledge; +- **remove**: apt removes all fledge packages; deletes /usr/local/fledge; -**install**: apt update; install fledge; install gui; install other fledge packages +- **install**: apt update; install fledge; install gui; install other fledge packages -**test**: curl commands to simulate all gui actions in the lab (except game) +- **test**: curl commands to simulate all gui actions in the lab (except game) -**reset**: Reset script is to stop fledge; reset the db and delete any python scripts. \ No newline at end of file +- **reset**: Reset script is to stop fledge; reset the db and delete any python scripts. + + +**`test.config` contains following variables that are used by `test` scripts in its execution:** + +- **FLEDGE_IP**: IP Address of the system on which fledge is running. + +- **PI_IP**: IP Address of PI Web API. + +- **PI_USER**: Username used for accessing PI Web API. + +- **PI_PASSWORD**: Password used for PI Web API. + +- **PI_PORT**: Port number of PI Web API on which fledge will connect. + +- **PI_DB**: Database in wihch PI Point is to be stored. + +- **MAX_RETRIES**: Retries to check data and info via API before declaring it failed to see the expected. + +- **SLEEP_FIX**: Time to sleep to fix bugs. This should be zero. + +- **EXIT_EARLY**: It is a Boolean variable, if contains value '1' then test will stop execution as soon as any error occur. + +- **ADD_NORTH_AS_SERVICE**: This variable defines whether North(OMF) is created as a task or a service. + +- **VERIFY_EGRESS_TO_PI**: It is a Boolean variable, if contains value '1' then North(OMF) is created and data sent to PI Web API will be verified. + +- **STORAGE**: This variable defines the storage plugin for configuration used by fledge, i.e. sqlite, sqlitelb, postgres. + +- **READING_PLUGIN_DB**: This variable by default contains "Use main plugin" that mean READING_PLUGIN_DB will be the same that used in `STORAGE` variable. Apart of "Use main plugin", it may also contain sqlite, sqlitelb, sqlite-in-memory, postgres values. diff --git a/tests/system/lab/reset b/tests/system/lab/reset index 61fa279334..8967b1ba09 100755 --- a/tests/system/lab/reset +++ b/tests/system/lab/reset @@ -7,27 +7,6 @@ install_postgres() { sudo -u postgres createuser -d "$(whoami)" } -_postgres() { - install_postgres - [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "postgres" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true - [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true -} - -_sqliteinmemory () { - [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlite" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true - [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "sqlitememory" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true -} - -_sqlite () { - [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlite" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true - [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true -} - -_sqlitelb () { - [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg STORAGE_PLUGIN_VAL "sqlitelb" '.plugin.value=$STORAGE_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true - [[ -f $FLEDGE_ROOT/data/etc/storage.json ]] && echo $(jq -c --arg READING_PLUGIN_VAL "Use main plugin" '.readingPlugin.value=$READING_PLUGIN_VAL' $FLEDGE_ROOT/data/etc/storage.json) > $FLEDGE_ROOT/data/etc/storage.json || true -} - _config_reading_db () { if [[ "postgres" == @($1|$2) ]] then @@ -40,21 +19,12 @@ _config_reading_db () { # check for storage plugin . ./test.config -if [[ ${STORAGE} == "postgres" && ${READING_PLUGIN_DB} == "Use main plugin" ]] -then - _postgres -elif [[ ${STORAGE} == "sqlite-in-memory" && ${READING_PLUGIN_DB} == "Use main plugin" ]] -then - _sqliteinmemory -elif [[ ${STORAGE} == "sqlitelb" && ${READING_PLUGIN_DB} == "Use main plugin" ]] -then - _sqlitelb -elif [[ ${STORAGE} == "sqlite" && ${READING_PLUGIN_DB} == "Use main plugin" ]] -then - _sqlite -elif [[ ${STORAGE} == @(sqlite|postgres|sqlitelb) && ${READING_PLUGIN_DB} != "Use main plugin" ]] +if [[ ${STORAGE} == @(sqlite|postgres|sqlitelb) && ${READING_PLUGIN_DB} == @(Use main plugin|sqlitememory|sqlite|postgres|sqlitelb) ]] then - _config_reading_db ${STORAGE} ${READING_PLUGIN_DB} + _config_reading_db "${STORAGE}" "${READING_PLUGIN_DB}" +else + echo "Invalid Storage Configuration" + exit 1 fi echo "Stopping Fledge using systemctl ..." diff --git a/tests/system/lab/run b/tests/system/lab/run index 835b853e44..3a4981d78f 100755 --- a/tests/system/lab/run +++ b/tests/system/lab/run @@ -11,5 +11,5 @@ fi ./remove ./install ${VERSION} -./reset +./reset || exit 1 ./test diff --git a/tests/system/lab/run_until_fails b/tests/system/lab/run_until_fails index 1d92e45286..f0afd387bc 100755 --- a/tests/system/lab/run_until_fails +++ b/tests/system/lab/run_until_fails @@ -25,11 +25,7 @@ for i in $(seq ${ITERATIONS}); do echo "Run $i" echo "***************" echo "***************" - ./reset - ./test - if [[ $? -ne 0 ]] - then - exit 1 - fi + ./reset || exit 1 + ./test || exit 1 done diff --git a/tests/system/lab/test.config b/tests/system/lab/test.config index 525b856155..e0530db391 100755 --- a/tests/system/lab/test.config +++ b/tests/system/lab/test.config @@ -10,4 +10,4 @@ EXIT_EARLY=0 ADD_NORTH_AS_SERVICE=true VERIFY_EGRESS_TO_PI=1 STORAGE=sqlite # postgres, sqlite-in-memory, sqlitelb -READING_PLUGIN_DB="Use main plugin" +READING_PLUGIN_DB='Use main plugin' From 59d50ce245e0d3b9a945b1db911bf81efa98300f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 27 Mar 2023 15:12:22 +0530 Subject: [PATCH 224/499] debug statements are removed/disabled Signed-off-by: ashish-jabble --- scripts/extras/update_task.apt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/extras/update_task.apt b/scripts/extras/update_task.apt index 8bf577d488..ffd9e9a1f2 100755 --- a/scripts/extras/update_task.apt +++ b/scripts/extras/update_task.apt @@ -69,7 +69,6 @@ fledge_stop() { run_update() { # Download and update the package information from all of the configured sources UPDATE_CMD="sudo apt -y update" - write_log "" "$0" "debug" "Executing ${UPDATE_CMD} ..." "logonly" UPDATE_CMD_OUT=`$UPDATE_CMD` UPDATE_CMD_STATUS="$?" if [ "$UPDATE_CMD_STATUS" != "0" ]; then @@ -82,7 +81,6 @@ run_upgrade() { # Upgrade Packages PACKAGES_LIST=$(cat ${FLEDGE_DATA}/.upgradable) UPGRADE_CMD="sudo apt -y upgrade $PACKAGES_LIST" - write_log "" "$0" "debug" "Executing ${UPGRADE_CMD} ..." "logonly" UPGRADE_CMD_OUT=$($UPGRADE_CMD) UPGRADE_CMD_STATUS="$?" if [ "$UPGRADE_CMD_STATUS" != "0" ]; then @@ -179,10 +177,10 @@ update_task() { upgrade_check() { # Find the upgradable list of fledge packages UPGRADABLE_LIST="sudo apt list --upgradable | grep ^fledge" - write_log "" "$0" "debug" "Executing $UPGRADABLE_LIST ..." "logonly" + # write_log "" "$0" "debug" "Executing $UPGRADABLE_LIST ..." "logonly" UPGRADE_CMD_OUT=$(eval $UPGRADABLE_LIST) UPGRADE_CMD_STATUS="$?" - write_log "" "$0" "debug" "Upgrade check result [$UPGRADE_CMD_OUT], retcode $UPGRADE_CMD_STATUS" "all" "pretty" + # write_log "" "$0" "debug" "Upgrade check result [$UPGRADE_CMD_OUT], retcode $UPGRADE_CMD_STATUS" "all" "pretty" if [ "$UPGRADE_CMD_STATUS" != "0" ]; then write_log "" "$0" "info" "No new Fledge packages to upgrade." "all" "pretty" echo 0 From 23af7d29c1c4f9428d542051cc7c52a3c3e5e4df Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Wed, 29 Mar 2023 18:53:25 +0530 Subject: [PATCH 225/499] Fixed Assertion condition (#1027) Signed-off-by: Mohit Singh Tomar --- tests/system/python/packages/test_omf_north_service.py | 2 +- tests/system/python/pair/test_c_north_service_pair.py | 2 +- tests/system/python/pair/test_pyton_north_service_pair.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/system/python/packages/test_omf_north_service.py b/tests/system/python/packages/test_omf_north_service.py index 77d4a24fe1..f62e0ce042 100644 --- a/tests/system/python/packages/test_omf_north_service.py +++ b/tests/system/python/packages/test_omf_north_service.py @@ -636,7 +636,7 @@ def test_omf_service_with_delete_add_filter(self, reset_fledge, start_south_nort delete_url = "/fledge/filter/{}".format(filter1_name) resp = utils.delete_request(fledge_url, delete_url) - assert "Filter {} deleted successfully".format(filter1_name) == resp['result'] + assert "Filter {} deleted successfully.".format(filter1_name) == resp['result'] filter_cfg_scale = {"enable": "true"} add_filter("scale", None, filter1_name, filter_cfg_scale, fledge_url, north_service_name, diff --git a/tests/system/python/pair/test_c_north_service_pair.py b/tests/system/python/pair/test_c_north_service_pair.py index 8f086d4f8f..8f1cf5ddb5 100644 --- a/tests/system/python/pair/test_c_north_service_pair.py +++ b/tests/system/python/pair/test_c_north_service_pair.py @@ -667,7 +667,7 @@ def test_north_C_service_with_delete_add_filter(self, setup_local, setup_remote, delete_url = "/fledge/filter/{}".format(filter_name) resp = utils.delete_request(fledge_url, urllib.parse.quote(delete_url)) - assert "Filter {} deleted successfully".format(filter_name) == resp['result'] + assert "Filter {} deleted successfully.".format(filter_name) == resp['result'] # Re-add filter in enabled mode filter_cfg_scale = {"enable": "true"} diff --git a/tests/system/python/pair/test_pyton_north_service_pair.py b/tests/system/python/pair/test_pyton_north_service_pair.py index d2a4884a4b..818372fef6 100644 --- a/tests/system/python/pair/test_pyton_north_service_pair.py +++ b/tests/system/python/pair/test_pyton_north_service_pair.py @@ -669,7 +669,7 @@ def test_north_python_service_with_delete_add_filter(self, setup_local, setup_re delete_url = "/fledge/filter/{}".format(filter_name) resp = utils.delete_request(fledge_url, urllib.parse.quote(delete_url)) - assert "Filter {} deleted successfully".format(filter_name) == resp['result'] + assert "Filter {} deleted successfully.".format(filter_name) == resp['result'] # Re-add filter in enabled mode filter_cfg_scale = {"enable": "true"} From d875821d10e298b1ebe26184a47605d83abe752d Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Wed, 29 Mar 2023 11:38:16 -0400 Subject: [PATCH 226/499] Add documentation to EDS version lookup Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/plugin.cpp | 51 +++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index b57f355337..02679663b6 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -1554,6 +1554,37 @@ int PIWebAPIGetVersion(CONNECTOR_INFO* connInfo, bool logMessage) return httpCode; } +/** + * Finds major and minor product version numbers in a version string + * + * @param versionString Version string of the form x.x.x.x where x's are integers + * @param major Major product version returned (first digit) + * @param minor Minor product version returned (second digit) + */ +static void ParseProductVersion(std::string &versionString, int *major, int *minor) +{ + *major = 0; + *minor = 0; + size_t last = 0; + size_t next = versionString.find(".", last); + if (next != string::npos) + { + *major = atoi(versionString.substr(last, next - last).c_str()); + last = next + 1; + next = versionString.find(".", last); + if (next != string::npos) + { + *minor = atoi(versionString.substr(last, next - last).c_str()); + } + } +} + +/** + * Parses the Edge Data Store version string from the /productinformation REST response + * + * @param json REST response from /api/v1/diagnostics/productinformation + * @return version Edge Data Store version string + */ static std::string ParseEDSProductInformation(std::string json) { std::string version; @@ -1579,6 +1610,12 @@ static std::string ParseEDSProductInformation(std::string json) return version; } +/** + * Calls the Edge Data Store product information endpoint to get the EDS version + * + * @param connInfo The CONNECTOR_INFO data structure + * @return HttpCode REST response code + */ int EDSGetVersion(CONNECTOR_INFO *connInfo) { int res; @@ -1656,19 +1693,7 @@ void SetOMFVersion(CONNECTOR_INFO *connInfo) { int major = 0; int minor = 0; - size_t last = 0; - size_t next = connInfo->RestServerVersion.find(".", last); - if (next != string::npos) - { - major = atoi(connInfo->RestServerVersion.substr(last, next - last).c_str()); - last = next + 1; - next = connInfo->RestServerVersion.find(".", last); - if (next != string::npos) - { - minor = atoi(connInfo->RestServerVersion.substr(last, next - last).c_str()); - } - } - + ParseProductVersion(connInfo->RestServerVersion, &major, &minor); if ((major > 1) || (major == 1 && minor > 0)) { connInfo->omfversion = "1.2"; From 4b6ae7f664f9bf1c436215415688caca5d8c9abd Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 30 Mar 2023 13:28:34 +0530 Subject: [PATCH 227/499] plugin type fixes incase of notification delivery and rule plugins in Remove plugin API having lesser or equal 2.1.0 version Signed-off-by: ashish-jabble --- python/fledge/services/core/api/plugins/remove.py | 10 +--------- .../fledge/services/core/api/plugins/test_remove.py | 2 +- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index ffd2f8dd29..5784917673 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -205,14 +205,8 @@ async def remove_plugin(request: web.Request) -> web.Response: # only OMF is an inbuilt plugin if name.lower() == 'omf': raise ValueError("Cannot delete an inbuilt {} plugin.".format(name.upper())) - if plugin_type == 'notify': - installed_dir_name = 'notificationDelivery' - elif plugin_type == 'rule': - installed_dir_name = 'notificationRule' - else: - installed_dir_name = plugin_type result_payload = {} - installed_plugins = PluginDiscovery.get_plugins_installed(installed_dir_name, False) + installed_plugins = PluginDiscovery.get_plugins_installed(plugin_type, False) plugin_info = [(_plugin["name"], _plugin["packageName"], _plugin["version"]) for _plugin in installed_plugins] package_name = "fledge-{}-{}".format(plugin_type, name.lower().replace("_", "-")) plugin_found = False @@ -225,7 +219,6 @@ async def remove_plugin(request: web.Request) -> web.Response: break if not plugin_found: raise KeyError("Invalid plugin name {} or plugin is not installed.".format(name)) - if plugin_type in ['notify', 'rule']: notification_instances_plugin_used_in = await _check_plugin_usage_in_notification_instances(name) if notification_instances_plugin_used_in: @@ -387,7 +380,6 @@ async def _put_refresh_cache(protocol: str, host: int, port: int) -> None: def purge_plugin(plugin_type: str, plugin_name: str, pkg_name: str, version: str, uid: uuid, storage: connect) -> tuple: from fledge.services.core.server import Server - _logger.info("{} plugin remove started...".format(pkg_name)) is_package = True stdout_file_path = '' diff --git a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py index 20eb202dfa..e105ccb6b3 100644 --- a/tests/unit/python/fledge/services/core/api/plugins/test_remove.py +++ b/tests/unit/python/fledge/services/core/api/plugins/test_remove.py @@ -153,7 +153,7 @@ async def async_mock(return_value): assert 1 == patch_logger.call_count patch_logger.assert_called_once_with(expected_msg) plugin_usage_patch.assert_called_once_with(plugin_installed_dirname) - plugin_installed_patch.assert_called_once_with(plugin_type_installed_dir, False) + plugin_installed_patch.assert_called_once_with(plugin_type, False) @pytest.mark.skipif(RUN_TESTS_BEFORE_210_VERSION, reason="requires lesser or equal to core 2.1.0 version") async def test_package_already_in_progress(self, client): From 6aab0e4350a13225c80aaf52531d9cbf6768bc14 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Thu, 30 Mar 2023 19:13:00 +0530 Subject: [PATCH 228/499] FOGL-7431 Fixed Assertion message in system tests. (#1028) * Fixed Assertion condition Signed-off-by: Mohit Singh Tomar * Fixed assertion messege in test_authentication.py file Signed-off-by: Mohit Singh Tomar --------- Signed-off-by: Mohit Singh Tomar --- .../python/packages/test_authentication.py | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/system/python/packages/test_authentication.py b/tests/system/python/packages/test_authentication.py index cc44a0bb2a..77e56a9205 100644 --- a/tests/system/python/packages/test_authentication.py +++ b/tests/system/python/packages/test_authentication.py @@ -586,10 +586,10 @@ def test_get_roles_with_certificate_token(self, fledge_url): @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, {'user': {'userName': 'any1', 'userId': 3, 'roleId': 2, 'accessMethod': 'any', 'realName': 'AJ', - 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully'}), + 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully.'}), ({"username": "admin1", "password": "F0gl@mp!", "role_id": 1}, {'user': {'userName': 'admin1', 'userId': 4, 'roleId': 1, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'admin1 user has been created successfully'}) + 'description': ''}, 'message': 'admin1 user has been created successfully.'}) ]) def test_create_user_with_password_token(self, fledge_url, form_data, expected_values): conn = http.client.HTTPConnection(fledge_url) @@ -604,10 +604,10 @@ def test_create_user_with_password_token(self, fledge_url, form_data, expected_v @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any2", "password": "User@123", "real_name": "PG", "description": "Nerd user"}, {'user': {'userName': 'any2', 'userId': 5, 'roleId': 2, 'accessMethod': 'any', 'realName': 'PG', - 'description': 'Nerd user'}, 'message': 'any2 user has been created successfully'}), + 'description': 'Nerd user'}, 'message': 'any2 user has been created successfully.'}), ({"username": "admin2", "password": "F0gl@mp!", "role_id": 1}, {'user': {'userName': 'admin2', 'userId': 6, 'roleId': 1, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'admin2 user has been created successfully'}) + 'description': ''}, 'message': 'admin2 user has been created successfully.'}) ]) def test_create_user_with_certificate_token(self, fledge_url, form_data, expected_values): conn = http.client.HTTPConnection(fledge_url) @@ -644,7 +644,7 @@ def test_update_password_with_password_token(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'Password has been updated successfully for user id:<{}>'.format(uid)} == jdoc + assert {'message': 'Password has been updated successfully for user ID:<{}>.'.format(uid)} == jdoc def test_update_password_with_certificate_token(self, fledge_url): uid = 5 @@ -656,7 +656,7 @@ def test_update_password_with_certificate_token(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'Password has been updated successfully for user id:<{}>'.format(uid)} == jdoc + assert {'message': 'Password has been updated successfully for user ID:<{}>.'.format(uid)} == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "F0gl@mp1"}, LOGIN_SUCCESS_MSG), @@ -679,7 +679,7 @@ def test_reset_user_with_password_token(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<3> has been updated successfully'} == jdoc + assert {'message': 'User with ID:<3> has been updated successfully.'} == jdoc def test_reset_user_with_certificate_token(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -689,7 +689,7 @@ def test_reset_user_with_certificate_token(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<5> has been updated successfully'} == jdoc + assert {'message': 'User with ID:<5> has been updated successfully.'} == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "F0gl@mp!#1"}, LOGIN_SUCCESS_MSG), @@ -711,7 +711,7 @@ def test_delete_user_with_password_token(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': "User has been deleted successfully"} == jdoc + assert {'message': "User has been deleted successfully."} == jdoc def test_delete_user_with_certificate_token(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -720,7 +720,7 @@ def test_delete_user_with_certificate_token(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': "User has been deleted successfully"} == jdoc + assert {'message': "User has been deleted successfully."} == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "admin1", "password": "F0gl@mp!"}, ""), @@ -970,10 +970,10 @@ def test_get_roles(self, fledge_url): @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, {'user': {'userName': 'any1', 'userId': 3, 'roleId': 2, 'accessMethod': 'any', 'realName': 'AJ', - 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully'}), + 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully.'}), ({"username": "admin1", "password": "F0gl@mp!", "role_id": 1}, {'user': {'userName': 'admin1', 'userId': 4, 'roleId': 1, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'admin1 user has been created successfully'}) + 'description': ''}, 'message': 'admin1 user has been created successfully.'}) ]) def test_create_user(self, fledge_url, form_data, expected_values): conn = http.client.HTTPConnection(fledge_url) @@ -1008,7 +1008,7 @@ def test_update_password(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'Password has been updated successfully for user id:<{}>'.format(uid)} == jdoc + assert {'message': 'Password has been updated successfully for user ID:<{}>.'.format(uid)} == jdoc def test_login_with_updated_password(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -1027,7 +1027,7 @@ def test_reset_user(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<3> has been updated successfully'} == jdoc + assert {'message': 'User with ID:<3> has been updated successfully.'} == jdoc def test_login_with_resetted_password(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -1045,7 +1045,7 @@ def test_delete_user(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': "User has been deleted successfully"} == jdoc + assert {'message': "User has been deleted successfully."} == jdoc def test_login_of_deleted_user(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -1278,10 +1278,10 @@ def test_get_roles(self, fledge_url): @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, {'user': {'userName': 'any1', 'userId': 3, 'roleId': 2, 'accessMethod': 'any', 'realName': 'AJ', - 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully'}), + 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully.'}), ({"username": "admin1", "password": "F0gl@mp!", "role_id": 1}, {'user': {'userName': 'admin1', 'userId': 4, 'roleId': 1, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'admin1 user has been created successfully'}) + 'description': ''}, 'message': 'admin1 user has been created successfully.'}) ]) def test_create_user(self, fledge_url, form_data, expected_values): conn = http.client.HTTPConnection(fledge_url) @@ -1303,7 +1303,7 @@ def test_update_password(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'Password has been updated successfully for user id:<{}>'.format(uid)} == jdoc + assert {'message': 'Password has been updated successfully for user ID:<{}>.'.format(uid)} == jdoc def test_reset_user(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -1313,7 +1313,7 @@ def test_reset_user(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<3> has been updated successfully'} == jdoc + assert {'message': 'User with ID:<3> has been updated successfully.'} == jdoc def test_delete_user(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -1322,7 +1322,7 @@ def test_delete_user(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': "User has been deleted successfully"} == jdoc + assert {'message': "User has been deleted successfully."} == jdoc def test_logout_all(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -1696,10 +1696,10 @@ def test_get_roles_with_certificate_token(self): @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, {'user': {'userName': 'any1', 'userId': 3, 'roleId': 2, 'accessMethod': 'any', 'realName': 'AJ', - 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully'}), + 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully.'}), ({"username": "admin1", "password": "F0gl@mp!", "role_id": 1}, {'user': {'userName': 'admin1', 'userId': 4, 'roleId': 1, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'admin1 user has been created successfully'}) + 'description': ''}, 'message': 'admin1 user has been created successfully.'}) ]) def test_create_user_with_password_token(self, form_data, expected_values): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -1714,10 +1714,10 @@ def test_create_user_with_password_token(self, form_data, expected_values): @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any2", "password": "User@123", "real_name": "PG", "description": "Nerd user"}, {'user': {'userName': 'any2', 'userId': 5, 'roleId': 2, 'accessMethod': 'any', 'realName': 'PG', - 'description': 'Nerd user'}, 'message': 'any2 user has been created successfully'}), + 'description': 'Nerd user'}, 'message': 'any2 user has been created successfully.'}), ({"username": "admin2", "password": "F0gl@mp!", "role_id": 1}, {'user': {'userName': 'admin2', 'userId': 6, 'roleId': 1, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'admin2 user has been created successfully'}) + 'description': ''}, 'message': 'admin2 user has been created successfully.'}) ]) def test_create_user_with_certificate_token(self, form_data, expected_values): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -1754,7 +1754,7 @@ def test_update_password_with_password_token(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'Password has been updated successfully for user id:<{}>'.format(uid)} == jdoc + assert {'message': 'Password has been updated successfully for user ID:<{}>.'.format(uid)} == jdoc def test_update_password_with_certificate_token(self): uid = 5 @@ -1766,7 +1766,7 @@ def test_update_password_with_certificate_token(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'Password has been updated successfully for user id:<{}>'.format(uid)} == jdoc + assert {'message': 'Password has been updated successfully for user ID:<{}>.'.format(uid)} == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "F0gl@mp1"}, LOGIN_SUCCESS_MSG), @@ -1789,7 +1789,7 @@ def test_reset_user_with_password_token(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<3> has been updated successfully'} == jdoc + assert {'message': 'User with ID:<3> has been updated successfully.'} == jdoc def test_reset_user_with_certificate_token(self): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -1799,7 +1799,7 @@ def test_reset_user_with_certificate_token(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<5> has been updated successfully'} == jdoc + assert {'message': 'User with ID:<5> has been updated successfully.'} == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "F0gl@mp!#1"}, LOGIN_SUCCESS_MSG), @@ -1821,7 +1821,7 @@ def test_delete_user_with_password_token(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': "User has been deleted successfully"} == jdoc + assert {'message': "User has been deleted successfully."} == jdoc def test_delete_user_with_certificate_token(self): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -1830,7 +1830,7 @@ def test_delete_user_with_certificate_token(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': "User has been deleted successfully"} == jdoc + assert {'message': "User has been deleted successfully."} == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "admin1", "password": "F0gl@mp!"}, ""), @@ -2084,10 +2084,10 @@ def test_get_roles(self): @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, {'user': {'userName': 'any1', 'userId': 3, 'roleId': 2, 'accessMethod': 'any', 'realName': 'AJ', - 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully'}), + 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully.'}), ({"username": "admin1", "password": "F0gl@mp!", "role_id": 1}, {'user': {'userName': 'admin1', 'userId': 4, 'roleId': 1, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'admin1 user has been created successfully'}) + 'description': ''}, 'message': 'admin1 user has been created successfully.'}) ]) def test_create_user(self, form_data, expected_values): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -2122,7 +2122,7 @@ def test_update_password(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'Password has been updated successfully for user id:<{}>'.format(uid)} == jdoc + assert {'message': 'Password has been updated successfully for user ID:<{}>.'.format(uid)} == jdoc def test_login_with_updated_password(self): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -2141,7 +2141,7 @@ def test_reset_user(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<3> has been updated successfully'} == jdoc + assert {'message': 'User with ID:<3> has been updated successfully.'} == jdoc def test_login_with_resetted_password(self): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -2159,7 +2159,7 @@ def test_delete_user(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': "User has been deleted successfully"} == jdoc + assert {'message': "User has been deleted successfully."} == jdoc def test_login_of_deleted_user(self): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -2399,10 +2399,10 @@ def test_get_roles(self): @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, {'user': {'userName': 'any1', 'userId': 3, 'roleId': 2, 'accessMethod': 'any', 'realName': 'AJ', - 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully'}), + 'description': 'Nerd user'}, 'message': 'any1 user has been created successfully.'}), ({"username": "admin1", "password": "F0gl@mp!", "role_id": 1}, {'user': {'userName': 'admin1', 'userId': 4, 'roleId': 1, 'accessMethod': 'any', 'realName': '', - 'description': ''}, 'message': 'admin1 user has been created successfully'}) + 'description': ''}, 'message': 'admin1 user has been created successfully.'}) ]) def test_create_user(self, form_data, expected_values): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -2424,7 +2424,7 @@ def test_update_password(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'Password has been updated successfully for user id:<{}>'.format(uid)} == jdoc + assert {'message': 'Password has been updated successfully for user ID:<{}>.'.format(uid)} == jdoc def test_reset_user(self): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -2434,7 +2434,7 @@ def test_reset_user(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': 'User with id:<3> has been updated successfully'} == jdoc + assert {'message': 'User with ID:<3> has been updated successfully.'} == jdoc def test_delete_user(self): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -2443,7 +2443,7 @@ def test_delete_user(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'message': "User has been deleted successfully"} == jdoc + assert {'message': "User has been deleted successfully."} == jdoc def test_logout_all(self): conn = http.client.HTTPSConnection("localhost", 1995, context=context) From f204e55c9cb6c52502f49fddb2368e7687cf2edb Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 31 Mar 2023 11:16:29 +0100 Subject: [PATCH 229/499] FOGL-7556 Add documentation for new GUI features (#1031) * Add documentation of GUI settings Signed-off-by: Mark Riddoch * FOGL-7556 Add documentation for new GUI features Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- docs/images/gui_settings.jpg | Bin 0 -> 58422 bytes docs/images/multi_graph1.jpg | Bin 0 -> 187438 bytes docs/images/multi_graph2.jpg | Bin 0 -> 164522 bytes docs/images/multi_graph3.jpg | Bin 0 -> 232389 bytes docs/quick_start/viewing.rst | 31 +++++++++++++++++++++++++++++++ 5 files changed, 31 insertions(+) create mode 100644 docs/images/gui_settings.jpg create mode 100644 docs/images/multi_graph1.jpg create mode 100644 docs/images/multi_graph2.jpg create mode 100644 docs/images/multi_graph3.jpg diff --git a/docs/images/gui_settings.jpg b/docs/images/gui_settings.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c7790af64ed9b22f84beb5df1767434347a6b879 GIT binary patch literal 58422 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

0tRFnJfNl4MQkxjcq8q^-o~}9Z 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 USsW$Bx>p8`hI9Uh-iWdP1s9v2h5!Hn literal 0 HcmV?d00001 diff --git a/docs/images/multi_graph1.jpg b/docs/images/multi_graph1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ee4572bf331c2a8740dc89131ea04168dc75da95 GIT binary patch literal 187438 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 yfjk6u;b$k5PlvX53g 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 z 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 BBvAkW literal 0 HcmV?d00001 diff --git a/docs/images/multi_graph3.jpg b/docs/images/multi_graph3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f6c63a122ecbfd71ce1061275adf559e1d3da78d GIT binary patch literal 232389 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∾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%0D%Xo7Z^s=p7bV_Vhf;eHhp^V;+sM>?fU?R4anS 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 I`o9nU1IRBSJOBUy literal 0 HcmV?d00001 diff --git a/docs/quick_start/viewing.rst b/docs/quick_start/viewing.rst index 79c3d81afb..48131b9f67 100644 --- a/docs/quick_start/viewing.rst +++ b/docs/quick_start/viewing.rst @@ -5,6 +5,10 @@ .. |view_summary| image:: ../images/view_summary.jpg .. |view_times| image:: ../images/view_times.jpg .. |view_spreadsheet| image:: ../images/view_spreadsheet.jpg +.. |gui_settings| image:: ../images/gui_settings.jpg +.. |multi_graph1| image:: ../images/multi_graph1.jpg +.. |multi_graph2| image:: ../images/multi_graph2.jpg +.. |multi_graph3| image:: ../images/multi_graph3.jpg Viewing Data ############ @@ -34,12 +38,39 @@ It is possible to change the time period to which the graph refers by use of the | |view_times| | +--------------+ +It is also possible to change the default duration of a graph when it is first displayed. This is done via the *Settings* menu item. + ++----------------+ +| |gui_settings| | ++----------------+ + +This can be useful when very high frequency data is ingested into the system as it will prevent the initial graph that is displayed from pulling large amounts of data from the system and slowing down the response of the system and the GUI. + Where an asset contains multiple data points each of these is displayed in a different colour. Graphs for particular data points can be toggled on and off by clicking on the key at the top of the graph. Those data points not should will be indicated by striking through the name of the data point. +-------------+ | |view_hide| | +-------------+ +It is also possible to overlay the graphs for other assets onto the asset you are viewing. + ++----------------+ +| |multi_graph1| | ++----------------+ + +Using the pull down menu above the graph you may select another asset to add to the graph. + ++----------------+ +| |multi_graph2| | ++----------------+ + +All the data points from that asset will then be added to the graph. Multiple assets may be chosen from this pull down in order to build up more complex sets of graphs, individual data points for any of the assets may be hidden as above, or an entire asset may be removed from the graph by clicking on the **x** next to the asset name. + ++----------------+ +| |multi_graph3| | ++----------------+ + + A summary tab is also available, this will show the minimum, maximum and average values for each of the data points. Click on *Summary* to show the summary tab. +----------------+ From 05fdb4de486bfbd9101b242daa66c052d76d7afb Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 31 Mar 2023 15:34:04 +0100 Subject: [PATCH 230/499] FOGL-7566 Terminate poll wait early if a reconfiguration reduces the pol (#1030) wait time Signed-off-by: Mark Riddoch Co-authored-by: pintomax --- C/services/south/include/south_service.h | 1 + C/services/south/south.cpp | 88 ++++++++++++++---------- 2 files changed, 53 insertions(+), 36 deletions(-) diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index 814b134439..bb23c5cac6 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -77,6 +77,7 @@ class SouthService : public ServiceAuthHandler { void calculateTimerRate(); bool syncToNextPoll(); bool onDemandPoll(); + void checkPendingReconfigure(); private: std::thread *m_reconfThread; std::deque> m_pendingNewConfig; diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 45224dbc72..10467a6735 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -519,6 +519,13 @@ void SouthService::start(string& coreAddress, unsigned short corePort) { break; } + checkPendingReconfigure(); + if (rep > m_repeatCnt) + { + // Reconfigure has resulted in more frequent + // polling + rep = m_repeatCnt; + } } } else if (m_pollType == POLL_ON_DEMAND) @@ -546,46 +553,31 @@ void SouthService::start(string& coreAddress, unsigned short corePort) } else // V2 poll method { - while(1) + checkPendingReconfigure(); + ReadingSet *set = southPlugin->pollV2(); + if (set) { - unsigned int numPendingReconfs; + std::vector *vec = set->getAllReadingsPtr(); + std::vector *vec2 = new std::vector; + if (!vec) + { + Logger::getLogger()->info("%s:%d: V2 poll method: vec is NULL", __FUNCTION__, __LINE__); + continue; + } + else + { + for (auto & r : *vec) { - lock_guard guard(m_pendingNewConfigMutex); - numPendingReconfs = m_pendingNewConfig.size(); + Reading *r2 = new Reading(*r); // Need to copy reading objects here, since "del set" below would remove encapsulated reading objects + vec2->emplace_back(r2); } - // if a reconf is pending, make this poll thread yield CPU, sleep_for is needed to sleep this thread for sufficiently long time - if (numPendingReconfs) - { - Logger::getLogger()->debug("SouthService::start(): %d entries in m_pendingNewConfig, poll thread yielding CPU", numPendingReconfs); - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - } - else - break; - } - ReadingSet *set = southPlugin->pollV2(); - if (set) - { - std::vector *vec = set->getAllReadingsPtr(); - std::vector *vec2 = new std::vector; - if (!vec) - { - Logger::getLogger()->info("%s:%d: V2 poll method: vec is NULL", __FUNCTION__, __LINE__); - continue; - } - else - { - for (auto & r : *vec) - { - Reading *r2 = new Reading(*r); // Need to copy reading objects here, since "del set" below would remove encapsulated reading objects - vec2->emplace_back(r2); - } - } + } - ingest.ingest(vec2); - pollCount += (int) vec2->size(); - delete vec2; // each reading object inside vector has been allocated on heap and moved to Ingest class's internal queue - delete set; - } + ingest.ingest(vec2); + pollCount += (int) vec2->size(); + delete vec2; // each reading object inside vector has been allocated on heap and moved to Ingest class's internal queue + delete set; + } } throttlePoll(); } @@ -1648,3 +1640,27 @@ bool SouthService::onDemandPoll() } return m_doPoll; } + +/** + * Check to see if there is a reconfiguration option blocking in another + * thread and yield until that reconfiguration has occured. + */ +void SouthService::checkPendingReconfigure() +{ + while(1) + { + unsigned int numPendingReconfs; + { + lock_guard guard(m_pendingNewConfigMutex); + numPendingReconfs = m_pendingNewConfig.size(); + } + // if a reconf is pending, make this poll thread yield CPU, sleep_for is needed to sleep this thread for sufficiently long time + if (numPendingReconfs) + { + Logger::getLogger()->debug("SouthService::start(): %d entries in m_pendingNewConfig, poll thread yielding CPU", numPendingReconfs); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + else + return; + } +} From 9b1b0e0ce3f01cdb3c9f7c9f3448f7a384445c8f Mon Sep 17 00:00:00 2001 From: pintomax Date: Fri, 31 Mar 2023 17:30:22 +0200 Subject: [PATCH 231/499] FOGL-7625: do not store a reading with empty asset code (#1032) FOGL-7625: do not store a reading with empty asset code --- C/plugins/storage/postgres/connection.cpp | 37 +++++++++++++------ C/plugins/storage/sqlite/common/readings.cpp | 8 +++- .../storage/sqlitelb/common/readings.cpp | 13 +++++++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index 80c84b4291..68d07c0173 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -1516,8 +1516,6 @@ bool add_row = false; return -1; } - sql.append("INSERT INTO fledge.readings ( user_ts, asset_code, reading ) VALUES "); - if (!doc.HasMember("readings")) { raiseError("appendReadings", "Payload is missing a readings array"); @@ -1529,6 +1527,9 @@ bool add_row = false; raiseError("appendReadings", "Payload is missing the readings array"); return -1; } + + sql.append("INSERT INTO fledge.readings ( user_ts, asset_code, reading ) VALUES "); + for (Value::ConstValueIterator itr = rdings.Begin(); itr != rdings.End(); ++itr) { if (!itr->IsObject()) @@ -1538,6 +1539,12 @@ bool add_row = false; return -1; } add_row = true; + const char *asset_code = (*itr)["asset_code"].GetString(); + if (strlen(asset_code) == 0) + { + Logger::getLogger()->warn("Postgres appendReadings - empty asset code value, row is ignored"); + continue; + } const char *str = (*itr)["user_ts"].GetString(); // Check if the string is a function @@ -1581,7 +1588,7 @@ bool add_row = false; // Handles - asset_code sql.append(",\'"); - sql.append((*itr)["asset_code"].GetString()); + sql.append(asset_code); sql.append("', '"); // Handles - reading @@ -1598,17 +1605,25 @@ bool add_row = false; const char *query = sql.coalesce(); - logSQL("ReadingsAppend", query); - PGresult *res = PQexec(dbConnection, query); - delete[] query; - if (PQresultStatus(res) == PGRES_COMMAND_OK) + if (row > 0) { + logSQL("ReadingsAppend", query); + PGresult *res = PQexec(dbConnection, query); + delete[] query; + if (PQresultStatus(res) == PGRES_COMMAND_OK) + { + PQclear(res); + return atoi(PQcmdTuples(res)); + } + raiseError("appendReadings", PQerrorMessage(dbConnection)); PQclear(res); - return atoi(PQcmdTuples(res)); + return -1; + } + else + { + delete[] query; + return 0; } - raiseError("appendReadings", PQerrorMessage(dbConnection)); - PQclear(res); - return -1; } /** diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index ef9237d221..09a320fd1e 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -823,8 +823,14 @@ ostringstream threadId; // Handles - asset_code asset_code = (*itr)["asset_code"].GetString(); + if (strlen(asset_code) == 0) + { + Logger::getLogger()->warn("Sqlite appendReadings - empty asset code value, row ignored."); + stmt = NULL; + } + //# A different asset is managed respect the previous one - if (lastAsset.compare(asset_code)!= 0) + if (strlen(asset_code) && lastAsset.compare(asset_code) != 0) { ReadingsCatalogue::tyReadingReference ref; diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index 80badfbe81..f3f041144d 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -890,6 +890,12 @@ int sleep_time_ms = 0; // Handles - asset_code asset_code = (*itr)["asset_code"].GetString(); + if (strlen(asset_code) == 0) + { + Logger::getLogger()->warn("Sqlitelb appendReadings - empty asset code value, row is ignored"); + itr++; + continue; + } // Handles - reading StringBuffer buffer; Writer writer(buffer); @@ -1013,6 +1019,13 @@ int sleep_time_ms = 0; // Handles - asset_code asset_code = (*itr)["asset_code"].GetString(); + if (strlen(asset_code) == 0) + { + Logger::getLogger()->warn("Sqlitelb appendReadings - empty asset code value, row is ignored"); + itr++; + continue; + } + // Handles - reading StringBuffer buffer; Writer writer(buffer); From 66e374c78d3a1397e40ccd31b2ea4ccaa898c904 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 18:58:58 +0530 Subject: [PATCH 232/499] control pipelines schema for Sqlite engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlite/init.sql | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 105912711f..c62baab211 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -630,6 +630,42 @@ CREATE TABLE fledge.acl_usage ( entity_name character varying(255) NOT NULL, -- associated entity name CONSTRAINT usage_acl_pkey PRIMARY KEY (name, entity_type, entity_name) ); +-- Create control_source table +CREATE TABLE fledge.control_source ( + cpsid integer PRIMARY KEY AUTOINCREMENT, -- auto source id + name character varying(40) NOT NULL, -- source name + description character varying(120) NOT NULL -- source description + ); + +-- Create control_destination table +CREATE TABLE fledge.control_destination ( + cpdid integer PRIMARY KEY AUTOINCREMENT, -- auto destination id + name character varying(40) NOT NULL, -- destination name + description character varying(120) NOT NULL -- destination description + ); + +-- Create control_pipelines table +CREATE TABLE fledge.control_pipelines ( + cpid integer PRIMARY KEY AUTOINCREMENT, -- control pipeline id + name character varying(255) NOT NULL , -- control pipeline name + stype integer , -- source type id from control_source table + sname character varying(80) , -- source name from control_source table + dtype integer , -- destination type id from control_destination table + dname character varying(80) , -- destination name from control_destination table + enabled boolean NOT NULL DEFAULT 'f' , -- false = A given pipeline is disabled by default + execution character varying(20) NOT NULL DEFAULT 'shared' -- pipeline will be executed as with shared execution model by default + ); + +-- Create control_filters table +CREATE TABLE fledge.control_filters ( + fid integer PRIMARY KEY AUTOINCREMENT, -- auto filter id + cpid integer NOT NULL , -- control pipeline id + forder integer NOT NULL , -- filter order + fname character varying(255) NOT NULL , -- Name of the filter instance + CONSTRAINT control_filters_fk1 FOREIGN KEY (cpid) + REFERENCES control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); + ---------------------------------------------------------------------- -- Initialization phase - DML ---------------------------------------------------------------------- @@ -858,3 +894,21 @@ CREATE TABLE fledge.service_schema ( service character varying(255) NOT NULL, version integer NOT NULL, definition JSON); + +-- Control Source +DELETE FROM fledge.control_source; +INSERT INTO fledge.control_source ( name, description ) + VALUES ('Any', 'Any source.'), + ('Service', 'A named service in source of the control pipeline.'), + ('API', 'The control pipeline source is the REST API.'), + ('Notification', 'The control pipeline originated from a notification.'), + ('Schedule', 'The control request was triggered by a schedule.'), + ('Script', 'The control request has come from the named script.'); + +-- Control Destination +DELETE FROM fledge.control_destination; +INSERT INTO fledge.control_destination ( name, description ) + VALUES ('Service', 'A name of service that is being controlled.'), + ('Asset', 'A name of asset that is being controlled.'), + ('Script', 'A name of script that will be executed.'), + ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); From b0a3c17756083354438d1f9000becd169d895bbb Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 18:59:58 +0530 Subject: [PATCH 233/499] schema version updated to 60 Signed-off-by: ashish-jabble --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 496130b3aa..bab5c5183e 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.1.0 -fledge_schema=59 +fledge_schema=60 From 317273ea604a5fde493fb7586692a6dca09e8deb Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 19:00:54 +0530 Subject: [PATCH 234/499] upgrade/downgrade scripts for control pipeline schema changes added for Sqlite engine Signed-off-by: ashish-jabble --- .../plugins/storage/sqlite/downgrade/59.sql | 5 ++ scripts/plugins/storage/sqlite/upgrade/60.sql | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 scripts/plugins/storage/sqlite/downgrade/59.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/60.sql diff --git a/scripts/plugins/storage/sqlite/downgrade/59.sql b/scripts/plugins/storage/sqlite/downgrade/59.sql new file mode 100644 index 0000000000..c1c032ab8a --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/59.sql @@ -0,0 +1,5 @@ +-- Drop control pipeline tables +DROP TABLE IF EXISTS fledge.control_source; +DROP TABLE IF EXISTS fledge.control_destination; +DROP TABLE IF EXISTS fledge.control_pipelines; +DROP TABLE IF EXISTS fledge.control_filters; diff --git a/scripts/plugins/storage/sqlite/upgrade/60.sql b/scripts/plugins/storage/sqlite/upgrade/60.sql new file mode 100644 index 0000000000..fe914fda7f --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/60.sql @@ -0,0 +1,53 @@ +-- Create control_source table +CREATE TABLE fledge.control_source ( + cpsid integer PRIMARY KEY AUTOINCREMENT, -- auto source id + name character varying(40) NOT NULL, -- source name + description character varying(120) NOT NULL -- source description + ); + +-- Create control_destination table +CREATE TABLE fledge.control_destination ( + cpdid integer PRIMARY KEY AUTOINCREMENT, -- auto destination id + name character varying(40) NOT NULL, -- destination name + description character varying(120) NOT NULL -- destination description + ); + +-- Create control_pipelines table +CREATE TABLE fledge.control_pipelines ( + cpid integer PRIMARY KEY AUTOINCREMENT, -- control pipeline id + name character varying(255) NOT NULL , -- control pipeline name + stype integer , -- source type id from control_source table + sname character varying(80) , -- source name from control_source table + dtype integer , -- destination type id from control_destination table + dname character varying(80) , -- destination name from control_destination table + enabled boolean NOT NULL DEFAULT 'f' , -- false = A given pipeline is disabled by default + execution character varying(20) NOT NULL DEFAULT 'shared' -- pipeline will be executed as with shared execution model by default + ); + +-- Create control_filters table +CREATE TABLE fledge.control_filters ( + fid integer PRIMARY KEY AUTOINCREMENT, -- auto filter id + cpid integer NOT NULL , -- control pipeline id + forder integer NOT NULL , -- filter order + fname character varying(255) NOT NULL , -- Name of the filter instance + CONSTRAINT control_filters_fk1 FOREIGN KEY (cpid) + REFERENCES control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); + +-- Insert predefined entries for Control Source +DELETE FROM fledge.control_source; +INSERT INTO fledge.control_source ( name, description ) + VALUES ('Any', 'Any source.'), + ('Service', 'A named service in source of the control pipeline.'), + ('API', 'The control pipeline source is the REST API.'), + ('Notification', 'The control pipeline originated from a notification.'), + ('Schedule', 'The control request was triggered by a schedule.'), + ('Script', 'The control request has come from the named script.'); + +-- Insert predefined entries for Control Destination +DELETE FROM fledge.control_destination; +INSERT INTO fledge.control_destination ( name, description ) + VALUES ('Service', 'A name of service that is being controlled.'), + ('Asset', 'A name of asset that is being controlled.'), + ('Script', 'A name of script that will be executed.'), + ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); From 393b0ab2f60edbf0eb8f30199bed042a9639d84b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 19:01:38 +0530 Subject: [PATCH 235/499] control pipeline routes added Signed-off-by: ashish-jabble --- python/fledge/services/core/routes.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/routes.py b/python/fledge/services/core/routes.py index 5084e96b0a..715606093b 100644 --- a/python/fledge/services/core/routes.py +++ b/python/fledge/services/core/routes.py @@ -5,21 +5,20 @@ # FLEDGE_END from fledge.services.core import proxy -from fledge.services.core.api import asset_tracker, auth, backup_restore, browser, certificate_store, filters, health, notification, north, package_log, python_packages, south, support, service, task, update +from fledge.services.core.api import asset_tracker, auth, backup_restore, browser, certificate_store, control_pipeline, filters, health, notification, north, package_log, python_packages, south, support, service, task, update from fledge.services.core.api import audit as api_audit from fledge.services.core.api import common as api_common from fledge.services.core.api import configuration as api_configuration from fledge.services.core.api import scheduler as api_scheduler from fledge.services.core.api import statistics as api_statistics +from fledge.services.core.api.control_service import script_management, acl_management from fledge.services.core.api.plugins import data as plugin_data from fledge.services.core.api.plugins import install as plugins_install, discovery as plugins_discovery from fledge.services.core.api.plugins import update as plugins_update from fledge.services.core.api.plugins import remove as plugins_remove +from fledge.services.core.api.repos import configure as configure_repo from fledge.services.core.api.snapshot import plugins as snapshot_plugins from fledge.services.core.api.snapshot import table as snapshot_table -from fledge.services.core.api.repos import configure as configure_repo -from fledge.services.core.api.control_service import script_management -from fledge.services.core.api.control_service import acl_management __author__ = "Ashish Jabble, Praveen Garg, Massimiliano Pinto, Amarendra K Sinha" @@ -248,6 +247,9 @@ def setup(app): app.router.add_route('PUT', '/fledge/service/{service_name}/ACL', acl_management.attach_acl_to_service) app.router.add_route('DELETE', '/fledge/service/{service_name}/ACL', acl_management.detach_acl_from_service) + # Control Pipelines + control_pipeline.setup(app) + app.router.add_route('GET', '/fledge/python/packages', python_packages.get_packages) app.router.add_route('POST', '/fledge/python/package', python_packages.install_package) From 7208b85690574239949a40c965b4605e71264257 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 19:05:28 +0530 Subject: [PATCH 236/499] GET control lookups endpoint added Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 python/fledge/services/core/api/control_pipeline.py diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py new file mode 100644 index 0000000000..3a35783ded --- /dev/null +++ b/python/fledge/services/core/api/control_pipeline.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +# FLEDGE_BEGIN +# See: http://fledge-iot.readthedocs.io/ +# FLEDGE_END + +import copy +import json +from aiohttp import web + +from fledge.common.logger import FLCoreLogger +from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.storage_client.payload_builder import PayloadBuilder +from fledge.common.storage_client.exceptions import StorageServerError +from fledge.services.core import connect + +__author__ = "Ashish Jabble" +__copyright__ = "Copyright (c) 2023 Dianomic Systems Inc." +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + +_logger = FLCoreLogger().get_logger(__name__) + + +def setup(app): + app.router.add_route('GET', '/fledge/control/lookup', get_lookup) + + +async def get_lookup(request: web.Request) -> web.Response: + """List of supported control source and destinations + + :Example: + curl -sX GET http://localhost:8081/fledge/control/lookup + curl -sX GET http://localhost:8081/fledge/control/lookup?type=source + curl -sX GET http://localhost:8081/fledge/control/lookup?type=destination + """ + try: + _type = request.query.get('type') + if _type is None or not _type: + lookup = await _get_all_lookups() + response = {'controlLookup': lookup} + else: + table_name = None + if _type == "source": + table_name = "control_source" + elif _type == "destination": + table_name = "control_destination" + if table_name: + lookup = await _get_all_lookups(table_name) + response = lookup + else: + lookup = await _get_all_lookups() + response = {'controlLookup': lookup} + except Exception as ex: + msg = str(ex) + _logger.error("Failed to get all control lookups. {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response(response) + + +async def _get_all_lookups(tbl_name=None): + storage = connect.get_storage_async() + if tbl_name: + res = await storage.query_tbl(tbl_name) + lookup = res["rows"] + return lookup + result = await storage.query_tbl("control_source") + source_lookup = result["rows"] + result = await storage.query_tbl("control_destination") + des_lookup = result["rows"] + return {"source": source_lookup, "destination": des_lookup} From 1159f4e03b97c2c85b902c6ba6bdeece8be1e0d0 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 19:07:56 +0530 Subject: [PATCH 237/499] POST control pipeline endpoint added Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index 3a35783ded..bb48e5026f 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -24,6 +24,7 @@ def setup(app): app.router.add_route('GET', '/fledge/control/lookup', get_lookup) + app.router.add_route('POST', '/fledge/control/pipeline', create) async def get_lookup(request: web.Request) -> web.Response: @@ -59,6 +60,57 @@ async def get_lookup(request: web.Request) -> web.Response: return web.json_response(response) +async def create(request: web.Request) -> web.Response: + """Create a control pipeline. It's name must be unique and there must be no other pipelines with the same + source or destination + + :Example: + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "pump", "enable": "true", "execution": "shared", "source": {"type": 2, "name": "pump"}, "destination": {}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "broadcast", "enable": "true", "execution": "exclusive", "source": {}, "destination": {"type": 4}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enable": "true", "execution": "shared", "source": {"type": 2, "name": "opcua"}, "destination": {"type": 2, "name": "pump1"}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "Test", "enable": "true", "source": {}, "destination": {}, "filters": ["Filter1", "Filter2"]}' + """ + try: + data = await request.json() + # Create entry in control_pipelines table + column_names = await _check_parameters(data) + payload = PayloadBuilder().INSERT(**column_names).payload() + storage = connect.get_storage_async() + insert_result = await storage.insert_into_tbl("control_pipelines", payload) + pipeline_name = column_names['name'] + pipeline_filter = data.get('filters', None) + source = {'type': column_names["stype"], 'name': column_names["sname"]} + destination = {'type': column_names["dtype"], 'name': column_names["dname"]} + if insert_result['response'] == "inserted" and insert_result['rows_affected'] == 1: + final_result = await _pipeline_in_use(pipeline_name, source, destination, info=True) + final_result["stype"] = await _get_lookup_value('source', final_result["stype"]) + final_result["dtype"] = await _get_lookup_value('destination', final_result["dtype"]) + final_result['enabled'] = False if final_result['enabled'] == 'f' else True + final_result['filters'] = [] + if pipeline_filter: + go_ahead = await _check_filters(storage, pipeline_filter) + if go_ahead: + filters = await _update_filters(storage, final_result['id'], pipeline_name, pipeline_filter) + final_result['filters'] = filters + else: + raise StorageServerError + except StorageServerError as serr: + msg = serr.error + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(body=json.dumps({"message": msg}), reason=msg) + except Exception as ex: + msg = str(ex) + _logger.error("Failed to create pipeline: {}. {}".format(pipeline_name, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response(final_result) + + async def _get_all_lookups(tbl_name=None): storage = connect.get_storage_async() if tbl_name: @@ -70,3 +122,247 @@ async def _get_all_lookups(tbl_name=None): result = await storage.query_tbl("control_destination") des_lookup = result["rows"] return {"source": source_lookup, "destination": des_lookup} + + +async def _get_table_column_by_value(table, column_name, column_value): + storage = connect.get_storage_async() + payload = PayloadBuilder().WHERE([column_name, '=', column_value]).payload() + result = await storage.query_tbl_with_payload(table, payload) + return result + + +async def _get_pipeline(cpid, filters=True): + result = await _get_table_column_by_value("control_pipelines", "cpid", cpid) + rows = result["rows"] + if not rows: + raise KeyError("Pipeline having ID: {} not found.".format(cpid)) + r = rows[0] + sname = "" + dname = "" + if r['stype']: + sname = await _get_lookup_value("source", r['stype']) + if not sname: + raise ValueError("Invalid source type found.") + if r['dtype']: + dname = await _get_lookup_value("destination", r['dtype']) + if not dname: + raise ValueError("Invalid destination type found.") + pipeline = { + 'id': r['cpid'], + 'name': r['name'], + 'source': {'type': sname, 'name': r['sname']} if r['stype'] else {}, + 'destination': {'type': dname, 'name': r['dname']} if r['dtype'] else {}, + 'enabled': False if r['enabled'] == 'f' else True, + 'execution': r['execution'] + } + if filters: + # update filters in pipeline + result = await _get_table_column_by_value("control_filters", "cpid", pipeline['id']) + pipeline['filters'] = [r['fname'] for r in result["rows"]] + return pipeline + + +async def _pipeline_in_use(name, source, destination, info=False): + result = await _get_table_column_by_value("control_pipelines", "name", name) + rows = result["rows"] + row = None + new_data = {'source': source if source else {'type': '', 'name': ''}, + 'destination': destination if destination else {'type': '', 'name': ''} + } + is_matched = False + for r in rows: + db_data = {'source': {'type': r['stype'], 'name': r['sname']}, + 'destination': {'type': r['dtype'], 'name': r['dname']}} + if json.dumps(db_data, sort_keys=True) == json.dumps(new_data, sort_keys=True): + is_matched = True + r["id"] = r['cpid'] + r.pop('cpid', None) + row = r + break + return row if info else is_matched + + +async def _get_lookup_value(_type, value): + if _type == "source": + tbl_name = "control_source" + key_name = 'cpsid' + else: + tbl_name = "control_destination" + key_name = 'cpdid' + lookup = await _get_all_lookups(tbl_name) + name = [lu['name'] for lu in lookup if value == lu[key_name]] + return ''.join(name) + + +async def _check_parameters(payload): + column_names = {} + # name + name = payload.get('name', None) + if name is not None: + if not isinstance(name, str): + raise ValueError('Pipeline name should be in string.') + name = name.strip() + if len(name) == 0: + raise ValueError('Pipeline name cannot be empty.') + column_names['name'] = name + # enable + enabled = payload.get('enable', None) + if enabled is not None: + if not isinstance(enabled, str): + raise ValueError('Enable should be in string.') + enabled = enabled.strip() + if len(enabled) == 0: + raise ValueError('Enable value cannot be empty.') + if enabled.lower() not in ["true", "false"]: + raise ValueError('Enable value either True or False.') + column_names['enabled'] = 't' if enabled.lower() == 'true' else 'f' + # execution + execution = payload.get('execution', None) + if execution is not None: + if not isinstance(execution, str): + raise ValueError('Execution should be in string.') + execution = execution.strip() + if len(execution) == 0: + raise ValueError('Execution value cannot be empty.') + if execution.lower() not in ["shared", "exclusive"]: + raise ValueError('Execution model value either shared or exclusive.') + column_names['execution'] = execution + # source + source = payload.get('source', None) + if source is not None: + if not isinstance(source, dict): + raise ValueError('Source should be passed with type and name.') + if len(source): + source_type = source.get("type") + source_name = source.get("name") + if source_type is not None: + if not isinstance(source_type, int): + raise ValueError("Source type should be an integer value.") + # TODO: source type validation + else: + raise ValueError('Source type is missing.') + if source_name is not None: + if not isinstance(source_name, str): + raise ValueError("Source name should be a string value.") + source_name = source_name.strip() + if len(source_name) == 0: + raise ValueError('Source name cannot be empty.') + # source['name'] = source_name + # TODO: source name validation from API + column_names["stype"] = source_type + column_names["sname"] = source_name + else: + raise ValueError('Source name is missing.') + else: + column_names["stype"] = "" + column_names["sname"] = "" + # destination + destination = payload.get('destination', None) + if destination is not None: + if not isinstance(destination, dict): + raise ValueError('Destination should be passed with type and name.') + if len(destination): + des_type = destination.get("type") + des_name = destination.get("name") + if des_type is not None: + if not isinstance(des_type, int): + raise ValueError("Destination type should be an integer value.") + # TODO: destination type validation + else: + raise ValueError('Destination type is missing.') + # Note: when destination type is Broadcast; no name is applied + if des_type != 4: + if des_name is not None: + if not isinstance(des_name, str): + raise ValueError("Destination name should be a string value.") + des_name = des_name.strip() + if len(des_name) == 0: + raise ValueError('Destination name cannot be empty.') + # TODO: destination name validation from API + # destination['name'] = des_name + column_names["dtype"] = des_type + column_names["dname"] = des_name + else: + raise ValueError('Destination name is missing.') + else: + des_name = '' + destination = {'type': des_type, 'name': des_name} + column_names["dtype"] = des_type + column_names["dname"] = des_name + else: + column_names["dtype"] = "" + column_names["dname"] = "" + if name: + # Check unique pipeline + if await _pipeline_in_use(name, source, destination): + raise ValueError("{} control pipeline must be unique and there must be no other pipelines " + "with the same source and destination.".format(name)) + # filters + filters = payload.get('filters', None) + if filters is not None: + if not isinstance(filters, list): + raise ValueError('Pipeline filters should be passed in list.') + return column_names + + +async def _remove_filters(storage, filters, cp_id): + cf_mgr = ConfigurationManager(storage) + if filters: + for f in filters: + # Delete entry from control_filter table + payload = PayloadBuilder().WHERE(['cpid', '=', cp_id]).AND_WHERE(['fname', '=', f]).payload() + await storage.delete_from_tbl("control_filters", payload) + # Delete the related category + await cf_mgr.delete_category_and_children_recursively(f) + + +async def _check_filters(storage, cp_filters): + is_exist = False + filters_result = await storage.query_tbl("filters") + if filters_result['rows']: + filters_instances_list = [f['name'] for f in filters_result['rows']] + check_if = all(f in filters_instances_list for f in cp_filters) + if check_if: + is_exist = True + else: + _logger.warning("Filters do not exist as per the given {} payload..".format(cp_filters)) + else: + _logger.warning("No filter instances exists in the system.") + return is_exist + + +async def _update_filters(storage, cp_id, cp_name, cp_filters): + cf_mgr = ConfigurationManager(storage) + new_filters = [] + if not cp_filters: + return new_filters + + for fid, fname in enumerate(cp_filters, start=1): + # get plugin config of filter + category_value = await cf_mgr.get_category_all_items(category_name=fname) + cat_value = copy.deepcopy(category_value) + if cat_value is None: + raise ValueError( + "{} category does not exist during {} control pipeline filter.".format( + fname, cp_name)) + # Copy value in default and remove value KV pair for creating new category + for k, v in cat_value.items(): + v['default'] = v['value'] + v.pop('value', None) + # Create category + # TODO: parent-child relation? + cat_name = "ctrl_{}_{}".format(cp_name, fname) + await cf_mgr.create_category(category_name=cat_name, + category_description="Filter of {} control pipeline.".format( + cp_name), + category_value=cat_value, + keep_original_items=True) + new_category = await cf_mgr.get_category_all_items(cat_name) + if new_category is None: + raise KeyError("No such {} category found.".format(new_category)) + # Create entry in control_filters table + column_names = {"cpid": cp_id, "forder": fid, "fname": cat_name} + payload = PayloadBuilder().INSERT(**column_names).payload() + await storage.insert_into_tbl("control_filters", payload) + new_filters.append(cat_name) + return new_filters From 547e60342d298116aa89841717a9ec4e704110da Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 19:09:40 +0530 Subject: [PATCH 238/499] GET control pipeline endpoint added Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index bb48e5026f..dfdcd911dd 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -25,6 +25,8 @@ def setup(app): app.router.add_route('GET', '/fledge/control/lookup', get_lookup) app.router.add_route('POST', '/fledge/control/pipeline', create) + app.router.add_route('GET', '/fledge/control/pipeline', get_all) + app.router.add_route('GET', '/fledge/control/pipeline/{id}', get_by_id) async def get_lookup(request: web.Request) -> web.Response: @@ -111,6 +113,63 @@ async def create(request: web.Request) -> web.Response: return web.json_response(final_result) +async def get_all(request: web.Request) -> web.Response: + """List of all control pipelines within the system + + :Example: + curl -sX GET http://localhost:8081/fledge/control/pipeline + """ + try: + storage = connect.get_storage_async() + result = await storage.query_tbl("control_pipelines") + control_pipelines = [] + source_lookup = await _get_all_lookups("control_source") + des_lookup = await _get_all_lookups("control_destination") + for r in result["rows"]: + source_name = [s['name'] for s in source_lookup if r['stype'] == s['cpsid']] + des_name = [s['name'] for s in des_lookup if r['dtype'] == s['cpdid']] + temp = { + 'id': r['cpid'], + 'name': r['name'], + 'source': {'type': ''.join(source_name), 'name': r['sname']} if r['stype'] else {}, + 'destination': {'type': ''.join(des_name), 'name': r['dname']} if r['dtype'] else {}, + 'enabled': False if r['enabled'] == 'f' else True, + 'execution': r['execution'] + } + result = await _get_table_column_by_value("control_filters", "cpid", r['cpid']) + temp.update({'filters': [r['fname'] for r in result["rows"]]}) + control_pipelines.append(temp) + except Exception as ex: + msg = str(ex) + _logger.error("Failed to get all pipelines. {}".format(msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response({'pipelines': control_pipelines}) + + +async def get_by_id(request: web.Request) -> web.Response: + """Fetch the pipeline within the system + + :Example: + curl -sX GET http://localhost:8081/fledge/control/pipeline/2 + """ + cpid = request.match_info.get('id', None) + try: + pipeline = await _get_pipeline(cpid) + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + except Exception as ex: + msg = str(ex) + _logger.error("Failed to fetch details of pipeline having ID:{}. {}".format(cpid, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response(pipeline) + + async def _get_all_lookups(tbl_name=None): storage = connect.get_storage_async() if tbl_name: From 62fc910c036cf12ec68d9653f59d90c8aeea64c7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 19:10:41 +0530 Subject: [PATCH 239/499] PUT control pipeline endpoint added Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index dfdcd911dd..7f7a5d3574 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -27,6 +27,7 @@ def setup(app): app.router.add_route('POST', '/fledge/control/pipeline', create) app.router.add_route('GET', '/fledge/control/pipeline', get_all) app.router.add_route('GET', '/fledge/control/pipeline/{id}', get_by_id) + app.router.add_route('PUT', '/fledge/control/pipeline/{id}', update) async def get_lookup(request: web.Request) -> web.Response: @@ -170,6 +171,50 @@ async def get_by_id(request: web.Request) -> web.Response: return web.json_response(pipeline) +async def update(request: web.Request) -> web.Response: + """Update an existing pipeline within the system + + :Example: + curl -sX PUT http://localhost:8081/fledge/control/pipeline/1 -d '{"filters": ["F3", "F2"]}' + curl -sX PUT http://localhost:8081/fledge/control/pipeline/13 -d '{"name": "Changed"}' + curl -sX PUT http://localhost:8081/fledge/control/pipeline/9 -d '{"enable": "false", "execution": "exclusive", "filters": [], "source": {"type": 1, "name": "1"}, "destination": {"type": 3, "name": 1}}' + """ + cpid = request.match_info.get('id', None) + try: + pipeline = await _get_pipeline(cpid) + data = await request.json() + columns = await _check_parameters(data) + storage = connect.get_storage_async() + if columns: + payload = PayloadBuilder().SET(**columns).WHERE(['cpid', '=', cpid]).payload() + await storage.update_tbl("control_pipelines", payload) + filters = data.get('filters', None) + if filters is not None: + go_ahead = await _check_filters(storage, filters) + if go_ahead: + # remove old filters if exists + await _remove_filters(storage, pipeline['filters'], cpid) + # Update new filters + new_filters = await _update_filters(storage, cpid, pipeline['name'], filters) + if not new_filters: + raise ValueError('Filters do not exist as per the given list {}'.format(filters)) + else: + raise ValueError('Filters do not exist as per the given list {}'.format(filters)) + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + except Exception as ex: + msg = str(ex) + _logger.error("Failed to update pipeline having ID:{}. {}".format(cpid, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response( + {"message": "Control Pipeline with ID:<{}> has been updated successfully.".format(cpid)}) + + async def _get_all_lookups(tbl_name=None): storage = connect.get_storage_async() if tbl_name: From 97b61d03afc563c4e96235f5022e5316defca59d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 19:11:36 +0530 Subject: [PATCH 240/499] DELETE control pipeline endpoint added Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index 7f7a5d3574..bb742fa36b 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -28,6 +28,7 @@ def setup(app): app.router.add_route('GET', '/fledge/control/pipeline', get_all) app.router.add_route('GET', '/fledge/control/pipeline/{id}', get_by_id) app.router.add_route('PUT', '/fledge/control/pipeline/{id}', update) + app.router.add_route('DELETE', '/fledge/control/pipeline/{id}', delete) async def get_lookup(request: web.Request) -> web.Response: @@ -215,6 +216,37 @@ async def update(request: web.Request) -> web.Response: {"message": "Control Pipeline with ID:<{}> has been updated successfully.".format(cpid)}) +async def delete(request: web.Request) -> web.Response: + """Delete an existing pipeline within the system. + Also remove the filters along with configuration that are part of pipeline + + :Example: + curl -sX DELETE http://localhost:8081/fledge/control/pipeline/1 + """ + cpid = request.match_info.get('id', None) + try: + storage = connect.get_storage_async() + pipeline = await _get_pipeline(cpid) + # Remove filters if exists and also delete the entry from control_filter table + await _remove_filters(storage, pipeline['filters'], cpid) + # Delete entry from control_pipelines + payload = PayloadBuilder().WHERE(['cpid', '=', pipeline['id']]).payload() + await storage.delete_from_tbl("control_pipelines", payload) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + except Exception as ex: + msg = str(ex) + _logger.error("Failed to delete pipeline having ID:{}. {}".format(cpid, msg)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response( + {"message": "Control Pipeline with ID:<{}> has been deleted successfully.".format(cpid)}) + + async def _get_all_lookups(tbl_name=None): storage = connect.get_storage_async() if tbl_name: From 04270782250ead811fd4e20e3494b0a836ad5e10 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 4 Apr 2023 19:13:07 +0530 Subject: [PATCH 241/499] help added for control pipeline endpoints Signed-off-by: ashish-jabble --- python/fledge/services/core/api/control_pipeline.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index bb742fa36b..f7b555158a 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -21,6 +21,14 @@ _logger = FLCoreLogger().get_logger(__name__) +_help = """ + ----------------------------------------------------------------------------------- + | GET POST | /fledge/control/pipeline | + | GET PUT DELETE | /fledge/control/pipeline/{id} | + | GET | /fledge/control/lookup | + ----------------------------------------------------------------------------------- +""" + def setup(app): app.router.add_route('GET', '/fledge/control/lookup', get_lookup) From df5dc685ef61a372d4e3fdaf832248e8cf56ce27 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 5 Apr 2023 17:12:13 +0530 Subject: [PATCH 242/499] CONCH audit log entry added when KV pair diff in configuration Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 28c0b62e97..aff75b9af1 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -1138,7 +1138,15 @@ async def create_category(self, category_name, category_value, category_descript else: await self._update_category(category_name, category_val_prepared, category_description, display_name) - + diff = set(category_val_prepared) - set(category_val_storage) + if diff: + audit = AuditLogger(self._storage) + audit_details = { + 'category': category_name, + 'oldValue': category_val_storage, + 'newValue': category_val_prepared + } + await audit.information('CONCH', audit_details) is_acl, config_item, found_cat_name, found_value = await \ self.search_for_ACL_recursive_from_cat_name(category_name) _logger.debug("check if there is {} create category function for category {} ".format(is_acl, From e52cf53ba44038864119594e177a9d2f9d86fca5 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 5 Apr 2023 17:12:42 +0530 Subject: [PATCH 243/499] unit tests updated as per new audit log entry in create_category when KV pair diff Signed-off-by: ashish-jabble --- .../common/test_configuration_manager.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index c26546d62f..b6335b0d41 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -1258,15 +1258,22 @@ async def async_mock(return_value): with patch.object(ConfigurationManager, '_validate_category_val', side_effect=[_se, _se]) as valpatch: with patch.object(ConfigurationManager, '_read_category_val', return_value=_rv1) as readpatch: - with patch.object(ConfigurationManager, '_read_all_category_names', return_value=_rv2) as read_all_patch: + with patch.object(ConfigurationManager, '_read_all_category_names', + return_value=_rv2) as read_all_patch: with patch.object(ConfigurationManager, '_merge_category_vals', return_value=_rv3) as mergepatch: with patch.object(ConfigurationManager, '_run_callbacks', return_value=_rv4) as callbackpatch: - with patch.object(ConfigurationManager, '_update_category', return_value=_rv4) as updatepatch: - with patch.object(ConfigurationManager, 'search_for_ACL_recursive_from_cat_name', - return_value=_sr) as searchaclpatch: - cat = await c_mgr.create_category('catname', 'catvalue', 'catdesc') - assert cat is None - searchaclpatch.assert_called_once_with('catname') + with patch.object(ConfigurationManager, '_update_category', + return_value=_rv4) as updatepatch: + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=_rv4) as auditinfopatch: + with patch.object(ConfigurationManager, + 'search_for_ACL_recursive_from_cat_name', + return_value=_sr) as searchaclpatch: + cat = await c_mgr.create_category('catname', 'catvalue', 'catdesc') + assert cat is None + searchaclpatch.assert_called_once_with('catname') + auditinfopatch.assert_called_once_with( + 'CONCH', {'category': 'catname', 'oldValue': {}, 'newValue': {'bla': 'bla'}}) updatepatch.assert_called_once_with('catname', {'bla': 'bla'}, 'catdesc', 'catname') callbackpatch.assert_called_once_with('catname') mergepatch.assert_called_once_with({}, {}, False, 'catname') From e7d5968792827e97ad4375ac4bb5a08101671214 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 6 Apr 2023 11:02:17 +0530 Subject: [PATCH 244/499] item KV pair added in audit log entry to know about the configuration change Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 1 + tests/unit/python/fledge/common/test_configuration_manager.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index aff75b9af1..d6647f9002 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -1143,6 +1143,7 @@ async def create_category(self, category_name, category_value, category_descript audit = AuditLogger(self._storage) audit_details = { 'category': category_name, + 'item': "configurationChange", 'oldValue': category_val_storage, 'newValue': category_val_prepared } diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index b6335b0d41..6b26890c2e 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -1273,7 +1273,8 @@ async def async_mock(return_value): assert cat is None searchaclpatch.assert_called_once_with('catname') auditinfopatch.assert_called_once_with( - 'CONCH', {'category': 'catname', 'oldValue': {}, 'newValue': {'bla': 'bla'}}) + 'CONCH', {'category': 'catname', 'item': 'configurationChange', 'oldValue': {}, + 'newValue': {'bla': 'bla'}}) updatepatch.assert_called_once_with('catname', {'bla': 'bla'}, 'catdesc', 'catname') callbackpatch.assert_called_once_with('catname') mergepatch.assert_called_once_with({}, {}, False, 'catname') From 1d08329e80961208d24d212552761344b4ba1f09 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 6 Apr 2023 12:49:46 +0530 Subject: [PATCH 245/499] sqlitelb schema changes for control filter pipelines along with upgrade & downgrade scripts Signed-off-by: ashish-jabble --- .../plugins/storage/sqlitelb/downgrade/59.sql | 5 ++ scripts/plugins/storage/sqlitelb/init.sql | 54 +++++++++++++++++++ .../plugins/storage/sqlitelb/upgrade/60.sql | 53 ++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/59.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/60.sql diff --git a/scripts/plugins/storage/sqlitelb/downgrade/59.sql b/scripts/plugins/storage/sqlitelb/downgrade/59.sql new file mode 100644 index 0000000000..c1c032ab8a --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/59.sql @@ -0,0 +1,5 @@ +-- Drop control pipeline tables +DROP TABLE IF EXISTS fledge.control_source; +DROP TABLE IF EXISTS fledge.control_destination; +DROP TABLE IF EXISTS fledge.control_pipelines; +DROP TABLE IF EXISTS fledge.control_filters; diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 8da2ee2443..e55136b284 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -630,6 +630,42 @@ CREATE TABLE fledge.acl_usage ( entity_name character varying(255) NOT NULL, -- associated entity name CONSTRAINT usage_acl_pkey PRIMARY KEY (name, entity_type, entity_name) ); +-- Create control_source table +CREATE TABLE fledge.control_source ( + cpsid integer PRIMARY KEY AUTOINCREMENT, -- auto source id + name character varying(40) NOT NULL, -- source name + description character varying(120) NOT NULL -- source description + ); + +-- Create control_destination table +CREATE TABLE fledge.control_destination ( + cpdid integer PRIMARY KEY AUTOINCREMENT, -- auto destination id + name character varying(40) NOT NULL, -- destination name + description character varying(120) NOT NULL -- destination description + ); + +-- Create control_pipelines table +CREATE TABLE fledge.control_pipelines ( + cpid integer PRIMARY KEY AUTOINCREMENT, -- control pipeline id + name character varying(255) NOT NULL , -- control pipeline name + stype integer , -- source type id from control_source table + sname character varying(80) , -- source name from control_source table + dtype integer , -- destination type id from control_destination table + dname character varying(80) , -- destination name from control_destination table + enabled boolean NOT NULL DEFAULT 'f' , -- false = A given pipeline is disabled by default + execution character varying(20) NOT NULL DEFAULT 'shared' -- pipeline will be executed as with shared execution model by default + ); + +-- Create control_filters table +CREATE TABLE fledge.control_filters ( + fid integer PRIMARY KEY AUTOINCREMENT, -- auto filter id + cpid integer NOT NULL , -- control pipeline id + forder integer NOT NULL , -- filter order + fname character varying(255) NOT NULL , -- Name of the filter instance + CONSTRAINT control_filters_fk1 FOREIGN KEY (cpid) + REFERENCES control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); + ---------------------------------------------------------------------- -- Initialization phase - DML ---------------------------------------------------------------------- @@ -857,3 +893,21 @@ CREATE TABLE fledge.service_schema ( service character varying(255) NOT NULL, version integer NOT NULL, definition JSON); + +-- Control Source +DELETE FROM fledge.control_source; +INSERT INTO fledge.control_source ( name, description ) + VALUES ('Any', 'Any source.'), + ('Service', 'A named service in source of the control pipeline.'), + ('API', 'The control pipeline source is the REST API.'), + ('Notification', 'The control pipeline originated from a notification.'), + ('Schedule', 'The control request was triggered by a schedule.'), + ('Script', 'The control request has come from the named script.'); + +-- Control Destination +DELETE FROM fledge.control_destination; +INSERT INTO fledge.control_destination ( name, description ) + VALUES ('Service', 'A name of service that is being controlled.'), + ('Asset', 'A name of asset that is being controlled.'), + ('Script', 'A name of script that will be executed.'), + ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); diff --git a/scripts/plugins/storage/sqlitelb/upgrade/60.sql b/scripts/plugins/storage/sqlitelb/upgrade/60.sql new file mode 100644 index 0000000000..fe914fda7f --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/60.sql @@ -0,0 +1,53 @@ +-- Create control_source table +CREATE TABLE fledge.control_source ( + cpsid integer PRIMARY KEY AUTOINCREMENT, -- auto source id + name character varying(40) NOT NULL, -- source name + description character varying(120) NOT NULL -- source description + ); + +-- Create control_destination table +CREATE TABLE fledge.control_destination ( + cpdid integer PRIMARY KEY AUTOINCREMENT, -- auto destination id + name character varying(40) NOT NULL, -- destination name + description character varying(120) NOT NULL -- destination description + ); + +-- Create control_pipelines table +CREATE TABLE fledge.control_pipelines ( + cpid integer PRIMARY KEY AUTOINCREMENT, -- control pipeline id + name character varying(255) NOT NULL , -- control pipeline name + stype integer , -- source type id from control_source table + sname character varying(80) , -- source name from control_source table + dtype integer , -- destination type id from control_destination table + dname character varying(80) , -- destination name from control_destination table + enabled boolean NOT NULL DEFAULT 'f' , -- false = A given pipeline is disabled by default + execution character varying(20) NOT NULL DEFAULT 'shared' -- pipeline will be executed as with shared execution model by default + ); + +-- Create control_filters table +CREATE TABLE fledge.control_filters ( + fid integer PRIMARY KEY AUTOINCREMENT, -- auto filter id + cpid integer NOT NULL , -- control pipeline id + forder integer NOT NULL , -- filter order + fname character varying(255) NOT NULL , -- Name of the filter instance + CONSTRAINT control_filters_fk1 FOREIGN KEY (cpid) + REFERENCES control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); + +-- Insert predefined entries for Control Source +DELETE FROM fledge.control_source; +INSERT INTO fledge.control_source ( name, description ) + VALUES ('Any', 'Any source.'), + ('Service', 'A named service in source of the control pipeline.'), + ('API', 'The control pipeline source is the REST API.'), + ('Notification', 'The control pipeline originated from a notification.'), + ('Schedule', 'The control request was triggered by a schedule.'), + ('Script', 'The control request has come from the named script.'); + +-- Insert predefined entries for Control Destination +DELETE FROM fledge.control_destination; +INSERT INTO fledge.control_destination ( name, description ) + VALUES ('Service', 'A name of service that is being controlled.'), + ('Asset', 'A name of asset that is being controlled.'), + ('Script', 'A name of script that will be executed.'), + ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); From 5ed85f23b1de22729bc89083c2a86ba26cb0458a Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Thu, 6 Apr 2023 14:40:38 +0530 Subject: [PATCH 246/499] FOGL-7635: Added support for ingesting readings generated by filters in plugin_shutdown Signed-off-by: Amandeep Singh Arora --- C/common/filter_pipeline.cpp | 2 +- C/common/include/filter_pipeline.h | 3 + C/services/south/ingest.cpp | 96 ++++++++++++++++++------------ 3 files changed, 62 insertions(+), 39 deletions(-) diff --git a/C/common/filter_pipeline.cpp b/C/common/filter_pipeline.cpp index af78771705..d6229cc654 100755 --- a/C/common/filter_pipeline.cpp +++ b/C/common/filter_pipeline.cpp @@ -29,7 +29,7 @@ using namespace std; * @param serviceName Name of the service to which this pipeline applies */ FilterPipeline::FilterPipeline(ManagementClient* mgtClient, StorageClient& storage, string serviceName) : - mgtClient(mgtClient), storage(storage), serviceName(serviceName), m_ready(false) + mgtClient(mgtClient), storage(storage), serviceName(serviceName), m_ready(false), m_shutdown(false) { } diff --git a/C/common/include/filter_pipeline.h b/C/common/include/filter_pipeline.h index 0bb20f9b3e..6c3f7c4029 100644 --- a/C/common/include/filter_pipeline.h +++ b/C/common/include/filter_pipeline.h @@ -52,6 +52,8 @@ class FilterPipeline // Check FilterPipeline is ready for data ingest bool isReady() { return m_ready; }; bool hasChanged(const std::string pipeline) const { return m_pipeline != pipeline; } + bool isShuttingDown() { return m_shutdown; }; + void setShuttingDown() { m_shutdown = true; } private: PLUGIN_HANDLE loadFilterPlugin(const std::string& filterName); @@ -66,6 +68,7 @@ class FilterPipeline m_filterCategories; std::string m_pipeline; bool m_ready; + bool m_shutdown; ServiceHandler *m_serviceHandler; }; diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index b4b3826c1f..00eb984182 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -330,6 +330,17 @@ Ingest::~Ingest() { m_shutdown = true; m_running = false; + + // Cleanup filters + { + lock_guard guard(m_pipelineMutex); + if (m_filterPipeline) + { + m_filterPipeline->setShuttingDown(); + m_filterPipeline->cleanupFilters(m_serviceName); // filter's shutdown API could potentially try to feed some new readings using the async ingest mechnanism + } + } + m_cv.notify_one(); m_thread->join(); processQueue(); @@ -339,13 +350,12 @@ Ingest::~Ingest() delete m_queue; delete m_thread; delete m_statsThread; - //delete m_data; - - // Cleanup filters - no other threads are running so no need for the lock - if (m_filterPipeline) + + // Delete filter pipeline { - m_filterPipeline->cleanupFilters(m_serviceName); - delete m_filterPipeline; + lock_guard guard(m_pipelineMutex); + if (m_filterPipeline) + delete m_filterPipeline; } } @@ -491,13 +501,6 @@ void Ingest::waitForQueue() */ void Ingest::processQueue() { - -/* typedef struct{ - std::set setDp; - unsigned int count; - }dpCountObj; -*/ - do { /* * If we have some data that has been previously filtered but failed to send, @@ -646,11 +649,14 @@ void Ingest::processQueue() lock_guard fqguard(m_fqMutex); if (m_fullQueues.empty()) { - // Block of code to execute holding the mutex - lock_guard guard(m_qMutex); - std::vector *newQ = new vector; - m_data = m_queue; - m_queue = newQ; + if (!m_shutdown) + { + // Block of code to execute holding the mutex + lock_guard guard(m_qMutex); + std::vector *newQ = new vector; + m_data = m_queue; + m_queue = newQ; + } } else { @@ -676,7 +682,7 @@ void Ingest::processQueue() */ { lock_guard guard(m_pipelineMutex); - if (m_filterPipeline) + if (m_filterPipeline && !m_filterPipeline->isShuttingDown()) { FilterPlugin *firstFilter = m_filterPipeline->getFirstFilterPlugin(); if (firstFilter) @@ -713,24 +719,27 @@ void Ingest::processQueue() * Check the first reading in the list to see if we are meeting the * latency configuration we have been set */ - vector::iterator itr = m_data->begin(); - if (itr != m_data->cend()) + if (m_data) { - Reading *firstReading = *itr; - struct timeval tmFirst, tmNow, dur; - gettimeofday(&tmNow, NULL); - firstReading->getUserTimestamp(&tmFirst); - timersub(&tmNow, &tmFirst, &dur); - long latency = dur.tv_sec * 1000 + (dur.tv_usec / 1000); - if (latency > m_timeout && m_highLatency == false) - { - m_logger->warn("Current send latency of %ldmS exceeds requested maximum latency of %dmS", latency, m_timeout); - m_highLatency = true; - } - else if (latency <= m_timeout / 1000 && m_highLatency) + vector::iterator itr = m_data->begin(); + if (itr != m_data->cend()) { - m_logger->warn("Send latency now within requested limits"); - m_highLatency = false; + Reading *firstReading = *itr; + struct timeval tmFirst, tmNow, dur; + gettimeofday(&tmNow, NULL); + firstReading->getUserTimestamp(&tmFirst); + timersub(&tmNow, &tmFirst, &dur); + long latency = dur.tv_sec * 1000 + (dur.tv_usec / 1000); + if (latency > m_timeout && m_highLatency == false) + { + m_logger->warn("Current send latency of %ldmS exceeds requested maximum latency of %dmS", latency, m_timeout); + m_highLatency = true; + } + else if (latency <= m_timeout / 1000 && m_highLatency) + { + m_logger->warn("Send latency now within requested limits"); + m_highLatency = false; + } } } @@ -745,7 +754,7 @@ void Ingest::processQueue() * 2- some readings removed * 3- New set of readings */ - if (!m_data->empty()) + if (m_data && !m_data->empty()) { if (m_storage.readingAppend(*m_data) == false) { @@ -966,8 +975,19 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, Ingest* ingest = (Ingest *)outHandle; if (ingest->m_data != readingSet->getAllReadingsPtr()) { - ingest->m_data->clear();// Remove any pointers still in the vector - *(ingest->m_data) = readingSet->getAllReadings(); + if (ingest->m_data) + { + ingest->m_data->clear();// Remove any pointers still in the vector + *(ingest->m_data) = readingSet->getAllReadings(); + } + else + { + ingest->m_data = new std::vector; + for (auto & r : readingSet->getAllReadings()) + { + ingest->m_data->emplace_back(new Reading(*r)); // Need to copy reading objects here, since "del readingSet" below would remove encapsulated reading objects also + } + } } readingSet->clear(); delete readingSet; From 6a7201f199efc4c56110bd71f148f16329602b07 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 7 Apr 2023 19:08:24 +0530 Subject: [PATCH 247/499] C util error logging on different exceptions required for plugin failure reason Signed-off-by: ashish-jabble --- python/fledge/services/core/api/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/fledge/services/core/api/utils.py b/python/fledge/services/core/api/utils.py index e5001eb60f..d28bccf684 100644 --- a/python/fledge/services/core/api/utils.py +++ b/python/fledge/services/core/api/utils.py @@ -27,7 +27,7 @@ def get_plugin_info(name, dir): out, err = p.communicate() res = out.decode("utf-8") jdoc = json.loads(res) - except OSError as err: + except (OSError, ValueError) as err: _logger.error("{} C plugin get info failed due to {}".format(name, str(err))) return {} except subprocess.CalledProcessError as err: @@ -36,8 +36,6 @@ def get_plugin_info(name, dir): else: _logger.error("{} C plugin get info failed due to {}".format(name, str(err))) return {} - except ValueError as err: - return {} except Exception as ex: _logger.error("{} C plugin get info failed due to {}".format(name, str(ex))) return {} From e5da7a8d4fb9203e0f879039aeb0fc66da7ddc5f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 10 Apr 2023 11:17:01 +0530 Subject: [PATCH 248/499] plugin module path corrected in task api exception Signed-off-by: ashish-jabble --- python/fledge/services/core/api/task.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/fledge/services/core/api/task.py b/python/fledge/services/core/api/task.py index 292af6b9d6..2f50da83bf 100644 --- a/python/fledge/services/core/api/task.py +++ b/python/fledge/services/core/api/task.py @@ -176,6 +176,7 @@ async def add_task(request): return web.HTTPBadRequest(reason=msg) plugin_config = plugin_info['config'] if not plugin_config: + plugin_module_path = "{}/plugins/{}/{}".format(_FLEDGE_ROOT, task_type, plugin) raise web.HTTPNotFound(reason='Plugin "{}" import problem from path "{}"'.format( plugin, plugin_module_path)) except TypeError as ex: From 3fce927e25bf455572aae511178c872ef8e19a4e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 10 Apr 2023 15:57:17 +0530 Subject: [PATCH 249/499] PostgreSql schema changes for control filter pipeline along with upgrade and downgrade scripts Signed-off-by: ashish-jabble --- .../plugins/storage/postgres/downgrade/59.sql | 12 +++ scripts/plugins/storage/postgres/init.sql | 86 ++++++++++++++++++ .../plugins/storage/postgres/upgrade/60.sql | 88 +++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 scripts/plugins/storage/postgres/downgrade/59.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/60.sql diff --git a/scripts/plugins/storage/postgres/downgrade/59.sql b/scripts/plugins/storage/postgres/downgrade/59.sql new file mode 100644 index 0000000000..94df72c65a --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/59.sql @@ -0,0 +1,12 @@ +-- Drop control pipeline sequences & tables +DROP TABLE IF EXISTS fledge.control_source; +DROP SEQUENCE IF EXISTS fledge.control_source_id_seq; + +DROP TABLE IF EXISTS fledge.control_destination; +DROP SEQUENCE IF EXISTS fledge.control_destination_id_seq; + +DROP TABLE IF EXISTS fledge.control_filters; +DROP SEQUENCE IF EXISTS fledge.control_filters_id_seq; + +DROP TABLE IF EXISTS fledge.control_pipelines; +DROP SEQUENCE IF EXISTS fledge.control_pipelines_id_seq; diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 3098557080..df94c4212e 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -168,6 +168,35 @@ CREATE SEQUENCE fledge.asset_tracker_id_seq MAXVALUE 9223372036854775807 CACHE 1; +CREATE SEQUENCE fledge.control_source_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 9223372036854775807 + CACHE 1; + +CREATE SEQUENCE fledge.control_destination_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 9223372036854775807 + CACHE 1; + +CREATE SEQUENCE fledge.control_pipelines_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 9223372036854775807 + CACHE 1; + +CREATE SEQUENCE fledge.control_filters_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 9223372036854775807 + CACHE 1; + + ----- TABLES & SEQUENCES -- Log Codes Table @@ -837,6 +866,45 @@ CREATE TABLE fledge.acl_usage ( entity_name character varying(255) NOT NULL, -- associated entity name CONSTRAINT usage_acl_pkey PRIMARY KEY (name, entity_type, entity_name) ); +-- Create control_source table +CREATE TABLE fledge.control_source ( + cpsid integer NOT NULL DEFAULT nextval('fledge.control_source_id_seq'::regclass), -- auto source id + name character varying(40) NOT NULL , -- source name + description character varying(120) NOT NULL , -- source description + CONSTRAINT control_source_pkey PRIMARY KEY (cpsid) + ); + +-- Create control_destination table +CREATE TABLE fledge.control_destination ( + cpdid integer NOT NULL DEFAULT nextval('fledge.control_destination_id_seq'::regclass), -- auto destination id + name character varying(40) NOT NULL , -- destination name + description character varying(120) NOT NULL , -- destination description + CONSTRAINT control_destination_pkey PRIMARY KEY (cpdid) + ); + +-- Create control_pipelines table +CREATE TABLE fledge.control_pipelines ( + cpid integer NOT NULL DEFAULT nextval('fledge.control_pipelines_id_seq'::regclass), -- control pipeline id + name character varying(255) NOT NULL , -- control pipeline name + stype integer , -- source type id from control_source table + sname character varying(80) , -- source name from control_source table + dtype integer , -- destination type id from control_destination table + dname character varying(80) , -- destination name from control_destination table + enabled boolean NOT NULL DEFAULT FALSE , -- false = A given pipeline is disabled by default + execution character varying(20) NOT NULL DEFAULT 'shared' , -- pipeline will be executed as with shared execution model by default + CONSTRAINT control_pipelines_pkey PRIMARY KEY (cpid) + ); + +-- Create control_filters table +CREATE TABLE fledge.control_filters ( + fid integer NOT NULL DEFAULT nextval('fledge.control_filters_id_seq'::regclass), -- auto filter id + cpid integer NOT NULL , -- control pipeline id + forder integer NOT NULL , -- filter order + fname character varying(255) NOT NULL , -- Name of the filter instance + CONSTRAINT control_filters_pkey PRIMARY KEY (fid) , + CONSTRAINT control_filters_fk1 FOREIGN KEY (cpid) REFERENCES fledge.control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); + -- Grants to fledge schema GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA fledge TO PUBLIC; @@ -1070,3 +1138,21 @@ CREATE TABLE fledge.service_schema ( service character varying(255) NOT NULL, version integer NOT NULL, definition JSON); + +-- Insert predefined entries for Control Source +DELETE FROM fledge.control_source; +INSERT INTO fledge.control_source ( name, description ) + VALUES ('Any', 'Any source.'), + ('Service', 'A named service in source of the control pipeline.'), + ('API', 'The control pipeline source is the REST API.'), + ('Notification', 'The control pipeline originated from a notification.'), + ('Schedule', 'The control request was triggered by a schedule.'), + ('Script', 'The control request has come from the named script.'); + +-- Insert predefined entries for Control Destination +DELETE FROM fledge.control_destination; +INSERT INTO fledge.control_destination ( name, description ) + VALUES ('Service', 'A name of service that is being controlled.'), + ('Asset', 'A name of asset that is being controlled.'), + ('Script', 'A name of script that will be executed.'), + ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); diff --git a/scripts/plugins/storage/postgres/upgrade/60.sql b/scripts/plugins/storage/postgres/upgrade/60.sql new file mode 100644 index 0000000000..084ce96f02 --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/60.sql @@ -0,0 +1,88 @@ +-- Create SEQUENCE for control_source +CREATE SEQUENCE fledge.control_source_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 9223372036854775807 + CACHE 1; + +-- Create control_source table +CREATE TABLE fledge.control_source ( + cpsid integer NOT NULL DEFAULT nextval('fledge.control_source_id_seq'::regclass), -- auto source id + name character varying(40) NOT NULL , -- source name + description character varying(120) NOT NULL , -- source description + CONSTRAINT control_source_pkey PRIMARY KEY (cpsid) + ); + +-- Create SEQUENCE for control_destination +CREATE SEQUENCE fledge.control_destination_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 9223372036854775807 + CACHE 1; + +-- Create control_destination table +CREATE TABLE fledge.control_destination ( + cpdid integer NOT NULL DEFAULT nextval('fledge.control_destination_id_seq'::regclass), -- auto destination id + name character varying(40) NOT NULL , -- destination name + description character varying(120) NOT NULL , -- destination description + CONSTRAINT control_destination_pkey PRIMARY KEY (cpdid) + ); + +-- Create SEQUENCE for control_pipelines +CREATE SEQUENCE fledge.control_pipelines_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 9223372036854775807 + CACHE 1; + +-- Create control_pipelines table +CREATE TABLE fledge.control_pipelines ( + cpid integer NOT NULL DEFAULT nextval('fledge.control_pipelines_id_seq'::regclass), -- control pipeline id + name character varying(255) NOT NULL , -- control pipeline name + stype integer , -- source type id from control_source table + sname character varying(80) , -- source name from control_source table + dtype integer , -- destination type id from control_destination table + dname character varying(80) , -- destination name from control_destination table + enabled boolean NOT NULL DEFAULT FALSE , -- false = A given pipeline is disabled by default + execution character varying(20) NOT NULL DEFAULT 'shared' , -- pipeline will be executed as with shared execution model by default + CONSTRAINT control_pipelines_pkey PRIMARY KEY (cpid) + ); + +-- Create SEQUENCE for control_filters +CREATE SEQUENCE fledge.control_filters_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 9223372036854775807 + CACHE 1; + +-- Create control_filters table +CREATE TABLE fledge.control_filters ( + fid integer NOT NULL DEFAULT nextval('fledge.control_filters_id_seq'::regclass), -- auto filter id + cpid integer NOT NULL , -- control pipeline id + forder integer NOT NULL , -- filter order + fname character varying(255) NOT NULL , -- Name of the filter instance + CONSTRAINT control_filters_pkey PRIMARY KEY (fid) , + CONSTRAINT control_filters_fk1 FOREIGN KEY (cpid) REFERENCES fledge.control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); + +-- Insert predefined entries for Control Source +DELETE FROM fledge.control_source; +INSERT INTO fledge.control_source ( name, description ) + VALUES ('Any', 'Any source.'), + ('Service', 'A named service in source of the control pipeline.'), + ('API', 'The control pipeline source is the REST API.'), + ('Notification', 'The control pipeline originated from a notification.'), + ('Schedule', 'The control request was triggered by a schedule.'), + ('Script', 'The control request has come from the named script.'); + +-- Insert predefined entries for Control Destination +DELETE FROM fledge.control_destination; +INSERT INTO fledge.control_destination ( name, description ) + VALUES ('Service', 'A name of service that is being controlled.'), + ('Asset', 'A name of asset that is being controlled.'), + ('Script', 'A name of script that will be executed.'), + ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); From d9a913ac94a68b3cda3fce7ee2715e8559aea06e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 10 Apr 2023 16:18:11 +0530 Subject: [PATCH 250/499] source & destination KV pair fixes in create and update & also fixed empty filters pipeline update Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 67 ++++++++++--------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index f7b555158a..5428fd51da 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -77,10 +77,10 @@ async def create(request: web.Request) -> web.Response: source or destination :Example: - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "pump", "enable": "true", "execution": "shared", "source": {"type": 2, "name": "pump"}, "destination": {}}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "broadcast", "enable": "true", "execution": "exclusive", "source": {}, "destination": {"type": 4}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "pump", "enable": "true", "execution": "shared", "source": {"type": 2, "name": "pump"}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "broadcast", "enable": "true", "execution": "exclusive", "destination": {"type": 4}}' curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enable": "true", "execution": "shared", "source": {"type": 2, "name": "opcua"}, "destination": {"type": 2, "name": "pump1"}}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "Test", "enable": "true", "source": {}, "destination": {}, "filters": ["Filter1", "Filter2"]}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "Test", "enable": "true", "filters": ["Filter1", "Filter2"]}' """ try: data = await request.json() @@ -91,12 +91,20 @@ async def create(request: web.Request) -> web.Response: insert_result = await storage.insert_into_tbl("control_pipelines", payload) pipeline_name = column_names['name'] pipeline_filter = data.get('filters', None) - source = {'type': column_names["stype"], 'name': column_names["sname"]} - destination = {'type': column_names["dtype"], 'name': column_names["dname"]} + source_type = column_names.get("stype") + source = {'type': column_names["stype"], 'name': column_names["sname"]} if source_type else None + des_type = column_names.get("dtype") + destination = {'type': column_names["dtype"], 'name': column_names["dname"]} if des_type else None if insert_result['response'] == "inserted" and insert_result['rows_affected'] == 1: final_result = await _pipeline_in_use(pipeline_name, source, destination, info=True) - final_result["stype"] = await _get_lookup_value('source', final_result["stype"]) - final_result["dtype"] = await _get_lookup_value('destination', final_result["dtype"]) + final_result['source'] = {"type": await _get_lookup_value('source', final_result["stype"]), + "name": final_result['sname']} + final_result['destination'] = {"type": await _get_lookup_value('destination', final_result["dtype"]), + "name": final_result['dname']} + final_result.pop('stype', None) + final_result.pop('sname', None) + final_result.pop('dtype', None) + final_result.pop('dname', None) final_result['enabled'] = False if final_result['enabled'] == 'f' else True final_result['filters'] = [] if pipeline_filter: @@ -186,7 +194,7 @@ async def update(request: web.Request) -> web.Response: :Example: curl -sX PUT http://localhost:8081/fledge/control/pipeline/1 -d '{"filters": ["F3", "F2"]}' curl -sX PUT http://localhost:8081/fledge/control/pipeline/13 -d '{"name": "Changed"}' - curl -sX PUT http://localhost:8081/fledge/control/pipeline/9 -d '{"enable": "false", "execution": "exclusive", "filters": [], "source": {"type": 1, "name": "1"}, "destination": {"type": 3, "name": 1}}' + curl -sX PUT http://localhost:8081/fledge/control/pipeline/9 -d '{"enable": "false", "execution": "exclusive", "filters": [], "source": {"type": 1, "name": "Universal"}, "destination": {"type": 3, "name": "TestScript"}}' """ cpid = request.match_info.get('id', None) try: @@ -199,14 +207,15 @@ async def update(request: web.Request) -> web.Response: await storage.update_tbl("control_pipelines", payload) filters = data.get('filters', None) if filters is not None: - go_ahead = await _check_filters(storage, filters) + go_ahead = await _check_filters(storage, filters) if filters else True if go_ahead: # remove old filters if exists await _remove_filters(storage, pipeline['filters'], cpid) - # Update new filters - new_filters = await _update_filters(storage, cpid, pipeline['name'], filters) - if not new_filters: - raise ValueError('Filters do not exist as per the given list {}'.format(filters)) + if filters: + # Update new filters + new_filters = await _update_filters(storage, cpid, pipeline['name'], filters) + if not new_filters: + raise ValueError('Filters do not exist as per the given list {}'.format(filters)) else: raise ValueError('Filters do not exist as per the given list {}'.format(filters)) except ValueError as err: @@ -281,21 +290,13 @@ async def _get_pipeline(cpid, filters=True): if not rows: raise KeyError("Pipeline having ID: {} not found.".format(cpid)) r = rows[0] - sname = "" - dname = "" - if r['stype']: - sname = await _get_lookup_value("source", r['stype']) - if not sname: - raise ValueError("Invalid source type found.") - if r['dtype']: - dname = await _get_lookup_value("destination", r['dtype']) - if not dname: - raise ValueError("Invalid destination type found.") pipeline = { 'id': r['cpid'], 'name': r['name'], - 'source': {'type': sname, 'name': r['sname']} if r['stype'] else {}, - 'destination': {'type': dname, 'name': r['dname']} if r['dtype'] else {}, + 'source': {'type': await _get_lookup_value("source", r['stype']), + 'name': r['sname']} if r['stype'] else {'type': 0, 'name': ''}, + 'destination': {'type': await _get_lookup_value("destination", r['dtype']), + 'name': r['dname']} if r['dtype'] else {'type': 0, 'name': ''}, 'enabled': False if r['enabled'] == 'f' else True, 'execution': r['execution'] } @@ -310,8 +311,8 @@ async def _pipeline_in_use(name, source, destination, info=False): result = await _get_table_column_by_value("control_pipelines", "name", name) rows = result["rows"] row = None - new_data = {'source': source if source else {'type': '', 'name': ''}, - 'destination': destination if destination else {'type': '', 'name': ''} + new_data = {'source': source if source else {'type': 0, 'name': ''}, + 'destination': destination if destination else {'type': 0, 'name': ''} } is_matched = False for r in rows: @@ -382,7 +383,9 @@ async def _check_parameters(payload): if source_type is not None: if not isinstance(source_type, int): raise ValueError("Source type should be an integer value.") - # TODO: source type validation + stype = await _get_lookup_value("source", source_type) + if not stype: + raise ValueError("Invalid source type found.") else: raise ValueError('Source type is missing.') if source_name is not None: @@ -398,7 +401,7 @@ async def _check_parameters(payload): else: raise ValueError('Source name is missing.') else: - column_names["stype"] = "" + column_names["stype"] = 0 column_names["sname"] = "" # destination destination = payload.get('destination', None) @@ -411,7 +414,9 @@ async def _check_parameters(payload): if des_type is not None: if not isinstance(des_type, int): raise ValueError("Destination type should be an integer value.") - # TODO: destination type validation + dtype = await _get_lookup_value("destination", des_type) + if not dtype: + raise ValueError("Invalid destination type found.") else: raise ValueError('Destination type is missing.') # Note: when destination type is Broadcast; no name is applied @@ -434,7 +439,7 @@ async def _check_parameters(payload): column_names["dtype"] = des_type column_names["dname"] = des_name else: - column_names["dtype"] = "" + column_names["dtype"] = 0 column_names["dname"] = "" if name: # Check unique pipeline From 978a28639decde3930aee6a1a6e6ba6e8c325fc8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 12 Apr 2023 15:18:22 +0530 Subject: [PATCH 251/499] consistency in pipeline reponse for source and destination Signed-off-by: ashish-jabble --- .../fledge/services/core/api/control_pipeline.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index 5428fd51da..ca9dc5ebcf 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -149,8 +149,10 @@ async def get_all(request: web.Request) -> web.Response: temp = { 'id': r['cpid'], 'name': r['name'], - 'source': {'type': ''.join(source_name), 'name': r['sname']} if r['stype'] else {}, - 'destination': {'type': ''.join(des_name), 'name': r['dname']} if r['dtype'] else {}, + 'source': { + 'type': ''.join(source_name), 'name': r['sname']} if r['stype'] else {'type': '', 'name': ''}, + 'destination': { + 'type': ''.join(des_name), 'name': r['dname']} if r['dtype'] else {'type': '', 'name': ''}, 'enabled': False if r['enabled'] == 'f' else True, 'execution': r['execution'] } @@ -293,10 +295,10 @@ async def _get_pipeline(cpid, filters=True): pipeline = { 'id': r['cpid'], 'name': r['name'], - 'source': {'type': await _get_lookup_value("source", r['stype']), - 'name': r['sname']} if r['stype'] else {'type': 0, 'name': ''}, - 'destination': {'type': await _get_lookup_value("destination", r['dtype']), - 'name': r['dname']} if r['dtype'] else {'type': 0, 'name': ''}, + 'source': {'type': await _get_lookup_value("source", r['stype']), 'name': r['sname'] + } if r['stype'] else {'type': '', 'name': ''}, + 'destination': {'type': await _get_lookup_value("destination", r['dtype']), 'name': r['dname'] + } if r['dtype'] else {'type': '', 'name': ''}, 'enabled': False if r['enabled'] == 'f' else True, 'execution': r['execution'] } From 266d07955e39e127615a4c9aa70dfa2c131d055a Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Wed, 12 Apr 2023 19:36:12 +0530 Subject: [PATCH 252/499] FOGL-7607: Fixed handling of filtered readings from python filter Signed-off-by: Amandeep Singh Arora --- C/common/datapoint.cpp | 8 ++------ .../python/python_plugin_interface.cpp | 11 ++++++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/C/common/datapoint.cpp b/C/common/datapoint.cpp index 38bb6077ca..b588a51f48 100644 --- a/C/common/datapoint.cpp +++ b/C/common/datapoint.cpp @@ -143,11 +143,6 @@ void DatapointValue::deleteNestedDPV() delete m_value.a; m_value.a = NULL; } - else if (m_type == T_IMAGE) - { - delete m_value.image; - m_value.a = NULL; - } else if (m_type == T_DATABUFFER) { delete m_value.dataBuffer; @@ -408,4 +403,5 @@ std::vector *Datapoint::recursiveJson(const rapidjson::Value& docume } return p; -} \ No newline at end of file +} + diff --git a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp index 7be2634ed1..2b290e6e1d 100755 --- a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp @@ -272,8 +272,6 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) handle, readingsList); Py_CLEAR(pFunc); - // Remove input data - data->removeAll(); // Handle returned data if (!pReturn) @@ -295,8 +293,15 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) { // Create ReadingSet from Python reading list filteredReadingSet = new PythonReadingSet(readingsList); - data->append(filteredReadingSet); + + // Remove input data + data->removeAll(); + + // Append filtered readings + data->append(filteredReadingSet->getAllReadings()); + delete filteredReadingSet; + filteredReadingSet = NULL; } catch (std::exception e) { From de19bc2c528ab2b76f65c7b978c306b0eb9bd7bd Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 14 Apr 2023 12:42:55 +0530 Subject: [PATCH 253/499] fogbench related items fixes in documentation Signed-off-by: ashish-jabble --- docs/building_fledge/06_testing.rst | 165 ++++++++++----------- docs/rest_api_guide/04_RESTuser.rst | 217 ++++++++++++---------------- 2 files changed, 164 insertions(+), 218 deletions(-) diff --git a/docs/building_fledge/06_testing.rst b/docs/building_fledge/06_testing.rst index 9c40cd7e71..066ad1e7c3 100644 --- a/docs/building_fledge/06_testing.rst +++ b/docs/building_fledge/06_testing.rst @@ -263,34 +263,14 @@ The template file looks like this: $ cat /usr/local/fledge/data/extras/fogbench/fogbench_sensor_coap.template.json [ - { "name" : "fogbench_luxometer", - "sensor_values" : [ { "name": "lux", "type": "number", "min": 0, "max": 130000, "precision":3 } ] }, - { "name" : "fogbench_pressure", - "sensor_values" : [ { "name": "pressure", "type": "number", "min": 800.0, "max": 1100.0, "precision":1 } ] }, - { "name" : "fogbench_humidity", - "sensor_values" : [ { "name": "humidity", "type": "number", "min": 0.0, "max": 100.0 }, - { "name": "temperature", "type": "number", "min": 0.0, "max": 50.0 } ] }, - { "name" : "fogbench_temperature", - "sensor_values" : [ { "name": "object", "type": "number", "min": 0.0, "max": 50.0 }, - { "name": "ambient", "type": "number", "min": 0.0, "max": 50.0 } ] }, - { "name" : "fogbench_accelerometer", - "sensor_values" : [ { "name": "x", "type": "number", "min": -2.0, "max": 2.0 }, - { "name": "y", "type": "number", "min": -2.0, "max": 2.0 }, - { "name": "z", "type": "number", "min": -2.0, "max": 2.0 } ] }, - { "name" : "fogbench_gyroscope", - "sensor_values" : [ { "name": "x", "type": "number", "min": -255.0, "max": 255.0 }, - { "name": "y", "type": "number", "min": -255.0, "max": 255.0 }, - { "name": "z", "type": "number", "min": -255.0, "max": 255.0 } ] }, - { "name" : "fogbench_magnetometer", - "sensor_values" : [ { "name": "x", "type": "number", "min": -255.0, "max": 255.0 }, - { "name": "y", "type": "number", "min": -255.0, "max": 255.0 }, - { "name": "z", "type": "number", "min": -255.0, "max": 255.0 } ] }, - { "name" : "fogbench_mouse", - "sensor_values" : [ { "name": "button", "type": "enum", "list": [ "up", "down" ] } ] }, - { "name" : "fogbench_switch", - "sensor_values" : [ { "name": "button", "type": "enum", "list": [ "up", "down" ] } ] }, - { "name" : "fogbench_wall clock", - "sensor_values" : [ { "name": "tick", "type": "enum", "list": [ "tock" ] } ] } + { "name" : "asset_1", + "sensor_values" : [ { "name": "dp_1", "type": "number", "min": -2.0, "max": 2.0 }, + { "name": "dp_1", "type": "number", "min": -2.0, "max": 2.0 }, + { "name": "dp_1", "type": "number", "min": -2.0, "max": 2.0 } ] }, + { "name" : "asset_2", + "sensor_values" : [ { "name": "lux", "type": "number", "min": 0, "max": 130000, "precision":3 } ] }, + { "name" : "asset_3", + "sensor_values" : [ { "name": "pressure", "type": "number", "min": 800.0, "max": 1100.0, "precision":1 } ] } ] $ @@ -304,66 +284,67 @@ Now you should have all the information necessary to test the CoAP South microse - ``$FLEDGE_ROOT/scripts/extras/fogbench`` ``-t $FLEDGE_ROOT/data/extras/fogbench/fogbench_sensor_coap.template.json``, if you are in a development environment, with the *FLEDGE_ROOT* environment variable set with the path to your project repository folder - ``$FLEDGE_ROOT/bin/fogbench -t $FLEDGE_DATA/extras/fogbench/fogbench_sensor_coap.template.json``, if you are in a deployed environment, with *FLEDGE_ROOT* and *FLEDGE_DATA* set correctly. - - If you have installed Fledge in the default location (i.e. */usr/local/fledge*), type ``cd /usr/local/fledge;bin/fogbench -t data/extras/fogbench/fogbench_sensor_coap.template.json``. -- ``fledge.fogbench`` ``-t /snap/fledge/current/usr/local/fledge/data/extras/fogbench/fogbench_sensor_coap.template.json``, if you have installed a snap version of Fledge. + - If you have installed Fledge in the default location (i.e. */usr/local/fledge*), type ``/usr/local/fledge/bin/fogbench -t data/extras/fogbench/fogbench_sensor_coap.template.json``. In development environment the output of your command should be: .. code-block:: console - $ $FLEDGE_ROOT/scripts/extras/fogbench -t data/extras/fogbench/fogbench_sensor_coap.template.json - >>> Make sure south CoAP plugin service is running & listening on specified host and port - Total Statistics: + $ $FLEDGE_ROOT/scripts/extras/fogbench -t $FLEDGE_ROOT/data/extras/fogbench/fogbench_sensor_coap.template.json + >>> Make sure south CoAP plugin service is running + & listening on specified host and port + + Total Statistics: - Start Time: 2017-12-17 07:17:50.615433 - Ene Time: 2017-12-17 07:17:50.650620 + Start Time: 2023-04-14 11:15:50.679366 + End Time: 2023-04-14 11:15:50.711856 - Total Messages Transferred: 10 - Total Bytes Transferred: 2880 + Total Messages Transferred: 3 + Total Bytes Transferred: 720 - Total Iterations: 1 - Total Messages per Iteration: 10.0 - Total Bytes per Iteration: 2880.0 + Total Iterations: 1 + Total Messages per Iteration: 3.0 + Total Bytes per Iteration: 720.0 - Min messages/second: 284.19586779208225 - Max messages/second: 284.19586779208225 - Avg messages/second: 284.19586779208225 + Min messages/second: 92.33610341643583 + Max messages/second: 92.33610341643583 + Avg messages/second: 92.33610341643583 - Min Bytes/second: 81848.4099241197 - Max Bytes/second: 81848.4099241197 - Avg Bytes/second: 81848.4099241197 + Min Bytes/second: 22160.6648199446 + Max Bytes/second: 22160.6648199446 + Avg Bytes/second: 22160.6648199446 $ -Congratulations! You have just inserted data into Fledge from the CoAP South microservice. More specifically, the output informs you that the data inserted has been composed by 10 different messages for a total of 2,880 Bytes, for an average of 284 messages per second and 81,848 Bytes per second. +Congratulations! You have just inserted data into Fledge from the CoAP South microservice. More specifically, the output informs you that the data inserted has been composed by 10 different messages for a total of 720 Bytes, for an average of 92 messages per second and 22,160 Bytes per second. If you want to stress Fledge a bit, you may insert the same data sample several times, by using the *-I* or *--iterations* argument: .. code-block:: console $ $FLEDGE_ROOT/scripts/extras/fogbench -t data/extras/fogbench/fogbench_sensor_coap.template.json -I 100 - >>> Make sure south CoAP plugin service is running & listening on specified host and port - Total Statistics: + >>> Make sure south CoAP plugin service is running & listening on specified host and port + Total Statistics: - Start Time: 2017-12-17 07:33:40.568130 - End Time: 2017-12-17 07:33:43.205626 + Start Time: 2023-04-14 11:18:03.586924 + End Time: 2023-04-14 11:18:04.582291 - Total Messages Transferred: 1000 - Total Bytes Transferred: 288000 + Total Messages Transferred: 300 + Total Bytes Transferred: 72000 - Total Iterations: 100 - Total Messages per Iteration: 10.0 - Total Bytes per Iteration: 2880.0 + Total Iterations: 100 + Total Messages per Iteration: 3.0 + Total Bytes per Iteration: 720.0 - Min messages/second: 98.3032852957946 - Max messages/second: 625.860558267618 - Avg messages/second: 455.15247432732866 + Min messages/second: 90.53597295992274 + Max messages/second: 454.33893684688775 + Avg messages/second: 323.7178365566367 - Min Bytes/second: 28311.346165188843 - Max Bytes/second: 180247.840781074 - Avg Bytes/second: 131083.9126062706 + Min Bytes/second: 21728.63351038146 + Max Bytes/second: 109041.34484325306 + Avg Bytes/second: 77692.28077359282 $ -Here we have inserted the same set of data 100 times, therefore the total number of Bytes inserted is 288,000. The performance and insertion rates varies with each iteration and *fogbench* presents the minimum, maximum and average values. +Here we have inserted the same set of data 100 times, therefore the total number of Bytes inserted is 72,000. The performance and insertion rates varies with each iteration and *fogbench* presents the minimum, maximum and average values. Checking What's Inside Fledge @@ -374,13 +355,13 @@ We can check if Fledge has now stored what we have inserted from the South micro .. code-block:: console $ curl -s http://localhost:8081/fledge/asset ; echo - [{"assetCode": "fogbench_switch", "count": 11}, {"assetCode": "fogbench_temperature", "count": 11}, {"assetCode": "fogbench_humidity", "count": 11}, {"assetCode": "fogbench_luxometer", "count": 11}, {"assetCode": "fogbench_accelerometer", "count": 11}, {"assetCode": "wall clock", "count": 11}, {"assetCode": "fogbench_magnetometer", "count": 11}, {"assetCode": "mouse", "count": 11}, {"assetCode": "fogbench_pressure", "count": 11}, {"assetCode": "fogbench_gyroscope", "count": 11}] + [{"count": 11, "assetCode": "asset_1"}, {"count": 11, "assetCode": "asset_2"}, {"count": 11, "assetCode": "asset_3"}] $ The output of the asset entry point provides a list of assets buffered in Fledge and the count of elements stored. The output is a JSON array with two elements: -- **assetCode** : the name of the sensor or device that provides the data -- **count** : the number of occurrences of the asset in the buffer +- **count** : the number of occurrences of the asset in the buffer. +- **assetCode** : the name of the sensor or device that provides the data. Feeding East/West Applications @@ -390,53 +371,53 @@ Let's suppose that we are interested in the data collected for one of the assets .. code-block:: console - $ curl -s http://localhost:8081/fledge/asset/fogbench_temperature ; echo - [{"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41}}, {"timestamp": "2017-12-18 10:38:12.580", "reading": {"ambient": 33, "object": 7}}] + $ curl -s http://localhost:8081/fledge/asset/asset_2 ; echo + [{"reading": {"lux": 75723.923}, "timestamp": "2023-04-14 11:25:05.672528"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}] $ Let's see the JSON output on a more readable format: .. code-block:: json - [ { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:29.652", "reading": {"ambient": 13, "object": 41} }, - { "timestamp": "2017-12-18 10:38:12.580", "reading": {"ambient": 33, "object": 7} } ] + [ + {"reading": {"lux": 75723.923}, "timestamp": "2023-04-14 11:25:05.672528"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"}, + {"reading": {"lux": 50475.99}, "timestamp": "2023-04-14 11:24:49.767983"} + ] The JSON structure depends on the sensor and the plugin used to capture the data. In this case, the values shown are: +- **reading** : a JSON structure that is the set of data points provided by the sensor. In this case only datapoint named lux: +- **lux** : the lux meter value - **timestamp** : the timestamp generated by the sensors. In this case, since we have inserted 10 times the same value and one time a new value using *fogbench*, the result is 10 timestamps with the same value and one timestamp with a different value. -- **reading** : a JSON structure that is the set of data points provided by the sensor. In this case: -- **ambient** : the ambient temperature in Celsius -- **object** : the object temperature in Celsius. Again, the values are repeated 10 times, due to the iteration executed by *fogbench*, plus an isolated element, so there are 11 readings in total. Also, it is very unlikely that in a real sensor the ambient and the object temperature differ so much, but here we are using a random number generator. - -You can dig even more in the data and extract only a subset of the reading. Fog example, you can select the ambient temperature and limit to the last 5 readings: +You can dig even more in the data and extract only a subset of the reading. Fog example, you can select the lux and limit to the last 5 readings: .. code-block:: console - $ curl -s http://localhost:8081/fledge/asset/fogbench_temperature/ambient?limit=5 ; echo - [ { "ambient": 13, "timestamp": "2017-12-18 10:38:29.652" }, - { "ambient": 13, "timestamp": "2017-12-18 10:38:29.652" } - { "ambient": 13, "timestamp": "2017-12-18 10:38:29.652" }, - { "ambient": 13, "timestamp": "2017-12-18 10:38:29.652" }, - { "ambient": 13, "timestamp": "2017-12-18 10:38:29.652" } ] + $ curl -s http://localhost:8081/fledge/asset/asset_2/lux?limit=5 ; echo + [ + {"timestamp": "2023-04-14 11:25:05.672528", "lux": 75723.923}, + {"timestamp": "2023-04-14 11:24:49.767983", "lux": 50475.99}, + {"timestamp": "2023-04-14 11:24:49.767983", "lux": 50475.99}, + {"timestamp": "2023-04-14 11:24:49.767983", "lux": 50475.99}, + {"timestamp": "2023-04-14 11:24:49.767983", "lux": 50475.99} + ] $ - We have beautified the JSON output for you, so it is more readable. .. note:: When you select a specific element in the reading, the timestamp and the element are presented in the opposite order compared to the previous example. This is a known issue that will be fixed in the next version. - Sending Greetings to the Northern Hemisphere ============================================ diff --git a/docs/rest_api_guide/04_RESTuser.rst b/docs/rest_api_guide/04_RESTuser.rst index 924e5f5aeb..db5501da8d 100644 --- a/docs/rest_api_guide/04_RESTuser.rst +++ b/docs/rest_api_guide/04_RESTuser.rst @@ -55,33 +55,27 @@ An array of JSON objects, one per asset. - Type - Description - Example - * - assetCode - - string - - The code of the asset - - fogbench/accelerometer * - count - number - The number of recorded readings for the asset code - - 180 + - 11 + * - assetCode + - string + - The code of the asset + - asset_1 **Example** .. code-block:: console $ curl -sX GET http://localhost:8081/fledge/asset - [ { "count": 18, "assetCode": "fogbench/accelerometer" }, - { "count": 18, "assetCode": "fogbench/gyroscope" }, - { "count": 18, "assetCode": "fogbench/humidity" }, - { "count": 18, "assetCode": "fogbench/luxometer" }, - { "count": 18, "assetCode": "fogbench/magnetometer" }, - { "count": 18, "assetCode": "fogbench/mouse" }, - { "count": 18, "assetCode": "fogbench/pressure" }, - { "count": 18, "assetCode": "fogbench/switch" }, - { "count": 18, "assetCode": "fogbench/temperature" }, - { "count": 18, "assetCode": "fogbench/wall clock" } ] + [ + {"count": 11, "assetCode": "asset_1"}, + {"count": 11, "assetCode": "asset_2"}, + {"count": 11, "assetCode": "asset_3"} + ] $ - GET asset readings ~~~~~~~~~~~~~~~~~~ @@ -119,46 +113,35 @@ An array of JSON objects with the readings data for a series of readings sorted - Type - Description - Example - * - timestamp - - timestamp - - The time at which the reading was received - - 2018-04-16 14:33:18.215 * - reading - JSON object - The JSON reading object received from the sensor - - {"reading": {"x":0, "y":0, "z":1} + - {"pressure": 885.7} + * - timestamp + - timestamp + - The time at which the reading was received + - 2023-04-14 12:04:34.603963 **Example** .. code-block:: console - $ curl -sX GET http://localhost:8081/fledge/asset/fogbench%2Faccelerometer - [ { "reading": { "x": 0, "y": -2, "z": 0 }, "timestamp": "2018-04-19 14:20:59.692" }, - { "reading": { "x": 0, "y": 0, "z": -1 }, "timestamp": "2018-04-19 14:20:54.643" }, - { "reading": { "x": -1, "y": 2, "z": 1 }, "timestamp": "2018-04-19 14:20:49.899" }, - { "reading": { "x": -1, "y": -1, "z": 1 }, "timestamp": "2018-04-19 14:20:47.026" }, - { "reading": { "x": -1, "y": -2, "z": -2 }, "timestamp": "2018-04-19 14:20:42.746" }, - { "reading": { "x": 0, "y": 2, "z": 0 }, "timestamp": "2018-04-19 14:20:37.418" }, - { "reading": { "x": -2, "y": -1, "z": 2 }, "timestamp": "2018-04-19 14:20:32.650" }, - { "reading": { "x": 0, "y": 0, "z": 1 }, "timestamp": "2018-04-19 14:06:05.870" }, - { "reading": { "x": 1, "y": 1, "z": 1 }, "timestamp": "2018-04-19 14:06:05.870" }, - { "reading": { "x": 0, "y": 0, "z": -1 }, "timestamp": "2018-04-19 14:06:05.869" }, - { "reading": { "x": 2, "y": -1, "z": 0 }, "timestamp": "2018-04-19 14:06:05.868" }, - { "reading": { "x": -1, "y": -2, "z": 2 }, "timestamp": "2018-04-19 14:06:05.867" }, - { "reading": { "x": 2, "y": 1, "z": 1 }, "timestamp": "2018-04-19 14:06:05.867" }, - { "reading": { "x": 1, "y": -2, "z": 1 }, "timestamp": "2018-04-19 14:06:05.866" }, - { "reading": { "x": 2, "y": -1, "z": 1 }, "timestamp": "2018-04-19 14:06:05.865" }, - { "reading": { "x": 0, "y": -1, "z": 2 }, "timestamp": "2018-04-19 14:06:05.865" }, - { "reading": { "x": 0, "y": -2, "z": 1 }, "timestamp": "2018-04-19 14:06:05.864" }, - { "reading": { "x": -1, "y": -2, "z": 0 }, "timestamp": "2018-04-19 13:45:15.881" } ] + $ curl -sX GET http://localhost:8081/fledge/asset/asset_3 + [ + {"reading": {"pressure": 885.7}, "timestamp": "2023-04-14 12:04:34.603963"}, + {"reading": {"pressure": 846.3}, "timestamp": "2023-04-14 12:02:39.150127"}, + {"reading": {"pressure": 913.0}, "timestamp": "2023-04-14 12:02:26.616218"}, + {"reading": {"pressure": 994.7}, "timestamp": "2023-04-14 12:02:11.171338"}, + {"reading": {"pressure": 960.2}, "timestamp": "2023-04-14 12:01:56.979426"} + ] $ - $ curl -sX GET http://localhost:8081/fledge/asset/fogbench%2Faccelerometer?limit=5 - [ { "reading": { "x": 0, "y": -2, "z": 0 }, "timestamp": "2018-04-19 14:20:59.692" }, - { "reading": { "x": 0, "y": 0, "z": -1 }, "timestamp": "2018-04-19 14:20:54.643" }, - { "reading": { "x": -1, "y": 2, "z": 1 }, "timestamp": "2018-04-19 14:20:49.899" }, - { "reading": { "x": -1, "y": -1, "z": 1 }, "timestamp": "2018-04-19 14:20:47.026" }, - { "reading": { "x": -1, "y": -2, "z": -2 }, "timestamp": "2018-04-19 14:20:42.746" } ] + $ curl -sX GET http://localhost:8081/fledge/asset/asset_3?limit=3 + [ + {"reading": {"pressure": 885.7}, "timestamp": "2023-04-14 12:04:34.603963"}, + {"reading": {"pressure": 846.3}, "timestamp": "2023-04-14 12:02:39.150127"}, + {"reading": {"pressure": 913.0}, "timestamp": "2023-04-14 12:02:26.616218"} + ] $ Using *seconds* and *previous* to obtain historical data. @@ -218,43 +201,32 @@ An array of JSON objects with a series of readings sorted in reverse chronologic * - timestamp - timestamp - The time at which the reading was received - - 2018-04-16 14:33:18.215 - * - {reading} + - 2023-04-14 12:04:34.603937 + * - reading - JSON object - The value of the specified reading - - {"temperature": 20} + - {"lux": 47705.68} **Example** .. code-block:: console - $ curl -sX GET http://localhost:8081/fledge/asset/fogbench%2Fhumidity/temperature - [ { "temperature": 20, "timestamp": "2018-04-19 14:20:59.692" }, - { "temperature": 33, "timestamp": "2018-04-19 14:20:54.643" }, - { "temperature": 35, "timestamp": "2018-04-19 14:20:49.899" }, - { "temperature": 0, "timestamp": "2018-04-19 14:20:47.026" }, - { "temperature": 37, "timestamp": "2018-04-19 14:20:42.746" }, - { "temperature": 47, "timestamp": "2018-04-19 14:20:37.418" }, - { "temperature": 26, "timestamp": "2018-04-19 14:20:32.650" }, - { "temperature": 12, "timestamp": "2018-04-19 14:06:05.870" }, - { "temperature": 38, "timestamp": "2018-04-19 14:06:05.869" }, - { "temperature": 7, "timestamp": "2018-04-19 14:06:05.869" }, - { "temperature": 21, "timestamp": "2018-04-19 14:06:05.868" }, - { "temperature": 5, "timestamp": "2018-04-19 14:06:05.867" }, - { "temperature": 40, "timestamp": "2018-04-19 14:06:05.867" }, - { "temperature": 39, "timestamp": "2018-04-19 14:06:05.866" }, - { "temperature": 29, "timestamp": "2018-04-19 14:06:05.865" }, - { "temperature": 41, "timestamp": "2018-04-19 14:06:05.865" }, - { "temperature": 46, "timestamp": "2018-04-19 14:06:05.864" }, - { "temperature": 10, "timestamp": "2018-04-19 13:45:15.881" } ] + $ curl -sX GET http://localhost:8081/fledge/asset/asset_2/lux + [ + {"timestamp": "2023-04-14 12:04:34.603937", "lux": 47705.68}, + {"timestamp": "2023-04-14 12:02:39.150106", "lux": 97967.9}, + {"timestamp": "2023-04-14 12:02:26.616200", "lux": 28788.154}, + {"timestamp": "2023-04-14 12:02:11.171319", "lux": 57992.674}, + {"timestamp": "2023-04-14 12:01:56.979407", "lux": 10373.945} + ] $ - $ curl -sX GET http://localhost:8081/fledge/asset/fogbench%2Faccelerometer?limit=5 - [ { "temperature": 20, "timestamp": "2018-04-19 14:20:59.692" }, - { "temperature": 33, "timestamp": "2018-04-19 14:20:54.643" }, - { "temperature": 35, "timestamp": "2018-04-19 14:20:49.899" }, - { "temperature": 0, "timestamp": "2018-04-19 14:20:47.026" }, - { "temperature": 37, "timestamp": "2018-04-19 14:20:42.746" } ] + $ curl -sX GET http://localhost:8081/fledge/asset/asset_2/lux?limit=3 + [ + {"timestamp": "2023-04-14 11:25:05.672528", "lux": 75723.923}, + {"timestamp": "2023-04-14 11:24:49.767983", "lux": 50475.99}, + {"timestamp": "2023-04-14 11:23:15.672528", "lux": 75723.923} + ] $ @@ -282,28 +254,27 @@ A JSON object of a reading by asset code. - Type - Description - Example - * - {reading}.max - - number - - The maximum value of the set of sensor values selected in the query string - - 47 - * - {reading}.min + * - .lux.min - number - The minimum value of the set of sensor values selected in the query string - - 0 - * - {reading}.average + - 10373.945 + * - .lux.max + - number + - The maximum value of the set of sensor values selected in the query string + - 97967.9 + * - .lux.average - number - The average value of the set of sensor values selected in the query string - - 27 + - 48565.6706 **Example** .. code-block:: console - $ curl -sX GET http://localhost:8081/fledge/asset/fogbench%2Fhumidity/temperature/summary - { "temperature": { "max": 47, "min": 0, "average": 27 } } + $ curl -sX GET http://localhost:8081/fledge/asset/asset_2/lux/summary + {"lux": {"min": 10373.945, "max": 97967.9, "average": 48565.6706}} $ - GET all asset reading timespan ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -322,18 +293,18 @@ An array of JSON objects with newest and oldest timestamps of the readings held - Type - Description - Example - * - asset_code - - string - - The asset code for which the timestamps refer - - sinusoid * - oldest - string - The oldest timestamp held in the buffer for this asset - - "2022-11-08 17:07:02.623258" + - 2022-11-08 17:07:02.623258 * - newest - string - The newest timestamp held in the buffer for this asset - - "2022-11-09 14:52:50.069432" + - 2022-11-09 14:52:50.069432 + * - asset_code + - string + - The asset code for which the timestamps refer + - sinusoid **Example** @@ -374,11 +345,11 @@ A JSON object with the newest and oldest timestamps for the asset held in the st * - oldest - string - The oldest timestamp held in the buffer for this asset - - "2022-11-08 17:07:02.623258" + - 2022-11-08 17:07:02.623258 * - newest - string - The newest timestamp held in the buffer for this asset - - "2022-11-09 14:52:50.069432" + - 2022-11-09 14:52:50.069432 **Example** @@ -428,58 +399,52 @@ An array of JSON objects with a series of readings sorted in reverse chronologic - Type - Description - Example - * - timestamp - - timestamp - - The time the reading represents - - 2018-04-16 14:33:18 - * - max - - number - - The maximum value of the set of sensor values selected in the query string - - 47 * - min - number - The minimum value of the set of sensor values selected in the query string - - 0 + - 47705.68 + * - max + - number + - The maximum value of the set of sensor values selected in the query string + - 47705.68 * - average - number - The average value of the set of sensor values selected in the query string - - 27 + - 47705.68 + * - timestamp + - timestamp + - The time the reading represents + - 2023-04-14 12:04:34 **Example** .. code-block:: console - $ curl -sX GET http://localhost:8081/fledge/asset/fogbench%2Fhumidity/temperature/series - [ { "timestamp": "2018-04-19 14:20:59", "max": 20, "min": 20, "average": 20 }, - { "timestamp": "2018-04-19 14:20:54", "max": 33, "min": 33, "average": 33 }, - { "timestamp": "2018-04-19 14:20:49", "max": 35, "min": 35, "average": 35 }, - { "timestamp": "2018-04-19 14:20:47", "max": 0, "min": 0, "average": 0 }, - { "timestamp": "2018-04-19 14:20:42", "max": 37, "min": 37, "average": 37 }, - { "timestamp": "2018-04-19 14:20:37", "max": 47, "min": 47, "average": 47 }, - { "timestamp": "2018-04-19 14:20:32", "max": 26, "min": 26, "average": 26 }, - { "timestamp": "2018-04-19 14:06:05", "max": 46, "min": 5, "average": 27.8 }, - { "timestamp": "2018-04-19 13:45:15", "max": 10, "min": 10, "average": 10 } ] + $ curl -sX GET http://localhost:8081/fledge/asset/asset_2/lux/series + [ + {"min": 47705.68, "max": 47705.68, "average": 47705.68, "timestamp": "2023-04-14 12:04:34"}, + {"min": 97967.9, "max": 97967.9, "average": 97967.9, "timestamp": "2023-04-14 12:02:39"}, + {"min": 28788.154, "max": 28788.154, "average": 28788.154, "timestamp": "2023-04-14 12:02:26"}, + {"min": 57992.674, "max": 57992.674, "average": 57992.674, "timestamp": "2023-04-14 12:02:11"}, + {"min": 10373.945, "max": 10373.945, "average": 10373.945, "timestamp": "2023-04-14 12:01:56"} + ] $ - $ curl -sX GET http://localhost:8081/fledge/asset/fogbench%2Fhumidity/temperature/series?limit=5 - [ { "timestamp": "2018-04-19 14:20:59", "max": 20, "min": 20, "average": 20 }, - { "timestamp": "2018-04-19 14:20:54", "max": 33, "min": 33, "average": 33 }, - { "timestamp": "2018-04-19 14:20:49", "max": 35, "min": 35, "average": 35 }, - { "timestamp": "2018-04-19 14:20:47", "max": 0, "min": 0, "average": 0 }, - { "timestamp": "2018-04-19 14:20:42", "max": 37, "min": 37, "average": 37 } ] + $ curl -sX GET http://localhost:8081/fledge/asset/asset_2/lux/series?limit=3 + [ + {"min": 47705.68, "max": 47705.68, "average": 47705.68, "timestamp": "2023-04-14 12:04:34"}, + {"min": 97967.9, "max": 97967.9, "average": 97967.9, "timestamp": "2023-04-14 12:02:39"}, + {"min": 28788.154, "max": 28788.154, "average": 28788.154, "timestamp": "2023-04-14 12:02:26"} + ] Using *seconds* and *previous* to obtain historical data. .. code-block:: console - $ curl http://localhost:8081/fledge/asset/fogbench%2Fhumidity/temperature/series?seconds=5\&previous=60|jq + $ curl -sX GET http://localhost:8081/fledge/asset/asset_2/lux/series?seconds=5\&previous=60 [ - { "timestamp": "2022-11-09 09:37:51.930688", "max": 20, "min": 20, "average": 20 }, - { "timestamp": "2022-11-09 09:37:50.930887", "max": 33, "min": 33, "average": 33 }, - { "timestamp": "2022-11-09 09:37:49.933698", "max": 0, "min": 0, "average": 0 }, - { "timestamp": "2022-11-09 09:37:48.930644", "max": 5, "min": 1, "average": 4 }, - { "timestamp": "2022-11-09 09:37:47.930950", "max": 0, "min": 37, "average": 37 } + {"min": 47705.68, "max": 47705.68, "average": 47705.68, "timestamp": "2023-04-14 12:04:34"} ] - $ + $ The above call returned 5 seconds of data from the current time minus 65 seconds to the current time minus 5 seconds. From c0e84f8432818dacc2fa4910ac849a70fc5a645e Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Fri, 14 Apr 2023 17:52:37 +0530 Subject: [PATCH 254/499] FOGL-7643 Errors in reset of test_e2e_fledge_pair.py (#1037) * Updated test_e2e_fledge_pair to parse json properly Signed-off-by: Mohit Singh Tomar * updated ssh command Signed-off-by: Mohit Singh Tomar --------- Signed-off-by: Mohit Singh Tomar --- tests/system/python/pair/test_e2e_fledge_pair.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/system/python/pair/test_e2e_fledge_pair.py b/tests/system/python/pair/test_e2e_fledge_pair.py index f8726aafdd..4d9e2cf41e 100644 --- a/tests/system/python/pair/test_e2e_fledge_pair.py +++ b/tests/system/python/pair/test_e2e_fledge_pair.py @@ -81,7 +81,13 @@ def reset_and_start_fledge_remote(self, storage_plugin, remote_user, remote_ip, remote_ip, remote_fledge_path)], shell=True, check=True) storage_plugin_val = "postgres" if storage_plugin == 'postgres' else "sqlite" - subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} echo $(jq -c --arg STORAGE_PLUGIN_VAL {} '.plugin.value=$STORAGE_PLUGIN_VAL' {}/data/etc/storage.json) > {}/data/etc/storage.json".format(key_path, remote_user, remote_ip, storage_plugin_val, remote_fledge_path, remote_fledge_path)], shell=True, check=True) + # Check whether storage.json file exist on remote machine or not, if it doesn't exist then raise assertion otherwise update its storage plugin value. + ssh = subprocess.Popen(["ssh", "-o", "UserKnownHostsFile=/dev/null", "-o", "StrictHostKeyChecking=no", "-i", "{}".format(key_path), "{}@{}".format(remote_user, remote_ip), "cat {}/data/etc/storage.json".format(remote_fledge_path)], shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + result = ssh.stdout.readlines() + assert [] != result, "storage.json file not found on the remote machine {}".format(remote_ip) + data = json.loads(result[0]) + data['plugin']['value'] = storage_plugin_val + ssh = subprocess.Popen(["ssh", "-o", "UserKnownHostsFile=/dev/null", "-o", "StrictHostKeyChecking=no", "-i", "{}".format(key_path), "{}@{}".format(remote_user, remote_ip), "echo '" + json.dumps(data) + "' > {}/data/etc/storage.json".format(remote_fledge_path) ], shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE) subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};echo \"YES\nYES\" | $FLEDGE_ROOT/scripts/fledge reset'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, check=True) subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={};$FLEDGE_ROOT/scripts/fledge start'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True) stat = subprocess.run(["ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i {} {}@{} 'export FLEDGE_ROOT={}; $FLEDGE_ROOT/scripts/fledge status'".format(key_path, remote_user, remote_ip, remote_fledge_path)], shell=True, stdout=subprocess.PIPE) From a8ab4c6dc87244638e83e3c2b5a6e051ab21b2e9 Mon Sep 17 00:00:00 2001 From: pintomax Date: Fri, 14 Apr 2023 15:53:04 +0200 Subject: [PATCH 255/499] FOGL-7670: call refresh token two minutes before expiration (#1042) FOGL-7670: call refresh token two minutes before expiration --- C/services/common/service_security.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/C/services/common/service_security.cpp b/C/services/common/service_security.cpp index 7836695e36..ad52f03be5 100644 --- a/C/services/common/service_security.cpp +++ b/C/services/common/service_security.cpp @@ -12,6 +12,8 @@ #define TO_STRING_(...) #__VA_ARGS__ #define QUOTE(...) TO_STRING(__VA_ARGS__) +#define DELTA_SECONDS_BEFORE_TOKEN_EXPIRATION 120 + using namespace std; using HttpServer = SimpleWeb::Server; @@ -593,7 +595,7 @@ void ServiceAuthHandler::refreshBearerToken() // While server is running get bearer token // and sleeps for a few secods. - // When expires_in - 10 seconds is done + // When expires_in - DELTA_SECONDS_BEFORE_TOKEN_EXPIRATION seconds is done // then get new token and sleep again while (this->isRunning()) { @@ -644,7 +646,7 @@ void ServiceAuthHandler::refreshBearerToken() current_token = bToken.token(); // Token exists and it is valid, get expiration time - expires_in = bToken.getExpiration() - time(NULL) - 10; + expires_in = bToken.getExpiration() - time(NULL) - DELTA_SECONDS_BEFORE_TOKEN_EXPIRATION; Logger::getLogger()->debug("Bearer token refresh will be called in " "%ld seconds, service '%s'", From c5bc48c0a862c37db2094523f992e93955762980 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 17 Apr 2023 18:09:43 +0530 Subject: [PATCH 256/499] validation added for source and destination lookup names & other minor fixes Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 67 ++++++++++++++++--- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index ca9dc5ebcf..2e4811125f 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -12,7 +12,7 @@ from fledge.common.configuration_manager import ConfigurationManager from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError -from fledge.services.core import connect +from fledge.services.core import connect, server __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2023 Dianomic Systems Inc." @@ -86,16 +86,22 @@ async def create(request: web.Request) -> web.Response: data = await request.json() # Create entry in control_pipelines table column_names = await _check_parameters(data) + source_type = column_names.get("stype") + if source_type is None: + column_names['stype'] = 0 + column_names['sname'] = '' + des_type = column_names.get("dtype") + if des_type is None: + column_names['dtype'] = 0 + column_names['dname'] = '' payload = PayloadBuilder().INSERT(**column_names).payload() storage = connect.get_storage_async() insert_result = await storage.insert_into_tbl("control_pipelines", payload) pipeline_name = column_names['name'] pipeline_filter = data.get('filters', None) - source_type = column_names.get("stype") - source = {'type': column_names["stype"], 'name': column_names["sname"]} if source_type else None - des_type = column_names.get("dtype") - destination = {'type': column_names["dtype"], 'name': column_names["dname"]} if des_type else None if insert_result['response'] == "inserted" and insert_result['rows_affected'] == 1: + source = {'type': column_names["stype"], 'name': column_names["sname"]} + destination = {'type': column_names["dtype"], 'name': column_names["dname"]} final_result = await _pipeline_in_use(pipeline_name, source, destination, info=True) final_result['source'] = {"type": await _get_lookup_value('source', final_result["stype"]), "name": final_result['sname']} @@ -125,7 +131,7 @@ async def create(request: web.Request) -> web.Response: raise web.HTTPBadRequest(body=json.dumps({"message": msg}), reason=msg) except Exception as ex: msg = str(ex) - _logger.error("Failed to create pipeline: {}. {}".format(pipeline_name, msg)) + _logger.error("Failed to create pipeline: {}. {}".format(data.get('name'), msg)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(final_result) @@ -396,8 +402,7 @@ async def _check_parameters(payload): source_name = source_name.strip() if len(source_name) == 0: raise ValueError('Source name cannot be empty.') - # source['name'] = source_name - # TODO: source name validation from API + await _validate_lookup_name("source", source_type, source_name) column_names["stype"] = source_type column_names["sname"] = source_name else: @@ -429,8 +434,7 @@ async def _check_parameters(payload): des_name = des_name.strip() if len(des_name) == 0: raise ValueError('Destination name cannot be empty.') - # TODO: destination name validation from API - # destination['name'] = des_name + await _validate_lookup_name("destination", des_type, des_name) column_names["dtype"] = des_type column_names["dname"] = des_name else: @@ -456,6 +460,49 @@ async def _check_parameters(payload): return column_names +async def _validate_lookup_name(lookup_name, _type, value): + storage = connect.get_storage_async() + config_mgr = ConfigurationManager(storage) + + async def get_schedules(): + schedules = await server.Server.scheduler.get_schedules() + if not any(sch.name == value for sch in schedules): + raise ValueError("'{}' not a valid service or schedule name.".format(value)) + + async def get_control_scripts(): + script_payload = PayloadBuilder().SELECT("name").payload() + scripts = await storage.query_tbl_with_payload('control_script', script_payload) + if not any(s['name'] == value for s in scripts['rows']): + raise ValueError("'{}' not a valid script name.".format(value)) + + async def get_assets(): + asset_payload = PayloadBuilder().DISTINCT(["asset"]).payload() + assets = await storage.query_tbl_with_payload('asset_tracker', asset_payload) + if not any(ac['asset'] == value for ac in assets['rows']): + raise ValueError("'{}' not a valid asset name.".format(value)) + + async def get_notifications(): + all_notifications = await config_mgr._read_all_child_category_names("Notifications") + if not any(notify['child'] == value for notify in all_notifications): + raise ValueError("'{}' not a valid notification instance name.".format(value)) + + if (lookup_name == "source" and _type in [2, 5]) or (lookup_name == 'destination' and _type == 1): + # Verify schedule name + await get_schedules() + elif (lookup_name == "source" and _type == 6) or (lookup_name == 'destination' and _type == 3): + # Verify control script name + await get_control_scripts() + elif lookup_name == "source" and _type == 4: + # Verify notification instance name + await get_notifications() + elif lookup_name == "destination" and _type == 2: + # Verify asset name + await get_assets() + else: + """No validation required for source id 1(Any), 3(API) & destination id 4(Broadcast)""" + pass + + async def _remove_filters(storage, filters, cp_id): cf_mgr = ConfigurationManager(storage) if filters: From 5f9fd837777d98cd6374c9568d4745858597e93b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 14 Apr 2023 18:38:17 +0530 Subject: [PATCH 257/499] override logging error in python logger class Signed-off-by: ashish-jabble --- python/fledge/common/logger.py | 56 +++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/python/fledge/common/logger.py b/python/fledge/common/logger.py index 6939f36ed6..668c6f7752 100644 --- a/python/fledge/common/logger.py +++ b/python/fledge/common/logger.py @@ -8,7 +8,11 @@ import os import subprocess import logging +import inspect +import traceback from logging.handlers import SysLogHandler +from functools import wraps + __author__ = "Praveen Garg, Ashish Jabble" __copyright__ = "Copyright (c) 2017-2023 OSIsoft, LLC" @@ -23,10 +27,10 @@ """ CONSOLE = 1 """Send log entries to STDERR""" - - -FLEDGE_LOGS_DESTINATION = 'FLEDGE_LOGS_DESTINATION' # env variable -default_destination = SYSLOG # default for fledge +FLEDGE_LOGS_DESTINATION = 'FLEDGE_LOGS_DESTINATION' +"""Log destination environment variable""" +default_destination = SYSLOG +"""Default destination of logger""" def set_default_destination(destination: int): @@ -105,6 +109,9 @@ def setup(logger_name: str = None, else: raise ValueError("Invalid destination {}".format(destination)) + # save the old logging.error function + __logging_error = logger.error + # TODO: Consider using %r with message when using syslog .. \n looks better than # fmt = '{}[%(process)d] %(levelname)s: %(module)s: %(name)s: %(message)s'.format(get_process_name()) formatter = logging.Formatter(fmt=fmt) @@ -113,6 +120,20 @@ def setup(logger_name: str = None, logger.setLevel(level) logger.addHandler(handler) logger.propagate = propagate + + @wraps(logger.error) + def error(msg, *args, **kwargs): + if isinstance(msg, Exception): + trace_msg = traceback.format_exception(msg.__class__, msg, msg.__traceback__) + if args: + trace_msg[:0] = ["{}\n".format(args[0])] + [__logging_error(line.strip('\n')) for line in trace_msg] + else: + [__logging_error(m) for m in msg.splitlines()] + + # overwrite the default logging.error + logger.error = error + return logger @@ -181,10 +202,37 @@ def get_logger(self, logger_name: str): logger: returns logger for module """ _logger = logging.getLogger(logger_name) + + # save the old logging.error function + __logging_error = _logger.error + console_handler = self.get_console_handler() syslog_handler = self.get_syslog_handler() self.add_handlers(_logger, [syslog_handler, console_handler]) _logger.propagate = False + + @wraps(_logger.error) + def error(msg, *args, **kwargs): + """Case: When we pass exception in error and having different args + For example: + a) _logger.error(ex) + b) _logger.error(ex, "Failed to add data.") + """ + if isinstance(msg, Exception): + trace_msg = traceback.format_exception(msg.__class__, msg, msg.__traceback__) + if args: + trace_msg[:0] = ["{}\n".format(args[0])] + [__logging_error(line.strip('\n')) for line in trace_msg] + else: + """Case: When we pass string in error + For example: + a) _logger.error(str(ex)) + b) _logger.error("Failed to add data for key: {} along with error:{}".format("Test", str(ex))) + """ + [__logging_error(m) for m in msg.splitlines()] + + # overwrite the default logging.error + _logger.error = error return _logger def set_level(self, level_name: str): From 9497745e47a5797fd6ea0333735f0f0e0c9486ac Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Mon, 17 Apr 2023 15:53:36 -0400 Subject: [PATCH 258/499] Adjust product version check for changes in EDS 2023 Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/plugin.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 02679663b6..189f993b32 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -1580,7 +1580,8 @@ static void ParseProductVersion(std::string &versionString, int *major, int *min } /** - * Parses the Edge Data Store version string from the /productinformation REST response + * Parses the Edge Data Store version string from the /productinformation REST response. + * Note that the response format differs between EDS 2020 and EDS 2023. * * @param json REST response from /api/v1/diagnostics/productinformation * @return version Edge Data Store version string @@ -1595,11 +1596,16 @@ static std::string ParseEDSProductInformation(std::string json) { try { - if (doc.HasMember("Edge Data Store")) + if (doc.HasMember("Edge Data Store")) // EDS 2020 response { const rapidjson::Value &EDS = doc["Edge Data Store"]; version = EDS.GetString(); } + else if (doc.HasMember("Product Version")) // EDS 2023 response + { + const rapidjson::Value &EDS = doc["Product Version"]; + version = EDS.GetString(); + } } catch (...) { From de80fd3f6e35315fb28f0d82b97d1d46fba25f7c Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 18 Apr 2023 11:50:10 +0100 Subject: [PATCH 259/499] FOGL-7057 Fix resources leaks and add overflow table (#1045) * FOGL-7057 add some configuration limits Signed-off-by: Mark Riddoch * Initial set of changes Signed-off-by: Mark Riddoch * Checkpoint Signed-off-by: Mark Riddoch * Don;t keepo bad connections in the connection pool Signed-off-by: Mark Riddoch * Add connection usage diagnostics Signed-off-by: Mark Riddoch * SQLite IN does not work with strings Signed-off-by: Mark Riddoch * Release connections allocated from the pool in case of error and in code that looks for empty database tables Signed-off-by: Mark Riddoch * More leak fixes Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- .../storage/sqlite/common/connection.cpp | 49 +- .../sqlite/common/connection_manager.cpp | 234 ++-- .../sqlite/common/include/connection.h | 10 + .../common/include/connection_manager.h | 19 +- .../common/include/readings_catalogue.h | 36 +- C/plugins/storage/sqlite/common/readings.cpp | 427 ++++---- .../sqlite/common/readings_catalogue.cpp | 995 ++++++++++++------ C/plugins/storage/sqlite/plugin.cpp | 80 +- C/services/storage/configuration.cpp | 8 + C/services/storage/include/configuration.h | 1 + C/services/storage/include/storage_api.h | 1 + C/services/storage/storage.cpp | 2 + C/services/storage/storage_api.cpp | 13 + Makefile | 2 +- scripts/services/storage | 24 +- 15 files changed, 1268 insertions(+), 633 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index ef846c0328..83f798c183 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -482,7 +482,7 @@ Connection::Connection() const char* dbErrMsg = sqlite3_errmsg(dbHandle); const char* errMsg = "Failed to open the SQLite3 database"; - Logger::getLogger()->error("%s '%s': %s", + logger->error("%s '%s': %s", dbErrMsg, dbPath.c_str(), dbErrMsg); @@ -506,7 +506,7 @@ Connection::Connection() if (rc != SQLITE_OK) { string errMsg = "Failed to set WAL from the fledge DB - " DB_CONFIGURATION; - Logger::getLogger()->error("%s : error %s", + logger->error("%s : error %s", DB_CONFIGURATION, zErrMsg); connectErrorTime = time(0); @@ -535,7 +535,7 @@ Connection::Connection() if (rc != SQLITE_OK) { const char* errMsg = "Failed to attach 'fledge' database in"; - Logger::getLogger()->error("%s '%s': error %s", + logger->error("%s '%s': error %s", errMsg, sqlStmt, zErrMsg); @@ -546,7 +546,7 @@ Connection::Connection() } else { - Logger::getLogger()->info("Connected to SQLite3 database: %s", + logger->info("Connected to SQLite3 database: %s", dbPath.c_str()); } //Release sqlStmt buffer @@ -555,7 +555,7 @@ Connection::Connection() // Attach readings database - readings_1 if (access(dbPathReadings.c_str(), R_OK) != 0) { - Logger::getLogger()->info("No readings database, assuming seperate readings plugin is avialable"); + logger->info("No readings database, assuming seperate readings plugin is avialable"); m_noReadings = true; } else @@ -578,7 +578,7 @@ Connection::Connection() if (rc != SQLITE_OK) { const char* errMsg = "Failed to attach 'readings' database in"; - Logger::getLogger()->error("%s '%s': error %s", + logger->error("%s '%s': error %s", errMsg, sqlReadingsStmt, zErrMsg); @@ -589,7 +589,7 @@ Connection::Connection() } else { - Logger::getLogger()->info("Connected to SQLite3 database: %s", + logger->info("Connected to SQLite3 database: %s", dbPath.c_str()); } //Release sqlStmt buffer @@ -607,6 +607,9 @@ Connection::Connection() sqlite3_free(zErrMsg); } + + ReadingsCatalogue *catalogue = ReadingsCatalogue::getInstance(); + catalogue->createReadingsOverflowTable(dbHandle, 1); } } @@ -617,13 +620,22 @@ Connection::Connection() ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); if ( !readCat->connectionAttachAllDbs(dbHandle) ) { - const char* errMsg = "Failed to attach all the dbs to the connection :%X:'readings' database in"; - Logger::getLogger()->error("%s '%s': error %s", errMsg, dbHandle); + const char* errMsg = "Failed to attach all the databases to the connection database in"; + logger->error(errMsg); connectErrorTime = time(0); sqlite3_close_v2(dbHandle); + throw new runtime_error(errMsg); + } + else + { + logger->info("Attached all %d readings databases to connection", readCat->getReadingsCount()); } } + else + { + logger->info("Connection will not attach to readings tables"); + } m_schemaManager = SchemaManager::getInstance(); } @@ -1346,10 +1358,10 @@ std::size_t arr = data.find("inserts"); if (sqlite3_exec(dbHandle, "BEGIN TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) { - sqlite3_clear_bindings(stmt); - sqlite3_reset(stmt); if (stmt) { + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); sqlite3_finalize(stmt); } raiseError("insert", sqlite3_errmsg(dbHandle)); @@ -1362,20 +1374,12 @@ std::size_t arr = data.find("inserts"); m_writeAccessOngoing.fetch_sub(1); - if (sqlite3_resut == SQLITE_DONE) - { - sqlite3_clear_bindings(stmt); - sqlite3_reset(stmt); - } - else + if (sqlite3_resut != SQLITE_DONE) { failedInsertCount++; raiseError("insert", sqlite3_errmsg(dbHandle)); Logger::getLogger()->error("SQL statement: %s", sqlite3_expanded_sql(stmt)); - sqlite3_clear_bindings(stmt); - sqlite3_reset(stmt); - // transaction is still open, do rollback if (sqlite3_get_autocommit(dbHandle) == 0) { @@ -1387,6 +1391,8 @@ std::size_t arr = data.find("inserts"); } } + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); if (sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) @@ -1398,14 +1404,13 @@ std::size_t arr = data.find("inserts"); raiseError("insert", sqlite3_errmsg(dbHandle)); return -1; } - + sqlite3_finalize(stmt); } // Increment row count ins++; } - sqlite3_finalize(stmt); if (m_writeAccessOngoing == 0) db_cv.notify_all(); diff --git a/C/plugins/storage/sqlite/common/connection_manager.cpp b/C/plugins/storage/sqlite/common/connection_manager.cpp index 3acc222dcf..d841322ac5 100644 --- a/C/plugins/storage/sqlite/common/connection_manager.cpp +++ b/C/plugins/storage/sqlite/common/connection_manager.cpp @@ -9,9 +9,12 @@ */ #include #include +#include +#include #include #include +#include #include ConnectionManager *ConnectionManager::instance = 0; @@ -28,7 +31,9 @@ static void managerBackground(void *arg) /** * Default constructor for the connection manager. */ -ConnectionManager::ConnectionManager() : m_shutdown(false), m_vacuumInterval(6 * 60 * 60) +ConnectionManager::ConnectionManager() : m_shutdown(false), + m_vacuumInterval(6 * 60 * 60), + m_attachedDatabases(0) { lastError.message = NULL; lastError.entryPoint = NULL; @@ -37,6 +42,11 @@ ConnectionManager::ConnectionManager() : m_shutdown(false), m_vacuumInterval(6 * else m_trace = false; m_background = new std::thread(managerBackground, this); + + struct rlimit lim; + getrlimit(RLIMIT_NOFILE, &lim); + m_descriptorLimit = lim.rlim_cur; + } @@ -73,14 +83,39 @@ ConnectionManager *ConnectionManager::getInstance() */ void ConnectionManager::growPool(unsigned int delta) { + int poolSize = idle.size() + inUse.size(); + + if ((delta + poolSize) * m_attachedDatabases * NO_DESCRIPTORS_PER_DB + > (DESCRIPTOR_THRESHOLD * m_descriptorLimit) / 100) + { + Logger::getLogger()->warn("Request to grow database connection pool rejected" + " due to excessive file descriptor usage"); + return; + } + int failures = 0; while (delta-- > 0) { - Connection *conn = new Connection(); - if (m_trace) - conn->setTrace(true); + try { + Connection *conn = new Connection(); + if (m_trace) + conn->setTrace(true); + idleLock.lock(); + idle.push_back(conn); + idleLock.unlock(); + } catch (...) { + failures++; + } + } + if (failures > 0) + { idleLock.lock(); - idle.push_back(conn); + int idleCount = idle.size(); idleLock.unlock(); + inUseLock.lock(); + int inUseCount = inUse.size(); + inUseLock.unlock(); + Logger::getLogger()->warn("Connection pool growth restricted due to failure to create %d connections, %d idle connections & %d connection in use currently", failures, idleCount, inUseCount); + noConnectionsDiagnostic(); } } @@ -125,7 +160,13 @@ Connection *conn = 0; idleLock.lock(); if (idle.empty()) { - conn = new Connection(); + try { + conn = new Connection(); + } catch (...) { + conn = NULL; + Logger::getLogger()->error("Failed to create database connection to allocate"); + noConnectionsDiagnostic(); + } } else { @@ -156,6 +197,14 @@ bool ConnectionManager::attachNewDb(std::string &path, std::string &alias) bool result; char *zErrMsg = NULL; + int poolSize = idle.size() + inUse.size(); + if (poolSize * m_attachedDatabases * NO_DESCRIPTORS_PER_DB + > (DESCRIPTOR_THRESHOLD * m_descriptorLimit) / 100) + { + Logger::getLogger()->warn("Request to attach new database rejected" + " due to excessive file descriptor usage"); + return false; + } result = true; sqlCmd = "ATTACH DATABASE '" + path + "' AS " + alias + ";"; @@ -164,48 +213,49 @@ bool ConnectionManager::attachNewDb(std::string &path, std::string &alias) inUseLock.lock(); // attach the DB to all idle connections + for (auto conn : idle) { + dbHandle = conn->getDbHandle(); + rc = SQLExec (dbHandle, sqlCmd.c_str(), &zErrMsg); + if (rc != SQLITE_OK) + { + Logger::getLogger()->error("attachNewDb - It was not possible to attach the db :%s: to an idle connection, error :%s:", path.c_str(), zErrMsg); + sqlite3_free(zErrMsg); + result = false; + // TODO We are potentially left in an inconsistant state with the new database + // attached to some connections but not all. + break; + } + - for ( auto conn : idle) { + Logger::getLogger()->debug("attachNewDb idle dbHandle :%X: sqlCmd :%s: ", dbHandle, sqlCmd.c_str()); + } + + if (result) + { + // attach the DB to all inUse connections + for (auto conn : inUse) + { dbHandle = conn->getDbHandle(); rc = SQLExec (dbHandle, sqlCmd.c_str(), &zErrMsg); if (rc != SQLITE_OK) { - Logger::getLogger()->error("attachNewDb - It was not possible to attach the db :%s: to an idle connection, error :%s:", path.c_str(), zErrMsg); + Logger::getLogger()->error("attachNewDb - It was not possible to attach the db :%s: to an inUse connection, error :%s:", path.c_str() ,zErrMsg); sqlite3_free(zErrMsg); result = false; + // TODO We are potentially left in an inconsistant state with the new + // database attached to some connections but not all. break; } - Logger::getLogger()->debug("attachNewDb idle dbHandle :%X: sqlCmd :%s: ", dbHandle, sqlCmd.c_str()); - + Logger::getLogger()->debug("attachNewDb inUse dbHandle :%X: sqlCmd :%s: ", dbHandle, sqlCmd.c_str()); } } + m_attachedDatabases++; - if (result) - { - // attach the DB to all inUse connections - { - - for ( auto conn : inUse) { - - dbHandle = conn->getDbHandle(); - rc = SQLExec (dbHandle, sqlCmd.c_str(), &zErrMsg); - if (rc != SQLITE_OK) - { - Logger::getLogger()->error("attachNewDb - It was not possible to attach the db :%s: to an inUse connection, error :%s:", path.c_str() ,zErrMsg); - sqlite3_free(zErrMsg); - result = false; - break; - } - - Logger::getLogger()->debug("attachNewDb inUse dbHandle :%X: sqlCmd :%s: ", dbHandle, sqlCmd.c_str()); - } - } - } - idleLock.unlock(); inUseLock.unlock(); + idleLock.unlock(); return (result); } @@ -231,44 +281,41 @@ bool ConnectionManager::detachNewDb(std::string &alias) inUseLock.lock(); // attach the DB to all idle connections + for (auto conn : idle) { - for ( auto conn : idle) { + dbHandle = conn->getDbHandle(); + rc = SQLExec (dbHandle, sqlCmd.c_str(), &zErrMsg); + if (rc != SQLITE_OK) + { + Logger::getLogger()->error("detachNewDb - It was not possible to detach the db :%s: from an idle connection, error :%s:", alias.c_str(), zErrMsg); + sqlite3_free(zErrMsg); + result = false; + break; + } + Logger::getLogger()->debug("detachNewDb - idle dbHandle :%X: sqlCmd :%s: ", dbHandle, sqlCmd.c_str()); + } + if (result) + { + // attach the DB to all inUse connections + for (auto conn : inUse) + { dbHandle = conn->getDbHandle(); rc = SQLExec (dbHandle, sqlCmd.c_str(), &zErrMsg); if (rc != SQLITE_OK) { - Logger::getLogger()->error("detachNewDb - It was not possible to detach the db :%s: from an idle connection, error :%s:", alias.c_str(), zErrMsg); + Logger::getLogger()->error("detachNewDb - It was not possible to detach the db :%s: from an inUse connection, error :%s:", alias.c_str() ,zErrMsg); sqlite3_free(zErrMsg); result = false; break; } - Logger::getLogger()->debug("detachNewDb - idle dbHandle :%X: sqlCmd :%s: ", dbHandle, sqlCmd.c_str()); + Logger::getLogger()->debug("detachNewDb - inUse dbHandle :%X: sqlCmd :%s: ", dbHandle, sqlCmd.c_str()); } } + m_attachedDatabases--; - if (result) - { - // attach the DB to all inUse connections - { - - for ( auto conn : inUse) { - - dbHandle = conn->getDbHandle(); - rc = SQLExec (dbHandle, sqlCmd.c_str(), &zErrMsg); - if (rc != SQLITE_OK) - { - Logger::getLogger()->error("detachNewDb - It was not possible to detach the db :%s: from an inUse connection, error :%s:", alias.c_str() ,zErrMsg); - sqlite3_free(zErrMsg); - result = false; - break; - } - Logger::getLogger()->debug("detachNewDb - inUse dbHandle :%X: sqlCmd :%s: ", dbHandle, sqlCmd.c_str()); - } - } - } - idleLock.unlock(); inUseLock.unlock(); + idleLock.unlock(); return (result); } @@ -289,28 +336,33 @@ bool ConnectionManager::attachRequestNewDb(int newDbId, sqlite3 *dbHandle) bool result; char *zErrMsg = NULL; + int poolSize = idle.size() + inUse.size(); + if (poolSize * m_attachedDatabases * NO_DESCRIPTORS_PER_DB + > (DESCRIPTOR_THRESHOLD * m_descriptorLimit) / 100) + { + Logger::getLogger()->warn("Request to attach nwe database rejected" + " due to excessive file descriptor usage"); + return false; + } result = true; idleLock.lock(); inUseLock.lock(); // attach the DB to all idle connections + for (auto conn : idle) { + if (dbHandle == conn->getDbHandle()) + { + Logger::getLogger()->debug("attachRequestNewDb - idle skipped dbHandle :%X: sqlCmd :%s: ", conn->getDbHandle(), sqlCmd.c_str()); - for ( auto conn : idle) { - - if (dbHandle == conn->getDbHandle()) - { - Logger::getLogger()->debug("attachRequestNewDb - idle skipped dbHandle :%X: sqlCmd :%s: ", conn->getDbHandle(), sqlCmd.c_str()); - - } else - { - conn->setUsedDbId(newDbId); - - Logger::getLogger()->debug("attachRequestNewDb - idle, dbHandle :%X: sqlCmd :%s: ", conn->getDbHandle(), sqlCmd.c_str()); - } + } else + { + conn->setUsedDbId(newDbId); + Logger::getLogger()->debug("attachRequestNewDb - idle, dbHandle :%X: sqlCmd :%s: ", conn->getDbHandle(), sqlCmd.c_str()); } + } if (result) @@ -318,7 +370,7 @@ bool ConnectionManager::attachRequestNewDb(int newDbId, sqlite3 *dbHandle) // attach the DB to all inUse connections { - for ( auto conn : inUse) { + for (auto conn : inUse) { if (dbHandle == conn->getDbHandle()) { @@ -332,8 +384,10 @@ bool ConnectionManager::attachRequestNewDb(int newDbId, sqlite3 *dbHandle) } } } - idleLock.unlock(); + m_attachedDatabases++; + inUseLock.unlock(); + idleLock.unlock(); return (result); } @@ -347,6 +401,9 @@ bool ConnectionManager::attachRequestNewDb(int newDbId, sqlite3 *dbHandle) */ void ConnectionManager::release(Connection *conn) { +#if TRACK_CONNECTION_USER + conn->clearUsage(); +#endif inUseLock.lock(); inUse.remove(conn); inUseLock.unlock(); @@ -451,3 +508,40 @@ void ConnectionManager::background() } } } + +/** + * Determine if we can allow another database to be created and attached to all the + * connections. + * + * @return True if we can create anotehr database. + */ +bool ConnectionManager::allowMoreDatabases() +{ + // Allow for a couple of user defined schemas as well as the fledge database + if (m_attachedDatabases + 4 > ReadingsCatalogue::getInstance()->getMaxAttached()) + { + return false; + } + int poolSize = idle.size() + inUse.size(); + if (poolSize * (m_attachedDatabases + 1) * NO_DESCRIPTORS_PER_DB + > (DESCRIPTOR_THRESHOLD * m_descriptorLimit) / 100) + { + return false; + } + return true; +} + +void ConnectionManager::noConnectionsDiagnostic() +{ +#if TRACK_CONNECTION_USER + Logger *logger = Logger::getLogger(); + + inUseLock.lock(); + logger->warn("There are %d connections in use currently", inUse.size()); + for (auto conn : inUse) + { + logger->warn(" Connection is use by %s", conn->getUsage().c_str()); + } + inUseLock.unlock(); +#endif +} diff --git a/C/plugins/storage/sqlite/common/include/connection.h b/C/plugins/storage/sqlite/common/include/connection.h index 85308ee9a9..35d1ebac1c 100644 --- a/C/plugins/storage/sqlite/common/include/connection.h +++ b/C/plugins/storage/sqlite/common/include/connection.h @@ -21,6 +21,8 @@ #include #include +#define TRACK_CONNECTION_USER 0 // Set to 1 to get dianositcs about connection pool use + #define _DB_NAME "/fledge.db" #define READINGS_DB_NAME_BASE "readings" #define READINGS_DB_FILE_NAME "/" READINGS_DB_NAME_BASE "_1.db" @@ -161,6 +163,11 @@ class Connection { unsigned int purgeReadingsAsset(const std::string& asset); bool vacuum(); bool supportsReadings() { return ! m_noReadings; }; +#if TRACK_CONNECTION_USER + void setUsage(std::string usage) { m_usage = usage; }; + void clearUsage() { m_usage = ""; }; + std::string getUsage() { return m_usage; }; +#endif private: @@ -215,6 +222,9 @@ class Connection { bool appendTables(const std::string& schema, const rapidjson::Value& document, SQLBuffer& sql, int level); bool processJoinQueryWhereClause(const rapidjson::Value& query, SQLBuffer& sql, std::vector &asset_codes, int level); bool m_noReadings; +#if TRACK_CONNECTION_USER + std::string m_usage; +#endif }; #endif diff --git a/C/plugins/storage/sqlite/common/include/connection_manager.h b/C/plugins/storage/sqlite/common/include/connection_manager.h index 2d33cc81fe..7a448ab8ca 100644 --- a/C/plugins/storage/sqlite/common/include/connection_manager.h +++ b/C/plugins/storage/sqlite/common/include/connection_manager.h @@ -17,6 +17,9 @@ #include #include +#define NO_DESCRIPTORS_PER_DB 3 // 3 deascriptors per database when using WAL mode +#define DESCRIPTOR_THRESHOLD 75 // Percentage of descriptors that can be used on database connections + class Connection; /** @@ -30,7 +33,7 @@ class ConnectionManager { Connection *allocate(); bool attachNewDb(std::string &path, std::string &alias); bool attachRequestNewDb(int newDbId, sqlite3 *dbHandle); - bool detachNewDb(std::string &alias); + bool detachNewDb(std::string &alias); void release(Connection *); void shutdown(); void setError(const char *, const char *, bool); @@ -39,16 +42,20 @@ class ConnectionManager { return &lastError; } void background(); - void setVacuumInterval(long hours) { - m_vacuumInterval = 60 * 60 * hours; - }; + void setVacuumInterval(long hours) + { + m_vacuumInterval = 60 * 60 * hours; + }; + bool allowMoreDatabases(); protected: ConnectionManager(); private: static ConnectionManager *instance; - int SQLExec(sqlite3 *dbHandle, const char *sqlCmd, char **errMsg); + int SQLExec(sqlite3 *dbHandle, const char *sqlCmd, + char **errMsg); + void noConnectionsDiagnostic(); protected: std::list idle; @@ -61,6 +68,8 @@ class ConnectionManager { bool m_shutdown; std::thread *m_background; long m_vacuumInterval; + unsigned int m_descriptorLimit; + unsigned int m_attachedDatabases; }; #endif diff --git a/C/plugins/storage/sqlite/common/include/readings_catalogue.h b/C/plugins/storage/sqlite/common/include/readings_catalogue.h index ecde9053e3..4562bb066f 100644 --- a/C/plugins/storage/sqlite/common/include/readings_catalogue.h +++ b/C/plugins/storage/sqlite/common/include/readings_catalogue.h @@ -13,6 +13,8 @@ #include "connection.h" #include +#define OVERFLOW_TABLE_ID 0 // Table ID to use for the overflow table + /** * This class handles per thread started transaction boundaries: */ @@ -134,21 +136,26 @@ class ReadingsCatalogue { bool loadEmptyAssetReadingCatalogue(bool clean = true); bool latestDbUpdate(sqlite3 *dbHandle, int newDbId); - void preallocateNewDbsRange(int dbIdStart, int dbIdEnd); + int preallocateNewDbsRange(int dbIdStart, int dbIdEnd); + tyReadingReference getEmptyReadingTableReference(std::string& asset); tyReadingReference getReadingReference(Connection *connection, const char *asset_code); bool attachDbsToAllConnections(); std::string sqlConstructMultiDb(std::string &sqlCmdBase, std::vector &assetCodes, bool considerExclusion=false); + std::string sqlConstructOverflow(std::string &sqlCmdBase, std::vector &assetCodes, bool considerExclusion=false, bool groupBy = false); int purgeAllReadings(sqlite3 *dbHandle, const char *sqlCmdBase, char **errMsg = NULL, unsigned long *rowsAffected = NULL); bool connectionAttachAllDbs(sqlite3 *dbHandle); bool connectionAttachDbList(sqlite3 *dbHandle, std::vector &dbIdList); - bool attachDb(sqlite3 *dbHandle, std::string &path, std::string &alias); + bool attachDb(sqlite3 *dbHandle, std::string &path, std::string &alias, int dbId); void detachDb(sqlite3 *dbHandle, std::string &alias); void setUsedDbId(int dbId); int extractReadingsIdFromName(std::string tableName); int extractDbIdFromName(std::string tableName); int SQLExec(sqlite3 *dbHandle, const char *sqlCmd, char **errMsg = NULL); + bool createReadingsOverflowTable(sqlite3 *dbHandle, int dbId); + int getMaxAttached() { return m_attachLimit; }; + private: STORAGE_CONFIGURATION m_storageConfigCurrent; // The current configuration of the multiple readings @@ -176,7 +183,7 @@ class ReadingsCatalogue { } tyReadingsAvailable; - ReadingsCatalogue() { }; + ReadingsCatalogue(); bool createNewDB(sqlite3 *dbHandle, int newDbId, int startId, NEW_DB_OPERATION attachAllDb); int getUsedTablesDbId(int dbId); @@ -199,7 +206,7 @@ class ReadingsCatalogue { void dbsRemove(int startId, int endId); void storeReadingsConfiguration (sqlite3 *dbHandle); ACTION changesLogicDBs(int dbIdCurrent , int dbIdLast, int nDbPreallocateCurrent, int nDbPreallocateRequest, int nDbLeftFreeBeforeAllocate); - ACTION changesLogicTables(int maxUsed ,int Current, int Request); + ACTION changesLogicTables(int maxUsed ,int Current, int Request); int retrieveDbIdFromTableId(int tableId); void configChangeAddDb(sqlite3 *dbHandle); @@ -211,13 +218,16 @@ class ReadingsCatalogue { void dropReadingsTables(sqlite3 *dbHandle, int dbId, int idStart, int idEnd); - int m_dbIdCurrent; // Current database in use - int m_dbIdLast; // Last database available not already in use - int m_dbNAvailable; // Number of databases available - std::vector m_dbIdList; // Databases already created but not in use + int m_dbIdCurrent; // Current database in use + int m_dbIdLast; // Last database available not already in use + int m_dbNAvailable; // Number of databases available + std::vector + m_dbIdList; // Databases already created but not in use - std::atomic m_ReadingsGlobalId; // Global row id shared among all the readings table - int m_nReadingsAvailable = 0; // Number of readings tables available + std::atomic + m_ReadingsGlobalId; // Global row id shared among all the readings table + int + m_nReadingsAvailable = 0; // Number of readings tables available std::map > m_AssetReadingCatalogue={ // In memory structure to identify in which database/table an asset is stored // asset_code - reading Table Id, Db Id @@ -227,7 +237,11 @@ class ReadingsCatalogue { // asset_code - reading Table Id, Db Id // {"", ,{1 ,1 }} }; - std::mutex m_emptyReadingTableMutex; + int m_nextOverflow; // The next database to use for overflow assets + int m_attachLimit; + int m_maxOverflowUsed; + int m_compounds; // Max number of compound statements + std::mutex m_emptyReadingTableMutex; public: TransactionBoundary m_tx; diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index 09a320fd1e..01319c7929 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -501,7 +501,7 @@ int Connection::readingStream(ReadingStream **readings, bool commit) { if (!formatDate(formatted_date, sizeof(formatted_date), user_ts)) { - raiseError("appendReadings", "Invalid date |%s|", user_ts); + raiseError("appendReadings", "Invalid date '%s'", user_ts); add_row = false; } else @@ -539,7 +539,7 @@ int Connection::readingStream(ReadingStream **readings, bool commit) sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); retries++; - Logger::getLogger()->info("SQLITE_LOCKED - record :%d: - retry number :%d: sleep time ms :%d:",i, retries, sleep_time_ms); + Logger::getLogger()->info("SQLITE_LOCKED - record %d - retry number %d sleep time ms %d",i, retries, sleep_time_ms); std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); } @@ -551,7 +551,7 @@ int Connection::readingStream(ReadingStream **readings, bool commit) sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); retries++; - Logger::getLogger()->info("SQLITE_BUSY - thread :%s: - record :%d: - retry number :%d: sleep time ms :%d:", threadId.str().c_str() ,i , retries, sleep_time_ms); + Logger::getLogger()->info("SQLITE_BUSY - thread '%s' - record %d - retry number %d sleep time ms %d", threadId.str().c_str() ,i , retries, sleep_time_ms); std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); } @@ -567,7 +567,7 @@ int Connection::readingStream(ReadingStream **readings, bool commit) else { raiseError("appendReadings", - "Inserting a row into SQLIte using a prepared command - asset_code :%s: error :%s: reading :%s: ", + "Inserting a row into SQLIte using a prepared command - asset_code '%s' error '%s' reading '%s' ", asset_code, sqlite3_errmsg(dbHandle), reading.c_str()); @@ -583,7 +583,7 @@ int Connection::readingStream(ReadingStream **readings, bool commit) } catch (exception e) { - raiseError("appendReadings", "Inserting a row into SQLIte using a prepared command - error :%s:", e.what()); + raiseError("appendReadings", "Inserting a row into SQLIte using a prepared command - error '%s'", e.what()); sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION", NULL, NULL, NULL); m_streamOpenTransaction = true; @@ -599,7 +599,7 @@ int Connection::readingStream(ReadingStream **readings, bool commit) sqlite3_resut = sqlite3_exec(dbHandle, "END TRANSACTION", NULL, NULL, NULL); if (sqlite3_resut != SQLITE_OK) { - raiseError("appendReadings", "Executing the commit of the transaction - error :%s:", sqlite3_errmsg(dbHandle)); + raiseError("appendReadings", "Executing the commit of the transaction - error '%s'", sqlite3_errmsg(dbHandle)); rowNumber = -1; } m_streamOpenTransaction = true; @@ -609,7 +609,7 @@ int Connection::readingStream(ReadingStream **readings, bool commit) { if (sqlite3_finalize(stmt) != SQLITE_OK) { - raiseError("appendReadings","freeing SQLite in memory structure - error :%s:", sqlite3_errmsg(dbHandle)); + raiseError("appendReadings","freeing SQLite in memory structure - error '%s'", sqlite3_errmsg(dbHandle)); } } @@ -627,7 +627,7 @@ int Connection::readingStream(ReadingStream **readings, bool commit) timersub(&t2, &t1, &tm); timeT2 = tm.tv_sec + ((double)tm.tv_usec / 1000000); - Logger::getLogger()->debug("readingStream row count :%d:", rowNumber); + Logger::getLogger()->debug("readingStream row count %d", rowNumber); Logger::getLogger()->debug("readingStream Timing - stream handling %.3f seconds - commit/finalize %.3f seconds", timeT1, @@ -655,16 +655,16 @@ void Connection::shutdownAppendReadings() ostringstream threadId; threadId << std::this_thread::get_id(); - Logger::getLogger()->debug("%s - thread Id :%s: appendReadings shutting down started", __FUNCTION__, threadId.str().c_str()); + Logger::getLogger()->debug("%s - thread Id '%s' appendReadings shutting down started", __FUNCTION__, threadId.str().c_str()); m_shutdown=true; while (m_appendCount > 0) { - Logger::getLogger()->debug("%s - thread Id :%s: waiting threads to shut down, count :%d: ", __FUNCTION__, threadId.str().c_str(), int(m_appendCount)); + Logger::getLogger()->debug("%s - thread Id '%s' waiting threads to shut down, count %d ", __FUNCTION__, threadId.str().c_str(), int(m_appendCount)); std::this_thread::sleep_for(std::chrono::milliseconds(150)); } - Logger::getLogger()->debug("%s - thread Id :%s: appendReadings shutting down ended", __FUNCTION__, threadId.str().c_str()); + Logger::getLogger()->debug("%s - thread Id '%s' appendReadings shutting down ended", __FUNCTION__, threadId.str().c_str()); } @@ -714,34 +714,32 @@ ostringstream threadId; { if (m_shutdown) { - Logger::getLogger()->debug("%s - thread Id :%s: plugin is shutting down, operation cancelled", __FUNCTION__, threadId.str().c_str()); + Logger::getLogger()->debug("%s - thread Id '%s' plugin is shutting down, operation cancelled", __FUNCTION__, threadId.str().c_str()); return -1; } m_appendCount++; - Logger::getLogger()->debug("%s - thread Id :%s: operation started , threads count :%d: ", __FUNCTION__, threadId.str().c_str(), int(m_appendCount) ); + Logger::getLogger()->debug("%s - thread Id '%s' operation started , threads count %d ", __FUNCTION__, threadId.str().c_str(), int(m_appendCount) ); } ReadingsCatalogue *readCatalogue = ReadingsCatalogue::getInstance(); - { - // Attaches the needed databases if the queue is not empty - AttachDbSync *attachSync = AttachDbSync::getInstance(); - attachSync->lock(); + // Attaches the needed databases if the queue is not empty + AttachDbSync *attachSync = AttachDbSync::getInstance(); + attachSync->lock(); - if ( ! m_NewDbIdList.empty()) - { - readCatalogue->connectionAttachDbList(this->getDbHandle(), m_NewDbIdList); - } - attachSync->unlock(); + if ( ! m_NewDbIdList.empty()) + { + readCatalogue->connectionAttachDbList(this->getDbHandle(), m_NewDbIdList); } + attachSync->unlock(); stmtArraySize = readCatalogue->getReadingPosition(0, 0); vector readingsStmt(stmtArraySize + 1, nullptr); #if INSTRUMENT - Logger::getLogger()->debug("appendReadings start thread :%s:", threadId.str().c_str()); + Logger::getLogger()->debug("appendReadings start thread '%s'", threadId.str().c_str()); struct timeval start, t1, t2, t3, t4, t5; #endif @@ -837,11 +835,11 @@ ostringstream threadId; ref = readCatalogue->getReadingReference(this, asset_code); readingsId = ref.tableId; - Logger::getLogger()->debug("tyReadingReference :%s: :%d: :%d: ", asset_code, ref.dbId, ref.tableId); + Logger::getLogger()->debug("tyReadingReference '%s' %d %d ", asset_code, ref.dbId, ref.tableId); if (readingsId == -1) { - Logger::getLogger()->warn("appendReadings - It was not possible to insert the row for the asset_code :%s: into the readings, row ignored.", asset_code); + Logger::getLogger()->warn("appendReadings - It was not possible to insert the row for the asset_code '%s' into the readings, row ignored.", asset_code); stmt = NULL; } else @@ -851,14 +849,14 @@ ostringstream threadId; nReadings = readCatalogue->getReadingsCount(); idxReadings = readCatalogue->getReadingPosition(ref.dbId, ref.tableId); - Logger::getLogger()->debug("tyReadingReference :%s: :%d: :%d: idxReadings :%d:", asset_code, ref.dbId, ref.tableId, idxReadings); + Logger::getLogger()->debug("tyReadingReference '%s' %d %d idxReadings %d", asset_code, ref.dbId, ref.tableId, idxReadings); if (idxReadings >= stmtArraySize) { stmtArraySize = idxReadings + 1; readingsStmt.resize(stmtArraySize, nullptr); - Logger::getLogger()->debug("appendReadings: thread :%s: resize size :%d: idx :%d: ", threadId.str().c_str(), stmtArraySize, readingsId); + Logger::getLogger()->debug("appendReadings: thread '%s' resize size %d idx %d ", threadId.str().c_str(), stmtArraySize, readingsId); } if (readingsStmt[idxReadings] == nullptr) @@ -866,10 +864,18 @@ ostringstream threadId; string dbName = readCatalogue->generateDbName(ref.dbId); string dbReadingsName = readCatalogue->generateReadingsName(ref.dbId, readingsId); - sql_cmd = "INSERT INTO " + dbName + "." + dbReadingsName + " ( id, user_ts, reading ) VALUES (?,?,?)"; + if (readingsId == 0) + { + // Overflow table + sql_cmd = "INSERT INTO " + dbName + ".readings_" + to_string(ref.dbId) + "_overflow ( id, asset_code, user_ts, reading ) VALUES (?,'" + asset_code + "',?,?)"; + } + else + { + sql_cmd = "INSERT INTO " + dbName + "." + dbReadingsName + " ( id, user_ts, reading ) VALUES (?,?,?)"; + } rc = SQLPrepare(dbHandle, sql_cmd.c_str(), &readingsStmt[idxReadings]); - Logger::getLogger()->debug("tyReadingReference sql_cmd :%s: :%s: :%d: :%d: ", sql_cmd.c_str(), asset_code, ref.dbId, ref.tableId); + Logger::getLogger()->debug("tyReadingReference sql_cmd '%s' '%s' %d %d ", sql_cmd.c_str(), asset_code, ref.dbId, ref.tableId); if (rc != SQLITE_OK) { @@ -948,9 +954,9 @@ ostringstream threadId; if (retries >= LOG_AFTER_NERRORS) { Logger::getLogger()->warn("appendReadings - %s - " \ - "asset_code :%s: readingsId :%d: " \ - "thread :%s: dbHandle :%X: record " \ - ":%d: retry number :%d: sleep time ms :%d:error :%s:", + "asset_code '%s' readingsId %d " \ + "thread '%s' dbHandle %X record " \ + "%d retry number %d sleep time ms %derror '%s'", msgError.c_str(), asset_code, readingsId, @@ -978,17 +984,34 @@ ostringstream threadId; { raiseError("appendReadings","Inserting a row into " \ "SQLIte using a prepared command - asset_code " \ - ":%s: error :%s: reading :%s: dbHandle :%X:", + "'%s' error '%s' reading '%s' dbHandle %X", asset_code, sqlite3_errmsg(dbHandle), reading.c_str(), dbHandle); + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); + sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION", NULL, NULL, NULL); m_appendCount--; // Clear transaction boundary for this thread readCatalogue->m_tx.ClearThreadTransaction(tid); + + // Finalize sqlite structures + for (auto &item : readingsStmt) + { + if(item != nullptr) + { + + if (sqlite3_finalize(item) != SQLITE_OK) + { + raiseError("appendReadings","freeing SQLite in memory structure - error '%s'", sqlite3_errmsg(dbHandle)); + } + } + + } return -1; } } @@ -999,7 +1022,7 @@ ostringstream threadId; if (sqlite3_resut != SQLITE_OK) { raiseError("appendReadings", - "Executing the commit of the transaction :%s:", + "Executing the commit of the transaction '%s'", sqlite3_errmsg(dbHandle)); row = -1; } @@ -1022,7 +1045,7 @@ ostringstream threadId; if (sqlite3_finalize(item) != SQLITE_OK) { - raiseError("appendReadings","freeing SQLite in memory structure - error :%s:", sqlite3_errmsg(dbHandle)); + raiseError("appendReadings","freeing SQLite in memory structure - error '%s'", sqlite3_errmsg(dbHandle)); } } @@ -1045,7 +1068,7 @@ ostringstream threadId; timersub(&t3, &t2, &tm); timeT3 = tm.tv_sec + ((double)tm.tv_usec / 1000000); - Logger::getLogger()->debug("appendReadings end thread :%s: buffer :%10lu: count :%5d: JSON :%6.3f: inserts :%6.3f: finalize :%6.3f:", + Logger::getLogger()->debug("appendReadings end thread '%s' buffer :%10lu: count :%5d: JSON :%6.3f: inserts :%6.3f: finalize :%6.3f:", threadId.str().c_str(), strlen(readings), row, @@ -1125,55 +1148,68 @@ unsigned long rowsCount; } // Generate a single SQL statement that using a set of UNION considers all the readings table in handling + // SQL - start + sql_cmd = R"( + SELECT + id, + asset_code, + reading, + strftime('%Y-%m-%d %H:%M:%S', user_ts, 'utc') || + substr(user_ts, instr(user_ts, '.'), 7) AS user_ts, + strftime('%Y-%m-%d %H:%M:%f', ts, 'utc') AS ts + FROM + ( + )"; + + // SQL - union of all the readings tables + string sql_cmd_base; + string sql_cmd_tmp; + // Would like to add a LIMIT on each sub-query in the union all, however SQLITE + // does not support this. Note we can not use id + blocksize as this fail if we + // have holes in the id space + sql_cmd_base = " SELECT id, \"_assetcode_\" asset_code, reading, user_ts, ts " \ + "FROM _dbname_._tablename_ WHERE id >= " + + to_string(id) + " "; + + // Check for any uncommitted transactions: + // fetch the minimum reading id among all per thread transactions + // an use it as a boundary limit. + // If no pending transactions just use current global reading id as limit + unsigned long safe_id = readCatalogue->m_tx.GetMinReadingId(); + if (safe_id) + { + sql_cmd_base += "AND id < " + to_string(safe_id) + " "; + } + else { - // SQL - start - sql_cmd = R"( - SELECT - id, - asset_code, - reading, - strftime('%Y-%m-%d %H:%M:%S', user_ts, 'utc') || - substr(user_ts, instr(user_ts, '.'), 7) AS user_ts, - strftime('%Y-%m-%d %H:%M:%f', ts, 'utc') AS ts - FROM - ( - )"; + sql_cmd_base += "AND id < " + to_string(readCatalogue->getGlobalId()) + " "; + } - // SQL - union of all the readings tables - string sql_cmd_base; - string sql_cmd_tmp; - // Would like to add a LIMIT on each sub-query in the union all, however SQLITE - // does not support this. Note we can not use id + blocksize as this fail if we - // have holes in the id space - sql_cmd_base = " SELECT id, \"_assetcode_\" asset_code, reading, user_ts, ts " \ - "FROM _dbname_._tablename_ WHERE id >= " + - to_string(id) + " "; - - // Check for any uncommitted transactions: - // fetch the minimum reading id among all per thread transactions - // an use it as a boundary limit. - // If no pending transactions just use current global reading id as limit - unsigned long safe_id = readCatalogue->m_tx.GetMinReadingId(); - if (safe_id) - { - sql_cmd_base += "AND id < " + to_string(safe_id) + " "; - } - else - { - sql_cmd_base += "AND id < " + to_string(readCatalogue->getGlobalId()) + " "; - } + sql_cmd_tmp = readCatalogue->sqlConstructMultiDb(sql_cmd_base, asset_codes); + sql_cmd += sql_cmd_tmp; - sql_cmd_tmp = readCatalogue->sqlConstructMultiDb(sql_cmd_base, asset_codes); - sql_cmd += sql_cmd_tmp; + // Now add in ther overflow tables + sql_cmd_base = " SELECT id, asset_code, reading, user_ts, ts " \ + "FROM _dbname_._tablename_ WHERE id >= " + + to_string(id) + " "; + if (safe_id) + { + sql_cmd_base += "AND id < " + to_string(safe_id) + " "; + } + else + { + sql_cmd_base += "AND id < " + to_string(readCatalogue->getGlobalId()) + " "; + } - // SQL - end - sql_cmd += R"( - ) as tb - ORDER BY id ASC - LIMIT - )" + to_string(blksize); + sql_cmd_tmp = readCatalogue->sqlConstructOverflow(sql_cmd_base, asset_codes); + sql_cmd += sql_cmd_tmp; - } + // SQL - end + sql_cmd += R"( + ) as tb + ORDER BY id ASC + LIMIT + )" + to_string(blksize); logSQL("ReadingsFetch", sql_cmd.c_str()); @@ -1361,6 +1397,10 @@ vector asset_codes; sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, asset_codes); sql_cmd += sql_cmd_tmp; + sql_cmd_base = " SELECT id, asset_code, reading, user_ts, ts FROM _dbname_._tablename_ "; + sql_cmd_tmp = readCatalogue->sqlConstructOverflow(sql_cmd_base, asset_codes); + sql_cmd += sql_cmd_tmp; + // SQL - end sql_cmd += R"( ) as tb; @@ -1633,6 +1673,7 @@ vector asset_codes; // SQL - union of all the readings tables string sql_cmd_base; + string sql_cmd_overflow_base; string sql_cmd_tmp; // Specific optimization for the count operation @@ -1650,17 +1691,23 @@ vector asset_codes; sql_cmd_base += ", asset_code"; sql_cmd_base += ", id, reading, user_ts, ts "; + sql_cmd_overflow_base = sql_cmd_base; StringReplaceAll (sql_cmd_base, "asset_code", " \"_assetcode_\" .assetcode. "); sql_cmd_base += " FROM _dbname_._tablename_ "; + sql_cmd_overflow_base += " FROM _dbname_._tablename_ "; delete[] queryTmp; } else { sql_cmd_base = " SELECT ROWID, id, \"_assetcode_\" asset_code, reading, user_ts, ts FROM _dbname_._tablename_ "; + sql_cmd_overflow_base = " SELECT ROWID, id, asset_code, reading, user_ts, ts FROM _dbname_._tablename_ "; } sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, asset_codes); sql_cmd += sql_cmd_tmp; + + sql_cmd_tmp = readCatalogue->sqlConstructOverflow(sql_cmd_overflow_base, asset_codes, false, isOptAggregate); + sql_cmd += sql_cmd_tmp; // SQL - end sql_cmd += R"( @@ -1713,7 +1760,6 @@ vector asset_codes; sql.append(';'); const char *query = sql.coalesce(); - char *zErrMsg = NULL; int rc; sqlite3_stmt *stmt; @@ -1769,6 +1815,7 @@ unsigned long rowidLimit = 0, minrowidLimit = 0, maxrowidLimit = 0, rowidMin; struct timeval startTv, endTv; int blocks = 0; bool flag_retain; +char *zErrMsg = NULL; vector assetCodes; @@ -1802,7 +1849,7 @@ vector assetCodes; { flag_retain = true; } - Logger::getLogger()->debug("%s - flags :%X: flag_retain :%d: sent :%ld:", __FUNCTION__, flags, flag_retain, sent); + Logger::getLogger()->debug("%s - flags %X flag_retain %d sent :%ld:", __FUNCTION__, flags, flag_retain, sent); // Prepare empty result result = "{ \"removed\" : 0, "; @@ -1818,93 +1865,80 @@ vector assetCodes; * This provents us looping in the purge process if new readings become * eligible for purging at a rate that is faster than we can purge them. */ - { - char *zErrMsg = NULL; - int rc; - string sql_cmd; - // Generate a single SQL statement that using a set of UNION considers all the readings table in handling - { - // SQL - start - sql_cmd = R"( - SELECT MAX(rowid) - FROM - ( - )"; - - // SQL - union of all the readings tables - string sql_cmd_base; - string sql_cmd_tmp; - sql_cmd_base = " SELECT MAX(rowid) rowid FROM _dbname_._tablename_ "; - ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); - sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes); - sql_cmd += sql_cmd_tmp; - - // SQL - end - sql_cmd += R"( - ) as readings_1 - )"; - } - - rc = SQLexec(dbHandle, - sql_cmd.c_str(), - rowidCallback, - &rowidLimit, - &zErrMsg); + string sql_cmd; + string sql_cmd_tmp; + // Generate a single SQL statement that using a set of UNION considers all the readings table in handling + // SQL - start + sql_cmd = R"( + SELECT MAX(rowid) + FROM + ( + )"; + + // SQL - union of all the readings tables + string sql_cmd_base = " SELECT MAX(rowid) rowid FROM _dbname_._tablename_ "; + ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); + sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes); + sql_cmd += sql_cmd_tmp; + sql_cmd_tmp = readCat->sqlConstructOverflow(sql_cmd_base, assetCodes); + sql_cmd += sql_cmd_tmp; + + // SQL - end + sql_cmd += R"( + ) as readings_1 + )"; + + int rc = SQLexec(dbHandle, + sql_cmd.c_str(), + rowidCallback, + &rowidLimit, + &zErrMsg); - if (rc != SQLITE_OK) - { - raiseError("purge - phase 0, fetching rowid limit ", zErrMsg); - sqlite3_free(zErrMsg); - return 0; - } - maxrowidLimit = rowidLimit; + if (rc != SQLITE_OK) + { + raiseError("purge - phase 0, fetching rowid limit ", zErrMsg); + sqlite3_free(zErrMsg); + return 0; } + maxrowidLimit = rowidLimit; Logger::getLogger()->debug("purgeReadings rowidLimit %lu", rowidLimit); - { - char *zErrMsg = NULL; - int rc; - - string sql_cmd; - // Generate a single SQL statement that using a set of UNION considers all the readings table in handling - { - // SQL - start - sql_cmd = R"( - SELECT MIN(rowid) - FROM - ( - )"; - - // SQL - union of all the readings tables - string sql_cmd_base; - string sql_cmd_tmp; - sql_cmd_base = " SELECT MIN(rowid) rowid FROM _dbname_._tablename_ "; - ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); - sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes, true); - sql_cmd += sql_cmd_tmp; - // SQL - end - sql_cmd += R"( - ) as readings_1 - )"; - } - - Logger::getLogger()->debug("%s - SELECT MIN - :%s:", __FUNCTION__, sql_cmd.c_str() ); - - rc = SQLexec(dbHandle, - sql_cmd.c_str(), - rowidCallback, - &minrowidLimit, - &zErrMsg); + // Generate a single SQL statement that using a set of UNION considers all the readings table in handling + // SQL - start + sql_cmd = R"( + SELECT MIN(rowid) + FROM + ( + )"; + + // SQL - union of all the readings tables + sql_cmd_base = " SELECT MIN(rowid) rowid FROM _dbname_._tablename_ "; + sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes, true); + sql_cmd += sql_cmd_tmp; + sql_cmd_tmp = readCat->sqlConstructOverflow(sql_cmd_base, assetCodes, true); + sql_cmd += sql_cmd_tmp; + + // SQL - end + sql_cmd += R"( + ) as readings_1 + )"; + + Logger::getLogger()->debug("%s - SELECT MIN - '%s'", __FUNCTION__, sql_cmd.c_str() ); + + rc = SQLexec(dbHandle, + sql_cmd.c_str(), + rowidCallback, + &minrowidLimit, + &zErrMsg); - if (rc != SQLITE_OK) - { - raiseError("purge - phaase 0, fetching minrowid limit ", zErrMsg); - sqlite3_free(zErrMsg); - return 0; - } + if (rc != SQLITE_OK) + { + raiseError("purge - phaase 0, fetching minrowid limit ", zErrMsg); + sqlite3_free(zErrMsg); + return 0; } Logger::getLogger()->debug("purgeReadings minrowidLimit %lu", minrowidLimit); @@ -1917,28 +1951,27 @@ vector assetCodes; */ string sql_cmd; // Generate a single SQL statement that using a set of UNION considers all the readings table in handling - { - // SQL - start - sql_cmd = R"( - SELECT (strftime('%s','now', 'utc') - strftime('%s', MIN(user_ts)))/360 - FROM - ( - )"; - - // SQL - union of all the readings tables - string sql_cmd_base; - string sql_cmd_tmp; - sql_cmd_base = " SELECT MIN(user_ts) user_ts FROM _dbname_._tablename_ WHERE rowid <= " + to_string(rowidLimit); - ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); - sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes, true); - sql_cmd += sql_cmd_tmp; + // SQL - start + sql_cmd = R"( + SELECT (strftime('%s','now', 'utc') - strftime('%s', MIN(user_ts)))/360 + FROM + ( + )"; - // SQL - end - sql_cmd += R"( - ) as readings_1 - )"; + // SQL - union of all the readings tables + string sql_cmd_base; + string sql_cmd_tmp; + sql_cmd_base = " SELECT MIN(user_ts) user_ts FROM _dbname_._tablename_ WHERE rowid <= " + to_string(rowidLimit); + ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); + sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes, true); + sql_cmd += sql_cmd_tmp; + sql_cmd_tmp = readCat->sqlConstructOverflow(sql_cmd_base, assetCodes, true); + sql_cmd += sql_cmd_tmp; - } + // SQL - end + sql_cmd += R"( + ) as readings_1 + )"; SQLBuffer oldest; oldest.append(sql_cmd); @@ -1969,7 +2002,7 @@ vector assetCodes; return 0; } - Logger::getLogger()->debug("purgeReadings purge_readings :%d: age :%d:", purge_readings, age); + Logger::getLogger()->debug("purgeReadings purge_readings %d age %d", purge_readings, age); } Logger::getLogger()->debug("%s - rowidLimit :%lu: maxrowidLimit :%lu: maxrowidLimit :%lu: age :%lu:", __FUNCTION__, rowidLimit, maxrowidLimit, minrowidLimit, age); @@ -2027,6 +2060,8 @@ vector assetCodes; ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes); sql_cmd += sql_cmd_tmp; + sql_cmd_tmp = readCat->sqlConstructOverflow(sql_cmd_base, assetCodes); + sql_cmd += sql_cmd_tmp; // SQL - end sql_cmd += R"( @@ -2111,6 +2146,8 @@ vector assetCodes; ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes); sql_cmd += sql_cmd_tmp; + sql_cmd_tmp = readCat->sqlConstructOverflow(sql_cmd_base, assetCodes); + sql_cmd += sql_cmd_tmp; // SQL - end sql_cmd += R"( @@ -2147,7 +2184,7 @@ vector assetCodes; unsentPurged = unsent; } - Logger::getLogger()->debug("%s - lastPurgedId :%d: unsentPurged :%ld:",__FUNCTION__, lastPurgedId, unsentPurged); + Logger::getLogger()->debug("%s - lastPurgedId %d unsentPurged :%ld:",__FUNCTION__, lastPurgedId, unsentPurged); } if (m_writeAccessOngoing) { @@ -2158,13 +2195,11 @@ vector assetCodes; } unsigned int deletedRows = 0; - char *zErrMsg = NULL; + zErrMsg = NULL; unsigned long rowsAffected; unsigned int totTime=0, prevBlocks=0, prevTotTime=0; logger->info("Purge about to delete readings # %ld to %ld", rowidMin, rowidLimit); - ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); - while (rowidMin < rowidLimit) { blocks++; @@ -2191,7 +2226,7 @@ vector assetCodes; rc = readCat->purgeAllReadings(dbHandle, query ,&zErrMsg, &rowsAffected); END_TIME; - logger->debug("%s - DELETE sql :%s: rowsAffected :%ld:", __FUNCTION__, query ,rowsAffected); + logger->debug("%s - DELETE sql '%s' rowsAffected :%ld:", __FUNCTION__, query ,rowsAffected); // Release memory for 'query' var delete[] query; @@ -2265,7 +2300,7 @@ vector assetCodes; unsigned long duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; logger->info("Purge process complete in %d blocks in %lduS", blocks, duration); - Logger::getLogger()->debug("%s - age :%lu: flag_retain :%x: sent :%lu: result :%s:", __FUNCTION__, age, flags, flag_retain, result.c_str() ); + Logger::getLogger()->debug("%s - age :%lu: flag_retain :%x: sent :%lu: result '%s'", __FUNCTION__, age, flags, flag_retain, result.c_str() ); return deletedRows; } @@ -2326,7 +2361,7 @@ bool flag_retain; { flag_retain = true; } - Logger::getLogger()->debug("%s - flags :%X: flag_retain :%d: sent :%ld:", __FUNCTION__, flags, flag_retain, sent); + Logger::getLogger()->debug("%s - flags %X flag_retain %d sent :%ld:", __FUNCTION__, flags, flag_retain, sent); logger->info("Purge by Rows called"); @@ -2359,6 +2394,8 @@ bool flag_retain; ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes); sql_cmd += sql_cmd_tmp; + sql_cmd_tmp = readCat->sqlConstructOverflow(sql_cmd_base, assetCodes); + sql_cmd += sql_cmd_tmp; // SQL - end sql_cmd += R"( @@ -2398,6 +2435,8 @@ bool flag_retain; ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes); sql_cmd += sql_cmd_tmp; + sql_cmd_tmp = readCat->sqlConstructOverflow(sql_cmd_base, assetCodes); + sql_cmd += sql_cmd_tmp; // SQL - end sql_cmd += R"( @@ -2448,13 +2487,15 @@ bool flag_retain; ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); sql_cmd_tmp = readCat->sqlConstructMultiDb(sql_cmd_base, assetCodes, true); sql_cmd += sql_cmd_tmp; + sql_cmd_tmp = readCat->sqlConstructOverflow(sql_cmd_base, assetCodes, true); + sql_cmd += sql_cmd_tmp; // SQL - end sql_cmd += R"( ) as readings_1 )"; - logger->debug("%s - SELECT MIN - sql_cmd :%s: ", __FUNCTION__, sql_cmd.c_str() ); + logger->debug("%s - SELECT MIN - sql_cmd '%s' ", __FUNCTION__, sql_cmd.c_str() ); } rc = SQLexec(dbHandle, @@ -2501,7 +2542,7 @@ bool flag_retain; // Exec DELETE query: no callback, no resultset rc = readCat->purgeAllReadings(dbHandle, query ,&zErrMsg, &rowsAffected); - logger->debug(" %s - DELETE - query :%s: rowsAffected :%ld:", __FUNCTION__, query ,rowsAffected); + logger->debug(" %s - DELETE - query '%s' rowsAffected :%ld:", __FUNCTION__, query ,rowsAffected); deletedRows += rowsAffected; numReadings -= rowsAffected; @@ -2541,7 +2582,7 @@ bool flag_retain; result = convert.str(); - Logger::getLogger()->debug("%s - Purge by Rows complete - rows :%lu: flag :%x: sent :%lu: numReadings :%lu: rowsAffected :%u: result :%s:", __FUNCTION__, rows, flags, sent, numReadings, rowsAffected, result.c_str() ); + Logger::getLogger()->debug("%s - Purge by Rows complete - rows :%lu: flag :%x: sent :%lu: numReadings :%lu: rowsAffected :%u: result '%s'", __FUNCTION__, rows, flags, sent, numReadings, rowsAffected, result.c_str() ); return deletedRows; } @@ -2563,7 +2604,7 @@ int Connection::SQLPrepare(sqlite3 *dbHandle, const char *sqlCmd, sqlite3_stmt * { if (retries >= LOG_AFTER_NERRORS){ - Logger::getLogger()->warn("SQLPrepare - error :%s: dbHandle :%X: sqlCmd :%s: retry :%d: of :%d:", + Logger::getLogger()->warn("SQLPrepare - error '%s' dbHandle %X sqlCmd '%s' retry %d of %d", sqlite3_errmsg(dbHandle), dbHandle, sqlCmd, diff --git a/C/plugins/storage/sqlite/common/readings_catalogue.cpp b/C/plugins/storage/sqlite/common/readings_catalogue.cpp index e885553c36..fc4acfade1 100644 --- a/C/plugins/storage/sqlite/common/readings_catalogue.cpp +++ b/C/plugins/storage/sqlite/common/readings_catalogue.cpp @@ -28,8 +28,22 @@ using namespace rapidjson; #define LOG_TX_BOUNDARIES 0 /** - * Logs an error + * Constructor * + * This is never explicitly called as the ReadingsCatalogue is a + * singleton class. + */ +ReadingsCatalogue::ReadingsCatalogue() : m_nextOverflow(1), m_maxOverflowUsed(0) +{ +} + +/** + * Logs an error. A variable argument function that + * uses a printf format string to log an error message with the + * associated operation. + * + * @param operation The operastion in progress + * @param reason A printf format string with the error message text */ void ReadingsCatalogue::raiseError(const char *operation, const char *reason, ...) { @@ -39,7 +53,7 @@ void ReadingsCatalogue::raiseError(const char *operation, const char *reason, .. va_start(ap, reason); vsnprintf(tmpbuf, sizeof(tmpbuf), reason, ap); va_end(ap); - Logger::getLogger()->error("ReadingsCatalogues error: %s", tmpbuf); + Logger::getLogger()->error("ReadingsCatalogues: %s during operation %s", tmpbuf, operation); } /** @@ -59,43 +73,41 @@ bool ReadingsCatalogue::configurationRetrieve(sqlite3 *dbHandle) sqlite3_stmt *stmt; // Retrieves the global_id from thd DB - { - sql_cmd = " SELECT global_id, db_id_Last, n_readings_per_db, n_db_preallocate FROM " READINGS_DB ".configuration_readings "; + sql_cmd = " SELECT global_id, db_id_Last, n_readings_per_db, n_db_preallocate FROM " READINGS_DB ".configuration_readings "; - if (sqlite3_prepare_v2(dbHandle,sql_cmd.c_str(),-1, &stmt,NULL) != SQLITE_OK) - { - raiseError("configurationRetrieve", sqlite3_errmsg(dbHandle)); - return false; - } + if (sqlite3_prepare_v2(dbHandle,sql_cmd.c_str(),-1, &stmt,NULL) != SQLITE_OK) + { + raiseError("configurationRetrieve", sqlite3_errmsg(dbHandle)); + return false; + } - if (SQLStep(stmt) != SQLITE_ROW) - { - m_ReadingsGlobalId = 1; - m_dbIdLast = 0; + if (SQLStep(stmt) != SQLITE_ROW) + { + m_ReadingsGlobalId = 1; + m_dbIdLast = 0; - m_storageConfigCurrent.nReadingsPerDb = m_storageConfigApi.nReadingsPerDb; - m_storageConfigCurrent.nDbPreallocate = m_storageConfigApi.nDbPreallocate; + m_storageConfigCurrent.nReadingsPerDb = m_storageConfigApi.nReadingsPerDb; + m_storageConfigCurrent.nDbPreallocate = m_storageConfigApi.nDbPreallocate; - sql_cmd = " INSERT INTO " READINGS_DB ".configuration_readings VALUES (" + to_string(m_ReadingsGlobalId) + "," - + to_string(m_dbIdLast) + "," - + to_string(m_storageConfigCurrent.nReadingsPerDb) + "," - + to_string(m_storageConfigCurrent.nDbPreallocate) + ")"; - if (SQLExec(dbHandle, sql_cmd.c_str()) != SQLITE_OK) - { - raiseError("configurationRetrieve", sqlite3_errmsg(dbHandle)); - return false; - } - } - else + sql_cmd = " INSERT INTO " READINGS_DB ".configuration_readings VALUES (" + to_string(m_ReadingsGlobalId) + "," + + to_string(m_dbIdLast) + "," + + to_string(m_storageConfigCurrent.nReadingsPerDb) + "," + + to_string(m_storageConfigCurrent.nDbPreallocate) + ")"; + if (SQLExec(dbHandle, sql_cmd.c_str()) != SQLITE_OK) { - nCols = sqlite3_column_count(stmt); - m_ReadingsGlobalId = sqlite3_column_int(stmt, 0); - m_dbIdLast = sqlite3_column_int(stmt, 1); - m_storageConfigCurrent.nReadingsPerDb = sqlite3_column_int(stmt, 2); - m_storageConfigCurrent.nDbPreallocate = sqlite3_column_int(stmt, 3); + raiseError("configurationRetrieve", sqlite3_errmsg(dbHandle)); + return false; } } - Logger::getLogger()->debug("configurationRetrieve: ReadingsGlobalId :%d: dbIdLast :%d: ", (int) m_ReadingsGlobalId, m_dbIdLast); + else + { + nCols = sqlite3_column_count(stmt); + m_ReadingsGlobalId = sqlite3_column_int(stmt, 0); + m_dbIdLast = sqlite3_column_int(stmt, 1); + m_storageConfigCurrent.nReadingsPerDb = sqlite3_column_int(stmt, 2); + m_storageConfigCurrent.nDbPreallocate = sqlite3_column_int(stmt, 3); + } + Logger::getLogger()->debug("configurationRetrieve: ReadingsGlobalId %d dbIdLast %d ", (int) m_ReadingsGlobalId, m_dbIdLast); sqlite3_finalize(stmt); @@ -122,42 +134,46 @@ bool ReadingsCatalogue::evaluateGlobalId () ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Evaluate Global ID"; + connection->setUsage(usage); +#endif dbHandle = connection->getDbHandle(); // Retrieves the global_id from thd DB - { - sql_cmd = " SELECT global_id FROM " READINGS_DB ".configuration_readings "; + sql_cmd = " SELECT global_id FROM " READINGS_DB ".configuration_readings "; - if (sqlite3_prepare_v2(dbHandle,sql_cmd.c_str(),-1, &stmt,NULL) != SQLITE_OK) - { - raiseError("evaluateGlobalId", sqlite3_errmsg(dbHandle)); - return false; - } + if (sqlite3_prepare_v2(dbHandle,sql_cmd.c_str(),-1, &stmt,NULL) != SQLITE_OK) + { + raiseError("evaluateGlobalId", sqlite3_errmsg(dbHandle)); + manager->release(connection); + return false; + } - if (SQLStep(stmt) != SQLITE_ROW) - { - m_ReadingsGlobalId = 1; + if (SQLStep(stmt) != SQLITE_ROW) + { + m_ReadingsGlobalId = 1; - sql_cmd = " INSERT INTO " READINGS_DB ".configuration_readings VALUES (" + to_string(m_ReadingsGlobalId) + "," - + "0" + "," - + to_string(m_storageConfigApi.nReadingsPerDb) + "," - + to_string(m_storageConfigApi.nDbPreallocate) + ")"; + sql_cmd = " INSERT INTO " READINGS_DB ".configuration_readings VALUES (" + to_string(m_ReadingsGlobalId) + "," + + "0" + "," + + to_string(m_storageConfigApi.nReadingsPerDb) + "," + + to_string(m_storageConfigApi.nDbPreallocate) + ")"; - if (SQLExec(dbHandle, sql_cmd.c_str()) != SQLITE_OK) - { - raiseError("evaluateGlobalId", sqlite3_errmsg(dbHandle)); - return false; - } - } - else + if (SQLExec(dbHandle, sql_cmd.c_str()) != SQLITE_OK) { - nCols = sqlite3_column_count(stmt); - m_ReadingsGlobalId = sqlite3_column_int(stmt, 0); + raiseError("evaluateGlobalId", sqlite3_errmsg(dbHandle)); + manager->release(connection); + return false; } } + else + { + nCols = sqlite3_column_count(stmt); + m_ReadingsGlobalId = sqlite3_column_int(stmt, 0); + } id = m_ReadingsGlobalId; - Logger::getLogger()->debug("evaluateGlobalId - global id from the DB :%d:", id); + Logger::getLogger()->debug("evaluateGlobalId - global id from the DB %d", id); if (m_ReadingsGlobalId == -1) { @@ -165,18 +181,17 @@ bool ReadingsCatalogue::evaluateGlobalId () } id = m_ReadingsGlobalId; - Logger::getLogger()->debug("evaluateGlobalId - global id from the DB :%d:", id); + Logger::getLogger()->debug("evaluateGlobalId - global id from the DB %d", id); // Set the global_id in the DB to -1 to force a calculation at the restart // in case the shutdown is not executed and the proper value stored - { - sql_cmd = " UPDATE " READINGS_DB ".configuration_readings SET global_id=-1;"; + sql_cmd = " UPDATE " READINGS_DB ".configuration_readings SET global_id=-1;"; - if (SQLExec(dbHandle, sql_cmd.c_str()) != SQLITE_OK) - { - raiseError("evaluateGlobalId", sqlite3_errmsg(dbHandle)); - return false; - } + if (SQLExec(dbHandle, sql_cmd.c_str()) != SQLITE_OK) + { + raiseError("evaluateGlobalId", sqlite3_errmsg(dbHandle)); + manager->release(connection); + return false; } sqlite3_finalize(stmt); @@ -200,11 +215,15 @@ bool ReadingsCatalogue::storeGlobalId () int i; i = m_ReadingsGlobalId; - Logger::getLogger()->debug("storeGlobalId m_globalId :%d: ", i); + Logger::getLogger()->debug("storeGlobalId m_globalId %d ", i); ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Store Global ID"; + connection->setUsage(usage); +#endif dbHandle = connection->getDbHandle(); sql_cmd = " UPDATE " READINGS_DB ".configuration_readings SET global_id=" + to_string(m_ReadingsGlobalId); @@ -212,6 +231,7 @@ bool ReadingsCatalogue::storeGlobalId () if (SQLExec(dbHandle, sql_cmd.c_str()) != SQLITE_OK) { raiseError("storeGlobalId", sqlite3_errmsg(dbHandle)); + manager->release(connection); return false; } @@ -240,24 +260,25 @@ int ReadingsCatalogue::calculateGlobalId (sqlite3 *dbHandle) id = 1; // Prepare the sql command to calculate the global id from the rows in the DB - { - sql_cmd = R"( - SELECT - max(id) id - FROM - ( - )"; + sql_cmd = R"( + SELECT + max(id) id + FROM + ( + )"; - bool firstRow = true; - if (m_AssetReadingCatalogue.empty()) - { - string dbReadingsName = generateReadingsName(1, 1); + bool firstRow = true; + if (m_AssetReadingCatalogue.empty()) + { + string dbReadingsName = generateReadingsName(1, 1); - sql_cmd += " SELECT max(id) id FROM " READINGS_DB "." + dbReadingsName + " "; - } - else + sql_cmd += " SELECT max(id) id FROM " READINGS_DB "." + dbReadingsName + " "; + } + else + { + for (auto &item : m_AssetReadingCatalogue) { - for (auto &item : m_AssetReadingCatalogue) + if (item.second.first != 0) { if (!firstRow) { @@ -271,8 +292,20 @@ int ReadingsCatalogue::calculateGlobalId (sqlite3 *dbHandle) firstRow = false; } } - sql_cmd += ") AS tb"; + // Now add overflow tables + for (int i = 1; i <= m_maxOverflowUsed; i++) + { + if (!firstRow) + { + sql_cmd += " UNION "; + } + dbName = generateDbName(i); + dbReadingsName = generateReadingsName(i, 0); + sql_cmd += " SELECT max(id) id FROM " + dbName + "." + dbReadingsName + " "; + firstRow = false; + } } + sql_cmd += ") AS tb"; if (sqlite3_prepare_v2(dbHandle,sql_cmd.c_str(),-1, &stmt,NULL) != SQLITE_OK) @@ -283,6 +316,7 @@ int ReadingsCatalogue::calculateGlobalId (sqlite3 *dbHandle) if (SQLStep(stmt) != SQLITE_ROW) { + raiseError("calculateGlobalId SQLStep", sqlite3_errmsg(dbHandle)); id = 1; } else @@ -293,7 +327,7 @@ int ReadingsCatalogue::calculateGlobalId (sqlite3 *dbHandle) id++; } - Logger::getLogger()->debug("calculateGlobalId - global id evaluated :%d:", id); + Logger::getLogger()->debug("calculateGlobalId - global id evaluated %d", id); sqlite3_finalize(stmt); return (id); @@ -337,15 +371,30 @@ int ReadingsCatalogue::getMinGlobalId (sqlite3 *dbHandle) else { for (auto &item : m_AssetReadingCatalogue) + { + if (item.second.first != 0) + { + if (!firstRow) + { + sql_cmd += " UNION "; + } + + dbName = generateDbName(item.second.second); + dbReadingsName = generateReadingsName(item.second.second, item.second.first); + + sql_cmd += " SELECT min(id) id FROM " + dbName + "." + dbReadingsName + " "; + firstRow = false; + } + } + // Now add overflow tables + for (int i = 1; i <= m_maxOverflowUsed; i++) { if (!firstRow) { sql_cmd += " UNION "; } - - dbName = generateDbName(item.second.second); - dbReadingsName = generateReadingsName(item.second.second, item.second.first); - + dbName = generateDbName(i); + dbReadingsName = generateReadingsName(i, 0); sql_cmd += " SELECT min(id) id FROM " + dbName + "." + dbReadingsName + " "; firstRow = false; } @@ -353,7 +402,6 @@ int ReadingsCatalogue::getMinGlobalId (sqlite3 *dbHandle) sql_cmd += ") AS tb"; } - if (sqlite3_prepare_v2(dbHandle,sql_cmd.c_str(),-1, &stmt,NULL) != SQLITE_OK) { raiseError(__FUNCTION__, sqlite3_errmsg(dbHandle)); @@ -370,7 +418,7 @@ int ReadingsCatalogue::getMinGlobalId (sqlite3 *dbHandle) id = sqlite3_column_int(stmt, 0); } - Logger::getLogger()->debug("%s - global id evaluated :%d:", __FUNCTION__, id); + Logger::getLogger()->debug("%s - global id evaluated %d", __FUNCTION__, id); sqlite3_finalize(stmt); @@ -395,6 +443,10 @@ bool ReadingsCatalogue::loadAssetReadingCatalogue() ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Load Asset Reading Catalogue"; + connection->setUsage(usage); +#endif dbHandle = connection->getDbHandle(); // loads readings catalog from the db @@ -412,6 +464,7 @@ bool ReadingsCatalogue::loadAssetReadingCatalogue() if (sqlite3_prepare_v2(dbHandle,sql_cmd,-1, &stmt,NULL) != SQLITE_OK) { raiseError("retrieve asset_reading_catalogue", sqlite3_errmsg(dbHandle)); + manager->release(connection); return false; } else @@ -428,11 +481,15 @@ bool ReadingsCatalogue::loadAssetReadingCatalogue() if (dbId > maxDbID) maxDbID = dbId; - Logger::getLogger()->debug("loadAssetReadingCatalogue - thread :%s: reading Id :%d: dbId :%d: asset name :%s: max db Id :%d:", threadId.str().c_str(), tableId, dbId, asset_name, maxDbID); + Logger::getLogger()->debug("loadAssetReadingCatalogue - thread '%s' reading Id %d dbId %d asset name '%s' max db Id %d", threadId.str().c_str(), tableId, dbId, asset_name, maxDbID); auto newItem = make_pair(tableId,dbId); auto newMapValue = make_pair(asset_name,newItem); m_AssetReadingCatalogue.insert(newMapValue); + if (tableId == 0 && dbId > m_maxOverflowUsed) // Overflow + { + m_maxOverflowUsed = dbId; + } } @@ -441,7 +498,7 @@ bool ReadingsCatalogue::loadAssetReadingCatalogue() manager->release(connection); m_dbIdCurrent = maxDbID; - Logger::getLogger()->debug("loadAssetReadingCatalogue maxdb :%d:", m_dbIdCurrent); + Logger::getLogger()->debug("loadAssetReadingCatalogue maxdb %d", m_dbIdCurrent); return true; } @@ -450,23 +507,24 @@ bool ReadingsCatalogue::loadAssetReadingCatalogue() * Add the newly create db to the list * */ -void ReadingsCatalogue::setUsedDbId(int dbId) { - +void ReadingsCatalogue::setUsedDbId(int dbId) +{ m_dbIdList.push_back(dbId); } /** - * Preallocate all the needed database: + * Preallocate all the required databases: * * - Initial stage - creates the databases requested by the preallocation * - Following runs - attaches all the databases already created * */ -void ReadingsCatalogue::prepareAllDbs() { +void ReadingsCatalogue::prepareAllDbs() +{ int dbId, dbIdStart, dbIdEnd; - Logger::getLogger()->debug("prepareAllDbs - dbIdCurrent :%d: dbIdLast :%d: nDbPreallocate :%d:", m_dbIdCurrent, m_dbIdLast, m_storageConfigCurrent.nDbPreallocate); + Logger::getLogger()->debug("prepareAllDbs - dbIdCurrent %d dbIdLast %d nDbPreallocate %d", m_dbIdCurrent, m_dbIdLast, m_storageConfigCurrent.nDbPreallocate); if (m_dbIdLast == 0) { @@ -476,10 +534,13 @@ void ReadingsCatalogue::prepareAllDbs() { dbIdStart = 2; dbIdEnd = dbIdStart + m_storageConfigCurrent.nDbPreallocate - 2; - preallocateNewDbsRange(dbIdStart, dbIdEnd); - - m_dbIdLast = dbIdEnd; - } else + int created = preallocateNewDbsRange(dbIdStart, dbIdEnd); + if (created) + { + m_dbIdLast = dbIdStart + created - 1; + } + } + else { Logger::getLogger()->debug("prepareAllDbs - following runs"); @@ -493,7 +554,7 @@ void ReadingsCatalogue::prepareAllDbs() { m_dbNAvailable = (m_dbIdLast - m_dbIdCurrent) - m_storageConfigCurrent.nDbLeftFreeBeforeAllocate; - Logger::getLogger()->debug("prepareAllDbs - dbNAvailable :%d:", m_dbNAvailable); + Logger::getLogger()->debug("prepareAllDbs - dbNAvailable %d", m_dbNAvailable); } /** @@ -501,33 +562,46 @@ void ReadingsCatalogue::prepareAllDbs() { * * @param dbIdStart Range of the database to create * @param dbIdEnd Range of the database to create + * @return int The number of datbases created * */ -void ReadingsCatalogue::preallocateNewDbsRange(int dbIdStart, int dbIdEnd) { +int ReadingsCatalogue::preallocateNewDbsRange(int dbIdStart, int dbIdEnd) { int dbId; int startReadingsId; tyReadingsAvailable readingsAvailable; + int created = 0; - Logger::getLogger()->debug("preallocateNewDbsRange - Id start :%d: Id end :%d: ", dbIdStart, dbIdEnd); + Logger::getLogger()->debug("preallocateNewDbsRange - Id start %d Id end %d ", dbIdStart, dbIdEnd); for (dbId = dbIdStart; dbId <= dbIdEnd; dbId++) { readingsAvailable = evaluateLastReadingAvailable(NULL, dbId - 1); startReadingsId = 1; - createNewDB(NULL, dbId, startReadingsId, NEW_DB_ATTACH_ALL); + if (!createNewDB(NULL, dbId, startReadingsId, NEW_DB_ATTACH_ALL)) + { + Logger::getLogger()->error("Failed to preallocated all databases, terminated after creating %d databases", created); + break; + } + else + { + created++; + } - Logger::getLogger()->debug("preallocateNewDbsRange - db created :%d: startReadingsIdOnDB :%d:", dbId, startReadingsId); + Logger::getLogger()->debug("preallocateNewDbsRange - db created %d startReadingsIdOnDB %d", dbId, startReadingsId); } + return created; } /** - * Generates a list of all the used databases + * Generates a list of all the used databases. Note this list does not include + * the first database, readings_1, onl the ohtes that have been added. * * @param dbIdList returned by reference, the list databases in use * */ -void ReadingsCatalogue::getAllDbs(vector &dbIdList) { +void ReadingsCatalogue::getAllDbs(vector &dbIdList) +{ int dbId; @@ -541,7 +615,7 @@ void ReadingsCatalogue::getAllDbs(vector &dbIdList) { if (std::find(dbIdList.begin(), dbIdList.end(), dbId) == dbIdList.end() ) { dbIdList.push_back(dbId); - Logger::getLogger()->debug("getAllDbs DB :%d:", dbId); + Logger::getLogger()->debug("getAllDbs DB %d", dbId); } } @@ -554,7 +628,7 @@ void ReadingsCatalogue::getAllDbs(vector &dbIdList) { if (std::find(dbIdList.begin(), dbIdList.end(), dbId) == dbIdList.end() ) { dbIdList.push_back(dbId); - Logger::getLogger()->debug("getAllDbs DB created :%d:", dbId); + Logger::getLogger()->debug("getAllDbs DB created %d", dbId); } } @@ -562,7 +636,7 @@ void ReadingsCatalogue::getAllDbs(vector &dbIdList) { } /** - * Retrieve the list of newly created db + * Retrieve the list of newly created databases * * @param dbIdList returned by reference, the list of new databases * @@ -576,7 +650,7 @@ void ReadingsCatalogue::getNewDbs(vector &dbIdList) { if (std::find(dbIdList.begin(), dbIdList.end(), dbId) == dbIdList.end() ) { dbIdList.push_back(dbId); - Logger::getLogger()->debug("getNewDbs - dbId :%d:", dbId); + Logger::getLogger()->debug("getNewDbs - dbId %d", dbId); } } @@ -584,7 +658,8 @@ void ReadingsCatalogue::getNewDbs(vector &dbIdList) { } /** - * Enable WAL on the provided database file + * Enable WAL mode on the given database file. This method will open and then + * close the database and does not use any existing connection. * * @param dbPathReadings Database path for which the WAL must be enabled * @@ -594,7 +669,7 @@ bool ReadingsCatalogue::enableWAL(string &dbPathReadings) { int rc; sqlite3 *dbHandle; - Logger::getLogger()->debug("enableWAL on :%s:", dbPathReadings.c_str()); + Logger::getLogger()->debug("enableWAL on '%s'", dbPathReadings.c_str()); rc = sqlite3_open(dbPathReadings.c_str(), &dbHandle); if(rc != SQLITE_OK) @@ -617,33 +692,42 @@ bool ReadingsCatalogue::enableWAL(string &dbPathReadings) { } /** - * Attach a database to all the connections, idle and inuse + * Attach a database to the database connection passed to the call * * @param dbHandle Database connection to use for the operations * @param path path of the database to attach * @param alias alias to be assigned to the attached database + * @param id the database ID */ -bool ReadingsCatalogue::attachDb(sqlite3 *dbHandle, std::string &path, std::string &alias) +bool ReadingsCatalogue::attachDb(sqlite3 *dbHandle, std::string &path, std::string &alias, int id) { - int rc; - std::string sqlCmd; - bool result; - char *zErrMsg = NULL; - - result = true; +int rc; +string sqlCmd; +bool result = true; +char *zErrMsg = NULL; sqlCmd = "ATTACH DATABASE '" + path + "' AS " + alias + ";"; - Logger::getLogger()->debug("attachDb - path :%s: alias :%s: cmd :%s:" , path.c_str(), alias.c_str() , sqlCmd.c_str() ); + Logger::getLogger()->debug("attachDb - path '%s' alias '%s' cmd '%s'" , path.c_str(), alias.c_str() , sqlCmd.c_str() ); rc = SQLExec (dbHandle, sqlCmd.c_str(), &zErrMsg); if (rc != SQLITE_OK) { - Logger::getLogger()->error("attachDb - It was not possible to attach the db :%s: to the connection :%X:, error :%s:", path.c_str(), dbHandle, zErrMsg); + Logger::getLogger()->error("Failed to attach the db '%s' to the connection %X, error '%s'", path.c_str(), dbHandle, zErrMsg); sqlite3_free(zErrMsg); result = false; } - return (result); + // See if the overflow table exists and if not create it + // This is a workaround as the schema update mechanism can't cope + // with multiple readings tables + sqlCmd = "select count(*) from " + alias + ".readings_overflow;"; + rc = SQLExec(dbHandle, sqlCmd.c_str(), &zErrMsg); + if (rc != SQLITE_OK) + { + createReadingsOverflowTable(dbHandle, id); + } + + return result; } /** @@ -660,11 +744,11 @@ void ReadingsCatalogue::detachDb(sqlite3 *dbHandle, std::string &alias) sqlCmd = "DETACH DATABASE " + alias + ";"; - Logger::getLogger()->debug("%s - db :%s: cmd :%s:" ,__FUNCTION__, alias.c_str() , sqlCmd.c_str() ); + Logger::getLogger()->debug("%s - db '%s' cmd '%s'" ,__FUNCTION__, alias.c_str() , sqlCmd.c_str() ); rc = SQLExec (dbHandle, sqlCmd.c_str(), &zErrMsg); if (rc != SQLITE_OK) { - Logger::getLogger()->error("%s - It was not possible to detach the db :%s: from the connection :%X:, error :%s:", __FUNCTION__, alias.c_str(), dbHandle, zErrMsg); + Logger::getLogger()->error("%s - It was not possible to detach the db '%s' from the connection %X, error '%s'", __FUNCTION__, alias.c_str(), dbHandle, zErrMsg); sqlite3_free(zErrMsg); } } @@ -688,25 +772,27 @@ bool ReadingsCatalogue::connectionAttachDbList(sqlite3 *dbHandle, vector &d result = true; - Logger::getLogger()->debug("connectionAttachDbList - start dbHandle :%X:" ,dbHandle); + Logger::getLogger()->debug("connectionAttachDbList - start dbHandle %X" ,dbHandle); - while (!dbIdList.empty()) + while (result && !dbIdList.empty()) { item = dbIdList.back(); dbPathReadings = generateDbFilePah(item); dbAlias = generateDbAlias(item); - Logger::getLogger()->debug("connectionAttachDbList - dbHandle :%X: dbId :%d: path :%s: alias :%s:",dbHandle, item, dbPathReadings.c_str(), dbAlias.c_str()); + Logger::getLogger()->debug( + "connectionAttachDbList - dbHandle %X dbId %d path %s alias %s", + dbHandle, item, dbPathReadings.c_str(), dbAlias.c_str()); - result = attachDb(dbHandle, dbPathReadings, dbAlias); + result = attachDb(dbHandle, dbPathReadings, dbAlias, item); dbIdList.pop_back(); } - Logger::getLogger()->debug("connectionAttachDbList - end dbHandle :%X:" ,dbHandle); + Logger::getLogger()->debug("connectionAttachDbList - end dbHandle %X" ,dbHandle); - return (result); + return result; } @@ -734,13 +820,16 @@ bool ReadingsCatalogue::connectionAttachAllDbs(sqlite3 *dbHandle) dbPathReadings = generateDbFilePah(item); dbAlias = generateDbAlias(item); - result = attachDb(dbHandle, dbPathReadings, dbAlias); + result = attachDb(dbHandle, dbPathReadings, dbAlias, item); if (! result) + { + Logger::getLogger()->error("Unable to attach all databases to the connection"); break; + } - Logger::getLogger()->debug("connectionAttachAllDbs - dbId :%d: path :%s: alias :%s:", item, dbPathReadings.c_str(), dbAlias.c_str()); + Logger::getLogger()->debug("connectionAttachAllDbs - dbId %d path %s alias %s", item, dbPathReadings.c_str(), dbAlias.c_str()); } - return (result); + return result; } @@ -762,10 +851,14 @@ bool ReadingsCatalogue::attachDbsToAllConnections() ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Attach DBs to all connections"; + connection->setUsage(usage); +#endif getAllDbs(dbIdList); - for(int item : dbIdList) + for (int item : dbIdList) { dbPathReadings = generateDbFilePah(item); dbAlias = generateDbAlias(item); @@ -776,7 +869,7 @@ bool ReadingsCatalogue::attachDbsToAllConnections() if (! result) break; - Logger::getLogger()->debug("attachDbsToAllConnections - dbId :%d: path :%s: alias :%s:", item, dbPathReadings.c_str(), dbAlias.c_str()); + Logger::getLogger()->debug("attachDbsToAllConnections - dbId %d path '%s' alias '%s'", item, dbPathReadings.c_str(), dbAlias.c_str()); } manager->release(connection); @@ -796,13 +889,22 @@ void ReadingsCatalogue::multipleReadingsInit(STORAGE_CONFIGURATION &storageConfi ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Multiple readings init"; + connection->setUsage(usage); +#endif if (! connection->supportsReadings()) { + manager->release(connection); return; } dbHandle = connection->getDbHandle(); - + // Enquire for the attached database limit + m_attachLimit = sqlite3_limit(dbHandle, SQLITE_LIMIT_ATTACHED, -1); + Logger::getLogger()->info("The version of SQLite can support %d attached databases", + m_attachLimit); + m_compounds = sqlite3_limit(dbHandle, SQLITE_LIMIT_COMPOUND_SELECT, -1); if (storageConfig.nDbLeftFreeBeforeAllocate < 1) { @@ -830,14 +932,14 @@ void ReadingsCatalogue::multipleReadingsInit(STORAGE_CONFIGURATION &storageConfi loadAssetReadingCatalogue(); preallocateReadingsTables(1); // on the first database - Logger::getLogger()->debug("nReadingsPerDb :%d:", m_storageConfigCurrent.nReadingsPerDb); - Logger::getLogger()->debug("nDbPreallocate :%d:", m_storageConfigCurrent.nDbPreallocate); + Logger::getLogger()->debug("nReadingsPerDb %d", m_storageConfigCurrent.nReadingsPerDb); + Logger::getLogger()->debug("nDbPreallocate %d", m_storageConfigCurrent.nDbPreallocate); prepareAllDbs(); applyStorageConfigChanges(dbHandle); - Logger::getLogger()->debug("multipleReadingsInit - dbIdCurrent :%d: dbIdLast :%d: nDbPreallocate current :%d: requested :%d:", + Logger::getLogger()->debug("multipleReadingsInit - dbIdCurrent %d dbIdLast %d nDbPreallocate current %d requested %d", m_dbIdCurrent, m_dbIdLast, m_storageConfigCurrent.nDbPreallocate, @@ -854,7 +956,7 @@ void ReadingsCatalogue::multipleReadingsInit(STORAGE_CONFIGURATION &storageConfi } catch (exception& e) { - Logger::getLogger()->error("It is not possible to initialize the multiple readings handling, error :%s: ", e.what()); + Logger::getLogger()->error("It is not possible to initialize the multiple readings handling, error '%s' ", e.what()); } manager->release(connection); @@ -872,13 +974,13 @@ void ReadingsCatalogue::storeReadingsConfiguration (sqlite3 *dbHandle) string errMsg; string sql_cmd; - Logger::getLogger()->debug("storeReadingsConfiguration - nReadingsPerDb :%d: nDbPreallocate :%d:", m_storageConfigCurrent.nReadingsPerDb , m_storageConfigCurrent.nDbPreallocate); + Logger::getLogger()->debug("storeReadingsConfiguration - nReadingsPerDb %d nDbPreallocate %d", m_storageConfigCurrent.nReadingsPerDb , m_storageConfigCurrent.nDbPreallocate); sql_cmd = " UPDATE " READINGS_DB ".configuration_readings SET n_readings_per_db=" + to_string(m_storageConfigCurrent.nReadingsPerDb) + "," + "n_db_preallocate=" + to_string(m_storageConfigCurrent.nDbPreallocate) + "," + "db_id_Last=" + to_string(m_dbIdLast) + ";"; - Logger::getLogger()->debug("sql_cmd :%s:", sql_cmd.c_str()); + Logger::getLogger()->debug("sql_cmd '%s'", sql_cmd.c_str()); if (SQLExec(dbHandle, sql_cmd.c_str()) != SQLITE_OK) { @@ -906,13 +1008,14 @@ void ReadingsCatalogue::configChangeAddDb(sqlite3 *dbHandle) startId = m_dbIdLast +1; endId = m_storageConfigApi.nDbPreallocate; - Logger::getLogger()->debug("configChangeAddDb - dbIdCurrent :%d: dbIdLast :%d: nDbPreallocate current :%d: requested :%d:", + Logger::getLogger()->debug("configChangeAddDb - dbIdCurrent %d dbIdLast %d nDbPreallocate current %d requested %d", m_dbIdCurrent, m_dbIdLast, m_storageConfigCurrent.nDbPreallocate, m_storageConfigApi.nDbPreallocate); - Logger::getLogger()->debug("configChangeAddDb - Id start :%d: Id end :%d: ", startId, endId); + Logger::getLogger()->debug("configChangeAddDb - Id start %d Id end %d ", startId, endId); + int created = 0; try { @@ -931,16 +1034,20 @@ void ReadingsCatalogue::configChangeAddDb(sqlite3 *dbHandle) errMsg = "Unable to add a new database"; throw runtime_error(errMsg.c_str()); } - Logger::getLogger()->debug("configChangeAddDb - db created :%d: startReadingsIdOnDB :%d:", dbId, startReadingsId); + else + { + created++; + } + Logger::getLogger()->debug("configChangeAddDb - db created %d startReadingsIdOnDB %d", dbId, startReadingsId); } } catch (exception& e) { - Logger::getLogger()->error("It is not possible to add the requested databases, error :%s: - removing created databases", e.what()); + Logger::getLogger()->error("It is not possible to add the requested databases, error '%s' - removing created databases", e.what()); dbsRemove(startId , endId); } - m_dbIdLast = m_storageConfigApi.nDbPreallocate; + m_dbIdLast = startId + created - 1; m_storageConfigCurrent.nDbPreallocate = m_storageConfigApi.nDbPreallocate; m_dbNAvailable = (m_dbIdLast - m_dbIdCurrent) - m_storageConfigCurrent.nDbLeftFreeBeforeAllocate; } @@ -962,14 +1069,14 @@ void ReadingsCatalogue::configChangeRemoveDb(sqlite3 *dbHandle) ConnectionManager *manager = ConnectionManager::getInstance(); - Logger::getLogger()->debug("configChangeRemoveDb - dbIdCurrent :%d: dbIdLast :%d: nDbPreallocate current :%d: requested :%d:", + Logger::getLogger()->debug("configChangeRemoveDb - dbIdCurrent %d dbIdLast %d nDbPreallocate current %d requested %d", m_dbIdCurrent, m_dbIdLast, m_storageConfigCurrent.nDbPreallocate, m_storageConfigApi.nDbPreallocate); - Logger::getLogger()->debug("configChangeRemoveDb - Id start :%d: Id end :%d: ", m_dbIdCurrent, m_storageConfigApi.nDbPreallocate); + Logger::getLogger()->debug("configChangeRemoveDb - Id start %d Id end %d ", m_dbIdCurrent, m_storageConfigApi.nDbPreallocate); dbsRemove(m_storageConfigApi.nDbPreallocate + 1, m_dbIdLast); @@ -996,7 +1103,7 @@ void ReadingsCatalogue::configChangeAddTables(sqlite3 *dbHandle, int startId, in nTables = endId - startId +1; - Logger::getLogger()->debug("%s - startId :%d: endId :%d: nTables :%d:", + Logger::getLogger()->debug("%s - startId %d endId %d nTables %d", __FUNCTION__, startId, endId, @@ -1004,7 +1111,7 @@ void ReadingsCatalogue::configChangeAddTables(sqlite3 *dbHandle, int startId, in for (dbId = 1; dbId <= m_dbIdLast ; dbId++ ) { - Logger::getLogger()->debug("%s - configChangeAddTables - dbId :%d: startId :%d: nTables :%d:", + Logger::getLogger()->debug("%s - configChangeAddTables - dbId %d startId %d nTables %d", __FUNCTION__, dbId, startId, @@ -1016,7 +1123,7 @@ void ReadingsCatalogue::configChangeAddTables(sqlite3 *dbHandle, int startId, in maxReadingUsed = calcMaxReadingUsed(); m_nReadingsAvailable = m_storageConfigCurrent.nReadingsPerDb - maxReadingUsed; - Logger::getLogger()->debug("%s - maxReadingUsed :%d: nReadingsPerDb :%d: m_nReadingsAvailable :%d:", + Logger::getLogger()->debug("%s - maxReadingUsed %d nReadingsPerDb %d m_nReadingsAvailable %d", __FUNCTION__, maxReadingUsed, m_storageConfigCurrent.nReadingsPerDb, @@ -1036,14 +1143,14 @@ void ReadingsCatalogue::configChangeRemoveTables(sqlite3 *dbHandle, int startId, int dbId; int maxReadingUsed; - Logger::getLogger()->debug("%s - startId :%d: endId :%d:", + Logger::getLogger()->debug("%s - startId %d endId %d", __FUNCTION__, startId, endId); for (dbId = 1; dbId <= m_dbIdLast ; dbId++ ) { - Logger::getLogger()->debug("%s - configChangeRemoveTables - dbId :%d: startId :%d: endId :%d:", + Logger::getLogger()->debug("%s - configChangeRemoveTables - dbId %d startId %d endId %d", __FUNCTION__, dbId, startId, @@ -1055,7 +1162,7 @@ void ReadingsCatalogue::configChangeRemoveTables(sqlite3 *dbHandle, int startId, maxReadingUsed = calcMaxReadingUsed(); m_nReadingsAvailable = m_storageConfigCurrent.nReadingsPerDb - maxReadingUsed; - Logger::getLogger()->debug("%s - maxReadingUsed :%d: nReadingsPerDb :%d: m_nReadingsAvailable :%d:", + Logger::getLogger()->debug("%s - maxReadingUsed %d nReadingsPerDb %d m_nReadingsAvailable %d", __FUNCTION__, maxReadingUsed, m_storageConfigCurrent.nReadingsPerDb, @@ -1082,7 +1189,7 @@ void ReadingsCatalogue::dropReadingsTables(sqlite3 *dbHandle, int dbId, int idS int idx; bool newConnection; - Logger::getLogger()->debug("%s - dropping tales on database id :%d:form id :%d: to :%d:", __FUNCTION__, dbId, idStart, idEnd); + Logger::getLogger()->debug("%s - dropping tales on database id %dform id %d to %d", __FUNCTION__, dbId, idStart, idEnd); dbName = generateDbName(dbId); @@ -1131,14 +1238,14 @@ void ReadingsCatalogue::dbsRemove(int startId, int endId) ConnectionManager *manager = ConnectionManager::getInstance(); - Logger::getLogger()->debug("dbsRemove - startId :%d: endId :%d:", startId, endId); + Logger::getLogger()->debug("dbsRemove - startId %d endId %d", startId, endId); for (dbId = startId; dbId <= endId; dbId++) { dbAlias = generateDbAlias(dbId); dbPath = generateDbFilePah(dbId); - Logger::getLogger()->debug("dbsRemove - db alias :%s: db path :%s:", dbAlias.c_str(), dbPath.c_str()); + Logger::getLogger()->debug("dbsRemove - db alias '%s' db path '%s'", dbAlias.c_str(), dbPath.c_str()); manager->detachNewDb(dbAlias); dbFileDelete(dbPath); @@ -1156,7 +1263,7 @@ void ReadingsCatalogue::dbFileDelete(string dbPath) string errMsg; bool success; - Logger::getLogger()->debug("dbFileDelete - db path :%s:", dbPath.c_str()); + Logger::getLogger()->debug("dbFileDelete - db path '%s'", dbPath.c_str()); if (remove (dbPath.c_str()) !=0) { @@ -1179,7 +1286,7 @@ bool ReadingsCatalogue::applyStorageConfigChanges(sqlite3 *dbHandle) configChanged = false; - Logger::getLogger()->debug("applyStorageConfigChanges - dbIdCurrent :%d: dbIdLast :%d: nDbPreallocate current :%d: requested :%d: nDbLeftFreeBeforeAllocate :%d:", + Logger::getLogger()->debug("applyStorageConfigChanges - dbIdCurrent %d dbIdLast %d nDbPreallocate current %d requested %d nDbLeftFreeBeforeAllocate %d", m_dbIdCurrent, m_dbIdLast, m_storageConfigCurrent.nDbPreallocate, @@ -1202,17 +1309,17 @@ bool ReadingsCatalogue::applyStorageConfigChanges(sqlite3 *dbHandle) { if (operation == ACTION_DB_ADD) { - Logger::getLogger()->debug("applyStorageConfigChanges - parameters nDbPreallocate changed, adding more databases from :%d: to :%d:", m_dbIdLast, m_storageConfigApi.nDbPreallocate); + Logger::getLogger()->debug("applyStorageConfigChanges - parameters nDbPreallocate changed, adding more databases from %d to %d", m_dbIdLast, m_storageConfigApi.nDbPreallocate); configChanged = true; configChangeAddDb(dbHandle); } else if (operation == ACTION_INVALID) { - Logger::getLogger()->warn("applyStorageConfigChanges: parameter nDbPreallocate changed, but it is not possible to apply the change as there are already data stored in the database id :%d:, use a larger value", m_dbIdCurrent); + Logger::getLogger()->warn("applyStorageConfigChanges: parameter nDbPreallocate changed, but it is not possible to apply the change as there are already data stored in the database id %d, use a larger value", m_dbIdCurrent); } else if (operation == ACTION_DB_REMOVE) { - Logger::getLogger()->debug("applyStorageConfigChanges - parameters nDbPreallocate changed, removing databases from :%d: to :%d:", m_storageConfigApi.nDbPreallocate, m_dbIdLast); + Logger::getLogger()->debug("applyStorageConfigChanges - parameters nDbPreallocate changed, removing databases from %d to %d", m_storageConfigApi.nDbPreallocate, m_dbIdLast); configChanged = true; configChangeRemoveDb(dbHandle); } else @@ -1232,7 +1339,7 @@ bool ReadingsCatalogue::applyStorageConfigChanges(sqlite3 *dbHandle) m_storageConfigCurrent.nReadingsPerDb, m_storageConfigApi.nReadingsPerDb); - Logger::getLogger()->debug("%s - maxReadingUsed :%d: Current :%d: Requested :%d:", + Logger::getLogger()->debug("%s - maxReadingUsed %d Current %d Requested %d", __FUNCTION__, maxReadingUsed, m_storageConfigCurrent.nReadingsPerDb, @@ -1247,13 +1354,13 @@ bool ReadingsCatalogue::applyStorageConfigChanges(sqlite3 *dbHandle) startId = m_storageConfigCurrent.nReadingsPerDb +1; endId = m_storageConfigApi.nReadingsPerDb; - Logger::getLogger()->debug("applyStorageConfigChanges - parameters nReadingsPerDb changed, adding more tables from :%d: to :%d:", startId, endId); + Logger::getLogger()->debug("applyStorageConfigChanges - parameters nReadingsPerDb changed, adding more tables from %d to %d", startId, endId); configChanged = true; configChangeAddTables(dbHandle, startId, endId); } else if (operation == ACTION_INVALID) { - Logger::getLogger()->warn("applyStorageConfigChanges: parameter nReadingsPerDb changed, but it is not possible to apply the change as there are already data stored in the table id :%d:, use a larger value", maxReadingUsed); + Logger::getLogger()->warn("applyStorageConfigChanges: parameter nReadingsPerDb changed, but it is not possible to apply the change as there are already data stored in the table id %d, use a larger value", maxReadingUsed); } else if (operation == ACTION_TB_REMOVE) { @@ -1262,7 +1369,7 @@ bool ReadingsCatalogue::applyStorageConfigChanges(sqlite3 *dbHandle) startId = m_storageConfigApi.nReadingsPerDb +1; endId = m_storageConfigCurrent.nReadingsPerDb; - Logger::getLogger()->debug("applyStorageConfigChanges - parameters nReadingsPerDb changed, removing tables from :%d: to :%d:", m_storageConfigApi.nReadingsPerDb +1, m_storageConfigCurrent.nReadingsPerDb); + Logger::getLogger()->debug("applyStorageConfigChanges - parameters nReadingsPerDb changed, removing tables from %d to %d", m_storageConfigApi.nReadingsPerDb +1, m_storageConfigCurrent.nReadingsPerDb); configChanged = true; configChangeRemoveTables(dbHandle, startId, endId); } else @@ -1279,7 +1386,7 @@ bool ReadingsCatalogue::applyStorageConfigChanges(sqlite3 *dbHandle) } catch (exception& e) { - Logger::getLogger()->error("It is not possible to apply the chnages to the multi readings handling, error :%s: ", e.what()); + Logger::getLogger()->error("It is not possible to apply the chnages to the multi readings handling, error '%s' ", e.what()); } return configChanged; @@ -1293,11 +1400,10 @@ bool ReadingsCatalogue::applyStorageConfigChanges(sqlite3 *dbHandle) */ int ReadingsCatalogue::calcMaxReadingUsed() { - int maxReading; - maxReading = 0; - - for (auto &item : m_AssetReadingCatalogue) { + int maxReading = 0; + for (auto &item : m_AssetReadingCatalogue) + { if (item.second.first > maxReading) maxReading = item.second.first; } @@ -1319,7 +1425,7 @@ ReadingsCatalogue::ACTION ReadingsCatalogue::changesLogicTables(int maxUsed ,in { ACTION operation; - Logger::getLogger()->debug("%s - maxUsed :%d: Request :%d: Request current :%d:", + Logger::getLogger()->debug("%s - maxUsed %d Request %d Request current %d", __FUNCTION__, maxUsed, Current, @@ -1384,7 +1490,7 @@ ReadingsCatalogue::ACTION ReadingsCatalogue::changesLogicDBs(int dbIdCurrent , i /** - * Creates all the needed readings tables considering the tables already defined in the database + * Creates all the required readings tables considering the tables already defined in the database * and the number of tables to have on each database. * * @param dbId Database Id in which the table must be created @@ -1417,13 +1523,12 @@ void ReadingsCatalogue::preallocateReadingsTables(int dbId) startId = 2; else startId = 1; - createReadingsTables(NULL, dbId, startId, readingsToCreate); } m_nReadingsAvailable = readingsToAllocate - getUsedTablesDbId(dbId); - Logger::getLogger()->debug("preallocateReadingsTables - dbId :%d: nReadingsAvailable :%d: lastReadingsCreated :%d: tableCount :%d:", m_dbIdCurrent, m_nReadingsAvailable, readingsAvailable.lastReadings, readingsAvailable.tableCount); + Logger::getLogger()->debug("preallocateReadingsTables - dbId %d nReadingsAvailable %d lastReadingsCreated %d tableCount %d", m_dbIdCurrent, m_nReadingsAvailable, readingsAvailable.lastReadings, readingsAvailable.tableCount); } /** @@ -1473,7 +1578,7 @@ bool ReadingsCatalogue::latestDbUpdate(sqlite3 *dbHandle, int newDbId) { string sql_cmd; - Logger::getLogger()->debug("latestDbUpdate - dbHandle :%X: newDbId :%d:", dbHandle, newDbId); + Logger::getLogger()->debug("latestDbUpdate - dbHandle %X newDbId %d", dbHandle, newDbId); { sql_cmd = " UPDATE " READINGS_DB ".configuration_readings SET db_id_Last=" + to_string(newDbId) + ";"; @@ -1521,32 +1626,40 @@ bool ReadingsCatalogue::createNewDB(sqlite3 *dbHandle, int newDbId, int startId ConnectionManager *manager = ConnectionManager::getInstance(); + // Are there enough descriptors available to create another database + if (!manager->allowMoreDatabases()) + { + return false; + } + if (dbHandle == NULL) { connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Create New database"; + connection->setUsage(usage); +#endif dbHandle = connection->getDbHandle(); connAllocated = true; } // Creates the DB data file - { - dbPathReadings = generateDbFilePah(newDbId); + dbPathReadings = generateDbFilePah(newDbId); - dbAlreadyPresent = false; - if(stat(dbPathReadings.c_str(),&st) == 0) - { - Logger::getLogger()->info("createNewDB - database file :%s: already present, creation skipped " , dbPathReadings.c_str() ); - dbAlreadyPresent = true; - } - else - { - Logger::getLogger()->debug("createNewDB - new database created :%s:", dbPathReadings.c_str()); - } - enableWAL(dbPathReadings); + dbAlreadyPresent = false; + if(stat(dbPathReadings.c_str(),&st) == 0) + { + Logger::getLogger()->info("createNewDB - database file '%s' already present, creation skipped " , dbPathReadings.c_str() ); + dbAlreadyPresent = true; + } + else + { + Logger::getLogger()->debug("createNewDB - new database created '%s'", dbPathReadings.c_str()); + } + enableWAL(dbPathReadings); - latestDbUpdate(dbHandle, newDbId); + latestDbUpdate(dbHandle, newDbId); - } readingsToAllocate = getNReadingsAllocate(); readingsToCreate = readingsToAllocate; @@ -1562,13 +1675,13 @@ bool ReadingsCatalogue::createNewDB(sqlite3 *dbHandle, int newDbId, int startId { Logger::getLogger()->debug("createNewDB - attach single"); - result = attachDb(dbHandle, dbPathReadings, dbAlias); + result = attachDb(dbHandle, dbPathReadings, dbAlias, newDbId); result = manager->attachRequestNewDb(newDbId, dbHandle); } else if (attachAllDb == NEW_DB_DETACH) { Logger::getLogger()->debug("createNewDB - attach"); - result = attachDb(dbHandle, dbPathReadings, dbAlias); + result = attachDb(dbHandle, dbPathReadings, dbAlias, newDbId); } if (result) @@ -1581,14 +1694,14 @@ bool ReadingsCatalogue::createNewDB(sqlite3 *dbHandle, int newDbId, int startId if (readingsAvailable.lastReadings == -1) { - Logger::getLogger()->error("createNewDB - database file :%s: is already present but it is not possible to evaluate the readings table already present" , dbPathReadings.c_str() ); + Logger::getLogger()->error("createNewDB - database file '%s' is already present but it is not possible to evaluate the readings table already present" , dbPathReadings.c_str() ); result = false; } else { readingsToCreate = readingsToAllocate - readingsAvailable.tableCount; startId = readingsAvailable.lastReadings +1; - Logger::getLogger()->info("createNewDB - database file :%s: is already present, creating readings tables - from id :%d: n :%d: " , dbPathReadings.c_str(), startId, readingsToCreate); + Logger::getLogger()->info("createNewDB - database file '%s' is already present, creating readings tables - from id %d n %d " , dbPathReadings.c_str(), startId, readingsToCreate); } } @@ -1597,11 +1710,14 @@ bool ReadingsCatalogue::createNewDB(sqlite3 *dbHandle, int newDbId, int startId startId = 1; createReadingsTables(dbHandle, newDbId ,startId, readingsToCreate); - Logger::getLogger()->info("createNewDB - database file :%s: created readings table - from id :%d: n :%d: " , dbPathReadings.c_str(), startId, readingsToCreate); + Logger::getLogger()->info("createNewDB - database file '%s' created readings table - from id %d n %d " , dbPathReadings.c_str(), startId, readingsToCreate); } m_nReadingsAvailable = readingsToAllocate; } + // Create the overflow table in the new database + createReadingsOverflowTable(dbHandle, newDbId); + if (attachAllDb == NEW_DB_DETACH) { Logger::getLogger()->debug("createNewDB - deattach"); @@ -1644,11 +1760,15 @@ bool ReadingsCatalogue::createReadingsTables(sqlite3 *dbHandle, int dbId, int i if (dbHandle == NULL) { connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Create Readings Tables"; + connection->setUsage(usage); +#endif dbHandle = connection->getDbHandle(); newConnection = true; } - logger->info("Creating :%d: readings table in advance starting id :%d:", nTables, idStartFrom); + logger->info("Creating %d readings table in advance starting id %d", nTables, idStartFrom); dbName = generateDbName(dbId); @@ -1658,7 +1778,7 @@ bool ReadingsCatalogue::createReadingsTables(sqlite3 *dbHandle, int dbId, int i dbReadingsName = generateReadingsName(dbId, tableId); createReadings = R"( - CREATE TABLE )" + dbName + "." + dbReadingsName + R"( ( + CREATE TABLE IF NOT EXISTS )" + dbName + "." + dbReadingsName + R"( ( id INTEGER PRIMARY KEY AUTOINCREMENT, reading JSON NOT NULL DEFAULT '{}', user_ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')), @@ -1667,15 +1787,19 @@ bool ReadingsCatalogue::createReadingsTables(sqlite3 *dbHandle, int dbId, int i )"; createReadingsIdx = R"( - CREATE INDEX )" + dbName + "." + dbReadingsName + R"(_ix3 ON )" + dbReadingsName + R"( (user_ts); + CREATE INDEX IF NOT EXISTS )" + dbName + "." + dbReadingsName + R"(_ix3 ON )" + dbReadingsName + R"( (user_ts); )"; - logger->info(" Creating table :%s: sql cmd :%s:", dbReadingsName.c_str(), createReadings.c_str()); + logger->info(" Creating table '%s' sql cmd '%s'", dbReadingsName.c_str(), createReadings.c_str()); rc = SQLExec(dbHandle, createReadings.c_str()); if (rc != SQLITE_OK) { raiseError("createReadingsTables", sqlite3_errmsg(dbHandle)); + if (newConnection) + { + manager->release(connection); + } return false; } @@ -1683,6 +1807,10 @@ bool ReadingsCatalogue::createReadingsTables(sqlite3 *dbHandle, int dbId, int i if (rc != SQLITE_OK) { raiseError("createReadingsTables", sqlite3_errmsg(dbHandle)); + if (newConnection) + { + manager->release(connection); + } return false; } } @@ -1694,6 +1822,82 @@ bool ReadingsCatalogue::createReadingsTables(sqlite3 *dbHandle, int dbId, int i return true; } +/** + * Create the overflow reading tables in the given database id + * + * We should only do this once when we upgrade to the version with an + * overflow table. Although this should ideally be done in the schema + * update script we can't do this as we can not loop over all the + * databases in that script. + * + * @param dbHandle Database connection to use for the operation + * + */ +bool ReadingsCatalogue::createReadingsOverflowTable(sqlite3 *dbHandle, int dbId) +{ + string dbReadingsName; + + Logger *logger = Logger::getLogger(); + + ConnectionManager *manager = ConnectionManager::getInstance(); + + string dbName = generateDbName(dbId); + logger->info("Creating reading overflow table for database '%s'", dbName.c_str()); + + dbReadingsName = string(READINGS_TABLE) + "_" + to_string(dbId); + dbReadingsName.append("_overflow"); + + string createReadings = R"( + CREATE TABLE IF NOT EXISTS )" + dbName + "." + dbReadingsName + R"( ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_code CHARACTER varying(50) NOT NULL, + reading JSON NOT NULL DEFAULT '{}', + user_ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')), + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) + ); + )"; + + string createReadingsIdx1 = R"( + CREATE INDEX IF NOT EXISTS )" + dbName + "." + dbReadingsName + R"(_ix1 ON )" + dbReadingsName + R"( (asset_code, user_ts desc); + )"; + string createReadingsIdx2 = R"( + CREATE INDEX IF NOT EXISTS )" + dbName + "." + dbReadingsName + R"(_ix2 ON )" + dbReadingsName + R"( (asset_code); + )"; + string createReadingsIdx3 = R"( + CREATE INDEX IF NOT EXISTS )" + dbName + "." + dbReadingsName + R"(_ix3 ON )" + dbReadingsName + R"( (user_ts); + )"; + + logger->info(" Creating table '%s' sql cmd '%s'", dbReadingsName.c_str(), createReadings.c_str()); + + int rc = SQLExec(dbHandle, createReadings.c_str()); + if (rc != SQLITE_OK) + { + raiseError("creating overflow table", sqlite3_errmsg(dbHandle)); + return false; + } + + rc = SQLExec(dbHandle, createReadingsIdx1.c_str()); + if (rc != SQLITE_OK) + { + raiseError("creating overflow table index 1", sqlite3_errmsg(dbHandle)); + return false; + } + rc = SQLExec(dbHandle, createReadingsIdx2.c_str()); + if (rc != SQLITE_OK) + { + raiseError("creating overflow table index 2", sqlite3_errmsg(dbHandle)); + return false; + } + rc = SQLExec(dbHandle, createReadingsIdx3.c_str()); + if (rc != SQLITE_OK) + { + raiseError("creating overflow table index 3", sqlite3_errmsg(dbHandle)); + return false; + } + + return true; +} + /** * Evaluates the latest reading table defined in the provided database id looking at sqlite_master, the SQLite repository * @@ -1716,18 +1920,19 @@ ReadingsCatalogue::tyReadingsAvailable ReadingsCatalogue::evaluateLastReadingAv tyReadingsAvailable readingsAvailable; Connection *connection; - bool connAllocated; - - connAllocated = false; + bool connAllocated = false; vector readingsId(getNReadingsAvailable(), 0); ConnectionManager *manager = ConnectionManager::getInstance(); - if (dbHandle == NULL) { connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Evaluate last reading available"; + connection->setUsage(usage); +#endif dbHandle = connection->getDbHandle(); connAllocated = true; } @@ -1756,14 +1961,17 @@ ReadingsCatalogue::tyReadingsAvailable ReadingsCatalogue::evaluateLastReadingAv nCols = sqlite3_column_count(stmt); tableName = (char *)sqlite3_column_text(stmt, 0); - id = extractReadingsIdFromName(tableName); + if (tableName.find_first_of("overflow") == string::npos) + { + id = extractReadingsIdFromName(tableName); - if (id > readingsAvailable.lastReadings) - readingsAvailable.lastReadings = id; + if (id > readingsAvailable.lastReadings) + readingsAvailable.lastReadings = id; - readingsAvailable.tableCount++; + readingsAvailable.tableCount++; + } } - Logger::getLogger()->debug("evaluateLastReadingAvailable - tableName :%s: lastReadings :%d:", tableName.c_str(), readingsAvailable.lastReadings); + Logger::getLogger()->debug("evaluateLastReadingAvailable - tableName '%s' lastReadings %d", tableName.c_str(), readingsAvailable.lastReadings); sqlite3_finalize(stmt); } @@ -1831,13 +2039,13 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co auto item = m_AssetReadingCatalogue.find(asset_code); if (item != m_AssetReadingCatalogue.end()) { - //# An asset already managed + //# The asset is already allocated to a table ref.tableId = item->second.first; ref.dbId = item->second.second; } else { - Logger::getLogger()->debug("getReadingReference - before lock dbHandle :%X: threadId :%s:", dbHandle, threadId.str().c_str() ); + Logger::getLogger()->debug("getReadingReference - before lock dbHandle %X threadId '%s'", dbHandle, threadId.str().c_str() ); AttachDbSync *attachSync = AttachDbSync::getInstance(); attachSync->lock(); @@ -1853,17 +2061,10 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co else { - if (! isReadingAvailable () ) + if (! isReadingAvailable ()) { - //No Readding table available... Get empty reading table - auto it = m_EmptyAssetReadingCatalogue.begin(); - if (it != m_EmptyAssetReadingCatalogue.end()) - { - emptyAsset = it->first; - emptyTableReference.tableId = it->second.first; - emptyTableReference.dbId = it->second.second; - } - + // No Reading table available... Get empty reading table + emptyTableReference = getEmptyReadingTableReference(emptyAsset); if ( !emptyAsset.empty() ) { ref = emptyTableReference; @@ -1871,26 +2072,27 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co else { //# Allocate a new block of readings table - Logger::getLogger()->debug("getReadingReference - allocate a new db, dbNAvailable :%d:", m_dbNAvailable); + Logger::getLogger()->debug("Allocating a new db form the preallocated tables. %d preallocated tables available.", m_dbNAvailable); if (m_dbNAvailable > 0) { - // DBs already created are available + // DBs already pre-allocated are available m_dbIdCurrent++; m_dbNAvailable--; m_nReadingsAvailable = getNReadingsAllocate(); - Logger::getLogger()->debug("getReadingReference - allocate a new db, db already available - dbIdCurrent :%d: dbIdLast :%d: dbNAvailable :%d: nReadingsAvailable :%d: ", m_dbIdCurrent, m_dbIdLast, m_dbNAvailable, m_nReadingsAvailable); + Logger::getLogger()->debug("Allocate dbIdCurrent %d dbIdLast %d dbNAvailable %d nReadingsAvailable %d ", m_dbIdCurrent, m_dbIdLast, m_dbNAvailable, m_nReadingsAvailable); } else { + // There are no pre-allocated databases available // Allocates new DBs - int dbId, dbIdStart, dbIdEnd; + int dbId, dbIdStart, dbIdEnd, allocated = 0; dbIdStart = m_dbIdLast +1; dbIdEnd = m_dbIdLast + m_storageConfigCurrent.nDbToAllocate; - Logger::getLogger()->debug("getReadingReference - allocate a new db - create new db - dbIdCurrent :%d: dbIdStart :%d: dbIdEnd :%d:", m_dbIdCurrent, dbIdStart, dbIdEnd); + Logger::getLogger()->debug("getReadingReference - allocate a new db - create new db - dbIdCurrent %d dbIdStart %d dbIdEnd %d", m_dbIdCurrent, dbIdStart, dbIdEnd); for (dbId = dbIdStart; dbId <= dbIdEnd; dbId++) { @@ -1901,12 +2103,20 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co success = createNewDB(dbHandle, dbId, startReadingsId, NEW_DB_ATTACH_REQUEST); if (success) { - Logger::getLogger()->debug("getReadingReference - allocate a new db - create new dbs - dbId :%d: startReadingsIdOnDB :%d:", dbId, startReadingsId); + Logger::getLogger()->debug("getReadingReference - allocate a new db - create new dbs - dbId %d startReadingsIdOnDB %d", dbId, startReadingsId); + allocated++; + } + else + { + break; } } - m_dbIdLast = dbIdEnd; - m_dbIdCurrent++; - m_dbNAvailable = (m_dbIdLast - m_dbIdCurrent) - m_storageConfigCurrent.nDbLeftFreeBeforeAllocate; + if (allocated) + { + m_dbIdLast += allocated; + m_dbIdCurrent++; + m_dbNAvailable += (allocated - 1); + } } ref.tableId = -1; @@ -1916,60 +2126,85 @@ ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getReadingReference(Co } if (success) + // Associate a reading table to the asset { - // Associate a reading table to the asset + // Associate the asset to the reading_id + if (emptyAsset.empty()) { - // Associate the asset to the reading_id - if (emptyAsset.empty()) - { - ref.tableId = getMaxReadingsId(m_dbIdCurrent) + 1; - ref.dbId = m_dbIdCurrent; - } + ref.tableId = getMaxReadingsId(m_dbIdCurrent) + 1; + ref.dbId = m_dbIdCurrent; + } + { + m_EmptyAssetReadingCatalogue.erase(emptyAsset); + m_AssetReadingCatalogue.erase(emptyAsset); + auto newItem = make_pair(ref.tableId, ref.dbId); + auto newMapValue = make_pair(asset_code, newItem); + m_AssetReadingCatalogue.insert(newMapValue); + } + + // Allocate the table in the reading catalogue + if (emptyAsset.empty()) + { + sql_cmd = + "INSERT INTO " READINGS_DB ".asset_reading_catalogue (table_id, db_id, asset_code) VALUES (" + + to_string(ref.tableId) + "," + + to_string(ref.dbId) + "," + + "\"" + asset_code + "\")"; + + Logger::getLogger()->debug("getReadingReference - allocate a new reading table for the asset '%s' db Id %d readings Id %d ", asset_code, ref.dbId, ref.tableId); + + } + else + { + sql_cmd = " UPDATE " READINGS_DB ".asset_reading_catalogue SET asset_code ='" + string(asset_code) + "'" + + " WHERE db_id = " + to_string(ref.dbId) + " AND table_id = " + to_string(ref.tableId) + ";"; + + Logger::getLogger()->debug("getReadingReference - Use empty table %readings_%d_%d: ",ref.dbId,ref.tableId); + } + + { + rc = SQLExec(dbHandle, sql_cmd.c_str()); + if (rc != SQLITE_OK) { - m_EmptyAssetReadingCatalogue.erase(emptyAsset); - m_AssetReadingCatalogue.erase(emptyAsset); - auto newItem = make_pair(ref.tableId, ref.dbId); - auto newMapValue = make_pair(asset_code, newItem); - m_AssetReadingCatalogue.insert(newMapValue); + msg = string(sqlite3_errmsg(dbHandle)) + " asset :" + asset_code + ":"; + raiseError("asset_reading_catalogue update", msg.c_str()); } - // Allocate the table in the reading catalogue if (emptyAsset.empty()) { - sql_cmd = - "INSERT INTO " READINGS_DB ".asset_reading_catalogue (table_id, db_id, asset_code) VALUES (" - + to_string(ref.tableId) + "," - + to_string(ref.dbId) + "," - + "\"" + asset_code + "\")"; - - Logger::getLogger()->debug("getReadingReference - allocate a new reading table for the asset :%s: db Id :%d: readings Id :%d: ", asset_code, ref.dbId, ref.tableId); - - } - else - { - sql_cmd = " UPDATE " READINGS_DB ".asset_reading_catalogue SET asset_code ='" + string(asset_code) + "'" + - " WHERE db_id = " + to_string(ref.dbId) + " AND table_id = " + to_string(ref.tableId) + ";"; - - Logger::getLogger()->debug("getReadingReference - Use empty table %readings_%d_%d: ",ref.dbId,ref.tableId); + allocateReadingAvailable(); } - { - rc = SQLExec(dbHandle, sql_cmd.c_str()); - if (rc != SQLITE_OK) - { - msg = string(sqlite3_errmsg(dbHandle)) + " asset :" + asset_code + ":"; - raiseError("asset_reading_catalogue update", msg.c_str()); - } - - if (emptyAsset.empty()) - { - allocateReadingAvailable(); - } - - } + } + } + else + { + // Assign to overflow + Logger::getLogger()->info("Assign asset %s to the overflow table", asset_code); + auto newItem = make_pair(0, m_nextOverflow); + auto newMapValue = make_pair(asset_code, newItem); + m_AssetReadingCatalogue.insert(newMapValue); + sql_cmd = + "INSERT INTO " READINGS_DB ".asset_reading_catalogue (table_id, db_id, asset_code) VALUES ( 0," + + to_string(m_nextOverflow) + "," + + "\"" + asset_code + "\")"; + rc = SQLExec(dbHandle, sql_cmd.c_str()); + if (rc != SQLITE_OK) + { + msg = string(sqlite3_errmsg(dbHandle)) + " asset :" + asset_code + ":"; + raiseError("asset_reading_catalogue update", msg.c_str()); } + ref.tableId = 0; + ref.dbId = m_nextOverflow; + if (m_nextOverflow > m_maxOverflowUsed) + { + m_maxOverflowUsed = m_nextOverflow; + } + m_nextOverflow++; + if (m_nextOverflow > m_dbIdLast) + m_nextOverflow = 1; } } @@ -1991,8 +2226,6 @@ bool ReadingsCatalogue::loadEmptyAssetReadingCatalogue(bool clean) string sql_cmd; sqlite3_stmt *stmt; ConnectionManager *manager = ConnectionManager::getInstance(); - Connection *connection = manager->allocate(); - dbHandle = connection->getDbHandle(); if (clean) { @@ -2001,38 +2234,75 @@ bool ReadingsCatalogue::loadEmptyAssetReadingCatalogue(bool clean) // Do not populate m_EmptyAssetReadingCatalogue if data is already there if (m_EmptyAssetReadingCatalogue.size()) + { return true; + } + Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Load empty sset reading catalogue"; + connection->setUsage(usage); +#endif + dbHandle = connection->getDbHandle(); for (auto &item : m_AssetReadingCatalogue) { string asset_name = item.first; // Asset int tableId = item.second.first; // tableId; int dbId = item.second.second; // dbId; - - sql_cmd = "SELECT COUNT(*) FROM readings_" + to_string(dbId) + ".readings_" + to_string(dbId) + "_" + to_string(tableId) + " ;"; - if (sqlite3_prepare_v2(dbHandle, sql_cmd.c_str(), -1, &stmt, NULL) != SQLITE_OK) - { - sqlite3_finalize(stmt); - continue; - } - if (SQLStep(stmt) == SQLITE_ROW) + if (tableId > 0) { - if (sqlite3_column_int(stmt, 0) == 0) + + sql_cmd = "SELECT COUNT(*) FROM readings_" + to_string(dbId) + ".readings_" + to_string(dbId) + "_" + to_string(tableId) + " ;"; + if (sqlite3_prepare_v2(dbHandle, sql_cmd.c_str(), -1, &stmt, NULL) != SQLITE_OK) { - auto newItem = make_pair(tableId,dbId); - auto newMapValue = make_pair(asset_name,newItem); - m_EmptyAssetReadingCatalogue.insert(newMapValue); - + sqlite3_finalize(stmt); + continue; } + + if (SQLStep(stmt) == SQLITE_ROW) + { + if (sqlite3_column_int(stmt, 0) == 0) + { + auto newItem = make_pair(tableId,dbId); + auto newMapValue = make_pair(asset_name,newItem); + m_EmptyAssetReadingCatalogue.insert(newMapValue); + + } + } + sqlite3_finalize(stmt); } - sqlite3_finalize(stmt); } manager->release(connection); return true; } +/** + * Get Empty Reading Table + * + * @param asset emptyAsset, copies value of asset for which empty table is found + * @return the reading id associated to the provided empty table + */ +ReadingsCatalogue::tyReadingReference ReadingsCatalogue::getEmptyReadingTableReference(std::string& asset) +{ + ReadingsCatalogue::tyReadingReference emptyTableReference = {-1, -1}; + if (m_EmptyAssetReadingCatalogue.size() == 0) + { + loadEmptyAssetReadingCatalogue(); + } + + auto it = m_EmptyAssetReadingCatalogue.begin(); + if (it != m_EmptyAssetReadingCatalogue.end()) + { + asset = it->first; + emptyTableReference.tableId = it->second.first; + emptyTableReference.dbId = it->second.second; + } + + return emptyTableReference; +} + /** * Retrieve the maximum readings id for the provided database id * @@ -2044,11 +2314,10 @@ int ReadingsCatalogue::getMaxReadingsId(int dbId) { int maxId = 0; - for (auto &item : m_AssetReadingCatalogue) { - - if (item.second.second == dbId ) - if (item.second.first > maxId) - maxId = item.second.first; + for (auto &item : m_AssetReadingCatalogue) + { + if (item.second.second == dbId && item.second.first > maxId) + maxId = item.second.first; } return (maxId); @@ -2101,9 +2370,9 @@ int ReadingsCatalogue::getUsedTablesDbId(int dbId) { int count = 0; - for (auto &item : m_AssetReadingCatalogue) { - - if (item.second.second == dbId) + for (auto &item : m_AssetReadingCatalogue) + { + if (item.second.first != 0 && item.second.second == dbId) count++; } @@ -2165,7 +2434,7 @@ int ReadingsCatalogue::purgeAllReadings(sqlite3 *dbHandle, const char *sqlCmdBa rc = SQLExec(dbHandle, sqlCmdTmp.c_str(), zErrMsg); - Logger::getLogger()->debug("purgeAllReadings: rc :%d: cmd :%s:", rc ,sqlCmdTmp.c_str() ); + Logger::getLogger()->debug("purgeAllReadings: rc %d cmd '%s'", rc ,sqlCmdTmp.c_str() ); if (rc != SQLITE_OK) { @@ -2240,6 +2509,12 @@ string ReadingsCatalogue::sqlConstructMultiDb(string &sqlCmdBase, vector &assetCodes, bool considerExclusion, bool groupBy) +{ + string dbReadingsName; + string dbName; + string sqlCmdTmp; + string sqlCmd; + + string assetCode; + bool addTable; + bool addedOne; + + for (int dbId = 1; dbId <= m_maxOverflowUsed; dbId++) + { + dbReadingsName = generateReadingsName(dbId, 0); + sqlCmdTmp = sqlCmdBase; + + sqlCmd += " UNION ALL "; + + dbName = generateDbName(dbId); + + StringReplaceAll (sqlCmdTmp, ".assetcode.", "asset_code"); + StringReplaceAll(sqlCmdTmp, "_dbname_", dbName); + StringReplaceAll(sqlCmdTmp, "_tablename_", dbReadingsName); + sqlCmd += sqlCmdTmp; + if (! assetCodes.empty()) + { + sqlCmd += " WHERE "; + bool first = true; + for (auto& code : assetCodes) + { + if (!first) + { + sqlCmd += " or "; + first = false; + } + sqlCmd += "asset_code = \'"; + sqlCmd += code; + sqlCmd += "\'"; + } + } + + if (groupBy) + { + sqlCmd += " GROUP By asset_code"; + } + } + return sqlCmd; } /** - * Generates a SQLIte db alis from the database id + * Generates a SQLite db alias from the database id * * @param dbId Database id for which the alias must be generated * @return Generated alias @@ -2329,49 +2664,60 @@ string ReadingsCatalogue::generateDbFileName(int dbId) * Extracts the readings id from the table name * * @param tableName Table name from which the id must be extracted - * @return Extracted reading id + * @return Extracted reading id or -1 on error * */ int ReadingsCatalogue::extractReadingsIdFromName(string tableName) { int dbId; - int tableId; + int tableId = -1; string dbIdTableId; - dbIdTableId = tableName.substr (tableName.find('_') + 1); + try { + dbIdTableId = tableName.substr (tableName.find('_') + 1); - tableId = stoi(dbIdTableId.substr (dbIdTableId.find('_') + 1)); + tableId = stoi(dbIdTableId.substr (dbIdTableId.find('_') + 1)); - dbId = stoi(dbIdTableId.substr (0, dbIdTableId.find('_') )); + dbId = stoi(dbIdTableId.substr (0, dbIdTableId.find('_') )); + } catch (exception &e) { + Logger::getLogger()->fatal("extractReadingsIdFromName: exception on table %s, %s", + tableName.c_str(), e.what()); + } - return(tableId); + return tableId; } /** * Extract the database id from the table name * * @param tableName Table name from which the database id must be extracted - * @return Extracted database id + * @return Extracted database id or -1 on error * */ int ReadingsCatalogue::extractDbIdFromName(string tableName) { - int dbId; + int dbId = -1; int tableId; string dbIdTableId; - dbIdTableId = tableName.substr (tableName.find('_') + 1); + try { + dbIdTableId = tableName.substr (tableName.find('_') + 1); - tableId = stoi(dbIdTableId.substr (dbIdTableId.find('_') + 1)); + tableId = stoi(dbIdTableId.substr (dbIdTableId.find('_') + 1)); - dbId = stoi(dbIdTableId.substr (0, dbIdTableId.find('_') )); + dbId = stoi(dbIdTableId.substr (0, dbIdTableId.find('_') )); + } catch (exception &e) { + Logger::getLogger()->fatal("extractReadingsIdFromName: exception on table %s, %s", + tableName.c_str(), e.what()); + } - return(dbId); + return dbId; } /** * Generates the name of the reading table from the given table id as: - * Prefix + db Id + reading Id + * Prefix + db Id + reading Id. If the tableId is 0 then this is a + * reference to the overflow table * * @param dbId Database id to use for the generation of the table name * @param tableId Table id to use for the generation of the table name @@ -2383,12 +2729,19 @@ string ReadingsCatalogue::generateReadingsName(int dbId, int tableId) string tableName; if (dbId == -1) - dbId = retrieveDbIdFromTableId (tableId); + dbId = retrieveDbIdFromTableId(tableId); - tableName = READINGS_TABLE "_" + to_string(dbId) + "_" + to_string(tableId); - Logger::getLogger()->debug("%s - dbId :%d: tableId :%d: table name :%s: ", __FUNCTION__, dbId, tableId, tableName.c_str()); + if (tableId == 0) // Overflow table + { + tableName = READINGS_TABLE "_" + to_string(dbId) + "_overflow"; + } + else + { + tableName = READINGS_TABLE "_" + to_string(dbId) + "_" + to_string(tableId); + } + Logger::getLogger()->debug("%s - dbId %d tableId %d table name '%s' ", __FUNCTION__, dbId, tableId, tableName.c_str()); - return (tableName); + return tableName; } /** @@ -2458,7 +2811,7 @@ int ReadingsCatalogue::SQLExec(sqlite3 *dbHandle, const char *sqlCmd, char **err { *errMsg = NULL; } - Logger::getLogger()->debug("SQLExec: cmd :%s: ", sqlCmd); + Logger::getLogger()->debug("SQLExec: cmd '%s' ", sqlCmd); do { if (errMsg && *errMsg) @@ -2467,14 +2820,14 @@ int ReadingsCatalogue::SQLExec(sqlite3 *dbHandle, const char *sqlCmd, char **err *errMsg = NULL; } rc = sqlite3_exec(dbHandle, sqlCmd, NULL, NULL, errMsg); - Logger::getLogger()->debug("SQLExec: rc :%d: ", rc); + Logger::getLogger()->debug("SQLExec: rc %d ", rc); retries++; if (rc == SQLITE_LOCKED || rc == SQLITE_BUSY) { int interval = (retries * RETRY_BACKOFF); usleep(interval); // sleep retries milliseconds - if (retries > 5) Logger::getLogger()->info("SQLExec - error :%s: retry %d of %d, rc=%s, DB connection @ %p, slept for %d msecs", + if (retries > 5) Logger::getLogger()->info("SQLExec - error '%s' retry %d of %d, rc=%s, DB connection @ %p, slept for %d msecs", sqlite3_errmsg(dbHandle), retries, MAX_RETRIES, (rc==SQLITE_LOCKED)?"SQLITE_LOCKED":"SQLITE_BUSY", this, interval); } } while (retries < MAX_RETRIES && (rc == SQLITE_LOCKED || rc == SQLITE_BUSY)); diff --git a/C/plugins/storage/sqlite/plugin.cpp b/C/plugins/storage/sqlite/plugin.cpp index d1a3f14ae4..7bc1b2ebe6 100644 --- a/C/plugins/storage/sqlite/plugin.cpp +++ b/C/plugins/storage/sqlite/plugin.cpp @@ -41,14 +41,18 @@ const char *default_config = QUOTE({ "poolSize" : { "description" : "The number of connections to create in the intial pool of connections", "type" : "integer", + "minimum": "1", + "maximum": "10", "default" : "5", "displayName" : "Pool Size", "order" : "1" }, "nReadingsPerDb" : { - "description" : "The number of readings tables in each database that is created", + "description" : "The number of unique assets tables to maintain in each database that is created", "type" : "integer", + "minimum": "1", "default" : "15", + "maximum": "00", "displayName" : "No. Readings per database", "order" : "2" }, @@ -56,6 +60,8 @@ const char *default_config = QUOTE({ "description" : "Number of databases to allocate in advance. NOTE: SQLite has a default maximum of 10 attachable databases", "type" : "integer", "default" : "3", + "minimum": "1", + "maximum" : "10", "displayName" : "No. databases to allocate in advance", "order" : "3" }, @@ -63,6 +69,8 @@ const char *default_config = QUOTE({ "description" : "Allocate new databases when the number of free databases drops below this value", "type" : "integer", "default" : "1", + "minimum": "1", + "maximum": "10", "displayName" : "Database allocation threshold", "order" : "4" }, @@ -70,6 +78,8 @@ const char *default_config = QUOTE({ "description" : "The number of databases to create whenever the number of available databases drops below the allocation threshold", "type" : "integer", "default" : "2", + "minimum" : "1", + "maximum" : "10", "displayName" : "Database allocation size", "order" : "5" }, @@ -182,6 +192,10 @@ int plugin_common_insert(PLUGIN_HANDLE handle, char *schema, char *table, char * ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Insert into " + string(table); + connection->setUsage(usage); +#endif int result = connection->insert(std::string(schema), std::string(table), std::string(data)); manager->release(connection); return result; @@ -196,6 +210,10 @@ ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); std::string results; +#if TRACK_CONNECTION_USER + string usage = "Retrieve from " + string(table); + connection->setUsage(usage); +#endif bool rval = connection->retrieve(std::string(schema), std::string(table), std::string(query), results); manager->release(connection); if (rval) @@ -213,6 +231,11 @@ int plugin_common_update(PLUGIN_HANDLE handle, char *schema, char *table, char * ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Update " + string(table); + connection->setUsage(usage); +#endif + int result = connection->update(std::string(schema), std::string(table), std::string(data)); manager->release(connection); return result; @@ -226,6 +249,10 @@ int plugin_common_delete(PLUGIN_HANDLE handle, char *schema, char *table, char * ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Delete from " + string(table); + connection->setUsage(usage); +#endif int result = connection->deleteRows(std::string(schema), std::string(table), std::string(condition)); manager->release(connection); return result; @@ -239,6 +266,10 @@ int plugin_reading_append(PLUGIN_HANDLE handle, char *readings) ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Reading append"; + connection->setUsage(usage); +#endif int result = connection->appendReadings(readings); manager->release(connection); return result;; @@ -253,6 +284,11 @@ int plugin_readingStream(PLUGIN_HANDLE handle, ReadingStream **readings, bool co ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Reading Stream"; + connection->setUsage(usage); +#endif + result = connection->readingStream(readings, commit); manager->release(connection); @@ -268,6 +304,11 @@ ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); std::string resultSet; +#if TRACK_CONNECTION_USER + string usage = "Fetch readings"; + connection->setUsage(usage); +#endif + connection->fetchReadings(id, blksize, resultSet); manager->release(connection); return strdup(resultSet.c_str()); @@ -282,6 +323,11 @@ ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); std::string results; +#if TRACK_CONNECTION_USER + string usage = "Reading retrieve"; + connection->setUsage(usage); +#endif + connection->retrieveReadings(std::string(condition), results); manager->release(connection); return strdup(results.c_str()); @@ -297,6 +343,10 @@ Connection *connection = manager->allocate(); std::string results; unsigned long age, size; +#if TRACK_CONNECTION_USER + string usage = "Purge"; + connection->setUsage(usage); +#endif if (flags & STORAGE_PURGE_SIZE) { (void)connection->purgeReadingsByRows(param, flags, sent, results); @@ -338,6 +388,10 @@ ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Shutdown"; + connection->setUsage(usage); +#endif if (connection->supportsReadings()) { connection->shutdownAppendReadings(); @@ -369,6 +423,10 @@ int plugin_create_table_snapshot(PLUGIN_HANDLE handle, ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Snapshot " + string(table); + connection->setUsage(usage); +#endif int result = connection->create_table_snapshot(std::string(table), std::string(id)); manager->release(connection); @@ -390,6 +448,10 @@ int plugin_load_table_snapshot(PLUGIN_HANDLE handle, ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Load snapshot " + string(table); + connection->setUsage(usage); +#endif int result = connection->load_table_snapshot(std::string(table), std::string(id)); manager->release(connection); @@ -412,6 +474,10 @@ int plugin_delete_table_snapshot(PLUGIN_HANDLE handle, ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Delete snapshot " + string(table); + connection->setUsage(usage); +#endif int result = connection->delete_table_snapshot(std::string(table), std::string(id)); manager->release(connection); @@ -433,6 +499,10 @@ ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); std::string results; +#if TRACK_CONNECTION_USER + string usage = "Get table snapshots" + string(table); + connection->setUsage(usage); +#endif bool rval = connection->get_table_snapshots(std::string(table), results); manager->release(connection); @@ -454,6 +524,10 @@ int plugin_createSchema(PLUGIN_HANDLE handle, char *definition) ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Create schema"; + connection->setUsage(usage); +#endif int result = connection->createSchema(std::string(definition)); manager->release(connection); return result; @@ -467,6 +541,10 @@ unsigned int plugin_reading_purge_asset(PLUGIN_HANDLE handle, char *asset) ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); +#if TRACK_CONNECTION_USER + string usage = "Purge asset "; + connection->setUsage(usage); +#endif unsigned int deleted = connection->purgeReadingsAsset(asset); manager->release(connection); return deleted; diff --git a/C/services/storage/configuration.cpp b/C/services/storage/configuration.cpp index 0b1efc876f..10464ad4b5 100644 --- a/C/services/storage/configuration.cpp +++ b/C/services/storage/configuration.cpp @@ -97,6 +97,14 @@ StorageConfiguration::StorageConfiguration() checkCache(); } +/** + * Storage configuration destructor + */ +StorageConfiguration::~StorageConfiguration() +{ + delete document; +} + /** * Return if a value exsits for the cached configuration category */ diff --git a/C/services/storage/include/configuration.h b/C/services/storage/include/configuration.h index 1918fc62f2..a0fc8eac0d 100644 --- a/C/services/storage/include/configuration.h +++ b/C/services/storage/include/configuration.h @@ -32,6 +32,7 @@ class StorageConfiguration { public: StorageConfiguration(); + ~StorageConfiguration(); const char *getValue(const std::string& key); bool hasValue(const std::string& key); bool setValue(const std::string& key, const std::string& value); diff --git a/C/services/storage/include/storage_api.h b/C/services/storage/include/storage_api.h index d91fe7df94..e117795367 100644 --- a/C/services/storage/include/storage_api.h +++ b/C/services/storage/include/storage_api.h @@ -60,6 +60,7 @@ class StorageApi { public: StorageApi(const unsigned short port, const unsigned int threads); + ~StorageApi(); static StorageApi *getInstance(); void initResources(); void setPlugin(StoragePlugin *); diff --git a/C/services/storage/storage.cpp b/C/services/storage/storage.cpp index 55361855f5..f99304536a 100644 --- a/C/services/storage/storage.cpp +++ b/C/services/storage/storage.cpp @@ -244,6 +244,8 @@ unsigned short servicePort; */ StorageService::~StorageService() { + delete config; + delete logger; } /** diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp index d878a415d7..79fddd15e9 100644 --- a/C/services/storage/storage_api.cpp +++ b/C/services/storage/storage_api.cpp @@ -396,6 +396,19 @@ StorageApi::StorageApi(const unsigned short port, const unsigned int threads) : StorageApi::m_instance = this; } +/** + * Destructor for the storage API class. There is only ever one StorageApi class + * in existance and it lives for the entire duration of the storage service, so this + * is really for completerness rather than any pracitical use. + */ +StorageApi::~StorageApi() +{ + if (m_server) + { + delete m_server; + } +} + /** * Return the singleton instance of the StorageAPI class */ diff --git a/Makefile b/Makefile index 3ce471339c..c03632f2ac 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ ifneq ("$(PLATFORM_RH)","") else PIP_INSTALL_REQUIREMENTS := python3 -m pip install -Ir PYTHON_BUILD_PACKAGE = python3 setup.py build -b ../$(PYTHON_BUILD_DIR) - CMAKE := cmake + CMAKE := cmake -DCMAKE_BUILD_TYPE=Debug endif MKDIR_PATH := mkdir -p diff --git a/scripts/services/storage b/scripts/services/storage index 2fa276098c..0262751327 100755 --- a/scripts/services/storage +++ b/scripts/services/storage @@ -56,10 +56,17 @@ if [[ "$1" != "--plugin" ]]; then storagePlugin=${res[0]} managedEngine=${res[1]} # Call plugin check: this will create database if not set yet - ${pluginScriptPath}/${storagePlugin}.sh init ${FLEDGE_SCHEMA} ${managedEngine} -fi - -if [[ "$1" != "--readingsPlugin" ]]; then + ${pluginScriptPath}/${storagePlugin}.sh init ${FLEDGE_SCHEMA} ${managedEngine} + if [[ "$VALGRIND_STORAGE" = "y" ]]; then + write_log "" "scripts.services.storage" "warn" "Running storage service under valgrind" "logonly" "" + if [[ -f "$HOME/storage.valgrind.out" ]]; then + rm $HOME/storage.valgrind.out + fi + valgrind --leak-check=full --show-leak-kinds=all --trace-children=yes --log-file=$HOME/storage.valgrind.out ${storageExec} "$@" -d & + else + ${storageExec} "$@" + fi +elif [[ "$1" != "--readingsPlugin" ]]; then # Get db schema FLEDGE_VERSION_FILE="${FLEDGE_ROOT}/VERSION" FLEDGE_SCHEMA=`cat ${FLEDGE_VERSION_FILE} | tr -d ' ' | grep -i "FLEDGE_SCHEMA=" | sed -e 's/\(.*\)=\(.*\)/\2/g'` @@ -71,14 +78,13 @@ if [[ "$1" != "--readingsPlugin" ]]; then if [[ -x ${pluginScriptPath}/${storagePlugin}.sh ]]; then ${pluginScriptPath}/${storagePlugin}.sh init ${FLEDGE_SCHEMA} ${managedEngine} fi -fi - -# Run storage service -if [[ "$VALGRIND_STORAGE" = "y" ]]; then + ${storageExec} "$@" +elif [[ "$VALGRIND_STORAGE" = "y" ]]; then + write_log "" "scripts.services.storage" "warn" "Running storage service under valgrind" "logonly" "" if [[ -f "$HOME/storage.valgrind.out" ]]; then rm $HOME/storage.valgrind.out fi - valgrind --leak-check=full --trace-children=yes --log-file=$HOME/storage.valgrind.out ${storageExec} "$@" + valgrind --leak-check=full --show-leak-kinds=all --trace-children=yes --log-file=$HOME/storage.valgrind.out ${storageExec} "$@" -d & else ${storageExec} "$@" fi From 5972bae5049862acb515b37029a6598a7385ebeb Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 18 Apr 2023 18:24:19 +0530 Subject: [PATCH 260/499] cleanup fixes Signed-off-by: ashish-jabble --- python/fledge/common/logger.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/python/fledge/common/logger.py b/python/fledge/common/logger.py index 668c6f7752..22a442e82b 100644 --- a/python/fledge/common/logger.py +++ b/python/fledge/common/logger.py @@ -8,7 +8,6 @@ import os import subprocess import logging -import inspect import traceback from logging.handlers import SysLogHandler from functools import wraps @@ -213,12 +212,12 @@ def get_logger(self, logger_name: str): @wraps(_logger.error) def error(msg, *args, **kwargs): - """Case: When we pass exception in error and having different args - For example: - a) _logger.error(ex) - b) _logger.error(ex, "Failed to add data.") - """ + """Override error logger to print multi-line traceback and error string with newline""" if isinstance(msg, Exception): + """For example: + a) _logger.error(ex) + b) _logger.error(ex, "Failed to add data.") + """ trace_msg = traceback.format_exception(msg.__class__, msg, msg.__traceback__) if args: trace_msg[:0] = ["{}\n".format(args[0])] @@ -227,7 +226,8 @@ def error(msg, *args, **kwargs): """Case: When we pass string in error For example: a) _logger.error(str(ex)) - b) _logger.error("Failed to add data for key: {} along with error:{}".format("Test", str(ex))) + b) _logger.error("Failed to log audit trail entry") + c) _logger.error("Failed to log audit trail entry '{}' \n{}".format(code, str(ex))) """ [__logging_error(m) for m in msg.splitlines()] From ec9b2d0dad7da2630b5959a17d21147638cf14c9 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 20 Apr 2023 12:08:54 +0530 Subject: [PATCH 261/499] pipeline dynamic filter categories child relationship with dispatcher if exists Signed-off-by: ashish-jabble --- python/fledge/services/core/api/control_pipeline.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index 2e4811125f..f0115525dc 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -80,6 +80,7 @@ async def create(request: web.Request) -> web.Response: curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "pump", "enable": "true", "execution": "shared", "source": {"type": 2, "name": "pump"}}' curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "broadcast", "enable": "true", "execution": "exclusive", "destination": {"type": 4}}' curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enable": "true", "execution": "shared", "source": {"type": 2, "name": "opcua"}, "destination": {"type": 2, "name": "pump1"}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enable": "true", "execution": "exclusive", "source": {"type": 2, "name": "southOpcua"}, "destination": {"type": 1, "name": "northOpcua"}, "filters": ["Filter1"]}' curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "Test", "enable": "true", "filters": ["Filter1", "Filter2"]}' """ try: @@ -548,7 +549,6 @@ async def _update_filters(storage, cp_id, cp_name, cp_filters): v['default'] = v['value'] v.pop('value', None) # Create category - # TODO: parent-child relation? cat_name = "ctrl_{}_{}".format(cp_name, fname) await cf_mgr.create_category(category_name=cat_name, category_description="Filter of {} control pipeline.".format( @@ -563,4 +563,9 @@ async def _update_filters(storage, cp_id, cp_name, cp_filters): payload = PayloadBuilder().INSERT(**column_names).payload() await storage.insert_into_tbl("control_filters", payload) new_filters.append(cat_name) + try: + # Create parent-child relation with Dispatcher service + await cf_mgr.create_child_category("dispatcher", new_filters) + except: + pass return new_filters From 7ddabbc90718f7a22a7f5116f7c8ca58b0ed88c3 Mon Sep 17 00:00:00 2001 From: nandan Date: Thu, 20 Apr 2023 18:41:42 +0530 Subject: [PATCH 262/499] FOGL-7612 : Added SetValue function for string type Signed-off-by: nandan --- C/common/include/datapoint.h | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/C/common/include/datapoint.h b/C/common/include/datapoint.h index 2f6d604d38..a81183b4fb 100644 --- a/C/common/include/datapoint.h +++ b/C/common/include/datapoint.h @@ -140,7 +140,21 @@ class DatapointValue { */ ~DatapointValue(); - + /** + * Set the value of a datapoint, this may + * also cause the type to be changed. + * @param value An string value to set + */ + void setValue(std::string value) + { + if(m_value.str) + { + delete m_value.str; + } + m_value.str = new std::string(value); + m_type = T_STRING; + } + /** * Set the value of a datapoint, this may * also cause the type to be changed. From e5cc3b095d4da01a134a8600d33d23f2934dba7c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 21 Apr 2023 11:27:37 +0530 Subject: [PATCH 263/499] updates to Contributing guide Signed-off-by: ashish-jabble --- CONTRIBUTING.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e75e5ca9f2..25c9717c52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,9 +8,9 @@ The following is a set of guidelines for contributing to Fledge IoT project and its plugins, which are hosted in the [fledge-iot Organization](https://github.com/fledge-iot) on GitHub. -To give us feedback or make suggestions use the [Fledge Slack Channel](https://lfedge.slack.com/archives/CLJ7CNCAX). +To give us feedback or make suggestions use the fledge or fledge-help Slack Channel on [LFEdge](https://lfedge.slack.com/archives/CLJ7CNCAX). -If you find a security vulnerability within Fledge or any of it's plugins then we request that you inform us via email rather than by opening an issue in GitHub. This allows us to act on it without giving information that others might exploit. Any security vulnerability will be discussed at the project TSC and user will be informed of the need to upgrade via the Fledge slack channel. The email address to which vulnerabilities should be reported is security@dianomic.com. +If you find a security vulnerability within Fledge or any of its plugins then we request that **you inform us via email rather than by opening an issue in GitHub**. This allows us to act on it without giving information that others might exploit. Any security vulnerability will be discussed at the project TSC and user will be informed of the need to upgrade via the Fledge Slack channel. The email address to which vulnerabilities should be reported is security@dianomic.com. ## Pull requests @@ -18,7 +18,7 @@ If you find a security vulnerability within Fledge or any of it's plugins then w refactoring code etc.), otherwise you risk spending a lot of time working on something that might already be underway or is unlikely to be merged into the project. -Join the Fledge slack channel on [LFEdge](https://lfedge.slack.com/archives/CLJ7CNCAX). This +Join the fledge or fledge-help Slack channel on [LFEdge](https://lfedge.slack.com/archives/CLJ7CNCAX). This will allow you to talk to the wider fledge community and discuss your proposed changes and get help from the maintainers when needed. @@ -35,26 +35,26 @@ Adhering to the following process is the best way to get your work included in t ```bash # Clone your fork of the repo into the current directory - git clone https://github.com//fledge-iot.git + git clone https://github.com//fledge.git # Navigate to the newly cloned directory - cd fledge-iot + cd fledge # Assign the original repo to a remote called "upstream" git remote add upstream https://github.com/fledge-iot/fledge.git ``` -2. If you cloned a while ago, get the latest changes from upstream: +2. If you cloned a while ago, get the latest changes of develop branch from upstream: ```bash - git checkout main - git pull --rebase upstream main + git checkout develop + git pull --rebase upstream develop ``` 3. Create a new topic branch from `develop`, if you are working a particular issue from the Project Jira then the convention for branch names is to use the Jira name, otherwise choose a descriptive branch name that contains your GitHub username in order to help us track the changes. ```bash - git checkout -b [branch-name] + git checkout -b [topic-branch-name] upstream/develop ``` 4. Commit your changes in logical chunks. When you are ready to commit, make sure to write a Good @@ -70,24 +70,27 @@ Adhering to the following process is the best way to get your work included in t 5. Locally merge (or rebase) the upstream development branch into your topic branch: ```bash - git pull --rebase upstream main + git pull --rebase upstream develop ``` 6. Push your topic branch up to your fork: ```bash - git push origin [branch-name] + git push -u origin [topic-branch-name] ``` 7. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) with a clear title - and detailed description. + and detailed description. Also make sure always raise Pull request against develop base branch of upstream only. + It must have at least one reviewer to expedite the review and also verify GitHub status checks which let you know if your commits meet the conditions set for the repository you're contributing to. + GitHub Status checks are based on external processes, such as continuous integration builds, which run for each push you make to a repository. You can see the pending, passing, or failing state of status checks next to individual commits in your pull request. + ### Plugins The above addresses the main Fledge repository, however plugins each have a repository of their own which contains the code for the plugin and the documentation for the plugin. If you wish to work on an existing plugin -then the process is similar to that above, just replace the fledge.git +then the process is similar to that above, just replace the "fledge.git" repository with the fledge-{plugin-type}-{plugin-name}.git repository, for example ```bash @@ -100,6 +103,7 @@ repository with the fledge-{plugin-type}-{plugin-name}.git repository, for examp # Assign the original repo to a remote called "upstream" git remote add upstream https://github.com/fledge-iot/fledge-south-sinusoid.git ``` +Repeat futher steps which we mentioned [here](#pull-requests) If you wish to create a new plugin then contact the maintainers and we will create a blank base repository for you to add your code into. From bb1b6f26689bda0eb1e146d7a9c0f5348a23ed2c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 21 Apr 2023 16:18:10 +0530 Subject: [PATCH 264/499] typo fixes' Signed-off-by: ashish-jabble --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25c9717c52..3e0db2c2cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,7 +103,7 @@ repository with the fledge-{plugin-type}-{plugin-name}.git repository, for examp # Assign the original repo to a remote called "upstream" git remote add upstream https://github.com/fledge-iot/fledge-south-sinusoid.git ``` -Repeat futher steps which we mentioned [here](#pull-requests) +Repeat further steps which we mentioned [here](#pull-requests) If you wish to create a new plugin then contact the maintainers and we will create a blank base repository for you to add your code into. From 276fd963c2d1144c9f95b885c9e330d71ba0e704 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 24 Apr 2023 13:11:06 +0530 Subject: [PATCH 265/499] logger error formatting fixes when args passed & ELSE condition fixes when message is of non-string type case Signed-off-by: ashish-jabble --- python/fledge/common/logger.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/python/fledge/common/logger.py b/python/fledge/common/logger.py index 22a442e82b..eecfdd36db 100644 --- a/python/fledge/common/logger.py +++ b/python/fledge/common/logger.py @@ -128,7 +128,13 @@ def error(msg, *args, **kwargs): trace_msg[:0] = ["{}\n".format(args[0])] [__logging_error(line.strip('\n')) for line in trace_msg] else: - [__logging_error(m) for m in msg.splitlines()] + if isinstance(msg, str): + if args: + msg = msg % args + [__logging_error(m) for m in msg.splitlines()] + else: + # Default logging error + __logging_error(msg) # overwrite the default logging.error logger.error = error @@ -223,13 +229,21 @@ def error(msg, *args, **kwargs): trace_msg[:0] = ["{}\n".format(args[0])] [__logging_error(line.strip('\n')) for line in trace_msg] else: - """Case: When we pass string in error - For example: - a) _logger.error(str(ex)) - b) _logger.error("Failed to log audit trail entry") - c) _logger.error("Failed to log audit trail entry '{}' \n{}".format(code, str(ex))) - """ - [__logging_error(m) for m in msg.splitlines()] + if isinstance(msg, str): + """For example: + a) _logger.error(str(ex)) + b) _logger.error("Failed to log audit trail entry") + c) _logger.error('Failed to log audit trail entry for code: %s', "CONCH") + d) _logger.error('Failed to log audit trail entry for code: {log_code}'.format(log_code="CONAD")) + e) _logger.error('Failed to log audit trail entry for code: {0}'.format("CONAD")) + f) _logger.error("Failed to log audit trail entry for code '{}' \n{}".format("CONCH", "Next line")) + """ + if args: + msg = msg % args + [__logging_error(m) for m in msg.splitlines()] + else: + # Default logging error + __logging_error(msg) # overwrite the default logging.error _logger.error = error From f131e5669e0d6733d3c20310d4fce1cb94db58d5 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 24 Apr 2023 13:18:01 +0530 Subject: [PATCH 266/499] common error_override def added for logger class Signed-off-by: ashish-jabble --- python/fledge/common/logger.py | 76 ++++++++++++++-------------------- 1 file changed, 32 insertions(+), 44 deletions(-) diff --git a/python/fledge/common/logger.py b/python/fledge/common/logger.py index eecfdd36db..8629058c50 100644 --- a/python/fledge/common/logger.py +++ b/python/fledge/common/logger.py @@ -108,9 +108,6 @@ def setup(logger_name: str = None, else: raise ValueError("Invalid destination {}".format(destination)) - # save the old logging.error function - __logging_error = logger.error - # TODO: Consider using %r with message when using syslog .. \n looks better than # fmt = '{}[%(process)d] %(levelname)s: %(module)s: %(name)s: %(message)s'.format(get_process_name()) formatter = logging.Formatter(fmt=fmt) @@ -120,26 +117,51 @@ def setup(logger_name: str = None, logger.addHandler(handler) logger.propagate = propagate - @wraps(logger.error) + # Call error override + error_override(logger) + return logger + + +def error_override(_logger: logging.Logger) -> None: + """Override error logger to print multi-line traceback and error string with newline + Args: + _logger: Logger Object + + Returns: + None + """ + # save the old logging.error function + __logging_error = _logger.error + + @wraps(_logger.error) def error(msg, *args, **kwargs): if isinstance(msg, Exception): + """For example: + a) _logger.error(ex) + b) _logger.error(ex, "Failed to add data.") + """ trace_msg = traceback.format_exception(msg.__class__, msg, msg.__traceback__) if args: trace_msg[:0] = ["{}\n".format(args[0])] [__logging_error(line.strip('\n')) for line in trace_msg] else: if isinstance(msg, str): + """For example: + a) _logger.error(str(ex)) + b) _logger.error("Failed to log audit trail entry") + c) _logger.error('Failed to log audit trail entry for code: %s', "CONCH") + d) _logger.error('Failed to log audit trail entry for code: {log_code}'.format(log_code="CONAD")) + e) _logger.error('Failed to log audit trail entry for code: {0}'.format("CONAD")) + f) _logger.error("Failed to log audit trail entry for code '{}' \n{}".format("CONCH", "Next line")) + """ if args: msg = msg % args [__logging_error(m) for m in msg.splitlines()] else: # Default logging error __logging_error(msg) - # overwrite the default logging.error - logger.error = error - - return logger + _logger.error = error class FLCoreLogger: @@ -207,46 +229,12 @@ def get_logger(self, logger_name: str): logger: returns logger for module """ _logger = logging.getLogger(logger_name) - - # save the old logging.error function - __logging_error = _logger.error - console_handler = self.get_console_handler() syslog_handler = self.get_syslog_handler() self.add_handlers(_logger, [syslog_handler, console_handler]) _logger.propagate = False - - @wraps(_logger.error) - def error(msg, *args, **kwargs): - """Override error logger to print multi-line traceback and error string with newline""" - if isinstance(msg, Exception): - """For example: - a) _logger.error(ex) - b) _logger.error(ex, "Failed to add data.") - """ - trace_msg = traceback.format_exception(msg.__class__, msg, msg.__traceback__) - if args: - trace_msg[:0] = ["{}\n".format(args[0])] - [__logging_error(line.strip('\n')) for line in trace_msg] - else: - if isinstance(msg, str): - """For example: - a) _logger.error(str(ex)) - b) _logger.error("Failed to log audit trail entry") - c) _logger.error('Failed to log audit trail entry for code: %s', "CONCH") - d) _logger.error('Failed to log audit trail entry for code: {log_code}'.format(log_code="CONAD")) - e) _logger.error('Failed to log audit trail entry for code: {0}'.format("CONAD")) - f) _logger.error("Failed to log audit trail entry for code '{}' \n{}".format("CONCH", "Next line")) - """ - if args: - msg = msg % args - [__logging_error(m) for m in msg.splitlines()] - else: - # Default logging error - __logging_error(msg) - - # overwrite the default logging.error - _logger.error = error + # Call error override + error_override(_logger) return _logger def set_level(self, level_name: str): From 03d089fe3d5e2391fd6298dbfa3a1ed0e11090ab Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 25 Apr 2023 14:58:14 +0530 Subject: [PATCH 267/499] storage error handling added in Create user Signed-off-by: ashish-jabble --- python/fledge/services/core/api/auth.py | 26 +++++++++++------------ python/fledge/services/core/user_model.py | 4 +--- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/python/fledge/services/core/api/auth.py b/python/fledge/services/core/api/auth.py index f6e8976ce0..aa92f94911 100644 --- a/python/fledge/services/core/api/auth.py +++ b/python/fledge/services/core/api/auth.py @@ -341,14 +341,15 @@ async def get_user(request): users = await User.Objects.all() res = [] for row in users: - u = OrderedDict() - u["userId"] = row["id"] - u["userName"] = row["uname"] - u["roleId"] = row["role_id"] - u["accessMethod"] = row["access_method"] - u["realName"] = row["real_name"] - u["description"] = row["description"] - res.append(u) + if row['enabled'] == 't': + u = OrderedDict() + u["userId"] = row["id"] + u["userName"] = row["uname"] + u["roleId"] = row["role_id"] + u["accessMethod"] = row["access_method"] + u["realName"] = row["real_name"] + u["description"] = row["description"] + res.append(u) result = {'users': res} return web.json_response(result) @@ -413,15 +414,12 @@ async def create_user(request): if not (await is_valid_role(role_id)): msg = "Invalid role ID." raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) - try: - await User.Objects.get(username=username) - except User.DoesNotExist: - pass - else: + users = await User.Objects.all() + unames = [u['uname'] for u in users] + if username in unames: msg = "Username already exists." _logger.warning(msg) raise web.HTTPConflict(reason=msg, body=json.dumps({"message": msg})) - u = dict() try: result = await User.Objects.create(username, password, role_id, access_method, real_name, description) diff --git a/python/fledge/services/core/user_model.py b/python/fledge/services/core/user_model.py index 5ca6dcb35a..7cf2c3900b 100644 --- a/python/fledge/services/core/user_model.py +++ b/python/fledge/services/core/user_model.py @@ -216,9 +216,7 @@ async def is_user_exists(cls, uid, password): @classmethod async def all(cls): storage_client = connect.get_storage_async() - payload = PayloadBuilder().SELECT("id", "uname", "role_id", "access_method", "real_name", - "description").WHERE(['enabled', '=', 't']).payload() - result = await storage_client.query_tbl_with_payload('users', payload) + result = await storage_client.query_tbl('users') return result['rows'] @classmethod From df73f892e3c97dc156a65a4cc458311db4dc52fd Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 25 Apr 2023 14:58:33 +0530 Subject: [PATCH 268/499] unit tests updated Signed-off-by: ashish-jabble --- .../services/core/api/test_auth_mandatory.py | 214 ++++++++++-------- .../services/core/api/test_auth_optional.py | 14 +- .../fledge/services/core/test_user_model.py | 5 +- 3 files changed, 127 insertions(+), 106 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py index 64115233d5..1d719c65ce 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py @@ -139,46 +139,50 @@ async def test_create_user_with_bad_role(self, client, mocker, request_data): async def test_create_dupe_user_name(self, client): msg = "Username already exists." - request_data = {"username": "ajtest", "password": "F0gl@mp"} + request_data = {"username": "dviewer", "password": "F0gl@mp"} valid_user = {'id': 1, 'uname': 'admin', 'role_id': '1'} + users = [{'id': 1, 'uname': 'admin', 'real_name': 'Admin user', 'role_id': 1, 'description': 'admin user', + 'enabled': 't', 'access_method': 'any'}, + {'id': 2, 'uname': 'user', 'real_name': 'Normal user', 'role_id': 2, 'description': 'normal user', + 'enabled': 'f', 'access_method': 'any'}, + {'id': 3, 'uname': 'dviewer', 'real_name': 'Data Viewer', 'role_id': 4, 'description': 'Test', + 'enabled': 'f', 'access_method': 'any'}] # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: _rv1 = await mock_coro(valid_user['id']) _rv2 = await mock_coro(None) _rv3 = await mock_coro([{'id': '1'}]) _rv4 = await mock_coro(True) - _se1 = await mock_coro(valid_user) - _se2 = await mock_coro({'role_id': '2', 'uname': 'ajtest', 'id': '2'}) + _rv5 = await mock_coro(valid_user) + _rv6 = await mock_coro(users) else: _rv1 = asyncio.ensure_future(mock_coro(valid_user['id'])) _rv2 = asyncio.ensure_future(mock_coro(None)) _rv3 = asyncio.ensure_future(mock_coro([{'id': '1'}])) _rv4 = asyncio.ensure_future(mock_coro(True)) - _se1 = asyncio.ensure_future(mock_coro(valid_user)) - _se2 = asyncio.ensure_future(mock_coro({'role_id': '2', 'uname': 'ajtest', 'id': '2'})) + _rv5 = asyncio.ensure_future(mock_coro(valid_user)) + _rv6 = asyncio.ensure_future(mock_coro(users)) with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'validate_token', return_value=_rv1) as patch_validate_token: with patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) as patch_refresh_token: - with patch.object(User.Objects, 'get', side_effect=[_se1, _se2]) as patch_user_get: - with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: - with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: - with patch.object(auth._logger, 'warning') as patch_logger_warning: - resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), - headers=ADMIN_USER_HEADER) - assert 409 == resp.status - assert msg == resp.reason - result = await resp.text() - json_response = json.loads(result) - assert {"message": msg} == json_response - patch_logger_warning.assert_called_once_with(msg) - patch_role.assert_called_once_with(2) - patch_role_id.assert_called_once_with('admin') - assert 2 == patch_user_get.call_count - args, kwargs = patch_user_get.call_args_list[0] - assert {'uid': valid_user['id']} == kwargs - args, kwargs = patch_user_get.call_args_list[1] - assert {'username': request_data['username']} == kwargs + with patch.object(User.Objects, 'get', return_value=_rv5) as patch_user_get: + with patch.object(User.Objects, 'all', return_value=_rv6) as patch_user_all: + with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: + with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: + with patch.object(auth._logger, 'warning') as patch_logger_warning: + resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), + headers=ADMIN_USER_HEADER) + assert 409 == resp.status + assert msg == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": msg} == json_response + patch_logger_warning.assert_called_once_with(msg) + patch_role.assert_called_once_with(2) + patch_role_id.assert_called_once_with('admin') + patch_user_all.assert_called_once_with() + patch_user_get.assert_called_once_with(uid=valid_user['id']) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') @@ -189,6 +193,12 @@ async def test_create_user(self, client): 'real_name': '', 'description': ''} expected = {} expected.update(data) + users = [{'id': 1, 'uname': 'admin', 'real_name': 'Admin user', 'role_id': 1, 'description': 'admin user', + 'enabled': 't', 'access_method': 'any'}, + {'id': 2, 'uname': 'user', 'real_name': 'Normal user', 'role_id': 2, 'description': 'normal user', + 'enabled': 'f', 'access_method': 'any'}, + {'id': 3, 'uname': 'dviewer', 'real_name': 'Data Viewer', 'role_id': 4, 'description': 'Test', + 'enabled': 'f', 'access_method': 'any'}] ret_val = {"response": "inserted", "rows_affected": 1} msg = '{} user has been created successfully.'.format(request_data['username']) valid_user = {'id': 1, 'uname': 'admin', 'role_id': '1'} @@ -199,6 +209,7 @@ async def test_create_user(self, client): _rv3 = await mock_coro([{'id': '1'}]) _rv4 = await mock_coro(True) _rv5 = await mock_coro(ret_val) + _rv6 = await mock_coro(users) _se1 = await mock_coro(valid_user) _se2 = await mock_coro(data) else: @@ -207,37 +218,39 @@ async def test_create_user(self, client): _rv3 = asyncio.ensure_future(mock_coro([{'id': '1'}])) _rv4 = asyncio.ensure_future(mock_coro(True)) _rv5 = asyncio.ensure_future(mock_coro(ret_val)) + _rv6 = asyncio.ensure_future(mock_coro(users)) _se1 = asyncio.ensure_future(mock_coro(valid_user)) _se2 = asyncio.ensure_future(mock_coro(data)) with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'validate_token', return_value=_rv1) as patch_validate_token: with patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) as patch_refresh_token: - with patch.object(User.Objects, 'get', side_effect=[_se1, User.DoesNotExist, _se2]) as patch_user_get: - with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: - with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: - with patch.object(User.Objects, 'create', return_value=_rv5) as patch_create_user: - with patch.object(auth._logger, 'info') as patch_auth_logger_info: - resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), - headers=ADMIN_USER_HEADER) - assert 200 == resp.status - r = await resp.text() - actual = json.loads(r) - assert msg == actual['message'] - assert expected['id'] == actual['user']['userId'] - assert expected['uname'] == actual['user']['userName'] - assert expected['role_id'] == actual['user']['roleId'] - patch_auth_logger_info.assert_called_once_with(msg) - patch_create_user.assert_called_once_with(request_data['username'], - request_data['password'], - int(expected['role_id']), 'any', '', '') - patch_role.assert_called_once_with(int(expected['role_id'])) - patch_role_id.assert_called_once_with('admin') - assert 3 == patch_user_get.call_count + with patch.object(User.Objects, 'get', side_effect=[_se1, _se2]) as patch_user_get: + with patch.object(User.Objects, 'all', return_value=_rv6) as patch_user_all: + with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: + with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: + with patch.object(User.Objects, 'create', return_value=_rv5) as patch_create_user: + with patch.object(auth._logger, 'info') as patch_auth_logger_info: + resp = await client.post('/fledge/admin/user', + data=json.dumps(request_data), + headers=ADMIN_USER_HEADER) + assert 200 == resp.status + r = await resp.text() + actual = json.loads(r) + assert msg == actual['message'] + assert expected['id'] == actual['user']['userId'] + assert expected['uname'] == actual['user']['userName'] + assert expected['role_id'] == actual['user']['roleId'] + patch_auth_logger_info.assert_called_once_with(msg) + patch_create_user.assert_called_once_with(request_data['username'], + request_data['password'], + int(expected['role_id']), 'any', '', '') + patch_role.assert_called_once_with(int(expected['role_id'])) + patch_role_id.assert_called_once_with('admin') + patch_user_all.assert_called_once_with() + assert 2 == patch_user_get.call_count args, kwargs = patch_user_get.call_args_list[0] assert {'uid': valid_user['id']} == kwargs args, kwargs = patch_user_get.call_args_list[1] - assert {'username': request_data['username']} == kwargs - args, kwargs = patch_user_get.call_args_list[2] assert {'username': expected['uname']} == kwargs patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -247,48 +260,49 @@ async def test_create_user_unknown_exception(self, client): request_data = {"username": "ajtest", "password": "F0gl@mp"} exc_msg = "Internal Server Error" valid_user = {'id': 1, 'uname': 'admin', 'role_id': '1'} - + users = [{'id': 1, 'uname': 'admin', 'real_name': 'Admin user', 'role_id': 1, 'description': 'admin user', + 'enabled': 't', 'access_method': 'any'}] # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: _rv1 = await mock_coro(valid_user['id']) _rv2 = await mock_coro(None) _rv3 = await mock_coro([{'id': '1'}]) _rv4 = await mock_coro(True) - _se1 = await mock_coro(valid_user) + _rv5 = await mock_coro(valid_user) + _rv6 = await mock_coro(users) else: _rv1 = asyncio.ensure_future(mock_coro(valid_user['id'])) _rv2 = asyncio.ensure_future(mock_coro(None)) _rv3 = asyncio.ensure_future(mock_coro([{'id': '1'}])) _rv4 = asyncio.ensure_future(mock_coro(True)) - _se1 = asyncio.ensure_future(mock_coro(valid_user)) - + _rv5 = asyncio.ensure_future(mock_coro(valid_user)) + _rv6 = asyncio.ensure_future(mock_coro(users)) with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'validate_token', return_value=_rv1) as patch_validate_token: with patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) as patch_refresh_token: - with patch.object(User.Objects, 'get', side_effect=[_se1, User.DoesNotExist]) as patch_user_get: - with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: - with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: - with patch.object(User.Objects, 'create', side_effect=Exception( - exc_msg)) as patch_create_user: - with patch.object(auth._logger, 'error') as patch_audit_logger_exc: - resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), - headers=ADMIN_USER_HEADER) - assert 500 == resp.status - assert exc_msg == resp.reason - result = await resp.text() - json_response = json.loads(result) - assert {"message": exc_msg} == json_response - patch_audit_logger_exc.assert_called_once_with('Failed to create user. {}'.format( - exc_msg)) - patch_create_user.assert_called_once_with(request_data['username'], - request_data['password'], 2, 'any', '', '') - patch_role.assert_called_once_with(2) - patch_role_id.assert_called_once_with('admin') - assert 2 == patch_user_get.call_count - args, kwargs = patch_user_get.call_args_list[0] - assert {'uid': valid_user['id']} == kwargs - args, kwargs = patch_user_get.call_args_list[1] - assert {'username': request_data['username']} == kwargs + with patch.object(User.Objects, 'get', return_value=_rv5) as patch_user_get: + with patch.object(User.Objects, 'all', return_value=_rv6) as patch_user_all: + with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: + with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: + with patch.object(User.Objects, 'create', side_effect=Exception( + exc_msg)) as patch_create_user: + with patch.object(auth._logger, 'error') as patch_audit_logger_exc: + resp = await client.post('/fledge/admin/user', + data=json.dumps(request_data), + headers=ADMIN_USER_HEADER) + assert 500 == resp.status + assert exc_msg == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": exc_msg} == json_response + patch_audit_logger_exc.assert_called_once_with( + 'Failed to create user. {}'.format(exc_msg)) + patch_create_user.assert_called_once_with( + request_data['username'], request_data['password'], 2, 'any', '', '') + patch_role.assert_called_once_with(2) + patch_role_id.assert_called_once_with('admin') + patch_user_all.assert_called_once_with() + patch_user_get.assert_called_once_with(uid=valid_user['id']) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') @@ -297,45 +311,45 @@ async def test_create_user_value_error(self, client): valid_user = {'id': 1, 'uname': 'admin', 'role_id': '1'} request_data = {"username": "ajtest", "password": "F0gl@mp"} exc_msg = "Value Error occurred" - + users = [{'id': 1, 'uname': 'admin', 'real_name': 'Admin user', 'role_id': 1, 'description': 'admin user', + 'enabled': 't', 'access_method': 'any'}] # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: _rv1 = await mock_coro(valid_user['id']) _rv2 = await mock_coro(None) _rv3 = await mock_coro([{'id': '1'}]) _rv4 = await mock_coro(True) - _se1 = await mock_coro(valid_user) + _rv5 = await mock_coro(valid_user) + _rv6 = await mock_coro(users) else: _rv1 = asyncio.ensure_future(mock_coro(valid_user['id'])) _rv2 = asyncio.ensure_future(mock_coro(None)) _rv3 = asyncio.ensure_future(mock_coro([{'id': '1'}])) _rv4 = asyncio.ensure_future(mock_coro(True)) - _se1 = asyncio.ensure_future(mock_coro(valid_user)) - + _rv5 = asyncio.ensure_future(mock_coro(valid_user)) + _rv6 = asyncio.ensure_future(mock_coro(users)) with patch.object(middleware._logger, 'debug') as patch_logger_debug: with patch.object(User.Objects, 'validate_token', return_value=_rv1) as patch_validate_token: with patch.object(User.Objects, 'refresh_token_expiry', return_value=_rv2) as patch_refresh_token: - with patch.object(User.Objects, 'get', side_effect=[_se1, User.DoesNotExist]) as patch_user_get: - with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: - with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: - with patch.object(User.Objects, 'create', side_effect=ValueError( - exc_msg)) as patch_create_user: - resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), - headers=ADMIN_USER_HEADER) - assert 400 == resp.status - assert exc_msg == resp.reason - result = await resp.text() - json_response = json.loads(result) - assert {"message": exc_msg} == json_response - patch_create_user.assert_called_once_with(request_data['username'], - request_data['password'], 2, 'any', '', '') - patch_role.assert_called_once_with(2) - patch_role_id.assert_called_once_with('admin') - assert 2 == patch_user_get.call_count - args, kwargs = patch_user_get.call_args_list[0] - assert {'uid': valid_user['id']} == kwargs - args, kwargs = patch_user_get.call_args_list[1] - assert {'username': request_data['username']} == kwargs + with patch.object(User.Objects, 'get', return_value=_rv5) as patch_user_get: + with patch.object(User.Objects, 'all', return_value=_rv6) as patch_user_all: + with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv3) as patch_role_id: + with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: + with patch.object(User.Objects, 'create', side_effect=ValueError( + exc_msg)) as patch_create_user: + resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), + headers=ADMIN_USER_HEADER) + assert 400 == resp.status + assert exc_msg == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": exc_msg} == json_response + patch_create_user.assert_called_once_with( + request_data['username'], request_data['password'], 2, 'any', '', '') + patch_role.assert_called_once_with(2) + patch_role_id.assert_called_once_with('admin') + patch_user_all.assert_called_once_with() + patch_user_get.assert_called_once_with(uid=valid_user['id']) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_validate_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) patch_logger_debug.assert_called_once_with('Received %s request for %s', 'POST', '/fledge/admin/user') diff --git a/tests/unit/python/fledge/services/core/api/test_auth_optional.py b/tests/unit/python/fledge/services/core/api/test_auth_optional.py index f6fb0e83c6..0412f09751 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_optional.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_optional.py @@ -63,11 +63,19 @@ async def test_get_roles(self, client): @pytest.mark.parametrize("ret_val, exp_result", [ ([], []), - ([{'uname': 'admin', 'role_id': '1', 'access_method': 'any', 'id': '1', 'real_name': 'Admin', 'description': 'Admin user'}, {'uname': 'user', 'role_id': '2', 'access_method': 'any', 'id': '2', 'real_name': 'Non-admin', 'description': 'Normal user'}], - [{"userId": "1", "userName": "admin", "roleId": "1", "accessMethod": "any", "realName": "Admin", "description": "Admin user"}, {"userId": "2", "userName": "user", "roleId": "2", "accessMethod": "any", "realName": "Non-admin", "description": "Normal user"}]) + ([{'uname': 'admin', 'role_id': '1', 'access_method': 'any', 'id': '1', 'real_name': 'Admin', + 'description': 'Admin user', 'enabled': 't'}, + {'uname': 'user', 'role_id': '2', 'access_method': 'any', 'id': '2', 'real_name': 'Non-admin', + 'description': 'Normal user', 'enabled': 't'}, + {'uname': 'dviewer', 'role_id': '3', 'access_method': 'any', 'id': '3', 'real_name': 'Data-Viewer', + 'description': 'Data user', 'enabled': 'f'} + ], + [{"userId": "1", "userName": "admin", "roleId": "1", "accessMethod": "any", "realName": "Admin", + "description": "Admin user"}, + {"userId": "2", "userName": "user", "roleId": "2", "accessMethod": "any", "realName": "Non-admin", + "description": "Normal user"}]) ]) async def test_get_all_users(self, client, ret_val, exp_result): - # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: _rv = await mock_coro(ret_val) diff --git a/tests/unit/python/fledge/services/core/test_user_model.py b/tests/unit/python/fledge/services/core/test_user_model.py index a62eaed502..d798c5c525 100644 --- a/tests/unit/python/fledge/services/core/test_user_model.py +++ b/tests/unit/python/fledge/services/core/test_user_model.py @@ -87,7 +87,6 @@ async def test_get_role_id_by_name(self): async def test_get_all(self): expected = {'rows': [], 'count': 0} - payload = '{"return": ["id", "uname", "role_id", "access_method", "real_name", "description"], "where": {"column": "enabled", "condition": "=", "value": "t"}}' storage_client_mock = MagicMock(StorageClientAsync) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. @@ -97,10 +96,10 @@ async def test_get_all(self): _rv = asyncio.ensure_future(mock_coro(expected)) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=_rv) as query_tbl_patch: + with patch.object(storage_client_mock, 'query_tbl', return_value=_rv) as query_tbl_patch: actual = await User.Objects.all() assert actual == expected['rows'] - query_tbl_patch.assert_called_once_with('users', payload) + query_tbl_patch.assert_called_once_with('users') @pytest.mark.parametrize("kwargs, payload", [ ({'username': None, 'uid': None}, '{"return": ["id", "uname", "role_id", "access_method", "real_name", "description"], "where": {"column": "enabled", "condition": "=", "value": "t"}}'), From f4f5cd735226987312a58a8148c3e0bb61b43d2a Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Apr 2023 10:57:27 +0530 Subject: [PATCH 269/499] Schema version bumped to 61 Signed-off-by: ashish-jabble --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index bab5c5183e..41f503bdb2 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.1.0 -fledge_schema=60 +fledge_schema=61 From ecce6b2b808fab40a4a366050cc22e635e967447 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Apr 2023 10:58:28 +0530 Subject: [PATCH 270/499] User audit log codes added for sqlite engine along with upgrade/downgrade scripts Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlite/downgrade/60.sql | 1 + scripts/plugins/storage/sqlite/init.sql | 6 +++++- scripts/plugins/storage/sqlite/upgrade/61.sql | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins/storage/sqlite/downgrade/60.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/61.sql diff --git a/scripts/plugins/storage/sqlite/downgrade/60.sql b/scripts/plugins/storage/sqlite/downgrade/60.sql new file mode 100644 index 0000000000..eb3c975713 --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/60.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('USRAD', 'USRDL', 'USRCH', 'USRRS' ); diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index c62baab211..9614b3dd26 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -728,7 +728,11 @@ INSERT INTO fledge.log_codes ( code, description ) ( 'ASTDP', 'Asset deprecated' ), ( 'ASTUN', 'Asset un-deprecated' ), ( 'PIPIN', 'Pip installation' ), - ( 'AUMRK', 'Audit Log Marker' ); + ( 'AUMRK', 'Audit Log Marker' ), + ( 'USRAD', 'User Added' ), + ( 'USRDL', 'User Deleted' ), + ( 'USRCH', 'User Changed' ), + ( 'USRRS', 'User Restored' ); -- -- Configuration parameters diff --git a/scripts/plugins/storage/sqlite/upgrade/61.sql b/scripts/plugins/storage/sqlite/upgrade/61.sql new file mode 100644 index 0000000000..f69e72ae07 --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/61.sql @@ -0,0 +1,6 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES + ( 'USRAD', 'User Added' ), + ( 'USRDL', 'User Deleted' ), + ( 'USRCH', 'User Changed' ), + ( 'USRRS', 'User Restored' ); From bddb73c194d1e8bd1bc77ff8ca692daebed3875d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Apr 2023 11:02:42 +0530 Subject: [PATCH 271/499] User audit log codes added for sqlitelb engine along with upgrade/downgrade scripts Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlitelb/downgrade/60.sql | 1 + scripts/plugins/storage/sqlitelb/init.sql | 6 +++++- scripts/plugins/storage/sqlitelb/upgrade/61.sql | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/60.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/61.sql diff --git a/scripts/plugins/storage/sqlitelb/downgrade/60.sql b/scripts/plugins/storage/sqlitelb/downgrade/60.sql new file mode 100644 index 0000000000..eb3c975713 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/60.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('USRAD', 'USRDL', 'USRCH', 'USRRS' ); diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index e55136b284..a8ae55077d 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -728,7 +728,11 @@ INSERT INTO fledge.log_codes ( code, description ) ( 'ASTDP', 'Asset deprecated' ), ( 'ASTUN', 'Asset un-deprecated' ), ( 'PIPIN', 'Pip installation' ), - ( 'AUMRK', 'Audit Log Marker' ); + ( 'AUMRK', 'Audit Log Marker' ), + ( 'USRAD', 'User Added' ), + ( 'USRDL', 'User Deleted' ), + ( 'USRCH', 'User Changed' ), + ( 'USRRS', 'User Restored' ); -- -- Configuration parameters diff --git a/scripts/plugins/storage/sqlitelb/upgrade/61.sql b/scripts/plugins/storage/sqlitelb/upgrade/61.sql new file mode 100644 index 0000000000..f69e72ae07 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/61.sql @@ -0,0 +1,6 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES + ( 'USRAD', 'User Added' ), + ( 'USRDL', 'User Deleted' ), + ( 'USRCH', 'User Changed' ), + ( 'USRRS', 'User Restored' ); From 9b5913441676837f1dad6dd2b5056b6ee40106e1 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Apr 2023 11:07:22 +0530 Subject: [PATCH 272/499] User audit log codes added for PostgreSql engine along with upgrade/downgrade scripts Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/60.sql | 1 + scripts/plugins/storage/postgres/init.sql | 6 +++++- scripts/plugins/storage/postgres/upgrade/61.sql | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins/storage/postgres/downgrade/60.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/61.sql diff --git a/scripts/plugins/storage/postgres/downgrade/60.sql b/scripts/plugins/storage/postgres/downgrade/60.sql new file mode 100644 index 0000000000..eb3c975713 --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/60.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('USRAD', 'USRDL', 'USRCH', 'USRRS' ); diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index df94c4212e..eddde0c83c 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -973,7 +973,11 @@ INSERT INTO fledge.log_codes ( code, description ) ( 'ASTDP', 'Asset deprecated' ), ( 'ASTUN', 'Asset un-deprecated' ), ( 'PIPIN', 'Pip installation' ), - ( 'AUMRK', 'Audit Log Marker' ); + ( 'AUMRK', 'Audit Log Marker' ), + ( 'USRAD', 'User Added' ), + ( 'USRDL', 'User Deleted' ), + ( 'USRCH', 'User Changed' ), + ( 'USRRS', 'User Restored' ); -- -- Configuration parameters diff --git a/scripts/plugins/storage/postgres/upgrade/61.sql b/scripts/plugins/storage/postgres/upgrade/61.sql new file mode 100644 index 0000000000..f69e72ae07 --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/61.sql @@ -0,0 +1,6 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES + ( 'USRAD', 'User Added' ), + ( 'USRDL', 'User Deleted' ), + ( 'USRCH', 'User Changed' ), + ( 'USRRS', 'User Restored' ); From b5a139182c4dd92d735e25657a66fb7dbf782784 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Apr 2023 12:18:30 +0530 Subject: [PATCH 273/499] source name check is removed when source type is Any Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index f0115525dc..7a6fbf662a 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -397,17 +397,24 @@ async def _check_parameters(payload): raise ValueError("Invalid source type found.") else: raise ValueError('Source type is missing.') - if source_name is not None: - if not isinstance(source_name, str): - raise ValueError("Source name should be a string value.") - source_name = source_name.strip() - if len(source_name) == 0: - raise ValueError('Source name cannot be empty.') - await _validate_lookup_name("source", source_type, source_name) + # Note: when source type is Any; no name is applied + if source_type != 1: + if source_name is not None: + if not isinstance(source_name, str): + raise ValueError("Source name should be a string value.") + source_name = source_name.strip() + if len(source_name) == 0: + raise ValueError('Source name cannot be empty.') + await _validate_lookup_name("source", source_type, source_name) + column_names["stype"] = source_type + column_names["sname"] = source_name + else: + raise ValueError('Source name is missing.') + else: + source_name = '' + source = {'type': source_type, 'name': source_name} column_names["stype"] = source_type column_names["sname"] = source_name - else: - raise ValueError('Source name is missing.') else: column_names["stype"] = 0 column_names["sname"] = "" From bdb68e5c0150f7d86629dcf0c23a8c99a2db2cc2 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Apr 2023 12:49:20 +0530 Subject: [PATCH 274/499] validation fixes for Service and Schedule type for both source and destination Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index 7a6fbf662a..9be4cbfe0d 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -474,8 +474,16 @@ async def _validate_lookup_name(lookup_name, _type, value): async def get_schedules(): schedules = await server.Server.scheduler.get_schedules() - if not any(sch.name == value for sch in schedules): - raise ValueError("'{}' not a valid service or schedule name.".format(value)) + if _type == 5: + # Verify against all type of schedules; we might filter out STARTUP type schedules? + if not any(sch.name == value for sch in schedules): + raise ValueError("'{}' not a valid schedule name.".format(value)) + else: + # Verify against STARTUP type schedule and having South, North based service; + # we might filter out source with South and destination with North? + if not any(sch.name == value for sch in schedules + if sch.schedule_type == 1 and sch.process_name in ('south_c', 'north_C')): + raise ValueError("'{}' not a valid service.".format(value)) async def get_control_scripts(): script_payload = PayloadBuilder().SELECT("name").payload() @@ -494,8 +502,8 @@ async def get_notifications(): if not any(notify['child'] == value for notify in all_notifications): raise ValueError("'{}' not a valid notification instance name.".format(value)) - if (lookup_name == "source" and _type in [2, 5]) or (lookup_name == 'destination' and _type == 1): - # Verify schedule name + if (lookup_name == "source" and _type == 2) or (lookup_name == 'destination' and _type == 1): + # Verify schedule name in startup type and south, north based schedules await get_schedules() elif (lookup_name == "source" and _type == 6) or (lookup_name == 'destination' and _type == 3): # Verify control script name @@ -503,6 +511,9 @@ async def get_notifications(): elif lookup_name == "source" and _type == 4: # Verify notification instance name await get_notifications() + elif lookup_name == "source" and _type == 5: + # Verify schedule name in all type of schedules + await get_schedules() elif lookup_name == "destination" and _type == 2: # Verify asset name await get_assets() From f775b6073737b4a05cc771f1b6ab1f5084391748 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Apr 2023 15:02:17 +0530 Subject: [PATCH 275/499] Update system API tests updated as per new audit log codes Signed-off-by: ashish-jabble --- tests/system/python/api/test_audit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/system/python/api/test_audit.py b/tests/system/python/api/test_audit.py index 0a324fe2b3..712e33a34e 100644 --- a/tests/system/python/api/test_audit.py +++ b/tests/system/python/api/test_audit.py @@ -29,7 +29,8 @@ def test_get_log_codes(self, fledge_url, reset_and_start_fledge): 'SRVFL', 'SRVRS', 'NHCOM', 'NHDWN', 'NHAVL', 'UPEXC', 'BKEXC', 'NTFDL', 'NTFAD', 'NTFSN', 'NTFCL', 'NTFST', 'NTFSD', 'PKGIN', 'PKGUP', 'PKGRM', 'DSPST', 'DSPSD', - 'ESSRT', 'ESSTP', 'ASTDP', 'ASTUN', 'PIPIN', 'AUMRK'] + 'ESSRT', 'ESSTP', 'ASTDP', 'ASTUN', 'PIPIN', 'AUMRK', + 'USRAD', 'USRDL', 'USRCH', 'USRRS'] conn = http.client.HTTPConnection(fledge_url) conn.request("GET", '/fledge/audit/logcode') r = conn.getresponse() From f7b54fdf726c164c23dca93aec8e695c4c755645 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Apr 2023 16:25:52 +0530 Subject: [PATCH 276/499] exception logger statements updated as per multiline traceback support Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 8 +++----- .../microservice_management_client.py | 13 ++++++------- python/fledge/common/plugin_discovery.py | 2 +- python/fledge/common/statistics.py | 18 +++++++++--------- python/fledge/services/common/microservice.py | 2 +- python/fledge/services/common/utils.py | 2 +- python/fledge/services/core/api/service.py | 2 +- .../core/asset_tracker/asset_tracker.py | 2 +- .../core/interest_registry/change_callback.py | 8 +++++--- .../services/core/scheduler/scheduler.py | 4 ++-- python/fledge/services/core/server.py | 18 +++++++++--------- python/fledge/services/south/ingest.py | 4 ++-- python/fledge/tasks/purge/purge.py | 2 +- 13 files changed, 42 insertions(+), 43 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index d6647f9002..20e351531a 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -673,7 +673,7 @@ async def update_configuration_item_bulk(self, category_name, config_item_list): await audit.information('CONCH', audit_details) except Exception as ex: - _logger.exception('Unable to bulk update config items %s', str(ex)) + _logger.exception(ex, 'Unable to bulk update config items') raise try: @@ -751,8 +751,7 @@ async def get_all_category_names(self, root=None, children=False): children) if root is not None else await self._read_all_category_names() return info except: - _logger.exception( - 'Unable to read all category names') + _logger.exception('Unable to read all category names') raise async def get_category_all_items(self, category_name): @@ -782,8 +781,7 @@ async def get_category_all_items(self, category_name): category["display_name"]) return category_value except: - _logger.exception( - 'Unable to get all category names based on category_name %s', category_name) + _logger.exception('Unable to get all category names based on category_name {}'.format(category_name)) raise async def get_category_item(self, category_name, item_name): diff --git a/python/fledge/common/microservice_management_client/microservice_management_client.py b/python/fledge/common/microservice_management_client/microservice_management_client.py index 70196433fb..cebd9431d0 100644 --- a/python/fledge/common/microservice_management_client/microservice_management_client.py +++ b/python/fledge/common/microservice_management_client/microservice_management_client.py @@ -55,7 +55,8 @@ def register_service(self, service_registration_payload): try: response["id"] except (KeyError, Exception) as ex: - _logger.exception("Could not register the microservice, From request %s, Reason: %s", json.dumps(service_registration_payload), str(ex)) + _logger.exception(ex, "Could not register the microservice, From request {}".format( + json.dumps(service_registration_payload))) raise return response @@ -86,8 +87,7 @@ def unregister_service(self, microservice_id): try: response["id"] except (KeyError, Exception) as ex: - _logger.exception("Could not unregister the micro-service having uuid %s, Reason: %s", - microservice_id, str(ex)) + _logger.exception(ex, "Could not unregister the micro-service having uuid {}".format(microservice_id)) raise return response @@ -117,8 +117,7 @@ def register_interest(self, category, microservice_id): try: response["id"] except (KeyError, Exception) as ex: - _logger.exception("Could not register interest, for request payload %s, Reason: %s", - payload, str(ex)) + _logger.exception(ex, "Could not register interest, for request payload {}".format(payload)) raise return response @@ -145,7 +144,7 @@ def unregister_interest(self, registered_interest_id): try: response["id"] except (KeyError, Exception) as ex: - _logger.exception("Could not unregister interest for %s, Reason: %s", registered_interest_id, str(ex)) + _logger.exception(ex, "Could not unregister interest for {}".format(registered_interest_id)) raise return response @@ -179,7 +178,7 @@ def get_services(self, service_name=None, service_type=None): try: response["services"] except (KeyError, Exception) as ex: - _logger.exception("Could not find the micro-service for requested url %s, Reason: %s", url, str(ex)) + _logger.exception(ex, "Could not find the micro-service for requested url {}".format(url)) raise return response diff --git a/python/fledge/common/plugin_discovery.py b/python/fledge/common/plugin_discovery.py index 18f048b039..73aba5c8fc 100644 --- a/python/fledge/common/plugin_discovery.py +++ b/python/fledge/common/plugin_discovery.py @@ -206,7 +206,7 @@ def get_plugin_config(cls, plugin_dir, plugin_type, installed_dir_name, is_confi except FileNotFoundError as ex: _logger.error('Plugin "{}" import problem from path "{}". {}'.format(plugin_dir, plugin_module_path, str(ex))) except Exception as ex: - _logger.exception('Plugin "{}" raised exception "{}" while fetching config'.format(plugin_dir, str(ex))) + _logger.exception(ex, 'Plugin "{}" failed while fetching config'.format(plugin_dir)) return plugin_config diff --git a/python/fledge/common/statistics.py b/python/fledge/common/statistics.py index 306c480b5f..b27c1c5de4 100644 --- a/python/fledge/common/statistics.py +++ b/python/fledge/common/statistics.py @@ -70,7 +70,7 @@ async def update_bulk(self, stat_list): payload['updates'].append(json.loads(payload_item)) await self._storage.update_tbl("statistics", json.dumps(payload, sort_keys=False)) except Exception as ex: - _logger.exception('Unable to bulk update statistics %s', str(ex)) + _logger.exception(ex, 'Unable to bulk update statistics') raise async def update(self, key, value_increment): @@ -96,9 +96,9 @@ async def update(self, key, value_increment): .payload() await self._storage.update_tbl("statistics", payload) except Exception as ex: - _logger.exception( - 'Unable to update statistics value based on statistics_key %s and value_increment %d, error %s' - , key, value_increment, str(ex)) + msg = 'Unable to update statistics value based on statistics_key {} and value_increment {}'.format( + key, value_increment) + _logger.exception(ex, msg) raise async def add_update(self, sensor_stat_dict): @@ -125,9 +125,9 @@ async def add_update(self, sensor_stat_dict): _logger.exception('Statistics key %s has not been registered', key) raise except Exception as ex: - _logger.exception( - 'Unable to update statistics value based on statistics_key %s and value_increment %s, error %s' - , key, value_increment, str(ex)) + msg = 'Unable to update statistics value based on statistics_key {} and value_increment {}'.format( + key, value_increment) + _logger.exception(ex, msg) raise async def register(self, key, description): @@ -143,7 +143,7 @@ async def register(self, key, description): """ The error may be because the key has been created in another process, reload keys """ await self._load_keys() if key not in self._registered_keys: - _logger.exception('Unable to create new statistic %s, error %s', key, str(ex)) + _logger.exception(ex, 'Unable to create new statistic {}'.format(key)) raise async def _load_keys(self): @@ -154,4 +154,4 @@ async def _load_keys(self): for row in results['rows']: self._registered_keys.append(row['key']) except Exception as ex: - _logger.exception('Failed to retrieve statistics keys, %s', str(ex)) + _logger.exception(ex, 'Failed to retrieve statistics keys') diff --git a/python/fledge/services/common/microservice.py b/python/fledge/services/common/microservice.py index f4df44afdc..65c9cc6488 100644 --- a/python/fledge/services/common/microservice.py +++ b/python/fledge/services/common/microservice.py @@ -85,7 +85,7 @@ def __init__(self): res = self.register_service_with_core(self._get_service_registration_payload()) self._microservice_id = res["id"] except Exception as ex: - _logger.exception('Unable to intialize FledgeMicroservice due to exception %s', str(ex)) + _logger.exception(ex, 'Unable to initialize FledgeMicroservice') raise def _make_microservice_management_app(self): diff --git a/python/fledge/services/common/utils.py b/python/fledge/services/common/utils.py index 745ccd9d47..6b9e1b3652 100644 --- a/python/fledge/services/common/utils.py +++ b/python/fledge/services/common/utils.py @@ -63,7 +63,7 @@ async def shutdown_service(service, loop=None): if not status_code == 200: raise Exception(message=text) except Exception as ex: - _logger.exception('Error in Service shutdown %s, %s', service._name, str(ex)) + _logger.exception(ex, 'Error in {} Service shutdown'.format(service._name)) return False else: _logger.info('Service %s, id %s at url %s successfully shutdown', service._name, service._id, url_shutdown) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index 1eb41e8118..4d16c5280d 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -361,7 +361,7 @@ async def add_service(request): plugin_module_path = "{}/plugins/{}/{}".format(_FLEDGE_ROOT, service_type, plugin) if not plugin_config: msg = "Plugin '{}' not found in path '{}'.".format(plugin, plugin_module_path) - _logger.exception("{} Detailed error logs are: {}".format(msg, str(ex))) + _logger.exception(ex, msg) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except TypeError as ex: raise web.HTTPBadRequest(reason=str(ex)) diff --git a/python/fledge/services/core/asset_tracker/asset_tracker.py b/python/fledge/services/core/asset_tracker/asset_tracker.py index 14f209c53e..e0be33965c 100644 --- a/python/fledge/services/core/asset_tracker/asset_tracker.py +++ b/python/fledge/services/core/asset_tracker/asset_tracker.py @@ -46,7 +46,7 @@ async def load_asset_records(self): for row in results['rows']: self._registered_asset_records.append(row) except Exception as ex: - _logger.exception('Failed to retrieve asset records, %s', str(ex)) + _logger.exception(ex, 'Failed to retrieve asset records') async def add_asset_record(self, *, asset, event, service, plugin, jsondata = {}): """ diff --git a/python/fledge/services/core/interest_registry/change_callback.py b/python/fledge/services/core/interest_registry/change_callback.py index b5f54ed95f..cbd4d18263 100644 --- a/python/fledge/services/core/interest_registry/change_callback.py +++ b/python/fledge/services/core/interest_registry/change_callback.py @@ -64,7 +64,7 @@ async def run(category_name): if status_code in range(500, 600): _LOGGER.error("Server error code: %d, reason: %s", status_code, resp.reason) except Exception as ex: - _LOGGER.exception("Unable to notify microservice with uuid %s due to exception: %s", i._microservice_uuid, str(ex)) + _LOGGER.exception(ex, "Unable to notify microservice with uuid {}".format(i._microservice_uuid)) continue @@ -110,9 +110,10 @@ async def run_child_create(parent_category_name, child_category_list): if status_code in range(500, 600): _LOGGER.error("Server error code: %d, reason: %s", status_code, resp.reason) except Exception as ex: - _LOGGER.exception("Unable to notify microservice with uuid %s due to exception: %s", i._microservice_uuid, str(ex)) + _LOGGER.exception(ex, "Unable to notify microservice with uuid {}".format(i._microservice_uuid)) continue + async def run_child_delete(parent_category_name, child_category): """ Call the child_delete Management API @@ -154,9 +155,10 @@ async def run_child_delete(parent_category_name, child_category): if status_code in range(500, 600): _LOGGER.error("Server error code: %d, reason: %s", status_code, resp.reason) except Exception as ex: - _LOGGER.exception("Unable to notify microservice with uuid %s due to exception: %s", i._microservice_uuid, str(ex)) + _LOGGER.exception(ex, "Unable to notify microservice with uuid {}".format(i._microservice_uuid)) continue + async def run_child(parent_category_name, child_category_list, operation): """ Callback run by configuration category to notify changes to interested microservices diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index ba505d5c20..a1c96baef1 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -847,7 +847,7 @@ async def stop(self): try: await self._purge_tasks_task except Exception as ex: - self._logger.exception('An exception was raised by Scheduler._purge_tasks %s', str(ex)) + self._logger.exception(ex, 'An exception was raised by Scheduler._purge_tasks.') self._resume_check_schedules() @@ -855,7 +855,7 @@ async def stop(self): try: await self._scheduler_loop_task except Exception as ex: - self._logger.exception('An exception was raised by Scheduler._scheduler_loop %s', str(ex)) + self._logger.exception(ex, 'An exception was raised by Scheduler._scheduler_loop') self._scheduler_loop_task = None # Can not iterate over _task_processes - it can change mid-iteration diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index a0ef881d72..48833300d8 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -479,7 +479,7 @@ async def rest_api_config(cls): port_from_config, type(port_from_config)) raise except Exception as ex: - _logger.exception(str(ex)) + _logger.exception(ex) raise @classmethod @@ -505,7 +505,7 @@ async def service_config(cls): except KeyError: cls._service_description = 'Fledge REST Services' except Exception as ex: - _logger.exception(str(ex)) + _logger.exception(ex) raise @classmethod @@ -526,7 +526,7 @@ async def installation_config(cls): cls._package_cache_manager = {"update": {"last_accessed_time": ""}, "upgrade": {"last_accessed_time": ""}, "list": {"last_accessed_time": ""}} except Exception as ex: - _logger.exception(str(ex)) + _logger.exception(ex) raise @classmethod @@ -545,7 +545,7 @@ async def core_logger_setup(cls): from fledge.common.logger import FLCoreLogger FLCoreLogger().set_level(cls._log_level) except Exception as ex: - _logger.exception(str(ex)) + _logger.exception(ex) raise @staticmethod @@ -614,7 +614,7 @@ def __start_storage(host, m_port): '--port={}'.format(m_port)] subprocess.call(cmd_with_args, cwd=_SCRIPTS_DIR) except Exception as ex: - _logger.exception(str(ex)) + _logger.exception(ex) @classmethod async def _start_storage(cls, loop): @@ -1078,7 +1078,7 @@ async def stop_microservices(cls): except service_registry_exceptions.DoesNotExist: pass except Exception as ex: - _logger.exception(str(ex)) + _logger.exception(ex) @classmethod async def _request_microservice_shutdown(cls, svc): @@ -1143,7 +1143,7 @@ def get_process_id(name): except service_registry_exceptions.DoesNotExist: pass except Exception as ex: - _logger.exception(str(ex)) + _logger.exception(ex) @classmethod async def _stop_scheduler(cls): @@ -1289,7 +1289,7 @@ async def unregister(cls, request): cls._audit = AuditLogger(cls._storage_client_async) await cls._audit.information('SRVUN', {'name': services[0]._name}) except Exception as ex: - _logger.exception(str(ex)) + _logger.exception(ex) _resp = {'id': str(service_id), 'message': 'Service unregistered'} @@ -1320,7 +1320,7 @@ async def restart_service(cls, request): cls._audit = AuditLogger(cls._storage_client_async) await cls._audit.information('SRVRS', {'name': services[0]._name}) except Exception as ex: - _logger.exception(str(ex)) + _logger.exception(ex) _resp = {'id': str(service_id), 'message': 'Service restart requested'} diff --git a/python/fledge/services/south/ingest.py b/python/fledge/services/south/ingest.py index 36d6ddc56d..bae967c3b6 100644 --- a/python/fledge/services/south/ingest.py +++ b/python/fledge/services/south/ingest.py @@ -382,7 +382,7 @@ async def _insert_readings(cls): except Exception as ex: attempt += 1 - _LOGGER.exception('Insert failed on attempt #%s, list index: %s | %s', attempt, list_index, str(ex)) + _LOGGER.exception(ex, 'Insert failed on attempt #{}, list index: {}'.format(attempt, list_index)) if cls._stop or attempt >= _MAX_ATTEMPTS: # Stopping. Discard the entire list upon failure. @@ -432,7 +432,7 @@ async def _write_statistics(cls): cls._discarded_readings_stats += discarded_readings for key in sensor_readings: cls._sensor_stats[key] += sensor_readings[key] - _LOGGER.exception('An error occurred while writing sensor statistics, Error: %s', str(ex)) + _LOGGER.exception(ex, 'An error occurred while writing sensor statistics') @classmethod def is_available(cls) -> bool: diff --git a/python/fledge/tasks/purge/purge.py b/python/fledge/tasks/purge/purge.py index fa2272c97d..7adb0155ff 100644 --- a/python/fledge/tasks/purge/purge.py +++ b/python/fledge/tasks/purge/purge.py @@ -273,4 +273,4 @@ async def run(self): await self.purge_stats_history(config) await self.purge_audit_trail_log(config) except Exception as ex: - self._logger.exception(str(ex)) + self._logger.exception(ex) From e9d5060185d45191c7d4c06eb44c822fe0dfec81 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Apr 2023 18:36:50 +0530 Subject: [PATCH 277/499] error logger statements updated as per multiline traceback support Signed-off-by: ashish-jabble --- python/fledge/common/acl_manager.py | 2 +- python/fledge/common/audit_logger.py | 2 +- python/fledge/common/configuration_manager.py | 2 +- python/fledge/common/jqfilter.py | 4 +-- python/fledge/common/plugin_discovery.py | 2 +- python/fledge/common/process.py | 2 +- .../fledge/services/core/api/asset_tracker.py | 8 ++--- python/fledge/services/core/api/audit.py | 4 +-- python/fledge/services/core/api/auth.py | 16 +++++----- .../services/core/api/backup_restore.py | 14 ++++----- python/fledge/services/core/api/browser.py | 30 +++++++++---------- python/fledge/services/core/api/common.py | 6 ++-- .../fledge/services/core/api/configuration.py | 24 +++++++-------- .../services/core/api/control_pipeline.py | 12 ++++---- .../api/control_service/acl_management.py | 12 ++++---- .../api/control_service/script_management.py | 12 ++++---- python/fledge/services/core/api/filters.py | 12 ++++---- python/fledge/services/core/api/health.py | 20 ++++++------- python/fledge/services/core/api/north.py | 2 +- .../fledge/services/core/api/notification.py | 28 ++++++++--------- .../fledge/services/core/api/package_log.py | 2 +- .../fledge/services/core/api/plugins/data.py | 8 ++--- .../services/core/api/plugins/discovery.py | 2 +- .../services/core/api/plugins/install.py | 2 +- .../services/core/api/plugins/remove.py | 6 ++-- .../services/core/api/plugins/update.py | 4 +-- .../services/core/api/repos/configure.py | 6 ++-- python/fledge/services/core/api/service.py | 10 +++---- python/fledge/services/core/api/support.py | 6 ++-- python/fledge/services/core/api/task.py | 13 ++++---- python/fledge/services/core/api/update.py | 8 ++--- python/fledge/services/core/api/utils.py | 8 ++--- python/fledge/services/core/connect.py | 4 +-- python/fledge/services/core/snapshot.py | 6 ++-- python/fledge/services/core/support.py | 4 +-- 35 files changed, 151 insertions(+), 152 deletions(-) diff --git a/python/fledge/common/acl_manager.py b/python/fledge/common/acl_manager.py index 465b7e6380..bbf2c26ed0 100644 --- a/python/fledge/common/acl_manager.py +++ b/python/fledge/common/acl_manager.py @@ -95,7 +95,7 @@ async def _notify_service_about_acl_change(self, entity_name, acl, reason): self._pending_notifications.pop(entity_name) except Exception as ex: - _logger.error("Could not notify {} due to {}".format(entity_name, str(ex))) + _logger.error(ex, "Could not notify {}.".format(entity_name)) async def handle_update_for_acl_usage(self, entity_name, acl_name, entity_type, message="updateACL"): diff --git a/python/fledge/common/audit_logger.py b/python/fledge/common/audit_logger.py index 450770bbe2..937c497db5 100644 --- a/python/fledge/common/audit_logger.py +++ b/python/fledge/common/audit_logger.py @@ -60,7 +60,7 @@ async def _log(self, level, code, log): await self._storage.insert_into_tbl("log", payload) except (StorageServerError, Exception) as ex: - _logger.error("Failed to log audit trail entry '{}': {}".format(code, str(ex))) + _logger.error(ex, "Failed to log audit trail entry '{}'.".format(code)) raise ex async def success(self, code, log): diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 20e351531a..7c273dfb4d 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -1473,7 +1473,7 @@ def delete_category_related_things(self, category_name): _logger.info("Removing file %s for category %s", f, category_name) os.remove(f) except Exception as ex: - _logger.error('Failed to delete file(s) for category %s. Exception %s', category_name, str(ex)) + _logger.error(ex, 'Failed to delete file(s) for category {}.'.format(category_name)) # raise ex def register_interest_child(self, category_name, callback): diff --git a/python/fledge/common/jqfilter.py b/python/fledge/common/jqfilter.py index 7127fe0e54..c14ecd0988 100644 --- a/python/fledge/common/jqfilter.py +++ b/python/fledge/common/jqfilter.py @@ -45,8 +45,8 @@ def transform(self, reading_block, filter_string): try: return pyjq.all(filter_string, reading_block) except TypeError as ex: - self._logger.error("Invalid JSON passed, exception %s", str(ex)) + self._logger.error(ex, "Invalid JSON passed during jq transform.") raise except ValueError as ex: - self._logger.error("Failed to transform, please check the transformation rule, exception %s", str(ex)) + self._logger.error(ex, "Failed to transform, please check the transformation rule.") raise diff --git a/python/fledge/common/plugin_discovery.py b/python/fledge/common/plugin_discovery.py index 73aba5c8fc..a133ec0394 100644 --- a/python/fledge/common/plugin_discovery.py +++ b/python/fledge/common/plugin_discovery.py @@ -204,7 +204,7 @@ def get_plugin_config(cls, plugin_dir, plugin_type, installed_dir_name, is_confi except DeprecationWarning: _logger.warning('"{}" plugin is deprecated'.format(plugin_dir.split('/')[-1])) except FileNotFoundError as ex: - _logger.error('Plugin "{}" import problem from path "{}". {}'.format(plugin_dir, plugin_module_path, str(ex))) + _logger.error(ex, 'Plugin "{}" import problem from path "{}".'.format(plugin_dir, plugin_module_path)) except Exception as ex: _logger.exception(ex, 'Plugin "{}" failed while fetching config'.format(plugin_dir)) diff --git a/python/fledge/common/process.py b/python/fledge/common/process.py index c6dbdb6ffb..99efe29fff 100644 --- a/python/fledge/common/process.py +++ b/python/fledge/common/process.py @@ -100,7 +100,7 @@ def __init__(self): raise ArgumentParserError("Invalid value {} for optional arg {}".format(kv[1], kv[0])) except ArgumentParserError as ex: - _logger.error("Arg parser error: %s", str(ex)) + _logger.error(ex, "Arg parser error.") raise self._core_microservice_management_client = MicroserviceManagementClient(self._core_management_host, diff --git a/python/fledge/services/core/api/asset_tracker.py b/python/fledge/services/core/api/asset_tracker.py index facb90c181..628d18c8b2 100644 --- a/python/fledge/services/core/api/asset_tracker.py +++ b/python/fledge/services/core/api/asset_tracker.py @@ -69,7 +69,7 @@ async def get_asset_tracker_events(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to get asset tracker events. {}".format(msg)) + _logger.error(ex, "Failed to get asset tracker events.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'track': response}) @@ -144,8 +144,8 @@ async def deprecate_asset_track_entry(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Deprecate {} asset entry failed for {} service with {} event. {}".format( - asset_name, svc_name, event_name, msg)) + _logger.error(ex, "Deprecate {} asset entry failed for {} service with {} event.".format( + asset_name, svc_name, event_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "For {} event, {} asset record entry has been deprecated.".format(event_name, asset_name) @@ -239,7 +239,7 @@ async def get_datapoint_usage(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=err_response, body=json.dumps({"message": err_response})) except Exception as ex: msg = str(ex) - _logger.error("Failed to get asset tracker store datapoints. {}".format(msg)) + _logger.error(ex, "Failed to get asset tracker store datapoints.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) diff --git a/python/fledge/services/core/api/audit.py b/python/fledge/services/core/api/audit.py index 6a9b1b3bc0..ff294cbb88 100644 --- a/python/fledge/services/core/api/audit.py +++ b/python/fledge/services/core/api/audit.py @@ -130,7 +130,7 @@ async def create_audit_entry(request): raise web.HTTPInternalServerError(reason=err_msg, body=json.dumps({"message": err_msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to log audit entry. {}".format(msg)) + _logger.error(ex, "Failed to log audit entry.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(message) @@ -270,7 +270,7 @@ async def get_audit_entries(request): res.append(r) except Exception as ex: msg = str(ex) - _logger.error("Get Audit log entry failed. {}".format(msg)) + _logger.error(ex, "Get Audit log entry failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'audit': res, 'totalCount': total_count}) diff --git a/python/fledge/services/core/api/auth.py b/python/fledge/services/core/api/auth.py index f6e8976ce0..5c40b06bf8 100644 --- a/python/fledge/services/core/api/auth.py +++ b/python/fledge/services/core/api/auth.py @@ -216,7 +216,7 @@ async def get_ott(request): is_admin = True except Exception as ex: msg = str(ex) - _logger.error("OTT token failed. {}".format(msg)) + _logger.error(ex, "OTT token failed.") raise web.HTTPBadRequest(reason="The request failed due to {}".format(msg)) else: now_time = datetime.datetime.now() @@ -440,7 +440,7 @@ async def create_user(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to create user. {}".format(msg)) + _logger.error(exc, "Failed to create user.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) msg = "{} user has been created successfully.".format(username) _logger.info(msg) @@ -488,7 +488,7 @@ async def update_me(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to update the user <{}> profile. {}".format(int(user_id), msg)) + _logger.error(exc, "Failed to update the user <{}> profile.".format(int(user_id))) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "Nothing to update." @@ -558,7 +558,7 @@ async def update_user(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to update the user ID:<{}>. {}".format(user_id, msg)) + _logger.error(exc, "Failed to update the user ID:<{}>.".format(user_id)) raise web.HTTPInternalServerError(reason=str(exc), body=json.dumps({"message": msg})) return web.json_response({'user_info': user_info}) @@ -616,7 +616,7 @@ async def update_password(request): raise web.HTTPBadRequest(reason=msg) except Exception as exc: msg = str(exc) - _logger.error("Failed to update the user ID:<{}>. {}".format(user_id, msg)) + _logger.error(exc, "Failed to update the user ID:<{}>.".format(user_id)) raise web.HTTPInternalServerError(reason=msg) msg = "Password has been updated successfully for user ID:<{}>.".format(int(user_id)) @@ -680,7 +680,7 @@ async def enable_user(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to enable/disable user ID:<{}>. {}".format(user_id, msg)) + _logger.error(exc, "Failed to enable/disable user ID:<{}>.".format(user_id)) raise web.HTTPInternalServerError(reason=str(exc), body=json.dumps({"message": msg})) return web.json_response({'message': 'User with ID:<{}> has been {} successfully.'.format(int(user_id), _text)}) @@ -743,7 +743,7 @@ async def reset(request): raise web.HTTPBadRequest(reason=msg) except Exception as exc: msg = str(exc) - _logger.error("Failed to reset the user ID:<{}>. {}".format(user_id, msg)) + _logger.error(exc, "Failed to reset the user ID:<{}>.".format(user_id)) raise web.HTTPInternalServerError(reason=msg) msg = "User with ID:<{}> has been updated successfully.".format(int(user_id)) @@ -794,7 +794,7 @@ async def delete_user(request): raise web.HTTPNotFound(reason=msg) except Exception as exc: msg = str(exc) - _logger.error("Failed to delete the user ID:<{}>. {}".format(user_id, msg)) + _logger.error(exc, "Failed to delete the user ID:<{}>.".format(user_id)) raise web.HTTPInternalServerError(reason=msg) _logger.info("User with ID:<{}> has been deleted successfully.".format(int(user_id))) diff --git a/python/fledge/services/core/api/backup_restore.py b/python/fledge/services/core/api/backup_restore.py index 02a7b625c2..ce52c34839 100644 --- a/python/fledge/services/core/api/backup_restore.py +++ b/python/fledge/services/core/api/backup_restore.py @@ -116,7 +116,7 @@ async def get_backups(request): res.append(r) except Exception as ex: msg = str(ex) - _logger.error("Get all backups failed. {}".format(msg)) + _logger.error(ex, "Get all backups failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"backups": res}) @@ -131,7 +131,7 @@ async def create_backup(request): status = await backup.create_backup() except Exception as ex: msg = str(ex) - _logger.error("Failed to create Backup. {}".format(msg)) + _logger.error(ex, "Failed to create Backup.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"status": status}) @@ -158,7 +158,7 @@ async def get_backup_details(request): raise web.HTTPNotFound(reason='Backup id {} does not exist'.format(backup_id)) except Exception as ex: msg = str(ex) - _logger.error("Failed to fetch backup details for ID: <{}>. {}".format(backup_id, msg)) + _logger.error(ex, "Failed to fetch backup details for ID: <{}>.".format(backup_id)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response(resp) @@ -206,7 +206,7 @@ async def get_backup_download(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to backup download for ID:<{}>. {}".format(backup_id, msg)) + _logger.error(ex, "Failed to backup download for ID: <{}>.".format(backup_id)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.FileResponse(path=gz_path) @@ -229,7 +229,7 @@ async def delete_backup(request): raise web.HTTPNotFound(reason='Backup id {} does not exist'.format(backup_id)) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete Backup ID:<{}>. {}".format(backup_id, msg)) + _logger.error(ex, "Failed to delete Backup ID: <{}>.".format(backup_id)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -252,7 +252,7 @@ async def restore_backup(request): raise web.HTTPNotFound(reason='Backup with {} does not exist'.format(backup_id)) except Exception as ex: msg = str(ex) - _logger.error("Failed to restore Backup ID:<{}>. {}".format(backup_id, msg)) + _logger.error(ex, "Failed to restore Backup ID: <{}>.".format(backup_id)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -357,7 +357,7 @@ async def upload_backup(request: web.Request) -> web.Response: raise web.HTTPNotImplemented(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to upload Backup. {}".format(msg)) + _logger.error(exc, "Failed to upload Backup.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "{} backup uploaded successfully.".format(file_name) diff --git a/python/fledge/services/core/api/browser.py b/python/fledge/services/core/api/browser.py index 2b3939d020..b8e33762dd 100644 --- a/python/fledge/services/core/api/browser.py +++ b/python/fledge/services/core/api/browser.py @@ -154,7 +154,7 @@ async def asset_counts(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to get all assets. {}".format(msg)) + _logger.error(exc, "Failed to get all assets.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(asset_json) @@ -240,7 +240,7 @@ async def asset(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to get {} asset. {}".format(asset_code, msg)) + _logger.error(exc, "Failed to get {} asset.".format(asset_code)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -267,7 +267,7 @@ async def asset_latest(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to get latest {} asset. {}".format(asset_code, msg)) + _logger.error(exc, "Failed to get latest {} asset.".format(asset_code)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -336,7 +336,7 @@ async def asset_reading(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to get {} asset for {} reading. {}".format(asset_code, reading, msg)) + _logger.error(exc, "Failed to get {} asset for {} reading.".format(asset_code, reading)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -412,7 +412,7 @@ async def asset_all_readings_summary(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to get {} asset readings summary. {}".format(asset_code, msg)) + _logger.error(exc, "Failed to get {} asset readings summary.".format(asset_code)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -478,7 +478,7 @@ async def asset_summary(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to get {} asset {} reading summary. {}".format(asset_code, reading, msg)) + _logger.error(exc, "Failed to get {} asset {} reading summary.".format(asset_code, reading)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({reading: response}) @@ -580,7 +580,7 @@ async def asset_averages(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to get average of {} readings for {} asset. {}".format(reading, asset_code, msg)) + _logger.error(exc, "Failed to get average of {} readings for {} asset.".format(reading, asset_code)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -715,7 +715,7 @@ async def asset_datapoints_with_bucket_size(request: web.Request) -> web.Respons raise web.HTTPBadRequest(reason=e) except Exception as ex: msg = str(ex) - _logger.error("Failed to get {} asset datapoints with {} bucket size. {}".format(asset_code, bucket_size, msg)) + _logger.error(ex, "Failed to get {} asset datapoints with {} bucket size.".format(asset_code, bucket_size)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -794,8 +794,8 @@ async def asset_readings_with_bucket_size(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=e) except Exception as ex: msg = str(ex) - _logger.error("Failed to get {} readings of {} asset with {} bucket size. {}".format( - reading, asset_code, bucket_size, msg)) + _logger.error(ex, "Failed to get {} readings of {} asset with {} bucket size.".format( + reading, asset_code, bucket_size)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -863,7 +863,7 @@ async def asset_structure(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to get assets structure. {}".format(msg)) + _logger.error(ex, "Failed to get assets structure.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(asset_json) @@ -905,7 +905,7 @@ async def asset_purge_all(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to purge all assets. {}".format(msg)) + _logger.error(exc, "Failed to purge all assets.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(results) @@ -946,7 +946,7 @@ async def asset_purge(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to purge {} asset. {}".format(asset_code, msg)) + _logger.error(exc, "Failed to purge {} asset.".format(asset_code)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(results) @@ -975,7 +975,7 @@ async def asset_timespan(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to get timespan of buffered readings for assets. {}".format(msg)) + _logger.error(exc, "Failed to get timespan of buffered readings for assets.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -1006,7 +1006,7 @@ async def asset_reading_timespan(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _logger.error("Failed to get timespan of buffered readings for {} asset. {}".format(asset_code, msg)) + _logger.error(exc, "Failed to get timespan of buffered readings for {} asset.".format(asset_code)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) diff --git a/python/fledge/services/core/api/common.py b/python/fledge/services/core/api/common.py index cbfa72a786..44bc68ba12 100644 --- a/python/fledge/services/core/api/common.py +++ b/python/fledge/services/core/api/common.py @@ -160,7 +160,7 @@ async def shutdown(request): raise web.HTTPRequestTimeout(reason=str(err)) except Exception as ex: msg = str(ex) - _logger.error("Error while stopping Fledge server: {}".format(msg)) + _logger.error(ex, "Error while stopping Fledge server.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -170,7 +170,7 @@ def do_shutdown(request): loop = request.loop asyncio.ensure_future(server.Server.shutdown(request), loop=loop) except RuntimeError as e: - _logger.error("Error while stopping Fledge server: {}".format(str(e))) + _logger.error(e, "Error while stopping Fledge server.") raise @@ -189,5 +189,5 @@ async def restart(request): raise web.HTTPRequestTimeout(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Error while stopping Fledge server: {}".format(msg)) + _logger.error(ex, "Error while stopping Fledge server.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) diff --git a/python/fledge/services/core/api/configuration.py b/python/fledge/services/core/api/configuration.py index 24ff08ddd0..6bcc41ff53 100644 --- a/python/fledge/services/core/api/configuration.py +++ b/python/fledge/services/core/api/configuration.py @@ -166,7 +166,7 @@ async def create_category(request): raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Failed to create category. {}".format(msg)) + _logger.error(ex, "Failed to create category.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response(result) @@ -192,7 +192,7 @@ async def delete_category(request): raise web.HTTPBadRequest(reason=ex) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete {} category. {}".format(category_name, msg)) + _logger.error(ex, "Failed to delete {} category.".format(category_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': 'Category {} deleted successfully.'.format(category_name)}) @@ -359,7 +359,7 @@ async def update_configuration_item_bulk(request): raise web.HTTPBadRequest(reason=ex) except Exception as ex: msg = str(ex) - _logger.error("Failed to bulk update {} category. {}".format(category_name, msg)) + _logger.error(ex, "Failed to bulk update {} category.".format(category_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: cat = await cf_mgr.get_category_all_items(category_name) @@ -437,10 +437,11 @@ async def add_configuration_item(request): raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Failed to create {} config item for {} category. {}".format(new_config_item, category_name, msg)) + _logger.error(ex, "Failed to create {} config item for {} category.".format(new_config_item, category_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) - return web.json_response({"message": "{} config item has been saved for {} category".format(new_config_item, category_name)}) + return web.json_response({"message": "{} config item has been saved for {} category".format(new_config_item, + category_name)}) async def delete_configuration_item_value(request): @@ -517,7 +518,7 @@ async def get_child_category(request): raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Failed to get the child {} category. {}".format(category_name, msg)) + _logger.error(ex, "Failed to get the child {} category.".format(category_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"categories": children}) @@ -551,7 +552,7 @@ async def create_child_category(request): raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Failed to create the child relationship for {} category. {}".format(category_name, msg)) + _logger.error(ex, "Failed to create the child relationship for {} category.".format(category_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response(r) @@ -582,8 +583,7 @@ async def delete_child_category(request): raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete the {} child of {} category. {}".format( - child_category, category_name, msg)) + _logger.error(ex, "Failed to delete the {} child of {} category.".format(child_category, category_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"children": result}) @@ -612,7 +612,7 @@ async def delete_parent_category(request): raise web.HTTPNotFound(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete the parent-child relationship of {} category. {}".format(category_name, msg)) + _logger.error(ex, "Failed to delete the parent-child relationship of {} category.".format(category_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"message": "Parent-child relationship for the parent-{} is deleted".format(category_name)}) @@ -678,8 +678,8 @@ async def upload_script(request): except Exception as ex: os.remove(script_file_path) msg = str(ex) - _logger.error("Failed to upload script for {} config item of {} category. {}".format( - config_item, category_name, msg)) + _logger.error(ex, "Failed to upload script for {} config item of {} category.".format(config_item, + category_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: result = await cf_mgr.get_category_item(category_name, config_item) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index f0115525dc..3e6cab1168 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -66,7 +66,7 @@ async def get_lookup(request: web.Request) -> web.Response: response = {'controlLookup': lookup} except Exception as ex: msg = str(ex) - _logger.error("Failed to get all control lookups. {}".format(msg)) + _logger.error(ex, "Failed to get all control lookups.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -132,7 +132,7 @@ async def create(request: web.Request) -> web.Response: raise web.HTTPBadRequest(body=json.dumps({"message": msg}), reason=msg) except Exception as ex: msg = str(ex) - _logger.error("Failed to create pipeline: {}. {}".format(data.get('name'), msg)) + _logger.error(ex, "Failed to create pipeline: {}.".format(data.get('name'))) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(final_result) @@ -168,7 +168,7 @@ async def get_all(request: web.Request) -> web.Response: control_pipelines.append(temp) except Exception as ex: msg = str(ex) - _logger.error("Failed to get all pipelines. {}".format(msg)) + _logger.error(ex, "Failed to get all pipelines.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'pipelines': control_pipelines}) @@ -191,7 +191,7 @@ async def get_by_id(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to fetch details of pipeline having ID:{}. {}".format(cpid, msg)) + _logger.error(ex, "Failed to fetch details of pipeline having ID: <{}>.".format(cpid)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(pipeline) @@ -235,7 +235,7 @@ async def update(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to update pipeline having ID:{}. {}".format(cpid, msg)) + _logger.error(ex, "Failed to update pipeline having ID: <{}>.".format(cpid)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response( @@ -266,7 +266,7 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete pipeline having ID:{}. {}".format(cpid, msg)) + _logger.error(ex, "Failed to delete pipeline having ID: <{}>.".format(cpid)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response( diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index fdc622c1bf..7e14014581 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -73,7 +73,7 @@ async def get_acl(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to get {} ACL. {}".format(name, msg)) + _logger.error(ex, "Failed to get {} ACL.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(acl_info) @@ -140,7 +140,7 @@ async def add_acl(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to create ACL. {}".format(msg)) + _logger.error(ex, "Failed to create ACL.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(result) @@ -206,7 +206,7 @@ async def update_acl(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=message, body=json.dumps({"message": message})) except Exception as ex: message = str(ex) - _logger.error("Failed to update {} ACL. {}".format(name, message)) + _logger.error(ex, "Failed to update {} ACL.".format(name)) raise web.HTTPInternalServerError(reason=message, body=json.dumps({"message": message})) else: # Fetch service name associated with acl @@ -264,7 +264,7 @@ async def delete_acl(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete {} ACL. {}".format(name, msg)) + _logger.error(ex, "Failed to delete {} ACL.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) @@ -352,7 +352,7 @@ async def attach_acl_to_service(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Attach ACL to {} service failed. {}".format(svc_name, msg)) + _logger.error(ex, "Attach ACL to {} service failed.".format(svc_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: # Call service security endpoint with attachACL = acl_name @@ -428,7 +428,7 @@ async def detach_acl_from_service(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Detach ACL from {} service failed. {}".format(svc_name, msg)) + _logger.error(ex, "Detach ACL from {} service failed.".format(svc_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index acea219365..b7f0cec5da 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -141,7 +141,7 @@ async def add_schedule_and_configuration(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to add schedule task for control script {}. {}".format(name, msg)) + _logger.error(ex, "Failed to add schedule task for control script {}.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: msg = "Schedule and configuration is created for control script {}".format(name) @@ -191,7 +191,7 @@ async def get_all(request: web.Request) -> web.Response: scripts.append(row) except Exception as ex: msg = str(ex) - _logger.error("Get Control script failed. {}".format(msg)) + _logger.error(ex, "Get Control script failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"scripts": scripts}) @@ -248,7 +248,7 @@ async def get_by_name(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Get Control script by name failed. {}".format(msg)) + _logger.error(ex, "Get Control script by name failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(rows) @@ -334,7 +334,7 @@ async def add(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Control script create failed. {}".format(msg)) + _logger.error(ex, "Control script create failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(result) @@ -426,7 +426,7 @@ async def update(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Control script update failed. {}".format(msg)) + _logger.error(ex, "Control script update failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) @@ -486,7 +486,7 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Control script delete failed. {}".format(msg)) + _logger.error(ex, "Control script delete failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) diff --git a/python/fledge/services/core/api/filters.py b/python/fledge/services/core/api/filters.py index 6c2a8d4c31..44ef07c221 100644 --- a/python/fledge/services/core/api/filters.py +++ b/python/fledge/services/core/api/filters.py @@ -145,7 +145,7 @@ async def create_filter(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _LOGGER.error("Add filter, caught exception: {}".format(msg)) + _LOGGER.error(ex, "Add filter failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -282,7 +282,7 @@ async def add_filters_pipeline(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _LOGGER.error("Add filters pipeline, caught exception: {}".format(msg)) + _LOGGER.error(ex, "Add filters pipeline failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -329,7 +329,7 @@ async def get_filter(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=str(err)) except Exception as ex: msg = str(ex) - _LOGGER.error("Get {} filter, caught exception: {}".format(filter_name, msg)) + _LOGGER.error(ex, "Get {} filter failed.".format(filter_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'filter': filter_detail}) @@ -351,7 +351,7 @@ async def get_filters(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _LOGGER.error("Get all filters, caught exception: {}".format(msg)) + _LOGGER.error(ex, "Get all filters failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'filters': filters}) @@ -385,7 +385,7 @@ async def get_filter_pipeline(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=str(err)) except Exception as ex: msg = str(ex) - _LOGGER.error("Get filter pipeline, caught exception: {}".format(msg)) + _LOGGER.error(ex, "Get filter pipeline failed.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': filter_value_from_storage}) @@ -449,7 +449,7 @@ async def delete_filter(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=str(err)) except Exception as ex: msg = str(ex) - _LOGGER.error("Delete {} filter, caught exception: {}".format(filter_name, msg)) + _LOGGER.error(ex, "Delete {} filter failed.".format(filter_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "Filter {} deleted successfully.".format(filter_name)}) diff --git a/python/fledge/services/core/api/health.py b/python/fledge/services/core/api/health.py index 01fb14fe5c..cc02e87206 100644 --- a/python/fledge/services/core/api/health.py +++ b/python/fledge/services/core/api/health.py @@ -108,9 +108,9 @@ async def get_logging_health(request: web.Request) -> web.Response: response["levels"] = log_levels except Exception as ex: - msg = "Could not fetch service information. {}".format(str(ex)) - _LOGGER.error(msg) - raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + msg = "Could not fetch service information." + _LOGGER.error(ex, msg) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "{} {}".format(msg, str(ex))})) try: response['disk'] = {} @@ -121,9 +121,9 @@ async def get_logging_health(request: web.Request) -> web.Response: response['disk']['available'] = available except Exception as ex: - msg = "Failed to get disk stats for /var/log. {}".format(str(ex)) - _LOGGER.error(msg) - raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + msg = "Failed to get disk stats for /var/log." + _LOGGER.error(ex, msg) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "{} {}".format(msg, str(ex))})) else: return web.json_response(response) @@ -188,7 +188,7 @@ async def get_storage_health(request: web.Request) -> web.Response: except Exception as ex: msg = str(ex) - _LOGGER.error("Could not ping the Storage service. {}".format(msg)) + _LOGGER.error(ex, "Could not ping the Storage service.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) try: @@ -207,8 +207,8 @@ async def get_storage_health(request: web.Request) -> web.Response: response['disk']['available'] = available response['disk']['status'] = status except Exception as ex: - msg = "Failed to get disk stats for Storage service. {}".format(str(ex)) - _LOGGER.error(msg) - raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + msg = "Failed to get disk stats for Storage service." + _LOGGER.error(ex, msg) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "{} {}".format(msg, str(ex))})) else: return web.json_response(response) diff --git a/python/fledge/services/core/api/north.py b/python/fledge/services/core/api/north.py index 09e00b5a09..9cdab722e4 100644 --- a/python/fledge/services/core/api/north.py +++ b/python/fledge/services/core/api/north.py @@ -185,7 +185,7 @@ async def get_north_schedules(request): return web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to get the north schedules. {}".format(msg)) + _logger.error(ex, "Failed to get the north schedules.") return web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(north_schedules) diff --git a/python/fledge/services/core/api/notification.py b/python/fledge/services/core/api/notification.py index 0964de004e..396c1a0e56 100644 --- a/python/fledge/services/core/api/notification.py +++ b/python/fledge/services/core/api/notification.py @@ -57,7 +57,7 @@ async def get_plugin(request): delivery_plugins = json.loads(await _hit_get_url(url)) except Exception as ex: msg = str(ex) - _logger.error("Failed to get notification plugin list. {}".format(msg)) + _logger.error(ex, "Failed to get notification plugin list.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'rules': rule_plugins, 'delivery': delivery_plugins}) @@ -114,7 +114,7 @@ async def get_notification(request): raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Failed to get {} notification instance. {}".format(notif, msg)) + _logger.error(ex, "Failed to get {} notification instance.".format(notif)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'notification': notification}) @@ -152,7 +152,7 @@ async def get_notifications(request): notifications.append(notification) except Exception as ex: msg = str(ex) - _logger.error("Failed to get notification instances. {}".format(msg)) + _logger.error(ex, "Failed to get notification instances.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'notifications': notifications}) @@ -289,7 +289,7 @@ async def post_notification(request): raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Failed to create notification instance. {}".format(msg)) + _logger.error(ex, "Failed to create notification instance.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "Notification {} created successfully".format(name)}) @@ -445,7 +445,7 @@ async def put_notification(request): raise web.HTTPNotFound(reason=str(e)) except Exception as ex: msg = str(ex) - _logger.error("Failed to update {} notification instance. {}".format(notif, msg)) + _logger.error(ex, "Failed to update {} notification instance.".format(notif)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: # TODO: Start notification after update @@ -486,7 +486,7 @@ async def delete_notification(request): raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete {} notification instance. {}".format(notif, msg)) + _logger.error(ex, "Failed to delete {} notification instance.".format(notif)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': 'Notification {} deleted successfully.'.format(notif)}) @@ -539,8 +539,8 @@ async def _update_configurations(config_mgr, name, notification_config, rule_con category_name = "delivery{}".format(name) await config_mgr.update_configuration_item_bulk(category_name, delivery_config) except Exception as ex: - msg = "Failed to update {} notification configuration. {}".format(name, str(ex)) - _logger.error(msg) + msg = "Failed to update {} notification configuration.".format(name) + _logger.error(ex, msg) return web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) @@ -639,7 +639,7 @@ async def get_delivery_channels(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to get delivery channels of {} notification. {}".format(notification_instance_name, msg)) + _logger.error(ex, "Failed to get delivery channels of {} notification.".format(notification_instance_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"channels": channels}) @@ -700,8 +700,7 @@ async def post_delivery_channel(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to create delivery channel of {} notification. {}".format( - notification_instance_name, msg)) + _logger.error(ex, "Failed to create delivery channel of {} notification".format(notification_instance_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"category": channel_name, "description": channel_description, @@ -734,8 +733,8 @@ async def get_delivery_channel_configuration(request: web.Request) -> web.Respon raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to get delivery channel configuration of {} notification. {}".format( - notification_instance_name, msg)) + _logger.error(ex, "Failed to get delivery channel configuration of {} notification.".format( + notification_instance_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"config": channel_config}) @@ -790,8 +789,7 @@ async def delete_delivery_channel(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete delivery channel of {} notification. {}".format( - notification_instance_name, msg)) + _logger.error(ex, "Failed to delete delivery channel of {} notification.".format(notification_instance_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"channels": channels}) diff --git a/python/fledge/services/core/api/package_log.py b/python/fledge/services/core/api/package_log.py index 6dbc1a1aff..81e2b0df00 100644 --- a/python/fledge/services/core/api/package_log.py +++ b/python/fledge/services/core/api/package_log.py @@ -143,7 +143,7 @@ async def get_package_status(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _LOGGER.error("Failed tp get package log status for {} action. {}".format(action, msg)) + _LOGGER.error(exc, "Failed tp get package log status for {} action.".format(action)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"packageStatus": result}) diff --git a/python/fledge/services/core/api/plugins/data.py b/python/fledge/services/core/api/plugins/data.py index 2b99f5ce7b..4376b3cdf7 100644 --- a/python/fledge/services/core/api/plugins/data.py +++ b/python/fledge/services/core/api/plugins/data.py @@ -72,7 +72,7 @@ async def get_persist_plugins(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to get persist plugins. {}".format(msg)) + _logger.error(ex, "Failed to get persist plugins.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'persistent': plugins}) @@ -107,7 +107,7 @@ async def get(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to get {} plugin data for {} service. {}".format(plugin, service, msg)) + _logger.error(ex, "Failed to get {} plugin data for {} service.".format(plugin, service)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'data': data}) @@ -154,7 +154,7 @@ async def add(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to create plugin data. {}".format(msg)) + _logger.error(ex, "Failed to create plugin data.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "{} key added successfully.".format(key)}) @@ -194,7 +194,7 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete {} plugin data for {} service. {}".format(plugin, service, msg)) + _logger.error(ex, "Failed to delete {} plugin data for {} service.".format(plugin, service)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': "{} deleted successfully.".format(key)}) diff --git a/python/fledge/services/core/api/plugins/discovery.py b/python/fledge/services/core/api/plugins/discovery.py index 8412d7110c..8ef665c9e3 100644 --- a/python/fledge/services/core/api/plugins/discovery.py +++ b/python/fledge/services/core/api/plugins/discovery.py @@ -85,7 +85,7 @@ async def get_plugins_available(request: web.Request) -> web.Response: raise web.HTTPBadRequest(body=json.dumps({"message": msg, "link": str(e)}), reason=msg) except Exception as ex: msg = str(ex) - _logger.error("Failed to get plugins available list. {}".format(msg)) + _logger.error(ex, "Failed to get plugins available list.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"plugins": plugins, "link": log_path}) diff --git a/python/fledge/services/core/api/plugins/install.py b/python/fledge/services/core/api/plugins/install.py index 2fefb60ec7..c898ddaba1 100644 --- a/python/fledge/services/core/api/plugins/install.py +++ b/python/fledge/services/core/api/plugins/install.py @@ -190,7 +190,7 @@ async def add_plugin(request: web.Request) -> web.Response: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: msg = str(ex) - _LOGGER.error("Failed to install plugin. {}".format(msg)) + _LOGGER.error(ex, "Failed to install plugin.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index 5784917673..d4dc403e51 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -145,7 +145,7 @@ async def remove_package(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete {} package. {}".format(package_name, msg)) + _logger.error(ex, "Failed to delete {} package.".format(package_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(final_response) @@ -287,7 +287,7 @@ async def remove_plugin(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: msg = str(ex) - _logger.error("Failed to remove {} plugin. {}".format(name, msg)) + _logger.error(ex, "Failed to remove {} plugin.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) @@ -443,6 +443,6 @@ def purge_plugin(plugin_type: str, plugin_name: str, pkg_name: str, version: str raise OSError("While deleting, invalid plugin path found for {}".format(plugin_name)) except Exception as ex: code = 1 - _logger.error("Error in removing plugin: {}".format(str(ex))) + _logger.error(ex, "Error in removing plugin.") _logger.info('{} plugin removed successfully.'.format(plugin_name)) return code, stdout_file_path, is_package diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index cf0f45c6b7..56ace3db88 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -172,7 +172,7 @@ async def update_package(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: msg = str(ex) - _logger.error("Failed to update {} package. {}".format(package_name, msg)) + _logger.error(ex, "Failed to update {} package.".format(package_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(final_response) @@ -313,7 +313,7 @@ async def update_plugin(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "Storage error: {}".format(msg)})) except Exception as ex: msg = str(ex) - _logger.error("Failed to update {} plugin. {}".format(name, msg)) + _logger.error(ex, "Failed to update {} plugin.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({'message': msg})) else: return web.json_response(result_payload) diff --git a/python/fledge/services/core/api/repos/configure.py b/python/fledge/services/core/api/repos/configure.py index e4903e1abf..1fd166010f 100644 --- a/python/fledge/services/core/api/repos/configure.py +++ b/python/fledge/services/core/api/repos/configure.py @@ -136,9 +136,9 @@ async def add_package_repo(request: web.Request) -> web.Response: raise web.HTTPBadRequest(body=json.dumps({"message": "Failed to configure package repository", "output_log": msg}), reason=msg) except Exception as ex: - msg = str(ex) - _LOGGER.error("Failed to configure archive package repository setup. {}".format(msg)) - raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + msg = "Failed to configure archive package repository setup." + _LOGGER.error(ex, msg) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "{} {}".format(msg, str(ex))})) else: return web.json_response({"message": "Package repository configured successfully.", "output_log": stdout_file_path}) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index 4d16c5280d..be9785b9f9 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -366,7 +366,7 @@ async def add_service(request): except TypeError as ex: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: - _logger.error("Failed to fetch plugin info config item. {}".format(str(ex))) + _logger.error(ex, "Failed to fetch plugin info config item.") raise web.HTTPInternalServerError(reason='Failed to fetch plugin configuration') elif service_type == 'notification': if not os.path.exists(_FLEDGE_ROOT + "/services/fledge.services.{}".format(service_type)): @@ -420,7 +420,7 @@ async def add_service(request): _logger.exception("Failed to create scheduled process. %s", ex.error) raise web.HTTPInternalServerError(reason='Failed to create service.') except Exception as ex: - _logger.error("Failed to create scheduled process. %s", str(ex)) + _logger.error(ex, "Failed to create scheduled process.") raise web.HTTPInternalServerError(reason='Failed to create service.') # check that notification service is not already registered, right now notification service LIMIT to 1 @@ -472,8 +472,8 @@ async def add_service(request): await config_mgr.set_category_item_value_entry(name, k, v['value']) except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - msg = "Failed to create plugin configuration while adding service. {}".format(str(ex)) - _logger.error(msg) + msg = "Failed to create plugin configuration while adding service." + _logger.error(ex, msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) # If all successful then lastly add a schedule to run the new service at startup @@ -495,7 +495,7 @@ async def add_service(request): raise web.HTTPInternalServerError(reason='Failed to create service.') except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.error("Failed to create service. %s", str(ex)) + _logger.error(ex, "Failed to create service.") raise web.HTTPInternalServerError(reason='Failed to create service.') except ValueError as err: msg = str(err) diff --git a/python/fledge/services/core/api/support.py b/python/fledge/services/core/api/support.py index 9c358f761f..0a4cadb067 100644 --- a/python/fledge/services/core/api/support.py +++ b/python/fledge/services/core/api/support.py @@ -109,8 +109,8 @@ async def create_support_bundle(request): try: bundle_name = await SupportBuilder(support_dir).build() except Exception as ex: - msg = 'Failed to create support bundle. {}'.format(str(ex)) - _logger.error(msg) + msg = 'Failed to create support bundle.' + _logger.error(ex, msg) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"bundle created": bundle_name}) @@ -221,7 +221,7 @@ async def get_syslog_entries(request): raise web.HTTPBadRequest(body=json.dumps({"message": msg}), reason=msg) except (OSError, Exception) as ex: msg = str(ex) - _logger.error("Failed to get syslog entries. {}".format(msg)) + _logger.error(ex, "Failed to get syslog entries.") raise web.HTTPInternalServerError(body=json.dumps({"message": msg}), reason=msg) return web.json_response(response) diff --git a/python/fledge/services/core/api/task.py b/python/fledge/services/core/api/task.py index 2f50da83bf..6495198a19 100644 --- a/python/fledge/services/core/api/task.py +++ b/python/fledge/services/core/api/task.py @@ -182,8 +182,9 @@ async def add_task(request): except TypeError as ex: raise web.HTTPBadRequest(reason=str(ex)) except Exception as ex: - _logger.error("Failed to fetch plugin configuration due to {}".format(str(ex))) - raise web.HTTPInternalServerError(reason='Failed to fetch plugin configuration.') + msg = "Failed to fetch plugin configuration." + _logger.error(ex, msg) + raise web.HTTPInternalServerError(reason=msg) storage = connect.get_storage_async() config_mgr = ConfigurationManager(storage) @@ -225,7 +226,7 @@ async def add_task(request): _logger.exception("Failed to create scheduled process due to {}".format(ex.error)) raise web.HTTPInternalServerError(reason='Failed to create north instance.') except Exception as ex: - _logger.error("Failed to create scheduled process due to {}".format(str(ex))) + _logger.error(ex, "Failed to create scheduled process.") raise web.HTTPInternalServerError(reason='Failed to create north instance.') # If successful then create a configuration entry from plugin configuration @@ -248,7 +249,7 @@ async def add_task(request): await config_mgr.set_category_item_value_entry(name, k, v['value']) except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.error("Failed to create plugin configuration due to {}".format(str(ex))) + _logger.error(ex, "Failed to create plugin configuration.") raise web.HTTPInternalServerError(reason='Failed to create plugin configuration. {}'.format(ex)) # If all successful then lastly add a schedule to run the new task at startup @@ -277,7 +278,7 @@ async def add_task(request): raise web.HTTPInternalServerError(reason='Failed to create north instance.') except Exception as ex: await config_mgr.delete_category_and_children_recursively(name) - _logger.error("Failed to create north instance due to {}".format(str(ex))) + _logger.error(ex, "Failed to create north instance.") raise web.HTTPInternalServerError(reason='Failed to create north instance.') except ValueError as e: @@ -323,7 +324,7 @@ async def delete_task(request): await update_deprecated_ts_in_asset_tracker(storage, north_instance) except Exception as ex: msg = str(ex) - _logger.error("Failed to delete {} north task. {}".format(north_instance, msg)) + _logger.error(ex, "Failed to delete {} north task.".format(north_instance)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'result': 'North instance {} deleted successfully.'.format(north_instance)}) diff --git a/python/fledge/services/core/api/update.py b/python/fledge/services/core/api/update.py index 18a9bd5c7f..e49e7cb75e 100644 --- a/python/fledge/services/core/api/update.py +++ b/python/fledge/services/core/api/update.py @@ -105,7 +105,7 @@ async def update_package(request): raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error("Failed to update Fledge package.{}".format(msg)) + _logger.error(ex, "Failed to update Fledge package.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"status": "Running", "message": status_message}) @@ -175,8 +175,8 @@ async def get_updates(request: web.Request) -> web.Response: # Make a set to avoid duplicates. upgradable_packages = list(set(packages)) except Exception as ex: - msg = "Failed to fetch upgradable packages list for the configured repository! {}".format(str(ex)) - _logger.error(msg) - raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + msg = "Failed to fetch upgradable packages list for the configured repository!" + _logger.error(ex, msg) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": "{} {}".format(msg, str(ex))})) else: return web.json_response({'updates': upgradable_packages}) diff --git a/python/fledge/services/core/api/utils.py b/python/fledge/services/core/api/utils.py index d28bccf684..cd53290592 100644 --- a/python/fledge/services/core/api/utils.py +++ b/python/fledge/services/core/api/utils.py @@ -28,16 +28,16 @@ def get_plugin_info(name, dir): res = out.decode("utf-8") jdoc = json.loads(res) except (OSError, ValueError) as err: - _logger.error("{} C plugin get info failed due to {}".format(name, str(err))) + _logger.error(err, "{} C plugin get info failed.".format(name)) return {} except subprocess.CalledProcessError as err: if err.output is not None: - _logger.error("{} C plugin get info failed '{}' due to {}".format(name, err.output, str(err))) + _logger.error(err, "{} C plugin get info failed '{}'.".format(name, err.output)) else: - _logger.error("{} C plugin get info failed due to {}".format(name, str(err))) + _logger.error(err, "{} C plugin get info failed.".format(name)) return {} except Exception as ex: - _logger.error("{} C plugin get info failed due to {}".format(name, str(ex))) + _logger.error(ex, "{} C plugin get info failed.".format(name)) return {} else: return jdoc diff --git a/python/fledge/services/core/connect.py b/python/fledge/services/core/connect.py index 47e6ac49db..ed0915873e 100644 --- a/python/fledge/services/core/connect.py +++ b/python/fledge/services/core/connect.py @@ -24,7 +24,7 @@ def get_storage_async(): storage_svc = services[0] _storage = StorageClientAsync(core_management_host=None, core_management_port=None, svc=storage_svc) except Exception as ex: - _logger.error(str(ex)) + _logger.error(ex) raise return _storage @@ -37,6 +37,6 @@ def get_readings_async(): storage_svc = services[0] _readings = ReadingsStorageClientAsync(core_mgt_host=None, core_mgt_port=None, svc=storage_svc) except Exception as ex: - _logger.error(str(ex)) + _logger.error(ex) raise return _readings diff --git a/python/fledge/services/core/snapshot.py b/python/fledge/services/core/snapshot.py index bc397cb37a..a9ebc4cab2 100644 --- a/python/fledge/services/core/snapshot.py +++ b/python/fledge/services/core/snapshot.py @@ -44,7 +44,7 @@ def __init__(self, snapshot_plugin_dir): self._out_file_path = snapshot_plugin_dir self._interim_file_path = snapshot_plugin_dir except (OSError, Exception) as ex: - _LOGGER.error("Error in initializing SnapshotPluginBuilder class: %s ", str(ex)) + _LOGGER.error(ex, "Error in initializing SnapshotPluginBuilder class.") raise RuntimeError(str(ex)) async def build(self): @@ -76,7 +76,7 @@ def reset(tarinfo): except Exception as ex: if os.path.isfile(tar_file_name): os.remove(tar_file_name) - _LOGGER.error("Error in creating Snapshot .tar.gz file: %s ", str(ex)) + _LOGGER.error(ex, "Error in creating Snapshot .tar.gz file.") raise RuntimeError(str(ex)) self.check_and_delete_temp_files(self._interim_file_path) @@ -98,7 +98,7 @@ def check_and_delete_plugins_tar_files(self, snapshot_plugin_dir): _LOGGER.warning("Removing plugin snapshot file %s.", _path) os.remove(_path) except OSError as ex: - _LOGGER.error("ERROR while deleting plugin file", str(ex)) + _LOGGER.error(ex, "ERROR while deleting plugin file.") def check_and_delete_temp_files(self, snapshot_plugin_dir): # Delete all non *.tar.gz files diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index dd8841c31b..ba39e184bc 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -57,7 +57,7 @@ def __init__(self, support_dir): self._interim_file_path = support_dir self._storage = get_storage_async() # from fledge.services.core.connect except (OSError, Exception) as ex: - _LOGGER.error("Error in initializing SupportBuilder class: %s ", str(ex)) + _LOGGER.error(ex, "Error in initializing SupportBuilder class.") raise RuntimeError(str(ex)) async def build(self): @@ -103,7 +103,7 @@ async def build(self): finally: pyz.close() except Exception as ex: - _LOGGER.error("Error in creating Support .tar.gz file: %s ", str(ex)) + _LOGGER.error(ex, "Error in creating Support .tar.gz file.") raise RuntimeError(str(ex)) self.check_and_delete_temp_files(self._interim_file_path) From 170a9460fa6964d755836d2bfd153cc3180f4985 Mon Sep 17 00:00:00 2001 From: YashTatkondawar Date: Wed, 26 Apr 2023 09:27:18 -0500 Subject: [PATCH 278/499] FOGL-7644: Test statistics history notification rule system tests (#1050) * Basic test * Added test cases * Added docstring, formatted payload * Separate class for egress * Renamed the test file * Updated the description * Feedback fixes --- ...st_statistics_history_notification_rule.py | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 tests/system/python/packages/test_statistics_history_notification_rule.py diff --git a/tests/system/python/packages/test_statistics_history_notification_rule.py b/tests/system/python/packages/test_statistics_history_notification_rule.py new file mode 100644 index 0000000000..f7eaa4bd07 --- /dev/null +++ b/tests/system/python/packages/test_statistics_history_notification_rule.py @@ -0,0 +1,295 @@ +# FLEDGE_BEGIN +# See: http://fledge-iot.readthedocs.io/ +# FLEDGE_END + +""" Test statistics history notification rule system tests: + Creates notification instance with source as statistics history in threshold rule + and notify asset plugin for triggering the notifications based on rules. +""" + +__author__ = "Yash Tatkondawar" +__copyright__ = "Copyright (c) 2023 Dianomic Systems, Inc." + +import base64 +import http.client +import json +import os +import ssl +import subprocess +import time +import urllib.parse +from pathlib import Path + +import pytest +import utils +from pytest import PKG_MGR + +# This gives the path of directory where fledge is cloned. test_file < packages < python < system < tests < ROOT +PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent +SCRIPTS_DIR_ROOT = "{}/tests/system/python/packages/data/".format(PROJECT_ROOT) +FLEDGE_ROOT = os.environ.get('FLEDGE_ROOT') +SOUTH_SERVICE_NAME = "Sine #1" +SOUTH_ASSET_NAME = "{}_sinusoid_assets".format(time.strftime("%Y%m%d")) +NOTIF_SERVICE_NAME = "notification" +NOTIF_INSTANCE_NAME = "notify #1" +AF_HIERARCHY_LEVEL = "{0}_teststatslvl1/{0}_teststatslvl2/{0}_teststatslvl3".format(time.strftime("%Y%m%d")) + +@pytest.fixture +def reset_fledge(wait_time): + try: + subprocess.run(["cd {}/tests/system/python/scripts/package && ./reset" + .format(PROJECT_ROOT)], shell=True, check=True) + except subprocess.CalledProcessError: + assert False, "reset package script failed!" + + # Wait for fledge server to start + time.sleep(wait_time) + + +@pytest.fixture +def start_south(add_south, fledge_url): + south_plugin = "sinusoid" + config = {"assetName": {"value": SOUTH_ASSET_NAME}} + # south_branch does not matter as these are archives.fledge-iot.org version install + add_south(south_plugin, None, fledge_url, service_name=SOUTH_SERVICE_NAME, installation_type='package', config=config) + + +@pytest.fixture +def start_north(start_north_omf_as_a_service, fledge_url, + pi_host, pi_port, pi_admin, pi_passwd, clear_pi_system_through_pi_web_api, pi_db): + + af_hierarchy_level_list = AF_HIERARCHY_LEVEL.split("/") + dp_list = ['sinusoid'] + asset_dict = {} + asset_dict[SOUTH_ASSET_NAME] = dp_list + clear_pi_system_through_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, + af_hierarchy_level_list, asset_dict) + + response = start_north_omf_as_a_service(fledge_url, pi_host, pi_port, pi_user=pi_admin, pi_pwd=pi_passwd, + default_af_location=AF_HIERARCHY_LEVEL) + + yield start_north + +@pytest.fixture +def add_notification_service(fledge_url, wait_time, enabled="true"): + try: + subprocess.run(["sudo {} install -y fledge-service-notification fledge-notify-asset".format(pytest.PKG_MGR)], shell=True, check=True) + except subprocess.CalledProcessError: + assert False, "notification service installation failed" + + # Enable service + data = {"name": NOTIF_SERVICE_NAME, "type": "notification", "enabled": enabled} + print (data) + utils.post_request(fledge_url, "/fledge/service", data) + + # Wait and verify service created or not + time.sleep(wait_time) + verify_service_added(fledge_url, NOTIF_SERVICE_NAME) + +@pytest.fixture +def add_notification_instance(fledge_url, enabled=True): + payload = { + "name": "test #1", + "description": "test notification instance", + "rule": "Threshold", + "rule_config": { + "source": "Statistics History", + "asset": "READINGS", + "trigger_value": "10.0", + }, + "channel": "asset", + "delivery_config": {"enable": "true"}, + "notification_type": "retriggered", + "retrigger_time": "30", + "enabled": enabled, + } + post_url = "/fledge/notification" + utils.post_request(fledge_url, post_url, payload) + + notification_url = "/fledge/notification" + resp = utils.get_request(fledge_url, notification_url) + assert "test #1" in [s["name"] for s in resp["notifications"]] + +def verify_service_added(fledge_url, name): + get_url = "/fledge/service" + result = utils.get_request(fledge_url, get_url) + assert len(result["services"]) + assert name in [s["name"] for s in result["services"]] + +def verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries): + get_url = "/fledge/ping" + ping_result = utils.get_request(fledge_url, get_url) + assert "dataRead" in ping_result + assert "dataSent" in ping_result + assert 0 < ping_result['dataRead'], "South data NOT seen in ping header" + + retry_count = 1 + sent = 0 + if not skip_verify_north_interface: + while retries > retry_count: + sent = ping_result["dataSent"] + if sent >= 1: + break + else: + time.sleep(wait_time) + + retry_count += 1 + ping_result = utils.get_request(fledge_url, get_url) + + assert 1 <= sent, "Failed to send data via PI Web API using Basic auth" + return ping_result + +def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries): + + af_hierarchy_level_list = AF_HIERARCHY_LEVEL.split("/") + retry_count = 0 + data_from_pi = None + # Name of asset in the PI server + pi_asset_name = "{}".format(SOUTH_ASSET_NAME) + + while (data_from_pi is None or data_from_pi == []) and retry_count < retries: + data_from_pi = read_data_from_pi_web_api(pi_host, pi_admin, pi_passwd, pi_db, af_hierarchy_level_list, + pi_asset_name, '') + if data_from_pi is None: + retry_count += 1 + time.sleep(wait_time) + + if data_from_pi is None or retry_count == retries: + assert False, "Failed to read data from PI" + + +class TestStatisticsHistoryBasedNotificationRuleOnIngress: + def test_stats_readings_south(self, clean_setup_fledge_packages, reset_fledge, start_south, add_notification_service, add_notification_instance, fledge_url, + skip_verify_north_interface, wait_time, retries): + """ Test NTFSN triggered or not with source as statistics history and name as READINGS in threshold rule. + clean_setup_fledge_packages: Fixture to remove and install latest fledge packages + reset_fledge: Fixture to reset fledge + start_south: Fixtures to add and start south services + add_notification_service: Fixture to add notification service with rule and delivery plugins + Assertions: + on endpoint GET /fledge/audit + on endpoint GET /fledge/ping + on endpoint GET /fledge/statistics/history """ + time.sleep(wait_time * 4) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + + # When rule is triggered, there should be audit entries for NTFSN + get_url = "/fledge/audit?source=NTFSN" + resp1 = utils.get_request(fledge_url, get_url) + assert len(resp1['audit']) + assert "test #1" in [s["details"]["name"] for s in resp1["audit"]] + for audit_detail in resp1['audit']: + if "test #1" == audit_detail['details']['name']: + assert "NTFSN" == audit_detail['source'] + # Waiting for 60 sec to get 2 more NTFSN entries if rule is triggered properly + time.sleep(60) + resp2 = utils.get_request(fledge_url, get_url) + assert len(resp2['audit']) - len(resp1['audit']) == 2, "ERROR: NTFSN not triggered properly" + + get_url = "/fledge/statistics/history?minutes=10" + r = utils.get_request(fledge_url, get_url) + if "READINGS" in r["statistics"][0]: + assert 0 < r["statistics"][0]["READINGS"] + + def test_stats_south_asset_ingest(self, fledge_url, wait_time, skip_verify_north_interface, retries): + """ Test NTFSN triggered or not with source as statistics history and name as ingested south asset in threshold rule. + Assertions: + on endpoint GET /fledge/audit + on endpoint GET /fledge/ping + on endpoint GET /fledge/statistics/history """ + # Change the config of threshold, name of statistics - READINGS replaced with statistics key name - Sine #1-Ingest + put_url = "/fledge/category/ruletest #1" + data = {"asset": "Sine #1-Ingest"} + utils.put_request(fledge_url, urllib.parse.quote(put_url), data) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + + # When rule is triggered, there should be audit entries for NTFSN + get_url = "/fledge/audit?source=NTFSN" + resp1 = utils.get_request(fledge_url, get_url) + assert len(resp1['audit']) + assert "test #1" in [s["details"]["name"] for s in resp1["audit"]] + for audit_detail in resp1['audit']: + if "test #1" == audit_detail['details']['name']: + assert "NTFSN" == audit_detail['source'] + # Waiting for 60 sec to get more NTFSN entries + time.sleep(60) + resp2 = utils.get_request(fledge_url, get_url) + assert len(resp2['audit']) - len(resp1['audit']) == 2, "ERROR: NTFSN not triggered properly" + + get_url = "/fledge/statistics/history?minutes=10" + r = utils.get_request(fledge_url, get_url) + if "Sine #1-Ingest" in r["statistics"][0]: + assert 0 < r["statistics"][0]["Sine #1-Ingest"] + + def test_stats_south_asset(self, fledge_url, wait_time, skip_verify_north_interface, retries): + """ Test NTFSN triggered or not with source as statistics history and name as south asset name in threshold rule. + Assertions: + on endpoint GET /fledge/audit + on endpoint GET /fledge/ping + on endpoint GET /fledge/statistics/history """ + # Change the config of threshold, name of statistics - Sine #1-Ingest replaced with statistics key name - 20230420_SINUSOID_ASSETS + put_url = "/fledge/category/ruletest #1" + data = {"asset": SOUTH_ASSET_NAME.upper()} + utils.put_request(fledge_url, urllib.parse.quote(put_url), data) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + + # When rule is triggered, there should be audit entries for NTFSN + get_url = "/fledge/audit?source=NTFSN" + resp1 = utils.get_request(fledge_url, get_url) + assert len(resp1['audit']) + assert "test #1" in [s["details"]["name"] for s in resp1["audit"]] + for audit_detail in resp1['audit']: + if "test #1" == audit_detail['details']['name']: + assert "NTFSN" == audit_detail['source'] + # Waiting for 60 sec to get more NTFSN entries + time.sleep(60) + resp2 = utils.get_request(fledge_url, get_url) + assert len(resp2['audit']) - len(resp1['audit']) == 2, "ERROR: NTFSN not triggered properly" + + get_url = "/fledge/statistics/history?minutes=10" + r = utils.get_request(fledge_url, get_url) + if SOUTH_ASSET_NAME.upper() in r["statistics"][0]: + assert 0 < r["statistics"][0][SOUTH_ASSET_NAME.upper()] + + +class TestStatisticsHistoryBasedNotificationRuleOnEgress: + def test_stats_readings_north(self, start_north, fledge_url, + wait_time, skip_verify_north_interface, retries, read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db): + """ Test NTFSN triggered or not with source as statistics history and name as READINGS in threshold rule. + clean_setup_fledge_packages: Fixture to remove and install latest fledge packages + reset_fledge: Fixture to reset fledge + start_south_north: Fixtures to add and start south and north services + add_notification_service: Fixture to add notification service with rule and delivery plugins + Assertions: + on endpoint GET /fledge/audit + on endpoint GET /fledge/ping + on endpoint GET /fledge/statistics/history """ + # Change the config of threshold, name of statistics - Sine #1-Ingest replaced with statistics key name - 20230420_SINUSOID_ASSETS + put_url = "/fledge/category/ruletest #1" + data = {"asset": "Readings Sent"} + utils.put_request(fledge_url, urllib.parse.quote(put_url), data) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + + # When rule is triggered, there should be audit entries for NTFSN + get_url = "/fledge/audit?source=NTFSN" + resp1 = utils.get_request(fledge_url, get_url) + assert len(resp1['audit']) + assert "test #1" in [s["details"]["name"] for s in resp1["audit"]] + for audit_detail in resp1['audit']: + if "test #1" == audit_detail['details']['name']: + assert "NTFSN" == audit_detail['source'] + # Waiting for 60 sec to get more NTFSN entries + time.sleep(60) + resp2 = utils.get_request(fledge_url, get_url) + assert len(resp2['audit']) - len(resp1['audit']) == 2, "ERROR: NTFSN for north not triggered properly" + + get_url = "/fledge/statistics/history?minutes=10" + r = utils.get_request(fledge_url, get_url) + if "Readings Sent" in r["statistics"][0]: + assert 0 < r["statistics"][0]["Readings Sent"] + + _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db, wait_time, retries) From 83334d10c76d587fbaaa58a1823342770ddb0c62 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Apr 2023 12:53:14 +0530 Subject: [PATCH 279/499] python unit tests updated as per changes in logger error and exception statements Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 2 +- .../core/interest_registry/change_callback.py | 3 +- .../test_microservice_management_client.py | 34 +++++++++---------- .../common/test_configuration_manager.py | 2 +- .../python/fledge/common/test_jqfilter.py | 10 +++--- .../fledge/common/test_plugin_discovery.py | 12 +++---- .../python/fledge/common/test_statistics.py | 10 +++--- .../services/common/test_microservice.py | 3 +- .../services/core/api/test_api_utils.py | 18 +++++----- .../services/core/api/test_auth_mandatory.py | 21 +++++++----- .../fledge/services/core/api/test_filters.py | 3 +- .../fledge/services/core/api/test_support.py | 4 ++- .../fledge/services/core/api/test_task.py | 3 +- .../interest_registry/test_change_callback.py | 10 +++--- .../services/core/scheduler/test_scheduler.py | 7 ++-- .../fledge/services/core/test_connect.py | 4 +-- .../python/fledge/tasks/purge/test_purge.py | 3 +- 17 files changed, 81 insertions(+), 68 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 7c273dfb4d..ecbc8cb4c0 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -781,7 +781,7 @@ async def get_category_all_items(self, category_name): category["display_name"]) return category_value except: - _logger.exception('Unable to get all category names based on category_name {}'.format(category_name)) + _logger.exception('Unable to get all category items of {} category.'.format(category_name)) raise async def get_category_item(self, category_name, item_name): diff --git a/python/fledge/services/core/interest_registry/change_callback.py b/python/fledge/services/core/interest_registry/change_callback.py index cbd4d18263..eeb7e4c10c 100644 --- a/python/fledge/services/core/interest_registry/change_callback.py +++ b/python/fledge/services/core/interest_registry/change_callback.py @@ -96,7 +96,8 @@ async def run_child_create(parent_category_name, child_category_list): try: service_record = ServiceRegistry.get(idx=i._microservice_uuid)[0] except service_registry_exceptions.DoesNotExist: - _LOGGER.exception("Unable to notify microservice with uuid %s as it is not found in the service registry", i._microservice_uuid) + _LOGGER.exception("Unable to notify microservice with uuid {} as it is not " + "found in the service registry".format(i._microservice_uuid)) continue url = "{}://{}:{}/fledge/child_create".format(service_record._protocol, service_record._address, service_record._management_port) diff --git a/tests/unit/python/fledge/common/microservice_management_client/test_microservice_management_client.py b/tests/unit/python/fledge/common/microservice_management_client/test_microservice_management_client.py index 1f94cdde15..44516abd9d 100644 --- a/tests/unit/python/fledge/common/microservice_management_client/test_microservice_management_client.py +++ b/tests/unit/python/fledge/common/microservice_management_client/test_microservice_management_client.py @@ -60,8 +60,8 @@ def test_register_service_no_id(self): ms_mgt_client.register_service({}) assert excinfo.type is KeyError assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Could not register the microservice, From request %s, Reason: %s', '{}' - , "'id'") + args = log_exc.call_args + assert 'Could not register the microservice, From request {}'.format('{}') == args[0][1] response_patch.assert_called_once_with() request_patch.assert_called_once_with(body='{}', method='POST', url='/fledge/service') @@ -117,10 +117,10 @@ def test_unregister_service_no_id(self): with patch.object(_logger, "error") as log_error: with pytest.raises(Exception) as excinfo: ms_mgt_client.unregister_service('someid') - assert excinfo.type is KeyError - assert 1 == log_error.call_count - log_error.assert_called_once_with('Could not unregister the micro-service having ' - 'uuid %s, Reason: %s', 'someid', "'id'", exc_info=True) + assert excinfo.type is KeyError + assert 1 == log_error.call_count + args = log_error.call_args + assert 'Could not unregister the micro-service having uuid {}'.format('someid') == args[0][1] response_patch.assert_called_once_with() request_patch.assert_called_once_with(method='DELETE', url='/fledge/service/someid') @@ -169,25 +169,24 @@ def test_register_interest_good_id(self): def test_register_interest_no_id(self): microservice_management_host = 'host1' microservice_management_port = 1 - ms_mgt_client = MicroserviceManagementClient( - microservice_management_host, microservice_management_port) + ms_mgt_client = MicroserviceManagementClient(microservice_management_host, microservice_management_port) response_mock = MagicMock(type=HTTPResponse) undecoded_data_mock = MagicMock() response_mock.read.return_value = undecoded_data_mock undecoded_data_mock.decode.return_value = json.dumps({'notid': 'bla'}) response_mock.status = 200 + payload = '{"category": "cat", "service": "msid"}' with patch.object(HTTPConnection, 'request') as request_patch: with patch.object(HTTPConnection, 'getresponse', return_value=response_mock) as response_patch: with patch.object(_logger, "error") as log_error: with pytest.raises(Exception) as excinfo: ms_mgt_client.register_interest('cat', 'msid') - assert excinfo.type is KeyError + assert excinfo.type is KeyError assert 1 == log_error.call_count - log_error.assert_called_once_with('Could not register interest, for request payload %s, Reason: %s', - '{"category": "cat", "service": "msid"}', "'id'", exc_info=True) + args = log_error.call_args + assert 'Could not register interest, for request payload {}'.format(payload) == args[0][1] response_patch.assert_called_once_with() - request_patch.assert_called_once_with(body='{"category": "cat", "service": "msid"}', method='POST', - url='/fledge/interest') + request_patch.assert_called_once_with(body=payload, method='POST', url='/fledge/interest') @pytest.mark.parametrize("status_code, host", [(450, 'Client'), (550, 'Server')]) def test_register_interest_status_client_err(self, status_code, host): @@ -244,8 +243,8 @@ def test_unregister_interest_no_id(self): ms_mgt_client.unregister_interest('someid') assert excinfo.type is KeyError assert 1 == log_error.call_count - log_error.assert_called_once_with('Could not unregister interest for %s, Reason: %s', 'someid', - "'id'", exc_info=True) + args = log_error.call_args + assert 'Could not unregister interest for {}'.format('someid') == args[0][1] response_patch.assert_called_once_with() request_patch.assert_called_once_with(method='DELETE', url='/fledge/interest/someid') @@ -313,8 +312,9 @@ def test_get_services_no_services(self): ms_mgt_client.get_services('foo', 'bar') assert excinfo.type is KeyError assert 1 == log_error.call_count - log_error.assert_called_once_with('Could not find the micro-service for requested url %s, Reason: %s', - '/fledge/service?name=foo&type=bar', "'services'", exc_info=True) + args = log_error.call_args + assert 'Could not find the micro-service for requested url {}'.format( + '/fledge/service?name=foo&type=bar') == args[0][1] response_patch.assert_called_once_with() request_patch.assert_called_once_with(method='GET', url='/fledge/service?name=foo&type=bar') diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 6b26890c2e..5048a75f03 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -1754,7 +1754,7 @@ async def test_get_category_all_items_bad(self, reset_singleton): await c_mgr.get_category_all_items(category_name) readpatch.assert_called_once_with(category_name) assert 1 == log_exc.call_count - log_exc.assert_called_once_with('Unable to get all category names based on category_name %s', 'catname') + log_exc.assert_called_once_with('Unable to get all category items of {} category.'.format(category_name)) async def test_get_category_item_good(self, reset_singleton): diff --git a/tests/unit/python/fledge/common/test_jqfilter.py b/tests/unit/python/fledge/common/test_jqfilter.py index d0da1ec080..5cf87ca336 100644 --- a/tests/unit/python/fledge/common/test_jqfilter.py +++ b/tests/unit/python/fledge/common/test_jqfilter.py @@ -39,14 +39,16 @@ def test_transform(self, input_filter_string, input_reading_block, expected_retu mock_pyjq.assert_called_once_with(input_reading_block, input_filter_string) @pytest.mark.parametrize("input_filter_string, input_reading_block, expected_error, expected_log", [ - (".", '{"a" 1}', TypeError, 'Invalid JSON passed, exception %s'), - ("..", '{"a" 1}', ValueError, 'Failed to transform, please check the transformation rule, exception %s') + (".", '{"a" 1}', TypeError, 'Invalid JSON passed during jq transform.'), + ("..", '{"a" 1}', ValueError, 'Failed to transform, please check the transformation rule.') ]) def test_transform_exceptions(self, input_filter_string, input_reading_block, expected_error, expected_log): jqfilter_instance = JQFilter() with patch.object(pyjq, "all", side_effect=expected_error) as mock_pyjq: - with patch.object(jqfilter_instance._logger, "error") as log: + with patch.object(jqfilter_instance._logger, "error") as patch_log: with pytest.raises(expected_error): jqfilter_instance.transform(input_filter_string, input_reading_block) + args = patch_log.call_args + assert expected_error == args[0][0].__class__ + assert expected_log == args[0][1] mock_pyjq.assert_called_once_with(input_reading_block, input_filter_string) - log.assert_called_once_with(expected_log, '') diff --git a/tests/unit/python/fledge/common/test_plugin_discovery.py b/tests/unit/python/fledge/common/test_plugin_discovery.py index 039b9db0d5..ccbc31627b 100644 --- a/tests/unit/python/fledge/common/test_plugin_discovery.py +++ b/tests/unit/python/fledge/common/test_plugin_discovery.py @@ -560,24 +560,24 @@ def test_bad_fetch_c_north_plugin_installed(self, info, exc_count): @pytest.mark.parametrize("exc_name, log_exc_name, msg", [ (FileNotFoundError, "error", 'Plugin "modbus" import problem from path "modbus".'), - (Exception, "exception", 'Plugin "modbus" raised exception "" while fetching config') + (Exception, "exception", 'Plugin "modbus" failed while fetching config') ]) def test_bad_get_south_plugin_config(self, exc_name, log_exc_name, msg): with patch.object(_logger, log_exc_name) as patch_log_exc: with patch.object(common, 'load_and_fetch_python_plugin_info', side_effect=[exc_name]): PluginDiscovery.get_plugin_config("modbus", "south", "south", False) assert 1 == patch_log_exc.call_count - args, kwargs = patch_log_exc.call_args - assert msg in args[0] + args = patch_log_exc.call_args + assert msg == args[0][1] @pytest.mark.parametrize("exc_name, log_exc_name, msg", [ (FileNotFoundError, "error", 'Plugin "http" import problem from path "http".'), - (Exception, "exception", 'Plugin "http" raised exception "" while fetching config') + (Exception, "exception", 'Plugin "http" failed while fetching config') ]) def test_bad_get_north_plugin_config(self, exc_name, log_exc_name, msg): with patch.object(_logger, log_exc_name) as patch_log_exc: with patch.object(common, 'load_and_fetch_python_plugin_info', side_effect=[exc_name]): PluginDiscovery.get_plugin_config("http", "north", "north", False) assert 1 == patch_log_exc.call_count - args, kwargs = patch_log_exc.call_args - assert msg in args[0] + args = patch_log_exc.call_args + assert msg == args[0][1] diff --git a/tests/unit/python/fledge/common/test_statistics.py b/tests/unit/python/fledge/common/test_statistics.py index dd67112d03..1276b31eb8 100644 --- a/tests/unit/python/fledge/common/test_statistics.py +++ b/tests/unit/python/fledge/common/test_statistics.py @@ -170,8 +170,8 @@ async def mock_coro(): with patch.object(statistics._logger, 'exception') as logger_exception: with patch.object(s._storage, 'query_tbl_with_payload', return_value=_rv): await s._load_keys() - args, kwargs = logger_exception.call_args - assert args[0] == 'Failed to retrieve statistics keys, %s' + args = logger_exception.call_args + assert args[0][1] == 'Failed to retrieve statistics keys' async def test_update(self): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -212,13 +212,13 @@ async def test_update_with_invalid_params(self, key, value_increment, exception_ async def test_update_exception(self): storage_client_mock = MagicMock(spec=StorageClientAsync) s = statistics.Statistics(storage_client_mock) - msg = 'Unable to update statistics value based on statistics_key %s and value_increment %d,' \ - ' error %s', 'BUFFERED', 5, '' + msg = 'Unable to update statistics value based on statistics_key {} and value_increment {}'.format('BUFFERED', 5) with patch.object(s._storage, 'update_tbl', side_effect=Exception()): with pytest.raises(Exception): with patch.object(statistics._logger, 'exception') as logger_exception: await s.update('BUFFERED', 5) - logger_exception.assert_called_once_with(*msg) + args = logger_exception.call_args + assert msg == args[0][1] async def test_add_update(self): stat_dict = {'FOGBENCH/TEMPERATURE': 1} diff --git a/tests/unit/python/fledge/services/common/test_microservice.py b/tests/unit/python/fledge/services/common/test_microservice.py index e6132968b3..ba6824c379 100644 --- a/tests/unit/python/fledge/services/common/test_microservice.py +++ b/tests/unit/python/fledge/services/common/test_microservice.py @@ -160,7 +160,8 @@ async def add_track(self): with patch.object(_logger, 'exception') as logger_patch: with pytest.raises(Exception) as excinfo: fm = FledgeMicroserviceImp() - logger_patch.assert_called_once_with('Unable to intialize FledgeMicroservice due to exception %s', '') + args = logger_patch.call_args + assert 'Unable to initialize FledgeMicroservice' == args[0][1] @pytest.mark.asyncio async def test_ping(self, loop): diff --git a/tests/unit/python/fledge/services/core/api/test_api_utils.py b/tests/unit/python/fledge/services/core/api/test_api_utils.py index 340f4f299e..0a528ff2c7 100644 --- a/tests/unit/python/fledge/services/core/api/test_api_utils.py +++ b/tests/unit/python/fledge/services/core/api/test_api_utils.py @@ -37,16 +37,16 @@ def test_get_plugin_info_value_error(self): plugin_name = 'Random' with patch.object(utils, '_find_c_util', return_value='plugins/utils/get_plugin_info') as patch_util: with patch.object(utils, '_find_c_lib', return_value=None) as patch_lib: - assert {} == utils.get_plugin_info(plugin_name, dir='south') + with patch.object(utils._logger, 'error') as patch_logger: + assert {} == utils.get_plugin_info(plugin_name, dir='south') + assert 1 == patch_logger.call_count + args = patch_logger.call_args + assert '{} C plugin get info failed.'.format(plugin_name) == args[0][1] patch_lib.assert_called_once_with(plugin_name, 'south') patch_util.assert_called_once_with('get_plugin_info') - @pytest.mark.parametrize("exc_name, msg", [ - (Exception, ""), - (OSError, ""), - (subprocess.CalledProcessError, "__init__() missing 2 required positional arguments: 'returncode' and 'cmd'") - ]) - def test_get_plugin_info_exception(self, exc_name, msg): + @pytest.mark.parametrize("exc_name", [Exception, OSError, subprocess.CalledProcessError]) + def test_get_plugin_info_exception(self, exc_name): plugin_name = 'OMF' plugin_lib_path = 'fledge/plugins/north/{}/lib{}'.format(plugin_name, plugin_name) with patch.object(utils, '_find_c_util', return_value='plugins/utils/get_plugin_info') as patch_util: @@ -55,8 +55,8 @@ def test_get_plugin_info_exception(self, exc_name, msg): with patch.object(utils._logger, 'error') as patch_logger: assert {} == utils.get_plugin_info(plugin_name, dir='south') assert 1 == patch_logger.call_count - args, kwargs = patch_logger.call_args - assert '{} C plugin get info failed due to {}'.format(plugin_name, msg) == args[0] + args = patch_logger.call_args + assert '{} C plugin get info failed.'.format(plugin_name) == args[0][1] patch_lib.assert_called_once_with(plugin_name, 'south') patch_util.assert_called_once_with('get_plugin_info') diff --git a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py index 64115233d5..0d3e3203fb 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py @@ -270,7 +270,7 @@ async def test_create_user_unknown_exception(self, client): with patch.object(auth, 'is_valid_role', return_value=_rv4) as patch_role: with patch.object(User.Objects, 'create', side_effect=Exception( exc_msg)) as patch_create_user: - with patch.object(auth._logger, 'error') as patch_audit_logger_exc: + with patch.object(auth._logger, 'error') as patch_logger: resp = await client.post('/fledge/admin/user', data=json.dumps(request_data), headers=ADMIN_USER_HEADER) assert 500 == resp.status @@ -278,8 +278,8 @@ async def test_create_user_unknown_exception(self, client): result = await resp.text() json_response = json.loads(result) assert {"message": exc_msg} == json_response - patch_audit_logger_exc.assert_called_once_with('Failed to create user. {}'.format( - exc_msg)) + args = patch_logger.call_args + assert 'Failed to create user.' == args[0][1] patch_create_user.assert_called_once_with(request_data['username'], request_data['password'], 2, 'any', '', '') patch_role.assert_called_once_with(2) @@ -540,7 +540,7 @@ async def test_update_password_unknown_exception(self, client): request_data = {"current_password": "fledge", "new_password": "F0gl@mp"} uid = 2 msg = 'Something went wrong' - logger_msg = 'Failed to update the user ID:<{}>. {}'.format(uid, msg) + logger_msg = 'Failed to update the user ID:<{}>.'.format(uid) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(uid) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(uid)) with patch.object(middleware._logger, 'debug') as patch_logger_debug: @@ -550,7 +550,8 @@ async def test_update_password_unknown_exception(self, client): resp = await client.put('/fledge/user/{}/password'.format(uid), data=json.dumps(request_data)) assert 500 == resp.status assert msg == resp.reason - patch_logger.assert_called_once_with(logger_msg) + args = patch_logger.call_args + assert logger_msg == args[0][1] patch_update.assert_called_once_with(2, {'password': request_data['new_password']}) patch_user_exists.assert_called_once_with(str(uid), request_data['current_password']) patch_logger_debug.assert_called_once_with('Received %s request for %s', @@ -725,13 +726,14 @@ async def test_delete_user_unknown_exception(self, client, mocker): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. _rv = await mock_coro(ret_val) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(ret_val)) with patch.object(User.Objects, 'get_role_id_by_name', return_value=_rv) as patch_role_id: - with patch.object(auth._logger, 'error') as patch_auth_logger_exc: + with patch.object(auth._logger, 'error') as patch_logger: with patch.object(User.Objects, 'delete', side_effect=Exception(msg)) as patch_user_delete: resp = await client.delete('/fledge/admin/2/delete', headers=ADMIN_USER_HEADER) assert 500 == resp.status assert msg == resp.reason patch_user_delete.assert_called_once_with(2) - patch_auth_logger_exc.assert_called_once_with('Failed to delete the user ID:<2>. {}'.format(msg)) + args = patch_logger.call_args + assert 'Failed to delete the user ID:<2>.' == args[0][1] patch_role_id.assert_called_once_with('admin') patch_user_get.assert_called_once_with(uid=1) patch_refresh_token.assert_called_once_with(ADMIN_USER_HEADER['Authorization']) @@ -1017,7 +1019,7 @@ async def test_reset_unknown_exception(self, client, mocker): request_data = {'role_id': '2'} user_id = 2 msg = 'Something went wrong' - logger_msg = 'Failed to reset the user ID:<{}>. {}'.format(user_id, msg) + logger_msg = 'Failed to reset the user ID:<{}>.'.format(user_id) patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) @@ -1037,7 +1039,8 @@ async def test_reset_unknown_exception(self, client, mocker): headers=ADMIN_USER_HEADER) assert 500 == resp.status assert msg == resp.reason - patch_logger.assert_called_once_with(logger_msg) + args = patch_logger.call_args + assert logger_msg == args[0][1] patch_update.assert_called_once_with(str(user_id), request_data) patch_role.assert_called_once_with(request_data['role_id']) patch_role_id.assert_called_once_with('admin') diff --git a/tests/unit/python/fledge/services/core/api/test_filters.py b/tests/unit/python/fledge/services/core/api/test_filters.py index a3cdd21833..08eb7c649c 100644 --- a/tests/unit/python/fledge/services/core/api/test_filters.py +++ b/tests/unit/python/fledge/services/core/api/test_filters.py @@ -387,7 +387,8 @@ async def test_create_filter_exception(self, client): assert 500 == resp.status assert resp.reason is '' assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with('Add filter, caught exception: ') + args = patch_logger.call_args + assert 'Add filter failed.' == args[0][1] get_cat_info_patch.assert_called_once_with(category_name=name) async def test_create_filter(self, client): diff --git a/tests/unit/python/fledge/services/core/api/test_support.py b/tests/unit/python/fledge/services/core/api/test_support.py index a25b935b81..4b60a8d4be 100644 --- a/tests/unit/python/fledge/services/core/api/test_support.py +++ b/tests/unit/python/fledge/services/core/api/test_support.py @@ -133,7 +133,7 @@ async def mock_build(): assert {"bundle created": "support-180301-13-35-23.tar.gz"} == jdict async def test_create_support_bundle_exception(self, client): - msg = "Failed to create support bundle. blah" + msg = "Failed to create support bundle." with patch.object(SupportBuilder, "__init__", return_value=None): with patch.object(SupportBuilder, "build", side_effect=RuntimeError("blah")): with patch.object(support._logger, "error") as patch_logger: @@ -141,6 +141,8 @@ async def test_create_support_bundle_exception(self, client): assert 500 == resp.status assert msg == resp.reason assert 1 == patch_logger.call_count + args = patch_logger.call_args + assert msg == args[0][1] async def test_get_syslog_entries_all_ok(self, client): def mock_syslog(): diff --git a/tests/unit/python/fledge/services/core/api/test_task.py b/tests/unit/python/fledge/services/core/api/test_task.py index 96f4171263..4f4345aa2f 100644 --- a/tests/unit/python/fledge/services/core/api/test_task.py +++ b/tests/unit/python/fledge/services/core/api/test_task.py @@ -577,7 +577,8 @@ async def test_delete_task_exception(self, mocker, client): assert 500 == resp.status assert resp.reason is '' assert 1 == patch_logger.call_count - patch_logger.assert_called_once_with('Failed to delete Test north task. ') + args = patch_logger.call_args + assert 'Failed to delete Test north task.' == args[0][1] async def mock_bad_result(): return {"count": 0, "rows": []} diff --git a/tests/unit/python/fledge/services/core/interest_registry/test_change_callback.py b/tests/unit/python/fledge/services/core/interest_registry/test_change_callback.py index c3666f0021..c984f87573 100644 --- a/tests/unit/python/fledge/services/core/interest_registry/test_change_callback.py +++ b/tests/unit/python/fledge/services/core/interest_registry/test_change_callback.py @@ -257,9 +257,11 @@ async def async_mock(return_value): with patch.object(ConfigurationManager, 'get_category_all_items', return_value=_rv) as cm_get_patch: with patch.object(aiohttp.ClientSession, 'post', side_effect=Exception) as post_patch: - with patch.object(cb._LOGGER, 'exception') as exception_patch: + with patch.object(cb._LOGGER, 'exception') as patch_logger: await cb.run('catname1') - exception_patch.assert_called_once_with( - 'Unable to notify microservice with uuid %s due to exception: %s', s_id_1, '') - post_patch.assert_has_calls([call('http://saddress1:1/fledge/change', data='{"category": "catname1", "items": null}', headers={'content-type': 'application/json'})]) + args = patch_logger.call_args + assert 'Unable to notify microservice with uuid {}'.format(s_id_1) == args[0][1] + post_patch.assert_has_calls( + [call('http://saddress1:1/fledge/change', data='{"category": "catname1", "items": null}', + headers={'content-type': 'application/json'})]) cm_get_patch.assert_called_once_with('catname1') diff --git a/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py b/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py index a1fd5c01d2..1278ca6e35 100644 --- a/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py +++ b/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py @@ -610,10 +610,9 @@ async def test_stop(self, mocker): calls = [call('Processing stop request'), call('Stopped')] log_info.assert_has_calls(calls, any_order=True) - # TODO: Find why these exceptions are being raised despite mocking _purge_tasks_task, _scheduler_loop_task - calls = [call('An exception was raised by Scheduler._purge_tasks %s', "object MagicMock can't be used in 'await' expression"), - call('An exception was raised by Scheduler._scheduler_loop %s', "object MagicMock can't be used in 'await' expression")] - log_exception.assert_has_calls(calls) + # FIXME: Find why exception is being raised despite mocking _scheduler_loop_task + args = log_exception.call_args + assert 'An exception was raised by Scheduler._scheduler_loop' == args[0][1] @pytest.mark.asyncio async def test_get_scheduled_processes(self, mocker): diff --git a/tests/unit/python/fledge/services/core/test_connect.py b/tests/unit/python/fledge/services/core/test_connect.py index c9b983c10a..c5ce8ea601 100644 --- a/tests/unit/python/fledge/services/core/test_connect.py +++ b/tests/unit/python/fledge/services/core/test_connect.py @@ -44,7 +44,7 @@ def test_exception_when_no_storage(self, mock_logger): with pytest.raises(DoesNotExist) as excinfo: connect.get_storage_async() assert str(excinfo).endswith('DoesNotExist') - mock_logger.error.assert_called_once_with('') + assert 1 == mock_logger.error.call_count @patch('fledge.services.core.connect._logger') def test_exception_when_non_fledge_storage(self, mock_logger): @@ -58,4 +58,4 @@ def test_exception_when_non_fledge_storage(self, mock_logger): with pytest.raises(DoesNotExist) as excinfo: connect.get_storage_async() assert str(excinfo).endswith('DoesNotExist') - mock_logger.error.assert_called_once_with('') + assert 1 == mock_logger.error.call_count diff --git a/tests/unit/python/fledge/tasks/purge/test_purge.py b/tests/unit/python/fledge/tasks/purge/test_purge.py index 7defae79b2..88b525383b 100644 --- a/tests/unit/python/fledge/tasks/purge/test_purge.py +++ b/tests/unit/python/fledge/tasks/purge/test_purge.py @@ -330,4 +330,5 @@ async def mock_purge(x): with patch.object(p, 'write_statistics'): await p.run() # Test the negative case when function purge_data raise some exception - p._logger.exception.assert_called_once_with("") + assert 1 == p._logger.exception.call_count + From f91269aed8141a8f0b394902e141d8b241379b15 Mon Sep 17 00:00:00 2001 From: YashTatkondawar Date: Fri, 28 Apr 2023 00:46:12 -0500 Subject: [PATCH 280/499] FOGL-7645: Add system test for rule data availability (#1057) * Add system test for rule data availability * Added new test cases * Added test for asset code * Added test flow for egress * skip verify north in Ingress class --- .../packages/test_rule_data_availability.py | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 tests/system/python/packages/test_rule_data_availability.py diff --git a/tests/system/python/packages/test_rule_data_availability.py b/tests/system/python/packages/test_rule_data_availability.py new file mode 100644 index 0000000000..be58e43bec --- /dev/null +++ b/tests/system/python/packages/test_rule_data_availability.py @@ -0,0 +1,292 @@ +# FLEDGE_BEGIN +# See: http://fledge-iot.readthedocs.io/ +# FLEDGE_END + +""" Test data availability notification rule system tests: + Creates notification instance with data availability rule + and notify asset plugin for triggering the notifications based on CONCH. +""" + +__author__ = "Yash Tatkondawar" +__copyright__ = "Copyright (c) 2023 Dianomic Systems, Inc." + + +import os +import subprocess +import time +import urllib.parse +import json +from pathlib import Path +import http +from datetime import datetime +import pytest +import utils +from pytest import PKG_MGR + +# This gives the path of directory where fledge is cloned. test_file < packages < python < system < tests < ROOT +PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent +SCRIPTS_DIR_ROOT = "{}/tests/system/python/packages/data/".format(PROJECT_ROOT) +FLEDGE_ROOT = os.environ.get('FLEDGE_ROOT') +SOUTH_SERVICE_NAME = "Sine #1" +SOUTH_DP_NAME="sinusoid" +SOUTH_ASSET_NAME = "{}_sinusoid_assets".format(time.strftime("%Y%m%d")) +NORTH_PLUGIN = "OMF" +TASK_NAME = "EDS #1" +NOTIF_SERVICE_NAME = "notification" +NOTIF_INSTANCE_NAME = "notify #1" +AF_HIERARCHY_LEVEL = "{0}_teststatslvl1/{0}_teststatslvl2/{0}_teststatslvl3".format(time.strftime("%Y%m%d")) + +@pytest.fixture +def reset_fledge(wait_time): + try: + subprocess.run(["cd {}/tests/system/python/scripts/package && ./reset" + .format(PROJECT_ROOT)], shell=True, check=True) + except subprocess.CalledProcessError: + assert False, "reset package script failed!" + + # Wait for fledge server to start + time.sleep(wait_time) + +@pytest.fixture +def reset_eds(): + eds_reset_url = "/api/v1/Administration/Storage/Reset" + con = http.client.HTTPConnection("localhost", 5590) + con.request("POST", eds_reset_url, "") + resp = con.getresponse() + assert 204 == resp.status + +@pytest.fixture +def check_eds_installed(): + dpkg_list = os.popen('dpkg --list osisoft.edgedatastore >/dev/null; echo $?') + ls_output = dpkg_list.read() + assert ls_output == "0\n", "EDS not installed. Please install it first!" + eds_data_url = "/api/v1/diagnostics/productinformation" + con = http.client.HTTPConnection("localhost", 5590) + con.request("GET", eds_data_url) + resp = con.getresponse() + r = json.loads(resp.read().decode()) + assert len(r) != 0, "EDS not installed. Please install it first!" + +@pytest.fixture +def start_south(add_south, fledge_url): + south_plugin = "sinusoid" + config = {"assetName": {"value": SOUTH_ASSET_NAME}} + # south_branch does not matter as these are archives.fledge-iot.org version install + add_south(south_plugin, None, fledge_url, service_name=SOUTH_SERVICE_NAME, installation_type='package', config=config) + + +@pytest.fixture +def start_north(fledge_url, enabled=True): + conn = http.client.HTTPConnection(fledge_url) + data = {"name": TASK_NAME, + "plugin": NORTH_PLUGIN, + "type": "north", + "enabled": enabled, + "config": {"PIServerEndpoint": {"value": "Edge Data Store"}, + "NamingScheme": {"value": "Backward compatibility"}} + } + post_url = "/fledge/service" + utils.post_request(fledge_url, post_url, data) + +@pytest.fixture +def add_notification_service(fledge_url, wait_time, enabled="true"): + try: + subprocess.run(["sudo {} install -y fledge-service-notification fledge-notify-asset".format(pytest.PKG_MGR)], shell=True, check=True) + except subprocess.CalledProcessError: + assert False, "notification service installation failed" + + # Enable service + data = {"name": NOTIF_SERVICE_NAME, "type": "notification", "enabled": enabled} + print (data) + utils.post_request(fledge_url, "/fledge/service", data) + + # Wait and verify service created or not + time.sleep(wait_time) + verify_service_added(fledge_url, NOTIF_SERVICE_NAME) + +@pytest.fixture +def add_notification_instance(fledge_url, enabled=True): + payload = { + "name": "test #1", + "description": "test notification instance", + "rule": "DataAvailability", + "rule_config": { + "auditCode": "CONAD,SCHAD" + }, + "channel": "asset", + "delivery_config": {"enable": "true"}, + "notification_type": "retriggered", + "retrigger_time": "5", + "enabled": enabled + } + post_url = "/fledge/notification" + utils.post_request(fledge_url, post_url, payload) + + notification_url = "/fledge/notification" + resp = utils.get_request(fledge_url, notification_url) + assert "test #1" in [s["name"] for s in resp["notifications"]] + +def verify_service_added(fledge_url, name): + get_url = "/fledge/service" + result = utils.get_request(fledge_url, get_url) + assert len(result["services"]) + assert name in [s["name"] for s in result["services"]] + +def verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries): + get_url = "/fledge/ping" + ping_result = utils.get_request(fledge_url, get_url) + assert "dataRead" in ping_result + assert "dataSent" in ping_result + assert 0 < ping_result['dataRead'], "South data NOT seen in ping header" + + retry_count = 1 + sent = 0 + if not skip_verify_north_interface: + while retries > retry_count: + sent = ping_result["dataSent"] + if sent >= 1: + break + else: + time.sleep(wait_time) + + retry_count += 1 + ping_result = utils.get_request(fledge_url, get_url) + + assert 1 <= sent, "Failed to send data to Edge Data Store" + return ping_result + +def verify_eds_data(): + eds_data_url = "/api/v1/tenants/default/namespaces/default/streams/1measurement_{}/Data/Last".format(SOUTH_ASSET_NAME) + print (eds_data_url) + con = http.client.HTTPConnection("localhost", 5590) + con.request("GET", eds_data_url) + resp = con.getresponse() + r = json.loads(resp.read().decode()) + return r + +class TestDataAvailabilityAuditBasedNotificationRuleOnIngress: + def test_data_availability_multiple_audit(self, clean_setup_fledge_packages, reset_fledge, add_notification_service, add_notification_instance, start_south, fledge_url, + skip_verify_north_interface, wait_time, retries): + """ Test NTFSN triggered or not with CONAD, SCHAD. + clean_setup_fledge_packages: Fixture to remove and install latest fledge packages + reset_fledge: Fixture to reset fledge + start_south: Fixtures to add and start south services + add_notification_service: Fixture to add notification service + add_notification_instance: Fixture to add notification instance + Assertions: + on endpoint GET /fledge/audit + on endpoint GET /fledge/ping + on endpoint GET /fledge/category """ + time.sleep(wait_time) + + verify_ping(fledge_url, True, wait_time, retries) + + get_url = "/fledge/audit?source=NTFSN" + resp1 = utils.get_request(fledge_url, get_url) + print (len(resp1['audit'])) + assert len(resp1['audit']) + + assert "test #1" in [s["details"]["name"] for s in resp1["audit"]] + for audit_detail in resp1['audit']: + if "test #1" == audit_detail['details']['name']: + assert "NTFSN" == audit_detail['source'], "ERROR: NTFSN not triggered properly on CONAD or SCHAD" + + def test_data_availability_single_audit(self, fledge_url, skip_verify_north_interface, wait_time, retries): + """ Test NTFSN triggered or not with CONCH in sinusoid plugin. + on endpoint GET /fledge/audit + on endpoint GET /fledge/ping + on endpoint GET /fledge/category """ + get_url = "/fledge/audit?source=NTFSN" + resp1 = utils.get_request(fledge_url, get_url) + + # Change the configuration of rule plugin + put_url = "/fledge/category/ruletest #1" + data = {"auditCode": "CONCH"} + utils.put_request(fledge_url, urllib.parse.quote(put_url), data) + + # Change the configuration of sinusoid plugin + put_url = "/fledge/category/Sine #1Advanced" + data = {"readingsPerSec": "10"} + utils.put_request(fledge_url, urllib.parse.quote(put_url), data) + + time.sleep(wait_time) + get_url = "/fledge/audit?source=NTFSN" + resp2 = utils.get_request(fledge_url, get_url) + assert len(resp2['audit']) - len(resp1['audit']) == 1, "ERROR: NTFSN not triggered properly on CONCH" + + def test_data_availability_all_audit(self, fledge_url, add_south, skip_verify_north_interface, wait_time, retries): + """ Test NTFSN triggered or not with all audit changes. + on endpoint GET /fledge/audit + on endpoint GET /fledge/ping + on endpoint GET /fledge/category """ + get_url = "/fledge/audit?source=NTFSN" + resp1 = utils.get_request(fledge_url, get_url) + + # Change the configuration of rule plugin + put_url = "/fledge/category/ruletest #1" + data = {"auditCode": "*"} + utils.put_request(fledge_url, urllib.parse.quote(put_url), data) + + # Add new service + south_plugin = "sinusoid" + config = {"assetName": {"value": "sine-test"}} + # south_branch does not matter as these are archives.fledge-iot.org version install + add_south(south_plugin, None, fledge_url, service_name="sine-test", installation_type='package', config=config) + + time.sleep(wait_time) + get_url = "/fledge/audit?source=NTFSN" + resp2 = utils.get_request(fledge_url, get_url) + assert len(resp2['audit']) > len(resp1['audit']), "ERROR: NTFSN not triggered properly with * audit code" + +class TestDataAvailabilityAssetBasedNotificationRuleOnIngress: + def test_data_availability_asset(self, fledge_url, add_south, skip_verify_north_interface, wait_time, retries): + """ Test NTFSN triggered or not with all audit changes. + on endpoint GET /fledge/audit + on endpoint GET /fledge/ping + on endpoint GET /fledge/category """ + get_url = "/fledge/audit?source=NTFSN" + resp1 = utils.get_request(fledge_url, get_url) + + # Change the configuration of rule plugin + put_url = "/fledge/category/ruletest #1" + data = {"auditCode": "", "assetCode": SOUTH_ASSET_NAME} + utils.put_request(fledge_url, urllib.parse.quote(put_url), data) + + time.sleep(wait_time) + get_url = "/fledge/audit?source=NTFSN" + resp2 = utils.get_request(fledge_url, get_url) + assert len(resp2['audit']) > len(resp1['audit']), "ERROR: NTFSN not triggered properly with asset code" + +class TestDataAvailabilityBasedNotificationRuleOnEgress: + def test_data_availability_north(self, check_eds_installed, reset_fledge, add_notification_service, add_notification_instance, + reset_eds, start_north, fledge_url, wait_time, skip_verify_north_interface, add_south, retries): + """ Test NTFSN triggered or not with configuration change in north EDS plugin. + start_north: Fixtures to add and start south services + Assertions: + on endpoint GET /fledge/audit + on endpoint GET /fledge/ping """ + + # Change the configuration of rule plugin + put_url = "/fledge/category/ruletest #1" + data = {"auditCode": "", "assetCode": SOUTH_ASSET_NAME} + utils.put_request(fledge_url, urllib.parse.quote(put_url), data) + + # Add new service + south_plugin = "sinusoid" + config = {"assetName": {"value": SOUTH_ASSET_NAME}} + # south_branch does not matter as these are archives.fledge-iot.org version install + add_south(south_plugin, None, fledge_url, service_name="sine-test", installation_type='package', config=config) + + get_url = "/fledge/audit?source=NTFSN" + resp1 = utils.get_request(fledge_url, get_url) + + get_url = "/fledge/audit?source=NTFSN" + resp2 = utils.get_request(fledge_url, get_url) + assert len(resp2['audit']) > len(resp1['audit']), "ERROR: NTFSN not triggered properly with asset code" + + time.sleep(wait_time) + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + r = verify_eds_data() + assert SOUTH_DP_NAME in r, "Data in EDS not found!" + ts = r.get("Time") + assert ts.find(datetime.now().strftime("%Y-%m-%d")) != -1, "Latest data not found in EDS!" From 1921eace39772defb3c776e3d65b9502e12b2717 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Fri, 28 Apr 2023 12:28:38 +0530 Subject: [PATCH 281/499] FOGL-7352 Add Azure north system test (#1052) * Initial Commit Signed-off-by: Mohit Singh Tomar * Added test for checking data sent to Azure or not and updated conftest as well as requirements-test.txt file Signed-off-by: Mohit Singh Tomar * Added Test2 for sending data thriugh mqtt over websockets Signed-off-by: Mohit Singh Tomar * Added third test for enabling and disabling South and North services Signed-off-by: Mohit Singh Tomar * Added class that runs tests as task Signed-off-by: Mohit Singh Tomar * Removed dependency of pandas and added test for checking invalid configuration Signed-off-by: Mohit Singh Tomar * Added Long run test Signed-off-by: Mohit Singh Tomar * Added Test to send with filter Signed-off-by: Mohit Singh Tomar * Refactored Code Signed-off-by: Mohit Singh Tomar * Feedback changes Signed-off-by: Mohit Singh Tomar * Feedback changes-2 Signed-off-by: Mohit Singh Tomar --------- Signed-off-by: Mohit Singh Tomar --- tests/system/python/conftest.py | 58 +- .../python/packages/test_north_azure.py | 729 ++++++++++++++++++ 2 files changed, 783 insertions(+), 4 deletions(-) create mode 100644 tests/system/python/packages/test_north_azure.py diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index c71caf924f..f04c7e2cdd 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -19,7 +19,6 @@ from pathlib import Path import pytest - __author__ = "Vaibhav Singhal" __copyright__ = "Copyright (c) 2019 Dianomic Systems" __license__ = "Apache 2.0" @@ -154,7 +153,7 @@ def clone_make_install(): @pytest.fixture def add_north(): def _add_fledge_north(fledge_url, north_plugin, north_branch, installation_type='make', north_instance_name="play", - config=None, + config=None, schedule_repeat_time=30, plugin_lang="python", use_pip_cache=True, enabled=True, plugin_discovery_name=None, is_task=True): """Add north plugin and start the service/task by default""" @@ -192,9 +191,10 @@ def clone_make_install(): if is_task: # Create north task - data = {"name": "{}".format(north_instance_name), "type": "North", + data = {"name": "{}".format(north_instance_name), "type": "north", "plugin": "{}".format(plugin_discovery_name), - "schedule_enabled": _enabled, "schedule_repeat": 30, "schedule_type": "3", "config": _config} + "schedule_enabled": _enabled, "schedule_repeat": "{}".format(schedule_repeat_time), "schedule_type": "3", "config": _config} + print(data) conn.request("POST", '/fledge/scheduled/task', json.dumps(data)) else: # Create north service @@ -807,6 +807,28 @@ def pytest_addoption(parser): parser.addoption("--fogbench-port", action="store", default="5683", type=int, help="FogBench Destination Port") + + # Azure-IoT Config + parser.addoption("--azure-host", action="store", default="azure-server", + help="Azure-IoT Host Name") + + parser.addoption("--azure-device", action="store", default="azure-iot-device", + help="Azure-IoT Device ID") + + parser.addoption("--azure-key", action="store", default="azure-iot-key", + help="Azure-IoT SharedAccess key") + + parser.addoption("--azure-storage-account-url", action="store", default="azure-storage-account-url", + help="Azure Storage Account URL") + + parser.addoption("--azure-storage-account-key", action="store", default="azure-storage-account-key", + help="Azure Storage Account Access Key") + + parser.addoption("--azure-storage-container", action="store", default="azure_storage_container", + help="Container Name in Azure where data is stored") + + parser.addoption("--run-time", action="store", default="60", + help="The number of minute for which a test should run") @pytest.fixture def num_assets(request): @@ -1141,3 +1163,31 @@ def fogbench_host(request): @pytest.fixture def fogbench_port(request): return request.config.getoption("--fogbench-port") + +@pytest.fixture +def azure_host(request): + return request.config.getoption("--azure-host") + +@pytest.fixture +def azure_device(request): + return request.config.getoption("--azure-device") + +@pytest.fixture +def azure_key(request): + return request.config.getoption("--azure-key") + +@pytest.fixture +def azure_storage_account_url(request): + return request.config.getoption("--azure-storage-account-url") + +@pytest.fixture +def azure_storage_account_key(request): + return request.config.getoption("--azure-storage-account-key") + +@pytest.fixture +def azure_storage_container(request): + return request.config.getoption("--azure-storage-container") + +@pytest.fixture +def run_time(request): + return request.config.getoption("--run-time") \ No newline at end of file diff --git a/tests/system/python/packages/test_north_azure.py b/tests/system/python/packages/test_north_azure.py new file mode 100644 index 0000000000..38bc668e31 --- /dev/null +++ b/tests/system/python/packages/test_north_azure.py @@ -0,0 +1,729 @@ +# -*- coding: utf-8 -*- + +# FLEDGE_BEGIN +# See: http://fledge-iot.readthedocs.io/ +# FLEDGE_END + +""" Test sending data to Azure-IoT-Hub using fledge-north-azure plugin + +""" + +__author__ = "Mohit Singh Tomar" +__copyright__ = "Copyright (c) 2023 Dianomic Systems Inc" +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + +import subprocess +import http.client +import pytest +import os +import time +import utils +from pathlib import Path +import urllib.parse +import json +import sys +import datetime + +try: + subprocess.run(["python3 -m pip install azure-storage-blob==12.13.1"], shell=True, check=True) +except subprocess.CalledProcessError: + assert False, "Failed to install azure-storage-blob module" + +from azure.storage.blob import BlobServiceClient + +# This gives the path of directory where fledge is cloned. test_file < packages < python < system < tests < ROOT +PROJECT_ROOT = subprocess.getoutput("git rev-parse --show-toplevel") +SCRIPTS_DIR_ROOT = "{}/tests/system/python/scripts/package/".format(PROJECT_ROOT) +SOUTH_SERVICE_NAME = "FOGL-7352_sysinfo" +SOUTH_PLUGIN = "systeminfo" +NORTH_SERVICE_NAME = "FOGL-7352_azure" +NORTH_PLUGIN_NAME = "azure-iot" +NORTH_PLUGIN_DISCOVERY_NAME = "azure_iot" +LOCALJSONFILE = "azure.json" +FILTER = "expression" + +@pytest.fixture +def reset_fledge(wait_time): + try: + subprocess.run(["cd {} && ./reset" + .format(SCRIPTS_DIR_ROOT)], shell=True, check=True) + except subprocess.CalledProcessError: + assert False, "reset package script failed!" + + +def read_data_from_azure_storage_container(azure_storage_account_url,azure_storage_account_key, azure_storage_container): + + try: + t1=time.time() + blob_service_client_instance = BlobServiceClient(account_url=azure_storage_account_url, credential=azure_storage_account_key) + + container_client = blob_service_client_instance.get_container_client(container=azure_storage_container) + + blob_list = container_client.list_blobs() + + for blob in blob_list: + BLOBNAME = blob.name + print(f"Name: {blob.name}") + + + blob_client_instance = blob_service_client_instance.get_blob_client(azure_storage_container, BLOBNAME, snapshot=None) + with open(LOCALJSONFILE, "wb") as my_blob: + blob_data = blob_client_instance.download_blob() + blob_data.readinto(my_blob) + t2=time.time() + print(("It takes %s seconds to download "+BLOBNAME) % (t2 - t1)) + + with open(LOCALJSONFILE) as handler: + data = handler.readlines() + + return data + + except (Exception) as ex: + print("Failed to read data due to {}".format(ex)) + return None + +def verify_north_stats_on_invalid_config(fledge_url): + get_url = "/fledge/ping" + ping_result = utils.get_request(fledge_url, get_url) + assert "dataRead" in ping_result + assert ping_result['dataRead'] > 0, "South data NOT seen in ping header" + assert "dataSent" in ping_result + assert ping_result['dataSent'] < 1, "Data sent to Azure Iot Hub" + +def verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries): + get_url = "/fledge/ping" + ping_result = utils.get_request(fledge_url, get_url) + assert "dataRead" in ping_result + assert "dataSent" in ping_result + assert 0 < ping_result['dataRead'], "South data NOT seen in ping header" + + retry_count = 1 + sent = 0 + if not skip_verify_north_interface: + while retries > retry_count: + sent = ping_result["dataSent"] + if sent >= 1: + break + else: + time.sleep(wait_time) + + retry_count += 1 + ping_result = utils.get_request(fledge_url, get_url) + + assert 1 <= sent, "Failed to send data to Azure-IoT-Hub" + return ping_result + + +def verify_statistics_map(fledge_url, skip_verify_north_interface): + get_url = "/fledge/statistics" + jdoc = utils.get_request(fledge_url, get_url) + actual_stats_map = utils.serialize_stats_map(jdoc) + assert 1 <= actual_stats_map["{}-Ingest".format(SOUTH_SERVICE_NAME)] + assert 1 <= actual_stats_map['READINGS'] + if not skip_verify_north_interface: + assert 1 <= actual_stats_map['Readings Sent'] + assert 1 <= actual_stats_map[NORTH_SERVICE_NAME] + + +def verify_asset(fledge_url, ASSET): + get_url = "/fledge/asset" + result = utils.get_request(fledge_url, get_url) + assert len(result), "No asset found" + assert any(filter(lambda x: ASSET in x, [s["assetCode"] for s in result])) + + +def verify_asset_tracking_details(fledge_url, skip_verify_north_interface, ASSET): + tracking_details = utils.get_asset_tracking_details(fledge_url, "Ingest") + assert len(tracking_details["track"]), "Failed to track Ingest event" + tracked_item = tracking_details["track"][0] + assert ASSET in tracked_item["asset"] + assert "systeminfo" == tracked_item["plugin"] + + egress_tracking_details = utils.get_asset_tracking_details(fledge_url, "Egress") + assert len(egress_tracking_details["track"]), "Failed to track Egress event" + tracked_item = egress_tracking_details["track"][0] + assert ASSET in tracked_item["asset"] + assert NORTH_PLUGIN_DISCOVERY_NAME == tracked_item["plugin"] + + +def _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET): + retry_count = 0 + data_from_azure = None + + while (data_from_azure is None or len(data_from_azure) == 0) and retry_count < retries: + data_from_azure = read_data_from_azure_storage_container(azure_storage_account_url,azure_storage_account_key, azure_storage_container) + + if data_from_azure is None: + retry_count += 1 + time.sleep(wait_time) + + if data_from_azure is None or retry_count == retries: + assert False, "Failed to read data from Azure IoT Hub" + + asset_collected = list() + for ele in data_from_azure: + asset_collected.extend(list(map(lambda d: d['asset'], json.loads(ele)["Body"]))) + + assert any(filter(lambda x: ASSET in x, asset_collected)) + +@pytest.fixture +def add_south_north_task(add_south, add_north, fledge_url, azure_host, azure_device, azure_key): + """ This fixture + add_south: Fixture that adds a south service with given configuration + add_north: Fixture that adds a north service with given configuration + + """ + + # south_branch does not matter as these are archives.fledge-iot.org version install + add_south(SOUTH_PLUGIN, None, fledge_url, service_name=SOUTH_SERVICE_NAME, start_service=False, installation_type='package') + + _config = { + "primaryConnectionString": {"value":"HostName={};DeviceId={};SharedAccessKey={}".format(azure_host, azure_device, azure_key)} + } + # north_branch does not matter as these are archives.fledge-iot.org version install + add_north(fledge_url, NORTH_PLUGIN_NAME, None, installation_type='package', north_instance_name=NORTH_SERVICE_NAME, + config=_config, schedule_repeat_time=10, enabled=False, plugin_discovery_name=NORTH_PLUGIN_DISCOVERY_NAME, is_task=True) + +@pytest.fixture +def add_south_north_service(add_south, add_north, fledge_url, azure_host, azure_device, azure_key): + """ This fixture + add_south: Fixture that adds a south service with given configuration + add_north: Fixture that adds a north service with given configuration + + """ + # south_branch does not matter as these are archives.fledge-iot.org version install + add_south(SOUTH_PLUGIN, None, fledge_url, service_name=SOUTH_SERVICE_NAME, start_service=False, installation_type='package') + + _config = { + "primaryConnectionString": {"value":"HostName={};DeviceId={};SharedAccessKey={}".format(azure_host, azure_device, azure_key)} + } + # north_branch does not matter as these are archives.fledge-iot.org version install + add_north(fledge_url, NORTH_PLUGIN_NAME, None, installation_type='package', north_instance_name=NORTH_SERVICE_NAME, + config=_config, enabled=False, plugin_discovery_name=NORTH_PLUGIN_DISCOVERY_NAME, is_task=False) + +def config_south(fledge_url, ASSET): + payload = {"assetNamePrefix": "{}".format(ASSET)} + put_url = "/fledge/category/{}".format(SOUTH_SERVICE_NAME) + utils.put_request(fledge_url, urllib.parse.quote(put_url), payload) + +def update_filter_config(fledge_url, plugin, mode): + data = {"enable": "{}".format(mode)} + put_url = "/fledge/category/{}_{}_exp".format(NORTH_SERVICE_NAME, plugin) + utils.put_request(fledge_url, urllib.parse.quote(put_url, safe='?,=,&,/'), data) + +def add_expression_filter(add_filter, fledge_url, NORTH_PLUGIN_NAME): + filter_cfg = {"enable": "true", "expression": "log(1K-blocks)".format(), "name": "{}_exp".format(NORTH_PLUGIN_NAME)} + add_filter("{}".format(FILTER), None, "{}_exp".format(NORTH_PLUGIN_NAME), filter_cfg, fledge_url, "{}".format(NORTH_SERVICE_NAME), installation_type='package') + +class TestNorthAzureIoTHubDevicePlugin: + + def test_send(self, clean_setup_fledge_packages, reset_fledge, add_south_north_service, fledge_url, enable_schedule, + disable_schedule, azure_host, azure_device, azure_key, wait_time, retries, skip_verify_north_interface, + azure_storage_account_url, azure_storage_account_key, azure_storage_container): + + """ Test that check data is inserted in Fledge and sent to Azure-IoT Hub or not. + clean_setup_fledge_packages: Fixture for removing fledge from system completely if it is already present + and reinstall it baased on commandline arguments. + reset_fledge: Fixture that reset and cleanup the fledge + add_south_north_service: Fixture that add south and north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + azure_storage_account_url: Fixture that provide URL for accessing Storage Blob of Azure + azure_storage_account_key: Fixture that provide access key for accessing Storage Blob + azure_storage_container: Fixture that provides name of container deployed in Azure + """ + # Update Asset name + ASSET = "test1_FOGL-7352_system" + config_south(fledge_url, ASSET) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + time.sleep(wait_time) + disable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_asset(fledge_url, ASSET) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface, ASSET) + + # Storage blob JSON will be created every 2 minutes + time.sleep(150) + + + _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET) + + + def test_mqtt_over_websocket_reconfig(self, reset_fledge, add_south_north_service, fledge_url, enable_schedule, disable_schedule, + azure_host, azure_device, azure_key, azure_storage_account_url, azure_storage_account_key, + azure_storage_container, wait_time, retries, skip_verify_north_interface): + + """ Test that enable MQTT over websocket then check data inserted into Fledge and sent to Azure-IoT Hub or not. + + reset_fledge: Fixture that reset and cleanup the fledge + add_south_north_service: Fixture that add south and north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + azure_storage_account_url: Fixture that provide URL for accessing Storage Blob of Azure + azure_storage_account_key: Fixture that provide access key for accessing Storage Blob + azure_storage_container: Fixture that provides name of container deployed in Azure + """ + # Update Asset name + ASSET = "test2_FOGL-7352_system" + config_south(fledge_url, ASSET) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + time.sleep(wait_time) + disable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable MQTT over websocket + payload = {"websockets": "true"} + put_url = "/fledge/category/{}".format(NORTH_SERVICE_NAME) + utils.put_request(fledge_url, urllib.parse.quote(put_url), payload) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_asset(fledge_url, ASSET) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface, ASSET) + + # Storage blob JSON will be created every 2 minutes + time.sleep(150) + + + _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET) + + + def test_disable_enable(self, reset_fledge, add_south_north_service, fledge_url, enable_schedule, disable_schedule, + azure_host, azure_device, azure_key, azure_storage_account_url, azure_storage_account_key, + azure_storage_container, wait_time, retries, skip_verify_north_interface): + + """ Test that enable and disable south and north service perioically then + check data inserted into Fledge and sent to Azure-IoT Hub or not. + + reset_fledge: Fixture that reset and cleanup the fledge + add_south_north_service: Fixture that add south and north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + azure_storage_account_url: Fixture that provide URL for accessing Storage Blob of Azure + azure_storage_account_key: Fixture that provide access key for accessing Storage Blob + azure_storage_container: Fixture that provides name of container deployed in Azure + """ + + for i in range(2): + # Update Asset name + ASSET = "test3.{}_FOGL-7352_system".format(i) + config_south(fledge_url, ASSET) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + time.sleep(wait_time) + disable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_asset(fledge_url, ASSET) + verify_statistics_map(fledge_url, skip_verify_north_interface) + + # Storage blob JSON will be created every 2 minutes + time.sleep(150) + disable_schedule(fledge_url, NORTH_SERVICE_NAME) + + _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET) + + + def test_send_with_filter(self, reset_fledge, add_south_north_service, fledge_url, enable_schedule, disable_schedule, + azure_host, azure_device, azure_key, azure_storage_account_url, azure_storage_account_key, + azure_storage_container, wait_time, retries, skip_verify_north_interface, add_filter): + + """ Test that attach filters to North service and enable and disable filter periodically + then check data inserted into Fledge and sent to Azure-IoT Hub or not. + + reset_fledge: Fixture that reset and cleanup the fledge + add_south_north_service: Fixture that add south and north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + azure_storage_account_url: Fixture that provide URL for accessing Storage Blob of Azure + azure_storage_account_key: Fixture that provide access key for accessing Storage Blob + azure_storage_container: Fixture that provides name of container deployed in Azure + add_filter:Fixture that add filter to south and north Instances + """ + # Update Asset name + ASSET = "test4_FOGL-7352_system" + config_south(fledge_url, ASSET) + + # Add Expression filter to North Service + add_expression_filter(add_filter, fledge_url, NORTH_PLUGIN_NAME) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + print("On/Off of filter starts") + count = 0 + while count<3: + # For Disabling filter + update_filter_config(fledge_url, NORTH_PLUGIN_NAME, 'false') + time.sleep(wait_time*2) + + # For enabling filter + update_filter_config(fledge_url, NORTH_PLUGIN_NAME, 'true') + time.sleep(wait_time*2) + count+=1 + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_asset(fledge_url, ASSET) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface, ASSET) + + # Storage blob JSON will be created every 2 minutes + time.sleep(150) + + + _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET) + + +class TestNorthAzureIoTHubDevicePluginTask: + + def test_send_as_a_task(self, reset_fledge, add_south_north_task, fledge_url, enable_schedule, disable_schedule, + azure_host, azure_device, azure_key, azure_storage_account_url, azure_storage_account_key, + azure_storage_container, wait_time, retries, skip_verify_north_interface): + + """ Test that creates south and north bound as task and check data is inserted in Fledge and sent to Azure-IoT Hub or not. + + reset_fledge: Fixture that reset and cleanup the fledge + add_south_north_task: Fixture that add south and north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + azure_storage_account_url: Fixture that provide URL for accessing Storage Blob of Azure + azure_storage_account_key: Fixture that provide access key for accessing Storage Blob + azure_storage_container: Fixture that provides name of container deployed in Azure + """ + # Update Asset name + ASSET = "test5_FOGL-7352_system" + config_south(fledge_url, ASSET) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + time.sleep(wait_time) + disable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_asset(fledge_url, ASSET) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface, ASSET) + + # Storage blob JSON will be created every 2 minutes + time.sleep(150) + + + _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET) + + + def test_mqtt_over_websocket_reconfig_task(self, reset_fledge, add_south_north_task, fledge_url, enable_schedule, disable_schedule, + azure_host, azure_device, azure_key, azure_storage_account_url, azure_storage_account_key, + azure_storage_container, wait_time, retries, skip_verify_north_interface): + + """ Test that creates south and north bound as task as well as enable MQTT over websocket then + check data inserted in Fledge and sent to Azure-IoT Hub or not. + + reset_fledge: Fixture that reset and cleanup the fledge + add_south_north_task: Fixture that add south and north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + azure_storage_account_url: Fixture that provide URL for accessing Storage Blob of Azure + azure_storage_account_key: Fixture that provide access key for accessing Storage Blob + azure_storage_container: Fixture that provides name of container deployed in Azure + """ + # Update Asset name + ASSET = "test6_FOGL-7352_system" + config_south(fledge_url, ASSET) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + time.sleep(wait_time) + disable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable MQTT over websocket + payload = {"websockets": "true"} + put_url = "/fledge/category/{}".format(NORTH_SERVICE_NAME) + utils.put_request(fledge_url, urllib.parse.quote(put_url), payload) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_asset(fledge_url, ASSET) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface, ASSET) + + # Storage blob JSON will be created every 2 minutes + time.sleep(150) + + + _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET) + + + def test_disable_enable_task(self, reset_fledge, add_south_north_task, fledge_url, enable_schedule, disable_schedule, + azure_host, azure_device, azure_key, azure_storage_account_url, azure_storage_account_key, + azure_storage_container, wait_time, retries, skip_verify_north_interface): + + """ Test that creates south and north bound as task as enable and disable them periodically then + check data inserted in Fledge and sent to Azure-IoT Hub or not. + + reset_fledge: Fixture that reset and cleanup the fledge + add_south_north_task: Fixture that add south and north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + azure_storage_account_url: Fixture that provide URL for accessing Storage Blob of Azure + azure_storage_account_key: Fixture that provide access key for accessing Storage Blob + azure_storage_container: Fixture that provides name of container deployed in Azure + """ + for i in range(2): + # Update Asset name + ASSET = "test7.{}_FOGL-7352_system".format(i) + config_south(fledge_url, ASSET) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + time.sleep(wait_time) + disable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_asset(fledge_url, ASSET) + verify_statistics_map(fledge_url, skip_verify_north_interface) + + # Storage blob JSON will be created every 2 minutes + time.sleep(150) + disable_schedule(fledge_url, NORTH_SERVICE_NAME) + + _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET) + + def test_send_with_filter_task(self, reset_fledge, add_south_north_task, fledge_url, enable_schedule, disable_schedule, + azure_host, azure_device, azure_key, azure_storage_account_url, azure_storage_account_key, + azure_storage_container, wait_time, retries, skip_verify_north_interface, add_filter): + + """ Test that creates south and north bound as task and attach filters to North Bound as well as + enable and disable filters periodically then check data inserted in Fledge and sent to Azure-IoT Hub or not. + + reset_fledge: Fixture that reset and cleanup the fledge + add_south_north_task: Fixture that add south and north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + azure_storage_account_url: Fixture that provide URL for accessing Storage Blob of Azure + azure_storage_account_key: Fixture that provide access key for accessing Storage Blob + azure_storage_container: Fixture that provides name of container deployed in Azure + add_filter: Fixture that add fiter to south or north instances. + """ + # Update Asset name + ASSET = "test8_FOGL-7352_system" + config_south(fledge_url, ASSET) + + # Add Expression filter to North Service + add_expression_filter(add_filter, fledge_url, NORTH_PLUGIN_NAME) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + print("On/Off of filter starts") + count = 0 + while count<3: + # For Disabling filter + update_filter_config(fledge_url, NORTH_PLUGIN_NAME, 'false') + time.sleep(wait_time*2) + + # For enabling filter + update_filter_config(fledge_url, NORTH_PLUGIN_NAME, 'true') + time.sleep(wait_time*2) + count+=1 + + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_asset(fledge_url, ASSET) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface, ASSET) + + # Storage blob JSON will be created every 2 minutes + time.sleep(150) + + + _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET) + + + +class TestNorthAzureIoTHubDevicePluginInvalidConfig: + + def test_invalid_connstr(self, reset_fledge, add_south, add_north, fledge_url, enable_schedule, disable_schedule, wait_time, retries): + + """ Test that checks connection string of north azure plugin is invalid or not. + + reset_fledge: Fixture that reset and cleanup the fledge + add_south: Fixture that south Instance in disable mode + add_north: Fixture that add north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + """ + # Add South and North + add_south(SOUTH_PLUGIN, None, fledge_url, service_name=SOUTH_SERVICE_NAME, start_service=False, installation_type='package') + + _config = { + "primaryConnectionString": {"value":"InvalidConfig"} + } + # north_branch does not matter as these are archives.fledge-iot.org version install + add_north(fledge_url, NORTH_PLUGIN_NAME, None, installation_type='package', north_instance_name=NORTH_SERVICE_NAME, + config=_config, enabled=False, plugin_discovery_name=NORTH_PLUGIN_DISCOVERY_NAME, is_task=False) + + # Update Asset name + ASSET = "test9_FOGL-7352_system" + config_south(fledge_url, ASSET) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + time.sleep(wait_time) + disable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + verify_north_stats_on_invalid_config(fledge_url) + + + def test_invalid_connstr_sharedkey(self, reset_fledge, add_south, add_north, fledge_url, enable_schedule, disable_schedule, + wait_time, retries, azure_host, azure_device, azure_key): + + """ Test that checks shared key passed to connection string of north azure plugin is invalid or not. + + reset_fledge: Fixture that reset and cleanup the fledge + add_south: Fixture that south Instance in disable mode + add_north: Fixture that add north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + """ + # Add South and North + add_south(SOUTH_PLUGIN, None, fledge_url, service_name=SOUTH_SERVICE_NAME, start_service=False, installation_type='package') + + _config = { + "primaryConnectionString": {"value":"HostName={};DeviceId={};SharedAccessKey={}".format(azure_host, azure_device, azure_key[:-5])} + } + # north_branch does not matter as these are archives.fledge-iot.org version install + add_north(fledge_url, NORTH_PLUGIN_NAME, None, installation_type='package', north_instance_name=NORTH_SERVICE_NAME, + config=_config, enabled=False, plugin_discovery_name=NORTH_PLUGIN_DISCOVERY_NAME, is_task=False) + + # Update Asset name + ASSET = "test10_FOGL-7352_system" + config_south(fledge_url, ASSET) + + # Enable South Service for 10 Seonds + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + time.sleep(wait_time) + disable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + verify_north_stats_on_invalid_config(fledge_url) + + +class TestNorthAzureIoTHubDevicePluginLongRun: + + def test_send_long_run(self, clean_setup_fledge_packages, reset_fledge, add_south_north_service, fledge_url, enable_schedule, + disable_schedule, azure_host, azure_device, azure_key, wait_time, retries, skip_verify_north_interface, + azure_storage_account_url, azure_storage_account_key, azure_storage_container, run_time): + + """ Test that check data is inserted in Fledge and sent to Azure-IoT Hub for long duration based parameter passed. + + clean_setup_fledge_packages: Fixture for removing fledge from system completely if it is already present + and reinstall it baased on commandline arguments. + reset_fledge: Fixture that reset and cleanup the fledge + add_south_north_service: Fixture that add south and north instance in disable mode + enable_schedule: Fixture for enabling schedules or services + disable_schedule: Fixture for disabling schedules or services + azure_host: Fixture that provide Hostname of Azure IoT Hub + azure_device: Fixture that provide ID of Device deployed in Azure IoT Hub + azure_key: Fixture that provide access key of Azure IoT Hub + azure_storage_account_url: Fixture that provide URL for accessing Storage Blob of Azure + azure_storage_account_key: Fixture that provide access key for accessing Storage Blob + azure_storage_container: Fixture that provides name of container deployed in Azure + run_time: Fixture that defines durration for which this test will be executed. + """ + START_TIME = datetime.datetime.now() + current_iteration = 1 + # Update Asset name + ASSET = "test11_FOGL-7352_system" + config_south(fledge_url, ASSET) + + + # Enable South Service for ingesting data into fledge + enable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + time.sleep(wait_time) + # Enable North Service for sending data to Azure-IOT-Hub + enable_schedule(fledge_url, NORTH_SERVICE_NAME) + + while (datetime.datetime.now() - START_TIME).seconds <= (int(run_time) * 60): + verify_ping(fledge_url, skip_verify_north_interface, wait_time, retries) + verify_asset(fledge_url, ASSET) + verify_statistics_map(fledge_url, skip_verify_north_interface) + verify_asset_tracking_details(fledge_url, skip_verify_north_interface, ASSET) + + # Storage blob JSON will be created every 2 minutes + time.sleep(150) + + + _verify_egress(azure_storage_account_url, azure_storage_account_key, azure_storage_container, wait_time, retries, ASSET) + + print('Successfully ran {} iterations'.format(current_iteration), datetime.datetime.now()) + current_iteration += 1 + current_duration = (datetime.datetime.now() - START_TIME).seconds + + # Disable South Service + disable_schedule(fledge_url, SOUTH_SERVICE_NAME) + + # Disable North Service + disable_schedule(fledge_url, NORTH_SERVICE_NAME) + \ No newline at end of file From 57b737d191c8323119ad8f2f6c0a899b884a9eb0 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 1 May 2023 11:03:17 +0530 Subject: [PATCH 282/499] USRAD audit trail entry added on user creation Signed-off-by: ashish-jabble --- python/fledge/services/core/user_model.py | 12 +++++++--- .../fledge/services/core/test_user_model.py | 23 +++++++++++++------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/python/fledge/services/core/user_model.py b/python/fledge/services/core/user_model.py index 7cf2c3900b..f7bc4c19ba 100644 --- a/python/fledge/services/core/user_model.py +++ b/python/fledge/services/core/user_model.py @@ -4,14 +4,14 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END -""" Fledge user entity class with CRUD operations to Storage layer - -""" +"""Fledge user entity class with CRUD operations to Storage layer""" +import json import uuid import hashlib from datetime import datetime, timedelta import jwt +from fledge.common.audit_logger import AuditLogger from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_DATA from fledge.common.configuration_manager import ConfigurationManager from fledge.common.logger import FLCoreLogger @@ -110,6 +110,12 @@ async def create(cls, username, password, role_id, access_method='any', real_nam description=description).payload() try: result = await storage_client.insert_into_tbl("users", payload) + # USRAD audit trail entry + audit = AuditLogger(storage_client) + audit_details = json.loads(payload) + audit_details.pop('pwd', None) + audit_details['message'] = "'{}' username created for '{}' user.".format(username, real_name) + await audit.information('USRAD', audit_details) except StorageServerError as ex: if ex.error["retryable"]: pass # retry INSERT diff --git a/tests/unit/python/fledge/services/core/test_user_model.py b/tests/unit/python/fledge/services/core/test_user_model.py index d798c5c525..1e74b44308 100644 --- a/tests/unit/python/fledge/services/core/test_user_model.py +++ b/tests/unit/python/fledge/services/core/test_user_model.py @@ -1,20 +1,21 @@ # -*- coding: utf-8 -*- - # FLEDGE_BEGIN # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END +import copy import json import asyncio from unittest.mock import MagicMock, patch -import pytest import sys +import pytest -from fledge.services.core import connect +from fledge.common.audit_logger import AuditLogger +from fledge.common.configuration_manager import ConfigurationManager from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.storage_client.exceptions import StorageServerError +from fledge.services.core import connect from fledge.services.core.user_model import User -from fledge.common.configuration_manager import ConfigurationManager __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -172,19 +173,27 @@ async def test_create_user(self): expected = {'rows_affected': 1, "response": "inserted"} payload = {"pwd": "dd7171406eaf4baa8bc805857f719bca", "role_id": 1, "uname": "aj", 'access_method': 'any', 'description': '', 'real_name': ''} + audit_details = copy.deepcopy(payload) + audit_details.pop('pwd', None) + audit_details['message'] = "'{}' username created for '{}' user.".format(payload['uname'], payload['real_name']) storage_client_mock = MagicMock(StorageClientAsync) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: _rv = await mock_coro(expected) + _rv3 = await mock_coro(None) else: - _rv = asyncio.ensure_future(mock_coro(expected)) + _rv = asyncio.ensure_future(mock_coro(expected)) + _rv3 = asyncio.ensure_future(mock_coro(None)) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(User.Objects, 'hash_password', return_value=hashed_password) as hash_pwd_patch: with patch.object(storage_client_mock, 'insert_into_tbl', return_value=_rv) as insert_tbl_patch: - actual = await User.Objects.create("aj", "fledge", 1) - assert actual == expected + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=_rv3) as patch_audit: + actual = await User.Objects.create("aj", "fledge", 1) + assert actual == expected + patch_audit.assert_called_once_with('USRAD', audit_details) assert 1 == insert_tbl_patch.call_count assert insert_tbl_patch.called is True args, kwargs = insert_tbl_patch.call_args From 9d48465ab169cffc50872df354a4fc86972cbd28 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 1 May 2023 11:10:31 +0530 Subject: [PATCH 283/499] USRDL audit trail entry added on user deletion Signed-off-by: ashish-jabble --- python/fledge/services/core/user_model.py | 4 ++++ .../fledge/services/core/test_user_model.py | 20 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/python/fledge/services/core/user_model.py b/python/fledge/services/core/user_model.py index f7bc4c19ba..70e0390d9e 100644 --- a/python/fledge/services/core/user_model.py +++ b/python/fledge/services/core/user_model.py @@ -143,6 +143,10 @@ async def delete(cls, user_id): payload = PayloadBuilder().SET(enabled="f").WHERE(['id', '=', user_id]).AND_WHERE(['enabled', '=', 't']).payload() result = await storage_client.update_tbl("users", payload) + # USRDL audit trail entry + audit = AuditLogger(storage_client) + await audit.information( + 'USRDL', {"user_id": user_id, "message": "User ID: <{}> has been disabled.".format(user_id)}) except StorageServerError as ex: if ex.error["retryable"]: pass # retry INSERT diff --git a/tests/unit/python/fledge/services/core/test_user_model.py b/tests/unit/python/fledge/services/core/test_user_model.py index 1e74b44308..3beb1e2945 100644 --- a/tests/unit/python/fledge/services/core/test_user_model.py +++ b/tests/unit/python/fledge/services/core/test_user_model.py @@ -181,16 +181,16 @@ async def test_create_user(self): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: _rv = await mock_coro(expected) - _rv3 = await mock_coro(None) + _rv2 = await mock_coro(None) else: _rv = asyncio.ensure_future(mock_coro(expected)) - _rv3 = asyncio.ensure_future(mock_coro(None)) + _rv2 = asyncio.ensure_future(mock_coro(None)) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(User.Objects, 'hash_password', return_value=hashed_password) as hash_pwd_patch: with patch.object(storage_client_mock, 'insert_into_tbl', return_value=_rv) as insert_tbl_patch: with patch.object(AuditLogger, '__init__', return_value=None): - with patch.object(AuditLogger, 'information', return_value=_rv3) as patch_audit: + with patch.object(AuditLogger, 'information', return_value=_rv2) as patch_audit: actual = await User.Objects.create("aj", "fledge", 1) assert actual == expected patch_audit.assert_called_once_with('USRAD', audit_details) @@ -234,20 +234,26 @@ async def test_delete_user(self): r2 = {'response': 'updated', 'rows_affected': 1} storage_client_mock = MagicMock(StorageClientAsync) - + user_id = 2 + audit_details = {"user_id": user_id, "message": "User ID: <{}> has been disabled.".format(user_id)} # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: _rv1 = await mock_coro(r1) _rv2 = await mock_coro(r2) + _rv3 = await mock_coro(None) else: _rv1 = asyncio.ensure_future(mock_coro(r1)) _rv2 = asyncio.ensure_future(mock_coro(r2)) - + _rv3 = asyncio.ensure_future(mock_coro(None)) + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(storage_client_mock, 'delete_from_tbl', return_value=_rv1) as delete_tbl_patch: with patch.object(storage_client_mock, 'update_tbl', return_value=_rv2) as update_tbl_patch: - actual = await User.Objects.delete(2) - assert r2 == actual + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=_rv3) as patch_audit: + actual = await User.Objects.delete(user_id) + assert r2 == actual + patch_audit.assert_called_once_with('USRDL', audit_details) update_tbl_patch.assert_called_once_with('users', p2) delete_tbl_patch.assert_called_once_with('user_logins', p1) From 7cb3d39410a1e2d13d0ac6cadf562ffbcca8e18c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 1 May 2023 12:22:03 +0530 Subject: [PATCH 284/499] USRCH audit trail entry added on user changed Signed-off-by: ashish-jabble --- python/fledge/services/core/user_model.py | 30 ++- .../fledge/services/core/test_user_model.py | 185 ++++++++++++------ 2 files changed, 148 insertions(+), 67 deletions(-) diff --git a/python/fledge/services/core/user_model.py b/python/fledge/services/core/user_model.py index 70e0390d9e..4b5a51a6f3 100644 --- a/python/fledge/services/core/user_model.py +++ b/python/fledge/services/core/user_model.py @@ -165,29 +165,35 @@ async def update(cls, user_id, user_data): """ if not user_data: return False - kwargs = dict() + old_data = await cls.get(uid=user_id) + new_kwargs = {} + old_kwargs = {} if 'access_method' in user_data: - kwargs.update({"access_method": user_data['access_method']}) + old_kwargs["access_method"] = old_data['access_method'] + new_kwargs.update({"access_method": user_data['access_method']}) if 'real_name' in user_data: - kwargs.update({"real_name": user_data['real_name']}) + old_kwargs["real_name"] = old_data['real_name'] + new_kwargs.update({"real_name": user_data['real_name']}) if 'description' in user_data: - kwargs.update({"description": user_data['description']}) + old_kwargs["description"] = old_data['description'] + new_kwargs.update({"description": user_data['description']}) if 'role_id' in user_data: - kwargs.update({"role_id": user_data['role_id']}) + old_kwargs["role_id"] = old_data['role_id'] + new_kwargs.update({"role_id": user_data['role_id']}) storage_client = connect.get_storage_async() - hashed_pwd = None pwd_history_list = [] if 'password' in user_data: if len(user_data['password']): hashed_pwd = cls.hash_password(user_data['password']) current_datetime = datetime.now() - kwargs.update({"pwd": hashed_pwd, "pwd_last_changed": str(current_datetime)}) + old_kwargs["pwd"] = "****" + new_kwargs.update({"pwd": hashed_pwd, "pwd_last_changed": str(current_datetime)}) # get password history list pwd_history_list = await cls._get_password_history(storage_client, user_id, user_data) try: - payload = PayloadBuilder().SET(**kwargs).WHERE(['id', '=', user_id]).AND_WHERE( + payload = PayloadBuilder().SET(**new_kwargs).WHERE(['id', '=', user_id]).AND_WHERE( ['enabled', '=', 't']).payload() result = await storage_client.update_tbl("users", payload) if result['rows_affected']: @@ -201,6 +207,14 @@ async def update(cls, user_id, user_data): await cls._insert_pwd_history_with_oldest_pwd_deletion_if_count_exceeds( storage_client, user_id, hashed_pwd, pwd_history_list) + # USRCH audit trail entry + audit = AuditLogger(storage_client) + if 'pwd' in new_kwargs: + new_kwargs['pwd'] = "Password has been updated." + new_kwargs.pop('pwd_last_changed', None) + await audit.information( + 'USRCH', {'user_id': user_id, 'old_value': old_kwargs, 'new_value': new_kwargs, + "message": "'{}' user has been changed.".format(old_data['uname'])}) return True except StorageServerError as ex: if ex.error["retryable"]: diff --git a/tests/unit/python/fledge/services/core/test_user_model.py b/tests/unit/python/fledge/services/core/test_user_model.py index 3beb1e2945..7392942f53 100644 --- a/tests/unit/python/fledge/services/core/test_user_model.py +++ b/tests/unit/python/fledge/services/core/test_user_model.py @@ -289,25 +289,38 @@ async def test_delete_user_exception(self): async def test_update_user_role(self, user_data, payload): expected = {'response': 'updated', 'rows_affected': 1} storage_client_mock = MagicMock(StorageClientAsync) - + user_id = 2 + user_info = {'id': user_id, 'uname': 'dianomic', 'role_id': 4, 'access_method': 'cert', 'real_name': 'D System', + 'description': ''} + audit_details = {'user_id': user_id, 'old_value': {'role_id': 4}, + 'message': "'dianomic' user has been changed.", 'new_value': user_data} # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: + _rv0 = await mock_coro(user_info) _rv1 = await mock_coro() _rv2 = await mock_coro(expected) + _rv3 = await mock_coro(None) else: + _rv0 = asyncio.ensure_future(mock_coro(user_info)) _rv1 = asyncio.ensure_future(mock_coro()) _rv2 = asyncio.ensure_future(mock_coro(expected)) - - with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'update_tbl', return_value=_rv2) as update_tbl_patch: - with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv1) as delete_token_patch: - actual = await User.Objects.update(2, user_data) - assert actual is True - delete_token_patch.assert_called_once_with(2) - args, kwargs = update_tbl_patch.call_args - assert 'users' == args[0] - p = json.loads(args[1]) - assert payload == p + _rv3 = asyncio.ensure_future(mock_coro(None)) + + with patch.object(User.Objects, 'get', return_value=_rv0) as patch_get: + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'update_tbl', return_value=_rv2) as update_tbl_patch: + with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv1) as delete_token_patch: + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=_rv3) as patch_audit: + actual = await User.Objects.update(user_id, user_data) + assert actual is True + patch_audit.assert_called_once_with('USRCH', audit_details) + delete_token_patch.assert_called_once_with(user_id) + args, kwargs = update_tbl_patch.call_args + assert 'users' == args[0] + p = json.loads(args[1]) + assert payload == p + patch_get.assert_called_once_with(uid=user_id) @pytest.mark.parametrize("user_data, payload", [ ({'password': "Test@123"}, {"values": {"pwd": "HASHED_PASSWORD"}, "where": {"column": "id", "condition": "=", "value": 2}}) @@ -315,59 +328,96 @@ async def test_update_user_role(self, user_data, payload): async def test_update_user_password(self, user_data, payload): expected = {'response': 'updated', 'rows_affected': 1} storage_client_mock = MagicMock(StorageClientAsync) - + user_id = 2 + user_info = {'id': user_id, 'uname': 'dianomic', 'role_id': 4, 'access_method': 'cert', 'real_name': 'D System', + 'description': ''} + audit_details = {'user_id': user_id, 'old_value': {'pwd': '****'}, + 'new_value': {'pwd': 'Password has been updated.'}, + 'message': "'dianomic' user has been changed."} + # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: + _rv0 = await mock_coro(user_info) _rv1 = await mock_coro() _rv2 = await mock_coro(expected) _rv3 = await mock_coro(['HASHED_PWD']) + _rv4 = await mock_coro(None) else: + _rv0 = asyncio.ensure_future(mock_coro(user_info)) _rv1 = asyncio.ensure_future(mock_coro()) _rv2 = asyncio.ensure_future(mock_coro(expected)) _rv3 = asyncio.ensure_future(mock_coro(['HASHED_PWD'])) - - with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(User.Objects, 'hash_password', return_value='HASHED_PWD') as hash_pwd_patch: - with patch.object(User.Objects, '_get_password_history', return_value=_rv3) as pwd_list_patch: - with patch.object(storage_client_mock, 'update_tbl', return_value=_rv2) as update_tbl_patch: - with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv1) as delete_token_patch: - with patch.object(User.Objects, '_insert_pwd_history_with_oldest_pwd_deletion_if_count_exceeds', return_value=_rv1) as pwd_history_patch: - actual = await User.Objects.update(2, user_data) - assert actual is True - pwd_history_patch.assert_called_once_with(storage_client_mock, 2, 'HASHED_PWD', ['HASHED_PWD']) - delete_token_patch.assert_called_once_with(2) - args, kwargs = update_tbl_patch.call_args - assert 'users' == args[0] - # FIXME: payload ordering issue after datetime patch - # update_tbl_patch.assert_called_once_with('users', payload) - pwd_list_patch.assert_called_once_with(storage_client_mock, 2, user_data) - hash_pwd_patch.assert_called_once_with(user_data['password']) + _rv4 = asyncio.ensure_future(mock_coro(None)) + + with patch.object(User.Objects, 'get', return_value=_rv0) as patch_get: + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(User.Objects, 'hash_password', return_value='HASHED_PWD') as hash_pwd_patch: + with patch.object(User.Objects, '_get_password_history', return_value=_rv3) as pwd_list_patch: + with patch.object(storage_client_mock, 'update_tbl', return_value=_rv2) as update_tbl_patch: + with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv1) as delete_token_patch: + with patch.object(User.Objects, + '_insert_pwd_history_with_oldest_pwd_deletion_if_count_exceeds', + return_value=_rv1) as pwd_history_patch: + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=_rv4) as patch_audit: + actual = await User.Objects.update(user_id, user_data) + assert actual is True + patch_audit.assert_called_once_with('USRCH', audit_details) + pwd_history_patch.assert_called_once_with( + storage_client_mock, user_id, 'HASHED_PWD', ['HASHED_PWD']) + delete_token_patch.assert_called_once_with(user_id) + args, kwargs = update_tbl_patch.call_args + assert 'users' == args[0] + # FIXME: payload ordering issue after datetime patch + # update_tbl_patch.assert_called_once_with('users', payload) + pwd_list_patch.assert_called_once_with(storage_client_mock, user_id, user_data) + hash_pwd_patch.assert_called_once_with(user_data['password']) + patch_get.assert_called_once_with(uid=user_id) async def test_update_user_storage_exception(self): expected = {'message': 'Something went wrong', 'retryable': False, 'entryPoint': 'update'} - payload = '{"values": {"role_id": 2}, "where": {"column": "id", "condition": "=", "value": 2, "and": {"column": "enabled", "condition": "=", "value": "t"}}}' - storage_client_mock = MagicMock(StorageClientAsync) - with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'update_tbl', side_effect=StorageServerError(code=400, reason="blah", error=expected)) as update_tbl_patch: - with pytest.raises(ValueError) as excinfo: - await User.Objects.update(2, {'role_id': 2}) - assert str(excinfo.value) == expected['message'] - update_tbl_patch.assert_called_once_with('users', payload) + payload = '{"values": {"role_id": 2}, "where": {"column": "id", "condition": "=", "value": 2, ' \ + '"and": {"column": "enabled", "condition": "=", "value": "t"}}}' + user_id = 2 + user_info = {'id': user_id, 'uname': 'dianomic', 'role_id': 4, 'access_method': 'cert', 'real_name': 'D System', + 'description': ''} + _rv0 = await mock_coro(user_info) if sys.version_info.major == 3 and sys.version_info.minor >= 8 else \ + asyncio.ensure_future(mock_coro(user_info)) + storage_client_mock = MagicMock(StorageClientAsync) + with patch.object(User.Objects, 'get', return_value=_rv0) as patch_get: + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'update_tbl', side_effect=StorageServerError( + code=400, reason="blah", error=expected)) as update_tbl_patch: + with pytest.raises(ValueError) as excinfo: + await User.Objects.update(user_id, {'role_id': 2}) + assert str(excinfo.value) == expected['message'] + update_tbl_patch.assert_called_once_with('users', payload) + patch_get.assert_called_once_with(uid=user_id) async def test_update_user_exception(self): - payload = '{"values": {"role_id": "blah"}, "where": {"column": "id", "condition": "=", "value": 2, "and": {"column": "enabled", "condition": "=", "value": "t"}}}' + payload = '{"values": {"role_id": "blah"}, "where": {"column": "id", "condition": "=", "value": 2, ' \ + '"and": {"column": "enabled", "condition": "=", "value": "t"}}}' msg = 'Bad role id' storage_client_mock = MagicMock(StorageClientAsync) - with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'update_tbl', side_effect=ValueError(msg)) as update_tbl_patch: - with pytest.raises(Exception) as excinfo: - await User.Objects.update(2, {'role_id': 'blah'}) - assert excinfo.type is ValueError - assert str(excinfo.value) == msg - update_tbl_patch.assert_called_once_with('users', payload) + user_id = 2 + user_info = {'id': user_id, 'uname': 'dianomic', 'role_id': 4, 'access_method': 'cert', 'real_name': 'D System', + 'description': ''} + _rv0 = await mock_coro(user_info) if sys.version_info.major == 3 and sys.version_info.minor >= 8 else \ + asyncio.ensure_future(mock_coro(user_info)) + with patch.object(User.Objects, 'get', return_value=_rv0) as patch_get: + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'update_tbl', side_effect=ValueError(msg)) as update_tbl_patch: + with pytest.raises(Exception) as excinfo: + await User.Objects.update(user_id, {'role_id': 'blah'}) + assert excinfo.type is ValueError + assert str(excinfo.value) == msg + update_tbl_patch.assert_called_once_with('users', payload) + patch_get.assert_called_once_with(uid=user_id) @pytest.mark.parametrize("user_data", [ - {'real_name': 'MSD'}, {'description': 'Captain Cool'}, {'real_name': 'MSD', 'description': 'Captain Cool'}, + {'real_name': 'MSD'}, + {'description': 'Captain Cool'}, + {'real_name': 'MSD', 'description': 'Captain Cool'}, {'access_method': 'pwd'} ]) async def test_update_user_other_fields(self, user_data): @@ -376,25 +426,42 @@ async def test_update_user_other_fields(self, user_data): 'and': {'column': 'enabled', 'condition': '=', 'value': 't'}}} expected_payload.update({'values': user_data}) storage_client_mock = MagicMock(StorageClientAsync) - + user_id = 2 + user_info = {'id': user_id, 'uname': 'dianomic', 'role_id': 4, 'access_method': 'cert', 'real_name': 'D System', + 'description': ''} + + audit_details = {'user_id': user_id, 'new_value': user_data, 'message': "'dianomic' user has been changed."} + temp = {} + for u in user_data.keys(): + temp[u] = user_info[u] + audit_details['old_value'] = temp # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: + _rv0 = await mock_coro(user_info) _rv1 = await mock_coro() _rv2 = await mock_coro(expected) + _rv3 = await mock_coro(None) else: + _rv0 = asyncio.ensure_future(mock_coro(user_info)) _rv1 = asyncio.ensure_future(mock_coro()) _rv2 = asyncio.ensure_future(mock_coro(expected)) - - with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'update_tbl', return_value=_rv2) as update_tbl_patch: - with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv1) as delete_token_patch: - actual = await User.Objects.update(2, user_data) - assert actual is True - delete_token_patch.assert_not_called() - args, kwargs = update_tbl_patch.call_args - assert 'users' == args[0] - p = json.loads(args[1]) - assert expected_payload == p + _rv3 = asyncio.ensure_future(mock_coro(None)) + + with patch.object(User.Objects, 'get', return_value=_rv0) as patch_get: + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'update_tbl', return_value=_rv2) as update_tbl_patch: + with patch.object(User.Objects, 'delete_user_tokens', return_value=_rv1) as delete_token_patch: + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=_rv3) as patch_audit: + actual = await User.Objects.update(user_id, user_data) + assert actual is True + patch_audit.assert_called_once_with('USRCH', audit_details) + delete_token_patch.assert_not_called() + args, kwargs = update_tbl_patch.call_args + assert 'users' == args[0] + p = json.loads(args[1]) + assert expected_payload == p + patch_get.assert_called_once_with(uid=user_id) async def test_login_if_no_user_exists(self): async def mock_get_category_item(): From c058818e427267fa0cebe6c9379d3e51ce49d2a6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 1 May 2023 12:56:33 +0530 Subject: [PATCH 285/499] USRCH audit trail entry updated on enabling or disabling user Signed-off-by: ashish-jabble --- python/fledge/services/core/api/auth.py | 28 +++++------ python/fledge/services/core/user_model.py | 3 +- .../services/core/api/test_auth_mandatory.py | 50 ++++++++++++------- 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/python/fledge/services/core/api/auth.py b/python/fledge/services/core/api/auth.py index aa92f94911..2a7e8c3f69 100644 --- a/python/fledge/services/core/api/auth.py +++ b/python/fledge/services/core/api/auth.py @@ -12,6 +12,7 @@ import jwt from aiohttp import web +from fledge.common.audit_logger import AuditLogger from fledge.common.logger import FLCoreLogger from fledge.common.web.middleware import has_permission from fledge.common.web.ssl_wrapper import SSLVerifier @@ -275,13 +276,10 @@ async def logout(request): if int(request.user["role_id"]) == ADMIN_ROLE_ID or int(request.user["id"]) == int(user_id): result = await User.Objects.delete_user_tokens(user_id) - if not result['rows_affected']: raise web.HTTPNotFound() - # Remove OTT token for this user if there. __remove_ott_for_user(user_id) - _logger.info("User with ID:<{}> has been logged out successfully.".format(int(user_id))) else: # requester is not an admin but trying to take action for another user @@ -547,7 +545,6 @@ async def update_user(request): if 'access_method' in data: # Remove OTT token for this user only if access method is updated. __remove_ott_for_user(user_id) - except ValueError as err: msg = str(err) raise web.HTTPBadRequest(reason=str(err), body=json.dumps({"message": msg})) @@ -601,7 +598,6 @@ async def update_password(request): try: await User.Objects.update(int(user_id), {'password': new_password}) - # Remove OTT token for this user if there. __remove_ott_for_user(user_id) except ValueError as ex: @@ -650,8 +646,8 @@ async def enable_user(request): payload = PayloadBuilder().SELECT("id", "uname", "role_id", "enabled").WHERE( ['id', '=', user_id]).payload() storage_client = connect.get_storage_async() - result = await storage_client.query_tbl_with_payload('users', payload) - if len(result['rows']) == 0: + old_result = await storage_client.query_tbl_with_payload('users', payload) + if len(old_result['rows']) == 0: raise User.DoesNotExist payload = PayloadBuilder().SET(enabled=user_data['enabled']).WHERE(['id', '=', user_id]).payload() result = await storage_client.update_tbl("users", payload) @@ -661,9 +657,15 @@ async def enable_user(request): _text = 'enabled' if user_data['enabled'] == 't' else 'disabled' payload = PayloadBuilder().SELECT("id", "uname", "role_id", "enabled").WHERE( ['id', '=', user_id]).payload() - result = await storage_client.query_tbl_with_payload('users', payload) - if len(result['rows']) == 0: + new_result = await storage_client.query_tbl_with_payload('users', payload) + if len(new_result['rows']) == 0: raise User.DoesNotExist + # USRCH audit trail entry + audit = AuditLogger(storage_client) + await audit.information( + 'USRCH', {'user_id': int(user_id), 'old_value': {'enabled': old_result['rows'][0]['enabled']}, + 'new_value': {'enabled': new_result['rows'][0]['enabled']}, + "message": "'{}' user has been {}.".format(new_result['rows'][0]['uname'], _text)}) else: raise ValueError('Something went wrong during update. Check Syslogs.') else: @@ -723,13 +725,13 @@ async def reset(request): user_data.update({'role_id': data['role_id']}) if 'password' in data: user_data.update({'password': data['password']}) - + if not user_data: + msg = "Nothing to update." + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) try: await User.Objects.update(user_id, user_data) - # Remove OTT token for this user if there. __remove_ott_for_user(user_id) - except ValueError as ex: raise web.HTTPBadRequest(reason=str(ex)) except User.DoesNotExist: @@ -781,10 +783,8 @@ async def delete_user(request): result = await User.Objects.delete(user_id) if not result['rows_affected']: raise User.DoesNotExist - # Remove OTT token for this user if there. __remove_ott_for_user(user_id) - except ValueError as ex: raise web.HTTPBadRequest(reason=str(ex)) except User.DoesNotExist: diff --git a/python/fledge/services/core/user_model.py b/python/fledge/services/core/user_model.py index 4b5a51a6f3..f4449da5af 100644 --- a/python/fledge/services/core/user_model.py +++ b/python/fledge/services/core/user_model.py @@ -141,7 +141,8 @@ async def delete(cls, user_id): # first delete the active login references await cls.delete_user_tokens(user_id) - payload = PayloadBuilder().SET(enabled="f").WHERE(['id', '=', user_id]).AND_WHERE(['enabled', '=', 't']).payload() + payload = PayloadBuilder().SET(enabled="f").WHERE(['id', '=', user_id]).AND_WHERE( + ['enabled', '=', 't']).payload() result = await storage_client.update_tbl("users", payload) # USRDL audit trail entry audit = AuditLogger(storage_client) diff --git a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py index 1d719c65ce..212245b67a 100644 --- a/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py +++ b/tests/unit/python/fledge/services/core/api/test_auth_mandatory.py @@ -11,14 +11,13 @@ import pytest import sys -from fledge.common.web import middleware -from fledge.services.core import routes -from fledge.services.core import connect +from fledge.common.audit_logger import AuditLogger from fledge.common.storage_client.storage_client import StorageClientAsync -from fledge.services.core.user_model import User -from fledge.services.core.api import auth -from fledge.services.core import server +from fledge.common.web import middleware from fledge.common.web.ssl_wrapper import SSLVerifier +from fledge.services.core import connect, routes, server +from fledge.services.core.api import auth +from fledge.services.core.user_model import User __author__ = "Ashish Jabble" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -874,27 +873,36 @@ async def test_enable_with_bad_data(self, client, mocker, request_data, msg): ]) async def test_enable_user(self, client, mocker, request_data): uid = 2 - user_record = {'rows': [{'id': uid, 'role_id': '1', 'uname': 'AJ'}], 'count': 1} - update_user_record = {'rows': [{'id': uid, 'role_id': '1', 'uname': 'AJ', 'enabled': request_data['enabled']}], - 'count': 1} + if request_data['enabled'].lower() == 'true': + _modified_enabled_val = 't' + _text = 'enabled' + _payload = '{"values": {"enabled": "t"}, "where": {"column": "id", "condition": "=", "value": "2"}}' + else: + _modified_enabled_val = 'f' + _text = 'disabled' + _payload = '{"values": {"enabled": "f"}, "where": {"column": "id", "condition": "=", "value": "2"}}' + + user_record = {'rows': [{'id': uid, 'role_id': '1', 'uname': 'AJ', 'enabled': 't'}], 'count': 1} + update_user_record = {'rows': [{'id': uid, 'role_id': '1', 'uname': 'AJ', + 'enabled': _modified_enabled_val}], 'count': 1} update_result = {"rows_affected": 1, "response": "updated"} - update_payload = '{"values": {"enabled": "t"}, "where": {"column": "id", "condition": "=", "value": "2"}}' - _text, _enable, _payload = ('enabled', 't', '{"values": {"enabled": "t"}, ' - '"where": {"column": "id", "condition": "=", "value": "2"}}') \ - if str(request_data['enabled']).lower() == 'true' else ( - 'disabled', 'f', '{"values": {"enabled": "f"}, "where": {"column": "id", "condition": "=", "value": "2"}}') patch_logger_debug, patch_validate_token, patch_refresh_token, patch_user_get = await self.auth_token_fixture( mocker) + audit_details = {'user_id': uid, 'old_value': {'enabled': 't'}, + 'new_value': {'enabled': _modified_enabled_val}, + 'message': "'AJ' user has been {}.".format(_text)} storage_client_mock = MagicMock(StorageClientAsync) # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: _rv1 = await mock_coro([{'id': '1'}]) _rv2 = await mock_coro(update_result) + _rv3 = await mock_coro(None) _se1 = await mock_coro(user_record) _se2 = await mock_coro(update_user_record) else: _rv1 = asyncio.ensure_future(mock_coro([{'id': '1'}])) _rv2 = asyncio.ensure_future(mock_coro(update_result)) + _rv3 = asyncio.ensure_future(mock_coro(None)) _se1 = asyncio.ensure_future(mock_coro(user_record)) _se2 = asyncio.ensure_future(mock_coro(update_user_record)) @@ -904,11 +912,15 @@ async def test_enable_user(self, client, mocker, request_data): side_effect=[_se1, _se2]) as q_tbl_patch: with patch.object(storage_client_mock, 'update_tbl', return_value=_rv2) as update_tbl_patch: - resp = await client.put('/fledge/admin/{}/enable'.format(uid), data=json.dumps(request_data), - headers=ADMIN_USER_HEADER) - assert 200 == resp.status - r = await resp.text() - assert {"message": "User with ID:<2> has been {} successfully.".format(_text)} == json.loads(r) + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=_rv3) as patch_audit: + resp = await client.put('/fledge/admin/{}/enable'.format(uid), data=json.dumps( + request_data), headers=ADMIN_USER_HEADER) + assert 200 == resp.status + r = await resp.text() + assert {"message": "User with ID:<2> has been {} successfully.".format(_text) + } == json.loads(r) + patch_audit.assert_called_once_with('USRCH', audit_details) update_tbl_patch.assert_called_once_with('users', _payload) assert 2 == q_tbl_patch.call_count args, kwargs = q_tbl_patch.call_args_list[0] From f54166730d7ff3ef5182bef3e272407b2fa99d32 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 1 May 2023 15:51:24 +0530 Subject: [PATCH 286/499] logger error text fixes as per feedback Signed-off-by: ashish-jabble --- .../microservice_management_client.py | 4 ++-- python/fledge/common/plugin_discovery.py | 4 ++-- python/fledge/common/statistics.py | 2 +- python/fledge/services/common/utils.py | 2 +- python/fledge/services/core/api/audit.py | 2 +- .../fledge/services/core/api/backup_restore.py | 4 ++-- .../test_microservice_management_client.py | 16 ++++++++-------- .../fledge/common/test_plugin_discovery.py | 8 ++++---- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/python/fledge/common/microservice_management_client/microservice_management_client.py b/python/fledge/common/microservice_management_client/microservice_management_client.py index cebd9431d0..247205fe5a 100644 --- a/python/fledge/common/microservice_management_client/microservice_management_client.py +++ b/python/fledge/common/microservice_management_client/microservice_management_client.py @@ -87,7 +87,7 @@ def unregister_service(self, microservice_id): try: response["id"] except (KeyError, Exception) as ex: - _logger.exception(ex, "Could not unregister the micro-service having uuid {}".format(microservice_id)) + _logger.exception(ex, "Could not unregister the microservice having UUID {}".format(microservice_id)) raise return response @@ -178,7 +178,7 @@ def get_services(self, service_name=None, service_type=None): try: response["services"] except (KeyError, Exception) as ex: - _logger.exception(ex, "Could not find the micro-service for requested url {}".format(url)) + _logger.exception(ex, "Could not find the microservice for requested url {}".format(url)) raise return response diff --git a/python/fledge/common/plugin_discovery.py b/python/fledge/common/plugin_discovery.py index a133ec0394..02bfe73fea 100644 --- a/python/fledge/common/plugin_discovery.py +++ b/python/fledge/common/plugin_discovery.py @@ -204,9 +204,9 @@ def get_plugin_config(cls, plugin_dir, plugin_type, installed_dir_name, is_confi except DeprecationWarning: _logger.warning('"{}" plugin is deprecated'.format(plugin_dir.split('/')[-1])) except FileNotFoundError as ex: - _logger.error(ex, 'Plugin "{}" import problem from path "{}".'.format(plugin_dir, plugin_module_path)) + _logger.error(ex, 'Import problem from path "{}" for {} plugin.'.format(plugin_module_path, plugin_dir)) except Exception as ex: - _logger.exception(ex, 'Plugin "{}" failed while fetching config'.format(plugin_dir)) + _logger.exception(ex, 'Failed to fetch config for {} plugin.'.format(plugin_dir)) return plugin_config diff --git a/python/fledge/common/statistics.py b/python/fledge/common/statistics.py index b27c1c5de4..7481c0c463 100644 --- a/python/fledge/common/statistics.py +++ b/python/fledge/common/statistics.py @@ -143,7 +143,7 @@ async def register(self, key, description): """ The error may be because the key has been created in another process, reload keys """ await self._load_keys() if key not in self._registered_keys: - _logger.exception(ex, 'Unable to create new statistic {}'.format(key)) + _logger.exception(ex, 'Unable to create new statistic {} key.'.format(key)) raise async def _load_keys(self): diff --git a/python/fledge/services/common/utils.py b/python/fledge/services/common/utils.py index 6b9e1b3652..b31cf68fab 100644 --- a/python/fledge/services/common/utils.py +++ b/python/fledge/services/common/utils.py @@ -63,7 +63,7 @@ async def shutdown_service(service, loop=None): if not status_code == 200: raise Exception(message=text) except Exception as ex: - _logger.exception(ex, 'Error in {} Service shutdown'.format(service._name)) + _logger.exception(ex, 'Failed to shutdown {} service.'.format(service._name)) return False else: _logger.info('Service %s, id %s at url %s successfully shutdown', service._name, service._id, url_shutdown) diff --git a/python/fledge/services/core/api/audit.py b/python/fledge/services/core/api/audit.py index ff294cbb88..fbf6864547 100644 --- a/python/fledge/services/core/api/audit.py +++ b/python/fledge/services/core/api/audit.py @@ -270,7 +270,7 @@ async def get_audit_entries(request): res.append(r) except Exception as ex: msg = str(ex) - _logger.error(ex, "Get Audit log entry failed.") + _logger.error(ex, "Failed to get Audit log entry.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({'audit': res, 'totalCount': total_count}) diff --git a/python/fledge/services/core/api/backup_restore.py b/python/fledge/services/core/api/backup_restore.py index ce52c34839..7d9e9a9c87 100644 --- a/python/fledge/services/core/api/backup_restore.py +++ b/python/fledge/services/core/api/backup_restore.py @@ -116,7 +116,7 @@ async def get_backups(request): res.append(r) except Exception as ex: msg = str(ex) - _logger.error(ex, "Get all backups failed.") + _logger.error(ex, "Failed to get the list of Backup records.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) return web.json_response({"backups": res}) @@ -206,7 +206,7 @@ async def get_backup_download(request): raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error(ex, "Failed to backup download for ID: <{}>.".format(backup_id)) + _logger.error(ex, "Failed to download Backup file for ID: <{}>.".format(backup_id)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.FileResponse(path=gz_path) diff --git a/tests/unit/python/fledge/common/microservice_management_client/test_microservice_management_client.py b/tests/unit/python/fledge/common/microservice_management_client/test_microservice_management_client.py index 44516abd9d..ad17fd7e28 100644 --- a/tests/unit/python/fledge/common/microservice_management_client/test_microservice_management_client.py +++ b/tests/unit/python/fledge/common/microservice_management_client/test_microservice_management_client.py @@ -120,7 +120,7 @@ def test_unregister_service_no_id(self): assert excinfo.type is KeyError assert 1 == log_error.call_count args = log_error.call_args - assert 'Could not unregister the micro-service having uuid {}'.format('someid') == args[0][1] + assert 'Could not unregister the microservice having UUID {}'.format('someid') == args[0][1] response_patch.assert_called_once_with() request_patch.assert_called_once_with(method='DELETE', url='/fledge/service/someid') @@ -313,7 +313,7 @@ def test_get_services_no_services(self): assert excinfo.type is KeyError assert 1 == log_error.call_count args = log_error.call_args - assert 'Could not find the micro-service for requested url {}'.format( + assert 'Could not find the microservice for requested url {}'.format( '/fledge/service?name=foo&type=bar') == args[0][1] response_patch.assert_called_once_with() request_patch.assert_called_once_with(method='GET', url='/fledge/service?name=foo&type=bar') @@ -354,7 +354,7 @@ def test_get_configuration_category(self): test_dict = { 'ping_timeout': { 'type': 'integer', - 'description': 'Timeout for a response from any given micro-service. (must be greater than 0)', + 'description': 'Timeout for a response from any given microservice. (must be greater than 0)', 'value': '1', 'default': '1'}, 'sleep_interval': { @@ -406,7 +406,7 @@ def test_get_configuration_item(self): response_mock.read.return_value = undecoded_data_mock test_dict = { 'type': 'integer', - 'description': 'Timeout for a response from any given micro-service. (must be greater than 0)', + 'description': 'Timeout for a response from any given microservice. (must be greater than 0)', 'value': '1', 'default': '1' } @@ -457,7 +457,7 @@ def test_create_configuration_category(self): 'value': { 'ping_timeout': { 'type': 'integer', - 'description': 'Timeout for a response from any given micro-service. (must be greater than 0)', + 'description': 'Timeout for a response from any given microservice. (must be greater than 0)', 'value': '1', 'default': '1'}, 'sleep_interval': { @@ -495,7 +495,7 @@ def test_create_configuration_category_exception(self, status_code, host): 'value': { 'ping_timeout': { 'type': 'integer', - 'description': 'Timeout for a response from any given micro-service. (must be greater than 0)', + 'description': 'Timeout for a response from any given microservice. (must be greater than 0)', 'value': '1', 'default': '1'}, 'sleep_interval': { @@ -540,7 +540,7 @@ def test_create_configuration_category_keep_original(self): 'value': { 'ping_timeout': { 'type': 'integer', - 'description': 'Timeout for a response from any given micro-service. (must be greater than 0)', + 'description': 'Timeout for a response from any given microservice. (must be greater than 0)', 'value': '1', 'default': '1'}, 'sleep_interval': { @@ -559,7 +559,7 @@ def test_create_configuration_category_keep_original(self): 'value': { 'ping_timeout': { 'type': 'integer', - 'description': 'Timeout for a response from any given micro-service. (must be greater than 0)', + 'description': 'Timeout for a response from any given microservice. (must be greater than 0)', 'value': '1', 'default': '1'}, 'sleep_interval': { diff --git a/tests/unit/python/fledge/common/test_plugin_discovery.py b/tests/unit/python/fledge/common/test_plugin_discovery.py index ccbc31627b..1af95266b6 100644 --- a/tests/unit/python/fledge/common/test_plugin_discovery.py +++ b/tests/unit/python/fledge/common/test_plugin_discovery.py @@ -559,8 +559,8 @@ def test_bad_fetch_c_north_plugin_installed(self, info, exc_count): assert exc_count == patch_log_exc.call_count @pytest.mark.parametrize("exc_name, log_exc_name, msg", [ - (FileNotFoundError, "error", 'Plugin "modbus" import problem from path "modbus".'), - (Exception, "exception", 'Plugin "modbus" failed while fetching config') + (FileNotFoundError, "error", 'Import problem from path "modbus" for modbus plugin.'), + (Exception, "exception", 'Failed to fetch config for modbus plugin.') ]) def test_bad_get_south_plugin_config(self, exc_name, log_exc_name, msg): with patch.object(_logger, log_exc_name) as patch_log_exc: @@ -571,8 +571,8 @@ def test_bad_get_south_plugin_config(self, exc_name, log_exc_name, msg): assert msg == args[0][1] @pytest.mark.parametrize("exc_name, log_exc_name, msg", [ - (FileNotFoundError, "error", 'Plugin "http" import problem from path "http".'), - (Exception, "exception", 'Plugin "http" failed while fetching config') + (FileNotFoundError, "error", 'Import problem from path "http" for http plugin.'), + (Exception, "exception", 'Failed to fetch config for http plugin.') ]) def test_bad_get_north_plugin_config(self, exc_name, log_exc_name, msg): with patch.object(_logger, log_exc_name) as patch_log_exc: From d2cf5b14a5e03148723a705152fc03a833b17ce8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 2 May 2023 11:49:37 +0530 Subject: [PATCH 287/499] enabled KV pair across control pipeline Signed-off-by: ashish-jabble --- .../services/core/api/control_pipeline.py | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index 3e6cab1168..771e8bd57f 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -77,11 +77,11 @@ async def create(request: web.Request) -> web.Response: source or destination :Example: - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "pump", "enable": "true", "execution": "shared", "source": {"type": 2, "name": "pump"}}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "broadcast", "enable": "true", "execution": "exclusive", "destination": {"type": 4}}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enable": "true", "execution": "shared", "source": {"type": 2, "name": "opcua"}, "destination": {"type": 2, "name": "pump1"}}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enable": "true", "execution": "exclusive", "source": {"type": 2, "name": "southOpcua"}, "destination": {"type": 1, "name": "northOpcua"}, "filters": ["Filter1"]}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "Test", "enable": "true", "filters": ["Filter1", "Filter2"]}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "pump", "enabled": true, "execution": "shared", "source": {"type": 2, "name": "pump"}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "broadcast", "enabled": true, "execution": "exclusive", "destination": {"type": 4}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enabled": true, "execution": "shared", "source": {"type": 2, "name": "opcua"}, "destination": {"type": 2, "name": "pump1"}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enabled": true, "execution": "exclusive", "source": {"type": 2, "name": "southOpcua"}, "destination": {"type": 1, "name": "northOpcua"}, "filters": ["Filter1"]}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "Test", "enabled": false, "filters": ["Filter1", "Filter2"]}' """ try: data = await request.json() @@ -203,7 +203,7 @@ async def update(request: web.Request) -> web.Response: :Example: curl -sX PUT http://localhost:8081/fledge/control/pipeline/1 -d '{"filters": ["F3", "F2"]}' curl -sX PUT http://localhost:8081/fledge/control/pipeline/13 -d '{"name": "Changed"}' - curl -sX PUT http://localhost:8081/fledge/control/pipeline/9 -d '{"enable": "false", "execution": "exclusive", "filters": [], "source": {"type": 1, "name": "Universal"}, "destination": {"type": 3, "name": "TestScript"}}' + curl -sX PUT http://localhost:8081/fledge/control/pipeline/9 -d '{"enabled": false, "execution": "exclusive", "filters": [], "source": {"type": 1, "name": "Universal"}, "destination": {"type": 3, "name": "TestScript"}}' """ cpid = request.match_info.get('id', None) try: @@ -359,17 +359,12 @@ async def _check_parameters(payload): if len(name) == 0: raise ValueError('Pipeline name cannot be empty.') column_names['name'] = name - # enable - enabled = payload.get('enable', None) + # enabled + enabled = payload.get('enabled', None) if enabled is not None: - if not isinstance(enabled, str): - raise ValueError('Enable should be in string.') - enabled = enabled.strip() - if len(enabled) == 0: - raise ValueError('Enable value cannot be empty.') - if enabled.lower() not in ["true", "false"]: - raise ValueError('Enable value either True or False.') - column_names['enabled'] = 't' if enabled.lower() == 'true' else 'f' + if not isinstance(enabled, bool): + raise ValueError('Enabled should be a bool.') + column_names['enabled'] = 't' if enabled else 'f' # execution execution = payload.get('execution', None) if execution is not None: From fc9d04314347d90cc592535a5905961c7069c4a4 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 3 May 2023 15:16:54 +0530 Subject: [PATCH 288/499] source type API handling of value added internally in API for both authentication mode Signed-off-by: ashish-jabble --- python/fledge/services/core/api/control_pipeline.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_pipeline.py index 9188859d7d..f100d41dfd 100644 --- a/python/fledge/services/core/api/control_pipeline.py +++ b/python/fledge/services/core/api/control_pipeline.py @@ -86,7 +86,7 @@ async def create(request: web.Request) -> web.Response: try: data = await request.json() # Create entry in control_pipelines table - column_names = await _check_parameters(data) + column_names = await _check_parameters(data, request) source_type = column_names.get("stype") if source_type is None: column_names['stype'] = 0 @@ -209,7 +209,7 @@ async def update(request: web.Request) -> web.Response: try: pipeline = await _get_pipeline(cpid) data = await request.json() - columns = await _check_parameters(data) + columns = await _check_parameters(data, request) storage = connect.get_storage_async() if columns: payload = PayloadBuilder().SET(**columns).WHERE(['cpid', '=', cpid]).payload() @@ -348,7 +348,7 @@ async def _get_lookup_value(_type, value): return ''.join(name) -async def _check_parameters(payload): +async def _check_parameters(payload, request): column_names = {} # name name = payload.get('name', None) @@ -392,8 +392,8 @@ async def _check_parameters(payload): raise ValueError("Invalid source type found.") else: raise ValueError('Source type is missing.') - # Note: when source type is Any; no name is applied - if source_type != 1: + # Note: when source type is Any or API; no name is applied + if source_type not in (1, 3): if source_name is not None: if not isinstance(source_name, str): raise ValueError("Source name should be a string value.") @@ -407,6 +407,8 @@ async def _check_parameters(payload): raise ValueError('Source name is missing.') else: source_name = '' + if source_type == 3: + source_name = request.user["uname"] if hasattr(request, "user") and request.user else "anonymous" source = {'type': source_type, 'name': source_name} column_names["stype"] = source_type column_names["sname"] = source_name From 7a0e7a2892074521e9cbfad7146cd5dd51cb7328 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 4 May 2023 11:03:25 +0530 Subject: [PATCH 289/499] pipeline API moved to control_service Signed-off-by: ashish-jabble --- .../{control_pipeline.py => control_service/pipeline.py} | 0 python/fledge/services/core/routes.py | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename python/fledge/services/core/api/{control_pipeline.py => control_service/pipeline.py} (100%) diff --git a/python/fledge/services/core/api/control_pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py similarity index 100% rename from python/fledge/services/core/api/control_pipeline.py rename to python/fledge/services/core/api/control_service/pipeline.py diff --git a/python/fledge/services/core/routes.py b/python/fledge/services/core/routes.py index 715606093b..125315e5b5 100644 --- a/python/fledge/services/core/routes.py +++ b/python/fledge/services/core/routes.py @@ -5,13 +5,13 @@ # FLEDGE_END from fledge.services.core import proxy -from fledge.services.core.api import asset_tracker, auth, backup_restore, browser, certificate_store, control_pipeline, filters, health, notification, north, package_log, python_packages, south, support, service, task, update +from fledge.services.core.api import asset_tracker, auth, backup_restore, browser, certificate_store, filters, health, notification, north, package_log, python_packages, south, support, service, task, update from fledge.services.core.api import audit as api_audit from fledge.services.core.api import common as api_common from fledge.services.core.api import configuration as api_configuration from fledge.services.core.api import scheduler as api_scheduler from fledge.services.core.api import statistics as api_statistics -from fledge.services.core.api.control_service import script_management, acl_management +from fledge.services.core.api.control_service import script_management, acl_management, pipeline from fledge.services.core.api.plugins import data as plugin_data from fledge.services.core.api.plugins import install as plugins_install, discovery as plugins_discovery from fledge.services.core.api.plugins import update as plugins_update @@ -248,7 +248,7 @@ def setup(app): app.router.add_route('DELETE', '/fledge/service/{service_name}/ACL', acl_management.detach_acl_from_service) # Control Pipelines - control_pipeline.setup(app) + pipeline.setup(app) app.router.add_route('GET', '/fledge/python/packages', python_packages.get_packages) app.router.add_route('POST', '/fledge/python/package', python_packages.install_package) From 38b9652c115561d9631b30adaffe5b227a6a9d64 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 4 May 2023 15:04:01 +0530 Subject: [PATCH 290/499] pipeline not allowed with same type of source & destination Signed-off-by: ashish-jabble --- .../core/api/control_service/pipeline.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index f100d41dfd..ec0430750d 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -452,6 +452,20 @@ async def _check_parameters(payload, request): else: column_names["dtype"] = 0 column_names["dname"] = "" + if source is not None and destination is not None: + error_msg = "Pipeline is not allowed with same type of source and destination." + # Service + if source_type == 2 and des_type == 1: + schedules = await server.Server.scheduler.get_schedules() + south_schedules = [sch.name for sch in schedules if sch.schedule_type == 1 and sch.process_name == "south_c"] + north_schedules = [sch.name for sch in schedules if + sch.schedule_type == 1 and sch.process_name == "north_C"] + if (source_name in south_schedules and des_name in south_schedules) or ( + source_name in north_schedules and des_name in north_schedules): + raise ValueError(error_msg) + # Script + if source_type == 6 and des_type == 3: + raise ValueError(error_msg) if name: # Check unique pipeline if await _pipeline_in_use(name, source, destination): @@ -472,12 +486,11 @@ async def _validate_lookup_name(lookup_name, _type, value): async def get_schedules(): schedules = await server.Server.scheduler.get_schedules() if _type == 5: - # Verify against all type of schedules; we might filter out STARTUP type schedules? + # Verify against all type of schedules if not any(sch.name == value for sch in schedules): raise ValueError("'{}' not a valid schedule name.".format(value)) else: - # Verify against STARTUP type schedule and having South, North based service; - # we might filter out source with South and destination with North? + # Verify against STARTUP type schedule and having South, North based service if not any(sch.name == value for sch in schedules if sch.schedule_type == 1 and sch.process_name in ('south_c', 'north_C')): raise ValueError("'{}' not a valid service.".format(value)) @@ -515,7 +528,7 @@ async def get_notifications(): # Verify asset name await get_assets() else: - """No validation required for source id 1(Any), 3(API) & destination id 4(Broadcast)""" + """No validation required for source id 1(Any) & destination id 4(Broadcast)""" pass From 2a99cc043d4ef328e4e4349ee051ccc99d93bfc7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 5 May 2023 11:26:57 +0530 Subject: [PATCH 291/499] urllib3 >=2 is not compatible with OpenSSL 1.1.1+;therefore 1.26.15 version used Signed-off-by: ashish-jabble --- docs/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index f5cae46112..7220b25057 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ Sphinx==3.5.4 -docutils<0.18 \ No newline at end of file +docutils<0.18 +urllib3==1.26.15 \ No newline at end of file From 8ece5cbfb582b772928d2bd00ae573be71402fd9 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 8 May 2023 15:55:41 +0530 Subject: [PATCH 292/499] Handling of disallowing a name while updating pipeline Signed-off-by: ashish-jabble --- .../core/api/control_service/pipeline.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index ec0430750d..fb9c57b764 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -207,8 +207,9 @@ async def update(request: web.Request) -> web.Response: """ cpid = request.match_info.get('id', None) try: - pipeline = await _get_pipeline(cpid) data = await request.json() + name = data.get('name', None) + pipeline = await _check_unique_pipeline(name, cpid) columns = await _check_parameters(data, request) storage = connect.get_storage_async() if columns: @@ -467,10 +468,12 @@ async def _check_parameters(payload, request): if source_type == 6 and des_type == 3: raise ValueError(error_msg) if name: - # Check unique pipeline - if await _pipeline_in_use(name, source, destination): - raise ValueError("{} control pipeline must be unique and there must be no other pipelines " - "with the same source and destination.".format(name)) + cpid = request.match_info.get('id', None) + if cpid is None: + # Check unique pipeline in case of creation + if await _pipeline_in_use(name, source, destination): + raise ValueError("{} control pipeline must be unique and there must be no other pipelines " + "with the same source and destination.".format(name)) # filters filters = payload.get('filters', None) if filters is not None: @@ -597,3 +600,23 @@ async def _update_filters(storage, cp_id, cp_name, cp_filters): except: pass return new_filters + + +async def _check_unique_pipeline(name, cpid=None): + pipeline = await _get_pipeline(cpid) + if name is not None: + name = name.strip() + """Disallow pipeline name cases when cpid is not None: + a) If given pipeline have already attached filters. + b) If given pipeline name already exists in DB. + """ + if name != pipeline['name']: + pipeline_filter_result = await _get_table_column_by_value("control_filters", "cpid", cpid) + if pipeline_filter_result['rows']: + raise ValueError('Filters have already attached to {} pipeline. ' + 'So, pipeline name cannot be changed.'.format(pipeline['name'])) + pipeline_result = await _get_table_column_by_value("control_pipelines", "name", name) + if pipeline_result['rows']: + raise ValueError('{} pipeline already in use. So pipeline name cannot be changed.'.format(name)) + return pipeline + From 38c1fee223f33034bd8b747e990ee3a2bec20f56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 May 2023 18:26:59 +0530 Subject: [PATCH 293/499] Bump pyjwt from 1.6.4 to 2.4.0 in /python (#660) * Bump pyjwt from 1.6.4 to 2.4.0 in /python Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 1.6.4 to 2.4.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/1.6.4...2.4.0) --- updated-dependencies: - dependency-name: pyjwt dependency-type: direct:production ... Signed-off-by: dependabot[bot] * changes as per pyjwt upgrade pip package to stable released version (#683) * Bump pyjwt from 1.6.4 to 2.4.0 in /python Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 1.6.4 to 2.4.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/1.6.4...2.4.0) --- updated-dependencies: - dependency-name: pyjwt dependency-type: direct:production ... Signed-off-by: dependabot[bot] * changes as per pyjwt upgrade pip package to stable released version Signed-off-by: ashish-jabble Signed-off-by: dependabot[bot] Signed-off-by: ashish-jabble Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * attribute 'decode' error fixes for jwt Signed-off-by: ashish-jabble --------- Signed-off-by: dependabot[bot] Signed-off-by: ashish-jabble Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mark Riddoch Co-authored-by: Ashish Jabble Co-authored-by: Praveen Garg --- python/fledge/services/core/api/auth.py | 3 +-- python/fledge/services/core/server.py | 14 ++++---------- python/fledge/services/core/user_model.py | 2 +- python/requirements.txt | 2 +- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/python/fledge/services/core/api/auth.py b/python/fledge/services/core/api/auth.py index 0bf33ead26..8a47c5a6fb 100644 --- a/python/fledge/services/core/api/auth.py +++ b/python/fledge/services/core/api/auth.py @@ -222,8 +222,7 @@ async def get_ott(request): else: now_time = datetime.datetime.now() p = {'uid': user_id, 'exp': now_time} - ott_token = jwt.encode(p, JWT_SECRET, JWT_ALGORITHM).decode("utf-8") - + ott_token = jwt.encode(p, JWT_SECRET, JWT_ALGORITHM) already_existed_token = False key_to_remove = None for k, v in OTT.OTT_MAP.items(): diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 48833300d8..ff1854e795 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -1244,9 +1244,7 @@ async def register(cls, request): } # Create JWT token - bearer_token = jwt.encode(claims, - SERVICE_JWT_SECRET, - SERVICE_JWT_ALGORITHM).decode("utf-8") if token is not None else "" + bearer_token = jwt.encode(claims, SERVICE_JWT_SECRET, SERVICE_JWT_ALGORITHM) if token is not None else "" # Add the bearer token for that service being registered ServiceRegistry.addBearerToken(service_name, bearer_token) @@ -1853,10 +1851,8 @@ def validate_token(cls, token): """ Validate service bearer token """ try: - ret = jwt.decode(token, - SERVICE_JWT_SECRET, - algorithms=[SERVICE_JWT_ALGORITHM], - options={"verify_signature": True, "verify_aud": False, "verify_exp": True}) + ret = jwt.decode(token, SERVICE_JWT_SECRET, algorithms=[SERVICE_JWT_ALGORITHM], + options={"verify_signature": True, "verify_aud": False, "verify_exp": True}) return ret except Exception as e: return {'error': str(e)} @@ -1880,9 +1876,7 @@ async def refresh_token(cls, request): claims = cls.get_token_common(request) # Expiration set to now + delta claims['exp'] = int(time.time()) + SERVICE_JWT_EXP_DELTA_SECONDS - bearer_token = jwt.encode(claims, - SERVICE_JWT_SECRET, - SERVICE_JWT_ALGORITHM).decode("utf-8") + bearer_token = jwt.encode(claims, SERVICE_JWT_SECRET, SERVICE_JWT_ALGORITHM) # Replace bearer_token for the service ServiceRegistry.addBearerToken(claims['sub'], bearer_token) diff --git a/python/fledge/services/core/user_model.py b/python/fledge/services/core/user_model.py index f4449da5af..8575264639 100644 --- a/python/fledge/services/core/user_model.py +++ b/python/fledge/services/core/user_model.py @@ -379,7 +379,7 @@ async def _get_new_token(cls, storage_client, found_user, host): exp = datetime.now() + timedelta(seconds=JWT_EXP_DELTA_SECONDS) uid = found_user['id'] p = {'uid': uid, 'exp': exp} - jwt_token = jwt.encode(p, JWT_SECRET, JWT_ALGORITHM).decode("utf-8") + jwt_token = jwt.encode(p, JWT_SECRET, JWT_ALGORITHM) payload = PayloadBuilder().INSERT(user_id=p['uid'], token=jwt_token, token_expiration=str(exp), ip=host).payload() diff --git a/python/requirements.txt b/python/requirements.txt index 47cd3e821b..75e8a090ce 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -4,8 +4,8 @@ aiohttp_cors==0.7.0 cchardet==2.1.4;python_version<"3.9" cchardet==2.1.7;python_version>="3.9" yarl==1.7.2 +pyjwt==2.4.0 -pyjwt==1.6.4 # only required for Public Proxy multipart payload requests-toolbelt==0.9.1 From 07654c0306c8c2d31867b2399f033eb0335ddb9f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 9 May 2023 11:54:41 +0530 Subject: [PATCH 294/499] Handling of duplicate pipeline name on creation Signed-off-by: ashish-jabble --- .../core/api/control_service/pipeline.py | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index fb9c57b764..e2edd95455 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -80,7 +80,7 @@ async def create(request: web.Request) -> web.Response: curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "pump", "enabled": true, "execution": "shared", "source": {"type": 2, "name": "pump"}}' curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "broadcast", "enabled": true, "execution": "exclusive", "destination": {"type": 4}}' curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enabled": true, "execution": "shared", "source": {"type": 2, "name": "opcua"}, "destination": {"type": 2, "name": "pump1"}}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enabled": true, "execution": "exclusive", "source": {"type": 2, "name": "southOpcua"}, "destination": {"type": 1, "name": "northOpcua"}, "filters": ["Filter1"]}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump1", "enabled": true, "execution": "exclusive", "source": {"type": 2, "name": "southOpcua"}, "destination": {"type": 1, "name": "northOpcua"}, "filters": ["Filter1"]}' curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "Test", "enabled": false, "filters": ["Filter1", "Filter2"]}' """ try: @@ -207,9 +207,9 @@ async def update(request: web.Request) -> web.Response: """ cpid = request.match_info.get('id', None) try: + pipeline = await _get_pipeline(cpid) data = await request.json() - name = data.get('name', None) - pipeline = await _check_unique_pipeline(name, cpid) + data['old_pipeline_name'] = pipeline['name'] columns = await _check_parameters(data, request) storage = connect.get_storage_async() if columns: @@ -287,9 +287,11 @@ async def _get_all_lookups(tbl_name=None): return {"source": source_lookup, "destination": des_lookup} -async def _get_table_column_by_value(table, column_name, column_value): +async def _get_table_column_by_value(table, column_name, column_value, limit=None): storage = connect.get_storage_async() payload = PayloadBuilder().WHERE([column_name, '=', column_value]).payload() + if limit is not None: + payload = PayloadBuilder().WHERE([column_name, '=', column_value]).LIMIT(limit).payload() result = await storage.query_tbl_with_payload(table, payload) return result @@ -359,6 +361,9 @@ async def _check_parameters(payload, request): name = name.strip() if len(name) == 0: raise ValueError('Pipeline name cannot be empty.') + cpid = request.match_info.get('id', None) + old_name = payload.get('old_pipeline_name', None) + await _check_unique_pipeline(name, old_name, cpid) column_names['name'] = name # enabled enabled = payload.get('enabled', None) @@ -467,13 +472,6 @@ async def _check_parameters(payload, request): # Script if source_type == 6 and des_type == 3: raise ValueError(error_msg) - if name: - cpid = request.match_info.get('id', None) - if cpid is None: - # Check unique pipeline in case of creation - if await _pipeline_in_use(name, source, destination): - raise ValueError("{} control pipeline must be unique and there must be no other pipelines " - "with the same source and destination.".format(name)) # filters filters = payload.get('filters', None) if filters is not None: @@ -602,21 +600,20 @@ async def _update_filters(storage, cp_id, cp_name, cp_filters): return new_filters -async def _check_unique_pipeline(name, cpid=None): - pipeline = await _get_pipeline(cpid) - if name is not None: - name = name.strip() - """Disallow pipeline name cases when cpid is not None: - a) If given pipeline have already attached filters. - b) If given pipeline name already exists in DB. - """ - if name != pipeline['name']: - pipeline_filter_result = await _get_table_column_by_value("control_filters", "cpid", cpid) +async def _check_unique_pipeline(name, old_name=None, cpid=None): + """Disallow pipeline name cases: + a) If given pipeline name already exists in DB. + b) If given pipeline has already attached filters. + """ + if cpid is not None: + if name != old_name: + pipeline_filter_result = await _get_table_column_by_value("control_filters", "cpid", cpid, limit=1) if pipeline_filter_result['rows']: - raise ValueError('Filters have already attached to {} pipeline. ' - 'So, pipeline name cannot be changed.'.format(pipeline['name'])) - pipeline_result = await _get_table_column_by_value("control_pipelines", "name", name) + raise ValueError('Filters are attached. Pipeline name cannot be changed.') + pipeline_result = await _get_table_column_by_value("control_pipelines", "name", name, limit=1) if pipeline_result['rows']: - raise ValueError('{} pipeline already in use. So pipeline name cannot be changed.'.format(name)) - return pipeline - + raise ValueError('{} pipeline already exists, name cannot be changed.'.format(name)) + else: + pipeline_result = await _get_table_column_by_value("control_pipelines", "name", name, limit=1) + if pipeline_result['rows']: + raise ValueError('{} pipeline already exists with the same name.'.format(name)) From c595c784e2909d3c768288ba906d27326d59f93c Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Wed, 10 May 2023 18:39:41 +0530 Subject: [PATCH 295/499] FOGL-7687 Add fixture to install & add external services in conftest.py (#1062) --- tests/system/python/conftest.py | 135 ++++++++++++++++++ .../packages/test_rule_data_availability.py | 53 +++---- ...st_statistics_history_notification_rule.py | 50 +++---- tests/system/python/scripts/install_c_plugin | 10 +- 4 files changed, 178 insertions(+), 70 deletions(-) diff --git a/tests/system/python/conftest.py b/tests/system/python/conftest.py index f04c7e2cdd..62c4c37edb 100644 --- a/tests/system/python/conftest.py +++ b/tests/system/python/conftest.py @@ -17,7 +17,10 @@ import shutil from urllib.parse import quote from pathlib import Path +import time import pytest +from helpers import utils + __author__ = "Vaibhav Singhal" __copyright__ = "Copyright (c) 2019 Dianomic Systems" @@ -212,6 +215,138 @@ def clone_make_install(): return _add_fledge_north +@pytest.fixture +def add_service(): + def _add_service(fledge_url, service, service_branch, retries, installation_type = "make", service_name = "svc@123", + enabled = True): + + """ + Fixture to add Service and start the start service by default + fledge_url: IP address or domain to access fledge + service: Service to be installed + service_branch: Branch of service to be installed + retries: Number of tries for polling + installation_type: Type of installation for service i.e. make or package + service_name: Name that will be given to service to be installed + enabled: Flag to enable or disable notification instance + """ + + # Check if the service is already installed installed + retval = utils.get_request(fledge_url, "/fledge/service") + for ele in retval["services"]: + if ele["type"].lower() == service: + return ele + + PROJECT_ROOT = Path(__file__).parent.parent.parent.parent + + # Install Service + def clone_make_install(): + try: + subprocess.run(["{}/tests/system/python/scripts/install_c_service {} {}".format( + PROJECT_ROOT, service_branch, service)], shell=True, check=True) + except subprocess.CalledProcessError: + assert False, "{} service installation failed".format(service) + + if installation_type == 'make': + clone_make_install() + elif installation_type == 'package': + try: + subprocess.run(["sudo {} install -y fledge-service-{}".format(pytest.PKG_MGR, service)], shell=True, + check=True) + except subprocess.CalledProcessError: + assert False, "{} package installation failed!".format(service) + else: + return("Skipped {} service installation. Installation mechanism is set to {}.".format(service, installation_type)) + + # Add Service + data = {"name": "{}".format(service_name), "type": "{}".format(service), "enabled": enabled} + retval = utils.post_request(fledge_url, "/fledge/service", data) + assert service_name == retval["name"] + return retval + + return _add_service + +@pytest.fixture +def add_notification_instance(): + def _add_notification_instance(fledge_url, delivery_plugin, delivery_branch , rule_config={}, delivery_config={}, + rule_plugin="Threshold", rule_branch=None, rule_plugin_discovery_name=None, + delivery_plugin_discovery_name=None, installation_type='make', notification_type="one shot", + notification_instance_name="noti@123", retrigger_time=30, enabled=True): + """ + Fixture to add Service instance and start the instance by default + fledge_url: IP address or domain to access fledge + delivery_plugin: Notify or Delivery plugin to be installed + delivery_branch: Branch of Notify or Delivery plugin to be installed + rule_config: Configuration of Rule plugin + delivery_config: Configuration of Delivery plugin + rule_plugin: Rule plugin to be installed, by default Threshold and DataAvailability plugin is installed + rule_branch: Branch of Rule plugin to be installed + rule_plugin_discovery_name: Name to identify the Rule Plugin after installation + delivery_plugin_discovery_name: Name to identify the Delivery Plugin after installation + installation_type: Type of installation for plugins i.e. make or package + notification_type: Type of notification to be triggered i.e. one_shot, retriggered, toggle + notification_instance_name: Name that will be given to notification instance to be created + retrigger_time: Interval between retriggered notifications + enabled: Flag to enable or disable notification instance + """ + PROJECT_ROOT = Path(__file__).parent.parent.parent.parent + + if rule_plugin_discovery_name is None: + rule_plugin_discovery_name = rule_plugin + + if delivery_plugin_discovery_name is None: + delivery_plugin_discovery_name = delivery_plugin + + def clone_make_install(plugin_branch, plugin_type, plugin): + try: + subprocess.run(["{}/tests/system/python/scripts/install_c_plugin {} {} {}".format( + PROJECT_ROOT, plugin_branch, plugin_type, plugin)], shell=True, check=True) + except subprocess.CalledProcessError: + assert False, "{} plugin installation failed".format(plugin) + + if installation_type == 'make': + # Install Rule Plugin if it is not Threshold or DataAvailability + if rule_plugin not in ("Threshold","DataAvailability"): + clone_make_install(rule_branch, "rule", rule_plugin) + + clone_make_install(delivery_branch, "notify", delivery_plugin) + + elif installation_type == 'package': + try: + if rule_plugin not in ["Threshold", "DataAvailability"]: + subprocess.run(["sudo {} install -y fledge-rule-{}".format(pytest.PKG_MGR, rule_plugin)], shell=True, + check=True) + + except subprocess.CalledProcessError: + assert False, "Package installation of {} failed!".format(rule_plugin) + + try : + subprocess.run(["sudo {} install -y fledge-notify-{}".format(pytest.PKG_MGR, delivery_plugin)], shell=True, + check=True) + + except subprocess.CalledProcessError: + assert False, "Package installation of {} failed!".format(delivery_plugin) + else: + return("Skipped {} and {} plugin installation. Installation mechanism is set to {}.".format(rule_plugin, delivery_plugin, + installation_type)) + + data = { + "name": notification_instance_name, + "description": "{} notification instance".format(notification_instance_name), + "rule_config": rule_config, + "rule": rule_plugin_discovery_name, + "delivery_config": delivery_config, + "channel": delivery_plugin_discovery_name, + "notification_type": notification_type, + "enabled": enabled, + "retrigger_time": "{}".format(retrigger_time), + } + + retval = utils.post_request(fledge_url, "/fledge/notification", data) + assert "Notification {} created successfully".format(notification_instance_name) == retval["result"] + return retval + + return _add_notification_instance @pytest.fixture def start_north_pi_v2(): diff --git a/tests/system/python/packages/test_rule_data_availability.py b/tests/system/python/packages/test_rule_data_availability.py index be58e43bec..38faa3b97a 100644 --- a/tests/system/python/packages/test_rule_data_availability.py +++ b/tests/system/python/packages/test_rule_data_availability.py @@ -89,39 +89,23 @@ def start_north(fledge_url, enabled=True): utils.post_request(fledge_url, post_url, data) @pytest.fixture -def add_notification_service(fledge_url, wait_time, enabled="true"): - try: - subprocess.run(["sudo {} install -y fledge-service-notification fledge-notify-asset".format(pytest.PKG_MGR)], shell=True, check=True) - except subprocess.CalledProcessError: - assert False, "notification service installation failed" - - # Enable service - data = {"name": NOTIF_SERVICE_NAME, "type": "notification", "enabled": enabled} - print (data) - utils.post_request(fledge_url, "/fledge/service", data) - +def start_notification(fledge_url, add_service, add_notification_instance,wait_time, retries): + + # Install and Add Notification Service + add_service(fledge_url, "notification", None, retries, installation_type='package', service_name=NOTIF_SERVICE_NAME) + # Wait and verify service created or not time.sleep(wait_time) verify_service_added(fledge_url, NOTIF_SERVICE_NAME) - -@pytest.fixture -def add_notification_instance(fledge_url, enabled=True): - payload = { - "name": "test #1", - "description": "test notification instance", - "rule": "DataAvailability", - "rule_config": { - "auditCode": "CONAD,SCHAD" - }, - "channel": "asset", - "delivery_config": {"enable": "true"}, - "notification_type": "retriggered", - "retrigger_time": "5", - "enabled": enabled - } - post_url = "/fledge/notification" - utils.post_request(fledge_url, post_url, payload) + # Add Notification Instance + rule_config = {"auditCode": "CONAD,SCHAD"} + delivery_config = {"enable": "true"} + add_notification_instance(fledge_url, "asset", None, rule_config=rule_config, delivery_config=delivery_config, + rule_plugin="DataAvailability", installation_type='package', notification_type="retriggered", + notification_instance_name="test #1", retrigger_time=5) + + # Verify Notification Instance created or not notification_url = "/fledge/notification" resp = utils.get_request(fledge_url, notification_url) assert "test #1" in [s["name"] for s in resp["notifications"]] @@ -165,14 +149,13 @@ def verify_eds_data(): return r class TestDataAvailabilityAuditBasedNotificationRuleOnIngress: - def test_data_availability_multiple_audit(self, clean_setup_fledge_packages, reset_fledge, add_notification_service, add_notification_instance, start_south, fledge_url, - skip_verify_north_interface, wait_time, retries): + def test_data_availability_multiple_audit(self, clean_setup_fledge_packages, reset_fledge, start_notification, + start_south, fledge_url, skip_verify_north_interface, wait_time, retries): """ Test NTFSN triggered or not with CONAD, SCHAD. clean_setup_fledge_packages: Fixture to remove and install latest fledge packages reset_fledge: Fixture to reset fledge start_south: Fixtures to add and start south services - add_notification_service: Fixture to add notification service - add_notification_instance: Fixture to add notification instance + start_notification: Fixture to add and start notification service with rule and delivery plugins Assertions: on endpoint GET /fledge/audit on endpoint GET /fledge/ping @@ -258,8 +241,8 @@ def test_data_availability_asset(self, fledge_url, add_south, skip_verify_north_ assert len(resp2['audit']) > len(resp1['audit']), "ERROR: NTFSN not triggered properly with asset code" class TestDataAvailabilityBasedNotificationRuleOnEgress: - def test_data_availability_north(self, check_eds_installed, reset_fledge, add_notification_service, add_notification_instance, - reset_eds, start_north, fledge_url, wait_time, skip_verify_north_interface, add_south, retries): + def test_data_availability_north(self, check_eds_installed, reset_fledge, start_notification, reset_eds, + start_north, fledge_url, wait_time, skip_verify_north_interface, add_south, retries): """ Test NTFSN triggered or not with configuration change in north EDS plugin. start_north: Fixtures to add and start south services Assertions: diff --git a/tests/system/python/packages/test_statistics_history_notification_rule.py b/tests/system/python/packages/test_statistics_history_notification_rule.py index f7eaa4bd07..83619eb3d2 100644 --- a/tests/system/python/packages/test_statistics_history_notification_rule.py +++ b/tests/system/python/packages/test_statistics_history_notification_rule.py @@ -71,41 +71,26 @@ def start_north(start_north_omf_as_a_service, fledge_url, yield start_north @pytest.fixture -def add_notification_service(fledge_url, wait_time, enabled="true"): - try: - subprocess.run(["sudo {} install -y fledge-service-notification fledge-notify-asset".format(pytest.PKG_MGR)], shell=True, check=True) - except subprocess.CalledProcessError: - assert False, "notification service installation failed" - - # Enable service - data = {"name": NOTIF_SERVICE_NAME, "type": "notification", "enabled": enabled} - print (data) - utils.post_request(fledge_url, "/fledge/service", data) - +def start_notification(fledge_url, add_service, add_notification_instance,wait_time, retries): + + # Install and Add Notification Service + add_service(fledge_url, "notification", None, retries, installation_type='package', service_name=NOTIF_SERVICE_NAME) + # Wait and verify service created or not time.sleep(wait_time) verify_service_added(fledge_url, NOTIF_SERVICE_NAME) - -@pytest.fixture -def add_notification_instance(fledge_url, enabled=True): - payload = { - "name": "test #1", - "description": "test notification instance", - "rule": "Threshold", - "rule_config": { + + # Add Notification Instance + rule_config = { "source": "Statistics History", "asset": "READINGS", "trigger_value": "10.0", - }, - "channel": "asset", - "delivery_config": {"enable": "true"}, - "notification_type": "retriggered", - "retrigger_time": "30", - "enabled": enabled, - } - post_url = "/fledge/notification" - utils.post_request(fledge_url, post_url, payload) + } + delivery_config = {"enable": "true"} + add_notification_instance(fledge_url, "asset", None, rule_config=rule_config, delivery_config=delivery_config, installation_type='package', + notification_type="retriggered", notification_instance_name="test #1", retrigger_time=30) + # Verify Notification Instance created or not notification_url = "/fledge/notification" resp = utils.get_request(fledge_url, notification_url) assert "test #1" in [s["name"] for s in resp["notifications"]] @@ -159,13 +144,13 @@ def _verify_egress(read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_d class TestStatisticsHistoryBasedNotificationRuleOnIngress: - def test_stats_readings_south(self, clean_setup_fledge_packages, reset_fledge, start_south, add_notification_service, add_notification_instance, fledge_url, + def test_stats_readings_south(self, clean_setup_fledge_packages, reset_fledge, start_south, start_notification, fledge_url, skip_verify_north_interface, wait_time, retries): """ Test NTFSN triggered or not with source as statistics history and name as READINGS in threshold rule. clean_setup_fledge_packages: Fixture to remove and install latest fledge packages reset_fledge: Fixture to reset fledge start_south: Fixtures to add and start south services - add_notification_service: Fixture to add notification service with rule and delivery plugins + start_notification: Fixture to add and start notification service with rule and delivery plugins Assertions: on endpoint GET /fledge/audit on endpoint GET /fledge/ping @@ -256,13 +241,12 @@ def test_stats_south_asset(self, fledge_url, wait_time, skip_verify_north_interf class TestStatisticsHistoryBasedNotificationRuleOnEgress: - def test_stats_readings_north(self, start_north, fledge_url, - wait_time, skip_verify_north_interface, retries, read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db): + def test_stats_readings_north(self, start_north, fledge_url, wait_time, skip_verify_north_interface, retries, + read_data_from_pi_web_api, pi_host, pi_admin, pi_passwd, pi_db): """ Test NTFSN triggered or not with source as statistics history and name as READINGS in threshold rule. clean_setup_fledge_packages: Fixture to remove and install latest fledge packages reset_fledge: Fixture to reset fledge start_south_north: Fixtures to add and start south and north services - add_notification_service: Fixture to add notification service with rule and delivery plugins Assertions: on endpoint GET /fledge/audit on endpoint GET /fledge/ping diff --git a/tests/system/python/scripts/install_c_plugin b/tests/system/python/scripts/install_c_plugin index 7cf7064f3a..62a0b3907c 100755 --- a/tests/system/python/scripts/install_c_plugin +++ b/tests/system/python/scripts/install_c_plugin @@ -22,11 +22,16 @@ PLUGIN_NAME=$3 os_name=$(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g') os_version=$(grep -o '^VERSION_ID=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g') REPO_NAME=fledge-${PLUGIN_TYPE}-${PLUGIN_NAME,,} -if [[ "${PLUGIN_TYPE}" = "rule" ]]; then rm -rf /tmp/fledge-service-notification; fi +if [[ "${PLUGIN_TYPE}" = "rule" || "${PLUGIN_TYPE}" == "notify" ]]; then rm -rf /tmp/fledge-service-notification; fi clean () { rm -rf /tmp/${REPO_NAME} + if [[ "${PLUGIN_TYPE}" = "rule" ]]; then + rm -rf ${FLEDGE_ROOT}/plugins/notificationRule/${PLUGIN_NAME} + elif [[ "${PLUGIN_TYPE}" == "notify" ]]; then + rm -rf ${FLEDGE_ROOT}/plugins/notificationDelivery/${PLUGIN_NAME} + fi rm -rf ${FLEDGE_ROOT}/plugins/${PLUGIN_TYPE}/${PLUGIN_NAME} } @@ -40,8 +45,9 @@ install_requirement (){ } install_binary_file () { - if [[ "${PLUGIN_TYPE}" = "rule" ]] + if [[ "${PLUGIN_TYPE}" = "rule" || "${PLUGIN_TYPE}" == "notify" ]] then + # fledge-service-notification repo is required to build notificationRule Plugins service_repo_name='fledge-service-notification' git clone -b ${BRANCH_NAME} --single-branch https://github.com/fledge-iot/${service_repo_name}.git /tmp/${service_repo_name} From 59efcc11324891ef6919f0bae67739dba768c291 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Thu, 18 May 2023 11:46:32 +0530 Subject: [PATCH 296/499] FOGL-7687 Add fixture to install & add external services in conftest.py (#1070) * Refactored code Signed-off-by: Mohit Singh Tomar * Fixed plugin removal code Signed-off-by: Mohit Singh Tomar --------- Signed-off-by: Mohit Singh Tomar --- tests/system/python/scripts/install_c_plugin | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/system/python/scripts/install_c_plugin b/tests/system/python/scripts/install_c_plugin index 62a0b3907c..4ec8c0689a 100755 --- a/tests/system/python/scripts/install_c_plugin +++ b/tests/system/python/scripts/install_c_plugin @@ -27,11 +27,7 @@ if [[ "${PLUGIN_TYPE}" = "rule" || "${PLUGIN_TYPE}" == "notify" ]]; then rm -rf clean () { rm -rf /tmp/${REPO_NAME} - if [[ "${PLUGIN_TYPE}" = "rule" ]]; then - rm -rf ${FLEDGE_ROOT}/plugins/notificationRule/${PLUGIN_NAME} - elif [[ "${PLUGIN_TYPE}" == "notify" ]]; then - rm -rf ${FLEDGE_ROOT}/plugins/notificationDelivery/${PLUGIN_NAME} - fi + if [[ "${PLUGIN_TYPE}" = "rule" ]]; then rm -rf ${FLEDGE_ROOT}/plugins/notificationRule/${PLUGIN_NAME} ; elif [[ "${PLUGIN_TYPE}" == "notify" ]]; then rm -rf ${FLEDGE_ROOT}/plugins/notificationDelivery/${PLUGIN_NAME} ; fi rm -rf ${FLEDGE_ROOT}/plugins/${PLUGIN_TYPE}/${PLUGIN_NAME} } From 9e15bcd42088825691954804feb94c4999673a31 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 25 May 2023 16:46:31 +0530 Subject: [PATCH 297/499] formuala changed to get statistical rate in API as per comments in FOGL-7764 Signed-off-by: ashish-jabble --- python/fledge/services/core/api/statistics.py | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/python/fledge/services/core/api/statistics.py b/python/fledge/services/core/api/statistics.py index 142cf90026..a59b408e01 100644 --- a/python/fledge/services/core/api/statistics.py +++ b/python/fledge/services/core/api/statistics.py @@ -9,6 +9,7 @@ from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect from fledge.services.core.scheduler.scheduler import Scheduler +from fledge.common.logger import FLCoreLogger __author__ = "Amarendra K. Sinha, Ashish Jabble" __copyright__ = "Copyright (c) 2017 OSIsoft, LLC" @@ -23,6 +24,8 @@ ------------------------------------------------------------------------------ """ +_logger = FLCoreLogger().get_logger(__name__) + ################################# # Statistics @@ -152,20 +155,19 @@ async def get_statistics_history(request): async def get_statistics_rate(request: web.Request) -> web.Response: - """ + """To retrieve the statistics rates and will be calculated by formula: + (sum(value) / ((60 * period) / stats_collector_interval)) + For example: + If stats_collector_interval set to 15 seconds then + a) For a 1 minute period should take 4 statistics history values, sum those and then divide by 4 + b) For a 5 minute period should take 20 statistics history values, sum those and then divide by 20 Args: request: Returns: - A JSON document with the rates for each of the statistics + A JSON document with the rates for each of the statistics :Example: - curl -X GET http://localhost:8081/fledge/statistics/rate?periods=1,5,15&statistics=SINUSOID,FASTSINUSOID,READINGS - - Implementation: - Calculation via: (sum(value) / count(value)) * 60 / () - Queries for above example: - select key, 4 * (sum(value) / count(value)) from statistics_history where history_ts >= datetime('now', '-1 Minute') and key in ("SINUSOID", "FASTSINUSOID", "READINGS" ) group by key; - select key, 4 * (sum(value) / count(value)) from statistics_history where history_ts >= datetime('now', '-5 Minute') and key in ("SINUSOID", "FASTSINUSOID", "READINGS" ) group by key; - select key, 4 * (sum(value) / count(value)) from statistics_history where history_ts >= datetime('now', '-15 Minute') and key in ("SINUSOID", "FASTSINUSOID", "READINGS" ) group by key; + curl -sX GET "http://localhost:8081/fledge/statistics/rate?periods=5&statistics=READINGS" + curl -sX GET "http://localhost:8081/fledge/statistics/rate?periods=1,5,15&statistics=SINUSOID,FASTSINUSOID" """ params = request.query if 'periods' not in params: @@ -202,24 +204,22 @@ async def get_statistics_rate(request: web.Request) -> web.Response: seconds=interval_dt.second).total_seconds() else: raise web.HTTPNotFound(reason="No stats collector schedule found") - ts = datetime.datetime.now().timestamp() resp = [] for x, y in [(x, y) for x in period_split_list for y in stat_split_list]: - time_diff = ts - int(x) - # TODO: FOGL-4102 - # For example: - # time_diff = 1590066814.037321 - # ERROR: PostgreSQL storage plugin raising error: ERROR: invalid input syntax for type timestamp with time zone: "1590066814.037321" - # "where": {"column": "history_ts", "condition": ">=", "value": "1590066814.037321"} - Payload works with sqlite engine BUT not with postgres - # To overcome above problem on postgres - I have used "dt = 2020-05-21 13:13:34" - but I see some deviations in results for both engines when we use datetime format - _payload = PayloadBuilder().SELECT("key").AGGREGATE(["sum", "value"]).AGGREGATE(["count", "value"]).WHERE( - ['history_ts', '>=', str(time_diff)]).AND_WHERE(['key', '=', y]).chain_payload() + time_diff = datetime.datetime.utcnow().astimezone() - datetime.timedelta(minutes=int(x)) + """FIXME: FOGL-4102 once resolved + ERROR: PostgreSQL storage plugin raising error: ERROR: invalid input syntax for type timestamp with + time zone: "1590066814.037321" + "where": {"column": "history_ts", "condition": ">=", "value": "1590066814.037321"} + Therefore, Payload works Only with sqlite engine BUT not with PostgreSQL""" + + _payload = PayloadBuilder().SELECT("key").AGGREGATE(["sum", "value"]).WHERE( + ['history_ts', '>=', str(time_diff.timestamp())]).AND_WHERE(['key', '=', y]).chain_payload() stats_rate_payload = PayloadBuilder(_payload).GROUP_BY("key").payload() result = await storage_client.query_tbl_with_payload("statistics_history", stats_rate_payload) temp_dict = {y: {x: 0}} if result['rows']: - calculated_formula_str = (int(result['rows'][0]['sum_value']) / int(result['rows'][0]['count_value']) - ) * (60 / int(interval_in_secs)) + calculated_formula_str = (int(result['rows'][0]['sum_value']) / ((60 * int(x)) / int(interval_in_secs))) temp_dict = {y: {x: calculated_formula_str}} resp.append(temp_dict) rate_dict = {} From 47e80e8d40aa10d9fcf1ae71153fd5434208fe4a Mon Sep 17 00:00:00 2001 From: nandan Date: Thu, 25 May 2023 17:18:09 +0530 Subject: [PATCH 298/499] FOGL-7748: Added support for json Array type in Reading class --- C/common/reading.cpp | 28 +++++++ tests/unit/C/common/test_reading_array.cpp | 91 ++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 tests/unit/C/common/test_reading_array.cpp diff --git a/C/common/reading.cpp b/C/common/reading.cpp index 33db1a16d9..fd02bc41ad 100755 --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -119,6 +119,20 @@ Reading::Reading(const string& asset, const string& datapoints) : m_asset(asset) DatapointValue dpv(values, true); m_values.push_back(new Datapoint(name, dpv)); } + else if (itr->value.IsArray()) + { + vector arr; + for (auto& v : itr->value.GetArray()) + { + if (v.IsNumber()) + arr.push_back(v.GetDouble()); + else + throw runtime_error("Only numeric lists are currently supported in datapoints"); + } + + DatapointValue dpv(arr); + m_values.push_back(new Datapoint(name, dpv)); + } } // Store seconds and microseconds gettimeofday(&m_timestamp, NULL); @@ -590,6 +604,20 @@ vector *values = new vector; DatapointValue dpv(nestedValues, true); values->push_back(new Datapoint(name, dpv)); } + else if (itr->value.IsArray()) + { + vector arr; + for (auto& v : itr->value.GetArray()) + { + if (v.IsNumber()) + arr.push_back(v.GetDouble()); + else + throw runtime_error("Only numeric lists are currently supported in datapoints"); + } + + DatapointValue dpv(arr); + values->push_back(new Datapoint(name, dpv)); + } } return values; } diff --git a/tests/unit/C/common/test_reading_array.cpp b/tests/unit/C/common/test_reading_array.cpp new file mode 100644 index 0000000000..e52ef16371 --- /dev/null +++ b/tests/unit/C/common/test_reading_array.cpp @@ -0,0 +1,91 @@ +/* + * unit tests - FOGL-7748 : Support array data in reading json + * + * Copyright (c) 2023 Dianomic Systems, Inc. + * + * Released under the Apache 2.0 Licence + * + * Author: Devki Nandan Ghildiyal + */ + +#include +#include +#include +#include +#include +#include + +using namespace std; + + +const char *ReadingJSON = R"( + { + "floor1":30.25, "floor2":34.28, "floor3":[38.25,60.89,40.28] + } +)"; + +const char *unsupportedReadingJSON = R"( + { + "floor1":[38,"error",40] + } +)"; + +const char *NestedReadingJSON = R"( +{ + "pressure": {"floor1":30, "floor2":34, "floor3":[38,60,40] } +} +)"; + +TEST(TESTReading, TestUnspportedReadingForListType ) +{ + try + { + vector readings; + readings.push_back(new Reading("test", unsupportedReadingJSON)); + vector&dp = readings[0]->getReadingData(); + + ASSERT_EQ(readings[0]->getDatapointCount(),1); + ASSERT_EQ(readings[0]->getAssetName(),"test"); + } + catch(exception& ex) + { + string msg(ex.what()); + ASSERT_EQ(msg,"Only numeric lists are currently supported in datapoints"); + } + + +} + +TEST(TESTReading, TestReadingForListType ) +{ + vector readings; + readings.push_back(new Reading("test", ReadingJSON)); + vector&dp = readings[0]->getReadingData(); + + ASSERT_EQ(readings[0]->getDatapointCount(),3); + ASSERT_EQ(readings[0]->getAssetName(),"test"); + + ASSERT_EQ(dp[0]->getName(),"floor1"); + ASSERT_EQ(dp[0]->getData().toDouble(),30.25); + + ASSERT_EQ(dp[1]->getName(),"floor2"); + ASSERT_EQ(dp[1]->getData().toDouble(),34.28); + + ASSERT_EQ(dp[2]->getName(),"floor3"); + ASSERT_EQ(dp[2]->getData().toString(),"[38.25, 60.89, 40.28]"); +} + +TEST(TESTReading, TestReadingForNestedListType ) +{ + vector readings; + readings.push_back(new Reading("test", NestedReadingJSON)); + vector&dp = readings[0]->getReadingData(); + + ASSERT_EQ(readings[0]->getDatapointCount(),1); + ASSERT_EQ(readings[0]->getAssetName(),"test"); + + ASSERT_EQ(dp[0]->getName(),"pressure"); + ASSERT_EQ(dp[0]->getData().toString(),"{\"floor1\":30, \"floor2\":34, \"floor3\":[38, 60, 40]}"); + +} + From 274ddfed28e4849e206ab1c157f3fdaeb99f724d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 25 May 2023 17:27:54 +0530 Subject: [PATCH 299/499] unit tests updated Signed-off-by: ashish-jabble --- .../services/core/api/test_statistics_api.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/test_statistics_api.py b/tests/unit/python/fledge/services/core/api/test_statistics_api.py index 6b4bd6f338..a5f428cb5d 100644 --- a/tests/unit/python/fledge/services/core/api/test_statistics_api.py +++ b/tests/unit/python/fledge/services/core/api/test_statistics_api.py @@ -492,24 +492,21 @@ async def test_bad_get_statistics_rate(self, client, params, msg): assert 400 == resp.status assert msg == resp.reason - async def test_get_statistics_rate(self, client, params='?periods=1,5&statistics=readings'): - output = {'rates': {'readings': {'1': 120.52585669781932, '5': 120.52585669781932}}} + async def test_get_statistics_rate(self, client, params='?periods=1,5&statistics=READINGS'): + output = {'rates': {'READINGS': {'1': 24180.5, '5': 4836.1}}} p1 = {'where': {'value': 'stats collector', 'condition': '=', 'column': 'process_name'}, 'return': ['schedule_interval']} - p2 = {"return": ["key"], "aggregate": [{"operation": "sum", "column": "value"}, - {"operation": "count", "column": "value"}], - "where": {"column": "history_ts", "condition": ">=", "value": "1590126369.123255", + p2 = {"return": ["key"], "aggregate": [{"operation": "sum", "column": "value"}], + "where": {"column": "history_ts", "condition": ">=", "value": "1684995048.726104", "and": {"column": "key", "condition": "=", "value": "READINGS"}}, "group": "key"} - p3 = {"return": ["key"], "aggregate": [{"operation": "sum", "column": "value"}, - {"operation": "count", "column": "value"}], - "where": {"column": "history_ts", "condition": ">=", "value": "1590126369.123255", + p3 = {"return": ["key"], "aggregate": [{"operation": "sum", "column": "value"}], + "where": {"column": "history_ts", "condition": ">=", "value": "1684994808.726297", "and": {"column": "key", "condition": "=", "value": "READINGS"}}, "group": "key"} @asyncio.coroutine def q_result(*args): table = args[0] payload = args[1] - if table == 'schedules': assert p1 == json.loads(payload) return {"rows": [{"schedule_interval": "00:00:15"}]} From 1d2e5a65c430f89f77c242d15f24af6e592c6d40 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 26 May 2023 16:17:05 +0530 Subject: [PATCH 300/499] doc output corrected Signed-off-by: ashish-jabble --- docs/rest_api_guide/03_RESTstatistics.rst | 32 +++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/rest_api_guide/03_RESTstatistics.rst b/docs/rest_api_guide/03_RESTstatistics.rst index c8117a92bb..6a62bf9e30 100644 --- a/docs/rest_api_guide/03_RESTstatistics.rst +++ b/docs/rest_api_guide/03_RESTstatistics.rst @@ -145,33 +145,33 @@ GET statistics/rate **Request Parameters** - - **statistics** - a comma separated list of statistics values to return + - **statistics** - a comma separated list of statistics keys. - - **periods** - a comma separated list of time periods in minutes. The corresponding rate that will be returned for a given value X is the counts per minute over the previous X minutes. + - **periods** - a comma separated list of time periods in minutes. + +The corresponding rate that will be returned for a given value X is the counts per minute over the previous X minutes. **Example** .. code-block:: console $ curl -sX GET http://localhost:8081/fledge/statistics/rate?statistics=READINGS,Readings%20Sent\&periods=1,5,15,30,60 - { + + { "rates": { "READINGS": { - "1": 12.938816958618938, - "5": 12.938816958618938, - "15": 12.938816958618938, - "30": 12.938816958618938, - "60": 12.938816958618938 + "1": 2561.0, + "5": 512.2, + "15": 170.73333333333332, + "30": 85.36666666666666, + "60": 42.68333333333333 }, "Readings Sent": { - "1": 0, - "5": 0, - "15": 0, - "30": 0, - "60": 0 + "1": 2225.0, + "5": 445.0, + "15": 148.33333333333334, + "30": 74.16666666666667, + "60": 37.083333333333336 } } } - $ - - From 40459a3556cec99849c8bc3dd78231de3adeff94 Mon Sep 17 00:00:00 2001 From: nandan Date: Mon, 29 May 2023 15:40:42 +0530 Subject: [PATCH 301/499] FOGL-7748: Replace push_back with emplace_back Signed-off-by: nandan --- C/common/reading.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/C/common/reading.cpp b/C/common/reading.cpp index fd02bc41ad..906378c0e1 100755 --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -125,13 +125,13 @@ Reading::Reading(const string& asset, const string& datapoints) : m_asset(asset) for (auto& v : itr->value.GetArray()) { if (v.IsNumber()) - arr.push_back(v.GetDouble()); + arr.emplace_back(v.GetDouble()); else throw runtime_error("Only numeric lists are currently supported in datapoints"); } DatapointValue dpv(arr); - m_values.push_back(new Datapoint(name, dpv)); + m_values.emplace_back(new Datapoint(name, dpv)); } } // Store seconds and microseconds @@ -610,13 +610,13 @@ vector *values = new vector; for (auto& v : itr->value.GetArray()) { if (v.IsNumber()) - arr.push_back(v.GetDouble()); + arr.emplace_back(v.GetDouble()); else throw runtime_error("Only numeric lists are currently supported in datapoints"); } DatapointValue dpv(arr); - values->push_back(new Datapoint(name, dpv)); + values->emplace_back(new Datapoint(name, dpv)); } } return values; From 615af8ce30bcc6f62554f819300b12d3af70e4db Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 31 May 2023 11:27:02 +0530 Subject: [PATCH 302/499] HTTP status code correction on deleting an ACL when associated with an entity; also logger level changed Signed-off-by: ashish-jabble --- .../services/core/api/control_service/acl_management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index 7e14014581..654981920f 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -243,8 +243,8 @@ async def delete_acl(request: web.Request) -> web.Response: if services or scripts: message = "{} is associated with an entity. So cannot delete." \ " Make sure to remove all the usages of this ACL.".format(name) - _logger.info(message) - return web.json_response({"message": message}) + _logger.warning(message) + return web.HTTPConflict(reason=message, body=json.dumps({"message": message})) delete_result = await storage.delete_from_tbl("control_acl", payload) if 'response' in delete_result: From 11a4b719e96377c615d30495fe8ef19a3f6e3868 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 6 Jun 2023 12:43:37 +0530 Subject: [PATCH 303/499] deprecated query param filter added in GET track API call Signed-off-by: ashish-jabble --- python/fledge/services/core/api/asset_tracker.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/fledge/services/core/api/asset_tracker.py b/python/fledge/services/core/api/asset_tracker.py index 628d18c8b2..af807a3a1f 100644 --- a/python/fledge/services/core/api/asset_tracker.py +++ b/python/fledge/services/core/api/asset_tracker.py @@ -43,6 +43,7 @@ async def get_asset_tracker_events(request: web.Request) -> web.Response: curl -sX GET http://localhost:8081/fledge/track?asset=XXX curl -sX GET http://localhost:8081/fledge/track?event=XXX curl -sX GET http://localhost:8081/fledge/track?service=XXX + curl -sX GET http://localhost:8081/fledge/track?deprecated=true curl -sX GET http://localhost:8081/fledge/track?event=XXX&asset=XXX&service=XXX """ payload = PayloadBuilder().SELECT("asset", "event", "service", "fledge", "plugin", "ts", "deprecated_ts", "data") \ @@ -58,6 +59,10 @@ async def get_asset_tracker_events(request: web.Request) -> web.Response: if 'service' in request.query and request.query['service'] != '': service = urllib.parse.unquote(request.query['service']) payload.AND_WHERE(['service', '=', service]) + if 'deprecated' in request.query and request.query['deprecated'] != '': + deprecated = request.query['deprecated'].strip().lower() + if deprecated == "true": + payload.AND_WHERE(['deprecated_ts', "notnull"]) storage_client = connect.get_storage_async() payload = PayloadBuilder(payload.chain_payload()) From f212b79d156b0de69ed9429bcbb7c5ebd12a60ee Mon Sep 17 00:00:00 2001 From: nandan Date: Tue, 6 Jun 2023 18:53:48 +0530 Subject: [PATCH 304/499] FOGL-7697: Updated regular expression for READING_INTEREST Signed-off-by: nandan --- C/services/storage/include/storage_api.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C/services/storage/include/storage_api.h b/C/services/storage/include/storage_api.h index e117795367..1b2d152ba4 100644 --- a/C/services/storage/include/storage_api.h +++ b/C/services/storage/include/storage_api.h @@ -27,7 +27,7 @@ using HttpServer = SimpleWeb::Server; #define READING_ACCESS "^/storage/reading$" #define READING_QUERY "^/storage/reading/query" #define READING_PURGE "^/storage/reading/purge" -#define READING_INTEREST "^/storage/reading/interest/([A-Za-z\\*][a-zA-Z0-9_%\\.\\-]*)$" +#define READING_INTEREST "^/storage/reading/interest/([A-Za-z0-9\\*][a-zA-Z0-9_%\\.\\-]*)$" #define TABLE_INTEREST "^/storage/table/interest/([A-Za-z\\*][a-zA-Z0-9_%\\.\\-]*)$" #define GET_TABLE_SNAPSHOTS "^/storage/table/([A-Za-z][a-zA-Z_0-9_]*)/snapshot$" From a4cbcda8cbefe7bc66f7bc002f2b81da3a93bb3d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 7 Jun 2023 15:52:02 +0530 Subject: [PATCH 305/499] handling for nested object type T_DP_DICT when given a Python value convert it into a DatapointValue Signed-off-by: ashish-jabble --- C/common/pythonreading.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/C/common/pythonreading.cpp b/C/common/pythonreading.cpp index 7df823472f..a45fac2b0e 100755 --- a/C/common/pythonreading.cpp +++ b/C/common/pythonreading.cpp @@ -231,7 +231,14 @@ DatapointValue *PythonReading::getDatapointValue(PyObject *value) DatapointValue *dpv = getDatapointValue(dValue); if (dpv) { - values->emplace_back(new Datapoint(string(PyBytes_AsString(dKey)), *dpv)); + if (PyUnicode_Check(dKey)) + { + values->emplace_back(new Datapoint(string(PyUnicode_AsUTF8(dKey)), *dpv)); + } + else + { + values->emplace_back(new Datapoint(string(PyBytes_AsString(dKey)), *dpv)); + } // Remove temp objects delete dpv; } From 7e8d9c1377ad3039f7dec0c2513c58e09c434487 Mon Sep 17 00:00:00 2001 From: pintomax Date: Wed, 7 Jun 2023 16:14:23 +0200 Subject: [PATCH 306/499] FOGL-7835: reduce asset tracking calls in ingest class (#1081) FOGL-7835: Initial checkin Time based call of unDeprecateAssetTrackingRecord Co-authored-by: Mark Riddoch --- C/common/asset_tracking.cpp | 57 ++++++++++++++++++ C/common/include/asset_tracking.h | 16 ++++++ C/common/include/management_client.h | 2 + C/common/management_client.cpp | 86 ++++++++++++++++++++++++++++ C/services/south/include/ingest.h | 7 ++- C/services/south/ingest.cpp | 45 ++++++++++++++- 6 files changed, 210 insertions(+), 3 deletions(-) diff --git a/C/common/asset_tracking.cpp b/C/common/asset_tracking.cpp index 498f3e9eda..7cbe35c41c 100644 --- a/C/common/asset_tracking.cpp +++ b/C/common/asset_tracking.cpp @@ -179,3 +179,60 @@ string AssetTracker::getService(const std::string& event, const std::string& ass throw runtime_error("Fetching service for asset not yet implemented"); } } + +/** + * Constructor for an asset tracking tuple table + */ +AssetTrackingTable::AssetTrackingTable() +{ +} + +/** + * Destructor for asset tracking tuple table + */ +AssetTrackingTable::~AssetTrackingTable() +{ + for (auto t : m_tuples) + { + delete t.second; + } +} + +/** + * Add a tuple to an asset tracking table + * + * @param tuple Pointer to the asset tracking tuple to add + */ +void AssetTrackingTable::add(AssetTrackingTuple *tuple) +{ + auto ret = m_tuples.insert(pair(tuple->getAssetName(), tuple)); + if (ret.second == false) + delete tuple; // Already exists +} + +/** + * Find the named asset tuple and return a pointer to te asset + * + * @param name The name of the asset to lookup + * @return AssetTrackingTupple* The matchign tuple or NULL + */ +AssetTrackingTuple *AssetTrackingTable::find(const string& name) +{ + auto ret = m_tuples.find(name); + if (ret != m_tuples.end()) + return ret->second; + return NULL; +} + +/** + * Remove an asset tracking tuple from the table + */ +void AssetTrackingTable::remove(const string& name) +{ + auto ret = m_tuples.find(name); + if (ret != m_tuples.end()) + { + m_tuples.erase(ret); + delete ret->second; // Free the tuple + } +} diff --git a/C/common/include/asset_tracking.h b/C/common/include/asset_tracking.h index fd27a1f2c5..1374d2d787 100644 --- a/C/common/include/asset_tracking.h +++ b/C/common/include/asset_tracking.h @@ -139,4 +139,20 @@ class AssetTracker { std::unordered_set, AssetTrackingTuplePtrEqual> assetTrackerTuplesCache; }; +/** + * A class to hold a set of asset tracking tuples that allows + * lookup by name. + */ +class AssetTrackingTable { + public: + AssetTrackingTable(); + ~AssetTrackingTable(); + void add(AssetTrackingTuple *tuple); + void remove(const std::string& name); + AssetTrackingTuple *find(const std::string& name); + private: + std::map + m_tuples; +}; + #endif diff --git a/C/common/include/management_client.h b/C/common/include/management_client.h index 86cd530f37..9fc8bf2123 100644 --- a/C/common/include/management_client.h +++ b/C/common/include/management_client.h @@ -30,6 +30,7 @@ using HttpServer = SimpleWeb::Server; using namespace rapidjson; class AssetTrackingTuple; +class AssetTrackingTable; class StorageAssetTrackingTuple; /** @@ -115,6 +116,7 @@ class ManagementClient { const std::string& assetName, const std::string& event); int validateDatapoints(std::string dp1, std::string dp2); + AssetTrackingTable *getDeprecatedAssetTrackingTuples(); private: std::ostringstream m_urlbase; diff --git a/C/common/management_client.cpp b/C/common/management_client.cpp index bb732fb6df..fc4133c12b 100644 --- a/C/common/management_client.cpp +++ b/C/common/management_client.cpp @@ -1496,6 +1496,92 @@ AssetTrackingTuple* ManagementClient::getAssetTrackingTuple(const std::string& s return tuple; } +/** + * Get the asset tracking tuples for all the deprecated assets + * + * @return A vector of pointers to AssetTrackingTuple objects allocated on heap + */ +AssetTrackingTable* ManagementClient::getDeprecatedAssetTrackingTuples() +{ + AssetTrackingTable* table = NULL; + try { + string url = "/fledge/track?deprecated=true"; + + auto res = this->getHttpClient()->request("GET", url.c_str()); + Document doc; + string response = res->content.string(); + doc.Parse(response.c_str()); + if (doc.HasParseError()) + { + bool httpError = (isdigit(response[0]) && + isdigit(response[1]) && + isdigit(response[2]) && + response[3]==':'); + m_logger->error("%s fetch asset tracking tuple: %s\n", + httpError?"HTTP error during":"Failed to parse result of", + response.c_str()); + throw new exception(); + } + else if (doc.HasMember("message")) + { + m_logger->error("Failed to fetch asset tracking tuple: %s.", + doc["message"].GetString()); + throw new exception(); + } + else + { + const rapidjson::Value& trackArray = doc["track"]; + if (trackArray.IsArray()) + { + table = new AssetTrackingTable(); + // Process every row and create the AssetTrackingTuple object + for (auto& rec : trackArray.GetArray()) + { + if (!rec.IsObject()) + { + throw runtime_error("Expected asset tracker tuple to be an object"); + } + + // Note: deprecatedTimestamp NULL value is returned as "" + // otherwise it's a string DATE + bool deprecated = rec.HasMember("deprecatedTimestamp") && + strlen(rec["deprecatedTimestamp"].GetString()); + + // Create a new AssetTrackingTuple object, to be freed by the caller + AssetTrackingTuple *tuple = new AssetTrackingTuple(rec["service"].GetString(), + rec["plugin"].GetString(), + rec["asset"].GetString(), + rec["event"].GetString(), + deprecated); + + m_logger->debug("Adding AssetTracker tuple for service %s: %s:%s:%s, " \ + "deprecated state is %d", + rec["service"].GetString(), + rec["plugin"].GetString(), + rec["asset"].GetString(), + rec["event"].GetString(), + deprecated); + + table->add(tuple); + } + } + else + { + throw runtime_error("Expected array of rows in asset track tuples array"); + } + + return table; + } + } catch (const SimpleWeb::system_error &e) { + m_logger->error("Fetch/parse of deprecated asset tracking tuples failed: %s.", + e.what()); + } catch (...) { + m_logger->error("Unexpected exception when retrieving asset tuples for deprecated assets"); + } + + return table; +} + /** * Return the content of the named ACL by calling the * management API of the Fledge core. diff --git a/C/services/south/include/ingest.h b/C/services/south/include/ingest.h index 97494d6700..956b39bcee 100644 --- a/C/services/south/include/ingest.h +++ b/C/services/south/include/ingest.h @@ -30,7 +30,9 @@ #define INGEST_SUFFIX "-Ingest" // Suffix for per service ingest statistic -#define STATS_UPDATE_FAIL_THRESHOLD 10 // After this many update fails try creatign new stats +#define STATS_UPDATE_FAIL_THRESHOLD 10 // After this many update fails try creating new stats + +#define DEPRECATED_CACHE_AGE 600 // Maximum allowed aged of the deprecated asset cache /** * The ingest class is used to ingest asset readings. @@ -132,6 +134,9 @@ class Ingest : public ServiceHandler { int m_statsUpdateFails; enum { STATS_BOTH, STATS_ASSET, STATS_SERVICE } m_statisticsOption; + AssetTrackingTable *m_deprecated; + time_t m_deprecatedAgeOut; + time_t m_deprecatedAgeOutStorage; }; #endif diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 00eb984182..ad62de5dbc 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -301,6 +301,11 @@ Ingest::Ingest(StorageClient& storage, createServiceStatsDbEntry(); m_filterPipeline = NULL; + + m_deprecated = NULL; + + m_deprecatedAgeOut = 0; + m_deprecatedAgeOutStorage = 0; } /** @@ -357,6 +362,8 @@ Ingest::~Ingest() if (m_filterPipeline) delete m_filterPipeline; } + + delete m_deprecated; } /** @@ -1100,6 +1107,26 @@ void Ingest::unDeprecateAssetTrackingRecord(AssetTrackingTuple* currentTuple, const string& assetName, const string& event) { + time_t now = time(0); + if (m_deprecatedAgeOut < now) + { + delete m_deprecated; + m_deprecated = m_mgtClient->getDeprecatedAssetTrackingTuples(); + m_deprecatedAgeOut = now + DEPRECATED_CACHE_AGE; + } + if (m_deprecated && m_deprecated->find(assetName)) + { + // The asset is deprecated possibly + m_deprecated->remove(assetName); + } + else + { + // The asset is not believed to be deprecated so return. If + // it has been deprecated since we last loaded the cache this + // will leave the asset incorrectly deprecated. This will be + // resolved next time the cache is reloaded + return; + } // Get up-to-date Asset Tracking record AssetTrackingTuple* updatedTuple = m_mgtClient->getAssetTrackingTuple( @@ -1182,12 +1209,26 @@ void Ingest::unDeprecateAssetTrackingRecord(AssetTrackingTuple* currentTuple, * * @param currentTuple Current StorageAssetTracking record for given assetName * @param assetName AssetName to fetch from AssetTracking - * @param event The event type to fetch + * @param datapoints The datapoints comma separated list + * @param count The number of datapoints per asset */ void Ingest::unDeprecateStorageAssetTrackingRecord(StorageAssetTrackingTuple* currentTuple, - const string& assetName, const string& datapoints, const unsigned int& count) + const string& assetName, + const string& datapoints, + const unsigned int& count) { + time_t now = time(0); + if (m_deprecatedAgeOutStorage < now) + { + m_deprecatedAgeOutStorage = now + DEPRECATED_CACHE_AGE; + } + else + { + // Nothing to do right now + return; + } + // Get up-to-date Asset Tracking record StorageAssetTrackingTuple* updatedTuple = From d7929a09f6124de5112016de32982cbe8108bab6 Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 9 Jun 2023 13:33:33 +0530 Subject: [PATCH 307/499] FOGL-7820: initialized m_pollType to POLL_INTERVAL in the constructor of SouthService Signed-off-by: nandan --- C/services/south/south.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 10467a6735..821a219c25 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -239,6 +239,7 @@ SouthService::SouthService(const string& myName, const string& token) : { m_name = myName; m_type = SERVICE_TYPE; + m_pollType = POLL_INTERVAL; logger = new Logger(myName); logger->setMinLevel("warning"); From 41dc3c33993634ed2926f8d751f4eac8bfa53fdb Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 12 Jun 2023 11:57:25 +0530 Subject: [PATCH 308/499] optional mandatory item added in name config item for service category Signed-off-by: ashish-jabble --- python/fledge/services/core/server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index ff1854e795..9faa4d19b3 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -149,7 +149,8 @@ class Server: 'type': 'string', 'default': 'Fledge', 'displayName': 'Name', - 'order': '1' + 'order': '1', + 'mandatory': "true" }, 'description': { 'description': 'Description of this Fledge service', From 01a9db1809e84719ec84fe978be982047ec3236f Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 13 Jun 2023 14:23:56 +0100 Subject: [PATCH 309/499] Make integer retrieval from JSON more robust (#1086) * Make integer retrieval from JSON more robust Signed-off-by: Mark Riddoch * Remove extra tests Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/datapoint.cpp | 14 +++++++++++++- C/common/reading_set.cpp | 37 +++++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/C/common/datapoint.cpp b/C/common/datapoint.cpp index b588a51f48..751337d37f 100644 --- a/C/common/datapoint.cpp +++ b/C/common/datapoint.cpp @@ -396,10 +396,22 @@ std::vector *Datapoint::recursiveJson(const rapidjson::Value& docume DatapointValue d(itr->value.GetDouble()); p->push_back(new Datapoint(itr->name.GetString(), d)); } - else if (itr->value.IsNumber() && !itr->value.IsDouble()) { + else if (itr->value.IsNumber() && itr->value.IsInt()) { DatapointValue d((long)itr->value.GetInt()); p->push_back(new Datapoint(itr->name.GetString(), d)); } + else if (itr->value.IsNumber() && itr->value.IsUint()) { + DatapointValue d((long)itr->value.GetUint()); + p->push_back(new Datapoint(itr->name.GetString(), d)); + } + else if (itr->value.IsNumber() && itr->value.IsInt64()) { + DatapointValue d((long)itr->value.GetInt64()); + p->push_back(new Datapoint(itr->name.GetString(), d)); + } + else if (itr->value.IsNumber() && itr->value.IsUint64()) { + DatapointValue d((long)itr->value.GetUint64()); + p->push_back(new Datapoint(itr->name.GetString(), d)); + } } return p; diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index fc9d39195e..29a01038e0 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -523,23 +523,28 @@ Datapoint *rval = NULL; // Number case (kNumberType): { - if (item.IsInt() || - item.IsUint() || - item.IsInt64() || - item.IsUint64()) + if (item.IsInt()) { - - DatapointValue *value; - if (item.IsInt() || item.IsUint()) - { - value = new DatapointValue((long) item.GetInt()); - } - else - { - value = new DatapointValue((long) item.GetInt64()); - } - rval = new Datapoint(name, *value); - delete value; + DatapointValue value((long)item.GetInt()); + rval = new Datapoint(name, value); + break; + } + else if (item.IsUint()) + { + DatapointValue value((long)item.GetUint()); + rval = new Datapoint(name, value); + break; + } + else if (item.IsInt64()) + { + DatapointValue value(item.GetInt64()); + rval = new Datapoint(name, value); + break; + } + else if (item.IsUint64()) + { + DatapointValue value((long)item.GetUint64()); + rval = new Datapoint(name, value); break; } else if (item.IsDouble()) From 38ae199a7ac0c03fba78965a8eaabb5a73d1dcab Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Wed, 14 Jun 2023 18:05:20 +0530 Subject: [PATCH 310/499] FOGL-7860 Fix Caching endpoint address scheme of core management API used by Package management API (#1084) * Fix Caching endpoint address scheme of core management API for plugin removal API Similar issue was fixed with https://github.com/fledge-iot/fledge/pull/994 * Fix Core Management API address scheme for package update calls Signed-off-by: Praveen Garg --------- Signed-off-by: Praveen Garg --- python/fledge/services/core/api/plugins/remove.py | 8 +++----- python/fledge/services/core/api/plugins/update.py | 5 ++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index d4dc403e51..169a39f432 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -169,8 +169,7 @@ def _uninstall(pkg_name: str, version: str, uid: uuid, storage: connect) -> tupl loop.run_until_complete(storage.update_tbl("packages", payload)) if code == 0: # Clear internal cache - loop.run_until_complete(_put_refresh_cache(Server.is_rest_server_http_enabled, - Server._host, Server.core_management_port)) + loop.run_until_complete(_put_refresh_cache("http", Server._host, Server.core_management_port)) # Audit logger audit = AuditLogger(storage) audit_detail = {'package_name': pkg_name, 'version': version} @@ -364,7 +363,7 @@ async def _check_plugin_usage_in_notification_instances(plugin_name: str) -> lis async def _put_refresh_cache(protocol: str, host: int, port: int) -> None: management_api_url = '{}://{}:{}/fledge/cache'.format(protocol, host, port) headers = {'content-type': 'application/json'} - verify_ssl = False if protocol == 'HTTP' else True + verify_ssl = False connector = aiohttp.TCPConnector(verify_ssl=verify_ssl) async with aiohttp.ClientSession(connector=connector) as session: async with session.put(management_api_url, data=json.dumps({}), headers=headers) as resp: @@ -418,8 +417,7 @@ def purge_plugin(plugin_type: str, plugin_name: str, pkg_name: str, version: str if code == 0: # Clear internal cache - loop.run_until_complete(_put_refresh_cache(Server.is_rest_server_http_enabled, - Server._host, Server.core_management_port)) + loop.run_until_complete(_put_refresh_cache("http", Server._host, Server.core_management_port)) # Audit info audit = AuditLogger(storage) audit_detail = {'package_name': pkg_name, 'version': version} diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index 56ace3db88..078f43292f 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -380,7 +380,10 @@ def _update_repo_sources_and_plugin(pkg_name: str) -> tuple: def do_update(http_enabled: bool, host: str, port: int, storage: connect, _type: str, plugin_name: str, pkg_name: str, uid: str, schedules: list, notifications: list) -> None: _logger.info("{} package update started...".format(pkg_name)) - protocol = "HTTP" if http_enabled else "HTTPS" + + # Protocol is always http:// on core_management_port + protocol = "HTTP" + code, link = _update_repo_sources_and_plugin(pkg_name) # Update record in Packages table From f758d5d05ff4f6820e27fb009dd233da199ab55c Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 15 Jun 2023 10:46:33 +0100 Subject: [PATCH 311/499] Fix cast for ARM compiler (#1088) Signed-off-by: Mark Riddoch --- C/common/reading_set.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 29a01038e0..292f44715e 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -537,7 +537,7 @@ Datapoint *rval = NULL; } else if (item.IsInt64()) { - DatapointValue value(item.GetInt64()); + DatapointValue value((long)item.GetInt64()); rval = new Datapoint(name, value); break; } From 657f5a70b1293f141ab75ebcdd2b2021689aa620 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 15 Jun 2023 11:26:30 +0100 Subject: [PATCH 312/499] Only use the Reading ID in the reading set if it has been set (#1089) Signed-off-by: Mark Riddoch --- C/common/include/reading.h | 1 + C/common/reading.cpp | 8 ++++---- C/common/reading_set.cpp | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/C/common/include/reading.h b/C/common/include/reading.h index 0ce59c5422..e3bfbfe865 100644 --- a/C/common/include/reading.h +++ b/C/common/include/reading.h @@ -54,6 +54,7 @@ class Reading { const std::vector getReadingData() const { return m_values; }; // Return refrerence to Reading datapoints std::vector& getReadingData() { return m_values; }; + bool hasId() const { return m_has_id; }; unsigned long getId() const { return m_id; }; unsigned long getTimestamp() const { return (unsigned long)m_timestamp.tv_sec; }; unsigned long getUserTimestamp() const { return (unsigned long)m_userTimestamp.tv_sec; }; diff --git a/C/common/reading.cpp b/C/common/reading.cpp index 906378c0e1..c2b4a3bcb2 100755 --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -35,7 +35,7 @@ std::vector Reading::m_dateTypes = { * Each actual datavalue that relates to that asset is held within an * instance of a Datapoint class. */ -Reading::Reading(const string& asset, Datapoint *value) : m_asset(asset) +Reading::Reading(const string& asset, Datapoint *value) : m_asset(asset), m_has_id(false) { m_values.push_back(value); // Store seconds and microseconds @@ -51,7 +51,7 @@ Reading::Reading(const string& asset, Datapoint *value) : m_asset(asset) * Each actual datavalue that relates to that asset is held within an * instance of a Datapoint class. */ -Reading::Reading(const string& asset, vector values) : m_asset(asset) +Reading::Reading(const string& asset, vector values) : m_asset(asset), m_has_id(false) { for (auto it = values.cbegin(); it != values.cend(); it++) { @@ -70,7 +70,7 @@ Reading::Reading(const string& asset, vector values) : m_asset(asse * Each actual datavalue that relates to that asset is held within an * instance of a Datapoint class. */ -Reading::Reading(const string& asset, vector values, const string& ts) : m_asset(asset) +Reading::Reading(const string& asset, vector values, const string& ts) : m_asset(asset), m_has_id(false) { for (auto it = values.cbegin(); it != values.cend(); it++) { @@ -84,7 +84,7 @@ Reading::Reading(const string& asset, vector values, const string& /** * Construct a reading with datapoints given as JSON */ -Reading::Reading(const string& asset, const string& datapoints) : m_asset(asset) +Reading::Reading(const string& asset, const string& datapoints) : m_asset(asset), m_has_id(false) { Document d; if (d.Parse(datapoints.c_str()).HasParseError()) diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 292f44715e..b56a5a4c2a 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -54,7 +54,7 @@ ReadingSet::ReadingSet(const vector* readings) : m_last_id(0) m_count = readings->size(); for (auto it = readings->begin(); it != readings->end(); ++it) { - if ((*it)->getId() > m_last_id) + if ((*it)->hasId() && (*it)->getId() > m_last_id) m_last_id = (*it)->getId(); m_readings.push_back(*it); } From 9a5cfaf6a7cfc190fdaa8e0cd489189b4099dfae Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 15 Jun 2023 16:13:36 +0530 Subject: [PATCH 313/499] improvement fixes for syslog utility script Signed-off-by: ashish-jabble --- scripts/common/get_logs.sh | 47 ++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/scripts/common/get_logs.sh b/scripts/common/get_logs.sh index c7482cc0bf..3b849047c1 100755 --- a/scripts/common/get_logs.sh +++ b/scripts/common/get_logs.sh @@ -42,7 +42,6 @@ else search_pattern="grep -a -E '${pattern}'" fi - echo "" >&2 echo "****************************************************************************************" >&2 echo "************************************* START ********************************************" >&2 @@ -67,46 +66,47 @@ echo "offset=$offset, limit=$limit, sum=$sum, pattern=$search_pattern, sourceApp # calculate how many log lines are to be checked to get 'n' result lines for a given service and log level # if for getting 100 lines of interest, 6400 last syslog lines need to be checked, then factor would be 64 factor=2 +initial_factor=$((${NUM_LOGFILE_LINES_TO_CHECK_INITIALLY} / $sum)) if [[ $script_runs -eq 0 ]]; then - factor=$((${NUM_LOGFILE_LINES_TO_CHECK_INITIALLY} / $sum)) + factor=$initial_factor [[ $factor -lt 2 ]] && factor=2 else if [ -f /tmp/fl_syslog_factor ]; then echo "Reading factor value from /tmp/fl_syslog_factor" >&2 if [[ $keyword_len -gt 0 ]]; then - factor_value=$(grep "$factor_keyword" /tmp/fl_syslog_factor | rev | cut -d: -f1 | rev) + cmd="grep '$factor_keyword' /tmp/fl_syslog_factor | rev | cut -d: -f1 | rev" else - factor_value=$(grep "$factor_keyword[0-9]+$" /tmp/fl_syslog_factor | rev | cut -d: -f1 | rev) + cmd="grep '$factor_keyword[0-9][0-9]*$' /tmp/fl_syslog_factor | rev | cut -d: -f1 | rev" fi + echo "Read factor cmd='$cmd'" >&2 + factor=$(eval $cmd) echo "Read factor value of '$factor' from /tmp/fl_syslog_factor" >&2 - [ -z $factor ] && factor=2 && echo "Using factor value of $factor" >&2 + [ -z $factor ] && factor=$initial_factor && echo "Using factor value of $factor" >&2 else - [ -z $factor ] && factor=2 && echo "Using factor value of $factor; file '/tmp/fl_syslog_factor' is missing" >&2 + [ -z $factor ] && factor=$initial_factor && echo "Using factor value of $factor; file '/tmp/fl_syslog_factor' is missing" >&2 echo "Starting with factor=$factor" >&2 fi + fi tmpfile=$(mktemp) loop_iters=0 +logfile_line_count=$(wc -l < $logfile) # check the last 'n' lines of syslog for log lines of interest, else keep doubling 'n' till syslog file size while [ 1 ]; do t1=$(date +%s%N) - filesz=$(stat -c%s $logfile) - filesz_dbl=$(($filesz * 2)) - - lines=$(($factor * $sum)) - - echo "loop_iters=$loop_iters: factor=$factor, lines=$lines, tmpfile=$tmpfile" >&2 - - cmd="tail -n $lines $logfile | ${search_pattern} > $tmpfile" - echo "cmd=$cmd, filesz=$filesz" >&2 + lines_to_check=$(($factor * $sum)) + echo >&2 + echo "loop_iters=$loop_iters: factor=$factor, lines=$lines_to_check, tmpfile=$tmpfile" >&2 + cmd="tail -n $lines_to_check $logfile | ${search_pattern} > $tmpfile" + echo "cmd=$cmd, logfile line count=$logfile_line_count" >&2 eval "$cmd" t2=$(date +%s%N) t_diff=$(((t2 - t1)/1000000)) count=$(wc -l < $tmpfile) - echo "Got $count matching log lines in last $lines lines of syslog file; processing time=${t_diff}ms" >&2 + echo "Got $count matching log lines in last $lines_to_check lines of syslog file; processing time=${t_diff}ms" >&2 if [[ $count -ge $sum ]]; then echo "Got sufficient number of matching log lines, current factor value of $factor is good" >&2 @@ -117,16 +117,10 @@ do grep -v "$factor_keyword" /tmp/fl_syslog_factor > /tmp/fl_syslog_factor.out; mv /tmp/fl_syslog_factor.out /tmp/fl_syslog_factor echo "$factor_keyword$factor" >> /tmp/fl_syslog_factor break - else - new_factor=$factor - [[ $count -ne 0 ]] && ( new_factor=$(($lines / $count)) && new_factor=$(($new_factor + 1)) ) - echo "factor=$factor, new_factor=$new_factor" >&2 - [[ $new_factor -eq $factor ]] && [[ $lines -lt $filesz_dbl ]] && factor=$(($factor * 2)) || factor=$new_factor - echo "Didn't get sufficient number of matching log lines, trying factor=$factor" >&2 fi - if [[ $lines -gt $filesz_dbl ]]; then - echo "Cannot increase factor value any further; filesz=$filesz, lines=$lines" >&2 + if [[ $lines_to_check -gt $logfile_line_count ]]; then + echo "Cannot increase factor value any further; logfile line count=$logfile_line_count, lines=$lines_to_check" >&2 cat $tmpfile | tail -n $sum | head -n $limit echo "Log results START:" >&2 @@ -139,8 +133,11 @@ do break fi + factor=$(($factor * 2)) + echo "Didn't get sufficient number of matching log lines, trying factor=$factor" >&2 + loop_iters=$(($loop_iters + 1)) done echo "******************************** END ***************************************************" >&2 -echo "" >&2 +echo "" >&2 \ No newline at end of file From 5f0e38289b3eb1bebeefa5bc34fc150086cf55fc Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 15 Jun 2023 16:59:45 +0530 Subject: [PATCH 314/499] utility syslog files added to support bundle Signed-off-by: ashish-jabble --- python/fledge/services/core/support.py | 16 ++++++++++++---- scripts/common/get_logs.sh | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index ba39e184bc..d696ff8509 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -70,6 +70,7 @@ async def build(self): await self.add_fledge_version_and_schema(pyz) self.add_syslog_fledge(pyz, file_spec) self.add_syslog_storage(pyz, file_spec) + self.add_syslog_utility(pyz) cf_mgr = ConfigurationManager(self._storage) try: south_cat = await cf_mgr.get_category_child("South") @@ -142,7 +143,7 @@ def add_syslog_fledge(self, pyz, file_spec): subprocess.call("grep -a '{}' {} > {}".format("Fledge", _SYSLOG_FILE, temp_file), shell=True) except OSError as ex: raise RuntimeError("Error in creating {}. Error-{}".format(temp_file, str(ex))) - pyz.add(temp_file, arcname=basename(temp_file)) + pyz.add(temp_file, arcname='logs/sys/{}'.format(basename(temp_file))) def add_syslog_storage(self, pyz, file_spec): # The contents of the syslog file that relate to the database layer (postgres) @@ -151,7 +152,7 @@ def add_syslog_storage(self, pyz, file_spec): subprocess.call("grep -a '{}' {} > {}".format("Fledge Storage", _SYSLOG_FILE, temp_file), shell=True) except OSError as ex: raise RuntimeError("Error in creating {}. Error-{}".format(temp_file, str(ex))) - pyz.add(temp_file, arcname=basename(temp_file)) + pyz.add(temp_file, arcname='logs/sys/{}'.format(basename(temp_file))) def add_syslog_service(self, pyz, file_spec, service): # The fledge entries from the syslog file for a service or task @@ -160,10 +161,17 @@ def add_syslog_service(self, pyz, file_spec, service): temp_file = self._interim_file_path + "/" + "syslog-{}-{}".format(tmp_svc, file_spec) try: subprocess.call("grep -a -E '(Fledge {})\[' {} > {}".format(service, _SYSLOG_FILE, temp_file), shell=True) - pyz.add(temp_file, arcname=basename(temp_file)) + pyz.add(temp_file, arcname='logs/sys/{}'.format(basename(temp_file))) except Exception as ex: raise RuntimeError("Error in creating {}. Error-{}".format(temp_file, str(ex))) + def add_syslog_utility(self, pyz): + # syslog utility files + for filename in os.listdir("/tmp"): + if filename.startswith("fl_syslog"): + temp_file = "/tmp/{}".format(filename) + pyz.add(temp_file, arcname='logs/sys/{}'.format(filename)) + async def add_table_configuration(self, pyz, file_spec): # The contents of the configuration table from the storage layer temp_file = self._interim_file_path + "/" + "configuration-{}".format(file_spec) @@ -285,7 +293,7 @@ def add_package_log_dir_content(self, pyz): script_package_logs_path = _PATH + '/logs' if os.path.exists(script_package_logs_path): # recursively 'true' by default and __pycache__ dir excluded - pyz.add(script_package_logs_path, arcname='package_logs', filter=self.exclude_pycache) + pyz.add(script_package_logs_path, arcname='logs/package', filter=self.exclude_pycache) def add_software_list(self, pyz, file_spec) -> None: data = { diff --git a/scripts/common/get_logs.sh b/scripts/common/get_logs.sh index 3b849047c1..ea67f64cae 100755 --- a/scripts/common/get_logs.sh +++ b/scripts/common/get_logs.sh @@ -98,7 +98,7 @@ while [ 1 ]; do t1=$(date +%s%N) lines_to_check=$(($factor * $sum)) - echo >&2 + echo >&2 echo "loop_iters=$loop_iters: factor=$factor, lines=$lines_to_check, tmpfile=$tmpfile" >&2 cmd="tail -n $lines_to_check $logfile | ${search_pattern} > $tmpfile" echo "cmd=$cmd, logfile line count=$logfile_line_count" >&2 From 0bc084802b80bd1599988c75cc82d53182fcd1f7 Mon Sep 17 00:00:00 2001 From: Himanshu Vimal <67678828+cyberwalk3r@users.noreply.github.com> Date: Fri, 16 Jun 2023 14:05:52 +0530 Subject: [PATCH 315/499] FOGL-7861: Memory growth on north service for DICT and LIST readings. (#1090) Signed-off-by: Himanshu Vimal --- C/common/pythonreading.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/C/common/pythonreading.cpp b/C/common/pythonreading.cpp index 7df823472f..97fcec0dce 100755 --- a/C/common/pythonreading.cpp +++ b/C/common/pythonreading.cpp @@ -577,9 +577,9 @@ PyObject *PythonReading::convertDatapoint(Datapoint *dp, bool bytesString) else if (dataType == DatapointValue::dataTagType::T_DP_DICT) { vector* children = dp->getData().getDpVec();; + value = PyDict_New(); for (auto child = children->begin(); child != children->end(); ++child) { - value = PyDict_New(); PyObject *childValue = convertDatapoint(*child); // Add Datapoint: key and value PyObject *key = PyUnicode_FromString((*child)->getName().c_str()); @@ -593,9 +593,9 @@ PyObject *PythonReading::convertDatapoint(Datapoint *dp, bool bytesString) { vector* children = dp->getData().getDpVec(); int i = 0; + value = PyList_New(children->size()); for (auto child = children->begin(); child != children->end(); ++child) { - value = PyList_New(children->size()); PyObject *childValue = convertDatapoint(*child); // TODO complete // Add Datapoint: key and value From 15283324537801a6f2f7ec2c52555739dd012c01 Mon Sep 17 00:00:00 2001 From: Himanshu Vimal <67678828+cyberwalk3r@users.noreply.github.com> Date: Fri, 16 Jun 2023 15:20:24 +0530 Subject: [PATCH 316/499] FOGL-7862: Rapid memory growth in north service when plugin raises error (#1091) Signed-off-by: Himanshu Vimal --- C/services/north/data_send.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/C/services/north/data_send.cpp b/C/services/north/data_send.cpp index 9fc48e39a8..ede749df7c 100755 --- a/C/services/north/data_send.cpp +++ b/C/services/north/data_send.cpp @@ -84,6 +84,9 @@ void DataSender::sendThread() // Set readings removal removeReadings = vec->size() == 0; + } else { + // If no reading sent, set readings removal + removeReadings = true; } } else { // All readings filtered out From 133392f8690d153ecb1ca1227a872268ed346769 Mon Sep 17 00:00:00 2001 From: pintomax Date: Fri, 16 Jun 2023 18:18:42 +0200 Subject: [PATCH 317/499] FOGL-7886: pospone write and operation calls for async plugins (#1094) FOGL-7886: pospone write and operation calls for async plugins --- C/services/south/include/south_plugin.h | 1 + C/services/south/south_plugin.cpp | 58 +++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/C/services/south/include/south_plugin.h b/C/services/south/include/south_plugin.h index 2c88261b8a..6027b6f551 100644 --- a/C/services/south/include/south_plugin.h +++ b/C/services/south/include/south_plugin.h @@ -52,6 +52,7 @@ class SouthPlugin : public Plugin { bool operation(const std::string& name, std::vector& ); private: PLUGIN_HANDLE instance; + bool m_started; // Plugin started indicator, for async plugins void (*pluginStartPtr)(PLUGIN_HANDLE); Reading (*pluginPollPtr)(PLUGIN_HANDLE); std::vector* (*pluginPollPtrV2)(PLUGIN_HANDLE); diff --git a/C/services/south/south_plugin.cpp b/C/services/south/south_plugin.cpp index 5f931ec83e..9f9c69229a 100755 --- a/C/services/south/south_plugin.cpp +++ b/C/services/south/south_plugin.cpp @@ -31,6 +31,8 @@ std::mutex mtx2; */ SouthPlugin::SouthPlugin(PLUGIN_HANDLE handle, const ConfigCategory& category) : Plugin(handle) { + m_started = false; // Set started indicator, overrided by async plugins only + // Call the init method of the plugin PLUGIN_HANDLE (*pluginInit)(const void *) = (PLUGIN_HANDLE (*)(const void *)) manager->resolveSymbol(handle, "plugin_init"); @@ -117,7 +119,9 @@ void SouthPlugin::start() { lock_guard guard(mtx2); try { - return this->pluginStartPtr(instance); + this->pluginStartPtr(instance); + m_started = true; // Set start indicator + return; } catch (exception& e) { Logger::getLogger()->fatal("Unhandled exception raised in south plugin start(), %s", e.what()); @@ -137,7 +141,9 @@ void SouthPlugin::startData(const string& data) { lock_guard guard(mtx2); try { - return this->pluginStartDataPtr(instance, data); + this->pluginStartDataPtr(instance, data); + m_started = true; // Set start indicator + return; } catch (exception& e) { Logger::getLogger()->fatal("Unhandled exception raised in south plugin start(), %s", e.what()); @@ -309,7 +315,29 @@ bool SouthPlugin::write(const string& name, const string& value) try { if (pluginWritePtr) { - return this->pluginWritePtr(instance, name, value); + bool run = true; + // Check plugin_start is done for async plugin before calling pluginWritePtr + if (isAsync()) { + int tries = 0; + while (!m_started) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + Logger::getLogger()->debug("South plugin write call is on hold, try %d", tries); + if (tries > 20) { + break; + } + tries++; + } + run = m_started; + } + + if (run) { + return this->pluginWritePtr(instance, name, value); + } + else + { + Logger::getLogger()->error("South plugin write canceled after waiting for 2 seconds"); + return false; + } } } catch (exception& e) { Logger::getLogger()->fatal("Unhandled exception in plugin write operation: %s", e.what()); @@ -349,7 +377,29 @@ bool SouthPlugin::operation(const string& name, vector& para } params[count] = NULL; try { - status = this->pluginOperationPtr(instance, name, (int)count, params); + bool run = true; + // Check plugin_start is done for async plugin before calling pluginOperationPtr + if (isAsync()) { + int tries = 0; + while (!m_started) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + Logger::getLogger()->debug("South plugin operation is on hold, try %d", tries); + if (tries > 20) { + break; + } + tries++; + } + run = m_started; + } + + if (run) { + status = this->pluginOperationPtr(instance, name, (int)count, params); + } + else + { + Logger::getLogger()->error("South plugin operation canceled after waiting for 2 seconds"); + return false; + } } catch (exception& e) { Logger::getLogger()->fatal("Unhandled exception in plugin operation: %s", e.what()); } From c9b26466435799b29a882c502588a0777c5a9090 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 19 Jun 2023 17:02:26 +0530 Subject: [PATCH 318/499] GET package log fixes when files other than .log extension; now handled gracefully Signed-off-by: ashish-jabble --- .../fledge/services/core/api/package_log.py | 33 ++++++----- .../services/core/api/test_package_log.py | 59 ++++++++++++++----- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/python/fledge/services/core/api/package_log.py b/python/fledge/services/core/api/package_log.py index 81e2b0df00..3355d1a341 100644 --- a/python/fledge/services/core/api/package_log.py +++ b/python/fledge/services/core/api/package_log.py @@ -48,19 +48,24 @@ async def get_logs(request: web.Request) -> web.Response: result = [] for f in found_files: - # Empty log name for update cmd - name = "" - t1 = f.split(".log") - t2 = t1[0].split("-fledge") - t3 = t2[0].split("-") - t4 = t1[0].split("-list") - if len(t2) >= 2: - name = "fledge{}".format(t2[1]) - if len(t4) >= 2: - name = "list" - dt = "{}-{}-{}-{}".format(t3[0], t3[1], t3[2], t3[3]) - ts = datetime.strptime(dt, "%y%m%d-%H-%M-%S").strftime('%Y-%m-%d %H:%M:%S') - result.append({"timestamp": ts, "name": name, "filename": f}) + if f.endswith(valid_extension): + t1 = f.split(".log") + t2 = t1[0].split("-fledge") + t3 = t2[0].split("-") + t4 = t1[0].split("-list") + if len(t2) >= 2: + name = "fledge{}".format(t2[1]) + elif len(t4) >= 2: + name = "list" + else: + name = t1[0] + if len(t3) >= 4: + dt = "{}-{}-{}-{}".format(t3[0], t3[1], t3[2], t3[3]) + ts = datetime.strptime(dt, "%y%m%d-%H-%M-%S").strftime('%Y-%m-%d %H:%M:%S') + else: + dt = datetime.now() + ts = dt.strftime("%Y-%m-%d %H:%M:%S") + result.append({"timestamp": ts, "name": name, "filename": f}) return web.json_response({"logs": result}) @@ -143,7 +148,7 @@ async def get_package_status(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as exc: msg = str(exc) - _LOGGER.error(exc, "Failed tp get package log status for {} action.".format(action)) + _LOGGER.error(exc, "Failed to get package log status for {} action.".format(action)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"packageStatus": result}) diff --git a/tests/unit/python/fledge/services/core/api/test_package_log.py b/tests/unit/python/fledge/services/core/api/test_package_log.py index 28fa6b2719..1bdf93a4d2 100644 --- a/tests/unit/python/fledge/services/core/api/test_package_log.py +++ b/tests/unit/python/fledge/services/core/api/test_package_log.py @@ -43,8 +43,17 @@ def logs_path(self): return "{}/logs".format(pathlib.Path(__file__).parent) async def test_get_logs(self, client, logs_path): - files = ["190801-13-21-56.log", "190801-13-18-02-fledge-north-httpc-install.log", - "190801-14-55-25-fledge-south-sinusoid-install.log", "191024-04-21-56-list.log"] + files = ["190801-13-21-56.log", + "190801-13-18-02-fledge-north-httpc-install.log", + "190801-14-55-25-fledge-south-sinusoid-install.log", + "191024-04-21-56-list.log", + "230619-10-20-31-fledge-south-http-south-remove.log", + "230619-10-17-36-fledge-south-s2opcua-update.log", + "trace.log", + "20230609_093006_Trace_00000.log", + "trace.txt", + "syslog" + ] with patch.object(package_log, '_get_logs_dir', side_effect=[logs_path]): with patch('os.walk') as mockwalk: mockwalk.return_value = [(str(logs_path), [], files)] @@ -53,19 +62,39 @@ async def test_get_logs(self, client, logs_path): res = await resp.text() jdict = json.loads(res) logs = jdict["logs"] - assert 4 == len(logs) - assert files[0] == logs[0]['filename'] - assert "2019-08-01 13:21:56" == logs[0]['timestamp'] - assert "" == logs[0]['name'] - assert files[1] == logs[1]['filename'] - assert "2019-08-01 13:18:02" == logs[1]['timestamp'] - assert "fledge-north-httpc-install" == logs[1]['name'] - assert files[2] == logs[2]['filename'] - assert "2019-08-01 14:55:25" == logs[2]['timestamp'] - assert "fledge-south-sinusoid-install" == logs[2]['name'] - assert files[3] == logs[3]['filename'] - assert "2019-10-24 04:21:56" == logs[3]['timestamp'] - assert "list" == logs[3]['name'] + assert len(files) - 2 == len(logs) + obj = logs[0] + assert files[0] == obj['filename'] + assert "2019-08-01 13:21:56" == obj['timestamp'] + assert "190801-13-21-56" == obj['name'] + obj = logs[1] + assert files[1] == obj['filename'] + assert "2019-08-01 13:18:02" == obj['timestamp'] + assert "fledge-north-httpc-install" == obj['name'] + obj = logs[2] + assert files[2] == obj['filename'] + assert "2019-08-01 14:55:25" == obj['timestamp'] + assert "fledge-south-sinusoid-install" == obj['name'] + obj = logs[3] + assert files[3] == obj['filename'] + assert "2019-10-24 04:21:56" == obj['timestamp'] + assert "list" == obj['name'] + obj = logs[4] + assert files[4] == obj['filename'] + assert "2023-06-19 10:20:31" == obj['timestamp'] + assert "fledge-south-http-south-remove" == obj['name'] + obj = logs[5] + assert files[5] == obj['filename'] + assert "2023-06-19 10:17:36" == obj['timestamp'] + assert "fledge-south-s2opcua-update" == obj['name'] + obj = logs[6] + assert files[6] == obj['filename'] + assert len(obj['timestamp']) > 0 + assert "trace" == obj['name'] + obj = logs[7] + assert files[7] == obj['filename'] + assert len(obj['timestamp']) > 0 + assert "20230609_093006_Trace_00000" == obj['name'] mockwalk.assert_called_once_with(logs_path) async def test_get_log_by_name_with_invalid_extension(self, client): From d40841148a6d768e7a3066c1efb879fa268b3c8c Mon Sep 17 00:00:00 2001 From: Himanshu Vimal <67678828+cyberwalk3r@users.noreply.github.com> Date: Wed, 21 Jun 2023 13:38:54 +0530 Subject: [PATCH 319/499] FOGL-7862.patch (#1093) - Added TODO for improvement while calling plugin_send. Signed-off-by: Himanshu Vimal --- C/services/north/data_send.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/C/services/north/data_send.cpp b/C/services/north/data_send.cpp index ede749df7c..b11489f92b 100755 --- a/C/services/north/data_send.cpp +++ b/C/services/north/data_send.cpp @@ -85,7 +85,9 @@ void DataSender::sendThread() // Set readings removal removeReadings = vec->size() == 0; } else { - // If no reading sent, set readings removal + // TODO: FOGL-7884: Reuse unsent readings without + // fetching again from storage. + // If failed to send reading, set readings removal to prevent memory leak removeReadings = true; } } else { From 089e5758f7d7edfdeae24f75776fdff7b83d9169 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Fri, 23 Jun 2023 14:02:40 +0530 Subject: [PATCH 320/499] FOGL:7585 Lab Script based Jekins job should provide the syslog and support bundle for every stage (#1078) --- tests/system/lab/test | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/system/lab/test b/tests/system/lab/test index 1be8aad3d7..c802b52b06 100755 --- a/tests/system/lab/test +++ b/tests/system/lab/test @@ -24,6 +24,7 @@ display_and_collect_err () { URL="http://$FLEDGE_IP:8081/fledge" +PROJECT_ROOT=$(git rev-parse --show-toplevel) sinusoid_config=$(cat < /dev/null; then + echo "Support Bundle Created" + rm -rf "$SUPPORT_BUNDLE_DIR" && mkdir -p "$SUPPORT_BUNDLE_DIR" && \ + cp -r /usr/local/fledge/data/support/* "$SUPPORT_BUNDLE_DIR"/. && \ + echo "Support bundle has been saved to path: $SUPPORT_BUNDLE_DIR" +else + echo "Failed to Create support bundle" + rm -rf "$SUPPORT_BUNDLE_DIR" && mkdir -p "$SUPPORT_BUNDLE_DIR" && \ + cp /var/log/syslog "$SUPPORT_BUNDLE_DIR"/. && \ + echo "Syslog Saved to $SUPPORT_BUNDLE_DIR" +fi +echo "===================== COLLECTED SUPPORT BUNDLE ============================" ERRORS="$(wc -c <"err.txt")" if [[ ${ERRORS} -ne 0 ]] From 5a01ba02b9f8b820a8a839a7751675fc6cd3eca6 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 23 Jun 2023 15:18:58 +0100 Subject: [PATCH 321/499] FOGL-7832 Add flow control the async ingest of the south service (#1099) * Addition of basic flow control for async ingest Signed-off-by: Mark Riddoch * FOGL-7832 Add flow control for async ingest Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/services/south/include/ingest.h | 12 +++++++++ C/services/south/ingest.cpp | 42 ++++++++++++++++++++++++++++++- C/services/south/south.cpp | 39 ++++++++++++++++------------ 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/C/services/south/include/ingest.h b/C/services/south/include/ingest.h index 956b39bcee..201c755e91 100644 --- a/C/services/south/include/ingest.h +++ b/C/services/south/include/ingest.h @@ -34,6 +34,14 @@ #define DEPRECATED_CACHE_AGE 600 // Maximum allowed aged of the deprecated asset cache +/* + * Constants related to flow control for async south services. + * + */ +#define AFC_SLEEP_INCREMENT 20 // Number of milliseconds to wait for readings to drain +#define AFC_SLEEP_MAX 200 // Maximum sleep tiem in ms between tests +#define AFC_MAX_WAIT 5000 // Maximum amount of time we wait for the queue to drain + /** * The ingest class is used to ingest asset readings. * It maintains a queue of readings to be sent to storage, @@ -82,6 +90,8 @@ class Ingest : public ServiceHandler { void setStatistics(const std::string& option); std::string getStringFromSet(const std::set &dpSet); + void setFlowControl(unsigned int lowWater, unsigned int highWater) { m_lowWater = lowWater; m_highWater = highWater; }; + void flowControl(); private: @@ -134,6 +144,8 @@ class Ingest : public ServiceHandler { int m_statsUpdateFails; enum { STATS_BOTH, STATS_ASSET, STATS_SERVICE } m_statisticsOption; + unsigned int m_highWater; + unsigned int m_lowWater; AssetTrackingTable *m_deprecated; time_t m_deprecatedAgeOut; time_t m_deprecatedAgeOutStorage; diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index ad62de5dbc..99a873dc7c 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -283,7 +283,8 @@ Ingest::Ingest(StorageClient& storage, m_failCnt(0), m_storageFailed(false), m_storesFailed(0), - m_statisticsOption(STATS_BOTH) + m_statisticsOption(STATS_BOTH), + m_highWater(0) { m_shutdown = false; m_running = true; @@ -1346,3 +1347,42 @@ std::string Ingest::getStringFromSet(const std::set &dpSet) s.pop_back(); return s; } + +/** + * Implement flow control backoff for the async ingest mechanism. + * + * The flow control is "soft" in that it will only wait for a maximum + * amount of time before continuing regardless of the queue length. + * + * The mechanism is to have a high water and low water mark. When the queue + * get longer than the high water mark we wait until the queue drains below + * the low water mark before proceeding. + * + * The wait is done with a backoff algorithm that start at AFC_SLEEP_INCREMENT + * and doubles each time we have not dropped below the low water mark. It will + * sleep for a maximum of AFC_SLEEP_MAX before testing again. + */ +void Ingest::flowControl() +{ + if (m_highWater == 0) // No flow control + { + return; + } + if (m_highWater < queueLength()) + { + m_logger->debug("Waiting for ingest queue to drain"); + int total = 0, delay = AFC_SLEEP_INCREMENT; + while (total < AFC_MAX_WAIT && queueLength() > m_lowWater) + { + this_thread::sleep_for(chrono::milliseconds(delay)); + total += delay; + delay *= 2; + if (delay > AFC_SLEEP_MAX) + { + delay = AFC_SLEEP_MAX; + } + } + m_logger->debug("Ingest queue has %s", queueLength() > m_lowWater + ? "failed to drain in sufficient time" : "has drained"); + } +} diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 821a219c25..371fcb4609 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -202,26 +202,29 @@ void doIngest(Ingest *ingest, Reading reading) void doIngestV2(Ingest *ingest, ReadingSet *set) { - std::vector *vec = set->getAllReadingsPtr(); - std::vector *vec2 = new std::vector; - if (!vec) - { - Logger::getLogger()->info("%s:%d: V2 async ingest method: vec is NULL", __FUNCTION__, __LINE__); - return; - } - else - { - for (auto & r : *vec) - { - Reading *r2 = new Reading(*r); // Need to copy reading objects here, since "del set" below would remove encapsulated reading objects also - vec2->emplace_back(r2); - } - } - Logger::getLogger()->debug("%s:%d: V2 async ingest method returned: vec->size()=%d", __FUNCTION__, __LINE__, vec->size()); + std::vector *vec = set->getAllReadingsPtr(); + std::vector *vec2 = new std::vector; + if (!vec) + { + Logger::getLogger()->info("%s:%d: V2 async ingest method: vec is NULL", __FUNCTION__, __LINE__); + return; + } + else + { + // TODO Remove the need for this copy of all the readings + for (auto & r : *vec) + { + Reading *r2 = new Reading(*r); // Need to copy reading objects here, since "del set" below would remove encapsulated reading objects also + vec2->emplace_back(r2); + } + } + Logger::getLogger()->debug("%s:%d: V2 async ingest method returned: vec->size()=%d", __FUNCTION__, __LINE__, vec->size()); ingest->ingest(vec2); delete vec2; // each reading object inside vector has been allocated on heap and moved to Ingest class's internal queue delete set; + + ingest->flowControl(); } /** @@ -397,6 +400,10 @@ void SouthService::start(string& coreAddress, unsigned short corePort) // Instantiate the Ingest class Ingest ingest(storage, m_name, pluginName, m_mgtClient); m_ingest = &ingest; + if (m_throttle) + { + m_ingest->setFlowControl(m_lowWater, m_highWater); + } if (m_configAdvanced.itemExists("statistics")) { From 584f60735e8e8a41be55b0e2c6575c5f0616a301 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Jun 2023 17:25:06 +0530 Subject: [PATCH 322/499] service update API cleanup fixes Signed-off-by: ashish-jabble --- python/fledge/services/core/api/service.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index be9785b9f9..d8d682c24c 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -644,10 +644,8 @@ async def update_service(request: web.Request) -> web.Response: if result['response'] == "inserted" and result['rows_affected'] == 1: pn = "{}-{}".format(action, name) # Scheme is always http:// on core_management_port - p = multiprocessing.Process(name=pn, target=do_update, args=(server.Server.is_rest_server_http_enabled, - server.Server._host, - server.Server.core_management_port, - storage_client, package_name, uid, sch_list)) + p = multiprocessing.Process(name=pn, target=do_update, args=( + server.Server._host, server.Server.core_management_port, storage_client, package_name, uid, sch_list)) p.daemon = True p.start() msg = "{} {} started".format(package_name, action) @@ -665,10 +663,10 @@ async def update_service(request: web.Request) -> web.Response: return web.json_response(result_payload) -async def _put_schedule(protocol: str, host: str, port: int, sch_id: uuid, is_enabled: bool) -> None: - management_api_url = '{}://{}:{}/fledge/schedule/{}/enable'.format(protocol, host, port, sch_id) +async def _put_schedule(host: str, port: int, sch_id: uuid, is_enabled: bool) -> None: + management_api_url = 'http://{}:{}/fledge/schedule/{}/enable'.format(host, port, sch_id) headers = {'content-type': 'application/json'} - verify_ssl = False if protocol == 'HTTP' else True + verify_ssl = False connector = aiohttp.TCPConnector(verify_ssl=verify_ssl) async with aiohttp.ClientSession(connector=connector) as session: async with session.put(management_api_url, data=json.dumps({"value": is_enabled}), headers=headers) as resp: @@ -682,16 +680,11 @@ async def _put_schedule(protocol: str, host: str, port: int, sch_id: uuid, is_en _logger.debug("PUT Schedule response: %s", response) -def do_update(http_enabled: bool, host: str, port: int, storage: connect, pkg_name: str, uid: str, - schedules: list) -> None: +def do_update(host: str, port: int, storage: connect, pkg_name: str, uid: str, schedules: list) -> None: _logger.info("{} service update started...".format(pkg_name)) stdout_file_path = common.create_log_file("update", pkg_name) pkg_mgt = 'yum' if utils.is_redhat_based() else 'apt' cmd = "sudo {} -y update > {} 2>&1".format(pkg_mgt, stdout_file_path) - - # Protocol is always http:// on core_management_port - protocol = "HTTP" - if pkg_mgt == 'yum': cmd = "sudo {} check-update > {} 2>&1".format(pkg_mgt, stdout_file_path) ret_code = os.system(cmd) @@ -719,7 +712,7 @@ def do_update(http_enabled: bool, host: str, port: int, storage: connect, pkg_na # Restart the service which was disabled before service update for sch in schedules: - loop.run_until_complete(_put_schedule(protocol, host, port, uuid.UUID(sch), True)) + loop.run_until_complete(_put_schedule(host, port, uuid.UUID(sch), True)) @has_permission("admin") From ed6e1922064740df2326fa95a296d7cf9e08ddcb Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Jun 2023 17:28:39 +0530 Subject: [PATCH 323/499] plugin remove/purge API cleanup fixes Signed-off-by: ashish-jabble --- python/fledge/services/core/api/plugins/remove.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index 169a39f432..14069f1717 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -169,7 +169,7 @@ def _uninstall(pkg_name: str, version: str, uid: uuid, storage: connect) -> tupl loop.run_until_complete(storage.update_tbl("packages", payload)) if code == 0: # Clear internal cache - loop.run_until_complete(_put_refresh_cache("http", Server._host, Server.core_management_port)) + loop.run_until_complete(_put_refresh_cache(Server._host, Server.core_management_port)) # Audit logger audit = AuditLogger(storage) audit_detail = {'package_name': pkg_name, 'version': version} @@ -360,8 +360,9 @@ async def _check_plugin_usage_in_notification_instances(plugin_name: str) -> lis return notification_instances -async def _put_refresh_cache(protocol: str, host: int, port: int) -> None: - management_api_url = '{}://{}:{}/fledge/cache'.format(protocol, host, port) +async def _put_refresh_cache(host: int, port: int) -> None: + # Scheme is always http:// on core_management_port + management_api_url = 'http://{}:{}/fledge/cache'.format(host, port) headers = {'content-type': 'application/json'} verify_ssl = False connector = aiohttp.TCPConnector(verify_ssl=verify_ssl) @@ -417,7 +418,7 @@ def purge_plugin(plugin_type: str, plugin_name: str, pkg_name: str, version: str if code == 0: # Clear internal cache - loop.run_until_complete(_put_refresh_cache("http", Server._host, Server.core_management_port)) + loop.run_until_complete(_put_refresh_cache(Server._host, Server.core_management_port)) # Audit info audit = AuditLogger(storage) audit_detail = {'package_name': pkg_name, 'version': version} From 9ef5a874ede98f8aab763d185cc04e997cb3269d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 26 Jun 2023 17:32:04 +0530 Subject: [PATCH 324/499] plugin update API cleanup fixes Signed-off-by: ashish-jabble --- .../services/core/api/plugins/update.py | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index 078f43292f..91921bafb9 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -148,12 +148,9 @@ async def update_package(request: web.Request) -> web.Response: if response: pn = "{}-{}".format(action, package_name) uid = response[0]['id'] - p = multiprocessing.Process(name=pn, - target=do_update, - args=(server.Server.is_rest_server_http_enabled, - server.Server._host, server.Server.core_management_port, - storage_client, plugin_type, plugin_name, package_name, uid, - schedules, notifications)) + p = multiprocessing.Process(name=pn, target=do_update, args=( + server.Server._host, server.Server.core_management_port, storage_client, plugin_type, plugin_name, + package_name, uid, schedules, notifications)) p.daemon = True p.start() msg = "{} {} started.".format(package_name, action) @@ -342,10 +339,11 @@ async def _get_sch_id_and_enabled_by_name(name: str) -> list: return result['rows'] -async def _put_schedule(protocol: str, host: str, port: int, sch_id: uuid, is_enabled: bool) -> None: - management_api_url = '{}://{}:{}/fledge/schedule/{}/enable'.format(protocol, host, port, sch_id) +async def _put_schedule(host: str, port: int, sch_id: uuid, is_enabled: bool) -> None: + # Scheme is always http:// on core_management_port + management_api_url = 'http://{}:{}/fledge/schedule/{}/enable'.format(host, port, sch_id) headers = {'content-type': 'application/json'} - verify_ssl = False if protocol == 'HTTP' else True + verify_ssl = False connector = aiohttp.TCPConnector(verify_ssl=verify_ssl) async with aiohttp.ClientSession(connector=connector) as session: async with session.put(management_api_url, data=json.dumps({"value": is_enabled}), headers=headers) as resp: @@ -377,12 +375,9 @@ def _update_repo_sources_and_plugin(pkg_name: str) -> tuple: return ret_code, link -def do_update(http_enabled: bool, host: str, port: int, storage: connect, _type: str, plugin_name: str, +def do_update(host: str, port: int, storage: connect, _type: str, plugin_name: str, pkg_name: str, uid: str, schedules: list, notifications: list) -> None: _logger.info("{} package update started...".format(pkg_name)) - - # Protocol is always http:// on core_management_port - protocol = "HTTP" code, link = _update_repo_sources_and_plugin(pkg_name) @@ -403,7 +398,7 @@ def do_update(http_enabled: bool, host: str, port: int, storage: connect, _type: _logger.info('{} package updated successfully.'.format(pkg_name)) # Restart the services which were disabled before plugin update for sch in schedules: - loop.run_until_complete(_put_schedule(protocol, host, port, uuid.UUID(sch), True)) + loop.run_until_complete(_put_schedule(host, port, uuid.UUID(sch), True)) # Below case is applicable for the notification plugins ONLY # Enabled back configuration categories which were disabled during update process From 13c0ac5b7de86f8357bec9b956b6fecdc57e63a6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 28 Jun 2023 13:41:49 +0530 Subject: [PATCH 325/499] most recent paramter support added in asset browser API along with additioal assets query Signed-off-by: ashish-jabble --- python/fledge/services/core/api/browser.py | 29 ++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/browser.py b/python/fledge/services/core/api/browser.py index b8e33762dd..60faf93ae2 100644 --- a/python/fledge/services/core/api/browser.py +++ b/python/fledge/services/core/api/browser.py @@ -179,6 +179,8 @@ async def asset(request): curl -sX GET http://localhost:8081/fledge/asset/fogbench_humidity?seconds=60 curl -sX GET http://localhost:8081/fledge/asset/fogbench_humidity?seconds=60&previous=600 curl -sX GET "http://localhost:8081/fledge/asset/fogbench_humidity?additional=sinusoid,random&seconds=600" + curl -sX GET "http://localhost:8081/fledge/asset/sinusoid?mostrecent=true&seconds=600" + curl -sX GET "http://localhost:8081/fledge/asset/sinusoid?mostrecent=true&seconds=60&additional=randomwalk" """ asset_code = request.match_info.get('asset_code', '') # A comma separated list of additional assets to generate the readings to display multiple graphs in GUI @@ -195,8 +197,32 @@ async def asset(request): if 'previous' in request.query and ( 'seconds' in request.query or 'minutes' in request.query or 'hours' in request.query): _and_where = where_window(request, _where) - elif 'seconds' in request.query or 'minutes' in request.query or 'hours' in request.query: + elif 'mostrecent' not in request.query and ( + 'seconds' in request.query or 'minutes' in request.query or 'hours' in request.query): _and_where = where_clause(request, _where) + elif 'mostrecent' in request.query and 'seconds' in request.query: + if str(request.query['mostrecent']).lower() == 'true': + # To get latest reading for an asset's + asset_codes = additional_asset_codes if 'additional' in request.query else [asset_code] + _readings = connect.get_readings_async() + date_times = [] + dt_format = '%Y-%m-%d %H:%M:%S.%f' + for ac in asset_codes: + payload = PayloadBuilder().SELECT("user_ts").ALIAS("return", ("user_ts", "timestamp")).WHERE( + ["asset_code", "=", ac]).LIMIT(1).ORDER_BY(["user_ts", "desc"]).payload() + results = await _readings.query(payload) + response = results['rows'] + if response and 'timestamp' in response[0]: + date_times.append(datetime.datetime.strptime(response[0]['timestamp'], dt_format)) + most_recent_ts = max(date_times) + # _logger.debug("DTS: {} most_recent_ts: {}".format(date_times, most_recent_ts)) + window = int(request.query['seconds']) + to_ts = most_recent_ts - datetime.timedelta(seconds=window) + most_recent_str = most_recent_ts.strftime(dt_format) + to_str = to_ts.strftime(dt_format) + # _logger.debug("user_ts <={} TO user_ts>{}".format(most_recent_str, to_str)) + _and_where = PayloadBuilder(_where).AND_WHERE(['user_ts', '<=', most_recent_str]).AND_WHERE( + ['user_ts', '>', to_str]).chain_payload() elif 'previous' in request.query: msg = "the parameter previous can only be given if one of seconds, minutes or hours is also given" raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) @@ -211,7 +237,6 @@ async def asset(request): if _order not in ('asc', 'desc'): msg = "order must be asc or desc" raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) - payload = PayloadBuilder(_and_where).ORDER_BY(["user_ts", _order]).payload() try: _readings = connect.get_readings_async() From c12ef966bef1105237ae7872f996f50d23b2f559 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 4 Jul 2023 11:06:33 +0100 Subject: [PATCH 326/499] FOGL-7658 Add source information for control requests from the north (#1073) * FOGL-7658 Add source information for control requests from the north service Signed-off-by: Mark Riddoch * FOGL-7658 Fix passing of set point value Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/services/north/include/north_service.h | 1 + C/services/north/north.cpp | 28 +++++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/C/services/north/include/north_service.h b/C/services/north/include/north_service.h index 77581d670f..ca4bdde943 100644 --- a/C/services/north/include/north_service.h +++ b/C/services/north/include/north_service.h @@ -59,6 +59,7 @@ class NorthService : public ServiceAuthHandler { void createConfigCategories(DefaultConfigCategory configCategory, std::string parent_name,std::string current_name); void restartPlugin(); private: + std::string controlSource(); bool sendToService(const std::string& southService, const std::string& name, const std::string& value); bool sendToDispatcher(const std::string& path, const std::string& payload); DataLoad *m_dataLoad; diff --git a/C/services/north/north.cpp b/C/services/north/north.cpp index 7c847a1b82..5d0df459ae 100755 --- a/C/services/north/north.cpp +++ b/C/services/north/north.cpp @@ -882,7 +882,8 @@ bool NorthService::write(const string& name, const string& value, const ControlD } // Build payload for dispatcher service string payload = "{ \"destination\" : \"broadcast\","; - payload += "\"write\" : { \""; + payload += controlSource(); + payload += ", \"write\" : { \""; payload += name; payload += "\" : \""; string escaped = value; @@ -927,10 +928,12 @@ bool NorthService::write(const string& name, const string& value, const ControlD payload += "broadcast\""; break; } + payload += ", "; + payload += controlSource(); payload += ", \"write\" : { \""; payload += name; payload += "\" : \""; - string escaped = name; + string escaped = value; StringEscapeQuotes(escaped); payload += escaped; payload += "\" } }"; @@ -958,7 +961,8 @@ int NorthService::operation(const string& name, int paramCount, char *names[], } // Build payload for dispatcher service string payload = "{ \"destination\" : \"broadcast\","; - payload += "\"operation\" : { \""; + payload += controlSource(); + payload += ", \"operation\" : { \""; payload += name; payload += "\" : { "; for (int i = 0; i < paramCount; i++) @@ -1016,6 +1020,8 @@ int NorthService::operation(const string& name, int paramCount, char *names[], c payload += "broadcast\""; break; } + payload += ", "; + payload += controlSource(); payload += ", \"operation\" : { \""; payload += name; payload += "\" : { "; @@ -1094,6 +1100,7 @@ bool NorthService::sendToService(const string& southService, const string& name, */ bool NorthService::sendToDispatcher(const string& path, const string& payload) { + Logger::getLogger()->debug("Dispatch %s with %s", path.c_str(), payload.c_str()); // Send the control message to the south service try { ServiceRecord service("dispatcher"); @@ -1141,3 +1148,18 @@ bool NorthService::sendToDispatcher(const string& path, const string& payload) } } + +/** + * Return the control source for control operations. This is used + * for pipeline matching. + * + * @return string The control source + */ +string NorthService::controlSource() +{ + string source = "\"source\" : \"service\", \"source_name\" : \""; + source += m_name; + source += "\""; + + return source; +} From 2407fcd4de27523239962e57c0bc785ede942f93 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 6 Jul 2023 08:36:00 +0000 Subject: [PATCH 327/499] FOGL-7936 Add links in plugin list documentation Signed-off-by: Mark Riddoch --- docs/scripts/fledge_plugin_list | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/scripts/fledge_plugin_list b/docs/scripts/fledge_plugin_list index b90cabc1ff..703e02ec1e 100755 --- a/docs/scripts/fledge_plugin_list +++ b/docs/scripts/fledge_plugin_list @@ -58,7 +58,7 @@ function table { if [[ ${is_branch_exists} -gt 0 ]]; then description=$(echo "$fledgeRepos" | python3 -c 'import json,sys;repos=json.load(sys.stdin);fRepo = [r for r in repos if r["name"] == "'"${repo}"'" ];print(fRepo[0]["description"])') if [[ "${description}" = "None" ]]; then description="A ${name} ${type} plugin"; fi - echo " * - ${name}" >> "$output" + echo " * - |${repo}|" >> "$output" echo " - ${description}" >> "$output" fi fi @@ -67,9 +67,23 @@ function table { echo " * - OMF" >> "$output" echo " - Send data to OSIsoft PI Server, Edge Data Store or OSIsoft Cloud Services" >> "$output" fi +} + +function links { + list="$1" + for repo in ${list} + do + type=$(echo "${repo}" | sed -e 's/fledge-//' -e 's/-.*//') + name=$(echo "${repo}" | sed -e 's/fledge-//' -e "s/${type}-//") + echo ".. |${repo}| raw:: html" >> $output + echo "" >> $output + echo " $name" >> $output + echo "" >> $output + done echo "" >> "$output" } +links "$REPOSITORIES" cat >> $output << EOF1 Fledge Plugins ============== From 258cef3a79ba26ea59081aea214c39925969cf0f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 6 Jul 2023 17:54:44 +0530 Subject: [PATCH 328/499] Plugins list now link to detailed documentation Signed-off-by: ashish-jabble --- docs/scripts/fledge_plugin_list | 37 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/docs/scripts/fledge_plugin_list b/docs/scripts/fledge_plugin_list index 703e02ec1e..1e49e9a741 100755 --- a/docs/scripts/fledge_plugin_list +++ b/docs/scripts/fledge_plugin_list @@ -50,17 +50,31 @@ function table { echo " - Description" >> "$output" for repo in ${list} do - product=$(echo "$repo}" | sed -e 's/-.*//') type=$(echo "${repo}" | sed -e 's/fledge-//' -e 's/-.*//') name=$(echo "${repo}" | sed -e 's/fledge-//' -e "s/${type}-//") if [[ ${type} = "$tableType" ]]; then - is_branch_exists=$(git ls-remote -h https://${USERNAME}:${GITHUB_ACCESS_TOKEN}@github.com/fledge-iot/${repo}.git ${DOCBRANCH} | grep -c "refs/heads/${DOCBRANCH}") - if [[ ${is_branch_exists} -gt 0 ]]; then + rm -rf ${repo} + git clone https://${USERNAME}:${GITHUB_ACCESS_TOKEN}@github.com/fledge-iot/${repo}.git --branch ${DOCBRANCH} >/dev/null 2>&1 + is_branch_exists=$? + if [[ ${is_branch_exists} -eq 0 ]]; then description=$(echo "$fledgeRepos" | python3 -c 'import json,sys;repos=json.load(sys.stdin);fRepo = [r for r in repos if r["name"] == "'"${repo}"'" ];print(fRepo[0]["description"])') if [[ "${description}" = "None" ]]; then description="A ${name} ${type} plugin"; fi - echo " * - |${repo}|" >> "$output" + # cloned directory replaced with installed directory name which is defined in Package file for each repo + installed_plugin_dir_name=$(cat ${repo}/Package | grep plugin_install_dirname= | sed -e "s/plugin_install_dirname=//g") + if [[ $installed_plugin_dir_name == "\${plugin_name}" ]]; then + installed_plugin_dir_name=$(cat ${repo}/Package | grep plugin_name= | sed -e "s/plugin_name=//g") + fi + old_plugin_name=$(echo ${repo} | cut -d '-' -f3-) + new_plugin_name=$(echo ${repo/$old_plugin_name/$installed_plugin_dir_name}) + # Only link when doc exists in plugins directory + if [[ -d ${repo}/docs && -f ${repo}/docs/index.rst ]]; then + echo " * - \`$name <"plugins/$new_plugin_name/index.html">\`__" >> "$output" + else + echo " * - ${name}" >> "$output" + fi echo " - ${description}" >> "$output" fi + rm -rf ${repo} fi done if [[ ${tableType} = "north" ]]; then @@ -69,21 +83,6 @@ function table { fi } -function links { - list="$1" - for repo in ${list} - do - type=$(echo "${repo}" | sed -e 's/fledge-//' -e 's/-.*//') - name=$(echo "${repo}" | sed -e 's/fledge-//' -e "s/${type}-//") - echo ".. |${repo}| raw:: html" >> $output - echo "" >> $output - echo " $name" >> $output - echo "" >> $output - done - echo "" >> "$output" -} - -links "$REPOSITORIES" cat >> $output << EOF1 Fledge Plugins ============== From 79a97800ae76a9f0e9bd756686242eda00ee5d88 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 6 Jul 2023 15:43:39 +0100 Subject: [PATCH 329/499] Update Makefile (#1105) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c03632f2ac..3ce471339c 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ ifneq ("$(PLATFORM_RH)","") else PIP_INSTALL_REQUIREMENTS := python3 -m pip install -Ir PYTHON_BUILD_PACKAGE = python3 setup.py build -b ../$(PYTHON_BUILD_DIR) - CMAKE := cmake -DCMAKE_BUILD_TYPE=Debug + CMAKE := cmake endif MKDIR_PATH := mkdir -p From df434ee52fd5a41996260c65fa772e39bdee1da9 Mon Sep 17 00:00:00 2001 From: Himanshu Vimal <67678828+cyberwalk3r@users.noreply.github.com> Date: Mon, 10 Jul 2023 15:29:46 +0530 Subject: [PATCH 330/499] FOGL-7898: Readings being skipped when not sent by north plugin (#1102) Signed-off-by: Himanshu Vimal --- C/services/north/data_send.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/C/services/north/data_send.cpp b/C/services/north/data_send.cpp index b11489f92b..9fc48e39a8 100755 --- a/C/services/north/data_send.cpp +++ b/C/services/north/data_send.cpp @@ -84,11 +84,6 @@ void DataSender::sendThread() // Set readings removal removeReadings = vec->size() == 0; - } else { - // TODO: FOGL-7884: Reuse unsent readings without - // fetching again from storage. - // If failed to send reading, set readings removal to prevent memory leak - removeReadings = true; } } else { // All readings filtered out From 05201f5ae720482d7891069f4d60e027e318a800 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 10 Jul 2023 18:21:02 +0530 Subject: [PATCH 331/499] standalone filter pipeline category handling on deletion Signed-off-by: ashish-jabble --- .../services/core/api/control_service/pipeline.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index e2edd95455..4588023eb6 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -255,7 +255,7 @@ async def delete(request: web.Request) -> web.Response: storage = connect.get_storage_async() pipeline = await _get_pipeline(cpid) # Remove filters if exists and also delete the entry from control_filter table - await _remove_filters(storage, pipeline['filters'], cpid) + await _remove_filters(storage, pipeline['filters'], cpid, pipeline['name']) # Delete entry from control_pipelines payload = PayloadBuilder().WHERE(['cpid', '=', pipeline['id']]).payload() await storage.delete_from_tbl("control_pipelines", payload) @@ -533,7 +533,7 @@ async def get_notifications(): pass -async def _remove_filters(storage, filters, cp_id): +async def _remove_filters(storage, filters, cp_id, cp_name=None): cf_mgr = ConfigurationManager(storage) if filters: for f in filters: @@ -542,6 +542,8 @@ async def _remove_filters(storage, filters, cp_id): await storage.delete_from_tbl("control_filters", payload) # Delete the related category await cf_mgr.delete_category_and_children_recursively(f) + if cp_name is not None: + await cf_mgr.delete_category_and_children_recursively(f.split("ctrl_{}_".format(cp_name))[1]) async def _check_filters(storage, cp_filters): @@ -562,6 +564,7 @@ async def _check_filters(storage, cp_filters): async def _update_filters(storage, cp_id, cp_name, cp_filters): cf_mgr = ConfigurationManager(storage) new_filters = [] + children = [] if not cp_filters: return new_filters @@ -592,9 +595,11 @@ async def _update_filters(storage, cp_id, cp_name, cp_filters): payload = PayloadBuilder().INSERT(**column_names).payload() await storage.insert_into_tbl("control_filters", payload) new_filters.append(cat_name) + children.append(cat_name) + children.extend([fname]) try: # Create parent-child relation with Dispatcher service - await cf_mgr.create_child_category("dispatcher", new_filters) + await cf_mgr.create_child_category("dispatcher", children) except: pass return new_filters From bcfa869ec4f2280fccac55042155604b82a5a684 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Mon, 10 Jul 2023 14:27:37 +0100 Subject: [PATCH 332/499] FOGL-7844 Improve performance of asset tracker (#1087) * Initial changes - incomplete Signed-off-by: Mark Riddoch * Initial thread impolementation Signed-off-by: Mark Riddoch * Add direct database interface Signed-off-by: Mark Riddoch * FOGL-7844 fix cast for ARM compiler Signed-off-by: Mark Riddoch * Remove change pushed on wrong branch Signed-off-by: Mark Riddoch * FOGL-7874: Storage Asset tracker refactored (#1098) FOGL-7874: Storage Asset tracker refactored * Clear asset trackign caches on shutdown Signed-off-by: Mark Riddoch * Undeprecate all "store" events for given service and asset Undeprecate all "store" events for given service and asset * compilation fix compilation fix * Removed unused parameter Removed unused parameter * Memory growth improvements Memory growth improvements --------- Signed-off-by: Mark Riddoch Co-authored-by: pintomax --- C/common/asset_tracking.cpp | 655 +++++++++++++++++++++- C/common/include/asset_tracking.h | 190 ++++++- C/common/include/storage_asset_tracking.h | 121 ---- C/common/management_client.cpp | 23 +- C/common/storage_asset_tracking.cpp | 303 ---------- C/services/south/include/ingest.h | 7 +- C/services/south/include/south_service.h | 2 - C/services/south/ingest.cpp | 126 +++-- C/services/south/south.cpp | 9 +- 9 files changed, 933 insertions(+), 503 deletions(-) delete mode 100644 C/common/include/storage_asset_tracking.h delete mode 100644 C/common/storage_asset_tracking.cpp diff --git a/C/common/asset_tracking.cpp b/C/common/asset_tracking.cpp index 7cbe35c41c..adccb96ce0 100644 --- a/C/common/asset_tracking.cpp +++ b/C/common/asset_tracking.cpp @@ -5,17 +5,27 @@ * * Released under the Apache 2.0 Licence * - * Author: Amandeep Singh Arora + * Author: Amandeep Singh Arora, Massimiliano Pinto */ #include #include +#include using namespace std; AssetTracker *AssetTracker::instance = 0; +/** + * Worker thread entry point + */ +static void worker(void *arg) +{ + AssetTracker *tracker = (AssetTracker *)arg; + tracker->workerThread(); +} + /** * Get asset tracker singleton instance for the current south service * @@ -36,6 +46,75 @@ AssetTracker::AssetTracker(ManagementClient *mgtClient, string service) : m_mgtClient(mgtClient), m_service(service) { instance = this; + m_shutdown = false; + m_storageClient = NULL; + m_thread = new thread(worker, this); + + try { + // Find out the name of the fledge service + ConfigCategory category = mgtClient->getCategory("service"); + if (category.itemExists("name")) + { + m_fledgeName = category.getValue("name"); + } + } catch (exception& ex) { + Logger::getLogger()->error("Unable to fetch the service category, %s", ex.what()); + } + + try { + // Get a handle on the storage layer + ServiceRecord storageRecord("Fledge Storage"); + if (!m_mgtClient->getService(storageRecord)) + { + Logger::getLogger()->fatal("Unable to find storage service"); + return; + } + Logger::getLogger()->info("Connect to storage on %s:%d", + storageRecord.getAddress().c_str(), + storageRecord.getPort()); + + + m_storageClient = new StorageClient(storageRecord.getAddress(), + storageRecord.getPort()); + } catch (exception& ex) { + Logger::getLogger()->error("Failed to create storage client", ex.what()); + } + +} + +/** + * Destructor for the asset tracker. We must make sure any pending + * tuples are written out before the asset tracker is destroyed. + */ +AssetTracker::~AssetTracker() +{ + m_shutdown = true; + // Signal the worker thread to flush the queue + { + unique_lock lck(m_mutex); + m_cv.notify_all(); + } + while (m_pending.size()) + { + // Wait for pending queue to drain + this_thread::sleep_for(chrono::milliseconds(10)); + } + if (m_thread) + { + m_thread->join(); + delete m_thread; + m_thread = NULL; + } + + if (m_storageClient) + { + delete m_storageClient; + m_storageClient = NULL; + } + + assetTrackerTuplesCache.clear(); + + storageAssetTrackerTuplesCache.clear(); } /** @@ -52,7 +131,7 @@ void AssetTracker::populateAssetTrackingCache(string /*plugin*/, string /*event* std::vector& vec = m_mgtClient->getAssetTrackingTuples(m_service); for (AssetTrackingTuple* & rec : vec) { - assetTrackerTuplesCache.insert(rec); + assetTrackerTuplesCache.emplace(rec); } delete (&vec); } @@ -83,6 +162,12 @@ bool AssetTracker::checkAssetTrackingCache(AssetTrackingTuple& tuple) return true; } +/** + * Lookup tuple in the asset tracker cache + * + * @param tuple The tuple to lookup + * @return NULL if the tuple is not in the cache or the tuple from the cache + */ AssetTrackingTuple* AssetTracker::findAssetTrackingCache(AssetTrackingTuple& tuple) { AssetTrackingTuple *ptr = &tuple; @@ -107,15 +192,13 @@ void AssetTracker::addAssetTrackingTuple(AssetTrackingTuple& tuple) std::unordered_set::const_iterator it = assetTrackerTuplesCache.find(&tuple); if (it == assetTrackerTuplesCache.end()) { - bool rv = m_mgtClient->addAssetTrackingTuple(tuple.m_serviceName, tuple.m_pluginName, tuple.m_assetName, tuple.m_eventName); - if (rv) // insert into cache only if DB operation succeeded - { - AssetTrackingTuple *ptr = new AssetTrackingTuple(tuple); - assetTrackerTuplesCache.insert(ptr); - Logger::getLogger()->info("addAssetTrackingTuple(): Added tuple to cache: '%s'", tuple.assetToString().c_str()); - } - else - Logger::getLogger()->error("addAssetTrackingTuple(): Failed to insert asset tracking tuple into DB: '%s'", tuple.assetToString().c_str()); + AssetTrackingTuple *ptr = new AssetTrackingTuple(tuple); + + assetTrackerTuplesCache.emplace(ptr); + + queue(ptr); + + Logger::getLogger()->debug("addAssetTrackingTuple(): Added tuple to cache: '%s'", tuple.assetToString().c_str()); } } @@ -142,7 +225,7 @@ void AssetTracker::addAssetTrackingTuple(string plugin, string asset, string eve } /** - * Return the name of the service responsible for particulr event of the named asset + * Return the name of the service responsible for particular event of the named asset * * @param event The event of interest * @param asset The asset we are interested in @@ -236,3 +319,551 @@ void AssetTrackingTable::remove(const string& name) delete ret->second; // Free the tuple } } + +/** + * Queue an asset tuple for writing to the database. + */ +void AssetTracker::queue(TrackingTuple *tuple) +{ + unique_lock lck(m_mutex); + m_pending.emplace(tuple); + m_cv.notify_all(); +} + +/** + * The worker thread that will flush any pending asset tuples to + * the database. + */ +void AssetTracker::workerThread() +{ + unique_lock lck(m_mutex); + while (m_pending.empty() && m_shutdown == false) + { + m_cv.wait_for(lck, chrono::milliseconds(500)); + processQueue(); + } + // Process any items left in the queue at shutdown + processQueue(); +} + +/** + * Process the queue of asset tracking tuple + */ +void AssetTracker::processQueue() +{ +vector values; +static bool warned = false; + + while (!m_pending.empty()) + { + // Get first element as TrackingTuple calss + TrackingTuple *tuple = m_pending.front(); + + // Write the tuple - ideally we would like a bulk update here or to go direct to the + // database. However we need the Fledge service name for that, which is now in + // the member variable m_fledgeName + + bool warn = warned; + // Call class specialised processData routine: + // - 1 Insert asset tracker data via Fledge API as fallback + // or + // - get values for direct DB operation + + InsertValues iValue = tuple->processData(m_storageClient != NULL, + m_mgtClient, + warn, + m_fledgeName); + warned = warn; + + // Bulk DB insert when queue is empty + if (iValue.size() > 0) + { + values.push_back(iValue); + } + + // Remove element + m_pending.pop(); + } + + // Queue processed, bulk direct DB data insert could be done + if (m_storageClient && values.size() > 0) + { + // Bulk DB insert + int n_rows = m_storageClient->insertTable("asset_tracker", values); + if (n_rows != values.size()) + { + Logger::getLogger()->warn("The asset tracker failed to insert all records %d of %d inserted", + n_rows, values.size()); + } + } +} + +/** + * Fetch all storage asset tracking tuples from DB and populate local cache + * + * Return the vector of deprecated asset names + * + */ +void AssetTracker::populateStorageAssetTrackingCache() +{ + + try { + std::vector& vec = + (std::vector&) m_mgtClient->getStorageAssetTrackingTuples(m_service); + + for (StorageAssetTrackingTuple* & rec : vec) + { + set setOfDPs = getDataPointsSet(rec->m_datapoints); + if (setOfDPs.size() == 0) + { + Logger::getLogger()->warn("%s:%d Datapoints unavailable for service %s ", + __FUNCTION__, + __LINE__, + m_service.c_str()); + } + // Add item into cache + storageAssetTrackerTuplesCache.emplace(rec, setOfDPs); + } + delete (&vec); + } + catch (...) + { + Logger::getLogger()->error("%s:%d Failed to populate storage asset " \ + "tracking tuples' cache", + __FUNCTION__, + __LINE__); + return; + } + + return; +} + +//This function takes a string of datapoints in comma-separated format and returns +//set of string datapoint values +std::set AssetTracker::getDataPointsSet(std::string strDatapoints) +{ + std::set tokens; + stringstream st(strDatapoints); + std::string temp; + + while(getline(st, temp, ',')) + { + tokens.insert(temp); + } + + return tokens; +} + +/** + * Return Plugin Information in the Fledge configuration + * + * @return bool True if the plugin info could be obtained + */ +bool AssetTracker::getFledgeConfigInfo() +{ + Logger::getLogger()->error("StorageAssetTracker::getPluginInfo start"); + try { + string url = "/fledge/category/service"; + if (!m_mgtClient) + { + Logger::getLogger()->error("%s:%d, m_mgtClient Ptr is NULL", + __FUNCTION__, + __LINE__); + return false; + } + + auto res = m_mgtClient->getHttpClient()->request("GET", url.c_str()); + Document doc; + string response = res->content.string(); + doc.Parse(response.c_str()); + if (doc.HasParseError()) + { + bool httpError = (isdigit(response[0]) && + isdigit(response[1]) && + isdigit(response[2]) && + response[3]==':'); + Logger::getLogger()->error("%s fetching service record: %s\n", + httpError?"HTTP error while":"Failed to parse result of", + response.c_str()); + return false; + } + else if (doc.HasMember("message")) + { + Logger::getLogger()->error("Failed to fetch /fledge/category/service %s.", + doc["message"].GetString()); + return false; + } + else + { + Value& serviceName = doc["name"]; + if (!serviceName.IsObject()) + { + Logger::getLogger()->error("%s:%d, serviceName is not an object", + __FUNCTION__, + __LINE__); + return false; + } + + if (!serviceName.HasMember("value")) + { + Logger::getLogger()->error("%s:%d, serviceName has no member value", + __FUNCTION__, + __LINE__); + return false; + + } + Value& serviceVal = serviceName["value"]; + if ( !serviceVal.IsString()) + { + Logger::getLogger()->error("%s:%d, serviceVal is not a string", + __FUNCTION__, + __LINE__); + return false; + } + + m_fledgeName = serviceVal.GetString(); + Logger::getLogger()->error("%s:%d, m_plugin value = %s", + __FUNCTION__, + __LINE__, + m_fledgeName.c_str()); + return true; + } + + } catch (const SimpleWeb::system_error &e) { + Logger::getLogger()->error("Get service failed %s.", e.what()); + return false; + } + + return false; +} + +/** This function takes a StorageAssetTrackingTuple pointer and searches for + * it in cache, if found then returns its Deprecated status + * + * @param ptr StorageAssetTrackingTuple* , as key in cache (map) + * @return bool Deprecation status + */ +bool AssetTracker::getDeprecated(StorageAssetTrackingTuple* ptr) +{ + StorageAssetCacheMapItr it = storageAssetTrackerTuplesCache.find(ptr); + + if (it == storageAssetTrackerTuplesCache.end()) + { + Logger::getLogger()->debug("%s:%d :tuple not found in cache", + __FUNCTION__, + __LINE__); + return false; + } + else + { + return (it->first)->isDeprecated(); + } + + return false; +} + +/** + * Updates datapoints present in the arg dpSet in the cache + * + * @param dpSet set of datapoints string values to be updated in cache + * @param ptr StorageAssetTrackingTuple* , as key in cache (map) + * Retval void + */ + +void AssetTracker::updateCache(std::set dpSet, StorageAssetTrackingTuple* ptr) +{ + if(ptr == nullptr) + { + Logger::getLogger()->error("%s:%d: StorageAssetTrackingTuple should not be NULL pointer", + __FUNCTION__, + __LINE__); + return; + } + + StorageAssetCacheMapItr it = storageAssetTrackerTuplesCache.find(ptr); + // search for the record in cache , if not present, simply update cache and return + if (it == storageAssetTrackerTuplesCache.end()) + { + Logger::getLogger()->debug("%s:%d :tuple not found in cache '%s', ptr '%p'", + __FUNCTION__, + __LINE__, + ptr->assetToString().c_str(), + ptr); + + // Create new tuple, add it to processing queue and to cache + addStorageAssetTrackingTuple(*ptr, dpSet, true); + + return; + } + else + { + Logger::getLogger()->debug("%s:%d :tuple found in cache '%p', '%s': datapoints '%d'", + __FUNCTION__, + __LINE__, + (it->first), + (it->first)->assetToString().c_str(), + (it->second).size()); + + // record is found in cache , compare the datapoints of the argument ptr to that present in the cache + // update the cache with datapoints present in argument record but absent in cache + + std::set &cacheRecord = it->second; + unsigned int sizeOfCacheRecord = cacheRecord.size(); + + // store all the datapoints to be updated in string strDatapoints which is sent to management_client + std::string strDatapoints; + unsigned int count = 0; + for (auto itr : cacheRecord) + { + strDatapoints.append(itr); + strDatapoints.append(","); + count++; + } + + // check which datapoints are not present in cache record, and need to be updated + // in cache and db, store them in string strDatapoints, in comma-separated format + for(auto itr: dpSet) + { + if (cacheRecord.find(itr) == cacheRecord.end()) + { + strDatapoints.append(itr); + strDatapoints.append(","); + count++; + } + } + + // remove the last comma + if (strDatapoints[strDatapoints.size()-1] == ',') + { + strDatapoints.pop_back(); + } + + if (count <= sizeOfCacheRecord) + { + // No need to update as count of cache record is not getting increased + return; + } + + // Add current StorageAssetTrackingTuple to the process queue + addStorageAssetTrackingTuple(*(it->first), dpSet); + + // if update of DB successful , then update the CacheRecord + for(auto itr: dpSet) + { + if (cacheRecord.find(itr) == cacheRecord.end()) + { + cacheRecord.insert(itr); + } + } + } +} + +/** + * Add asset tracking tuple via microservice management API and in cache + * + * @param tuple New tuple to add to the queue + * @param dpSet Set of datapoints to handle + * @param addObj Create a new obj for cache and queue if true. + * Otherwise just add current tuple to processing queue. + */ +void AssetTracker::addStorageAssetTrackingTuple(StorageAssetTrackingTuple& tuple, + std::set& dpSet, + bool addObj) +{ + // Create a comma separated list of datapoints + std::string strDatapoints; + unsigned int count = 0; + for (auto itr : dpSet) + { + strDatapoints.append(itr); + strDatapoints.append(","); + count++; + } + if (strDatapoints[strDatapoints.size()-1] == ',') + { + strDatapoints.pop_back(); + } + + if (addObj) + { + // Create new tuple from input one + StorageAssetTrackingTuple *ptr = new StorageAssetTrackingTuple(tuple); + + // Add new tuple to storage asset cache + storageAssetTrackerTuplesCache.emplace(ptr, dpSet); + + // Add datapoints and count needed for data insert + ptr->m_datapoints = strDatapoints; + ptr->m_maxCount = count; + + // Add new tuple to processing queue + queue(ptr); + } + else + { + // Add datapoints and count needed for data insert + tuple.m_datapoints = strDatapoints; + tuple.m_maxCount = count; + + // Just add current tuple to processing queue + queue(&tuple); + } +} + +/** + * Insert AssetTrackingTuple data via Fledge core API + * or prepare InsertValues object for direct DB operation + * + * @param storage Boolean for storage being available + * @param mgtClient ManagementClient object pointer + * @param warned Boolean ireference updated for logging operation + * @param instanceName Fledge instance name + * @return InsertValues object + */ +InsertValues AssetTrackingTuple::processData(bool storage, + ManagementClient *mgtClient, + bool &warned, + string &instanceName) +{ + InsertValues iValue; + + // Write the tuple - ideally we would like a bulk update here or to go direct to the + // database. However we need the Fledge service name passed in instanceName + if (!storage) + { + // Fall back to using interface to the core + if (!warned) + { + Logger::getLogger()->warn("Asset tracker falling back to core API"); + } + warned = true; + + mgtClient->addAssetTrackingTuple(m_serviceName, + m_pluginName, + m_assetName, + m_eventName); + } + else + { + iValue.push_back(InsertValue("asset", m_assetName)); + iValue.push_back(InsertValue("event", m_eventName)); + iValue.push_back(InsertValue("service", m_serviceName)); + iValue.push_back(InsertValue("fledge", instanceName)); + iValue.push_back(InsertValue("plugin", m_pluginName)); + } + + return iValue; +} + +/** + * Insert StorageAssetTrackingTuple data via Fledge core API + * or prepare InsertValues object for direct DB operation + * + * @param storage Boolean for storage being available + * @param mgtClient ManagementClient object pointer + * @param warned Boolean ireference updated for logging operation + * @param instanceName Fledge instance name + * @return InsertValues object + */ +InsertValues StorageAssetTrackingTuple::processData(bool storage, + ManagementClient *mgtClient, + bool &warned, + string &instanceName) +{ + InsertValues iValue; + + // Write the tuple - ideally we would like a bulk update here or to go direct to the + // database. However we need the Fledge service name for that, which is now in + // the member variable m_fledgeName + if (!storage) + { + // Fall back to using interface to the core + if (!warned) + { + Logger::getLogger()->warn("Storage Asset tracker falling back to core API"); + } + warned = true; + + // Insert tuple via Fledge core API + mgtClient->addStorageAssetTrackingTuple(m_serviceName, + m_pluginName, + m_assetName, + m_eventName, + false, + m_datapoints, + m_maxCount); + } + else + { + iValue.push_back(InsertValue("asset", m_assetName)); + iValue.push_back(InsertValue("event", m_eventName)); + iValue.push_back(InsertValue("service", m_serviceName)); + iValue.push_back(InsertValue("fledge", instanceName)); + iValue.push_back(InsertValue("plugin", m_pluginName)); + + // prepare JSON datapoints + string datapoints = "\""; + for ( int i = 0; i < m_datapoints.size(); ++i) + { + if (m_datapoints[i] == ',') + { + datapoints.append("\",\""); + } + else + { + datapoints.append(1,m_datapoints[i]); + } + } + datapoints.append("\""); + + Document doc; + string jsonData = "{\"count\": " + + std::to_string(m_maxCount) + + ", \"datapoints\": [" + + datapoints + "]}"; + doc.Parse(jsonData.c_str()); + iValue.push_back(InsertValue("data", doc)); + } + + return iValue; +} + +/** + * Check if a StorageAssetTrackingTuple is in cache + * + * @param tuple The StorageAssetTrackingTuple to find + * @return Pointer to found tuple or NULL + */ +StorageAssetTrackingTuple* AssetTracker::findStorageAssetTrackingCache(StorageAssetTrackingTuple& tuple) +{ + StorageAssetCacheMapItr it = storageAssetTrackerTuplesCache.find(&tuple); + + if (it == storageAssetTrackerTuplesCache.end()) + { + return NULL; + } + else + { + return it->first; + } +} + +/** + * Get stored value in the StorageAssetTrackingTuple cache for the given tuple + * + * @param tuple The StorageAssetTrackingTuple to find + * @return Pointer to found std::set result or NULL if tuble does not exist + */ +std::set* AssetTracker::getStorageAssetTrackingCacheData(StorageAssetTrackingTuple* tuple) +{ + StorageAssetCacheMapItr it = storageAssetTrackerTuplesCache.find(tuple); + + if (it == storageAssetTrackerTuplesCache.end()) + { + return NULL; + } + else + { + return &(it->second); + } +} diff --git a/C/common/include/asset_tracking.h b/C/common/include/asset_tracking.h index 1374d2d787..3629eda315 100644 --- a/C/common/include/asset_tracking.h +++ b/C/common/include/asset_tracking.h @@ -7,13 +7,34 @@ * * Released under the Apache 2.0 Licence * - * Author: Amandeep Singh Arora + * Author: Amandeep Singh Arora, Massimiliano Pinto */ #include #include +#include #include #include #include +#include +#include +#include +#include +#include + +/** + * Tracking abstract base class to be passed in the process data queue + */ +class TrackingTuple { +public: + TrackingTuple() {}; + virtual ~TrackingTuple() = default; + virtual InsertValues processData(bool storage_connected, + ManagementClient *mgtClient, + bool &warned, + std::string &instanceName) = 0; + virtual std::string assetToString() = 0; +}; + /** * The AssetTrackingTuple class is used to represent an asset @@ -21,15 +42,10 @@ * this class and pointer to this class that would be required * to create an unordered_set of this class. */ -class AssetTrackingTuple { +class AssetTrackingTuple : public TrackingTuple { public: - std::string m_serviceName; - std::string m_pluginName; - std::string m_assetName; - std::string m_eventName; - - std::string assetToString() + std::string assetToString() { std::ostringstream o; o << "service:" << m_serviceName << @@ -46,7 +62,7 @@ class AssetTrackingTuple { x.m_pluginName==m_pluginName && x.m_assetName==m_assetName && x.m_eventName==m_eventName); - } + }; AssetTrackingTuple(const std::string& service, const std::string& plugin, @@ -57,18 +73,28 @@ class AssetTrackingTuple { m_pluginName(plugin), m_assetName(asset), m_eventName(event), - m_deprecated(deprecated) - {} + m_deprecated(deprecated) {} - std::string& getAssetName() { return m_assetName; }; + std::string &getAssetName() { return m_assetName; }; std::string getPluginName() { return m_pluginName;} std::string getEventName() { return m_eventName;} std::string getServiceName() { return m_serviceName;} bool isDeprecated() { return m_deprecated; }; void unDeprecate() { m_deprecated = false; }; + InsertValues processData(bool storage_connected, + ManagementClient *mgtClient, + bool &warned, + std::string &instanceName); + +public: + std::string m_serviceName; + std::string m_pluginName; + std::string m_assetName; + std::string m_eventName; + private: - bool m_deprecated; + bool m_deprecated; }; struct AssetTrackingTuplePtrEqual { @@ -98,6 +124,108 @@ namespace std }; } +class StorageAssetTrackingTuple : public TrackingTuple { +public: + StorageAssetTrackingTuple(const std::string& service, + const std::string& plugin, + const std::string& asset, + const std::string& event, + const bool& deprecated = false, + const std::string& datapoints = "", + unsigned int c = 0) : m_serviceName(service), + m_pluginName(plugin), + m_assetName(asset), + m_eventName(event), + m_deprecated(deprecated), + m_datapoints(datapoints), + m_maxCount(c) {}; + + inline bool operator==(const StorageAssetTrackingTuple& x) const + { + return ( x.m_serviceName==m_serviceName && + x.m_pluginName==m_pluginName && + x.m_assetName==m_assetName && + x.m_eventName==m_eventName); + }; + std::string assetToString() + { + std::ostringstream o; + o << "service:" << m_serviceName << + ", plugin:" << m_pluginName << + ", asset:" << m_assetName << + ", event:" << m_eventName << + ", deprecated:" << m_deprecated << + ", m_datapoints:" << m_datapoints << + ", m_maxCount:" << m_maxCount; + return o.str(); + }; + + bool isDeprecated() { return m_deprecated; }; + + unsigned int getMaxCount() { return m_maxCount; } + std::string getDataPoints() { return m_datapoints; } + void unDeprecate() { m_deprecated = false; }; + void setDeprecate() { m_deprecated = true; }; + + InsertValues processData(bool storage, + ManagementClient *mgtClient, + bool &warned, + std::string &instanceName); + +public: + std::string m_datapoints; + unsigned int m_maxCount; + std::string m_serviceName; + std::string m_pluginName; + std::string m_assetName; + std::string m_eventName; + +private: + bool m_deprecated; +}; + +struct StorageAssetTrackingTuplePtrEqual { + bool operator()(StorageAssetTrackingTuple const* a, StorageAssetTrackingTuple const* b) const { + return *a == *b; + } +}; + +namespace std +{ + template <> + struct hash + { + size_t operator()(const StorageAssetTrackingTuple& t) const + { + return (std::hash()(t.m_serviceName + + t.m_pluginName + + t.m_assetName + + t.m_eventName)); + } + }; + + template <> + struct hash + { + size_t operator()(StorageAssetTrackingTuple* t) const + { + return (std::hash()(t->m_serviceName + + t->m_pluginName + + t->m_assetName + + t->m_eventName)); + } + }; +} + +typedef std::unordered_map, + std::hash, + StorageAssetTrackingTuplePtrEqual> StorageAssetCacheMap; +typedef std::unordered_map, + std::hash, + StorageAssetTrackingTuplePtrEqual>::iterator StorageAssetCacheMapItr; + class ManagementClient; /** @@ -109,14 +237,20 @@ class AssetTracker { public: AssetTracker(ManagementClient *mgtClient, std::string service); - ~AssetTracker() {} + ~AssetTracker(); static AssetTracker *getAssetTracker(); void populateAssetTrackingCache(std::string plugin, std::string event); + void populateStorageAssetTrackingCache(); bool checkAssetTrackingCache(AssetTrackingTuple& tuple); AssetTrackingTuple* findAssetTrackingCache(AssetTrackingTuple& tuple); void addAssetTrackingTuple(AssetTrackingTuple& tuple); void addAssetTrackingTuple(std::string plugin, std::string asset, std::string event); + void addStorageAssetTrackingTuple(StorageAssetTrackingTuple& tuple, + std::set& dpSet, + bool addObj = false); + StorageAssetTrackingTuple* + findStorageAssetTrackingCache(StorageAssetTrackingTuple& tuple); std::string getIngestService(const std::string& asset) { @@ -127,16 +261,36 @@ class AssetTracker { { return getService("Egress", asset); }; + void workerThread(); + + bool getDeprecated(StorageAssetTrackingTuple* ptr); + void updateCache(std::set dpSet, StorageAssetTrackingTuple* ptr); + std::set + *getStorageAssetTrackingCacheData(StorageAssetTrackingTuple* tuple); private: std::string getService(const std::string& event, const std::string& asset); + void queue(TrackingTuple *tuple); + void processQueue(); + std::set + getDataPointsSet(std::string strDatapoints); + bool getFledgeConfigInfo(); private: - static AssetTracker *instance; - ManagementClient *m_mgtClient; - std::string m_service; - std::unordered_set, AssetTrackingTuplePtrEqual> assetTrackerTuplesCache; + static AssetTracker *instance; + ManagementClient *m_mgtClient; + std::string m_service; + std::unordered_set, AssetTrackingTuplePtrEqual> + assetTrackerTuplesCache; + std::queue m_pending; // Tuples that are not yet written to the storage + std::thread *m_thread; + bool m_shutdown; + std::condition_variable m_cv; + std::mutex m_mutex; + std::string m_fledgeName; + StorageClient *m_storageClient; + StorageAssetCacheMap storageAssetTrackerTuplesCache; }; /** diff --git a/C/common/include/storage_asset_tracking.h b/C/common/include/storage_asset_tracking.h deleted file mode 100644 index 1fe2dc4695..0000000000 --- a/C/common/include/storage_asset_tracking.h +++ /dev/null @@ -1,121 +0,0 @@ -#ifndef _STORAGE_ASSET_TRACKING_H -#define _STORAGE_ASSET_TRACKING_H -/* - * Fledge storage asset tracking related - * - * Copyright (c) 2022 Dianomic Systems - * - * Released under the Apache 2.0 Licence - * - * Author: Ashwini Sinha - */ -#include -#include -#include -#include -#include -#include -#include - - -/** - * The StorageAssetTrackingTuple class is used to represent ai storage asset - * tracking tuple. Hash function and '==' operator are defined for - * this class and pointer to this class that would be required - * to create an unordered_set of this class. - */ - -class StorageAssetTrackingTuple : public AssetTrackingTuple { - -public: - std::string m_datapoints; - unsigned int m_maxCount; - - std::string assetToString() - { - std::ostringstream o; - o << AssetTrackingTuple::assetToString() << ", m_datapoints:" << m_datapoints << ", m_maxCount:" << m_maxCount; - return o.str(); - } - - unsigned int getMaxCount() { return m_maxCount; } - std::string getDataPoints() { return m_datapoints; } - - StorageAssetTrackingTuple(const std::string& service, - const std::string& plugin, - const std::string& asset, - const std::string& event, - const bool& deprecated = false, - const std::string& datapoints = "", - unsigned int c = 0) : - AssetTrackingTuple(service, plugin, asset, event, deprecated), m_datapoints(datapoints), m_maxCount(c) - {} - -private: -}; - -struct StorageAssetTrackingTuplePtrEqual { - bool operator()(StorageAssetTrackingTuple const* a, StorageAssetTrackingTuple const* b) const { - return *a == *b; - } -}; - -namespace std -{ - template <> - struct hash - { - size_t operator()(const StorageAssetTrackingTuple& t) const - { - return (std::hash()(t.m_serviceName + t.m_pluginName + t.m_assetName + t.m_eventName)); - } - }; - - template <> - struct hash - { - size_t operator()(StorageAssetTrackingTuple* t) const - { - return (std::hash()(t->m_serviceName + t->m_pluginName + t->m_assetName + t->m_eventName)); - } - }; -} - -class ManagementClient; - -typedef std::unordered_map, std::hash, StorageAssetTrackingTuplePtrEqual> StorageAssetCacheMap; - -typedef std::unordered_map, std::hash, StorageAssetTrackingTuplePtrEqual>::iterator StorageAssetCacheMapItr; - -/** - * The StorageAssetTracker class provides the asset tracking functionality. - * There are methods to populate asset tracking cache from asset_tracker DB table, - * and methods to check/add asset tracking tuples to DB and to cache - */ -class StorageAssetTracker { - -public: - StorageAssetTracker(ManagementClient *mgtClient, std::string m_service); - ~StorageAssetTracker() {} - void populateStorageAssetTrackingCache(); - StorageAssetTrackingTuple* - findStorageAssetTrackingCache(StorageAssetTrackingTuple& tuple); - void addStorageAssetTrackingTuple(StorageAssetTrackingTuple& tuple); - bool getFledgeConfigInfo(); - static StorageAssetTracker *getStorageAssetTracker(); - static void releaseStorageAssetTracker(); - void updateCache(std::set dpSet, StorageAssetTrackingTuple* ptr); - bool getDeprecated(StorageAssetTrackingTuple* ptr); - -private: - static StorageAssetTracker *instance; - ManagementClient *m_mgtClient; - std::string m_fledgeService; - std::string m_service; - std::string m_event; - std::set getDataPointsSet(std::string strDatapoints); - - StorageAssetCacheMap storageAssetTrackerTuplesCache; -}; - -#endif diff --git a/C/common/management_client.cpp b/C/common/management_client.cpp index fc4133c12b..55c0ebfa79 100644 --- a/C/common/management_client.cpp +++ b/C/common/management_client.cpp @@ -13,7 +13,6 @@ #include #include #include -#include #include #include #include @@ -827,6 +826,12 @@ std::vector& ManagementClient::getAssetTrackingTuples(const throw runtime_error("Expected asset tracker tuple to be an object"); } + // Do not load "store" events as they bill be loaded by getStorageAssetTrackingTuples() + if (rec["event"].GetString() == "store") + { + continue; + } + // Note: deprecatedTimestamp NULL value is returned as "" // otherwise it's a string DATE bool deprecated = rec.HasMember("deprecatedTimestamp") && @@ -1641,7 +1646,9 @@ ACL ManagementClient::getACL(const string& aclName) */ StorageAssetTrackingTuple* ManagementClient::getStorageAssetTrackingTuple(const std::string& serviceName, const std::string& assetName, - const std::string& event, const std::string& dp, const unsigned int& c) + const std::string& event, + const std::string& dp, + const unsigned int& c) { StorageAssetTrackingTuple* tuple = NULL; @@ -1749,7 +1756,9 @@ StorageAssetTrackingTuple* ManagementClient::getStorageAssetTrackingTuple(const if(validateDatapoints(dp,datapoints)) { //datapoints in db not same as in arg, continue - m_logger->debug("%s:%d :Datapoints in db not same as in arg",__FUNCTION__, __LINE__); + m_logger->debug("%s:%d :Datapoints in db not same as in arg", + __FUNCTION__, + __LINE__); continue; } @@ -1766,7 +1775,9 @@ StorageAssetTrackingTuple* ManagementClient::getStorageAssetTrackingTuple(const if ( count != c) { // count not same, continue - m_logger->debug("%s:%d :count in db not same as received in arg", __FUNCTION__, __LINE__); + m_logger->debug("%s:%d :count in db not same as received in arg", + __FUNCTION__, + __LINE__); continue; } @@ -1775,7 +1786,9 @@ StorageAssetTrackingTuple* ManagementClient::getStorageAssetTrackingTuple(const rec["plugin"].GetString(), rec["asset"].GetString(), rec["event"].GetString(), - deprecated, datapoints, count); + deprecated, + datapoints, + count); m_logger->debug("%s:%d : Adding StorageAssetTracker tuple for service %s: %s:%s:%s, " \ "deprecated state is %d, datapoints %s , count %d",__FUNCTION__, __LINE__, diff --git a/C/common/storage_asset_tracking.cpp b/C/common/storage_asset_tracking.cpp deleted file mode 100644 index 3ba3ad0238..0000000000 --- a/C/common/storage_asset_tracking.cpp +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Fledge Storage asset tracking related - * - * Copyright (c) 2022 Dianomic Systems - * - * Released under the Apache 2.0 Licence - * - * Author: Ashwini Sinha - */ - -#include -#include - -using namespace std; - -StorageAssetTracker *StorageAssetTracker::instance = 0; - -/** - * Get asset tracker singleton instance for the current south service - * - * @return Singleton asset tracker instance - */ -StorageAssetTracker *StorageAssetTracker::getStorageAssetTracker() -{ - return instance; -} - -/** - * Release the storage asset tracker singleton instance - * - * @return void - */ - -void StorageAssetTracker::releaseStorageAssetTracker() -{ - if (instance) - delete instance; - instance = nullptr; -} - - -/** - * AssetTracker class constructor - * - * @param mgtClient Management client object for this south service - * @param service Service name - */ -StorageAssetTracker::StorageAssetTracker(ManagementClient *mgtClient, std::string service) - : m_mgtClient(mgtClient), m_service(service), m_event("store") -{ - instance = this; -} - -/** - * Fetch all storage asset tracking tuples from DB and populate local cache - * - * Return the vector of deprecated asset names - * - */ -void StorageAssetTracker::populateStorageAssetTrackingCache() -{ - - try { - std::vector& vec = m_mgtClient->getStorageAssetTrackingTuples(m_service); - - for (StorageAssetTrackingTuple* & rec : vec) - { - set setOfDPs = getDataPointsSet(rec->m_datapoints); - if (setOfDPs.size() == 0) - { - Logger::getLogger()->warn("%s:%d Datapoints unavailable for service %s ", __FUNCTION__, __LINE__, m_service.c_str()); - } - storageAssetTrackerTuplesCache[rec] = setOfDPs; - } - delete (&vec); - } - catch (...) - { - Logger::getLogger()->error("%s:%d Failed to populate storage asset tracking tuples' cache", __FUNCTION__, __LINE__); - return; - } - - return; -} - -/** - * Return Plugin Information in the Fledge configuration - * - * @return bool True if the plugin info could be obtained - */ -bool StorageAssetTracker::getFledgeConfigInfo() -{ - Logger::getLogger()->error("StorageAssetTracker::getPluginInfo start"); - try { - string url = "/fledge/category/service"; - if (!m_mgtClient) - { - Logger::getLogger()->error("%s:%d, m_mgtClient Ptr is NULL", __FUNCTION__, __LINE__); - return false; - } - - auto res = m_mgtClient->getHttpClient()->request("GET", url.c_str()); - Document doc; - string response = res->content.string(); - doc.Parse(response.c_str()); - if (doc.HasParseError()) - { - bool httpError = (isdigit(response[0]) && isdigit(response[1]) && isdigit(response[2]) && response[3]==':'); - Logger::getLogger()->error("%s fetching service record: %s\n", - httpError?"HTTP error while":"Failed to parse result of", - response.c_str()); - return false; - } - else if (doc.HasMember("message")) - { - Logger::getLogger()->error("Failed to fetch /fledge/category/service %s.", - doc["message"].GetString()); - return false; - } - else - { - Value& serviceName = doc["name"]; - if (!serviceName.IsObject()) - { - Logger::getLogger()->error("%s:%d, serviceName is not an object", __FUNCTION__, __LINE__); - return false; - } - - if (!serviceName.HasMember("value")) - { - Logger::getLogger()->error("%s:%d, serviceName has no member value", __FUNCTION__, __LINE__); - return false; - - } - Value& serviceVal = serviceName["value"]; - if ( !serviceVal.IsString()) - { - Logger::getLogger()->error("%s:%d, serviceVal is not a string", __FUNCTION__, __LINE__); - return false; - } - - m_fledgeService = serviceVal.GetString(); - Logger::getLogger()->error("%s:%d, m_plugin value = %s", __FUNCTION__, __LINE__, m_fledgeService.c_str()); - return true; - } - - } catch (const SimpleWeb::system_error &e) { - Logger::getLogger()->error("Get service failed %s.", e.what()); - return false; - } - return false; -} - -/** - * Updates datapoints present in the arg dpSet in the cache - * - * @param dpSet set of datapoints string values to be updated in cache - * @param ptr StorageAssetTrackingTuple* , as key in cache (map) - * Retval void - */ - -void StorageAssetTracker::updateCache(std::set dpSet, StorageAssetTrackingTuple* ptr) -{ - if(ptr == nullptr) - { - Logger::getLogger()->error("%s:%d: StorageAssetTrackingTuple should not be NULL pointer", __FUNCTION__, __LINE__); - return; - } - - unsigned int sizeOfInputSet = dpSet.size(); - StorageAssetCacheMapItr it = storageAssetTrackerTuplesCache.find(ptr); - - // search for the record in cache , if not present, simply update cache and return - if (it == storageAssetTrackerTuplesCache.end()) - { - Logger::getLogger()->debug("%s:%d :tuple not found in cache ", __FUNCTION__, __LINE__); - storageAssetTrackerTuplesCache[ptr] = dpSet; - - std::string strDatapoints; - unsigned int count = 0; - for (auto itr : dpSet) - { - strDatapoints.append(itr); - strDatapoints.append(","); - count++; - } - if (strDatapoints[strDatapoints.size()-1] == ',') - strDatapoints.pop_back(); - - bool rv = m_mgtClient->addStorageAssetTrackingTuple(ptr->getServiceName(), ptr->getPluginName(), ptr->getAssetName(), ptr->getEventName(), false, strDatapoints, count); - if (rv) - { - storageAssetTrackerTuplesCache[ptr] = dpSet; - } - else - Logger::getLogger()->error("%s:%d: Failed to insert storage asset tracking tuple into DB: '%s'", __FUNCTION__, __LINE__, (ptr->getAssetName()).c_str()); - - return; - } - else - { - // record is found in cache , compare the datapoints of the argument ptr to that present in the cache - // update the cache with datapoints present in argument record but absent in cache - // - std::set &cacheRecord = it->second; - unsigned int sizeOfCacheRecord = cacheRecord.size(); - - // store all the datapoints to be updated in string strDatapoints which is sent to management_client - std::string strDatapoints; - unsigned int count = 0; - for (auto itr : cacheRecord) - { - strDatapoints.append(itr); - strDatapoints.append(","); - count++; - } - - // check which datapoints are not present in cache record, and need to be updated - // in cache and db, store them in string strDatapoints, in comma-separated format - for(auto itr: dpSet) - { - if (cacheRecord.find(itr) == cacheRecord.end()) - { - strDatapoints.append(itr); - strDatapoints.append(","); - count++; - } - } - - // remove the last comma - if (strDatapoints[strDatapoints.size()-1] == ',') - strDatapoints.pop_back(); - - if (count <= sizeOfCacheRecord) - { - // No need to update as count of cache record is not getting increased - return; - } - - // Update the DB - bool rv = m_mgtClient->addStorageAssetTrackingTuple(ptr->getServiceName(), ptr->getPluginName(), ptr->getAssetName(), ptr->getEventName(), false, strDatapoints, count); - if(rv) - { - // if update of DB successful , then update the CacheRecord - for(auto itr: dpSet) - { - if (cacheRecord.find(itr) == cacheRecord.end()) - { - cacheRecord.insert(itr); - } - } - } - else - { - // Log error if Update DB unsuccessful - Logger::getLogger()->error("%s:%d: Failed to insert storage asset tracking tuple into DB: '%s'", __FUNCTION__, __LINE__, (ptr->getAssetName()).c_str()); - - } - } -} - -//This function takes a string of datapoints in comma-separated format and returns -//set of string datapoint values -std::set StorageAssetTracker::getDataPointsSet(std::string strDatapoints) -{ - - std::set tokens; - stringstream st(strDatapoints); - std::string temp; - - while(getline(st, temp, ',')) - { - tokens.insert(temp); - } - - return tokens; -} - - -/** This function takes a StorageAssetTrackingTuple pointer and searches for - * it in cache, if found then returns its Deprecated status - * - * @param ptr StorageAssetTrackingTuple* , as key in cache (map) - * Retval bool Deprecation status - */ - - -bool StorageAssetTracker::getDeprecated(StorageAssetTrackingTuple* ptr) -{ - StorageAssetCacheMapItr it = storageAssetTrackerTuplesCache.find(ptr); - - if (it == storageAssetTrackerTuplesCache.end()) - { - Logger::getLogger()->debug("%s:%d :tuple not found in cache ", __FUNCTION__, __LINE__); - return false; - } - else - { - return (it->first)->isDeprecated(); - } - - return false; -} diff --git a/C/services/south/include/ingest.h b/C/services/south/include/ingest.h index 201c755e91..bf50035bcf 100644 --- a/C/services/south/include/ingest.h +++ b/C/services/south/include/ingest.h @@ -85,15 +85,16 @@ class Ingest : public ServiceHandler { void unDeprecateAssetTrackingRecord(AssetTrackingTuple* currentTuple, const std::string& assetName, const std::string& event); - void unDeprecateStorageAssetTrackingRecord(StorageAssetTrackingTuple* currentTuple, - const std::string& assetName, const std::string&, const unsigned int&); + void unDeprecateStorageAssetTrackingRecord(StorageAssetTrackingTuple* currentTuple, + const std::string& assetName, + const std::string&, + const unsigned int&); void setStatistics(const std::string& option); std::string getStringFromSet(const std::set &dpSet); void setFlowControl(unsigned int lowWater, unsigned int highWater) { m_lowWater = lowWater; m_highWater = highWater; }; void flowControl(); - private: void signalStatsUpdate() { // Signal stats thread to update stats diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index bb23c5cac6..2be3a886d2 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -17,7 +17,6 @@ #include #include #include -#include #include #define MAX_SLEEP 5 // Maximum number of seconds the service will sleep during a poll cycle @@ -109,7 +108,6 @@ class SouthService : public ServiceAuthHandler { bool m_dryRun; bool m_requestRestart; std::string m_rateUnits; - StorageAssetTracker *m_storageAssetTracker; enum { POLL_INTERVAL, POLL_FIXED, POLL_ON_DEMAND } m_pollType; std::vector m_hours; diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 99a873dc7c..4d90108fc1 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include using namespace std; @@ -294,9 +293,10 @@ Ingest::Ingest(StorageClient& storage, m_discardedReadings = 0; m_highLatency = false; - // populate asset tracking cache - AssetTracker::getAssetTracker()->populateAssetTrackingCache(m_pluginName, "Ingest"); - StorageAssetTracker::getStorageAssetTracker()->populateStorageAssetTrackingCache(); + // populate asset and storage asset tracking cache + AssetTracker *as = AssetTracker::getAssetTracker(); + as->populateAssetTrackingCache(m_pluginName, "Ingest"); + as->populateStorageAssetTrackingCache(); // Create the stats entry for the service createServiceStatsDbEntry(); @@ -364,7 +364,8 @@ Ingest::~Ingest() delete m_filterPipeline; } - delete m_deprecated; + if (m_deprecated) + delete m_deprecated; } /** @@ -556,10 +557,10 @@ void Ingest::processQueue() m_failCnt = 0; std::map statsEntriesCurrQueue; AssetTracker *tracker = AssetTracker::getAssetTracker(); - StorageAssetTracker *satracker = StorageAssetTracker::getStorageAssetTracker(); - if ( satracker == nullptr) + if (tracker == nullptr) { - Logger::getLogger()->error("%s could not initialize satracker ", __FUNCTION__); + Logger::getLogger()->error("%s could not initialize asset tracker", + __FUNCTION__); return; } @@ -632,24 +633,30 @@ void Ingest::processQueue() } for (auto itr : assetDatapointMap) - { - std::set &s = itr.second; - unsigned int count = s.size(); - StorageAssetTrackingTuple storageTuple(m_serviceName,m_pluginName, itr.first, "store", false, "",count); - StorageAssetTrackingTuple *ptr = &storageTuple; - satracker->updateCache(s, ptr); - bool deprecated = satracker->getDeprecated(ptr); - if (deprecated == true) - { - unDeprecateStorageAssetTrackingRecord(ptr, itr.first, getStringFromSet(s), count); - } - } + { + std::set &s = itr.second; + unsigned int count = s.size(); + StorageAssetTrackingTuple storageTuple(m_serviceName, + m_pluginName, + itr.first, + "store", + false, + "", + count); + + StorageAssetTrackingTuple *ptr = &storageTuple; + + // Update SAsset Tracker database and cache + tracker->updateCache(s, ptr); + } delete q; m_resendQueues.erase(m_resendQueues.begin()); unique_lock lck(m_statsMutex); for (auto &it : statsEntriesCurrQueue) + { statsPendingEntries[it.first] += it.second; + } } } @@ -787,7 +794,6 @@ void Ingest::processQueue() // check if this requires addition of a new asset tracker tuple // Remove the Readings in the vector AssetTracker *tracker = AssetTracker::getAssetTracker(); - StorageAssetTracker *satracker = StorageAssetTracker::getStorageAssetTracker(); string lastAsset; int *lastStat = NULL; @@ -857,23 +863,30 @@ void Ingest::processQueue() } - for (auto itr : assetDatapointMap) - { - std::set &s = itr.second; + for (auto itr : assetDatapointMap) + { + std::set &s = itr.second; unsigned int count = s.size(); - StorageAssetTrackingTuple storageTuple(m_serviceName,m_pluginName, itr.first, "store", false, "",count); + StorageAssetTrackingTuple storageTuple(m_serviceName, + m_pluginName, + itr.first, + "store", + false, + "", + count); + StorageAssetTrackingTuple *ptr = &storageTuple; - satracker->updateCache(s, ptr); - bool deprecated = satracker->getDeprecated(ptr); - if (deprecated == true) - { - unDeprecateStorageAssetTrackingRecord(ptr, itr.first, getStringFromSet(s), count); - } - } + + // Update SAsset Tracker database and cache + tracker->updateCache(s, ptr); + } + { unique_lock lck(m_statsMutex); for (auto &it : statsEntriesCurrQueue) + { statsPendingEntries[it.first] += it.second; + } } } } @@ -1135,6 +1148,7 @@ void Ingest::unDeprecateAssetTrackingRecord(AssetTrackingTuple* currentTuple, assetName, event); + bool unDeprecateDataPoints = false; if (updatedTuple) { if (updatedTuple->isDeprecated()) @@ -1187,8 +1201,11 @@ void Ingest::unDeprecateAssetTrackingRecord(AssetTrackingTuple* currentTuple, " for un-deprecated asset '%s'", assetName.c_str()); } - m_logger->info("Asset '%s' has been un-deprecated", - assetName.c_str()); + m_logger->info("Asset '%s' has been un-deprecated, event '%s'", + assetName.c_str(), + event.c_str()); + + unDeprecateDataPoints = true; } } } @@ -1201,6 +1218,47 @@ void Ingest::unDeprecateAssetTrackingRecord(AssetTrackingTuple* currentTuple, } delete updatedTuple; + + // Undeprecate all "store" events related to the serviceName and assetName + if (unDeprecateDataPoints) + { + // Prepare UPDATE query + const Condition conditionParams(Equals); + Where * wAsset = new Where("asset", + conditionParams, + assetName); + Where *wService = new Where("service", + conditionParams, + m_serviceName, + wAsset); + Where *wEvent = new Where("event", + conditionParams, + "store", + wService); + + InsertValues unDeprecated; + + // Set NULL value + unDeprecated.push_back(InsertValue("deprecated_ts")); + + // Update storage with NULL value + int rv = m_storage.updateTable("asset_tracker", + unDeprecated, + *wEvent); + + // Check update operation + if (rv < 0) + { + m_logger->error("Failure while un-deprecating asset '%s'", + assetName.c_str()); + } + else + { + m_logger->info("Asset '%s' has been un-deprecated, event '%s'", + assetName.c_str(), + "store"); + } + } } /** diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 371fcb4609..0340f95ecf 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -394,7 +394,6 @@ void SouthService::start(string& coreAddress, unsigned short corePort) } m_assetTracker = new AssetTracker(m_mgtClient, m_name); - m_storageAssetTracker = new StorageAssetTracker(m_mgtClient, m_name); { // Instantiate the Ingest class @@ -690,10 +689,10 @@ void SouthService::start(string& coreAddress, unsigned short corePort) */ void SouthService::stop() { - if (m_storageAssetTracker) - { - m_storageAssetTracker->releaseStorageAssetTracker(); - } + delete m_assetTracker; + delete m_auditLogger; + delete m_mgtClient; + logger->info("Stopping south service...\n"); } From 2bf91bc23ded7d597e9a75f0f7b5aec498529a51 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 11 Jul 2023 17:17:22 +0530 Subject: [PATCH 333/499] http scheme always on packages remove/update Signed-off-by: ashish-jabble --- .../services/core/api/plugins/remove.py | 8 ++++---- .../services/core/api/plugins/update.py | 19 +++++++++++-------- python/fledge/services/core/api/service.py | 15 +++++++++------ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/python/fledge/services/core/api/plugins/remove.py b/python/fledge/services/core/api/plugins/remove.py index 14069f1717..16e87281cf 100644 --- a/python/fledge/services/core/api/plugins/remove.py +++ b/python/fledge/services/core/api/plugins/remove.py @@ -169,7 +169,7 @@ def _uninstall(pkg_name: str, version: str, uid: uuid, storage: connect) -> tupl loop.run_until_complete(storage.update_tbl("packages", payload)) if code == 0: # Clear internal cache - loop.run_until_complete(_put_refresh_cache(Server._host, Server.core_management_port)) + loop.run_until_complete(_put_refresh_cache("http", Server._host, Server.core_management_port)) # Audit logger audit = AuditLogger(storage) audit_detail = {'package_name': pkg_name, 'version': version} @@ -360,9 +360,9 @@ async def _check_plugin_usage_in_notification_instances(plugin_name: str) -> lis return notification_instances -async def _put_refresh_cache(host: int, port: int) -> None: +async def _put_refresh_cache(protocol: str, host: int, port: int) -> None: # Scheme is always http:// on core_management_port - management_api_url = 'http://{}:{}/fledge/cache'.format(host, port) + management_api_url = '{}://{}:{}/fledge/cache'.format(protocol, host, port) headers = {'content-type': 'application/json'} verify_ssl = False connector = aiohttp.TCPConnector(verify_ssl=verify_ssl) @@ -418,7 +418,7 @@ def purge_plugin(plugin_type: str, plugin_name: str, pkg_name: str, version: str if code == 0: # Clear internal cache - loop.run_until_complete(_put_refresh_cache(Server._host, Server.core_management_port)) + loop.run_until_complete(_put_refresh_cache("http", Server._host, Server.core_management_port)) # Audit info audit = AuditLogger(storage) audit_detail = {'package_name': pkg_name, 'version': version} diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index 91921bafb9..490c322466 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -148,9 +148,11 @@ async def update_package(request: web.Request) -> web.Response: if response: pn = "{}-{}".format(action, package_name) uid = response[0]['id'] - p = multiprocessing.Process(name=pn, target=do_update, args=( - server.Server._host, server.Server.core_management_port, storage_client, plugin_type, plugin_name, - package_name, uid, schedules, notifications)) + p = multiprocessing.Process(name=pn, + target=do_update, + args=("http", server.Server._host, + server.Server.core_management_port, storage_client, plugin_type, + plugin_name, package_name, uid, schedules, notifications)) p.daemon = True p.start() msg = "{} {} started.".format(package_name, action) @@ -290,7 +292,7 @@ async def update_plugin(request: web.Request) -> web.Response: uid = response[0]['id'] p = multiprocessing.Process(name=pn, target=do_update, - args=(server.Server.is_rest_server_http_enabled, + args=("http", server.Server._host, server.Server.core_management_port, storage_client, _type, name, package_name, uid, schedules, notifications)) @@ -339,9 +341,9 @@ async def _get_sch_id_and_enabled_by_name(name: str) -> list: return result['rows'] -async def _put_schedule(host: str, port: int, sch_id: uuid, is_enabled: bool) -> None: +async def _put_schedule(protocol: str, host: str, port: int, sch_id: uuid, is_enabled: bool) -> None: # Scheme is always http:// on core_management_port - management_api_url = 'http://{}:{}/fledge/schedule/{}/enable'.format(host, port, sch_id) + management_api_url = '{}://{}:{}/fledge/schedule/{}/enable'.format(protocol, host, port, sch_id) headers = {'content-type': 'application/json'} verify_ssl = False connector = aiohttp.TCPConnector(verify_ssl=verify_ssl) @@ -375,7 +377,7 @@ def _update_repo_sources_and_plugin(pkg_name: str) -> tuple: return ret_code, link -def do_update(host: str, port: int, storage: connect, _type: str, plugin_name: str, +def do_update(protocol: str, host: str, port: int, storage: connect, _type: str, plugin_name: str, pkg_name: str, uid: str, schedules: list, notifications: list) -> None: _logger.info("{} package update started...".format(pkg_name)) @@ -396,9 +398,10 @@ def do_update(host: str, port: int, storage: connect, _type: str, plugin_name: s audit_detail['version'] = version[0] loop.run_until_complete(audit.information('PKGUP', audit_detail)) _logger.info('{} package updated successfully.'.format(pkg_name)) + # Restart the services which were disabled before plugin update for sch in schedules: - loop.run_until_complete(_put_schedule(host, port, uuid.UUID(sch), True)) + loop.run_until_complete(_put_schedule(protocol, host, port, uuid.UUID(sch), True)) # Below case is applicable for the notification plugins ONLY # Enabled back configuration categories which were disabled during update process diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index d8d682c24c..e15490d48d 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -644,8 +644,11 @@ async def update_service(request: web.Request) -> web.Response: if result['response'] == "inserted" and result['rows_affected'] == 1: pn = "{}-{}".format(action, name) # Scheme is always http:// on core_management_port - p = multiprocessing.Process(name=pn, target=do_update, args=( - server.Server._host, server.Server.core_management_port, storage_client, package_name, uid, sch_list)) + p = multiprocessing.Process(name=pn, + target=do_update, + args=("http", server.Server._host, + server.Server.core_management_port, storage_client, package_name, + uid, sch_list)) p.daemon = True p.start() msg = "{} {} started".format(package_name, action) @@ -663,8 +666,8 @@ async def update_service(request: web.Request) -> web.Response: return web.json_response(result_payload) -async def _put_schedule(host: str, port: int, sch_id: uuid, is_enabled: bool) -> None: - management_api_url = 'http://{}:{}/fledge/schedule/{}/enable'.format(host, port, sch_id) +async def _put_schedule(protocol: str, host: str, port: int, sch_id: uuid, is_enabled: bool) -> None: + management_api_url = '{}://{}:{}/fledge/schedule/{}/enable'.format(protocol, host, port, sch_id) headers = {'content-type': 'application/json'} verify_ssl = False connector = aiohttp.TCPConnector(verify_ssl=verify_ssl) @@ -680,7 +683,7 @@ async def _put_schedule(host: str, port: int, sch_id: uuid, is_enabled: bool) -> _logger.debug("PUT Schedule response: %s", response) -def do_update(host: str, port: int, storage: connect, pkg_name: str, uid: str, schedules: list) -> None: +def do_update(protocol: str, host: str, port: int, storage: connect, pkg_name: str, uid: str, schedules: list) -> None: _logger.info("{} service update started...".format(pkg_name)) stdout_file_path = common.create_log_file("update", pkg_name) pkg_mgt = 'yum' if utils.is_redhat_based() else 'apt' @@ -712,7 +715,7 @@ def do_update(host: str, port: int, storage: connect, pkg_name: str, uid: str, s # Restart the service which was disabled before service update for sch in schedules: - loop.run_until_complete(_put_schedule(host, port, uuid.UUID(sch), True)) + loop.run_until_complete(_put_schedule(protocol, host, port, uuid.UUID(sch), True)) @has_permission("admin") From c1a2af1b2f73f294a076e427fb78d6f7056499cd Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 11 Jul 2023 22:58:31 +0530 Subject: [PATCH 334/499] OMF inbuilt plugin link documentation Signed-off-by: ashish-jabble --- docs/scripts/fledge_plugin_list | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scripts/fledge_plugin_list b/docs/scripts/fledge_plugin_list index 1e49e9a741..bd6aae3ff2 100755 --- a/docs/scripts/fledge_plugin_list +++ b/docs/scripts/fledge_plugin_list @@ -78,7 +78,7 @@ function table { fi done if [[ ${tableType} = "north" ]]; then - echo " * - OMF" >> "$output" + echo " * - \`OMF <"plugins/fledge-north-OMF/index.html">\`__" >> "$output" echo " - Send data to OSIsoft PI Server, Edge Data Store or OSIsoft Cloud Services" >> "$output" fi } From 6610a617ed9a9af9ebf720cfd0319dbf0a5d395f Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Wed, 12 Jul 2023 18:22:58 +0530 Subject: [PATCH 335/499] FOGL-7839 Improvements in memory_leakage test script (#1106) --- tests/system/memory_leak/config.sh | 3 ++ tests/system/memory_leak/scripts/setup | 22 +++++++------ tests/system/memory_leak/test_memcheck.sh | 38 +++++++++++++++++------ 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/tests/system/memory_leak/config.sh b/tests/system/memory_leak/config.sh index 21c7505ccb..83e3d50d9c 100644 --- a/tests/system/memory_leak/config.sh +++ b/tests/system/memory_leak/config.sh @@ -1,2 +1,5 @@ FLEDGE_URL="http://localhost:8081/fledge" TEST_RUN_TIME=3600 +PI_IP="localhost" +PI_USER="Administrator" +PI_PASSWORD="password" \ No newline at end of file diff --git a/tests/system/memory_leak/scripts/setup b/tests/system/memory_leak/scripts/setup index 8eac77d792..832f7a4e6c 100755 --- a/tests/system/memory_leak/scripts/setup +++ b/tests/system/memory_leak/scripts/setup @@ -17,6 +17,7 @@ echo "UNAME is "${UNAME} sudo apt -y install git # cloning fledge +echo "Cloning Fledge branch $BRANCH" git clone -b $BRANCH https://github.com/fledge-iot/fledge.git && cd fledge && chmod +x requirements.sh && sh -x requirements.sh ; echo 'Changing CMakelists' @@ -34,30 +35,32 @@ export FLEDGE_ROOT=`pwd` && cd ..; # modifying script echo 'fledge root path is set to ${FLEDGE_ROOT}' -valgrind_conf=' --tool=memcheck --fullpath-after= --xml=yes --log-file=\/tmp\/south_valgrind.log --child-silent-after-fork=no --leak-check=full --show-leak-kinds=all --track-origins=yes ' +valgrind_conf=' --tool=memcheck --leak-check=full --show-leak-kinds=all' psouth_c=${FLEDGE_ROOT}/scripts/services/south_c echo $psouth_c sudo sed -i 's#/usr/local/fledge#'"$FLEDGE_ROOT"'#g' ${psouth_c} -sudo sed -i '/.\/fledge.services.south.*/s/^/valgrind -v --xml-file=\/tmp\/south_valgrind_%p.xml '"$valgrind_conf"' /' ${psouth_c} +sudo sed -i '/.\/fledge.services.south.*/s/^/valgrind --log-file=\/tmp\/south_valgrind.log '"$valgrind_conf"' /' ${psouth_c} pnorth_C=${FLEDGE_ROOT}/scripts/services/north_C echo $pnorth_C sudo sed -i 's#/usr/local/fledge#'"$FLEDGE_ROOT"'#g' ${pnorth_C} -sudo sed -i '/.\/fledge.services.north.*/s/^/valgrind -v --xml-file=\/tmp\/north_valgrind_%p.xml '"$valgrind_conf"' /' ${pnorth_C} +sudo sed -i '/.\/fledge.services.north.*/s/^/valgrind --log-file=\/tmp\/north_valgrind.log '"$valgrind_conf"' /' ${pnorth_C} pstorage=${FLEDGE_ROOT}/scripts/services/storage echo $pstorage sudo sed -i 's#/usr/local/fledge#'"$FLEDGE_ROOT"'#g' ${pstorage} -sudo sed -i '/\${storageExec} \"\$@\"/s/^/valgrind -v --xml-file=\/tmp\/storage_valgrind_%p.xml '"$valgrind_conf"' /' ${pstorage} +sudo sed -i '/\${storageExec} \"\$@\"/s/^/valgrind --log-file=\/tmp\/storage_valgrind.log '"$valgrind_conf"' /' ${pstorage} # cloning plugins based on parameters passed to the script, Currently only installing sinusoid -for i in ${1} + +IFS=' ' read -ra plugin_list <<< "${1}" +for i in "${plugin_list[@]}" do echo $i git clone https://github.com/fledge-iot/${i}.git && cd ${i}; plugin_dir=`pwd` - # Cheking requirements.sh file exists or not, to install plugins dependencies + # Cheking requirements.sh file exists or not, to install plugins dependencies if [[ -f ${plugin_dir}/requirements.sh ]] then ./${plugin_dir}/requirements.sh @@ -69,7 +72,7 @@ do sed -i 's|c++11 -O3|c++11 -O0 -ggdb|g' ${plugin_dir}/CMakeLists.txt # building C based plugin echo 'Building C plugin' - mkdir -p build && cd build && cmake -DFLEDGE_INSTALL=${FLEDGE_ROOT} -DFLEDGE_ROOT=${FLEDGE_ROOT} .. && make && make install + mkdir -p build && cd build && cmake -DFLEDGE_INSTALL=${FLEDGE_ROOT} -DFLEDGE_ROOT=${FLEDGE_ROOT} .. && make && make install && cd .. else # Checking requirements.txt file exists or not, to install plugins dependencies (if any) if [[ -f ${plugin_dir}/requirements.txt ]] @@ -81,8 +84,7 @@ do sudo cp -r $plugin_dir/python $FLEDGE_ROOT/ echo 'Copied.' fi - cd ../.. + cd ../ done echo 'Current location - '; pwd; -echo 'End of setup' - +echo 'End of setup' \ No newline at end of file diff --git a/tests/system/memory_leak/test_memcheck.sh b/tests/system/memory_leak/test_memcheck.sh index 43d53d56b0..80afce0da1 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -7,18 +7,17 @@ source config.sh export FLEDGE_ROOT=$(pwd)/fledge -FLEDGE_TEST_BRANCH=$1 #here fledge_test_branch means branch of fledge repository that is needed to be scanned, default is devops +FLEDGE_TEST_BRANCH="$1" # here fledge_test_branch means branch of fledge repository that is needed to be scanned, default is develop cleanup(){ # Removing temporary files, fledge and its plugin repository cloned by previous build of the Job - echo "Removing Cloned repository and tmp files" - rm -rf /tmp/*valgrind*.log /tmp/*valgrind*.xml + echo "Removing Cloned repository and log files" rm -rf fledge* reports && echo 'Done.' } # Setting up Fledge and installing its plugin setup(){ - ./scripts/setup fledge-south-sinusoid ${FLEDGE_TEST_BRANCH} + ./scripts/setup "fledge-south-sinusoid fledge-south-random" "${FLEDGE_TEST_BRANCH}" } reset_fledge(){ @@ -26,7 +25,7 @@ reset_fledge(){ } add_sinusoid(){ - echo -e INFO: "Add South" + echo -e INFO: "Add South Sinusoid" curl -sX POST "$FLEDGE_URL/service" -d \ '{ "name": "Sine", @@ -44,6 +43,25 @@ add_sinusoid(){ echo } +add_random(){ + echo -e INFO: "Add South Random" + curl -sX POST "$FLEDGE_URL/service" -d \ + '{ + "name": "Random", + "type": "south", + "plugin": "Random", + "enabled": true, + "config": {} + }' + echo + echo 'Updating Readings per second' + + sleep 60 + + curl -sX PUT "$FLEDGE_URL/category/RandomAdvanced" -d '{ "readingsPerSec": "100"}' + echo + +} setup_north_pi_egress () { # Add PI North as service echo 'Setting up North' @@ -58,16 +76,16 @@ setup_north_pi_egress () { "value": "PI Web API" }, "ServerHostname": { - "value": "'$2'" + "value": "'${PI_IP}'" }, "ServerPort": { "value": "443" }, "PIWebAPIUserId": { - "value": "'$3'" + "value": "'${PI_USER}'" }, "PIWebAPIPassword": { - "value": "'$4'" + "value": "'${PI_PASSWORD}'" }, "NamingScheme": { "value": "Backward compatibility" @@ -98,14 +116,14 @@ generate_valgrind_logs(){ echo 'Creating reports directory'; mkdir -p reports/test1 ; ls -lrth echo 'copying reports ' - cp -rf /tmp/*valgrind*.log /tmp/*valgrind*.xml reports/test1/. && echo 'copied' - rm -rf fledge* + cp -rf /tmp/*valgrind*.log /tmp/*valgrind*.xml reports/test1/. && echo 'copied' } cleanup setup reset_fledge add_sinusoid +add_random setup_north_pi_egress collect_data generate_valgrind_logs From 98cfe7858322041e890564a53e5a75c00d038204 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 17 Jul 2023 17:57:00 +0530 Subject: [PATCH 336/499] refinements in python utility to fetch C-based plugins Signed-off-by: ashish-jabble --- python/fledge/services/core/api/utils.py | 65 +++++++++++------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/python/fledge/services/core/api/utils.py b/python/fledge/services/core/api/utils.py index cd53290592..1226688dcc 100644 --- a/python/fledge/services/core/api/utils.py +++ b/python/fledge/services/core/api/utils.py @@ -4,25 +4,25 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END - import subprocess import os import json - from fledge.common.common import _FLEDGE_ROOT, _FLEDGE_PLUGIN_PATH from fledge.common.logger import FLCoreLogger _logger = FLCoreLogger().get_logger(__name__) _lib_path = _FLEDGE_ROOT + "/" + "plugins" +C_PLUGIN_UTIL_PATH = _FLEDGE_ROOT + "/extras/C/get_plugin_info" if os.path.isdir(_FLEDGE_ROOT + "/extras/C") \ + else _FLEDGE_ROOT + "/cmake_build/C/plugins/utils/get_plugin_info" + def get_plugin_info(name, dir): try: - arg1 = _find_c_util('get_plugin_info') arg2 = _find_c_lib(name, dir) if arg2 is None: raise ValueError('The plugin {} does not exist'.format(name)) - cmd_with_args = [arg1, arg2, "plugin_info"] + cmd_with_args = [C_PLUGIN_UTIL_PATH, arg2, "plugin_info"] p = subprocess.Popen(cmd_with_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = p.communicate() res = out.decode("utf-8") @@ -43,25 +43,22 @@ def get_plugin_info(name, dir): return jdoc -def _find_c_lib(name, dir): - _path = [_lib_path + "/" + dir] +def _find_c_lib(name, installed_dir): + _path = [_lib_path + "/" + installed_dir] _path = _find_plugins_from_env(_path) + lib_path = None + for fp in _path: for path, subdirs, files in os.walk(fp): for fname in files: # C-binary file if fname.endswith("lib{}.so".format(name)): - return os.path.join(path, fname) - return None - - -def _find_c_util(name): - for path, subdirs, files in os.walk(_FLEDGE_ROOT): - for fname in files: - # C-utility file - if fname == name: - return os.path.join(path, fname) - return None + lib_path = os.path.join(path, fname) + break + else: + continue + break + return lib_path def find_c_plugin_libs(direction): @@ -69,32 +66,30 @@ def find_c_plugin_libs(direction): _path = [_lib_path] _path = _find_plugins_from_env(_path) for fp in _path: - for root, dirs, files in os.walk(fp + "/" + direction): - for name in dirs: - p = os.path.join(root, name) - for path, subdirs, f in os.walk(p): - for fname in f: - # C-binary file - if fname.endswith('.so'): - # Replace lib and .so from fname - libraries.append((fname.replace("lib", "").replace(".so", ""), 'binary')) - # For Hybrid plugins - if direction == 'south' and fname.endswith('.json'): - libraries.append((fname.replace(".json", ""), 'json')) + if os.path.isdir(fp + "/" + direction): + for name in os.listdir(fp + "/" + direction): + p = fp + "/" + direction + "/" + name + for fname in os.listdir(p): + if fname.endswith('.so'): + # Replace lib and .so from fname + libraries.append((fname.replace("lib", "").replace(".so", ""), 'binary')) + # For Hybrid plugins + if direction == 'south' and fname.endswith('.json'): + libraries.append((fname.replace(".json", ""), 'json')) return libraries def _find_plugins_from_env(_plugin_path: list) -> list: if _FLEDGE_PLUGIN_PATH: my_list = _FLEDGE_PLUGIN_PATH.split(";") - for l in my_list: - dir_found = os.path.isdir(l) + for ml in my_list: + dir_found = os.path.isdir(ml) if dir_found: - subdirs = [dirs for x, dirs, files in os.walk(l)] + subdirs = [dirs for x, dirs, files in os.walk(ml)] if subdirs[0]: - _plugin_path.append(l) + _plugin_path.append(ml) else: - _logger.warning("{} subdir type not found.".format(l)) + _logger.warning("{} subdir type not found.".format(ml)) else: - _logger.warning("{} dir path not found.".format(l)) + _logger.warning("{} dir path not found.".format(ml)) return _plugin_path From 69c9a6d4d48d15dc1953959e643df7021923f486 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 18 Jul 2023 11:40:27 +0530 Subject: [PATCH 337/499] unit tests updated Signed-off-by: ashish-jabble --- .../services/core/api/test_api_utils.py | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/test_api_utils.py b/tests/unit/python/fledge/services/core/api/test_api_utils.py index 0a528ff2c7..fd09101a82 100644 --- a/tests/unit/python/fledge/services/core/api/test_api_utils.py +++ b/tests/unit/python/fledge/services/core/api/test_api_utils.py @@ -15,10 +15,10 @@ @pytest.allure.story("api", "utils") class TestUtils: - @pytest.mark.parametrize("direction", ['south', 'north']) + @pytest.mark.parametrize("direction", ['south', 'north', 'filter', 'notificationDelivery', 'notificationRule']) def test_find_c_plugin_libs_if_empty(self, direction): - with patch('os.walk') as mockwalk: - mockwalk.return_value = [([], [], [])] + with patch('os.listdir') as mockwalk: + mockwalk.return_value = [] assert [] == utils.find_c_plugin_libs(direction) @pytest.mark.parametrize("direction, plugin_name, plugin_type, libs", [ @@ -35,48 +35,42 @@ def test_find_c_plugin_libs(self, direction, plugin_name, plugin_type, libs): def test_get_plugin_info_value_error(self): plugin_name = 'Random' - with patch.object(utils, '_find_c_util', return_value='plugins/utils/get_plugin_info') as patch_util: - with patch.object(utils, '_find_c_lib', return_value=None) as patch_lib: - with patch.object(utils._logger, 'error') as patch_logger: - assert {} == utils.get_plugin_info(plugin_name, dir='south') - assert 1 == patch_logger.call_count - args = patch_logger.call_args - assert '{} C plugin get info failed.'.format(plugin_name) == args[0][1] - patch_lib.assert_called_once_with(plugin_name, 'south') - patch_util.assert_called_once_with('get_plugin_info') + with patch.object(utils, '_find_c_lib', return_value=None) as patch_lib: + with patch.object(utils._logger, 'error') as patch_logger: + assert {} == utils.get_plugin_info(plugin_name, dir='south') + assert 1 == patch_logger.call_count + args = patch_logger.call_args + assert '{} C plugin get info failed.'.format(plugin_name) == args[0][1] + patch_lib.assert_called_once_with(plugin_name, 'south') @pytest.mark.parametrize("exc_name", [Exception, OSError, subprocess.CalledProcessError]) def test_get_plugin_info_exception(self, exc_name): plugin_name = 'OMF' plugin_lib_path = 'fledge/plugins/north/{}/lib{}'.format(plugin_name, plugin_name) - with patch.object(utils, '_find_c_util', return_value='plugins/utils/get_plugin_info') as patch_util: - with patch.object(utils, '_find_c_lib', return_value=plugin_lib_path) as patch_lib: - with patch.object(utils.subprocess, "Popen", side_effect=exc_name): - with patch.object(utils._logger, 'error') as patch_logger: - assert {} == utils.get_plugin_info(plugin_name, dir='south') - assert 1 == patch_logger.call_count - args = patch_logger.call_args - assert '{} C plugin get info failed.'.format(plugin_name) == args[0][1] - patch_lib.assert_called_once_with(plugin_name, 'south') - patch_util.assert_called_once_with('get_plugin_info') + with patch.object(utils, '_find_c_lib', return_value=plugin_lib_path) as patch_lib: + with patch.object(utils.subprocess, "Popen", side_effect=exc_name): + with patch.object(utils._logger, 'error') as patch_logger: + assert {} == utils.get_plugin_info(plugin_name, dir='south') + assert 1 == patch_logger.call_count + args = patch_logger.call_args + assert '{} C plugin get info failed.'.format(plugin_name) == args[0][1] + patch_lib.assert_called_once_with(plugin_name, 'south') @patch('subprocess.Popen') def test_get_plugin_info(self, mock_subproc_popen): - with patch.object(utils, '_find_c_util', return_value='plugins/utils/get_plugin_info') as patch_util: - with patch.object(utils, '_find_c_lib', return_value='fledge/plugins/south/Random/libRandom') as patch_lib: - process_mock = MagicMock() - attrs = {'communicate.return_value': (b'{"name": "Random", "version": "1.0.0", "type": "south", ' - b'"interface": "1.0.0", "config": {"plugin" : ' - b'{ "description" : "Random C south plugin", "type" : "string", ' - b'"default" : "Random" }, "asset" : { "description" : ' - b'"Asset name", "type" : "string", ' - b'"default" : "Random" } } }\n', 'error')} - process_mock.configure_mock(**attrs) - mock_subproc_popen.return_value = process_mock - j = utils.get_plugin_info('Random', dir='south') - assert {'name': 'Random', 'type': 'south', 'version': '1.0.0', 'interface': '1.0.0', - 'config': {'plugin': {'description': 'Random C south plugin', 'type': 'string', - 'default': 'Random'}, - 'asset': {'description': 'Asset name', 'type': 'string', 'default': 'Random'}}} == j - patch_lib.assert_called_once_with('Random', 'south') - patch_util.assert_called_once_with('get_plugin_info') + with patch.object(utils, '_find_c_lib', return_value='fledge/plugins/south/Random/libRandom') as patch_lib: + process_mock = MagicMock() + attrs = {'communicate.return_value': (b'{"name": "Random", "version": "1.0.0", "type": "south", ' + b'"interface": "1.0.0", "config": {"plugin" : ' + b'{ "description" : "Random C south plugin", "type" : "string", ' + b'"default" : "Random" }, "asset" : { "description" : ' + b'"Asset name", "type" : "string", ' + b'"default" : "Random" } } }\n', 'error')} + process_mock.configure_mock(**attrs) + mock_subproc_popen.return_value = process_mock + j = utils.get_plugin_info('Random', dir='south') + assert {'name': 'Random', 'type': 'south', 'version': '1.0.0', 'interface': '1.0.0', + 'config': {'plugin': {'description': 'Random C south plugin', 'type': 'string', + 'default': 'Random'}, + 'asset': {'description': 'Asset name', 'type': 'string', 'default': 'Random'}}} == j + patch_lib.assert_called_once_with('Random', 'south') From b93b2762da18e1aaaa604b14dcb48785ba2a8ecb Mon Sep 17 00:00:00 2001 From: Himanshu Vimal <67678828+cyberwalk3r@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:36:49 +0530 Subject: [PATCH 338/499] FOGL-7814: Move readings in south plugin ingest. (#1096) * FOGL-7814: Move readings in south plugin ingest. - Added function to transfer readings vector in reading_set class. - Added function to remove and return a reading from reading vector in reading_set class. - Changed plugin ingest for poll and async using moveAllReadings function added to reading_set class. Signed-off-by: Himanshu Vimal --- C/common/include/reading_set.h | 5 ++++ C/common/reading_set.cpp | 30 ++++++++++++++++++++++ C/services/south/ingest.cpp | 7 ++--- C/services/south/south.cpp | 47 ++++++++++++---------------------- 4 files changed, 53 insertions(+), 36 deletions(-) diff --git a/C/common/include/reading_set.h b/C/common/include/reading_set.h index 304ad7cc76..2872303600 100755 --- a/C/common/include/reading_set.h +++ b/C/common/include/reading_set.h @@ -40,6 +40,11 @@ class ReadingSet { // Return the reference of readings std::vector* getAllReadingsPtr() { return &m_readings; }; + // Remove readings from reading set and return reference to readings + std::vector* moveAllReadings(); + // Delete a reading from reading set and return pointer of deleted reading + Reading* removeReading(unsigned long id); + // Return the reading id of the last data element unsigned long getLastId() const { return m_last_id; }; unsigned long getReadingId(uint32_t pos); diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index b56a5a4c2a..8749626e15 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -306,6 +306,36 @@ ReadingSet::clear() m_readings.clear(); } +/** + * Remove readings from the vector and return a reference to new vector + * containing readings* +*/ +std::vector* ReadingSet::moveAllReadings() +{ + std::vector* transferredPtr = new std::vector(std::move(m_readings)); + m_count = 0; + m_last_id = 0; + m_readings.clear(); + + return transferredPtr; +} + +/** + * Remove reading from vector based on index and return its pointer +*/ +Reading* ReadingSet::removeReading(unsigned long id) +{ + if (id >= m_readings.size()) { + return nullptr; + } + + Reading* reading = m_readings[id]; + m_readings.erase(m_readings.begin() + id); + m_count--; + + return reading; +} + /** * Return the ID of the nth reading in the reading set * diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 4d90108fc1..aa1d3e76e7 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -1003,11 +1003,8 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, } else { - ingest->m_data = new std::vector; - for (auto & r : readingSet->getAllReadings()) - { - ingest->m_data->emplace_back(new Reading(*r)); // Need to copy reading objects here, since "del readingSet" below would remove encapsulated reading objects also - } + // move reading vector to ingest + ingest->m_data = readingSet->moveAllReadings(); } } readingSet->clear(); diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 0340f95ecf..48a773fdf8 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -202,23 +202,16 @@ void doIngest(Ingest *ingest, Reading reading) void doIngestV2(Ingest *ingest, ReadingSet *set) { - std::vector *vec = set->getAllReadingsPtr(); - std::vector *vec2 = new std::vector; - if (!vec) - { - Logger::getLogger()->info("%s:%d: V2 async ingest method: vec is NULL", __FUNCTION__, __LINE__); - return; - } - else - { - // TODO Remove the need for this copy of all the readings - for (auto & r : *vec) - { - Reading *r2 = new Reading(*r); // Need to copy reading objects here, since "del set" below would remove encapsulated reading objects also - vec2->emplace_back(r2); - } - } - Logger::getLogger()->debug("%s:%d: V2 async ingest method returned: vec->size()=%d", __FUNCTION__, __LINE__, vec->size()); + std::vector *vec = set->getAllReadingsPtr(); + if (!vec) + { + Logger::getLogger()->info("%s:%d: V2 async ingest method: vec is NULL", __FUNCTION__, __LINE__); + return; + } + // move reading vector from set to new vector vec2 + std::vector *vec2 = set->moveAllReadings(); + + Logger::getLogger()->debug("%s:%d: V2 async ingest method returned: vec->size()=%d", __FUNCTION__, __LINE__, vec->size()); ingest->ingest(vec2); delete vec2; // each reading object inside vector has been allocated on heap and moved to Ingest class's internal queue @@ -565,25 +558,17 @@ void SouthService::start(string& coreAddress, unsigned short corePort) if (set) { std::vector *vec = set->getAllReadingsPtr(); - std::vector *vec2 = new std::vector; if (!vec) { Logger::getLogger()->info("%s:%d: V2 poll method: vec is NULL", __FUNCTION__, __LINE__); continue; } - else - { - for (auto & r : *vec) - { - Reading *r2 = new Reading(*r); // Need to copy reading objects here, since "del set" below would remove encapsulated reading objects - vec2->emplace_back(r2); - } - } - - ingest.ingest(vec2); - pollCount += (int) vec2->size(); - delete vec2; // each reading object inside vector has been allocated on heap and moved to Ingest class's internal queue - delete set; + // move reading vector from set to vec2 + std::vector *vec2 = set->moveAllReadings(); + ingest.ingest(vec2); + pollCount += (int) vec2->size(); + delete vec2; // each reading object inside vector has been allocated on heap and moved to Ingest class's internal queue + delete set; } } throttlePoll(); From 2ff96c9810716569e4f67db6ffb4eb1f7b3a4835 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 21 Jul 2023 17:34:56 +0530 Subject: [PATCH 339/499] control pipeline filters handling on CRUD's along with order; also some code cleanup Signed-off-by: ashish-jabble --- .../core/api/control_service/pipeline.py | 129 +++++++++++------- 1 file changed, 77 insertions(+), 52 deletions(-) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index 4588023eb6..17235ba259 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -217,17 +217,21 @@ async def update(request: web.Request) -> web.Response: await storage.update_tbl("control_pipelines", payload) filters = data.get('filters', None) if filters is not None: - go_ahead = await _check_filters(storage, filters) if filters else True - if go_ahead: - # remove old filters if exists - await _remove_filters(storage, pipeline['filters'], cpid) - if filters: - # Update new filters - new_filters = await _update_filters(storage, cpid, pipeline['name'], filters) - if not new_filters: - raise ValueError('Filters do not exist as per the given list {}'.format(filters)) + # Case: When filters payload is empty then remove all filters + if not filters: + await _remove_filters(storage, pipeline['filters'], cpid, pipeline['name']) else: - raise ValueError('Filters do not exist as per the given list {}'.format(filters)) + go_ahead = await _check_filters(storage, filters) if filters else True + if go_ahead: + if filters: + result_filters = await _get_table_column_by_value("control_filters", "cpid", cpid) + db_filters = None + if result_filters['rows']: + db_filters = [r['fname'].replace("ctrl_{}_".format(pipeline['name']), '' + ) for r in result_filters['rows']] + await _update_filters(storage, cpid, pipeline['name'], filters, db_filters) + else: + raise ValueError('Filters do not exist as per the given list {}'.format(filters)) except ValueError as err: msg = str(err) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) @@ -289,7 +293,10 @@ async def _get_all_lookups(tbl_name=None): async def _get_table_column_by_value(table, column_name, column_value, limit=None): storage = connect.get_storage_async() - payload = PayloadBuilder().WHERE([column_name, '=', column_value]).payload() + if table == "control_filters": + payload = PayloadBuilder().WHERE([column_name, '=', column_value]).ORDER_BY(["forder", "asc"]).payload() + else: + payload = PayloadBuilder().WHERE([column_name, '=', column_value]).payload() if limit is not None: payload = PayloadBuilder().WHERE([column_name, '=', column_value]).LIMIT(limit).payload() result = await storage.query_tbl_with_payload(table, payload) @@ -540,10 +547,15 @@ async def _remove_filters(storage, filters, cp_id, cp_name=None): # Delete entry from control_filter table payload = PayloadBuilder().WHERE(['cpid', '=', cp_id]).AND_WHERE(['fname', '=', f]).payload() await storage.delete_from_tbl("control_filters", payload) - # Delete the related category + + # Delete filter from filters table + filter_name = f.replace("ctrl_{}_".format(cp_name), '') + payload = PayloadBuilder().WHERE(['name', '=', filter_name]).payload() + await storage.delete_from_tbl("filters", payload) + + # Delete the filters category await cf_mgr.delete_category_and_children_recursively(f) - if cp_name is not None: - await cf_mgr.delete_category_and_children_recursively(f.split("ctrl_{}_".format(cp_name))[1]) + await cf_mgr.delete_category_and_children_recursively(filter_name) async def _check_filters(storage, cp_filters): @@ -561,47 +573,60 @@ async def _check_filters(storage, cp_filters): return is_exist -async def _update_filters(storage, cp_id, cp_name, cp_filters): +async def _update_filters(storage, cp_id, cp_name, cp_filters, db_filters=None): + if db_filters is None: + db_filters = [] cf_mgr = ConfigurationManager(storage) new_filters = [] children = [] - if not cp_filters: - return new_filters - - for fid, fname in enumerate(cp_filters, start=1): - # get plugin config of filter - category_value = await cf_mgr.get_category_all_items(category_name=fname) - cat_value = copy.deepcopy(category_value) - if cat_value is None: - raise ValueError( - "{} category does not exist during {} control pipeline filter.".format( - fname, cp_name)) - # Copy value in default and remove value KV pair for creating new category - for k, v in cat_value.items(): - v['default'] = v['value'] - v.pop('value', None) - # Create category - cat_name = "ctrl_{}_{}".format(cp_name, fname) - await cf_mgr.create_category(category_name=cat_name, - category_description="Filter of {} control pipeline.".format( - cp_name), - category_value=cat_value, - keep_original_items=True) - new_category = await cf_mgr.get_category_all_items(cat_name) - if new_category is None: - raise KeyError("No such {} category found.".format(new_category)) - # Create entry in control_filters table - column_names = {"cpid": cp_id, "forder": fid, "fname": cat_name} - payload = PayloadBuilder().INSERT(**column_names).payload() - await storage.insert_into_tbl("control_filters", payload) - new_filters.append(cat_name) - children.append(cat_name) - children.extend([fname]) - try: - # Create parent-child relation with Dispatcher service - await cf_mgr.create_child_category("dispatcher", children) - except: - pass + + insert_filters = set(cp_filters) - set(db_filters) + update_filters = set(cp_filters) & set(db_filters) + delete_filters = set(db_filters) - set(cp_filters) + + if insert_filters: + for fid, fname in enumerate(insert_filters, start=1): + # get plugin config of filter + category_value = await cf_mgr.get_category_all_items(category_name=fname) + cat_value = copy.deepcopy(category_value) + if cat_value is None: + raise ValueError( + "{} category does not exist during {} control pipeline filter.".format( + fname, cp_name)) + # Copy value in default and remove value KV pair for creating new category + for k, v in cat_value.items(): + v['default'] = v['value'] + v.pop('value', None) + # Create category + cat_name = "ctrl_{}_{}".format(cp_name, fname) + await cf_mgr.create_category(category_name=cat_name, + category_description="Filter of {} control pipeline.".format( + cp_name), + category_value=cat_value, + keep_original_items=True) + new_category = await cf_mgr.get_category_all_items(cat_name) + if new_category is None: + raise KeyError("No such {} category found.".format(new_category)) + # Create entry in control_filters table + column_names = {"cpid": cp_id, "forder": fid, "fname": cat_name} + payload = PayloadBuilder().INSERT(**column_names).payload() + await storage.insert_into_tbl("control_filters", payload) + new_filters.append(cat_name) + children.append(cat_name) + children.extend([fname]) + try: + # Create parent-child relation with Dispatcher service + await cf_mgr.create_child_category("dispatcher", children) + except: + pass + if update_filters: + # only order + for fid, fname in enumerate(cp_filters, start=1): + payload = PayloadBuilder().SET(forder=fid).WHERE(["fname", "=", "ctrl_{}_{}".format(cp_name, fname)]).AND_WHERE(["cpid", "=", cp_id]).payload() + await storage.update_tbl("control_filters", payload) + if delete_filters: + del_filters = ["ctrl_{}_{}".format(cp_name, f) for f in list(delete_filters)] + await _remove_filters(storage, del_filters, cp_id, cp_name) return new_filters From bfd6f5ce288e57eec2759faa9ac8bd7090008b97 Mon Sep 17 00:00:00 2001 From: Ashish Jabble Date: Fri, 21 Jul 2023 19:16:28 +0530 Subject: [PATCH 340/499] FOGL-7696 Addition of UTC timezone to the python payload builder & also Sqlite plugins readings has with UTC instead of localtime (#1116) * workaround addition of UTC timezone to the python payload builder Signed-off-by: ashish-jabble * FOGL-7696 Return asset data in UTC and honour timezone setting in query payload Signed-off-by: Mark Riddoch * timezone with alias on select payload Signed-off-by: ashish-jabble * browser api tests updated as per timezone addition in payload builder with select alias Signed-off-by: ashish-jabble * Sql query syntax fixes Signed-off-by: ashish-jabble --------- Signed-off-by: ashish-jabble Signed-off-by: Mark Riddoch Co-authored-by: Mark Riddoch --- C/plugins/storage/sqlite/common/readings.cpp | 44 +++++++++------- .../storage/sqlitelb/common/readings.cpp | 42 ++++++++++------ .../common/storage_client/payload_builder.py | 9 ++++ .../payload_select_alias_with_timezone.json | 7 +++ .../storage_client/test_payload_builder.py | 10 +++- .../services/core/api/test_browser_assets.py | 50 +++++++++---------- 6 files changed, 104 insertions(+), 58 deletions(-) create mode 100644 tests/unit/python/fledge/common/storage_client/data/payload_select_alias_with_timezone.json diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index 01319c7929..ff7171c2cd 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -1317,7 +1317,8 @@ unsigned long rowsCount; /** * Perform a query against the readings table * - * retrieveReadings, used by the API, returns timestamp in localtime. + * retrieveReadings, used by the API, returns timestamp in utc unless + * otherwise requested. * */ bool Connection::retrieveReadings(const string& condition, string& resultSet) @@ -1333,6 +1334,7 @@ SQLBuffer jsonConstraintsExt; SQLBuffer jsonConstraints; bool isAggregate = false; bool isOptAggregate = false; +const char *timezone = "utc"; string modifierExt; string modifierInt; @@ -1384,9 +1386,9 @@ vector asset_codes; id, asset_code, reading, - strftime(')" F_DATEH24_SEC R"(', user_ts, 'localtime') || + strftime(')" F_DATEH24_SEC R"(', user_ts, 'utc') || substr(user_ts, instr(user_ts, '.'), 7) AS user_ts, - strftime(')" F_DATEH24_MS R"(', ts, 'localtime') AS ts + strftime(')" F_DATEH24_MS R"(', ts, 'utc') AS ts FROM ( )"; @@ -1415,6 +1417,11 @@ vector asset_codes; return false; } + if (document.HasMember("timezone") && document["timezone"].IsString()) + { + timezone = document["timezone"].GetString(); + } + // timebucket aggregate all datapoints if (aggregateAll(document)) { @@ -1470,14 +1477,18 @@ vector asset_codes; if (strcmp(itr->GetString() ,"user_ts") == 0) { // Display without TZ expression and microseconds also - sql.append(" strftime('" F_DATEH24_SEC "', user_ts, 'localtime') "); + sql.append(" strftime('" F_DATEH24_SEC "', user_ts, '"); + sql.append(timezone); + sql.append("') "); sql.append(" || substr(user_ts, instr(user_ts, '.'), 7) "); sql.append(" as user_ts "); } else if (strcmp(itr->GetString() ,"ts") == 0) { // Display without TZ expression and microseconds also - sql.append(" strftime('" F_DATEH24_MS "', ts, 'localtime') "); + sql.append(" strftime('" F_DATEH24_MS "', ts, '"); + sql.append(timezone); + sql.append("') "); sql.append(" as ts "); } else @@ -1589,7 +1600,9 @@ vector asset_codes; { // Extract milliseconds and microseconds for the user_ts fields - sql.append("strftime('" F_DATEH24_SEC "', user_ts, 'localtime') "); + sql.append("strftime('" F_DATEH24_SEC "', user_ts, '"); + sql.append(timezone); + sql.append("') "); sql.append(" || substr(user_ts, instr(user_ts, '.'), 7) "); if (! itr->HasMember("alias")) { @@ -1601,7 +1614,9 @@ vector asset_codes; { sql.append("strftime('" F_DATEH24_MS "', "); sql.append((*itr)["column"].GetString()); - sql.append(", 'localtime')"); + sql.append(", '"); + sql.append(timezone); + sql.append("')"); if (! itr->HasMember("alias")) { sql.append(" AS "); @@ -1644,16 +1659,11 @@ vector asset_codes; sql.append(' '); } - const char *sql_cmd = R"( - id, - asset_code, - reading, - strftime(')" F_DATEH24_SEC R"(', user_ts, 'localtime') || - substr(user_ts, instr(user_ts, '.'), 7) AS user_ts, - strftime(')" F_DATEH24_MS R"(', ts, 'localtime') AS ts - FROM )"; - - sql.append(sql_cmd); + sql.append("id, asset_code, reading, strftime('" F_DATEH24_SEC "', user_ts, '"); + sql.append(timezone); + sql.append("') || substr(user_ts, instr(user_ts, '.'), 7) AS user_ts, strftime('" F_DATEH24_MS "', ts, '"); + sql.append(timezone); + sql.append("') AS ts FROM "); } { diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index f3f041144d..32219c417a 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -1241,7 +1241,8 @@ int retrieve; /** * Perform a query against the readings table * - * retrieveReadings, used by the API, returns timestamp in localtime. + * retrieveReadings, used by the API, returns timestamp in utc unless + * otherwise requested. * */ bool Connection::retrieveReadings(const string& condition, string& resultSet) @@ -1252,6 +1253,7 @@ SQLBuffer sql; // Extra constraints to add to where clause SQLBuffer jsonConstraints; bool isAggregate = false; +const char *timezone = "utc"; try { if (dbHandle == NULL) @@ -1267,7 +1269,7 @@ bool isAggregate = false; id, asset_code, reading, - strftime(')" F_DATEH24_SEC R"(', user_ts, 'localtime') || + strftime(')" F_DATEH24_SEC R"(', user_ts, 'utc') || substr(user_ts, instr(user_ts, '.'), 7) AS user_ts, strftime(')" F_DATEH24_MS R"(', ts, 'localtime') AS ts FROM )" READINGS_DB_NAME_BASE R"(.readings)"; @@ -1282,6 +1284,11 @@ bool isAggregate = false; return false; } + if (document.HasMember("timezone") && document["timezone"].IsString()) + { + timezone = document["timezone"].GetString(); + } + // timebucket aggregate all datapoints if (aggregateAll(document)) { @@ -1327,14 +1334,18 @@ bool isAggregate = false; if (strcmp(itr->GetString() ,"user_ts") == 0) { // Display without TZ expression and microseconds also - sql.append(" strftime('" F_DATEH24_SEC "', user_ts, 'localtime') "); + sql.append(" strftime('" F_DATEH24_SEC "', user_ts, '"); + sql.append(timezone); + sql.append("') "); sql.append(" || substr(user_ts, instr(user_ts, '.'), 7) "); sql.append(" as user_ts "); } else if (strcmp(itr->GetString() ,"ts") == 0) { // Display without TZ expression and microseconds also - sql.append(" strftime('" F_DATEH24_MS "', ts, 'localtime') "); + sql.append(" strftime('" F_DATEH24_MS "', ts, '"); + sql.append(timezone); + sql.append("') "); sql.append(" as ts "); } else @@ -1446,7 +1457,9 @@ bool isAggregate = false; { // Extract milliseconds and microseconds for the user_ts fields - sql.append("strftime('" F_DATEH24_SEC "', user_ts, 'localtime') "); + sql.append("strftime('" F_DATEH24_SEC "', user_ts, '"); + sql.append(timezone); + sql.append("') "); sql.append(" || substr(user_ts, instr(user_ts, '.'), 7) "); if (! itr->HasMember("alias")) { @@ -1458,7 +1471,9 @@ bool isAggregate = false; { sql.append("strftime('" F_DATEH24_MS "', "); sql.append((*itr)["column"].GetString()); - sql.append(", 'localtime')"); + sql.append(", '"); + sql.append(timezone); + sql.append("')"); if (! itr->HasMember("alias")) { sql.append(" AS "); @@ -1501,16 +1516,13 @@ bool isAggregate = false; sql.append(' '); } - const char *sql_cmd = R"( - id, - asset_code, - reading, - strftime(')" F_DATEH24_SEC R"(', user_ts, 'localtime') || - substr(user_ts, instr(user_ts, '.'), 7) AS user_ts, - strftime(')" F_DATEH24_MS R"(', ts, 'localtime') AS ts - FROM )" READINGS_DB_NAME_BASE R"(.)"; + sql.append("id, asset_code, reading, strftime('" F_DATEH24_SEC "', user_ts, '"); + sql.append(timezone); + sql.append("') || substr(user_ts, instr(user_ts, '.'), 7) AS user_ts,"); + sql.append("strftime('" F_DATEH24_MS "', ts, '"); + sql.append(timezone); + sql.append("') AS ts FROM " READINGS_DB_NAME_BASE "."); - sql.append(sql_cmd); } sql.append("readings"); if (document.HasMember("where")) diff --git a/python/fledge/common/storage_client/payload_builder.py b/python/fledge/common/storage_client/payload_builder.py index 6fb1f025df..b1b4ba4455 100644 --- a/python/fledge/common/storage_client/payload_builder.py +++ b/python/fledge/common/storage_client/payload_builder.py @@ -127,6 +127,15 @@ def add_clause_to_select(cls, clause, qp_list, col, clause_value): with_clause = OrderedDict() with_clause['column'] = item with_clause[clause] = clause_value + """ + NOTE: + For Sqlite based engines, Temporarily workaround in payload builder to add "utc" timezone always + when query with user_ts column and having alias timestamp. + Though for PostgreSQL we already have set a session level time zone to 'UTC' during connection + https://github.com/fledge-iot/fledge/pull/900/files + """ + if col == "user_ts" and clause_value == "timestamp": + with_clause["timezone"] = "utc" qp_list[i] = with_clause if isinstance(item, dict): if 'json' in qp_list[i] and qp_list[i]['json']['column'] == col: diff --git a/tests/unit/python/fledge/common/storage_client/data/payload_select_alias_with_timezone.json b/tests/unit/python/fledge/common/storage_client/data/payload_select_alias_with_timezone.json new file mode 100644 index 0000000000..32e791fae1 --- /dev/null +++ b/tests/unit/python/fledge/common/storage_client/data/payload_select_alias_with_timezone.json @@ -0,0 +1,7 @@ +{ + "return": [{ + "column": "user_ts", + "alias": "timestamp", + "timezone": "utc" + }] +} \ No newline at end of file diff --git a/tests/unit/python/fledge/common/storage_client/test_payload_builder.py b/tests/unit/python/fledge/common/storage_client/test_payload_builder.py index 49a831f682..dff213bb1c 100644 --- a/tests/unit/python/fledge/common/storage_client/test_payload_builder.py +++ b/tests/unit/python/fledge/common/storage_client/test_payload_builder.py @@ -49,7 +49,8 @@ def test_select_payload_with_alias1(self, test_input, expected): @pytest.mark.parametrize("test_input, expected", [ (("reading", "user_ts"), - {"return": ["reading", {"format": "YYYY-MM-DD HH24:MI:SS.MS", "column": "user_ts", "alias": "timestamp"}]}) + {"return": ["reading", {"format": "YYYY-MM-DD HH24:MI:SS.MS", "column": "user_ts", + "alias": "timestamp", "timezone": "utc"}]}) ]) def test_select_payload_with_alias_and_format(self, test_input, expected): res = PayloadBuilder().SELECT(test_input).ALIAS('return', ('user_ts', 'timestamp')).\ @@ -70,6 +71,13 @@ def test_select_payload_with_alias3(self, test_input, expected): res = PayloadBuilder().SELECT(test_input).ALIAS('return', ('name', 'my_name'), ('id', 'my_id')).payload() assert expected == json.loads(res) + @pytest.mark.parametrize("test_input, expected", [ + ("user_ts", _payload("data/payload_select_alias_with_timezone.json")) + ]) + def test_select_payload_with_alias_with_timezone(self, test_input, expected): + res = PayloadBuilder().SELECT(test_input).ALIAS('return', ('user_ts', 'timestamp'), ('timezone', 'utc')).payload() + assert expected == json.loads(res) + @pytest.mark.parametrize("test_input, expected", [ ("test", _payload("data/payload_from1.json")), ("test, test2", _payload("data/payload_from2.json")) diff --git a/tests/unit/python/fledge/services/core/api/test_browser_assets.py b/tests/unit/python/fledge/services/core/api/test_browser_assets.py index 93c0518233..dd8b65d35d 100644 --- a/tests/unit/python/fledge/services/core/api/test_browser_assets.py +++ b/tests/unit/python/fledge/services/core/api/test_browser_assets.py @@ -30,8 +30,8 @@ '/fledge/asset/fogbench%2fhumidity/temperature/series'] PAYLOADS = ['{"aggregate": {"column": "*", "alias": "count", "operation": "count"}, "group": "asset_code"}', - '{"return": ["reading", {"column": "user_ts", "alias": "timestamp"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}', - '{"return": [{"column": "user_ts", "alias": "timestamp"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}', + '{"return": ["reading", {"column": "user_ts", "alias": "timestamp", "timezone": "utc"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}', + '{"return": [{"column": "user_ts", "alias": "timestamp", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}', '{"aggregate": [{"operation": "min", "alias": "min", "json": {"properties": "temperature", "column": "reading"}}, {"operation": "max", "alias": "max", "json": {"properties": "temperature", "column": "reading"}}, {"operation": "avg", "alias": "average", "json": {"properties": "temperature", "column": "reading"}}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "group": {"format": "YYYY-MM-DD HH24:MI:SS", "column": "user_ts", "alias": "timestamp"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}' ] RESULTS = [{'rows': [{'count': 10, 'asset_code': 'TI sensorTag/luxometer'}], 'count': 1}, @@ -49,9 +49,9 @@ ] FILTERING_IMAGE_PAYLOADS = [ - '{"return": ["reading", {"column": "user_ts", "alias": "timestamp"}], "where": {"column": "asset_code", "condition": "=", "value": "testcard"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}', + '{"return": ["reading", {"column": "user_ts", "alias": "timestamp", "timezone": "utc"}], "where": {"column": "asset_code", "condition": "=", "value": "testcard"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}', '{"return": ["reading"], "where": {"column": "asset_code", "condition": "=", "value": "testcard"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}', - '{"return": [{"column": "user_ts", "alias": "timestamp"}, {"json": {"column": "reading", "properties": "testcard"}, "alias": "testcard"}], "where": {"column": "asset_code", "condition": "=", "value": "testcard"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}', + '{"return": [{"column": "user_ts", "alias": "timestamp", "timezone": "utc"}, {"json": {"column": "reading", "properties": "testcard"}, "alias": "testcard"}], "where": {"column": "asset_code", "condition": "=", "value": "testcard"}, "limit": 20, "sort": {"column": "user_ts", "direction": "desc"}}', '{"aggregate": [{"operation": "min", "json": {"column": "reading", "properties": "testcard"}, "alias": "min"}, {"operation": "max", "json": {"column": "reading", "properties": "testcard"}, "alias": "max"}, {"operation": "avg", "json": {"column": "reading", "properties": "testcard"}, "alias": "average"}], "where": {"column": "asset_code", "condition": "=", "value": "testcard"}}', '{"aggregate": [{"operation": "min", "json": {"column": "reading", "properties": "testcard"}, "alias": "min"}, {"operation": "max", "json": {"column": "reading", "properties": "testcard"}, "alias": "max"}, {"operation": "avg", "json": {"column": "reading", "properties": "testcard"}, "alias": "average"}], "where": {"column": "asset_code", "condition": "=", "value": "testcard"}, "limit": 20, "group": {"column": "user_ts", "alias": "timestamp", "format": "YYYY-MM-DD HH24:MI:SS"}, "sort": {"column": "user_ts", "direction": "desc"}}' ] @@ -333,16 +333,16 @@ async def test_request_params_with_bad_data(self, client, request_param, respons assert response_message == resp.reason @pytest.mark.parametrize("request_params, payload", [ - ('?limit=5', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 5, "sort": {"column": "user_ts", "direction": "desc"}}'), - ('?skip=1', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 20, "skip": 1, "sort": {"column": "user_ts", "direction": "desc"}}'), - ('?limit=5&skip=1', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 5, "skip": 1, "sort": {"column": "user_ts", "direction": "desc"}}'), - ('?seconds=3600', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 3600}}, "sort": {"column": "user_ts", "direction": "desc"}}'), - ('?minutes=20', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 1200}}, "sort": {"column": "user_ts", "direction": "desc"}}'), - ('?hours=3', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 10800}}, "sort": {"column": "user_ts", "direction": "desc"}}'), - ('?seconds=60&minutes=10', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 60}}, "sort": {"column": "user_ts", "direction": "desc"}}'), - ('?seconds=600&hours=1', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 600}}, "sort": {"column": "user_ts", "direction": "desc"}}'), - ('?minutes=20&hours=1', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 1200}}, "sort": {"column": "user_ts", "direction": "desc"}}'), - ('?seconds=10&minutes=10&hours=1', '{"return": [{"alias": "timestamp", "column": "user_ts"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 10}}, "sort": {"column": "user_ts", "direction": "desc"}}') + ('?limit=5', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 5, "sort": {"column": "user_ts", "direction": "desc"}}'), + ('?skip=1', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 20, "skip": 1, "sort": {"column": "user_ts", "direction": "desc"}}'), + ('?limit=5&skip=1', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"}, "limit": 5, "skip": 1, "sort": {"column": "user_ts", "direction": "desc"}}'), + ('?seconds=3600', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 3600}}, "sort": {"column": "user_ts", "direction": "desc"}}'), + ('?minutes=20', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 1200}}, "sort": {"column": "user_ts", "direction": "desc"}}'), + ('?hours=3', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 10800}}, "sort": {"column": "user_ts", "direction": "desc"}}'), + ('?seconds=60&minutes=10', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 60}}, "sort": {"column": "user_ts", "direction": "desc"}}'), + ('?seconds=600&hours=1', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 600}}, "sort": {"column": "user_ts", "direction": "desc"}}'), + ('?minutes=20&hours=1', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 1200}}, "sort": {"column": "user_ts", "direction": "desc"}}'), + ('?seconds=10&minutes=10&hours=1', '{"return": [{"alias": "timestamp", "column": "user_ts", "timezone": "utc"}, {"json": {"properties": "temperature", "column": "reading"}, "alias": "temperature"}], "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity", "and": {"column": "user_ts", "condition": "newer", "value": 10}}, "sort": {"column": "user_ts", "direction": "desc"}}') ]) async def test_limit_skip_time_units_payload(self, client, request_params, payload): readings_storage_client_mock = MagicMock(ReadingsStorageClientAsync) @@ -536,19 +536,19 @@ async def test_bad_asset_bucket_size_and_optional_params(self, client, url, code @pytest.mark.parametrize("request_params, payload", [ ('?limit=5&skip=1&order=asc', - '{"return": ["reading", {"column": "user_ts", "alias": "timestamp"}],' + '{"return": ["reading", {"column": "user_ts", "alias": "timestamp", "timezone": "utc"}],' ' "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"},' ' "skip": 1, "limit": 5, ' '"sort": {"column": "user_ts", "direction": "asc"}}' ), ('?limit=5&skip=1&order=desc', - '{"return": ["reading", {"column": "user_ts", "alias": "timestamp"}],' + '{"return": ["reading", {"column": "user_ts", "alias": "timestamp", "timezone": "utc"}],' ' "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"},' ' "skip": 1,"limit": 5, ' '"sort": {"column": "user_ts", "direction": "desc"}}' ), ('?limit=5&skip=1', - '{"return": ["reading", {"column": "user_ts", "alias": "timestamp"}],' + '{"return": ["reading", {"column": "user_ts", "alias": "timestamp", "timezone": "utc"}],' ' "where": {"column": "asset_code", "condition": "=", "value": "fogbench/humidity"},' ' "skip": 1,"limit": 5, ' '"sort": {"column": "user_ts", "direction": "desc"}}' @@ -622,15 +622,15 @@ async def test_filtering_image_data(self, client, request_url, payload, result): query_patch.assert_called_once_with(args[0]) @pytest.mark.parametrize("request_url, payload, is_image_excluded", [ - ('fledge/asset/testcard?images=include', '{"return": ["reading", {"column": "user_ts", "alias": "timestamp"}], ' - '"where": {"column": "asset_code", "condition": "=", ' - '"value": "testcard"}, "limit": 20, "sort": {"column": "user_ts", ' - '"direction": "desc"}}', + ('fledge/asset/testcard?images=include', + '{"return": ["reading", {"column": "user_ts", "alias": "timestamp", "timezone": "utc"}], ' + '"where": {"column": "asset_code", "condition": "=", "value": "testcard"}, "limit": 20, ' + '"sort": {"column": "user_ts", "direction": "desc"}}', True), - ('fledge/asset/testcard?images=exclude', '{"return": ["reading", {"column": "user_ts", "alias": "timestamp"}], ' - '"where": {"column": "asset_code", "condition": "=", ' - '"value": "testcard"}, "limit": 20, "sort": {"column": "user_ts", ' - '"direction": "desc"}}', + ('fledge/asset/testcard?images=exclude', + '{"return": ["reading", {"column": "user_ts", "alias": "timestamp", "timezone": "utc"}], ' + '"where": {"column": "asset_code", "condition": "=", "value": "testcard"}, "limit": 20, ' + '"sort": {"column": "user_ts", "direction": "desc"}}', False) ]) async def test_data_with_images_request_param(self, client, request_url, payload, is_image_excluded): From 5520e394e3a773c3db47256154475dff4f4e1c77 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 25 Jul 2023 15:06:34 +0530 Subject: [PATCH 341/499] control role added for SQlite along with upgrade and downgrade scripts Signed-off-by: ashish-jabble --- VERSION | 2 +- scripts/plugins/storage/sqlite/downgrade/61.sql | 1 + scripts/plugins/storage/sqlite/downgrade/62.sql | 5 +++++ scripts/plugins/storage/sqlite/init.sql | 3 ++- scripts/plugins/storage/sqlite/upgrade/62.sql | 1 + scripts/plugins/storage/sqlite/upgrade/63.sql | 3 +++ 6 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 scripts/plugins/storage/sqlite/downgrade/61.sql create mode 100644 scripts/plugins/storage/sqlite/downgrade/62.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/62.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/63.sql diff --git a/VERSION b/VERSION index 41f503bdb2..492ff57dcc 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.1.0 -fledge_schema=61 +fledge_schema=63 diff --git a/scripts/plugins/storage/sqlite/downgrade/61.sql b/scripts/plugins/storage/sqlite/downgrade/61.sql new file mode 100644 index 0000000000..88fd4c8096 --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/61.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-7980 changes \ No newline at end of file diff --git a/scripts/plugins/storage/sqlite/downgrade/62.sql b/scripts/plugins/storage/sqlite/downgrade/62.sql new file mode 100644 index 0000000000..112b7c25ae --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/62.sql @@ -0,0 +1,5 @@ +-- Delete roles +DELETE FROM fledge.roles WHERE name IN ('view','control'); +-- Reset auto increment +-- You cannot use ALTER TABLE for that. The autoincrement counter is stored in a separate table named "sqlite_sequence". You can modify the value there +UPDATE sqlite_sequence SET seq=1 WHERE name="roles"; diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 9614b3dd26..3fce302654 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -676,7 +676,8 @@ INSERT INTO fledge.roles ( name, description ) VALUES ('admin', 'All CRUD privileges'), ('user', 'All CRUD operations and self profile management'), ('view', 'Only to view the configuration'), - ('data-view', 'Only read the data in buffer'); + ('data-view', 'Only read the data in buffer'), + ('control', 'Same as editor can do and also have access for control scripts and pipelines'); -- Users DELETE FROM fledge.users; diff --git a/scripts/plugins/storage/sqlite/upgrade/62.sql b/scripts/plugins/storage/sqlite/upgrade/62.sql new file mode 100644 index 0000000000..88fd4c8096 --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/62.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-7980 changes \ No newline at end of file diff --git a/scripts/plugins/storage/sqlite/upgrade/63.sql b/scripts/plugins/storage/sqlite/upgrade/63.sql new file mode 100644 index 0000000000..8295ca306a --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/63.sql @@ -0,0 +1,3 @@ +-- Roles +INSERT INTO fledge.roles ( name, description ) + VALUES ('control', 'Same as editor can do and also have access for control scripts and pipelines'); From 6a7111db90d25d691dac065d636309cc84010048 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 25 Jul 2023 15:08:25 +0530 Subject: [PATCH 342/499] has permissions decoratore removed from control service API & other guard fixes at middleware Signed-off-by: ashish-jabble --- python/fledge/common/web/middleware.py | 15 ++++++++++++--- .../core/api/control_service/script_management.py | 4 ---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/python/fledge/common/web/middleware.py b/python/fledge/common/web/middleware.py index ce421c02cf..a67bfaa4d4 100644 --- a/python/fledge/common/web/middleware.py +++ b/python/fledge/common/web/middleware.py @@ -160,11 +160,13 @@ def handle_api_exception(ex, _class=None, if_trace=0): async def validate_requests(request): """ - a) With "view" based user role id=3 only + a) With "normal" based user role id=2 only + - restrict operations of Control scripts and pipelines except GET + b) With "view" based user role id=3 only - read access operations (GET calls) - change profile (PUT call) - logout (PUT call) - b) With "data-view" based user role id=4 only + c) With "data-view" based user role id=4 only - ping (GET call) - browser asset read operation (GET call) - service (GET call) @@ -173,9 +175,16 @@ async def validate_requests(request): - user roles (GET call) - change profile (PUT call) - logout (PUT call) + d) With "control" based user role id=5 only + - same as normal user can do + - All CRUD's privileges for control scripts + - All CRUD's privileges for control pipelines """ user_id = request.user['id'] - if int(request.user["role_id"]) == 3 and request.method != 'GET': + if int(request.user["role_id"]) == 2 and request.method != 'GET': + if str(request.rel_url).startswith('/fledge/control'): + raise web.HTTPForbidden + elif int(request.user["role_id"]) == 3 and request.method != 'GET': supported_endpoints = ['/fledge/user', '/fledge/user/{}/password'.format(user_id), '/logout'] if not str(request.rel_url).endswith(tuple(supported_endpoints)): raise web.HTTPForbidden diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index b7f0cec5da..e0b176bc0a 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -49,7 +49,6 @@ def setup(app): app.router.add_route('DELETE', '/fledge/control/script/{script_name}', delete) -@has_permission("admin") async def add_schedule_and_configuration(request: web.Request) -> web.Response: """ Create a schedule and configuration category for the task :Example: @@ -254,7 +253,6 @@ async def get_by_name(request: web.Request) -> web.Response: return web.json_response(rows) -@has_permission("admin") async def add(request: web.Request) -> web.Response: """ Add a script @@ -340,7 +338,6 @@ async def add(request: web.Request) -> web.Response: return web.json_response(result) -@has_permission("admin") async def update(request: web.Request) -> web.Response: """ Update a script Only the steps & ACL parameters can be updated @@ -432,7 +429,6 @@ async def update(request: web.Request) -> web.Response: return web.json_response({"message": message}) -@has_permission("admin") async def delete(request: web.Request) -> web.Response: """ Delete a script From 71e193b1af1bf625ca4fef9d7047b11df870941d Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 25 Jul 2023 15:13:58 +0100 Subject: [PATCH 343/499] FOGL-7658 Control pipeline documentation & core support (#1121) * FOGL-7658 Add source information for control requests from the north service Signed-off-by: Mark Riddoch * FOGL-7658 Fix passing of set point value Signed-off-by: Mark Riddoch * Checkpoint Signed-off-by: Mark Riddoch * Control pipeline documentation Signed-off-by: Mark Riddoch * Fix table Signed-off-by: Mark Riddoch * Update with review comments Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- docs/control.rst | 179 +++++++++++++++++- docs/images/control/pipeline_add.jpg | Bin 0 -> 57595 bytes docs/images/control/pipeline_context_menu.jpg | Bin 0 -> 8134 bytes docs/images/control/pipeline_destination.jpg | Bin 0 -> 20956 bytes docs/images/control/pipeline_filter_add.jpg | Bin 0 -> 58835 bytes .../images/control/pipeline_filter_config.jpg | Bin 0 -> 61854 bytes docs/images/control/pipeline_list.jpg | Bin 0 -> 34361 bytes docs/images/control/pipeline_menu.jpg | Bin 0 -> 11079 bytes docs/images/control/pipeline_model.jpg | Bin 0 -> 12799 bytes docs/images/control/pipeline_source.jpg | Bin 0 -> 27078 bytes docs/introduction.rst | 1 - 11 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 docs/images/control/pipeline_add.jpg create mode 100644 docs/images/control/pipeline_context_menu.jpg create mode 100644 docs/images/control/pipeline_destination.jpg create mode 100644 docs/images/control/pipeline_filter_add.jpg create mode 100644 docs/images/control/pipeline_filter_config.jpg create mode 100644 docs/images/control/pipeline_list.jpg create mode 100644 docs/images/control/pipeline_menu.jpg create mode 100644 docs/images/control/pipeline_model.jpg create mode 100644 docs/images/control/pipeline_source.jpg diff --git a/docs/control.rst b/docs/control.rst index 9d9df81755..3ef2b05f5c 100644 --- a/docs/control.rst +++ b/docs/control.rst @@ -14,13 +14,30 @@ .. |north_map4| image:: images/north_map4.jpg .. |opcua_server| image:: images/opcua_server.jpg .. |dispatcher_config| image:: images/dispatcher-config.jpg +.. |pipeline_list| image:: images/control/pipeline_list.jpg +.. |pipeline_add| image:: images/control/pipeline_add.jpg +.. |pipeline_menu| image:: images/control/pipeline_menu.jpg +.. |pipeline_model| image:: images/control/pipeline_model.jpg +.. |pipeline_source| image:: images/control/pipeline_source.jpg +.. |pipeline_filter_add| image:: images/control/pipeline_filter_add.jpg +.. |pipeline_filter_config| image:: images/control/pipeline_filter_config.jpg +.. |pipeline_context_menu| image:: images/control/pipeline_context_menu.jpg +.. |pipeline_destination| image:: images/control/pipeline_destination.jpg +.. Links +.. |ExpressionFilter| raw:: html + + expression filter + +.. |DeltaFilter| raw:: html + + delta filter ************************ Fledge Control Features ************************ -Fledge supports facilities that allows control of devices via the south service and plugins. This control in known as *set point control* as it is not intended for real time critical control of devices but rather to modify the behavior of a device based on one of many different information flows. The latency involved in these control operations is highly dependent on the control path itself and also the scheduling limitations of the underlying operating system. Hence the caveat that the control functions are not real time or guaranteed to be actioned within a specified time window. +Fledge supports facilities that allows control of devices via the south service and plugins. This control in known as *set point control* as it is not intended for real time critical control of devices but rather to modify the behavior of a device based on one of many different information flows. The latency involved in these control operations is highly dependent on the control path itself and also the scheduling limitations of the underlying operating system. Hence the caveat that the control functions are not real time or guaranteed to be actioned within a specified time window. This does not mean however that they can not be used for non-critical closed loop control, however we would not advise the use of this functionality in safety critical situations. Control Functions ================= @@ -56,6 +73,8 @@ Currently only the notification method is fully implemented within Fledge. The use of a notification in the Fledge instance itself provides the fastest response for an edge notification. All the processing for this is done on the edge by Fledge itself. +As with the data ingress and egress features of Fledge it is also possible to build filter pipelines in the control paths in order to alter the behavior and process the data in the control path. Pipelines in the control path as defined between the different end point of control operations and are defined such that the same pipeline can be utilised by multiple control paths. See :ref:`ControlPipelines` + Edge Based Control ------------------ @@ -380,3 +399,161 @@ Advanced Configuration - **Minimum Log Level**: Allows the minimum level at which logs will get written to the system log to be defined. - **Maximum number of dispatcher threads**: Dispatcher threads are used to execute automation scripts. Each script utilizes a single thread for the duration of the execution of the script. Therefore this setting determines how many scripts can be executed in parallel. + +.. _ControlPipelines: + +Control Pipelines +================= + +A control pipeline is very similar to pipelines in Fledge's data path, i.e. the ingress pipelines of a south service or the egress pipelines in the north data path. A control pipeline comprises an order set of filters through which the data in the control path is pushed. Each individual filter in the pipeline can add, remove or modify the data as it flows through the filter, in this case the data however are the set point write and operations. + +The flow of control requests is organised in such a way that the same filters that are used for data ingress in a south service or data egress in a north service can be used for control pipelines. This is done by mapping control data to asset names and datapoint names and value in the control path pipeline. + +Mapping Rules +------------- + +For a set point write the name of the asset will always be set to *reading*, the asset that is created will have a set of datapoints, one per each setpoint write operation that is to be executed. The name of the datapoint is the name of the set point to be written and the value of the datapoint is the value to to set. + +For example, if a set point write wishes to set the *Pump Speed* set point to *80* and the *Pump Running* set point to *True* then the reading that would be created and passed to the filter would have the asset_code of *reading* and two data points, one called *Pump Speed* with a value of *80* and another called *Pump Running* with a value of *True*. + +This reading can then be manipulated by a filter in the same way as in any other pipeline. For example the |ExpressionFilter| filter could be used to scale the pump speed. If the required was to multiply the pump speed by 10, then the expression defined would be *Pump Speed * 10* . + +In the case of an operation the mapping is very similar, except that the asset_code in the reading becomes the operation name and the data points are the parameters of the operation. + +For example, if an operation *Start Fan* required a parameter of *Fan Speed* then a reading with an asset_code of *Start Fan* with a single datapoint called *Fan Speed* would be created and passed through the filter pipeline. + +Data Types +~~~~~~~~~~ + +The values of all set points and the parameters of all operations are passed in the control services and between services as string representations, however they are converted to appropriate types when passed through the filter pipeline. If a value can be represented as an integer it will be and likewise for floating point values. + +.. note:: + + Currently complex types such as Image, Data Buffer and Array data can not be represented in the control pipelines. + +Pipeline Connections +-------------------- + +The control pipelines are not defined against a particular end point as they are with the data pipelines, they are defined separately and part of that definition includes the input and output end points to which the control pipeline may be attached. The input and output of a control pipeline may be defined as being able to connect to one of a set of endpoints. + ++--------------+-------------+-----------------------------------------------------------------------------------------+ +| Type | Endpoints | Description | ++==============+=============+=========================================================================================+ +| Any | Both | The pipeline can connection to any source or destination. This is only used in | +| | | situations where an exact match for an endpoint can not be satisfied. | ++--------------+-------------+-----------------------------------------------------------------------------------------+ +| API | Source | The source of the request is an API call to the public API of the Fledge instance. | ++--------------+-------------+-----------------------------------------------------------------------------------------+ +| Asset | Destination | The data will be sent to the service that is responsible for ingesting the named asset. | ++--------------+-------------+-----------------------------------------------------------------------------------------+ +| Broadcast | Destination | The requests will be sent to all south services that support control. | ++--------------+-------------+-----------------------------------------------------------------------------------------+ +| Notification | Source | The request originated from the named notification. | ++--------------+-------------+-----------------------------------------------------------------------------------------+ +| Schedule | Source | The request originated from a schedule. | ++--------------+-------------+-----------------------------------------------------------------------------------------+ +| Script | Both | The request is either originating from a script or being sent to a script. | ++--------------+-------------+-----------------------------------------------------------------------------------------+ +| Service | Both | The request is either coming from a named service or going to a named service. | ++--------------+-------------+-----------------------------------------------------------------------------------------+ + +Control pipelines are always executed in the control dispatcher service. When a request comes into the service it will look for a pipeline to pass that request through. This process will look at the source of the request and the destination of the request. If a pipeline that has source and destination endpoints that are an exact match for the source and destination of the control request then the control request will be processed through that pipeline. + +If no exact match is found then the source of the request will be checked against the defined pipelines for a match with the specified source and a destination of *any*. If there is a pipeline that matches these criteria it will be used. If not then a check is made for a pipeline with a source of *any* and a destination that matches the destination of this request. + +If all the above tests fail then a final test is made for a pipeline with a source of *any* and a destination of *any*. If no match occurs then the request is processed without passing through any filters. + +If a request is processed by a script in the control dispatcher then this request may pass through multiple filters, one from the source to the script and then one for each script step that performs a set point write or operation. Each of these may be a different pipeline. + +Pipeline Execution Models +~~~~~~~~~~~~~~~~~~~~~~~~~ + +When a pipeline is defined it may be set to use a *Shared* execution model or an *Exclusive* execution model. This is only important if any of the filters in the pipeline persist state that impacts future processing. + +In a *Shared* execution model one pipeline instance will be created and any requests that use resolve to the pipeline will share the same instance of the pipeline. This saves creating multiple objects within the control dispatcher and is the preferred model to use. + +However if the filters in the pipeline store previous data and use it to influence future decisions, such as the |DeltaFilter| this behavior is undesirable as requests from different sources or destined for different destinations may interfere with each other. In this case the *Exclusive* execution model should be used. + +In an *Exclusive* execution model a new instance of the pipeline will be created for each distinct source and destination of the control request that utilises the pipeline. This ensures that the different instances of the pipeline can not interfere with each other. + +Control Pipeline Management +--------------------------- + +The Fledge Graphical User Interface provides a mechanism to manage the control pipelines, this is found in the Control sub menu under the pipelines item. + ++-----------------+ +| |pipeline_menu| | ++-----------------+ + +The user is presented with a list of the pipelines that have been created to date and an option in the top right corner to add a new pipeline. + ++-----------------+ +| |pipeline_list| | ++-----------------+ + +The list displays the name of the pipeline, the source and destination of the pipeline, the filters in the pipeline, the execution model and the enabled/disabled state of the pipeline. + +The user has a number of actions that may be taken from this screen. + + - Enable or disable the pipeline by clicking on the checkbox to the left of the pipeline name + + - Click on the name of the plugin to view and edit the pipeline. + + - Click on the three vertical dots to view the content menu. + + +-------------------------+ + | |pipeline_context_menu| | + +-------------------------+ + + Currently the only operation that is supported is delete. + + - Click on the Add option in the top right corner to define a new pipeline. + +Adding A Control Pipeline +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Clicking on the add option will display the screen to add a new control pipeline. + ++----------------+ +| |pipeline_add| | ++----------------+ + + - **Name**: The name of the control pipeline. This should be a unique name that is used to identify the control pipeline. + + - **Execution**: The execution model to use to run this pipeline. In most cases the *Shared* execution model is sufficient. + + - **Source**: The control source that this pipeline should be considered to be used by. + + +-------------------+ + | |pipeline_source| | + +-------------------+ + + - **Filters**: The filters in the pipeline. Click on *Add new filter* to add a new filter to the pipeline. + + + Clicking on the *Add filter* link will display a dialog in which the filter plugin can be chosen and named. + + +-----------------------+ + | |pipeline_filter_add| | + +-----------------------+ + + Clicking on next from this dialog will display the configuration for the chosen filter, in this case we have chosen the |ExpressionFilter| + + +--------------------------+ + | |pipeline_filter_config| | + +--------------------------+ + + The filter should then be configured in the same way as it would for data path pipelines. + + On clicking *Done* the dialog will disappear and the original screen shown with the new pipeline displayed. In the list of filters. More filters can be added by clicking on the *Add new filter* link. If multiple filters are in the pipeline that can be re-ordered by dragging them around to change the order. + + - **Destination**: The control destination that this pipeline will considered to be used with. + + +------------------------+ + | |pipeline_destination| | + +------------------------+ + + - **Enabled**: Enable the execution of the pipeline + +Finally click on the *Save* button to save the new control pipeline. + diff --git a/docs/images/control/pipeline_add.jpg b/docs/images/control/pipeline_add.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3f7ee3f9b432a0c4e4dd23748c123024288c43ff GIT binary patch literal 57595 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 literal 0 HcmV?d00001 diff --git a/docs/images/control/pipeline_destination.jpg b/docs/images/control/pipeline_destination.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1d0f347bc1b3aaab1de3cc74034fd6b3d8fe1561 GIT binary patch literal 20956 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 B02=@R literal 0 HcmV?d00001 diff --git a/docs/images/control/pipeline_filter_add.jpg b/docs/images/control/pipeline_filter_add.jpg new file mode 100644 index 0000000000000000000000000000000000000000..92cd069dd845237e23cc10327488cb94a1cc28ea GIT binary patch literal 58835 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/images/control/pipeline_menu.jpg b/docs/images/control/pipeline_menu.jpg new file mode 100644 index 0000000000000000000000000000000000000000..05e7ff304c4f775fe99b80be47f1bea70173ca79 GIT binary patch literal 11079 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 literal 0 HcmV?d00001 diff --git a/docs/images/control/pipeline_model.jpg b/docs/images/control/pipeline_model.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fd9c8be651313f839106c90409176b0c041b83c7 GIT binary patch literal 12799 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 literal 0 HcmV?d00001 diff --git a/docs/introduction.rst b/docs/introduction.rst index eba8aba2b5..96ac8eb165 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -9,7 +9,6 @@ .. |DataPipelines| raw:: html - Developing Data Pipelines Introduction to Fledge From 780601193eef66a601cdd6c0e3127f15c8bc3094 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Jul 2023 11:28:07 +0530 Subject: [PATCH 344/499] control role added in PostgreSQL engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/61.sql | 1 + scripts/plugins/storage/postgres/downgrade/62.sql | 4 ++++ scripts/plugins/storage/postgres/init.sql | 3 ++- scripts/plugins/storage/postgres/upgrade/62.sql | 1 + scripts/plugins/storage/postgres/upgrade/63.sql | 3 +++ 5 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins/storage/postgres/downgrade/61.sql create mode 100644 scripts/plugins/storage/postgres/downgrade/62.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/62.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/63.sql diff --git a/scripts/plugins/storage/postgres/downgrade/61.sql b/scripts/plugins/storage/postgres/downgrade/61.sql new file mode 100644 index 0000000000..88fd4c8096 --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/61.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-7980 changes \ No newline at end of file diff --git a/scripts/plugins/storage/postgres/downgrade/62.sql b/scripts/plugins/storage/postgres/downgrade/62.sql new file mode 100644 index 0000000000..8e92e980f1 --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/62.sql @@ -0,0 +1,4 @@ +-- Delete role +DELETE FROM fledge.roles WHERE name='control'; +-- Reset auto increment +ALTER SEQUENCE fledge.roles_id_seq RESTART WITH 5 \ No newline at end of file diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index eddde0c83c..d2b1e3fec5 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -918,7 +918,8 @@ INSERT INTO fledge.roles ( name, description ) VALUES ('admin', 'All CRUD privileges'), ('user', 'All CRUD operations and self profile management'), ('view', 'Only to view the configuration'), - ('data-view', 'Only read the data in buffer'); + ('data-view', 'Only read the data in buffer'), + ('control', 'Same as editor can do and also have access for control scripts and pipelines'); -- Users diff --git a/scripts/plugins/storage/postgres/upgrade/62.sql b/scripts/plugins/storage/postgres/upgrade/62.sql new file mode 100644 index 0000000000..88fd4c8096 --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/62.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-7980 changes \ No newline at end of file diff --git a/scripts/plugins/storage/postgres/upgrade/63.sql b/scripts/plugins/storage/postgres/upgrade/63.sql new file mode 100644 index 0000000000..8295ca306a --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/63.sql @@ -0,0 +1,3 @@ +-- Roles +INSERT INTO fledge.roles ( name, description ) + VALUES ('control', 'Same as editor can do and also have access for control scripts and pipelines'); From e7cb91205a3a6f6881ae9923db35d54a7045060b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Jul 2023 11:37:42 +0530 Subject: [PATCH 345/499] control role added for sqlitelb engine along with upgrade and downgrade scripts Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlitelb/downgrade/61.sql | 1 + scripts/plugins/storage/sqlitelb/downgrade/62.sql | 5 +++++ scripts/plugins/storage/sqlitelb/init.sql | 3 ++- scripts/plugins/storage/sqlitelb/upgrade/62.sql | 1 + scripts/plugins/storage/sqlitelb/upgrade/63.sql | 3 +++ 5 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/61.sql create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/62.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/62.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/63.sql diff --git a/scripts/plugins/storage/sqlitelb/downgrade/61.sql b/scripts/plugins/storage/sqlitelb/downgrade/61.sql new file mode 100644 index 0000000000..88fd4c8096 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/61.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-7980 changes \ No newline at end of file diff --git a/scripts/plugins/storage/sqlitelb/downgrade/62.sql b/scripts/plugins/storage/sqlitelb/downgrade/62.sql new file mode 100644 index 0000000000..112b7c25ae --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/62.sql @@ -0,0 +1,5 @@ +-- Delete roles +DELETE FROM fledge.roles WHERE name IN ('view','control'); +-- Reset auto increment +-- You cannot use ALTER TABLE for that. The autoincrement counter is stored in a separate table named "sqlite_sequence". You can modify the value there +UPDATE sqlite_sequence SET seq=1 WHERE name="roles"; diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index a8ae55077d..0870444f50 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -676,7 +676,8 @@ INSERT INTO fledge.roles ( name, description ) VALUES ('admin', 'All CRUD privileges'), ('user', 'All CRUD operations and self profile management'), ('view', 'Only to view the configuration'), - ('data-view', 'Only read the data in buffer'); + ('data-view', 'Only read the data in buffer'), + ('control', 'Same as editor can do and also have access for control scripts and pipelines'); -- Users DELETE FROM fledge.users; diff --git a/scripts/plugins/storage/sqlitelb/upgrade/62.sql b/scripts/plugins/storage/sqlitelb/upgrade/62.sql new file mode 100644 index 0000000000..88fd4c8096 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/62.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-7980 changes \ No newline at end of file diff --git a/scripts/plugins/storage/sqlitelb/upgrade/63.sql b/scripts/plugins/storage/sqlitelb/upgrade/63.sql new file mode 100644 index 0000000000..8295ca306a --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/63.sql @@ -0,0 +1,3 @@ +-- Roles +INSERT INTO fledge.roles ( name, description ) + VALUES ('control', 'Same as editor can do and also have access for control scripts and pipelines'); From fdcabfb1e0d0009fbfba1fd70964ce859115034e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Jul 2023 12:03:02 +0530 Subject: [PATCH 346/499] auth system tests updated Signed-off-by: ashish-jabble --- tests/system/python/api/test_authentication.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/system/python/api/test_authentication.py b/tests/system/python/api/test_authentication.py index 7985a39eaf..37507faf9d 100644 --- a/tests/system/python/api/test_authentication.py +++ b/tests/system/python/api/test_authentication.py @@ -114,7 +114,9 @@ def test_get_roles(self, fledge_url): assert {'roles': [{'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, - {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'} + {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'}, + {'id': 5, 'name': 'control', 'description': + 'Same as editor can do and also have access for control scripts and pipelines'} ]} == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ @@ -136,7 +138,14 @@ def test_get_roles(self, fledge_url): "description": "Only read the data in buffer"}, {'user': { 'userName': 'dataview', 'userId': 7, 'roleId': 4, 'accessMethod': 'any', 'realName': 'DataView', - 'description': 'Only read the data in buffer'}, 'message': 'dataview user has been created successfully.'}) + 'description': 'Only read the data in buffer'}, 'message': 'dataview user has been created successfully.'} + ), + ({"username": "control", "password": "C0ntrol!", "role_id": 5, "real_name": "Control", + "description": "Same as editor can do and also have access for control scripts and pipelines"}, + {'user': { + 'userName': 'control', 'userId': 8, 'roleId': 5, 'accessMethod': 'any', 'realName': 'Control', + 'description': 'Same as editor can do and also have access for control scripts and pipelines'}, + 'message': 'control user has been created successfully.'}) ]) def test_create_user(self, fledge_url, form_data, expected_values): conn = http.client.HTTPConnection(fledge_url) From 36e60e386d07b9b0b41275446ab5d25137232806 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 26 Jul 2023 15:11:50 +0530 Subject: [PATCH 347/499] API endpoints system tests added for Control user & also update control scripts and pipeline for other user types Signed-off-by: ashish-jabble --- ...est_endpoints_with_different_user_types.py | 186 +++++++++++++++++- 1 file changed, 185 insertions(+), 1 deletion(-) diff --git a/tests/system/python/api/test_endpoints_with_different_user_types.py b/tests/system/python/api/test_endpoints_with_different_user_types.py index f273399a42..40b4d19368 100644 --- a/tests/system/python/api/test_endpoints_with_different_user_types.py +++ b/tests/system/python/api/test_endpoints_with_different_user_types.py @@ -22,6 +22,8 @@ VIEW_PWD = "V!3w@1" DATA_VIEW_USERNAME = "dataview" DATA_VIEW_PWD = "DV!3w$" +CONTROL_USERNAME = "control" +CONTROL_PWD = "C0ntrol!" @pytest.fixture @@ -66,7 +68,6 @@ def test_setup(reset_and_start_fledge, change_to_auth_mandatory, fledge_url, wai r = r.read().decode() jdoc = json.loads(r) assert "{} user has been created successfully.".format(VIEW_USERNAME) == jdoc["message"] - # Create Data view user data_view_payload = {"username": DATA_VIEW_USERNAME, "password": DATA_VIEW_PWD, "role_id": 4, "real_name": "DataView", "description": "Only read the data in buffer"} @@ -77,6 +78,15 @@ def test_setup(reset_and_start_fledge, change_to_auth_mandatory, fledge_url, wai r = r.read().decode() jdoc = json.loads(r) assert "{} user has been created successfully.".format(DATA_VIEW_USERNAME) == jdoc["message"] + # Create Control user + control_payload = {"username": CONTROL_USERNAME, "password": CONTROL_PWD, "role_id": 5, "real_name": "Control", + "description": "Same as editor can do and also have access for control scripts and pipelines"} + conn.request("POST", "/fledge/admin/user", body=json.dumps(control_payload), headers={"authorization": admin_token}) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert "{} user has been created successfully.".format(CONTROL_USERNAME) == jdoc["message"] class TestAPIEndpointsWithViewUserType: @@ -196,6 +206,14 @@ def test_login(self, fledge_url, wait_time): ("POST", "/fledge/ACL", 403), ("GET", "/fledge/ACL", 200), ("GET", "/fledge/ACL/foo", 404), ("PUT", "/fledge/ACL/foo", 403), ("DELETE", "/fledge/ACL/foo", 403), ("PUT", "/fledge/service/foo/ACL", 403), ("DELETE", "/fledge/service/foo/ACL", 403), + # control script + ("POST", "/fledge/control/script", 403), ("GET", "/fledge/control/script", 200), + ("GET", "/fledge/control/script/foo", 404), ("PUT", "/fledge/control/script/foo", 403), + ("DELETE", "/fledge/control/script/foo", 403), ("POST", "/fledge/control/script/foo/schedule", 403), + # control pipeline + ("POST", "/fledge/control/pipeline", 403), ("GET", "/fledge/control/lookup", 200), + ("GET", "/fledge/control/pipeline", 200), ("GET", "/fledge/control/pipeline/1", 404), + ("PUT", "/fledge/control/pipeline/1", 403), ("DELETE", "/fledge/control/pipeline/1", 403), # python packages ("GET", "/fledge/python/packages", 200), ("POST", "/fledge/python/package", 403), # notification @@ -342,6 +360,14 @@ def test_login(self, fledge_url, wait_time): ("POST", "/fledge/ACL", 403), ("GET", "/fledge/ACL", 403), ("GET", "/fledge/ACL/foo", 403), ("PUT", "/fledge/ACL/foo", 403), ("DELETE", "/fledge/ACL/foo", 403), ("PUT", "/fledge/service/foo/ACL", 403), ("DELETE", "/fledge/service/foo/ACL", 403), + # control script + ("POST", "/fledge/control/script", 403), ("GET", "/fledge/control/script", 403), + ("GET", "/fledge/control/script/foo", 403), ("PUT", "/fledge/control/script/foo", 403), + ("DELETE", "/fledge/control/script/foo", 403), ("POST", "/fledge/control/script/foo/schedule", 403), + # control pipeline + ("POST", "/fledge/control/pipeline", 403), ("GET", "/fledge/control/lookup", 403), + ("GET", "/fledge/control/pipeline", 403), ("GET", "/fledge/control/pipeline/1", 403), + ("PUT", "/fledge/control/pipeline/1", 403), ("DELETE", "/fledge/control/pipeline/1", 403), # python packages ("GET", "/fledge/python/packages", 403), ("POST", "/fledge/python/package", 403), # notification @@ -371,3 +397,161 @@ def test_logout_me(self, fledge_url): r = r.read().decode() jdoc = json.loads(r) assert jdoc['logout'] + + +class TestAPIEndpointsWithControlUserType: + def test_login(self, fledge_url, wait_time): + time.sleep(wait_time * 2) + conn = http.client.HTTPConnection(fledge_url) + conn.request("POST", "/fledge/login", json.dumps({"username": CONTROL_USERNAME, "password": CONTROL_PWD})) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert "Logged in successfully." == jdoc['message'] + assert "token" in jdoc + assert not jdoc['admin'] + global TOKEN + TOKEN = jdoc["token"] + + @pytest.mark.parametrize(("method", "route_path", "http_status_code"), [ + # common + ("GET", "/fledge/ping", 200), # ("PUT", "/fledge/shutdown", 200), ("PUT", "/fledge/restart", 200), + # health + ("GET", "/fledge/health/storage", 200), ("GET", "/fledge/health/logging", 200), + # user & roles + ("GET", "/fledge/user", 200), ("PUT", "/fledge/user", 500), ("PUT", "/fledge/user/1/password", 500), + ("PUT", "/fledge/user/3/password", 500), ("GET", "/fledge/user/role", 200), + # auth + ("POST", "/fledge/login", 500), ("PUT", "/fledge/31/logout", 401), + ("GET", "/fledge/auth/ott", 200), + # admin + ("POST", "/fledge/admin/user", 403), ("DELETE", "/fledge/admin/3/delete", 403), ("PUT", "/fledge/admin/3", 403), + ("PUT", "/fledge/admin/3/enable", 403), ("PUT", "/fledge/admin/3/reset", 403), + # category + ("GET", "/fledge/category", 200), ("POST", "/fledge/category", 400), ("GET", "/fledge/category/General", 200), + ("PUT", "/fledge/category/General", 400), ("DELETE", "/fledge/category/General", 400), + ("POST", "/fledge/category/General/children", 500), ("GET", "/fledge/category/General/children", 200), + ("DELETE", "/fledge/category/General/children/Advanced", 200), + ("DELETE", "/fledge/category/General/parent", 200), + ("GET", "/fledge/category/rest_api/allowPing", 200), ("PUT", "/fledge/category/rest_api/allowPing", 500), + ("DELETE", "/fledge/category/rest_api/allowPing/value", 200), + ("POST", "/fledge/category/rest_api/allowPing/upload", 400), + # schedule processes & schedules + ("GET", "/fledge/schedule/process", 200), ("POST", "/fledge/schedule/process", 500), + ("GET", "/fledge/schedule/process/purge", 200), + ("GET", "/fledge/schedule", 200), ("POST", "/fledge/schedule", 400), ("GET", "/fledge/schedule/type", 200), + ("GET", "/fledge/schedule/2176eb68-7303-11e7-8cf7-a6006ad3dba0", 200), + ("PUT", "/fledge/schedule/2176eb68-7303-11e7-8cf7-a6006ad3dba0/enable", 200), + ("PUT", "/fledge/schedule/2176eb68-7303-11e7-8cf7-a6006ad3dba0/disable", 200), + ("PUT", "/fledge/schedule/enable", 404), ("PUT", "/fledge/schedule/disable", 404), + ("POST", "/fledge/schedule/start/2176eb68-7303-11e7-8cf7-a6006ad3dba0", 200), + ("PUT", "/fledge/schedule/2176eb68-7303-11e7-8cf7-a6006ad3dba0", 400), + ("DELETE", "/fledge/schedule/d1631422-9ec6-11e7-abc4-cec278b6b50a", 200), + # tasks + ("GET", "/fledge/task", 200), ("GET", "/fledge/task/state", 200), ("GET", "/fledge/task/latest", 200), + ("GET", "/fledge/task/123", 404), ("PUT", "/fledge/task/123/cancel", 404), + ("POST", "/fledge/scheduled/task", 400), ("DELETE", "/fledge/scheduled/task/blah", 404), + # service + ("POST", "/fledge/service", 400), ("GET", "/fledge/service", 200), ("DELETE", "/fledge/service/blah", 404), + # ("GET", "/fledge/service/available", 200), -- checked manually and commented out only to avoid apt-update + ("GET", "/fledge/service/installed", 200), + ("PUT", "/fledge/service/Southbound/blah/update", 400), ("POST", "/fledge/service/blah/otp", 403), + # south & north + ("GET", "/fledge/south", 200), ("GET", "/fledge/north", 200), + # asset browse + ("GET", "/fledge/asset", 200), ("GET", "/fledge/asset/sinusoid", 200), + ("GET", "/fledge/asset/sinusoid/latest", 200), + ("GET", "/fledge/asset/sinusoid/summary", 404), ("GET", "/fledge/asset/sinusoid/sinusoid", 200), + ("GET", "/fledge/asset/sinusoid/sinusoid/summary", 404), ("GET", "/fledge/asset/sinusoid/sinusoid/series", 200), + ("GET", "/fledge/asset/sinusoid/bucket/1", 200), ("GET", "/fledge/asset/sinusoid/sinusoid/bucket/1", 200), + ("GET", "/fledge/structure/asset", 200), ("DELETE", "/fledge/asset", 200), + ("DELETE", "/fledge/asset/sinusoid", 200), + # asset tracker + ("GET", "/fledge/track", 200), ("GET", "/fledge/track/storage/assets", 200), + ("PUT", "/fledge/track/service/foo/asset/bar/event/Ingest", 404), + # statistics + ("GET", "/fledge/statistics", 200), ("GET", "/fledge/statistics/history", 200), + ("GET", "/fledge/statistics/rate?periods=1&statistics=FOO", 200), + # audit trail + ("POST", "/fledge/audit", 500), ("GET", "/fledge/audit", 200), ("GET", "/fledge/audit/logcode", 200), + ("GET", "/fledge/audit/severity", 200), + # backup & restore + ("GET", "/fledge/backup", 200), # ("POST", "/fledge/backup", 200), -- checked manually + ("POST", "/fledge/backup/upload", 500), + ("GET", "/fledge/backup/status", 200), ("GET", "/fledge/backup/123", 404), + ("DELETE", "/fledge/backup/123", 404), ("GET", "/fledge/backup/123/download", 404), + ("PUT", "/fledge/backup/123/restore", 200), + # package update + # ("GET", "/fledge/update", 200), -- checked manually and commented out only to avoid apt-update run + # ("PUT", "/fledge/update", 200), -- checked manually + # certs store + ("GET", "/fledge/certificate", 200), ("POST", "/fledge/certificate", 400), + ("DELETE", "/fledge/certificate/user", 403), + # support bundle + ("GET", "/fledge/support", 200), ("GET", "/fledge/support/foo", 400), + # ("POST", "/fledge/support", 200), - checked manually + # syslogs & package logs + ("GET", "/fledge/syslog", 200), ("GET", "/fledge/package/log", 200), ("GET", "/fledge/package/log/foo", 400), + ("GET", "/fledge/package/install/status", 404), + # plugins + ("GET", "/fledge/plugins/installed", 200), + # ("GET", "/fledge/plugins/available", 200), -- checked manually and commented out only to avoid apt-update + ("POST", "/fledge/plugins", 400), ("PUT", "/fledge/plugins/south/sinusoid/update", 404), + ("DELETE", "/fledge/plugins/south/sinusoid", 404), ("GET", "/fledge/service/foo/persist", 404), + ("GET", "/fledge/service/foo/plugin/omf/data", 404), ("POST", "/fledge/service/foo/plugin/omf/data", 404), + ("DELETE", "/fledge/service/foo/plugin/omf/data", 404), + # filters + ("POST", "/fledge/filter", 404), ("PUT", "/fledge/filter/foo/pipeline", 404), + ("GET", "/fledge/filter/foo/pipeline", 404), ("GET", "/fledge/filter/bar", 404), ("GET", "/fledge/filter", 200), + ("DELETE", "/fledge/filter/foo/pipeline", 500), ("DELETE", "/fledge/filter/bar", 404), + # snapshots + ("GET", "/fledge/snapshot/plugins", 403), ("POST", "/fledge/snapshot/plugins", 403), + ("PUT", "/fledge/snapshot/plugins/1", 403), ("DELETE", "/fledge/snapshot/plugins/1", 403), + ("GET", "/fledge/snapshot/category", 403), ("POST", "/fledge/snapshot/category", 403), + ("PUT", "/fledge/snapshot/category/1", 403), ("DELETE", "/fledge/snapshot/category/1", 403), + ("GET", "/fledge/snapshot/schedule", 403), ("POST", "/fledge/snapshot/schedule", 403), + ("PUT", "/fledge/snapshot/schedule/1", 403), ("DELETE", "/fledge/snapshot/schedule/1", 403), + # repository + ("POST", "/fledge/repository", 400), + # ACL + ("POST", "/fledge/ACL", 403), ("GET", "/fledge/ACL", 200), ("GET", "/fledge/ACL/foo", 404), + ("PUT", "/fledge/ACL/foo", 403), ("DELETE", "/fledge/ACL/foo", 403), ("PUT", "/fledge/service/foo/ACL", 403), + ("DELETE", "/fledge/service/foo/ACL", 403), + # control script + ("POST", "/fledge/control/script", 400), ("GET", "/fledge/control/script", 200), + ("GET", "/fledge/control/script/foo", 404), ("PUT", "/fledge/control/script/foo", 400), + ("DELETE", "/fledge/control/script/foo", 404), ("POST", "/fledge/control/script/foo/schedule", 404), + # control pipeline + ("POST", "/fledge/control/pipeline", 400), ("GET", "/fledge/control/lookup", 200), + ("GET", "/fledge/control/pipeline", 200), ("GET", "/fledge/control/pipeline/1", 404), + ("PUT", "/fledge/control/pipeline/1", 404), ("DELETE", "/fledge/control/pipeline/1", 404), + # python packages + ("GET", "/fledge/python/packages", 200), ("POST", "/fledge/python/package", 500), + # notification + ("GET", "/fledge/notification", 200), ("GET", "/fledge/notification/plugin", 404), + ("GET", "/fledge/notification/type", 200), ("GET", "/fledge/notification/N1", 400), + ("POST", "/fledge/notification", 404), ("PUT", "/fledge/notification/N1", 404), + ("DELETE", "/fledge/notification/N1", 404), ("GET", "/fledge/notification/N1/delivery", 404), + ("POST", "/fledge/notification/N1/delivery", 400), ("GET", "/fledge/notification/N1/delivery/C1", 404), + ("DELETE", "/fledge/notification/N1/delivery/C1", 404) + ]) + def test_endpoints(self, fledge_url, method, route_path, http_status_code, storage_plugin): + # FIXME: Once below JIRA is resolved + if storage_plugin == 'postgres': + if route_path == '/fledge/statistics/rate?periods=1&statistics=FOO': + pytest.skip('Due to FOGL-7097') + conn = http.client.HTTPConnection(fledge_url) + conn.request(method, route_path, headers={"authorization": TOKEN}) + r = conn.getresponse() + assert http_status_code == r.status + r.read().decode() + + def test_logout_me(self, fledge_url): + conn = http.client.HTTPConnection(fledge_url) + conn.request("PUT", '/fledge/logout', headers={"authorization": TOKEN}) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert jdoc['logout'] From f4eb183a6b2d106bd230065710eafacd93fe9dcb Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 26 Jul 2023 15:39:54 +0100 Subject: [PATCH 348/499] FOGL-7980 Initial performance counter implemetation (#1123) * FOGL-7980 Initial perforamnce coutner implemetation Signed-off-by: Mark Riddoch * Added flow control counters Signed-off-by: Mark Riddoch * Fix typos Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/services/common/include/perfmonitors.h | 66 +++++++ C/services/common/perfmonitor.cpp | 170 ++++++++++++++++++ C/services/south/include/ingest.h | 6 + C/services/south/include/south_service.h | 3 +- C/services/south/ingest.cpp | 15 +- C/services/south/south.cpp | 28 ++- VERSION | 2 +- scripts/plugins/storage/postgres/init.sql | 14 ++ .../plugins/storage/postgres/upgrade/62.sql | 11 ++ scripts/plugins/storage/sqlite/init.sql | 12 ++ scripts/plugins/storage/sqlite/upgrade/62.sql | 13 ++ scripts/plugins/storage/sqlitelb/init.sql | 13 ++ .../plugins/storage/sqlitelb/upgrade/62.sql | 13 ++ 13 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 C/services/common/include/perfmonitors.h create mode 100644 C/services/common/perfmonitor.cpp create mode 100644 scripts/plugins/storage/postgres/upgrade/62.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/62.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/62.sql diff --git a/C/services/common/include/perfmonitors.h b/C/services/common/include/perfmonitors.h new file mode 100644 index 0000000000..8ed24699f8 --- /dev/null +++ b/C/services/common/include/perfmonitors.h @@ -0,0 +1,66 @@ +#ifndef _PERFMONITOR_H +#define _PERFMONITOR_H +/* + * Fledge performance monitor + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ +#include +#include +#include +#include +#include +#include + +class PerfMon { + public: + PerfMon(const std::string& name); + void addValue(long value); + int getValues(InsertValues& values); + private: + std::string m_name; + long m_average; + long m_min; + long m_max; + int m_samples; + std::mutex m_mutex; +}; +/** + * Class to handle the performance monitors + */ +class PerformanceMonitor { + public: + PerformanceMonitor(const std::string& service, StorageClient *storage); + ~PerformanceMonitor(); + /** + * Collect a performance monitor + * + * @param name Name of the monitor + * @param calue Value of the monitor + */ + inline void collect(const std::string& name, long value) + { + if (m_collecting) + { + doCollection(name, value); + } + }; + void setCollecting(bool state); + void writeThread(); + private: + void doCollection(const std::string& name, long value); + private: + std::string m_service; + StorageClient *m_storage; + std::thread *m_thread; + bool m_collecting; + std::unordered_map + m_monitors; + std::condition_variable m_cv; + std::mutex m_mutex; +}; +#endif diff --git a/C/services/common/perfmonitor.cpp b/C/services/common/perfmonitor.cpp new file mode 100644 index 0000000000..2612d2edaf --- /dev/null +++ b/C/services/common/perfmonitor.cpp @@ -0,0 +1,170 @@ +/* + * Fledge storage service client + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ +#include +#include + +using namespace std; + +/** + * Constructor for an individual performance monitor + * + * @param name The name of the performance monitor + */ +PerfMon::PerfMon(const string& name) : m_name(name), m_samples(0) +{ +} + +/** + * Collect a new value for the performance monitor + * + * @param value The new value + */ +void PerfMon::addValue(long value) +{ + lock_guard guard(m_mutex); + if (m_samples) + { + if (value < m_min) + m_min = value; + else if (value > m_max) + m_max = value; + m_average = ((m_samples * m_average) + value) / (m_samples + 1); + m_samples++; + } + else + { + m_min = value; + m_max = value; + m_average = value; + m_samples = 1; + } +} + +/** + * Return the performance values to insert + * + */ +int PerfMon::getValues(InsertValues& values) +{ + lock_guard guard(m_mutex); + if (m_samples == 0) + return 0; + values.push_back(InsertValue("minimum", m_min)); + values.push_back(InsertValue("maximum", m_max)); + values.push_back(InsertValue("average", m_average)); + values.push_back(InsertValue("samples", m_samples)); + m_min = 0; + m_max = 0; + m_average = 0; + int samples = m_samples; + m_samples = 0; + return samples; +} + +/** + * Constructor for the performance monitors + * + * @param service The name of the service + * @param storage Point to the storage client class for the service + */ +PerformanceMonitor::PerformanceMonitor(const string& service, StorageClient *storage) : + m_service(service), m_storage(storage), m_collecting(false), m_thread(NULL) +{ +} + +/** + * Destructor for the performance monitor + */ +PerformanceMonitor::~PerformanceMonitor() +{ +} + +/** + * Monitor thread entry point + * + * @param perfMon The perforamnce monitore class + */ +static void monitorThread(PerformanceMonitor *perfMon) +{ + perfMon->writeThread(); +} + +/** + * Set the collection state of the performance monitors + * + * @param state The required collection state + */ +void PerformanceMonitor::setCollecting(bool state) +{ + m_collecting = state; + if (m_collecting && m_thread == NULL) + { + // Start the thread to write the monitors to the database + m_thread = new thread(monitorThread, this); + } + else if (m_collecting == false && m_thread) + { + // Stop the thread to write the monitors to the database + m_cv.notify_all(); + m_thread->join(); + m_thread = NULL; + } +} + +/** + * Add a new value to the named performance monitor + * + * @param name The name of the performance monitor + * @param value The value to add + */ +void PerformanceMonitor::doCollection(const string& name, long value) +{ + PerfMon *mon; + auto it = m_monitors.find(name); + if (it == m_monitors.end()) + { + // Create a new monitor + mon = new PerfMon(name); + m_monitors[name] = mon; + } + else + { + mon = it->second; + } + mon->addValue(value); +} + +/** + * The thread that runs to write database values + */ +void PerformanceMonitor::writeThread() +{ + while (m_collecting) + { + unique_lock lk(m_mutex); + m_cv.wait_for(lk, chrono::seconds(60)); + if (m_collecting) + { + // Write to the database + for (const auto& it : m_monitors) + { + string name = it.first; + PerfMon *mon = it.second; + InsertValues values; + if (mon->getValues(values) > 0) + { + values.push_back(InsertValue("service", m_service)); + values.push_back(InsertValue("monitor", name)); + m_storage->insertTable("monitors", values); + } + } + } + } +} diff --git a/C/services/south/include/ingest.h b/C/services/south/include/ingest.h index bf50035bcf..3b5dfbf157 100644 --- a/C/services/south/include/ingest.h +++ b/C/services/south/include/ingest.h @@ -25,6 +25,7 @@ #include #include #include +#include #define SERVICE_NAME "Fledge South" @@ -94,6 +95,10 @@ class Ingest : public ServiceHandler { std::string getStringFromSet(const std::set &dpSet); void setFlowControl(unsigned int lowWater, unsigned int highWater) { m_lowWater = lowWater; m_highWater = highWater; }; void flowControl(); + void setPerfMon(PerformanceMonitor *mon) + { + m_performance = mon; + }; private: void signalStatsUpdate() { @@ -150,6 +155,7 @@ class Ingest : public ServiceHandler { AssetTrackingTable *m_deprecated; time_t m_deprecatedAgeOut; time_t m_deprecatedAgeOutStorage; + PerformanceMonitor *m_performance; }; #endif diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index 2be3a886d2..34b631b035 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -18,6 +18,7 @@ #include #include #include +#include #define MAX_SLEEP 5 // Maximum number of seconds the service will sleep during a poll cycle @@ -120,6 +121,6 @@ class SouthService : public ServiceAuthHandler { std::mutex m_pollMutex; bool m_doPoll; AuditLogger *m_auditLogger; - + PerformanceMonitor *m_perfMonitor; }; #endif diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index aa1d3e76e7..710ee5e781 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -390,6 +390,8 @@ bool Ingest::isStopping() /** * Add a reading to the reading queue + * + * @param reading The single reading to ingest */ void Ingest::ingest(const Reading& reading) { @@ -411,10 +413,13 @@ vector *fullQueue = 0; } if (m_fullQueues.size()) m_cv.notify_all(); + m_performance->collect("queueLength", (long)queueLength()); } /** * Add a set of readings to the reading queue + * + * @param vec A vector of readings to ingest */ void Ingest::ingest(const vector *vec) { @@ -452,14 +457,16 @@ unsigned int nFullQueues = 0; { m_cv.notify_all(); } + m_performance->collect("queueLength", (long)queueLength()); + m_performance->collect("ingestCount", (long)vec->size()); } /** * Work out how long to wait based on age of oldest queued reading - * We do this in a seperaste function so that we can - * lock the qMutex to access the oldest element in the queue + * We do this in a separate function so that we can lock the qMutex + * to access the oldest element in the queue * - * @return the tiem to wait + * @return the time to wait */ long Ingest::calculateWaitTime() { @@ -745,6 +752,7 @@ void Ingest::processQueue() firstReading->getUserTimestamp(&tmFirst); timersub(&tmNow, &tmFirst, &dur); long latency = dur.tv_sec * 1000 + (dur.tv_usec / 1000); + m_performance->collect("readLatency", latency); if (latency > m_timeout && m_highLatency == false) { m_logger->warn("Current send latency of %ldmS exceeds requested maximum latency of %dmS", latency, m_timeout); @@ -1439,5 +1447,6 @@ void Ingest::flowControl() } m_logger->debug("Ingest queue has %s", queueLength() > m_lowWater ? "failed to drain in sufficient time" : "has drained"); + m_performance->collect("flow controlled", total); } } diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 48a773fdf8..e56eb17cb5 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -231,7 +231,8 @@ SouthService::SouthService(const string& myName, const string& token) : m_token(token), m_repeatCnt(1), m_dryRun(false), - m_requestRestart(false) + m_requestRestart(false), + m_perfMonitor(NULL) { m_name = myName; m_type = SERVICE_TYPE; @@ -335,6 +336,8 @@ void SouthService::start(string& coreAddress, unsigned short corePort) StorageClient storage(storageRecord.getAddress(), storageRecord.getPort()); storage.registerManagement(m_mgtClient); + + m_perfMonitor = new PerformanceMonitor(m_name, &storage); unsigned int threshold = 100; long timeout = 5000; std::string pluginName; @@ -391,6 +394,7 @@ void SouthService::start(string& coreAddress, unsigned short corePort) { // Instantiate the Ingest class Ingest ingest(storage, m_name, pluginName, m_mgtClient); + ingest.setPerfMon(m_perfMonitor); m_ingest = &ingest; if (m_throttle) { @@ -402,6 +406,15 @@ void SouthService::start(string& coreAddress, unsigned short corePort) m_ingest->setStatistics(m_configAdvanced.getValue("statistics")); } + if (m_configAdvanced.itemExists("perfmon")) + { + string perf = m_configAdvanced.getValue("perfmon"); + if (perf.compare("true") == 0) + m_perfMonitor->setCollecting(true); + else + m_perfMonitor->setCollecting(false); + } + m_ingest->start(timeout, threshold); // Start the ingest threads running try { @@ -850,6 +863,14 @@ void SouthService::processConfigChange(const string& categoryName, const string& { m_ingest->setStatistics(m_configAdvanced.getValue("statistics")); } + if (m_configAdvanced.itemExists("perfmon")) + { + string perf = m_configAdvanced.getValue("perfmon"); + if (perf.compare("true") == 0) + m_perfMonitor->setCollecting(true); + else + m_perfMonitor->setCollecting(false); + } if (! southPlugin->isAsync()) { try { @@ -1148,6 +1169,9 @@ void SouthService::addConfigDefaults(DefaultConfigCategory& defaultConfig) defaultConfig.addItem("statistics", "Collect statistics either for every asset ingested, for the service in total or both", "per asset & service", "per asset & service", statistics); defaultConfig.setItemDisplayName("statistics", "Statistics Collection"); + defaultConfig.addItem("perfmon", "Track and store performance counters", + "boolean", "false", "false"); + defaultConfig.setItemDisplayName("perfmon", "Performance Counters"); } /** @@ -1240,6 +1264,7 @@ struct timeval now, res; m_lastThrottle = now; m_throttled = true; logger->warn("%s Throttled down poll, rate is now %.1f%% of desired rate", m_name.c_str(), (desired * 100) / rate); + m_perfMonitor->collect("throttled rate", (long)(rate * 1000)); } else if (m_throttled && m_ingest->queueLength() < m_lowWater && res.tv_sec > SOUTH_THROTTLE_UP_INTERVAL) { @@ -1272,6 +1297,7 @@ struct timeval now, res; { logger->warn("%s Throttled up poll, rate is now %.1f%% of desired rate", m_name.c_str(), (desired * 100) / rate); } + m_perfMonitor->collect("throttled rate", (long)(rate * 1000)); close(m_timerfd); m_timerfd = createTimerFd(m_currentRate); // interval to be passed is in usecs m_lastThrottle = now; diff --git a/VERSION b/VERSION index 41f503bdb2..01edee910f 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.1.0 -fledge_schema=61 +fledge_schema=62 diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index eddde0c83c..252a6153dd 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -1160,3 +1160,17 @@ INSERT INTO fledge.control_destination ( name, description ) ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); + + +CREATE TABLE fledge.monitors ( + service character varying(80) NOT NULL, -- + monitor character varying(80) NOT NULL, + minimum bigint, + maximum bigint, + average bigint, + samples bigint, + timestamp timestamp(6) with time zone NOT NULL DEFAULT now() + +-- Index: log_ix1 - For queries by code +CREATE INDEX fledge.monitor_ix1 + ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/postgres/upgrade/62.sql b/scripts/plugins/storage/postgres/upgrade/62.sql new file mode 100644 index 0000000000..94e331707b --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/62.sql @@ -0,0 +1,11 @@ +CREATE TABLE fledge.monitors ( + service character varying(80) NOT NULL, -- + monitor character varying(80) NOT NULL, + minimum bigint, + maximum bigint, + average bigint, + samples bigint, + timestamp timestamp(6) with time zone NOT NULL DEFAULT now() + +CREATE INDEX fledge.monitor_ix1 + ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 9614b3dd26..874d7bfd60 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -916,3 +916,15 @@ INSERT INTO fledge.control_destination ( name, description ) ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); + +CREATE TABLE fledge.monitors ( + service character varying(80) NOT NULL, -- + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))); + +CREATE INDEX fledge.monitor_ix1 + ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlite/upgrade/62.sql b/scripts/plugins/storage/sqlite/upgrade/62.sql new file mode 100644 index 0000000000..2d6dfe6cf2 --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/62.sql @@ -0,0 +1,13 @@ + +CREATE TABLE fledge.monitors ( + service character varying(80) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))); + + +CREATE INDEX fledge.monitor_ix1 + ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index a8ae55077d..3c668b22c0 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -915,3 +915,16 @@ INSERT INTO fledge.control_destination ( name, description ) ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); + +CREATE TABLE fledge.monitors ( + service character varying(80) NOT NULL, -- + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) +); + +CREATE INDEX fledge.monitor_ix1 + ON log(service, monitor); diff --git a/scripts/plugins/storage/sqlitelb/upgrade/62.sql b/scripts/plugins/storage/sqlitelb/upgrade/62.sql new file mode 100644 index 0000000000..273e171ada --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/62.sql @@ -0,0 +1,13 @@ + +CREATE TABLE fledge.monitors ( + service character varying(80) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) +); + +CREATE INDEX fledge.monitor_ix1 + ON monitors(service, monitor); From e89255feb81aafd4698466f74b6d1dec2b255099 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 26 Jul 2023 17:08:46 +0100 Subject: [PATCH 349/499] FOGL-7994 fix SQL scripts (#1125) Signed-off-by: Mark Riddoch --- scripts/plugins/storage/postgres/init.sql | 4 ++-- scripts/plugins/storage/sqlite/init.sql | 4 ++-- scripts/plugins/storage/sqlite/upgrade/62.sql | 4 ++-- scripts/plugins/storage/sqlitelb/init.sql | 4 ++-- scripts/plugins/storage/sqlitelb/upgrade/62.sql | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 252a6153dd..871b9cb8bf 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -1163,7 +1163,7 @@ INSERT INTO fledge.control_destination ( name, description ) CREATE TABLE fledge.monitors ( - service character varying(80) NOT NULL, -- + service character varying(255) NOT NULL, -- monitor character varying(80) NOT NULL, minimum bigint, maximum bigint, @@ -1172,5 +1172,5 @@ CREATE TABLE fledge.monitors ( timestamp timestamp(6) with time zone NOT NULL DEFAULT now() -- Index: log_ix1 - For queries by code -CREATE INDEX fledge.monitor_ix1 +CREATE INDEX monitor_ix1 ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 874d7bfd60..6382156039 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -918,7 +918,7 @@ INSERT INTO fledge.control_destination ( name, description ) ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); CREATE TABLE fledge.monitors ( - service character varying(80) NOT NULL, -- + service character varying(255) NOT NULL, -- monitor character varying(80) NOT NULL, minimum integer, maximum integer, @@ -926,5 +926,5 @@ CREATE TABLE fledge.monitors ( samples integer, timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))); -CREATE INDEX fledge.monitor_ix1 +CREATE INDEX monitor_ix1 ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlite/upgrade/62.sql b/scripts/plugins/storage/sqlite/upgrade/62.sql index 2d6dfe6cf2..0ab7c0f449 100644 --- a/scripts/plugins/storage/sqlite/upgrade/62.sql +++ b/scripts/plugins/storage/sqlite/upgrade/62.sql @@ -1,6 +1,6 @@ CREATE TABLE fledge.monitors ( - service character varying(80) NOT NULL, + service character varying(255) NOT NULL, monitor character varying(80) NOT NULL, minimum integer, maximum integer, @@ -9,5 +9,5 @@ CREATE TABLE fledge.monitors ( timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))); -CREATE INDEX fledge.monitor_ix1 +CREATE INDEX monitor_ix1 ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 3c668b22c0..f3e8700380 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -917,7 +917,7 @@ INSERT INTO fledge.control_destination ( name, description ) ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); CREATE TABLE fledge.monitors ( - service character varying(80) NOT NULL, -- + service character varying(255) NOT NULL, -- monitor character varying(80) NOT NULL, minimum integer, maximum integer, @@ -926,5 +926,5 @@ CREATE TABLE fledge.monitors ( timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) ); -CREATE INDEX fledge.monitor_ix1 +CREATE INDEX monitor_ix1 ON log(service, monitor); diff --git a/scripts/plugins/storage/sqlitelb/upgrade/62.sql b/scripts/plugins/storage/sqlitelb/upgrade/62.sql index 273e171ada..87520ab6d6 100644 --- a/scripts/plugins/storage/sqlitelb/upgrade/62.sql +++ b/scripts/plugins/storage/sqlitelb/upgrade/62.sql @@ -1,6 +1,6 @@ CREATE TABLE fledge.monitors ( - service character varying(80) NOT NULL, + service character varying(255) NOT NULL, monitor character varying(80) NOT NULL, minimum integer, maximum integer, @@ -9,5 +9,5 @@ CREATE TABLE fledge.monitors ( timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) ); -CREATE INDEX fledge.monitor_ix1 - ON monitors(service, monitor); +CREATE INDEX monitor_ix1 + ON fledge.monitors(service, monitor); From 0f34a39f321ba862b1757e3cbb351538b036693f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 12:02:49 +0530 Subject: [PATCH 350/499] Syntax query fixes in PostgreSQL Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/init.sql | 27 +++++++++---------- .../plugins/storage/postgres/upgrade/62.sql | 6 ++--- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 871b9cb8bf..7771a1c424 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -905,6 +905,19 @@ CREATE TABLE fledge.control_filters ( CONSTRAINT control_filters_fk1 FOREIGN KEY (cpid) REFERENCES fledge.control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION ); +CREATE TABLE fledge.monitors ( + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum bigint, + maximum bigint, + average bigint, + samples bigint, + timestamp timestamp(6) with time zone NOT NULL DEFAULT now()); + +CREATE INDEX monitors_ix1 + ON fledge.monitors(service, monitor); + + -- Grants to fledge schema GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA fledge TO PUBLIC; @@ -1160,17 +1173,3 @@ INSERT INTO fledge.control_destination ( name, description ) ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); - - -CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, -- - monitor character varying(80) NOT NULL, - minimum bigint, - maximum bigint, - average bigint, - samples bigint, - timestamp timestamp(6) with time zone NOT NULL DEFAULT now() - --- Index: log_ix1 - For queries by code -CREATE INDEX monitor_ix1 - ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/postgres/upgrade/62.sql b/scripts/plugins/storage/postgres/upgrade/62.sql index 94e331707b..1c3a3a9216 100644 --- a/scripts/plugins/storage/postgres/upgrade/62.sql +++ b/scripts/plugins/storage/postgres/upgrade/62.sql @@ -1,11 +1,11 @@ CREATE TABLE fledge.monitors ( - service character varying(80) NOT NULL, -- + service character varying(255) NOT NULL, monitor character varying(80) NOT NULL, minimum bigint, maximum bigint, average bigint, samples bigint, - timestamp timestamp(6) with time zone NOT NULL DEFAULT now() + timestamp timestamp(6) with time zone NOT NULL DEFAULT now()); -CREATE INDEX fledge.monitor_ix1 +CREATE INDEX monitors_ix1 ON fledge.monitors(service, monitor); From 35258cf3aeced1185124a226082e262f2f392492 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 12:04:21 +0530 Subject: [PATCH 351/499] DDL queries moved from DML phase intialization & other syntax query for creating INDEX fixes of both sqlite and sqlitelb engines Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlite/init.sql | 24 +++++++++-------- scripts/plugins/storage/sqlite/upgrade/62.sql | 4 +-- scripts/plugins/storage/sqlitelb/init.sql | 26 ++++++++++--------- .../plugins/storage/sqlitelb/upgrade/62.sql | 4 +-- 4 files changed, 31 insertions(+), 27 deletions(-) diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 6382156039..cec876222d 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -666,6 +666,19 @@ CREATE TABLE fledge.control_filters ( REFERENCES control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION ); +-- Create monitors table +CREATE TABLE fledge.monitors ( + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))); + +CREATE INDEX monitors_ix1 + ON monitors(service, monitor); + ---------------------------------------------------------------------- -- Initialization phase - DML ---------------------------------------------------------------------- @@ -917,14 +930,3 @@ INSERT INTO fledge.control_destination ( name, description ) ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); -CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, -- - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))); - -CREATE INDEX monitor_ix1 - ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlite/upgrade/62.sql b/scripts/plugins/storage/sqlite/upgrade/62.sql index 0ab7c0f449..a5d892895d 100644 --- a/scripts/plugins/storage/sqlite/upgrade/62.sql +++ b/scripts/plugins/storage/sqlite/upgrade/62.sql @@ -9,5 +9,5 @@ CREATE TABLE fledge.monitors ( timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))); -CREATE INDEX monitor_ix1 - ON fledge.monitors(service, monitor); +CREATE INDEX monitors_ix1 + ON monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index f3e8700380..5eb1cde1ac 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -666,6 +666,20 @@ CREATE TABLE fledge.control_filters ( REFERENCES control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION ); +-- Create monitors table +CREATE TABLE fledge.monitors ( + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) +); + +CREATE INDEX monitors_ix1 + ON monitors(service, monitor); + ---------------------------------------------------------------------- -- Initialization phase - DML ---------------------------------------------------------------------- @@ -916,15 +930,3 @@ INSERT INTO fledge.control_destination ( name, description ) ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); -CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, -- - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) -); - -CREATE INDEX monitor_ix1 - ON log(service, monitor); diff --git a/scripts/plugins/storage/sqlitelb/upgrade/62.sql b/scripts/plugins/storage/sqlitelb/upgrade/62.sql index 87520ab6d6..a27635eb96 100644 --- a/scripts/plugins/storage/sqlitelb/upgrade/62.sql +++ b/scripts/plugins/storage/sqlitelb/upgrade/62.sql @@ -9,5 +9,5 @@ CREATE TABLE fledge.monitors ( timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) ); -CREATE INDEX monitor_ix1 - ON fledge.monitors(service, monitor); +CREATE INDEX monitors_ix1 + ON monitors(service, monitor); From 09268e4c367a3a9641becc2700e2dbdee70c53aa Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 12:13:16 +0530 Subject: [PATCH 352/499] Downgrade scripts added for schema number 62 i.e 61.sql files Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/61.sql | 2 ++ scripts/plugins/storage/sqlite/downgrade/61.sql | 2 ++ scripts/plugins/storage/sqlitelb/downgrade/61.sql | 2 ++ 3 files changed, 6 insertions(+) create mode 100644 scripts/plugins/storage/postgres/downgrade/61.sql create mode 100644 scripts/plugins/storage/sqlite/downgrade/61.sql create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/61.sql diff --git a/scripts/plugins/storage/postgres/downgrade/61.sql b/scripts/plugins/storage/postgres/downgrade/61.sql new file mode 100644 index 0000000000..66236db6c4 --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/61.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS fledge.monitors; +DROP INDEX IF EXISTS fledge.monitors_ix1; diff --git a/scripts/plugins/storage/sqlite/downgrade/61.sql b/scripts/plugins/storage/sqlite/downgrade/61.sql new file mode 100644 index 0000000000..66236db6c4 --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/61.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS fledge.monitors; +DROP INDEX IF EXISTS fledge.monitors_ix1; diff --git a/scripts/plugins/storage/sqlitelb/downgrade/61.sql b/scripts/plugins/storage/sqlitelb/downgrade/61.sql new file mode 100644 index 0000000000..66236db6c4 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/61.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS fledge.monitors; +DROP INDEX IF EXISTS fledge.monitors_ix1; From e7aa60b49709983f9dafda4c9db9fb89fbd2ec60 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 13:00:15 +0530 Subject: [PATCH 353/499] timestamp column renamed to ts and fixes for UTC in sqlite and sqlitelb Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/init.sql | 15 ++++++++------- scripts/plugins/storage/postgres/upgrade/62.sql | 15 ++++++++------- scripts/plugins/storage/sqlite/init.sql | 15 ++++++++------- scripts/plugins/storage/sqlite/upgrade/62.sql | 15 ++++++++------- scripts/plugins/storage/sqlitelb/init.sql | 16 ++++++++-------- scripts/plugins/storage/sqlitelb/upgrade/62.sql | 14 +++++++------- 6 files changed, 47 insertions(+), 43 deletions(-) diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 7771a1c424..b1f0aed56b 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -906,13 +906,14 @@ CREATE TABLE fledge.control_filters ( ); CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum bigint, - maximum bigint, - average bigint, - samples bigint, - timestamp timestamp(6) with time zone NOT NULL DEFAULT now()); + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum bigint, + maximum bigint, + average bigint, + samples bigint, + ts timestamp(6) with time zone NOT NULL DEFAULT now() + ); CREATE INDEX monitors_ix1 ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/postgres/upgrade/62.sql b/scripts/plugins/storage/postgres/upgrade/62.sql index 1c3a3a9216..5339a4841f 100644 --- a/scripts/plugins/storage/postgres/upgrade/62.sql +++ b/scripts/plugins/storage/postgres/upgrade/62.sql @@ -1,11 +1,12 @@ CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum bigint, - maximum bigint, - average bigint, - samples bigint, - timestamp timestamp(6) with time zone NOT NULL DEFAULT now()); + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum bigint, + maximum bigint, + average bigint, + samples bigint, + ts timestamp(6) with time zone NOT NULL DEFAULT now() +); CREATE INDEX monitors_ix1 ON fledge.monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index cec876222d..17cc44673e 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -668,13 +668,14 @@ CREATE TABLE fledge.control_filters ( -- Create monitors table CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))); + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) + ); CREATE INDEX monitors_ix1 ON monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlite/upgrade/62.sql b/scripts/plugins/storage/sqlite/upgrade/62.sql index a5d892895d..ed9ba30f3a 100644 --- a/scripts/plugins/storage/sqlite/upgrade/62.sql +++ b/scripts/plugins/storage/sqlite/upgrade/62.sql @@ -1,12 +1,13 @@ CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))); + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) +); CREATE INDEX monitors_ix1 diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 5eb1cde1ac..a9625d4fa7 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -668,14 +668,14 @@ CREATE TABLE fledge.control_filters ( -- Create monitors table CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) -); + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) + ); CREATE INDEX monitors_ix1 ON monitors(service, monitor); diff --git a/scripts/plugins/storage/sqlitelb/upgrade/62.sql b/scripts/plugins/storage/sqlitelb/upgrade/62.sql index a27635eb96..05fa8cb26c 100644 --- a/scripts/plugins/storage/sqlitelb/upgrade/62.sql +++ b/scripts/plugins/storage/sqlitelb/upgrade/62.sql @@ -1,12 +1,12 @@ CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - timestamp DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) ); CREATE INDEX monitors_ix1 From d54b93194c9c4be84299c3d0a455c440a740138b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 13:06:41 +0530 Subject: [PATCH 354/499] indentation fixes Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/init.sql | 12 ++++++------ scripts/plugins/storage/postgres/upgrade/62.sql | 12 ++++++------ scripts/plugins/storage/sqlite/init.sql | 14 +++++++------- scripts/plugins/storage/sqlite/upgrade/62.sql | 14 +++++++------- scripts/plugins/storage/sqlitelb/init.sql | 14 +++++++------- scripts/plugins/storage/sqlitelb/upgrade/62.sql | 14 +++++++------- 6 files changed, 40 insertions(+), 40 deletions(-) diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index b1f0aed56b..5233071c2a 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -906,12 +906,12 @@ CREATE TABLE fledge.control_filters ( ); CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum bigint, - maximum bigint, - average bigint, - samples bigint, + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum bigint, + maximum bigint, + average bigint, + samples bigint, ts timestamp(6) with time zone NOT NULL DEFAULT now() ); diff --git a/scripts/plugins/storage/postgres/upgrade/62.sql b/scripts/plugins/storage/postgres/upgrade/62.sql index 5339a4841f..021f4297aa 100644 --- a/scripts/plugins/storage/postgres/upgrade/62.sql +++ b/scripts/plugins/storage/postgres/upgrade/62.sql @@ -1,10 +1,10 @@ CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum bigint, - maximum bigint, - average bigint, - samples bigint, + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum bigint, + maximum bigint, + average bigint, + samples bigint, ts timestamp(6) with time zone NOT NULL DEFAULT now() ); diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 17cc44673e..7772d7f0f1 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -668,13 +668,13 @@ CREATE TABLE fledge.control_filters ( -- Create monitors table CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) ); CREATE INDEX monitors_ix1 diff --git a/scripts/plugins/storage/sqlite/upgrade/62.sql b/scripts/plugins/storage/sqlite/upgrade/62.sql index ed9ba30f3a..fec0571be1 100644 --- a/scripts/plugins/storage/sqlite/upgrade/62.sql +++ b/scripts/plugins/storage/sqlite/upgrade/62.sql @@ -1,12 +1,12 @@ CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) ); diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index a9625d4fa7..9a73ecfdfd 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -668,13 +668,13 @@ CREATE TABLE fledge.control_filters ( -- Create monitors table CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) ); CREATE INDEX monitors_ix1 diff --git a/scripts/plugins/storage/sqlitelb/upgrade/62.sql b/scripts/plugins/storage/sqlitelb/upgrade/62.sql index 05fa8cb26c..9119862285 100644 --- a/scripts/plugins/storage/sqlitelb/upgrade/62.sql +++ b/scripts/plugins/storage/sqlitelb/upgrade/62.sql @@ -1,12 +1,12 @@ CREATE TABLE fledge.monitors ( - service character varying(255) NOT NULL, - monitor character varying(80) NOT NULL, - minimum integer, - maximum integer, - average integer, - samples integer, - ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) + service character varying(255) NOT NULL, + monitor character varying(80) NOT NULL, + minimum integer, + maximum integer, + average integer, + samples integer, + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')) ); CREATE INDEX monitors_ix1 From a2e7d8b9d99228bd618ddfc3575cf0d644d9a088 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 13:54:50 +0530 Subject: [PATCH 355/499] APT operations involved in some of tests are commented and verified manually Signed-off-by: ashish-jabble --- .../python/api/test_endpoints_with_different_user_types.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/system/python/api/test_endpoints_with_different_user_types.py b/tests/system/python/api/test_endpoints_with_different_user_types.py index 40b4d19368..97312313cd 100644 --- a/tests/system/python/api/test_endpoints_with_different_user_types.py +++ b/tests/system/python/api/test_endpoints_with_different_user_types.py @@ -496,9 +496,10 @@ def test_login(self, fledge_url, wait_time): ("GET", "/fledge/package/install/status", 404), # plugins ("GET", "/fledge/plugins/installed", 200), - # ("GET", "/fledge/plugins/available", 200), -- checked manually and commented out only to avoid apt-update - ("POST", "/fledge/plugins", 400), ("PUT", "/fledge/plugins/south/sinusoid/update", 404), - ("DELETE", "/fledge/plugins/south/sinusoid", 404), ("GET", "/fledge/service/foo/persist", 404), + # ("GET", "/fledge/plugins/available", 200), -- checked manually and commented out only to avoid apt operations + # ("PUT", "/fledge/plugins/south/sinusoid/update", 200), + # ("DELETE", "/fledge/plugins/south/sinusoid", 404), + ("POST", "/fledge/plugins", 400), ("GET", "/fledge/service/foo/persist", 404), ("GET", "/fledge/service/foo/plugin/omf/data", 404), ("POST", "/fledge/service/foo/plugin/omf/data", 404), ("DELETE", "/fledge/service/foo/plugin/omf/data", 404), # filters From b8cde695eba656b5e941a504eae2cb123d77ff4e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 15:03:53 +0530 Subject: [PATCH 356/499] sort handling of inbuilt plugins and rule inbuilt plugins are added to fledge plugin summary list Signed-off-by: ashish-jabble --- docs/scripts/fledge_plugin_list | 59 +++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/docs/scripts/fledge_plugin_list b/docs/scripts/fledge_plugin_list index bd6aae3ff2..a0ef4c6583 100755 --- a/docs/scripts/fledge_plugin_list +++ b/docs/scripts/fledge_plugin_list @@ -48,39 +48,50 @@ function table { echo "" >> "$output" echo " * - Name" >> "$output" echo " - Description" >> "$output" - for repo in ${list} + INBUILT_PLUGINS="fledge-north-OMF fledge-rule-Threshold fledge-rule-DataAvailability" + REPOS=$(echo ${list} ${INBUILT_PLUGINS} | xargs -n1 | sort | xargs) + for repo in ${REPOS} do type=$(echo "${repo}" | sed -e 's/fledge-//' -e 's/-.*//') name=$(echo "${repo}" | sed -e 's/fledge-//' -e "s/${type}-//") if [[ ${type} = "$tableType" ]]; then - rm -rf ${repo} - git clone https://${USERNAME}:${GITHUB_ACCESS_TOKEN}@github.com/fledge-iot/${repo}.git --branch ${DOCBRANCH} >/dev/null 2>&1 - is_branch_exists=$? - if [[ ${is_branch_exists} -eq 0 ]]; then - description=$(echo "$fledgeRepos" | python3 -c 'import json,sys;repos=json.load(sys.stdin);fRepo = [r for r in repos if r["name"] == "'"${repo}"'" ];print(fRepo[0]["description"])') - if [[ "${description}" = "None" ]]; then description="A ${name} ${type} plugin"; fi - # cloned directory replaced with installed directory name which is defined in Package file for each repo - installed_plugin_dir_name=$(cat ${repo}/Package | grep plugin_install_dirname= | sed -e "s/plugin_install_dirname=//g") - if [[ $installed_plugin_dir_name == "\${plugin_name}" ]]; then - installed_plugin_dir_name=$(cat ${repo}/Package | grep plugin_name= | sed -e "s/plugin_name=//g") + if grep -q "$repo" <<< "$INBUILT_PLUGINS"; then + if [[ $repo == "fledge-north-OMF" ]]; then + echo " * - \`omf <"plugins/fledge-north-OMF/index.html">\`__" >> "$output" + echo " - Send data to OSIsoft PI Server, Edge Data Store or OSIsoft Cloud Services" >> "$output" + elif [[ $repo == "fledge-rule-DataAvailability" ]]; then + echo " * - \`data-availability <"plugins/fledge-rule-DataAvailability/index.html">\`__" >> "$output" + echo " - Triggers every time when it receives data that matches an asset code or audit code those given in the configuration" >> "$output" + elif [[ $repo == "fledge-rule-Threshold" ]]; then + echo " * - \`threshold <"plugins/fledge-rule-Threshold/index.html">\`__" >> "$output" + echo " - Detect the value of a data point within an asset going above or below a set threshold" >> "$output" fi - old_plugin_name=$(echo ${repo} | cut -d '-' -f3-) - new_plugin_name=$(echo ${repo/$old_plugin_name/$installed_plugin_dir_name}) - # Only link when doc exists in plugins directory - if [[ -d ${repo}/docs && -f ${repo}/docs/index.rst ]]; then - echo " * - \`$name <"plugins/$new_plugin_name/index.html">\`__" >> "$output" else - echo " * - ${name}" >> "$output" - fi - echo " - ${description}" >> "$output" + rm -rf ${repo} + git clone https://${USERNAME}:${GITHUB_ACCESS_TOKEN}@github.com/fledge-iot/${repo}.git --branch ${DOCBRANCH} >/dev/null 2>&1 + is_branch_exists=$? + if [[ ${is_branch_exists} -eq 0 ]]; then + description=$(echo "$fledgeRepos" | python3 -c 'import json,sys;repos=json.load(sys.stdin);fRepo = [r for r in repos if r["name"] == "'"${repo}"'" ];print(fRepo[0]["description"])') + if [[ "${description}" = "None" ]]; then description="A ${name} ${type} plugin"; fi + # cloned directory replaced with installed directory name which is defined in Package file for each repo + installed_plugin_dir_name=$(cat ${repo}/Package | grep plugin_install_dirname= | sed -e "s/plugin_install_dirname=//g") + if [[ $installed_plugin_dir_name == "\${plugin_name}" ]]; then + installed_plugin_dir_name=$(cat ${repo}/Package | grep plugin_name= | sed -e "s/plugin_name=//g") + fi + old_plugin_name=$(echo ${repo} | cut -d '-' -f3-) + new_plugin_name=$(echo ${repo/$old_plugin_name/$installed_plugin_dir_name}) + # Only link when doc exists in plugins directory + if [[ -d ${repo}/docs && -f ${repo}/docs/index.rst ]]; then + echo " * - \`$name <"plugins/$new_plugin_name/index.html">\`__" >> "$output" + else + echo " * - ${name}" >> "$output" + fi + echo " - ${description}" >> "$output" + fi + rm -rf ${repo} fi - rm -rf ${repo} fi done - if [[ ${tableType} = "north" ]]; then - echo " * - \`OMF <"plugins/fledge-north-OMF/index.html">\`__" >> "$output" - echo " - Send data to OSIsoft PI Server, Edge Data Store or OSIsoft Cloud Services" >> "$output" - fi } cat >> $output << EOF1 From 4ba6c0de2467e3824bbabfa70e70632424a38b2c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 15:04:25 +0530 Subject: [PATCH 357/499] sort handling of inbuilt plugins in plugin detailed documentation & other code refactroing Signed-off-by: ashish-jabble --- docs/scripts/plugin_and_service_documentation | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index c345b29876..30f3dd38e9 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -25,7 +25,6 @@ Fledge North Plugins .. toctree:: - fledge-north-OMF/index EOFNORTH cat > plugins/filter.rst << EOFFILTER ********************* @@ -42,8 +41,6 @@ Fledge Notification Rule Plugins .. toctree:: - fledge-rule-DataAvailability/index - fledge-rule-Threshold/index EOFRULE cat > plugins/notify.rst << EOFNOTIFY ************************************ @@ -89,6 +86,8 @@ fRepos = list(set(all_repos) - set(exclude_topic_packages.split()));\ print("\n".join(fRepos)); ' REPOSITORIES=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) +INBUILT_PLUGINS="fledge-north-OMF fledge-rule-Threshold fledge-rule-DataAvailability" +REPOSITORIES=$(echo ${REPOSITORIES} ${INBUILT_PLUGINS} | xargs -n1 | sort | xargs) echo "REPOS LIST: "${REPOSITORIES} function plugin_and_service_doc { @@ -132,33 +131,38 @@ function plugin_and_service_doc { rm -rf /tmp/doc.$$ } - for repo in ${REPOSITORIES} do type=$(echo $repo | sed -e 's/fledge-//' -e 's/-.*//') - if [ "$type" = "south" -o "$type" = "north" -o $type = "filter" -o $type = "rule" -o $type = "notify" ]; then - dest=plugins/${type}.rst + dest=plugins/${type}.rst + if grep -q "$repo" <<< "$INBUILT_PLUGINS"; then + if [[ $repo == "fledge-north-OMF" ]]; then + name="fledge-north-OMF" + echo " ${name}/index" >> $dest + mkdir plugins/${name} + ln -s ../../images plugins/${name}/images + echo '.. include:: ../../fledge-north-OMF.rst' > plugins/${name}/index.rst + # Append OMF.rst to the end of the file rather than including it so that we may edit the links to prevent duplicates + cat OMF.rst >> plugins/${name}/index.rst + sed -i -e 's/Naming_Scheme/Naming_Scheme_plugin/' -e 's/Linked_Types/Linked_Types_Plugin/' -e 's/Edge_Data_Store/Edge_Data_Store_OMF_Endpoint/' -e 's/_Connector_Relay/PI_Connector_Relay/' plugins/${name}/index.rst + elif [[ $repo == "fledge-rule-DataAvailability" ]]; then + name="fledge-rule-DataAvailability" + echo " ${name}/index" >> $dest + mkdir plugins/${name} + ln -s $(pwd)/${name}/images plugins/${name}/images + echo '.. include:: ../../fledge-rule-DataAvailability/index.rst' > plugins/${name}/index.rst + elif [[ $repo == "fledge-rule-Threshold" ]]; then + name="fledge-rule-Threshold" + echo " ${name}/index" >> $dest + mkdir plugins/${name} + ln -s $(pwd)/${name}/images plugins/${name}/images + echo '.. include:: ../../fledge-rule-Threshold/index.rst' > plugins/${name}/index.rst + fi + elif [ "$type" = "south" -o "$type" = "north" -o $type = "filter" -o $type = "rule" -o $type = "notify" ]; then plugin_and_service_doc $repo $dest "plugins" fi done -# Deal with builtin plugin documentation -mkdir plugins/fledge-north-OMF -ln -s ../../images plugins/fledge-north-OMF/images -echo '.. include:: ../../fledge-north-OMF.rst' > plugins/fledge-north-OMF/index.rst -# Append OMF.rst to the end of the file rather than including it so that we may -# edit the links to prevent duplicates -cat OMF.rst >> plugins/fledge-north-OMF/index.rst -sed -i -e 's/Naming_Scheme/Naming_Scheme_plugin/' -e 's/Linked_Types/Linked_Types_Plugin/' -e 's/Edge_Data_Store/Edge_Data_Store_OMF_Endpoint/' -e 's/_Connector_Relay/PI_Connector_Relay/' plugins/fledge-north-OMF/index.rst -# Create the Threshold rule documentation -mkdir plugins/fledge-rule-Threshold -ln -s $(pwd)/fledge-rule-Threshold/images plugins/fledge-rule-Threshold/images -echo '.. include:: ../../fledge-rule-Threshold/index.rst' > plugins/fledge-rule-Threshold/index.rst -# Create the DataAvailability rule documentation -mkdir plugins/fledge-rule-DataAvailability -ln -s $(pwd)/fledge-rule-DataAvailability/images plugins/fledge-rule-DataAvailability/images -echo '.. include:: ../../fledge-rule-DataAvailability/index.rst' > plugins/fledge-rule-DataAvailability/index.rst - cat > services/index.rst << EOFSERVICES ******************* Additional Services From d99dd94182383d8c6d45fc66b01786c447a6258f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 15:38:24 +0530 Subject: [PATCH 358/499] authentication package based system tests updated Signed-off-by: ashish-jabble --- .../python/packages/test_authentication.py | 56 ++++++------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/tests/system/python/packages/test_authentication.py b/tests/system/python/packages/test_authentication.py index 77e56a9205..4669da7782 100644 --- a/tests/system/python/packages/test_authentication.py +++ b/tests/system/python/packages/test_authentication.py @@ -34,6 +34,14 @@ context = ssl._create_unverified_context() LOGIN_SUCCESS_MSG = "Logged in successfully." +ROLES = {'roles': [ + {'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, + {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, + {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, + {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'}, + {'id': 5, 'name': 'control', + 'description': 'Same as editor can do and also have access for control scripts and pipelines'} +]} def send_data_using_fogbench(wait_time): @@ -564,11 +572,7 @@ def test_get_roles_with_password_token(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'roles': [{'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, - {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, - {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, - {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'} - ]} == jdoc + assert ROLES == jdoc def test_get_roles_with_certificate_token(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) @@ -577,11 +581,7 @@ def test_get_roles_with_certificate_token(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'roles': [{'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, - {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, - {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, - {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'} - ]} == jdoc + assert ROLES == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, @@ -961,11 +961,7 @@ def test_get_roles(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'roles': [{'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, - {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, - {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, - {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'} - ]} == jdoc + assert ROLES == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, @@ -1269,11 +1265,7 @@ def test_get_roles(self, fledge_url): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'roles': [{'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, - {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, - {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, - {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'} - ]} == jdoc + assert ROLES == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, @@ -1674,11 +1666,7 @@ def test_get_roles_with_password_token(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'roles': [{'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, - {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, - {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, - {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'} - ]} == jdoc + assert ROLES == jdoc def test_get_roles_with_certificate_token(self): conn = http.client.HTTPSConnection("localhost", 1995, context=context) @@ -1687,11 +1675,7 @@ def test_get_roles_with_certificate_token(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'roles': [{'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, - {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, - {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, - {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'} - ]} == jdoc + assert ROLES == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, @@ -2075,11 +2059,7 @@ def test_get_roles(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'roles': [{'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, - {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, - {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, - {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'} - ]} == jdoc + assert ROLES == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, @@ -2390,11 +2370,7 @@ def test_get_roles(self): assert 200 == r.status r = r.read().decode() jdoc = json.loads(r) - assert {'roles': [{'description': 'All CRUD privileges', 'id': 1, 'name': 'admin'}, - {'description': 'All CRUD operations and self profile management', 'id': 2, 'name': 'user'}, - {'id': 3, 'name': 'view', 'description': 'Only to view the configuration'}, - {'id': 4, 'name': 'data-view', 'description': 'Only read the data in buffer'} - ]} == jdoc + assert ROLES == jdoc @pytest.mark.parametrize(("form_data", "expected_values"), [ ({"username": "any1", "password": "User@123", "real_name": "AJ", "description": "Nerd user"}, From 3510d4aecb6918d5f889ecb86f2af6831060c61f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 16:58:14 +0530 Subject: [PATCH 359/499] case insensitive sort of repositories list Signed-off-by: ashish-jabble --- docs/scripts/fledge_plugin_list | 10 +++++----- docs/scripts/plugin_and_service_documentation | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/scripts/fledge_plugin_list b/docs/scripts/fledge_plugin_list index a0ef4c6583..d06e0a3d85 100755 --- a/docs/scripts/fledge_plugin_list +++ b/docs/scripts/fledge_plugin_list @@ -35,8 +35,10 @@ all_repos = [r["name"] for r in repos if r["archived"] is False];\ fRepos = list(set(all_repos) - set(exclude_topic_packages.split()));\ print("\n".join(fRepos)); ' -REPOSITORIES=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) -echo "REPOS LIST: "${REPOSITORIES} +REPOS=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) +INBUILT_PLUGINS="fledge-north-OMF fledge-rule-Threshold fledge-rule-DataAvailability" +REPOSITORIES=$(echo ${REPOS} ${INBUILT_PLUGINS} | xargs -n1 | sort -f | xargs) +echo "REPOSITORIES LIST: "${REPOSITORIES} function table { list="$1" @@ -48,9 +50,7 @@ function table { echo "" >> "$output" echo " * - Name" >> "$output" echo " - Description" >> "$output" - INBUILT_PLUGINS="fledge-north-OMF fledge-rule-Threshold fledge-rule-DataAvailability" - REPOS=$(echo ${list} ${INBUILT_PLUGINS} | xargs -n1 | sort | xargs) - for repo in ${REPOS} + for repo in ${list} do type=$(echo "${repo}" | sed -e 's/fledge-//' -e 's/-.*//') name=$(echo "${repo}" | sed -e 's/fledge-//' -e "s/${type}-//") diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index 30f3dd38e9..523122b066 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -85,10 +85,10 @@ all_repos = [r["name"] for r in repos if r["archived"] is False];\ fRepos = list(set(all_repos) - set(exclude_topic_packages.split()));\ print("\n".join(fRepos)); ' -REPOSITORIES=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) +REPOS=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) INBUILT_PLUGINS="fledge-north-OMF fledge-rule-Threshold fledge-rule-DataAvailability" -REPOSITORIES=$(echo ${REPOSITORIES} ${INBUILT_PLUGINS} | xargs -n1 | sort | xargs) -echo "REPOS LIST: "${REPOSITORIES} +REPOSITORIES=$(echo ${REPOS} ${INBUILT_PLUGINS} | xargs -n1 | sort -f | xargs) +echo "REPOSITORIES LIST: "${REPOSITORIES} function plugin_and_service_doc { repo_name=$1 From 11acb148da685efc789e218dbf47300bc4e22002 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 27 Jul 2023 13:58:12 +0100 Subject: [PATCH 360/499] FOGL-7740 Allow dynamic reconfiguration of data source in north service (#1122) Signed-off-by: Mark Riddoch --- C/services/north/data_load.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/C/services/north/data_load.cpp b/C/services/north/data_load.cpp index b7fa2d3cc0..f7e8eb3291 100755 --- a/C/services/north/data_load.cpp +++ b/C/services/north/data_load.cpp @@ -598,13 +598,18 @@ void DataLoad::configChange(const string& category, const string& newConfig) { /** * The category that has changed is the one for the north service itself. - * The only item that concerns us here is the filter item that defines - * the filter pipeline. We extract that item and check to see if it defines - * a pipeline that is different to the one we currently have. + * The only items that concerns us here is the filter item that defines + * the filter pipeline and the data source. If the item is the filter pipeline + * we extract that item and check to see if it defines a pipeline that is + * different to the one we currently have. * - * If it is we destroy the current pipeline and create a new one. + * If it is the filter pipeline we destroy the current pipeline and create a new one. */ ConfigCategory config("tmp", newConfig); + if (config.itemExists("source")) + { + setDataSource(config.getValue("source")); + } string newPipeline = ""; if (config.itemExists("filter")) { From 0d9d4371a6b260b613145456a1850f1f53813ea5 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 27 Jul 2023 19:48:15 +0530 Subject: [PATCH 361/499] malformed table fixes for control pipeline when white-labelled documentation Signed-off-by: ashish-jabble --- docs/control.rst | 51 +++++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/docs/control.rst b/docs/control.rst index 3ef2b05f5c..3d6dc2404f 100644 --- a/docs/control.rst +++ b/docs/control.rst @@ -436,26 +436,37 @@ Pipeline Connections The control pipelines are not defined against a particular end point as they are with the data pipelines, they are defined separately and part of that definition includes the input and output end points to which the control pipeline may be attached. The input and output of a control pipeline may be defined as being able to connect to one of a set of endpoints. -+--------------+-------------+-----------------------------------------------------------------------------------------+ -| Type | Endpoints | Description | -+==============+=============+=========================================================================================+ -| Any | Both | The pipeline can connection to any source or destination. This is only used in | -| | | situations where an exact match for an endpoint can not be satisfied. | -+--------------+-------------+-----------------------------------------------------------------------------------------+ -| API | Source | The source of the request is an API call to the public API of the Fledge instance. | -+--------------+-------------+-----------------------------------------------------------------------------------------+ -| Asset | Destination | The data will be sent to the service that is responsible for ingesting the named asset. | -+--------------+-------------+-----------------------------------------------------------------------------------------+ -| Broadcast | Destination | The requests will be sent to all south services that support control. | -+--------------+-------------+-----------------------------------------------------------------------------------------+ -| Notification | Source | The request originated from the named notification. | -+--------------+-------------+-----------------------------------------------------------------------------------------+ -| Schedule | Source | The request originated from a schedule. | -+--------------+-------------+-----------------------------------------------------------------------------------------+ -| Script | Both | The request is either originating from a script or being sent to a script. | -+--------------+-------------+-----------------------------------------------------------------------------------------+ -| Service | Both | The request is either coming from a named service or going to a named service. | -+--------------+-------------+-----------------------------------------------------------------------------------------+ +.. list-table:: + :widths: 20 20 70 + :header-rows: 1 + + * - Type + - Endpoints + - Description + * - Any + - Both + - The pipeline can connection to any source or destination. This is only used in situations where an exact match for an endpoint can not be satisfied. + * - API + - Source + - The source of the request is an API call to the public API of the Fledge instance. + * - Asset + - Destination + - The data will be sent to the service that is responsible for ingesting the named asset. + * - Broadcast + - Destination + - The requests will be sent to all south services that support control. + * - Notification + - Source + - The request originated from the named notification. + * - Schedule + - Source + - The request originated from a schedule. + * - Script + - Source + - The request is either originating from a script or being sent to a script. + * - Service + - Source + - The request is either coming from a named service or going to a named service. Control pipelines are always executed in the control dispatcher service. When a request comes into the service it will look for a pipeline to pass that request through. This process will look at the source of the request and the destination of the request. If a pipeline that has source and destination endpoints that are an exact match for the source and destination of the control request then the control request will be processed through that pipeline. From 9437f873e68667cb3518ad4bd875e742953f0734 Mon Sep 17 00:00:00 2001 From: nandan Date: Tue, 1 Aug 2023 12:31:31 +0530 Subject: [PATCH 362/499] FOGL-7743: clear the readings vector after append operation Signed-off-by: nandan --- .../python/python_plugin_interface.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp index 2b290e6e1d..e657755df9 100755 --- a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp @@ -297,8 +297,8 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) // Remove input data data->removeAll(); - // Append filtered readings - data->append(filteredReadingSet->getAllReadings()); + // Append filtered readings; append will empty the passed reading set as well + data->append(filteredReadingSet); delete filteredReadingSet; filteredReadingSet = NULL; From bf89366559a3d74b7bbc028d4ed3b2a583d12f00 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 2 Aug 2023 14:55:07 +0530 Subject: [PATCH 363/499] previous payload workaround added with mostrecent browser asset Signed-off-by: ashish-jabble --- python/fledge/services/core/api/browser.py | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/browser.py b/python/fledge/services/core/api/browser.py index 60faf93ae2..a909f3d9bd 100644 --- a/python/fledge/services/core/api/browser.py +++ b/python/fledge/services/core/api/browser.py @@ -215,12 +215,12 @@ async def asset(request): if response and 'timestamp' in response[0]: date_times.append(datetime.datetime.strptime(response[0]['timestamp'], dt_format)) most_recent_ts = max(date_times) - # _logger.debug("DTS: {} most_recent_ts: {}".format(date_times, most_recent_ts)) + _logger.debug("DTS: {} most_recent_ts: {}".format(date_times, most_recent_ts)) window = int(request.query['seconds']) to_ts = most_recent_ts - datetime.timedelta(seconds=window) most_recent_str = most_recent_ts.strftime(dt_format) to_str = to_ts.strftime(dt_format) - # _logger.debug("user_ts <={} TO user_ts>{}".format(most_recent_str, to_str)) + _logger.debug("user_ts <={} TO user_ts>{}".format(most_recent_str, to_str)) _and_where = PayloadBuilder(_where).AND_WHERE(['user_ts', '<=', most_recent_str]).AND_WHERE( ['user_ts', '>', to_str]).chain_payload() elif 'previous' in request.query: @@ -635,6 +635,29 @@ def where_clause(request, where): def where_window(request, where): + """ newer/older payload conditions only worked with datetime (now - seconds) + Also there is no support of BETWEEN operator. + For mostrecent functionality with back/forward buttons a.k.a previous payload + There is workaround implemented at python side to get it without any amendments at C Payload side + Now, client has to pass datetime UTC string and having format %Y-%m-%d %H:%M:%S.%f in "previous" payload + For example: /fledge/asset/randomwalk?mostrecent=TRUE&seconds=10&previous=2023-08-01 06:32:36.515 + Payload: + {"return": ["reading", {"column": "user_ts", "alias": "timestamp", "timezone": "utc"}], + "where": {"column": "asset_code", "condition": "=", "value": "randomwalk", + "and": {"column": "user_ts", "condition": "<=", "value": "2023-08-01 06:32:36.515", + "and": {"column": "user_ts", "condition": ">=", "value": "2023-08-01 06:32:26.515"}}}, + "sort": {"column": "user_ts", "direction": "desc"}} + """ + if 'mostrecent' in request.query and 'seconds' in request.query: + val = int(request.query['seconds']) + previous_str = request.query['previous'] + dt_format = '%Y-%m-%d %H:%M:%S.%f' + dt_obj = datetime.datetime.strptime(previous_str, dt_format) + dt_obj_diff = dt_obj - datetime.timedelta(seconds=val) + dt_str = dt_obj_diff.strftime(dt_format) + payload = PayloadBuilder(where).AND_WHERE(['user_ts', '<=', previous_str]).chain_payload() + return PayloadBuilder(payload).AND_WHERE(['user_ts', '>=', dt_str]).chain_payload() + val = 0 previous = 0 try: From 1c6f04a66aa087676891405aa29f4699142dee10 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 4 Aug 2023 14:15:41 +0100 Subject: [PATCH 364/499] FOGL-8002 Add support for delete operations in table registration (#1132) * Checkpoint Signed-off-by: Mark Riddoch * FOGL-8002 Add support for delete operations in table registration Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/services/storage/include/storage_registry.h | 4 + C/services/storage/storage_api.cpp | 2 + C/services/storage/storage_registry.cpp | 129 +++++++++++++++++- 3 files changed, 130 insertions(+), 5 deletions(-) diff --git a/C/services/storage/include/storage_registry.h b/C/services/storage/include/storage_registry.h index 1bb207f9b9..bdb109965d 100644 --- a/C/services/storage/include/storage_registry.h +++ b/C/services/storage/include/storage_registry.h @@ -34,6 +34,7 @@ class StorageRegistry { void process(const std::string& payload); void processTableInsert(const std::string& tableName, const std::string& payload); void processTableUpdate(const std::string& tableName, const std::string& payload); + void processTableDelete(const std::string& tableName, const std::string& payload); void registerTable(const std::string& table, const std::string& url); void unregisterTable(const std::string& table, const std::string& url); void run(); @@ -43,6 +44,7 @@ class StorageRegistry { void filterPayload(const std::string& url, char *payload, const std::string& asset); void processInsert(char *tableName, char *payload); void processUpdate(char *tableName, char *payload); + void processDelete(char *tableName, char *payload); TableRegistration* parseTableSubscriptionPayload(const std::string& payload); void insertTestTableReg(); @@ -59,6 +61,8 @@ class StorageRegistry { m_tableInsertQueue; std::queue m_tableUpdateQueue; + std::queue + m_tableDeleteQueue; std::mutex m_qMutex; std::mutex m_registrationsMutex; std::mutex m_tableRegistrationsMutex; diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp index 79fddd15e9..3810b9ad9e 100644 --- a/C/services/storage/storage_api.cpp +++ b/C/services/storage/storage_api.cpp @@ -771,6 +771,7 @@ string responsePayload; int rval = plugin->commonDelete(tableName, payload); if (rval != -1) { + registry.processTableDelete(tableName, payload); responsePayload = "{ \"response\" : \"deleted\", \"rows_affected\" : "; responsePayload += to_string(rval); responsePayload += " }"; @@ -1676,6 +1677,7 @@ string responsePayload; int rval = plugin->commonDelete(tableName, payload, const_cast(schemaName.c_str())); if (rval != -1) { + registry.processTableDelete(tableName, payload); responsePayload = "{ \"response\" : \"deleted\", \"rows_affected\" : "; responsePayload += to_string(rval); responsePayload += " }"; diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp index cb56133b57..9d4c0bc174 100644 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -139,7 +139,7 @@ StorageRegistry::processTableInsert(const string& tableName, const string& paylo * if any microservice has registered an interest * in this table. Called from StorageApi::commonUpdate() * - * @param payload The table insert payload + * @param payload The table update payload */ void StorageRegistry::processTableUpdate(const string& tableName, const string& payload) @@ -167,6 +167,39 @@ StorageRegistry::processTableUpdate(const string& tableName, const string& paylo } } +/** + * Process a table delete payload and determine + * if any microservice has registered an interest + * in this table. Called from StorageApi::commonDelete() + * + * @param payload The table delete payload + */ +void +StorageRegistry::processTableDelete(const string& tableName, const string& payload) +{ + Logger::getLogger()->info("Checking for registered interest in table %s with delete %s", tableName.c_str(), payload.c_str()); + + if (m_tableRegistrations.size() > 0) + { + /* + * We have some registrations so queue a copy of the payload + * to be examined in the thread the send table notifications + * to interested parties. + */ + char *table = strdup(tableName.c_str()); + char *data = strdup(payload.c_str()); + + if (data != NULL && table != NULL) + { + time_t now = time(0); + TableItem item = make_tuple(now, table, data); + lock_guard guard(m_qMutex); + m_tableDeleteQueue.push(item); + m_cv.notify_all(); + } + } +} + /** * Handle a registration request from a client of the storage layer * @@ -356,7 +389,7 @@ StorageRegistry::run() #endif { unique_lock mlock(m_cvMutex); - while (m_queue.size() == 0 && m_tableInsertQueue.size() == 0 && m_tableUpdateQueue.size() == 0) + while (m_queue.size() == 0 && m_tableInsertQueue.size() == 0 && m_tableUpdateQueue.size() == 0 && m_tableDeleteQueue.size() == 0) { m_cv.wait_for(mlock, std::chrono::seconds(REGISTRY_SLEEP_TIME)); if (!m_running) @@ -435,7 +468,31 @@ StorageRegistry::run() free(data); } } - + + while (!m_tableDeleteQueue.empty()) + { + char *tableName = NULL; + + TableItem item = m_tableDeleteQueue.front(); + m_tableDeleteQueue.pop(); + tableName = get<1>(item); + data = get<2>(item); +#if CHECK_QTIMES + qTime = item.first; +#endif + if (tableName && data) + { +#if CHECK_QTIMES + if (time(0) - qTime > QTIME_THRESHOLD) + { + Logger::getLogger()->error("Table delete data has been queued for %d seconds to be sent to registered party", (time(0) - qTime)); + } +#endif + processDelete(tableName, data); + free(tableName); + free(data); + } + } } } } @@ -497,7 +554,6 @@ StorageRegistry::sendPayload(const string& url, const char *payload) size_t found1 = url.find_first_of("/", found + 3); string hostport = url.substr(found+3, found1 - found - 3); string resource = url.substr(found1); - HttpClient client(hostport); try { client.request("POST", resource, payload); @@ -699,7 +755,7 @@ StorageRegistry::processUpdate(char *tableName, char *payload) if (tblreg->key.empty()) { - // No key to match, send alll updates to table + // No key to match, send all updates to table sendPayload(tblreg->url, payload); } else @@ -792,6 +848,69 @@ StorageRegistry::processUpdate(char *tableName, char *payload) } } +/** + * Process an incoming payload and distribute as required to registered + * services + * + * @param payload The payload to potentially distribute + */ +void +StorageRegistry::processDelete(char *tableName, char *payload) +{ + Document doc; + + doc.Parse(payload); + if (doc.HasParseError()) + { + Logger::getLogger()->error("Unable to parse table delete payload for table %s, request is %s", tableName, payload); + return; + } + + lock_guard guard(m_tableRegistrationsMutex); + for (auto & reg : m_tableRegistrations) + { + if (reg.first->compare(tableName) != 0) + continue; + + TableRegistration *tblreg = reg.second; + + // If key is empty string, no need to match key/value pair in payload + if (tblreg->operation.compare("delete") != 0) + { + continue; + } + if (tblreg->key.empty()) + { + // No key to match, send all updates to table + sendPayload(tblreg->url, payload); + } + else + { + if (doc.HasMember("where") && doc["where"].IsObject()) + { + const Value& where = doc["where"]; + if (where.HasMember("column") && where["column"].IsString() && + where.HasMember("value") && where["value"].IsString()) + { + string updateKey = where["column"].GetString(); + string keyValue = where["value"].GetString(); + if (updateKey.compare(tblreg->key) == 0 && + std::find(tblreg->keyValues.begin(), tblreg->keyValues.end(), keyValue) + != tblreg->keyValues.end()) + { + StringBuffer buffer; + Writer writer(buffer); + where.Accept(writer); + + const char *output = buffer.GetString(); + sendPayload(tblreg->url, output); + } + } + } + } + } +} + /** * Test function to add some dummy/test table subscriptions */ From 604be958afc18ed2e0f8613c769809c538251891 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 7 Aug 2023 11:25:03 +0530 Subject: [PATCH 365/499] monitors db content added to support bundle Signed-off-by: ashish-jabble --- python/fledge/services/core/support.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index d696ff8509..c964d1b1d8 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -94,6 +94,7 @@ async def build(self): await self.add_table_statistics_history(pyz, file_spec) await self.add_table_plugin_data(pyz, file_spec) await self.add_table_streams(pyz, file_spec) + await self.add_table_monitors(pyz, file_spec) self.add_service_registry(pyz, file_spec) self.add_machine_resources(pyz, file_spec) self.add_psinfo(pyz, file_spec) @@ -225,6 +226,12 @@ async def add_table_streams(self, pyz, file_spec): data = await self._storage.query_tbl_with_payload("streams", payload) self.write_to_tar(pyz, temp_file, data) + async def add_table_monitors(self, pyz, file_spec): + # The contents of the monitors table from the storage layer + temp_file = self._interim_file_path + "/" + "monitors-{}".format(file_spec) + data = await self._storage.query_tbl("monitors") + self.write_to_tar(pyz, temp_file, data) + def add_service_registry(self, pyz, file_spec): # The contents of the service registry temp_file = self._interim_file_path + "/" + "service_registry-{}".format(file_spec) From d2954039133025cb7d66c938ab9b906fd7f7b24c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 7 Aug 2023 11:52:20 +0530 Subject: [PATCH 366/499] added common function to get db table records Signed-off-by: ashish-jabble --- python/fledge/services/core/support.py | 43 +++++--------------------- 1 file changed, 8 insertions(+), 35 deletions(-) diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index c964d1b1d8..d4a82258bf 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -87,14 +87,11 @@ async def build(self): self.add_syslog_service(pyz, file_spec, task) except: pass - await self.add_table_configuration(pyz, file_spec) - await self.add_table_audit_log(pyz, file_spec) - await self.add_table_schedules(pyz, file_spec) - await self.add_table_scheduled_processes(pyz, file_spec) - await self.add_table_statistics_history(pyz, file_spec) - await self.add_table_plugin_data(pyz, file_spec) - await self.add_table_streams(pyz, file_spec) - await self.add_table_monitors(pyz, file_spec) + db_tables = {"configuration": "category", "log": "audit", "schedules": "schedule", + "scheduled_processes": "schedule-process", "monitors": "service-monitoring", + "statistics": "statistics"} + for tbl_name, file_name in sorted(db_tables.items()): + await self.add_db_content(pyz, file_spec, tbl_name, file_name) self.add_service_registry(pyz, file_spec) self.add_machine_resources(pyz, file_spec) self.add_psinfo(pyz, file_spec) @@ -173,27 +170,9 @@ def add_syslog_utility(self, pyz): temp_file = "/tmp/{}".format(filename) pyz.add(temp_file, arcname='logs/sys/{}'.format(filename)) - async def add_table_configuration(self, pyz, file_spec): - # The contents of the configuration table from the storage layer - temp_file = self._interim_file_path + "/" + "configuration-{}".format(file_spec) - data = await self._storage.query_tbl("configuration") - self.write_to_tar(pyz, temp_file, data) - - async def add_table_audit_log(self, pyz, file_spec): - # The contents of the audit log from the storage layer - temp_file = self._interim_file_path + "/" + "audit-{}".format(file_spec) - data = await self._storage.query_tbl("log") - self.write_to_tar(pyz, temp_file, data) - - async def add_table_schedules(self, pyz, file_spec): - # The contents of the schedules table from the storage layer - temp_file = self._interim_file_path + "/" + "schedules-{}".format(file_spec) - data = await self._storage.query_tbl("schedules") - self.write_to_tar(pyz, temp_file, data) - - async def add_table_scheduled_processes(self, pyz, file_spec): - temp_file = self._interim_file_path + "/" + "scheduled_processes-{}".format(file_spec) - data = await self._storage.query_tbl("scheduled_processes") + async def add_db_content(self, pyz, file_spec, tbl_name, file_name): + temp_file = "{}/{}-{}".format(self._interim_file_path, file_name, file_spec) + data = await self._storage.query_tbl(tbl_name) self.write_to_tar(pyz, temp_file, data) async def add_table_statistics_history(self, pyz, file_spec): @@ -226,12 +205,6 @@ async def add_table_streams(self, pyz, file_spec): data = await self._storage.query_tbl_with_payload("streams", payload) self.write_to_tar(pyz, temp_file, data) - async def add_table_monitors(self, pyz, file_spec): - # The contents of the monitors table from the storage layer - temp_file = self._interim_file_path + "/" + "monitors-{}".format(file_spec) - data = await self._storage.query_tbl("monitors") - self.write_to_tar(pyz, temp_file, data) - def add_service_registry(self, pyz, file_spec): # The contents of the service registry temp_file = self._interim_file_path + "/" + "service_registry-{}".format(file_spec) From 7f2d04dd5a915f20a0fde0eb753059fcc692be32 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Mon, 7 Aug 2023 16:27:01 +0100 Subject: [PATCH 367/499] FOGL-8020 Fix order of initialisation in asset tracker (#1136) * FOGL-8020 Fix order of initialisation in asset tracker Signed-off-by: Mark Riddoch * further re-ordering Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/include/asset_tracking.h | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/C/common/include/asset_tracking.h b/C/common/include/asset_tracking.h index 3629eda315..a54c232ef6 100644 --- a/C/common/include/asset_tracking.h +++ b/C/common/include/asset_tracking.h @@ -132,13 +132,14 @@ class StorageAssetTrackingTuple : public TrackingTuple { const std::string& event, const bool& deprecated = false, const std::string& datapoints = "", - unsigned int c = 0) : m_serviceName(service), - m_pluginName(plugin), - m_assetName(asset), - m_eventName(event), - m_deprecated(deprecated), - m_datapoints(datapoints), - m_maxCount(c) {}; + unsigned int c = 0) : m_datapoints(datapoints), + m_maxCount(c), + m_serviceName(service), + m_pluginName(plugin), + m_assetName(asset), + m_eventName(event), + m_deprecated(deprecated) + {}; inline bool operator==(const StorageAssetTrackingTuple& x) const { From d010f3945dd2d32cecb0b00224c0b55a61d580ca Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 8 Aug 2023 11:14:19 +0100 Subject: [PATCH 368/499] FOGL-8014 Prevent crash in resultset constructor with integer overflow (#1133) Signed-off-by: Mark Riddoch Co-authored-by: Ray Verhoeff --- C/common/result_set.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/C/common/result_set.cpp b/C/common/result_set.cpp index e29c5a4f74..401cf95db2 100644 --- a/C/common/result_set.cpp +++ b/C/common/result_set.cpp @@ -30,7 +30,7 @@ ResultSet::ResultSet(const std::string& json) { throw new ResultException("Unable to parse results json document"); } - if (doc.HasMember("count")) + if (doc.HasMember("count") && doc["count"].IsUint()) { m_rowCount = doc["count"].GetUint(); if (m_rowCount) @@ -95,7 +95,7 @@ ResultSet::ResultSet(const std::string& json) rowValue->append(new ColumnValue(string(item->value.GetString()))); break; case INT_COLUMN: - rowValue->append(new ColumnValue(item->value.GetInt())); + rowValue->append(new ColumnValue(item->value.GetInt64())); break; case NUMBER_COLUMN: rowValue->append(new ColumnValue(item->value.GetDouble())); From 986aef2ecd34e5b1de37e70c6352f4afad068053 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 8 Aug 2023 16:46:02 +0530 Subject: [PATCH 369/499] Control Flow API schema added for SQLite along with upgrade and downgrade sql Signed-off-by: ashish-jabble --- VERSION | 2 +- .../plugins/storage/sqlite/downgrade/63.sql | 4 +++ scripts/plugins/storage/sqlite/init.sql | 29 +++++++++++++++++++ scripts/plugins/storage/sqlite/upgrade/64.sql | 28 ++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins/storage/sqlite/downgrade/63.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/64.sql diff --git a/VERSION b/VERSION index b25b05dffa..ea41dc0681 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.1.0 -fledge_schema=63 \ No newline at end of file +fledge_schema=64 \ No newline at end of file diff --git a/scripts/plugins/storage/sqlite/downgrade/63.sql b/scripts/plugins/storage/sqlite/downgrade/63.sql new file mode 100644 index 0000000000..7f8daaeb9a --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/63.sql @@ -0,0 +1,4 @@ +-- Drop control pipeline tables +DROP TABLE IF EXISTS fledge.control_api; +DROP TABLE IF EXISTS fledge.control_api_parameters; +DROP TABLE IF EXISTS fledge.control_api_acl; diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index d4876dd8f3..8a6090d683 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -666,6 +666,35 @@ CREATE TABLE fledge.control_filters ( REFERENCES control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION ); +-- Create control_api table +CREATE TABLE fledge.control_api ( + name character varying(255) NOT NULL , -- control API name + description character varying(255) NOT NULL , -- description of control API + type integer NOT NULL , -- 0 for write and 1 for operation + operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero + anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false + CONSTRAINT control_api_pname PRIMARY KEY (name) + ); + +-- Create control_api_parameters table +CREATE TABLE fledge.control_api_parameters ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + parameter character varying(255) NOT NULL , -- name of parameter + value character varying(255) , -- value of parameter if constant otherwise default + constant boolean NOT NULL , -- parameter is either a constant or variable + FOREIGN KEY (name) REFERENCES control_api (name) + ); + +-- Create control_api_acl table +CREATE TABLE fledge.control_api_acl ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + user character varying(255) NOT NULL , -- foreign key to fledge.users + FOREIGN KEY (name) REFERENCES control_api (name) , + FOREIGN KEY (user) REFERENCES users (uname) + ); + -- Create monitors table CREATE TABLE fledge.monitors ( service character varying(255) NOT NULL, diff --git a/scripts/plugins/storage/sqlite/upgrade/64.sql b/scripts/plugins/storage/sqlite/upgrade/64.sql new file mode 100644 index 0000000000..6e445b9455 --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/64.sql @@ -0,0 +1,28 @@ +-- Create control_api table +CREATE TABLE fledge.control_api ( + name character varying(255) NOT NULL , -- control API name + description character varying(255) NOT NULL , -- description of control API + type integer NOT NULL , -- 0 for write and 1 for operation + operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero + anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false + CONSTRAINT control_api_pname PRIMARY KEY (name) + ); + +-- Create control_api_parameters table +CREATE TABLE fledge.control_api_parameters ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + parameter character varying(255) NOT NULL , -- name of parameter + value character varying(255) , -- value of parameter if constant otherwise default + constant boolean NOT NULL , -- parameter is either a constant or variable + FOREIGN KEY (name) REFERENCES control_api (name) + ); + +-- Create control_api_acl table +CREATE TABLE fledge.control_api_acl ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + user character varying(255) NOT NULL , -- foreign key to fledge.users + FOREIGN KEY (name) REFERENCES control_api (name) , + FOREIGN KEY (user) REFERENCES users (uname) + ); From c06fd2bd224fac1de44367745350754975fe5194 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 9 Aug 2023 11:30:53 +0530 Subject: [PATCH 370/499] Control Flow API schema added for SQLitelb along with upgrade and downgrade sql Signed-off-by: ashish-jabble --- .../plugins/storage/sqlitelb/downgrade/63.sql | 4 +++ scripts/plugins/storage/sqlitelb/init.sql | 29 +++++++++++++++++++ .../plugins/storage/sqlitelb/upgrade/64.sql | 28 ++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/63.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/64.sql diff --git a/scripts/plugins/storage/sqlitelb/downgrade/63.sql b/scripts/plugins/storage/sqlitelb/downgrade/63.sql new file mode 100644 index 0000000000..7f8daaeb9a --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/63.sql @@ -0,0 +1,4 @@ +-- Drop control pipeline tables +DROP TABLE IF EXISTS fledge.control_api; +DROP TABLE IF EXISTS fledge.control_api_parameters; +DROP TABLE IF EXISTS fledge.control_api_acl; diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 78598358f2..e6e4e4980e 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -666,6 +666,35 @@ CREATE TABLE fledge.control_filters ( REFERENCES control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION ); +-- Create control_api table +CREATE TABLE fledge.control_api ( + name character varying(255) NOT NULL , -- control API name + description character varying(255) NOT NULL , -- description of control API + type integer NOT NULL , -- 0 for write and 1 for operation + operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero + anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false + CONSTRAINT control_api_pname PRIMARY KEY (name) + ); + +-- Create control_api_parameters table +CREATE TABLE fledge.control_api_parameters ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + parameter character varying(255) NOT NULL , -- name of parameter + value character varying(255) , -- value of parameter if constant otherwise default + constant boolean NOT NULL , -- parameter is either a constant or variable + FOREIGN KEY (name) REFERENCES control_api (name) + ); + +-- Create control_api_acl table +CREATE TABLE fledge.control_api_acl ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + user character varying(255) NOT NULL , -- foreign key to fledge.users + FOREIGN KEY (name) REFERENCES control_api (name) , + FOREIGN KEY (user) REFERENCES users (uname) + ); + -- Create monitors table CREATE TABLE fledge.monitors ( service character varying(255) NOT NULL, diff --git a/scripts/plugins/storage/sqlitelb/upgrade/64.sql b/scripts/plugins/storage/sqlitelb/upgrade/64.sql new file mode 100644 index 0000000000..6e445b9455 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/64.sql @@ -0,0 +1,28 @@ +-- Create control_api table +CREATE TABLE fledge.control_api ( + name character varying(255) NOT NULL , -- control API name + description character varying(255) NOT NULL , -- description of control API + type integer NOT NULL , -- 0 for write and 1 for operation + operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero + anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false + CONSTRAINT control_api_pname PRIMARY KEY (name) + ); + +-- Create control_api_parameters table +CREATE TABLE fledge.control_api_parameters ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + parameter character varying(255) NOT NULL , -- name of parameter + value character varying(255) , -- value of parameter if constant otherwise default + constant boolean NOT NULL , -- parameter is either a constant or variable + FOREIGN KEY (name) REFERENCES control_api (name) + ); + +-- Create control_api_acl table +CREATE TABLE fledge.control_api_acl ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + user character varying(255) NOT NULL , -- foreign key to fledge.users + FOREIGN KEY (name) REFERENCES control_api (name) , + FOREIGN KEY (user) REFERENCES users (uname) + ); From 83236620305b9fb05c8f0dca12bd99f643983c96 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 9 Aug 2023 13:39:50 +0530 Subject: [PATCH 371/499] recovered support bundle stuff which got removed in FOGL-7982 Signed-off-by: ashish-jabble --- python/fledge/services/core/support.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index d4a82258bf..63575de32a 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -92,6 +92,9 @@ async def build(self): "statistics": "statistics"} for tbl_name, file_name in sorted(db_tables.items()): await self.add_db_content(pyz, file_spec, tbl_name, file_name) + await self.add_table_statistics_history(pyz, file_spec) + await self.add_table_plugin_data(pyz, file_spec) + await self.add_table_streams(pyz, file_spec) self.add_service_registry(pyz, file_spec) self.add_machine_resources(pyz, file_spec) self.add_psinfo(pyz, file_spec) From 6ebddcfb5b4b0864ef6a4ddb38ff8018723f1fe0 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 10 Aug 2023 10:04:35 +0100 Subject: [PATCH 372/499] FOGL-7983 Store statistic history in UTC and fix the newer/older (#1135) condition Signed-off-by: Mark Riddoch --- C/plugins/storage/sqlite/common/connection.cpp | 4 ++-- C/tasks/statistics_history/stats_history.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index 83f798c183..6540b8581d 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -1085,7 +1085,7 @@ vector asset_codes; if (document.HasMember("join")) { - if (!jsonWhereClause(document["where"], sql, asset_codes, true, "t0.")) + if (!jsonWhereClause(document["where"], sql, asset_codes, false, "t0.")) { return false; } @@ -1130,7 +1130,7 @@ vector asset_codes; } else if (document.HasMember("where")) { - if (!jsonWhereClause(document["where"], sql, asset_codes, true)) + if (!jsonWhereClause(document["where"], sql, asset_codes, false)) { raiseError("retrieve", "Failed to add where clause"); return false; diff --git a/C/tasks/statistics_history/stats_history.cpp b/C/tasks/statistics_history/stats_history.cpp index cba16a848a..2ce91238bb 100644 --- a/C/tasks/statistics_history/stats_history.cpp +++ b/C/tasks/statistics_history/stats_history.cpp @@ -163,7 +163,7 @@ std::string StatsHistory::getTime(void) const struct timeval tv ; struct tm* timeinfo; gettimeofday(&tv, NULL); - timeinfo = localtime(&tv.tv_sec); + timeinfo = gmtime(&tv.tv_sec); char date_time[DATETIME_MAX_LEN]; // Create datetime with seconds strftime(date_time, From 5fab1437ff43c03b11030d82ed5e38a6aaf60fe3 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 10 Aug 2023 16:11:17 +0530 Subject: [PATCH 373/499] Control entrypoint Flow API: Create operation Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 220 ++++++++++++++++++ python/fledge/services/core/routes.py | 6 +- 2 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 python/fledge/services/core/api/control_service/entrypoint.py diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py new file mode 100644 index 0000000000..e664772490 --- /dev/null +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- + +# FLEDGE_BEGIN +# See: http://fledge-iot.readthedocs.io/ +# FLEDGE_END + +import json +from aiohttp import web +from enum import IntEnum + +from fledge.common.logger import FLCoreLogger +from fledge.common.storage_client.payload_builder import PayloadBuilder +from fledge.services.core import connect, server + + +__author__ = "Ashish Jabble" +__copyright__ = "Copyright (c) 2023 Dianomic Systems Inc." +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + +_logger = FLCoreLogger().get_logger(__name__) + +_help = """ + Two types of users: Control Administrator and Control Requestor + Control Administrator: + - has access rights to create a control entrypoint. + - must be a user with role of admin or control + Control Requestor: + - can make requests to defined control entrypoint but cannot create new entrypoints. + - any user role can make request to control entrypoint but the username must match one in list + of users given when entrypoint was created. + ----------------------------------------------------------------------------------------------------------------- + | POST | /fledge/control/manage | + ------------------------------------------------------------------------------------------------------------------ +""" + + +def setup(app): + app.router.add_route('POST', '/fledge/control/manage', create) + + +class EntryPointType(IntEnum): + WRITE = 0 + OPERATION = 1 + + +class Destination(IntEnum): + BROADCAST = 0 + SERVICE = 1 + ASSET = 2 + SCRIPT = 3 + + +async def _get_type(identifier): + if isinstance(identifier, str): + type_converted = [ept.value for ept in EntryPointType if ept.name.lower() == identifier] + else: + type_converted = [ept.name.lower() for ept in EntryPointType if ept.value == identifier] + return type_converted[0] + + +async def _get_destination(identifier): + if isinstance(identifier, str): + dest_converted = [d.value for d in Destination if d.name.lower() == identifier] + else: + dest_converted = [d.name.lower() for d in Destination if d.value == identifier] + return dest_converted[0] + + +async def _check_parameters(payload): + required_keys = {"name", "description", "type", "destination", "constants", "variables"} + if not all(k in payload.keys() for k in required_keys): + raise KeyError("{} required keys are missing in request payload.".format(required_keys)) + final = {} + name = payload.get('name', None) + if name is not None: + if not isinstance(name, str): + raise ValueError('Control entrypoint name should be in string.') + name = name.strip() + if len(name) == 0: + raise ValueError('Control entrypoint name cannot be empty.') + final['name'] = name + description = payload.get('description', None) + if description is not None: + if not isinstance(description, str): + raise ValueError('Control entrypoint description should be in string.') + description = description.strip() + if len(description) == 0: + raise ValueError('Control entrypoint description cannot be empty.') + final['description'] = description + _type = payload.get('type', None) + if _type is not None: + if not isinstance(_type, str): + raise ValueError('Control entrypoint type should be in string.') + _type = _type.strip() + if len(_type) == 0: + raise ValueError('Control entrypoint type cannot be empty.') + ept_names = [ept.name.lower() for ept in EntryPointType] + if _type not in ept_names: + raise ValueError('Possible types are: {}'.format(ept_names)) + if _type == EntryPointType.OPERATION.name.lower(): + operation_name = payload.get('operation_name', None) + if operation_name is not None: + if not isinstance(operation_name, str): + raise ValueError('Control entrypoint operation name should be in string.') + operation_name = operation_name.strip() + if len(operation_name) == 0: + raise ValueError('Control entrypoint operation name cannot be empty.') + else: + raise KeyError('operation_name KV pair is missing') + final['operation_name'] = operation_name + final['type'] = await _get_type(_type) + + destination = payload.get('destination', None) + if destination is not None: + if not isinstance(destination, str): + raise ValueError('Control entrypoint destination should be in string.') + destination = destination.strip() + if len(destination) == 0: + raise ValueError('Control entrypoint destination cannot be empty.') + dest_names = [d.name.lower() for d in Destination] + if destination not in dest_names: + raise ValueError('Possible destination values are: {}'.format(dest_names)) + + destination_idx = await _get_destination(destination) + final['destination'] = destination_idx + + # only if non-zero + final['destination_arg'] = '' + if destination_idx: + destination_arg = payload.get(destination, None) + if destination_arg is not None: + if not isinstance(destination_arg, str): + raise ValueError('Control entrypoint destination argument should be in string.') + destination_arg = destination_arg.strip() + if len(destination_arg) == 0: + raise ValueError('Control entrypoint destination argument cannot be empty.') + final[destination] = destination_arg + final['destination_arg'] = destination + else: + raise KeyError('{} destination argument is missing.'.format(destination)) + anonymous = payload.get('anonymous', None) + if anonymous is not None: + if not isinstance(anonymous, bool): + raise ValueError('anonymous should be a bool.') + anonymous = 't' if anonymous else 'f' + final['anonymous'] = anonymous + constants = payload.get('constants', None) + if constants is not None: + if not isinstance(constants, dict): + raise ValueError('constants should be an object.') + # TODO: need confirmation on validation + if not constants: + raise ValueError('constants should not be empty.') + final['constants'] = constants + + variables = payload.get('variables', None) + if variables is not None: + if not isinstance(variables, dict): + raise ValueError('variables should be an object.') + # TODO: need confirmation on validation + if not variables: + raise ValueError('variables should not be empty.') + final['variables'] = variables + + allow = payload.get('allow', None) + if allow is not None: + if not isinstance(allow, list): + raise ValueError('allow should be an array of list of users.') + # FIXME: get usernames validation + final['allow'] = allow + return final + + +async def create(request: web.Request) -> web.Response: + """Create a control entrypoint + :Example: + curl -sX POST http://localhost:8081/fledge/control/manage -d '{"name": "SetLatheSpeed", "description": "Set the speed of the lathe", "type": "write", "destination": "asset", "asset": "lathe", "constants": {"units": "spin"}, "variables": {"rpm": "100"}, "allow":["AJ"], "anonymous": "reject"}' + """ + try: + data = await request.json() + payload = await _check_parameters(data) + name = payload['name'] + # add common data keys in control_api table + control_api_column_name = {"name": name, + "description": payload['description'], + "type": payload['type'], + "operation_name": payload['operation_name'] if payload['type'] == 1 else "", + "destination": payload['destination'], + "destination_arg": payload[ + payload['destination_arg']] if payload['destination'] else "", + "anonymous": payload['anonymous'] + } + api_insert_payload = PayloadBuilder().INSERT(**control_api_column_name).payload() + storage = connect.get_storage_async() + insert_api_result = await storage.insert_into_tbl("control_api", api_insert_payload) + if insert_api_result['rows_affected'] == 1: + # add if any params data keys in control_api_parameters table + for k, v in payload['constants'].items(): + control_api_params_column_name = {"name": name, "parameter": k, "value": v, "constant": 't'} + api_params_insert_payload = PayloadBuilder().INSERT(**control_api_params_column_name).payload() + await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) + for k, v in payload['variables'].items(): + control_api_params_column_name = {"name": name, "parameter": k, "value": v, "constant": 'f'} + api_params_insert_payload = PayloadBuilder().INSERT(**control_api_params_column_name).payload() + await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) + # add if any users in control_api_acl table + for u in payload['allow']: + control_acl_column_name = {"name": name, "user": u} + acl_insert_payload = PayloadBuilder().INSERT(**control_acl_column_name).payload() + await storage.insert_into_tbl("control_api_acl", acl_insert_payload) + except (KeyError, ValueError) as err: + msg = str(err) + raise web.HTTPBadRequest(body=json.dumps({"message": msg}), reason=msg) + except Exception as ex: + msg = str(ex) + _logger.error(ex, "Failed to create control entrypoint.") + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response({"message": "{} control entrypoint has been created successfully.".format(name)}) diff --git a/python/fledge/services/core/routes.py b/python/fledge/services/core/routes.py index 125315e5b5..b356008e63 100644 --- a/python/fledge/services/core/routes.py +++ b/python/fledge/services/core/routes.py @@ -11,7 +11,7 @@ from fledge.services.core.api import configuration as api_configuration from fledge.services.core.api import scheduler as api_scheduler from fledge.services.core.api import statistics as api_statistics -from fledge.services.core.api.control_service import script_management, acl_management, pipeline +from fledge.services.core.api.control_service import script_management, acl_management, pipeline, entrypoint from fledge.services.core.api.plugins import data as plugin_data from fledge.services.core.api.plugins import install as plugins_install, discovery as plugins_discovery from fledge.services.core.api.plugins import update as plugins_update @@ -250,6 +250,10 @@ def setup(app): # Control Pipelines pipeline.setup(app) + # Control Entrypoint + entrypoint.setup(app) + + # Python packages app.router.add_route('GET', '/fledge/python/packages', python_packages.get_packages) app.router.add_route('POST', '/fledge/python/package', python_packages.install_package) From 6748a9d009533de69f03bef62775359291333354 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 10 Aug 2023 16:12:49 +0530 Subject: [PATCH 374/499] Control entrypoint Flow API: Get list of all control entrypoints Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index e664772490..a9310a6ae1 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -30,14 +30,15 @@ - any user role can make request to control entrypoint but the username must match one in list of users given when entrypoint was created. ----------------------------------------------------------------------------------------------------------------- - | POST | /fledge/control/manage | + | GET POST | /fledge/control/manage | ------------------------------------------------------------------------------------------------------------------ """ def setup(app): app.router.add_route('POST', '/fledge/control/manage', create) - + app.router.add_route('GET', '/fledge/control/manage', get_all) + class EntryPointType(IntEnum): WRITE = 0 @@ -218,3 +219,22 @@ async def create(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": "{} control entrypoint has been created successfully.".format(name)}) + + +async def get_all(request: web.Request) -> web.Response: + """Get a list of all control entrypoints + :Example: + curl -sX GET http://localhost:8081/fledge/control/manage + """ + storage = connect.get_storage_async() + result = await storage.query_tbl("control_api") + entrypoint = [] + for r in result["rows"]: + """permitted: means user is able to make the API call + This is on the basis of anonymous flag if true then permitted true + If anonymous flag is false then list of allowed users to determine if the specific user can make the call + """ + # TODO: verify the user when anonymous is false and set permitted value based on it + entrypoint.append({"name": r['name'], "description": r['description'], + "permitted": True if r['anonymous'] == 't' else False}) + return web.json_response({"controls": entrypoint}) From e3da953f06a7ce92828cbee22d6808ed7b8ad778 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 10 Aug 2023 16:14:08 +0530 Subject: [PATCH 375/499] Control entrypoint Flow API: Get by name Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index a9310a6ae1..8cb6d13a1a 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -31,6 +31,7 @@ of users given when entrypoint was created. ----------------------------------------------------------------------------------------------------------------- | GET POST | /fledge/control/manage | + | GET | /fledge/control/manage/{name} | ------------------------------------------------------------------------------------------------------------------ """ @@ -38,6 +39,7 @@ def setup(app): app.router.add_route('POST', '/fledge/control/manage', create) app.router.add_route('GET', '/fledge/control/manage', get_all) + app.router.add_route('GET', '/fledge/control/manage/{name}', get_by_name) class EntryPointType(IntEnum): @@ -238,3 +240,54 @@ async def get_all(request: web.Request) -> web.Response: entrypoint.append({"name": r['name'], "description": r['description'], "permitted": True if r['anonymous'] == 't' else False}) return web.json_response({"controls": entrypoint}) + + +async def get_by_name(request: web.Request) -> web.Response: + """Get a control entrypoint by name + :Example: + curl -sX GET http://localhost:8081/fledge/control/manage/SetLatheSpeed + """ + # TODO: forbidden when permitted is false on the basis of anonymous + name = request.match_info.get('name', None) + try: + storage = connect.get_storage_async() + payload = PayloadBuilder().WHERE(["name", '=', name]).payload() + result = await storage.query_tbl_with_payload("control_api", payload) + if not result['rows']: + raise KeyError('{} control entrypoint not found.'.format(name)) + response = result['rows'][0] + response['type'] = await _get_type(response['type']) + response['destination'] = await _get_destination(response['destination']) + if response['destination'] != "broadcast": + response[response['destination']] = response['destination_arg'] + del response['destination_arg'] + param_result = await storage.query_tbl_with_payload("control_api_parameters", payload) + if param_result['rows']: + constants = {} + variables = {} + for r in param_result['rows']: + if r['constant'] == 't': + constants[r['parameter']] = r['value'] + else: + variables[r['parameter']] = r['value'] + response['constants'] = constants + response['variables'] = variables + response['allow'] = "" + acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) + if acl_result['rows']: + users = [] + for r in acl_result['rows']: + users.append(r['user']) + response['allow'] = users + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + except Exception as ex: + msg = str(ex) + _logger.error(ex, "Failed to fetch details of entry point for a given name: <{}>.".format(name)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response(response) From 4388606d2889e6e85675741fe8ab1243326b081e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 10 Aug 2023 16:15:18 +0530 Subject: [PATCH 376/499] Control entrypoint Flow API: Delete Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 8cb6d13a1a..b535610f05 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -31,7 +31,7 @@ of users given when entrypoint was created. ----------------------------------------------------------------------------------------------------------------- | GET POST | /fledge/control/manage | - | GET | /fledge/control/manage/{name} | + | GET DELETE | /fledge/control/manage/{name} | ------------------------------------------------------------------------------------------------------------------ """ @@ -40,6 +40,7 @@ def setup(app): app.router.add_route('POST', '/fledge/control/manage', create) app.router.add_route('GET', '/fledge/control/manage', get_all) app.router.add_route('GET', '/fledge/control/manage/{name}', get_by_name) + app.router.add_route('DELETE', '/fledge/control/manage/{name}', delete) class EntryPointType(IntEnum): @@ -291,3 +292,32 @@ async def get_by_name(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) + + +async def delete(request: web.Request) -> web.Response: + """Delete a control entrypoint + :Example: + curl -sX DELETE http://localhost:8081/fledge/control/manage/SetLatheSpeed + """ + name = request.match_info.get('name', None) + try: + storage = connect.get_storage_async() + payload = PayloadBuilder().WHERE(["name", '=', name]).payload() + result = await storage.query_tbl_with_payload("control_api", payload) + if not result['rows']: + raise KeyError('{} control entrypoint not found.'.format(name)) + await storage.delete_from_tbl("control_api_acl", payload) + await storage.delete_from_tbl("control_api_parameters", payload) + await storage.delete_from_tbl("control_api", payload) + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + except Exception as ex: + msg = str(ex) + _logger.error(ex, "Failed to delete entry point for a given name: <{}>.".format(name)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response({"message": "{} control entrypoint has been deleted successfully.".format(name)}) From 891bd64a690877aede0b17cb8b24aa2afff43973 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 10 Aug 2023 16:17:34 +0530 Subject: [PATCH 377/499] Control entrypoint Flow API: Update only skelton added Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index b535610f05..841385aad4 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -31,7 +31,8 @@ of users given when entrypoint was created. ----------------------------------------------------------------------------------------------------------------- | GET POST | /fledge/control/manage | - | GET DELETE | /fledge/control/manage/{name} | + | GET PUT DELETE | /fledge/control/manage/{name} | + | PUT | /fledge/control/request/{name} | ------------------------------------------------------------------------------------------------------------------ """ @@ -40,7 +41,9 @@ def setup(app): app.router.add_route('POST', '/fledge/control/manage', create) app.router.add_route('GET', '/fledge/control/manage', get_all) app.router.add_route('GET', '/fledge/control/manage/{name}', get_by_name) + app.router.add_route('PUT', '/fledge/control/manage/{name}', update) app.router.add_route('DELETE', '/fledge/control/manage/{name}', delete) + app.router.add_route('PUT', '/fledge/control/request/{name}', update_request) class EntryPointType(IntEnum): @@ -321,3 +324,33 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": "{} control entrypoint has been deleted successfully.".format(name)}) + + +async def update(request: web.Request) -> web.Response: + """Update a control entrypoint + :Example: + curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"name": "Changed"}' + """ + name = request.match_info.get('name', None) + try: + storage = connect.get_storage_async() + payload = PayloadBuilder().WHERE(["name", '=', name]).payload() + result = await storage.query_tbl_with_payload("control_api", payload) + if not result['rows']: + raise KeyError('{} control entrypoint not found.'.format(name)) + data = await request.json() + # TODO: update + except Exception as ex: + msg = str(ex) + _logger.error(ex, "Failed to update the details of entry point for a given name: <{}>.".format(name)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response({"message": "To be Implemented"}) + + +async def update_request(request: web.Request) -> web.Response: + """API control entry points can be called with PUT operation to URL form + :Example: + curl -sX PUT http://localhost:8081/fledge/control/request/SetLatheSpeed -d '{"distance": "13"}' + """ + return web.json_response({"message": "To be Implemented"}) From 6ff1f4eb688b17f3e6ab274a07ddf2077f9a824d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 10 Aug 2023 16:36:06 +0530 Subject: [PATCH 378/499] On create if name is already in use then return with 400 Signed-off-by: ashish-jabble --- .../fledge/services/core/api/control_service/entrypoint.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 841385aad4..84f4000d5a 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -188,6 +188,11 @@ async def create(request: web.Request) -> web.Response: data = await request.json() payload = await _check_parameters(data) name = payload['name'] + storage = connect.get_storage_async() + result = await storage.query_tbl("control_api") + entrypoints = [r['name'] for r in result['rows']] + if name in entrypoints: + raise ValueError('{} control entrypoint is already in use.'.format(name)) # add common data keys in control_api table control_api_column_name = {"name": name, "description": payload['description'], @@ -199,7 +204,6 @@ async def create(request: web.Request) -> web.Response: "anonymous": payload['anonymous'] } api_insert_payload = PayloadBuilder().INSERT(**control_api_column_name).payload() - storage = connect.get_storage_async() insert_api_result = await storage.insert_into_tbl("control_api", api_insert_payload) if insert_api_result['rows_affected'] == 1: # add if any params data keys in control_api_parameters table From a429fcd19f6e0bcd04510ffed5d7ebc646375c4b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 10 Aug 2023 17:54:58 +0530 Subject: [PATCH 379/499] Control entrypoint Flow API: Update control request endpoint Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 84f4000d5a..8b23062cc9 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -295,7 +295,7 @@ async def get_by_name(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error(ex, "Failed to fetch details of entry point for a given name: <{}>.".format(name)) + _logger.error(ex, "Failed to fetch details of {} entrypoint.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -324,7 +324,7 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error(ex, "Failed to delete entry point for a given name: <{}>.".format(name)) + _logger.error(ex, "Failed to delete of {} entrypoint.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": "{} control entrypoint has been deleted successfully.".format(name)}) @@ -346,7 +346,7 @@ async def update(request: web.Request) -> web.Response: # TODO: update except Exception as ex: msg = str(ex) - _logger.error(ex, "Failed to update the details of entry point for a given name: <{}>.".format(name)) + _logger.error(ex, "Failed to update the details of {} entrypoint.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": "To be Implemented"}) @@ -357,4 +357,39 @@ async def update_request(request: web.Request) -> web.Response: :Example: curl -sX PUT http://localhost:8081/fledge/control/request/SetLatheSpeed -d '{"distance": "13"}' """ - return web.json_response({"message": "To be Implemented"}) + name = request.match_info.get('name', None) + try: + storage = connect.get_storage_async() + payload = PayloadBuilder().WHERE(["name", '=', name]).payload() + result = await storage.query_tbl_with_payload("control_api", payload) + if not result['rows']: + raise KeyError('{} control entrypoint not found.'.format(name)) + result = await storage.query_tbl_with_payload("control_api_parameters", payload) + if request.user is not None: + # Admin and Control roles can always call entrypoints. But for a user it must match in list of allowed users + if request.user["role_id"] not in (1, 5): + acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) + allowed_user = [r['user'] for r in acl_result['rows']] + if request.user["uname"] not in allowed_user: + raise ValueError("Operation is not allowed for the {} user.".format(request.user['uname'])) + data = await request.json() + payload = {"updates": []} + for k, v in data.items(): + for r in result['rows']: + if r['parameter'] == k: + if isinstance(v, str): + payload_item = PayloadBuilder().SET(value=v).WHERE(["name", "=", name]).AND_WHERE(["parameter", "=", k]).payload() + payload['updates'].append(json.loads(payload_item)) + break + else: + raise ValueError("Value should be in string for {} parameter.".format(k)) + await storage.update_tbl("control_api_parameters", json.dumps(payload)) + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + except Exception as ex: + msg = str(ex) + _logger.error(ex, "Failed to update the control request details of {} entrypoint.".format(name)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response({"message": "To be Implemented"}) From 09a375d09f83794e3e008249ad7830a2df28dc15 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 10 Aug 2023 19:27:10 +0530 Subject: [PATCH 380/499] Control Flow API: Update operation with some exclusion of fields Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 8b23062cc9..c6f121a48c 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -74,10 +74,11 @@ async def _get_destination(identifier): return dest_converted[0] -async def _check_parameters(payload): - required_keys = {"name", "description", "type", "destination", "constants", "variables"} - if not all(k in payload.keys() for k in required_keys): - raise KeyError("{} required keys are missing in request payload.".format(required_keys)) +async def _check_parameters(payload, skip_required=False): + if not skip_required: + required_keys = {"name", "description", "type", "destination", "constants", "variables"} + if not all(k in payload.keys() for k in required_keys): + raise KeyError("{} required keys are missing in request payload.".format(required_keys)) final = {} name = payload.get('name', None) if name is not None: @@ -343,13 +344,28 @@ async def update(request: web.Request) -> web.Response: if not result['rows']: raise KeyError('{} control entrypoint not found.'.format(name)) data = await request.json() - # TODO: update + columns = await _check_parameters(data, skip_required=True) + # TODO: rename + if 'name' in columns: + del columns['name'] + # TODO: "constants", "variables", "allow" + possible_keys = {"name", "description", "type", "operation_name", "destination", "destination_arg", "anonymous"} + if 'type' in columns: + columns['operation_name'] = columns['operation_name'] if columns['type'] == 1 else "" + if 'destination_arg' in columns: + dest = await _get_destination(columns['destination']) + columns['destination_arg'] = columns[dest] if columns['destination'] else "" + entries_to_remove = set(columns) - set(possible_keys) + for k in entries_to_remove: + del columns[k] + payload = PayloadBuilder().SET(**columns).WHERE(['name', '=', name]).payload() + await storage.update_tbl("control_api", payload) except Exception as ex: msg = str(ex) _logger.error(ex, "Failed to update the details of {} entrypoint.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: - return web.json_response({"message": "To be Implemented"}) + return web.json_response({"message": "{} control entrypoint has been updated successfully.".format(name)}) async def update_request(request: web.Request) -> web.Response: @@ -392,4 +408,4 @@ async def update_request(request: web.Request) -> web.Response: _logger.error(ex, "Failed to update the control request details of {} entrypoint.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: - return web.json_response({"message": "To be Implemented"}) + return web.json_response({"message": "{} control entrypoint URL called.".format(name)}) From c10e4f462a5f9520805364f4ac84d36fd25e3033 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 10 Aug 2023 15:35:28 +0100 Subject: [PATCH 381/499] FOGL-8028 Resolve build issue with ARM compiler (#1139) Signed-off-by: Mark Riddoch --- C/common/result_set.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C/common/result_set.cpp b/C/common/result_set.cpp index 401cf95db2..953d15dd68 100644 --- a/C/common/result_set.cpp +++ b/C/common/result_set.cpp @@ -95,7 +95,7 @@ ResultSet::ResultSet(const std::string& json) rowValue->append(new ColumnValue(string(item->value.GetString()))); break; case INT_COLUMN: - rowValue->append(new ColumnValue(item->value.GetInt64())); + rowValue->append(new ColumnValue((long)(item->value.GetInt64()))); break; case NUMBER_COLUMN: rowValue->append(new ColumnValue(item->value.GetDouble())); From 236effb49830ccdcb8199440b20bb72cd4cb4e11 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 11 Aug 2023 14:32:25 +0530 Subject: [PATCH 382/499] Any destination entry added in schema Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/init.sql | 3 ++- scripts/plugins/storage/postgres/upgrade/60.sql | 3 ++- scripts/plugins/storage/sqlite/init.sql | 3 ++- scripts/plugins/storage/sqlite/upgrade/60.sql | 3 ++- scripts/plugins/storage/sqlitelb/init.sql | 3 ++- scripts/plugins/storage/sqlitelb/upgrade/60.sql | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 93d0a773de..24244ac1b7 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -1171,7 +1171,8 @@ INSERT INTO fledge.control_source ( name, description ) -- Insert predefined entries for Control Destination DELETE FROM fledge.control_destination; INSERT INTO fledge.control_destination ( name, description ) - VALUES ('Service', 'A name of service that is being controlled.'), + VALUES ('Any', 'Any destination.'), + ('Service', 'A name of service that is being controlled.'), ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); diff --git a/scripts/plugins/storage/postgres/upgrade/60.sql b/scripts/plugins/storage/postgres/upgrade/60.sql index 084ce96f02..a3e7be4608 100644 --- a/scripts/plugins/storage/postgres/upgrade/60.sql +++ b/scripts/plugins/storage/postgres/upgrade/60.sql @@ -82,7 +82,8 @@ INSERT INTO fledge.control_source ( name, description ) -- Insert predefined entries for Control Destination DELETE FROM fledge.control_destination; INSERT INTO fledge.control_destination ( name, description ) - VALUES ('Service', 'A name of service that is being controlled.'), + VALUES ('Any', 'Any destination.'), + ('Service', 'A name of service that is being controlled.'), ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index d4876dd8f3..ab548009fa 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -927,7 +927,8 @@ INSERT INTO fledge.control_source ( name, description ) -- Control Destination DELETE FROM fledge.control_destination; INSERT INTO fledge.control_destination ( name, description ) - VALUES ('Service', 'A name of service that is being controlled.'), + VALUES ('Any', 'Any destination.'), + ('Service', 'A name of service that is being controlled.'), ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); diff --git a/scripts/plugins/storage/sqlite/upgrade/60.sql b/scripts/plugins/storage/sqlite/upgrade/60.sql index fe914fda7f..ed5a072205 100644 --- a/scripts/plugins/storage/sqlite/upgrade/60.sql +++ b/scripts/plugins/storage/sqlite/upgrade/60.sql @@ -47,7 +47,8 @@ INSERT INTO fledge.control_source ( name, description ) -- Insert predefined entries for Control Destination DELETE FROM fledge.control_destination; INSERT INTO fledge.control_destination ( name, description ) - VALUES ('Service', 'A name of service that is being controlled.'), + VALUES ('Any', 'Any destination.'), + ('Service', 'A name of service that is being controlled.'), ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 78598358f2..5b2e9bc6b7 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -926,7 +926,8 @@ INSERT INTO fledge.control_source ( name, description ) -- Control Destination DELETE FROM fledge.control_destination; INSERT INTO fledge.control_destination ( name, description ) - VALUES ('Service', 'A name of service that is being controlled.'), + VALUES ('Any', 'Any destination.'), + ('Service', 'A name of service that is being controlled.'), ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); diff --git a/scripts/plugins/storage/sqlitelb/upgrade/60.sql b/scripts/plugins/storage/sqlitelb/upgrade/60.sql index fe914fda7f..ed5a072205 100644 --- a/scripts/plugins/storage/sqlitelb/upgrade/60.sql +++ b/scripts/plugins/storage/sqlitelb/upgrade/60.sql @@ -47,7 +47,8 @@ INSERT INTO fledge.control_source ( name, description ) -- Insert predefined entries for Control Destination DELETE FROM fledge.control_destination; INSERT INTO fledge.control_destination ( name, description ) - VALUES ('Service', 'A name of service that is being controlled.'), + VALUES ('Any', 'Any destination.'), + ('Service', 'A name of service that is being controlled.'), ('Asset', 'A name of asset that is being controlled.'), ('Script', 'A name of script that will be executed.'), ('Broadcast', 'No name is applied and pipeline will be considered for any control writes or operations to broadcast destinations.'); From 0dddb5eaf2200ea88620f78cb5026d5acddc5807 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 11 Aug 2023 14:32:59 +0530 Subject: [PATCH 383/499] Adapt Any type destination in control pipeline Signed-off-by: ashish-jabble --- .../core/api/control_service/pipeline.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index 17235ba259..a47490b361 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -77,10 +77,11 @@ async def create(request: web.Request) -> web.Response: source or destination :Example: + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "wildcard", "enabled": true, "execution": "shared", "source": {"type": 1}, "destination": {"type": 1}}' curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "pump", "enabled": true, "execution": "shared", "source": {"type": 2, "name": "pump"}}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "broadcast", "enabled": true, "execution": "exclusive", "destination": {"type": 4}}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enabled": true, "execution": "shared", "source": {"type": 2, "name": "opcua"}, "destination": {"type": 2, "name": "pump1"}}' - curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump1", "enabled": true, "execution": "exclusive", "source": {"type": 2, "name": "southOpcua"}, "destination": {"type": 1, "name": "northOpcua"}, "filters": ["Filter1"]}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "broadcast", "enabled": true, "execution": "exclusive", "destination": {"type": 5}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump", "enabled": true, "execution": "shared", "source": {"type": 2, "name": "opcua"}, "destination": {"type": 3, "name": "pump1"}}' + curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "opcua_pump1", "enabled": true, "execution": "exclusive", "source": {"type": 2, "name": "southOpcua"}, "destination": {"type": 2, "name": "northOpcua"}, "filters": ["Filter1"]}' curl -sX POST http://localhost:8081/fledge/control/pipeline -d '{"name": "Test", "enabled": false, "filters": ["Filter1", "Filter2"]}' """ try: @@ -203,7 +204,7 @@ async def update(request: web.Request) -> web.Response: :Example: curl -sX PUT http://localhost:8081/fledge/control/pipeline/1 -d '{"filters": ["F3", "F2"]}' curl -sX PUT http://localhost:8081/fledge/control/pipeline/13 -d '{"name": "Changed"}' - curl -sX PUT http://localhost:8081/fledge/control/pipeline/9 -d '{"enabled": false, "execution": "exclusive", "filters": [], "source": {"type": 1, "name": "Universal"}, "destination": {"type": 3, "name": "TestScript"}}' + curl -sX PUT http://localhost:8081/fledge/control/pipeline/9 -d '{"enabled": false, "execution": "exclusive", "filters": [], "source": {"type": 1, "name": "Universal"}, "destination": {"type": 4, "name": "TestScript"}}' """ cpid = request.match_info.get('id', None) try: @@ -444,8 +445,8 @@ async def _check_parameters(payload, request): raise ValueError("Invalid destination type found.") else: raise ValueError('Destination type is missing.') - # Note: when destination type is Broadcast; no name is applied - if des_type != 4: + # Note: when destination type is Any or Broadcast; no name is applied + if des_type not in (1, 5): if des_name is not None: if not isinstance(des_name, str): raise ValueError("Destination name should be a string value.") @@ -468,7 +469,7 @@ async def _check_parameters(payload, request): if source is not None and destination is not None: error_msg = "Pipeline is not allowed with same type of source and destination." # Service - if source_type == 2 and des_type == 1: + if source_type == 2 and des_type == 2: schedules = await server.Server.scheduler.get_schedules() south_schedules = [sch.name for sch in schedules if sch.schedule_type == 1 and sch.process_name == "south_c"] north_schedules = [sch.name for sch in schedules if @@ -477,7 +478,7 @@ async def _check_parameters(payload, request): source_name in north_schedules and des_name in north_schedules): raise ValueError(error_msg) # Script - if source_type == 6 and des_type == 3: + if source_type == 6 and des_type == 4: raise ValueError(error_msg) # filters filters = payload.get('filters', None) @@ -520,10 +521,10 @@ async def get_notifications(): if not any(notify['child'] == value for notify in all_notifications): raise ValueError("'{}' not a valid notification instance name.".format(value)) - if (lookup_name == "source" and _type == 2) or (lookup_name == 'destination' and _type == 1): + if (lookup_name == "source" and _type == 2) or (lookup_name == 'destination' and _type == 2): # Verify schedule name in startup type and south, north based schedules await get_schedules() - elif (lookup_name == "source" and _type == 6) or (lookup_name == 'destination' and _type == 3): + elif (lookup_name == "source" and _type == 6) or (lookup_name == 'destination' and _type == 4): # Verify control script name await get_control_scripts() elif lookup_name == "source" and _type == 4: @@ -532,7 +533,7 @@ async def get_notifications(): elif lookup_name == "source" and _type == 5: # Verify schedule name in all type of schedules await get_schedules() - elif lookup_name == "destination" and _type == 2: + elif lookup_name == "destination" and _type == 3: # Verify asset name await get_assets() else: From 34ed53d43033d8791cc0dee1feaa40c19fd2d2c8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 11 Aug 2023 14:33:33 +0530 Subject: [PATCH 384/499] doc screenshot updated for control pipeline destination Signed-off-by: ashish-jabble --- docs/images/control/pipeline_destination.jpg | Bin 20956 -> 13123 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/images/control/pipeline_destination.jpg b/docs/images/control/pipeline_destination.jpg index 1d0f347bc1b3aaab1de3cc74034fd6b3d8fe1561..2ab93471b5db641c545f82e98da61fe5781a5169 100644 GIT binary patch literal 13123 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 F{{?G%Qy2gM literal 20956 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 B02=@R From 40df292c3eb3f2c2720653f0c2f818d26dfba9bb Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 11 Aug 2023 15:13:45 +0530 Subject: [PATCH 385/499] Control Flow API schema added for PostgreSQL along with upgrade and downgrade SQL's Signed-off-by: ashish-jabble --- .../plugins/storage/postgres/downgrade/63.sql | 4 +++ scripts/plugins/storage/postgres/init.sql | 29 +++++++++++++++++++ .../plugins/storage/postgres/upgrade/64.sql | 28 ++++++++++++++++++ scripts/plugins/storage/sqlite/init.sql | 2 +- scripts/plugins/storage/sqlite/upgrade/64.sql | 2 +- scripts/plugins/storage/sqlitelb/init.sql | 2 +- .../plugins/storage/sqlitelb/upgrade/64.sql | 2 +- 7 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 scripts/plugins/storage/postgres/downgrade/63.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/64.sql diff --git a/scripts/plugins/storage/postgres/downgrade/63.sql b/scripts/plugins/storage/postgres/downgrade/63.sql new file mode 100644 index 0000000000..df75e578bf --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/63.sql @@ -0,0 +1,4 @@ +-- Drop control flow tables +DROP TABLE IF EXISTS fledge.control_api; +DROP TABLE IF EXISTS fledge.control_api_parameters; +DROP TABLE IF EXISTS fledge.control_api_acl; diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 93d0a773de..c906b7fb1b 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -905,6 +905,35 @@ CREATE TABLE fledge.control_filters ( CONSTRAINT control_filters_fk1 FOREIGN KEY (cpid) REFERENCES fledge.control_pipelines (cpid) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION ); +-- Create control_api table +CREATE TABLE fledge.control_api ( + name character varying(255) NOT NULL , -- control API name + description character varying(255) NOT NULL , -- description of control API + type integer NOT NULL , -- 0 for write and 1 for operation + operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script + destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero + anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false + CONSTRAINT control_api_pname PRIMARY KEY (name) + ); + +-- Create control_api_parameters table +CREATE TABLE fledge.control_api_parameters ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + parameter character varying(255) NOT NULL , -- name of parameter + value character varying(255) , -- value of parameter if constant otherwise default + constant boolean NOT NULL , -- parameter is either a constant or variable + CONSTRAINT control_api_parameters_fk1 FOREIGN KEY (name) REFERENCES fledge.control_api (name) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); + +-- Create control_api_acl table +CREATE TABLE fledge.control_api_acl ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + "user" character varying(255) NOT NULL , -- foreign key to fledge.users + CONSTRAINT control_api_acl_fk1 FOREIGN KEY (name) REFERENCES fledge.control_api (name) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION, + CONSTRAINT control_api_acl_fk2 FOREIGN KEY ("user") REFERENCES fledge.users (uname) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); + CREATE TABLE fledge.monitors ( service character varying(255) NOT NULL, monitor character varying(80) NOT NULL, diff --git a/scripts/plugins/storage/postgres/upgrade/64.sql b/scripts/plugins/storage/postgres/upgrade/64.sql new file mode 100644 index 0000000000..0fc56e790f --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/64.sql @@ -0,0 +1,28 @@ +-- Create control_api table +CREATE TABLE fledge.control_api ( + name character varying(255) NOT NULL , -- control API name + description character varying(255) NOT NULL , -- description of control API + type integer NOT NULL , -- 0 for write and 1 for operation + operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script + destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero + anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false + CONSTRAINT control_api_pname PRIMARY KEY (name) + ); + +-- Create control_api_parameters table +CREATE TABLE fledge.control_api_parameters ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + parameter character varying(255) NOT NULL , -- name of parameter + value character varying(255) , -- value of parameter if constant otherwise default + constant boolean NOT NULL , -- parameter is either a constant or variable + CONSTRAINT control_api_parameters_fk1 FOREIGN KEY (name) REFERENCES fledge.control_api (name) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); + +-- Create control_api_acl table +CREATE TABLE fledge.control_api_acl ( + name character varying(255) NOT NULL , -- foreign key to fledge.control_api + "user" character varying(255) NOT NULL , -- foreign key to fledge.users + CONSTRAINT control_api_acl_fk1 FOREIGN KEY (name) REFERENCES fledge.control_api (name) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION, + CONSTRAINT control_api_acl_fk2 FOREIGN KEY ("user") REFERENCES fledge.users (uname) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION + ); diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 8a6090d683..4ea5d6a21a 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -672,7 +672,7 @@ CREATE TABLE fledge.control_api ( description character varying(255) NOT NULL , -- description of control API type integer NOT NULL , -- 0 for write and 1 for operation operation_name character varying(255) , -- name of the operation and only valid if type is operation - destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false CONSTRAINT control_api_pname PRIMARY KEY (name) diff --git a/scripts/plugins/storage/sqlite/upgrade/64.sql b/scripts/plugins/storage/sqlite/upgrade/64.sql index 6e445b9455..ab191fa824 100644 --- a/scripts/plugins/storage/sqlite/upgrade/64.sql +++ b/scripts/plugins/storage/sqlite/upgrade/64.sql @@ -4,7 +4,7 @@ CREATE TABLE fledge.control_api ( description character varying(255) NOT NULL , -- description of control API type integer NOT NULL , -- 0 for write and 1 for operation operation_name character varying(255) , -- name of the operation and only valid if type is operation - destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false CONSTRAINT control_api_pname PRIMARY KEY (name) diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index e6e4e4980e..4bd7194145 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -672,7 +672,7 @@ CREATE TABLE fledge.control_api ( description character varying(255) NOT NULL , -- description of control API type integer NOT NULL , -- 0 for write and 1 for operation operation_name character varying(255) , -- name of the operation and only valid if type is operation - destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false CONSTRAINT control_api_pname PRIMARY KEY (name) diff --git a/scripts/plugins/storage/sqlitelb/upgrade/64.sql b/scripts/plugins/storage/sqlitelb/upgrade/64.sql index 6e445b9455..ab191fa824 100644 --- a/scripts/plugins/storage/sqlitelb/upgrade/64.sql +++ b/scripts/plugins/storage/sqlitelb/upgrade/64.sql @@ -4,7 +4,7 @@ CREATE TABLE fledge.control_api ( description character varying(255) NOT NULL , -- description of control API type integer NOT NULL , -- 0 for write and 1 for operation operation_name character varying(255) , -- name of the operation and only valid if type is operation - destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script operation_name character varying(255) , -- name of the operation and only valid if type is operation + destination integer NOT NULL , -- destination of request; 0-broadcast, 1-service, 2-asset, 3-script destination_arg character varying(255) , -- name of the destination and only used if destination is non-zero anonymous boolean NOT NULL DEFAULT 'f' , -- anonymous callers to make request to control API; by default false CONSTRAINT control_api_pname PRIMARY KEY (name) From 6b08e191f1d6e582292456109044472e93a3b09a Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 11 Aug 2023 17:42:57 +0530 Subject: [PATCH 386/499] Downgrade fixes when records are there Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/63.sql | 4 ++-- scripts/plugins/storage/sqlite/downgrade/63.sql | 6 +++--- scripts/plugins/storage/sqlitelb/downgrade/63.sql | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/plugins/storage/postgres/downgrade/63.sql b/scripts/plugins/storage/postgres/downgrade/63.sql index df75e578bf..5b68761180 100644 --- a/scripts/plugins/storage/postgres/downgrade/63.sql +++ b/scripts/plugins/storage/postgres/downgrade/63.sql @@ -1,4 +1,4 @@ -- Drop control flow tables -DROP TABLE IF EXISTS fledge.control_api; -DROP TABLE IF EXISTS fledge.control_api_parameters; DROP TABLE IF EXISTS fledge.control_api_acl; +DROP TABLE IF EXISTS fledge.control_api_parameters; +DROP TABLE IF EXISTS fledge.control_api; \ No newline at end of file diff --git a/scripts/plugins/storage/sqlite/downgrade/63.sql b/scripts/plugins/storage/sqlite/downgrade/63.sql index 7f8daaeb9a..5b68761180 100644 --- a/scripts/plugins/storage/sqlite/downgrade/63.sql +++ b/scripts/plugins/storage/sqlite/downgrade/63.sql @@ -1,4 +1,4 @@ --- Drop control pipeline tables -DROP TABLE IF EXISTS fledge.control_api; -DROP TABLE IF EXISTS fledge.control_api_parameters; +-- Drop control flow tables DROP TABLE IF EXISTS fledge.control_api_acl; +DROP TABLE IF EXISTS fledge.control_api_parameters; +DROP TABLE IF EXISTS fledge.control_api; \ No newline at end of file diff --git a/scripts/plugins/storage/sqlitelb/downgrade/63.sql b/scripts/plugins/storage/sqlitelb/downgrade/63.sql index 7f8daaeb9a..5b68761180 100644 --- a/scripts/plugins/storage/sqlitelb/downgrade/63.sql +++ b/scripts/plugins/storage/sqlitelb/downgrade/63.sql @@ -1,4 +1,4 @@ --- Drop control pipeline tables -DROP TABLE IF EXISTS fledge.control_api; -DROP TABLE IF EXISTS fledge.control_api_parameters; +-- Drop control flow tables DROP TABLE IF EXISTS fledge.control_api_acl; +DROP TABLE IF EXISTS fledge.control_api_parameters; +DROP TABLE IF EXISTS fledge.control_api; \ No newline at end of file From 69bfa829fc8017180e68c67121215f8274a1facf Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 14 Aug 2023 12:09:41 +0530 Subject: [PATCH 387/499] old schedule info added in audit trail entry for change schedule Signed-off-by: ashish-jabble --- .../services/core/scheduler/scheduler.py | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index a1c96baef1..61a5d659ac 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -1090,8 +1090,7 @@ async def save_schedule(self, schedule: Schedule, is_enabled_modified=None, dryr except Exception: self._logger.exception('Update failed: %s', update_payload) raise - audit = AuditLogger(self._storage_async) - await audit.information('SCHCH', {'schedule': schedule.toDict()}) + await self.audit_trail_entry(prev_schedule_row, schedule) else: insert_payload = PayloadBuilder() \ .INSERT(id=str(schedule.schedule_id), @@ -1237,6 +1236,7 @@ async def disable_schedule(self, schedule_id: uuid.UUID, bypass_check=None, reco return True, "Schedule {} already disabled".format(str(schedule_id)) # Disable Schedule - update the schedule in memory + prev_schedule_row = self._schedules[schedule_id] self._schedules[schedule_id] = self._schedules[schedule_id]._replace(enabled=False) # Update database @@ -1318,9 +1318,8 @@ async def disable_schedule(self, schedule_id: uuid.UUID, bypass_check=None, reco str(schedule_id), schedule.process_name) if record_audit_trail: - audit = AuditLogger(self._storage_async) - sch = await self.get_schedule(schedule_id) - await audit.information('SCHCH', {'schedule': sch.toDict()}) + new_schedule_row = await self.get_schedule(schedule_id) + await self.audit_trail_entry(prev_schedule_row, new_schedule_row) return True, "Schedule successfully disabled" async def enable_schedule(self, schedule_id: uuid.UUID, bypass_check=None, record_audit_trail=True): @@ -1344,6 +1343,7 @@ async def enable_schedule(self, schedule_id: uuid.UUID, bypass_check=None, recor return True, "Schedule is already enabled" # Enable Schedule + prev_schedule_row = self._schedules[schedule_id] self._schedules[schedule_id] = self._schedules[schedule_id]._replace(enabled=True) # Update database @@ -1370,9 +1370,8 @@ async def enable_schedule(self, schedule_id: uuid.UUID, bypass_check=None, recor str(schedule_id), schedule.process_name) if record_audit_trail: - audit = AuditLogger(self._storage_async) - sch = await self.get_schedule(schedule_id) - await audit.information('SCHCH', { 'schedule': sch.toDict() }) + new_schedule_row = await self.get_schedule(schedule_id) + await self.audit_trail_entry(prev_schedule_row, new_schedule_row) return True, "Schedule successfully enabled" async def queue_task(self, schedule_id: uuid.UUID, start_now=True) -> None: @@ -1632,3 +1631,19 @@ def extract_day_time_from_interval(self, str_interval): interval_time = datetime.datetime.strptime(interval_time, "%H:%M:%S") return int(interval_days), interval_time + + async def audit_trail_entry(self, old_row, new_row): + audit = AuditLogger(self._storage_async) + old_schedule = {"name": old_row.name, + 'type': old_row.type, + "processName": old_row.process_name, + "repeat": old_row.repeat.total_seconds() if old_row.repeat else 0, + "enabled": True if old_row.enabled else False, + "exclusive": True if old_row.exclusive else False + } + # Timed schedule KV pairs + if old_row.type == 2: + old_schedule["time"] = "{}:{}:{}".format(old_row.time.hour, old_row.time.minute, old_row.time.second + ) if old_row.time else '00:00:00' + old_schedule["day"] = old_row.day if old_row.day else 0 + await audit.information('SCHCH', {'schedule': new_row.toDict(), 'old_schedule': old_schedule}) From cd43e6fd141ec061b5c89dc4ccab9e29d7d20419 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 14 Aug 2023 12:10:13 +0530 Subject: [PATCH 388/499] scheduler tests updated Signed-off-by: ashish-jabble --- .../services/core/scheduler/test_scheduler.py | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py b/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py index 1278ca6e35..2d97fea859 100644 --- a/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py +++ b/tests/unit/python/fledge/services/core/scheduler/test_scheduler.py @@ -830,9 +830,12 @@ def mock_coro(): # THEN assert len(scheduler._storage_async.schedules) == len(scheduler._schedules) assert 1 == audit_logger.call_count - calls = [call('SCHCH', {'schedule': {'name': 'Test Schedule', 'enabled': True, 'repeat': 30.0, - 'exclusive': False, 'day': 1, 'time': '0:0:0', - 'processName': 'TestProcess', 'type': Schedule.Type.TIMED}})] + + new = {'schedule': {'name': 'Test Schedule', 'enabled': True, 'repeat': 30.0, 'exclusive': False, 'day': 1, + 'time': '0:0:0', 'processName': 'TestProcess', 'type': Schedule.Type.TIMED}} + old = {'old_schedule': {'enabled': True, 'exclusive': True, 'name': 'OMF to PI north', + 'processName': 'North Readings to PI', 'repeat': 30.0, 'type': Schedule.Type.INTERVAL}} + calls = [call('SCHCH', {**new, **old})] audit_logger.assert_has_calls(calls, any_order=True) assert 1 == first_task.call_count assert 1 == resume_sch.call_count @@ -873,9 +876,11 @@ def mock_coro(): # THEN assert len(scheduler._storage_async.schedules) == len(scheduler._schedules) assert 1 == audit_logger.call_count - calls = [call('SCHCH', {'schedule': {'name': 'Test Schedule', 'enabled': True, 'repeat': 30.0, - 'exclusive': False, 'day': 1, 'time': '0:0:0', - 'processName': 'TestProcess', 'type': Schedule.Type.TIMED}})] + new = {'schedule': {'name': 'Test Schedule', 'enabled': True, 'repeat': 30.0, 'exclusive': False, 'day': 1, + 'time': '0:0:0', 'processName': 'TestProcess', 'type': Schedule.Type.TIMED}} + old = {'old_schedule': {'enabled': True, 'exclusive': True, 'name': 'OMF to PI north', + 'processName': 'North Readings to PI', 'repeat': 30.0, 'type': Schedule.Type.INTERVAL}} + calls = [call('SCHCH', {**new, **old})] audit_logger.assert_has_calls(calls, any_order=True) assert 1 == first_task.call_count assert 1 == resume_sch.call_count @@ -982,9 +987,13 @@ async def test_disable_schedule(self, mocker): '2b614d26-760f-11e7-b5a5-be2e44b06b34', 'North Readings to PI')] log_info.assert_has_calls(calls) assert 1 == audit_logger.call_count - calls = [call('SCHCH', {'schedule': {'name': 'OMF to PI north', 'repeat': 30.0, 'enabled': False, + new = {'schedule': {'name': 'OMF to PI north', 'repeat': 30.0, 'enabled': False, + 'type': Schedule.Type.INTERVAL, 'exclusive': True, + 'processName': 'North Readings to PI'}} + old = {'old_schedule': {'name': 'OMF to PI north', 'repeat': 30.0, 'enabled': True, 'type': Schedule.Type.INTERVAL, 'exclusive': True, - 'processName': 'North Readings to PI'}})] + 'processName': 'North Readings to PI'}} + calls = [call('SCHCH', {**new, **old})] audit_logger.assert_has_calls(calls, any_order=True) @pytest.mark.asyncio @@ -1051,7 +1060,11 @@ async def test_enable_schedule(self, mocker): calls = [call("Enabled Schedule '%s/%s' process '%s'\n", 'backup hourly', 'd1631422-9ec6-11e7-abc4-cec278b6b50a', 'backup')] log_info.assert_has_calls(calls, any_order=True) assert 1 == audit_logger.call_count - calls = [call('SCHCH', {'schedule': {'name': 'backup hourly', 'type': Schedule.Type.INTERVAL, 'processName': 'backup', 'exclusive': True, 'repeat': 3600.0, 'enabled': True}})] + new = {'schedule': {'name': 'backup hourly', 'type': Schedule.Type.INTERVAL, 'processName': 'backup', + 'exclusive': True, 'repeat': 3600.0, 'enabled': True}} + old = {'old_schedule': {'name': 'backup hourly', 'type': Schedule.Type.INTERVAL, 'processName': 'backup', + 'exclusive': True, 'repeat': 3600.0, 'enabled': False}} + calls = [call('SCHCH', {**new, **old})] audit_logger.assert_has_calls(calls, any_order=True) @pytest.mark.asyncio From 577ccd031b0b31115f5921270794e496ff56994c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 16 Aug 2023 12:01:40 +0530 Subject: [PATCH 389/499] schema version changed to number 65 Signed-off-by: ashish-jabble --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b25b05dffa..86c6dd9c91 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.1.0 -fledge_schema=63 \ No newline at end of file +fledge_schema=65 \ No newline at end of file From 5c7a24796bdc3567b0518eef94b8eaeb32f1b11c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 16 Aug 2023 12:01:43 +0530 Subject: [PATCH 390/499] new audit log codes for ACL, Control Script and Pipeline in init.sql Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/init.sql | 6 +++++- scripts/plugins/storage/sqlite/init.sql | 6 +++++- scripts/plugins/storage/sqlitelb/init.sql | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 24244ac1b7..d400e90e4b 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -992,7 +992,11 @@ INSERT INTO fledge.log_codes ( code, description ) ( 'USRAD', 'User Added' ), ( 'USRDL', 'User Deleted' ), ( 'USRCH', 'User Changed' ), - ( 'USRRS', 'User Restored' ); + ( 'USRRS', 'User Restored' ), + ( 'ACLAD', 'ACL Added' ),( 'ACLCH', 'ACL Changed' ),( 'ACLDL', 'ACL Deleted' ), + ( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ), + ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ) + ; -- -- Configuration parameters diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index ab548009fa..07f956d82b 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -747,7 +747,11 @@ INSERT INTO fledge.log_codes ( code, description ) ( 'USRAD', 'User Added' ), ( 'USRDL', 'User Deleted' ), ( 'USRCH', 'User Changed' ), - ( 'USRRS', 'User Restored' ); + ( 'USRRS', 'User Restored' ), + ( 'ACLAD', 'ACL Added' ),( 'ACLCH', 'ACL Changed' ),( 'ACLDL', 'ACL Deleted' ), + ( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ), + ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ) + ; -- -- Configuration parameters diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 5b2e9bc6b7..a8a83226bd 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -747,7 +747,11 @@ INSERT INTO fledge.log_codes ( code, description ) ( 'USRAD', 'User Added' ), ( 'USRDL', 'User Deleted' ), ( 'USRCH', 'User Changed' ), - ( 'USRRS', 'User Restored' ); + ( 'USRRS', 'User Restored' ), + ( 'ACLAD', 'ACL Added' ),( 'ACLCH', 'ACL Changed' ),( 'ACLDL', 'ACL Deleted' ), + ( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ), + ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ) + ; -- -- Configuration parameters From 303d54159502afcef549d52b2e2d7882fb5c00b7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 16 Aug 2023 12:11:57 +0530 Subject: [PATCH 391/499] upgrade and downgrade scripts for schema number 65 for sqlitelb engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlitelb/downgrade/63.sql | 1 + scripts/plugins/storage/sqlitelb/downgrade/64.sql | 1 + scripts/plugins/storage/sqlitelb/upgrade/64.sql | 1 + scripts/plugins/storage/sqlitelb/upgrade/65.sql | 4 ++++ 4 files changed, 7 insertions(+) create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/63.sql create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/64.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/64.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/65.sql diff --git a/scripts/plugins/storage/sqlitelb/downgrade/63.sql b/scripts/plugins/storage/sqlitelb/downgrade/63.sql new file mode 100644 index 0000000000..4f252b8311 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/63.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-8021 \ No newline at end of file diff --git a/scripts/plugins/storage/sqlitelb/downgrade/64.sql b/scripts/plugins/storage/sqlitelb/downgrade/64.sql new file mode 100644 index 0000000000..42fa14f3d6 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/64.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('ACLAD', 'ACLCH', 'ACLDL', 'CTSAD', 'CTSCH', 'CTSDL', 'CTPAD', 'CTPCH', 'CTPDL'); diff --git a/scripts/plugins/storage/sqlitelb/upgrade/64.sql b/scripts/plugins/storage/sqlitelb/upgrade/64.sql new file mode 100644 index 0000000000..4f252b8311 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/64.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-8021 \ No newline at end of file diff --git a/scripts/plugins/storage/sqlitelb/upgrade/65.sql b/scripts/plugins/storage/sqlitelb/upgrade/65.sql new file mode 100644 index 0000000000..9a5e1f0435 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/65.sql @@ -0,0 +1,4 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES ( 'ACLAD', 'ACL Added' ),( 'ACLCH', 'ACL Changed' ),( 'ACLDL', 'ACL Deleted' ), + ( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ), + ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ); From 67a53eb3473457dce7e2fb01a9d021cb022e07cd Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 16 Aug 2023 12:15:49 +0530 Subject: [PATCH 392/499] upgrade and downgrade scripts of schema number 65 for sqlite engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlite/downgrade/63.sql | 1 + scripts/plugins/storage/sqlite/downgrade/64.sql | 1 + scripts/plugins/storage/sqlite/upgrade/64.sql | 1 + scripts/plugins/storage/sqlite/upgrade/65.sql | 4 ++++ 4 files changed, 7 insertions(+) create mode 100644 scripts/plugins/storage/sqlite/downgrade/63.sql create mode 100644 scripts/plugins/storage/sqlite/downgrade/64.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/64.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/65.sql diff --git a/scripts/plugins/storage/sqlite/downgrade/63.sql b/scripts/plugins/storage/sqlite/downgrade/63.sql new file mode 100644 index 0000000000..4f252b8311 --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/63.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-8021 \ No newline at end of file diff --git a/scripts/plugins/storage/sqlite/downgrade/64.sql b/scripts/plugins/storage/sqlite/downgrade/64.sql new file mode 100644 index 0000000000..42fa14f3d6 --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/64.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('ACLAD', 'ACLCH', 'ACLDL', 'CTSAD', 'CTSCH', 'CTSDL', 'CTPAD', 'CTPCH', 'CTPDL'); diff --git a/scripts/plugins/storage/sqlite/upgrade/64.sql b/scripts/plugins/storage/sqlite/upgrade/64.sql new file mode 100644 index 0000000000..4f252b8311 --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/64.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-8021 \ No newline at end of file diff --git a/scripts/plugins/storage/sqlite/upgrade/65.sql b/scripts/plugins/storage/sqlite/upgrade/65.sql new file mode 100644 index 0000000000..9a5e1f0435 --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/65.sql @@ -0,0 +1,4 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES ( 'ACLAD', 'ACL Added' ),( 'ACLCH', 'ACL Changed' ),( 'ACLDL', 'ACL Deleted' ), + ( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ), + ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ); From 621a6e22a16e2310a85d0f415e05e7659cab4ce9 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 16 Aug 2023 12:21:50 +0530 Subject: [PATCH 393/499] upgrade and downgrade scripts of schema number 65 for postgres engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/63.sql | 1 + scripts/plugins/storage/postgres/downgrade/64.sql | 1 + scripts/plugins/storage/postgres/upgrade/64.sql | 1 + scripts/plugins/storage/postgres/upgrade/65.sql | 4 ++++ 4 files changed, 7 insertions(+) create mode 100644 scripts/plugins/storage/postgres/downgrade/63.sql create mode 100644 scripts/plugins/storage/postgres/downgrade/64.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/64.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/65.sql diff --git a/scripts/plugins/storage/postgres/downgrade/63.sql b/scripts/plugins/storage/postgres/downgrade/63.sql new file mode 100644 index 0000000000..4f252b8311 --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/63.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-8021 \ No newline at end of file diff --git a/scripts/plugins/storage/postgres/downgrade/64.sql b/scripts/plugins/storage/postgres/downgrade/64.sql new file mode 100644 index 0000000000..42fa14f3d6 --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/64.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('ACLAD', 'ACLCH', 'ACLDL', 'CTSAD', 'CTSCH', 'CTSDL', 'CTPAD', 'CTPCH', 'CTPDL'); diff --git a/scripts/plugins/storage/postgres/upgrade/64.sql b/scripts/plugins/storage/postgres/upgrade/64.sql new file mode 100644 index 0000000000..4f252b8311 --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/64.sql @@ -0,0 +1 @@ +-- To be filled with FOGL-8021 \ No newline at end of file diff --git a/scripts/plugins/storage/postgres/upgrade/65.sql b/scripts/plugins/storage/postgres/upgrade/65.sql new file mode 100644 index 0000000000..9a5e1f0435 --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/65.sql @@ -0,0 +1,4 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES ( 'ACLAD', 'ACL Added' ),( 'ACLCH', 'ACL Changed' ),( 'ACLDL', 'ACL Deleted' ), + ( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ), + ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ); From ecff54fb3b58f76701fc68e28518b199d0a3ffe8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 16 Aug 2023 16:38:59 +0530 Subject: [PATCH 394/499] audit system tests updated Signed-off-by: ashish-jabble --- tests/system/python/api/test_audit.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/system/python/api/test_audit.py b/tests/system/python/api/test_audit.py index 712e33a34e..cfa863c113 100644 --- a/tests/system/python/api/test_audit.py +++ b/tests/system/python/api/test_audit.py @@ -22,7 +22,6 @@ class TestAudit: - def test_get_log_codes(self, fledge_url, reset_and_start_fledge): expected_code_list = ['PURGE', 'LOGGN', 'STRMN', 'SYPRG', 'START', 'FSTOP', 'CONCH', 'CONAD', 'SCHCH', 'SCHAD', 'SRVRG', 'SRVUN', @@ -30,7 +29,11 @@ def test_get_log_codes(self, fledge_url, reset_and_start_fledge): 'BKEXC', 'NTFDL', 'NTFAD', 'NTFSN', 'NTFCL', 'NTFST', 'NTFSD', 'PKGIN', 'PKGUP', 'PKGRM', 'DSPST', 'DSPSD', 'ESSRT', 'ESSTP', 'ASTDP', 'ASTUN', 'PIPIN', 'AUMRK', - 'USRAD', 'USRDL', 'USRCH', 'USRRS'] + 'USRAD', 'USRDL', 'USRCH', 'USRRS', + 'ACLAD', 'ACLCH', 'ACLDL', + 'CTSAD', 'CTSCH', 'CTSDL', + 'CTPAD', 'CTPCH', 'CTPDL' + ] conn = http.client.HTTPConnection(fledge_url) conn.request("GET", '/fledge/audit/logcode') r = conn.getresponse() From c244b45caec20e44891b20a8c180bf6847749933 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 16 Aug 2023 17:26:16 +0530 Subject: [PATCH 395/499] audit trail entry added for ACLAD logcode Signed-off-by: ashish-jabble --- .../core/api/control_service/acl_management.py | 4 ++++ .../api/control_service/test_acl_management.py | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index 654981920f..f0434f6e3b 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -8,6 +8,7 @@ from aiohttp import web from fledge.common.acl_manager import ACLManager +from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.exceptions import StorageServerError @@ -124,6 +125,9 @@ async def add_acl(request: web.Request) -> web.Response: if 'response' in insert_control_acl_result: if insert_control_acl_result['response'] == "inserted": result = {"name": name, "service": json.loads(services), "url": json.loads(urls)} + # ACLAD audit trail entry + audit = AuditLogger(storage) + await audit.information('ACLAD', result) else: raise StorageServerError(insert_control_acl_result) else: diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py index c610e71a57..1a9c905d2c 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py @@ -5,6 +5,7 @@ import pytest from aiohttp import web +from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.web import middleware @@ -138,19 +139,26 @@ async def test_good_add_acl(self, client): if sys.version_info >= (3, 8): value = await mock_coro(result) insert_value = await mock_coro(insert_result) + _rv = await mock_coro(None) else: value = asyncio.ensure_future(mock_coro(result)) insert_value = asyncio.ensure_future(mock_coro(insert_result)) + _rv = asyncio.ensure_future(mock_coro(None)) storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=value) as query_tbl_patch: with patch.object(storage_client_mock, 'insert_into_tbl', return_value=insert_value ) as insert_tbl_patch: - resp = await client.post('/fledge/ACL', data=json.dumps(request_payload)) - assert 200 == resp.status - result = await resp.text() - json_response = json.loads(result) - assert {'name': acl_name, 'service': [], 'url': []} == json_response + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=_rv) as audit_info_patch: + resp = await client.post('/fledge/ACL', data=json.dumps(request_payload)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {'name': acl_name, 'service': [], 'url': []} == json_response + args, _ = audit_info_patch.call_args + assert 'ACLAD' == args[0] + assert request_payload == args[1] args, _ = insert_tbl_patch.call_args_list[0] assert 'control_acl' == args[0] assert {'name': acl_name, 'service': '[]', 'url': '[]'} == json.loads(args[1]) From ecd0a7f48995cefa713e3e24386a86bb22314fc8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 16 Aug 2023 17:55:18 +0530 Subject: [PATCH 396/499] audit trail entry added for ACLDL logcode Signed-off-by: ashish-jabble --- .../api/control_service/acl_management.py | 3 +++ .../control_service/test_acl_management.py | 25 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index f0434f6e3b..0c4f397637 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -254,6 +254,9 @@ async def delete_acl(request: web.Request) -> web.Response: if 'response' in delete_result: if delete_result['response'] == "deleted": message = "{} ACL deleted successfully.".format(name) + # ACLDL audit trail entry + audit = AuditLogger(storage) + await audit.information('ACLDL', message) else: raise StorageServerError(delete_result) else: diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py index 1a9c905d2c..108bbca4a8 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py @@ -280,12 +280,13 @@ async def test_delete_acl(self, client): payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": acl_name}} delete_payload = {"where": {"column": "name", "condition": "=", "value": acl_name}} delete_result = {"response": "deleted", "rows_affected": 1} + message = '{} ACL deleted successfully.'.format(acl_name) if sys.version_info >= (3, 8): - value = await mock_coro(result) del_value = await mock_coro(delete_result) + arv = await mock_coro(None) else: - value = asyncio.ensure_future(mock_coro(result)) del_value = asyncio.ensure_future(mock_coro(delete_result)) + arv = asyncio.ensure_future(mock_coro(None)) acl_query_payload_service = {"return": ["entity_name"], "where": {"column": "entity_type", "condition": "=", @@ -318,13 +319,19 @@ def q_result(*args): return {} with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result) as query_tbl_patch: - with patch.object(storage_client_mock, 'delete_from_tbl', return_value=del_value) as patch_delete_tbl: - resp = await client.delete('/fledge/ACL/{}'.format(acl_name)) - assert 200 == resp.status - result = await resp.text() - json_response = json.loads(result) - assert {'message': '{} ACL deleted successfully.'.format(acl_name)} == json_response + with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result): + with patch.object(storage_client_mock, 'delete_from_tbl', return_value=del_value + ) as patch_delete_tbl: + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=arv) as audit_info_patch: + resp = await client.delete('/fledge/ACL/{}'.format(acl_name)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {'message': message} == json_response + args, _ = audit_info_patch.call_args + assert 'ACLDL' == args[0] + assert message == args[1] delete_args, _ = patch_delete_tbl.call_args assert 'control_acl' == delete_args[0] assert delete_payload == json.loads(delete_args[1]) From 6f330cdc3e485f2fa3de0335f45e058caf80e2ec Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 16 Aug 2023 19:11:57 +0530 Subject: [PATCH 397/499] audit trail entry added for ACLCH logcode Signed-off-by: ashish-jabble --- .../api/control_service/acl_management.py | 8 ++-- .../control_service/test_acl_management.py | 41 +++++++++++-------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index 0c4f397637..e1ac0502bd 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -174,13 +174,12 @@ async def update_acl(request: web.Request) -> web.Response: if url is not None and not isinstance(url, list): raise TypeError('url must be a list.') storage = connect.get_storage_async() - payload = PayloadBuilder().SELECT("name").WHERE(['name', '=', name]).payload() + payload = PayloadBuilder().SELECT("service", "url").WHERE(['name', '=', name]).payload() result = await storage.query_tbl_with_payload('control_acl', payload) message = "" if 'rows' in result: if result['rows']: update_query = PayloadBuilder() - set_values = {} if service is not None: set_values["service"] = json.dumps(service) @@ -188,11 +187,14 @@ async def update_acl(request: web.Request) -> web.Response: set_values["url"] = json.dumps(url) update_query.SET(**set_values).WHERE(['name', '=', name]) - update_result = await storage.update_tbl("control_acl", update_query.payload()) if 'response' in update_result: if update_result['response'] == "updated": message = "ACL {} updated successfully.".format(name) + # ACLCH audit trail entry + audit = AuditLogger(storage) + values = {'service': service, 'url': url} + await audit.information('ACLCH', {'acl': values, 'old_acl': result['rows']}) else: raise StorageServerError(update_result) else: diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py index 108bbca4a8..ce5cf46a41 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py @@ -185,7 +185,7 @@ async def test_update_acl_not_found(self, client): req_payload = {"service": []} result = {"count": 0, "rows": []} value = await mock_coro(result) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(result)) - query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": acl_name}} + query_payload = {"return": ["service", "url"], "where": {"column": "name", "condition": "=", "value": acl_name}} message = "ACL with name {} is not found.".format(acl_name) storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): @@ -213,21 +213,18 @@ async def test_update_acl(self, client, payload): acl_q_result = {"count": 0, "rows": []} update_result = {"response": "updated", "rows_affected": 1} query_tbl_result = {"count": 1, "rows": [{"name": acl_name, "service": [], "url": []}]} - query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": acl_name}} + query_payload = {"return": ["service", "url"], "where": {"column": "name", "condition": "=", "value": acl_name}} if sys.version_info >= (3, 8): - rv = await mock_coro(query_tbl_result) + arv = await mock_coro(None) update_value = await mock_coro(update_result) else: - rv = asyncio.ensure_future(mock_coro(query_tbl_result)) + arv = asyncio.ensure_future(mock_coro(None)) update_value = asyncio.ensure_future(mock_coro(update_result)) storage_client_mock = MagicMock(StorageClientAsync) - acl_query_payload_service = {"return": ["entity_name"], "where": {"column": "entity_type", - "condition": "=", - "value": "service", - "and": - {"column": "name", - "condition": "=", - "value": "{}".format(acl_name)}}} + acl_query_payload_service = {"return": ["entity_name"], + "where": {"column": "entity_type", "condition": "=", "value": "service", + "and": {"column": "name", "condition": "=", "value": "{}".format( + acl_name)}}} @asyncio.coroutine def q_result(*args): @@ -242,13 +239,21 @@ def q_result(*args): return {} with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result) as patch_query_tbl: - with patch.object(storage_client_mock, 'update_tbl', return_value=update_value) as patch_update_tbl: - resp = await client.put('/fledge/ACL/{}'.format(acl_name), data=json.dumps(payload)) - assert 200 == resp.status - result = await resp.text() - json_response = json.loads(result) - assert {"message": "ACL {} updated successfully.".format(acl_name)} == json_response + with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result): + with patch.object(storage_client_mock, 'update_tbl', return_value=update_value + ) as patch_update_tbl: + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=arv) as audit_info_patch: + resp = await client.put('/fledge/ACL/{}'.format(acl_name), data=json.dumps(payload)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {"message": "ACL {} updated successfully.".format(acl_name)} == json_response + args, _ = audit_info_patch.call_args + assert 'ACLCH' == args[0] + if 'url' not in payload: + payload['url'] = None + assert {"acl": payload, "old_acl": query_tbl_result['rows']} == args[1] update_args, _ = patch_update_tbl.call_args assert 'control_acl' == update_args[0] From bf77c5541ef6dc30fe18c553984ef9f5c8825426 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 17 Aug 2023 11:58:56 +0530 Subject: [PATCH 398/499] audit trail entry added for CTSAD logcode Signed-off-by: ashish-jabble --- .../api/control_service/script_management.py | 5 ++- .../control_service/test_script_management.py | 45 ++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index e0b176bc0a..c821eddf0b 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -11,11 +11,11 @@ from aiohttp import web from fledge.common.acl_manager import ACLManager +from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.exceptions import StorageServerError from fledge.common.storage_client.payload_builder import PayloadBuilder -from fledge.common.web.middleware import has_permission from fledge.services.core import connect, server from fledge.services.core.scheduler.entities import Schedule, ManualSchedule from fledge.services.core.api.control_service.exceptions import * @@ -313,6 +313,9 @@ async def add(request: web.Request) -> web.Response: if acl is not None: # Append ACL into response if acl exists in payload result["acl"] = acl + # CTSAD audit trail entry + audit = AuditLogger(storage) + await audit.information('CTSAD', result) else: raise StorageServerError(insert_control_script_result) else: diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py index 1427e52702..5da79e26cc 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py @@ -7,6 +7,7 @@ import pytest from aiohttp import web +from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.web import middleware @@ -268,19 +269,27 @@ async def test_good_add_script(self, client): if sys.version_info >= (3, 8): value = await mock_coro(result) insert_value = await mock_coro(insert_result) + arv = await mock_coro(None) else: value = asyncio.ensure_future(mock_coro(result)) insert_value = asyncio.ensure_future(mock_coro(insert_result)) + arv = asyncio.ensure_future(mock_coro(None)) storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): - with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=value) as query_tbl_patch: + with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=value + ) as query_tbl_patch: with patch.object(storage_client_mock, 'insert_into_tbl', return_value=insert_value ) as insert_tbl_patch: - resp = await client.post('/fledge/control/script', data=json.dumps(request_payload)) - assert 200 == resp.status - result = await resp.text() - json_response = json.loads(result) - assert {'name': script_name, 'steps': []} == json_response + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=arv) as audit_info_patch: + resp = await client.post('/fledge/control/script', data=json.dumps(request_payload)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {'name': script_name, 'steps': []} == json_response + args, _ = audit_info_patch.call_args + assert 'CTSAD' == args[0] + assert request_payload == args[1] args, _ = insert_tbl_patch.call_args_list[0] assert 'control_script' == args[0] expected = json.loads(args[1]) @@ -299,8 +308,7 @@ async def test_good_add_script_with_acl(self, client): insert_result = {"response": "inserted", "rows_affected": 1} script_query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": script_name}} acl_query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": acl_name}} - insert_value = await mock_coro(insert_result) if sys.version_info >= (3, 8) else \ - asyncio.ensure_future(mock_coro(insert_result)) + arv = await mock_coro(None) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(None)) @asyncio.coroutine def q_result(*args): @@ -323,19 +331,24 @@ def i_result(*args): payload = args[1] if table == 'control_script': assert {'name': script_name, 'steps': '[]', 'acl': acl_name} == json.loads(payload) - return insert_result elif table == "acl_usage": - assert {'name': acl_name, 'entity_type': 'script', - 'entity_name': script_name} == json.loads(payload) - return insert_result + assert {'name': acl_name, 'entity_type': 'script', 'entity_name': script_name} == json.loads(payload) + return insert_result storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result): - with patch.object(storage_client_mock, 'insert_into_tbl', side_effect=i_result - ) as insert_tbl_patch: - resp = await client.post('/fledge/control/script', data=json.dumps(request_payload)) - assert 200 == resp.status + with patch.object(storage_client_mock, 'insert_into_tbl', side_effect=i_result): + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=arv) as audit_info_patch: + resp = await client.post('/fledge/control/script', data=json.dumps(request_payload)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert request_payload == json_response + args, _ = audit_info_patch.call_args + assert 'CTSAD' == args[0] + assert request_payload == args[1] @pytest.mark.parametrize("payload, message", [ ({}, "Nothing to update for the given payload."), From b4305d6f4273f0766d3304da5ff93bc0bd461316 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 17 Aug 2023 13:45:17 +0530 Subject: [PATCH 399/499] audit trail entry added for CTSDL logcode Signed-off-by: ashish-jabble --- .../api/control_service/script_management.py | 3 + .../control_service/test_script_management.py | 56 +++++++++++++------ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index c821eddf0b..ad1ea924f7 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -471,6 +471,9 @@ async def delete(request: web.Request) -> web.Response: if 'response' in delete_result: if delete_result['response'] == "deleted": message = "{} script deleted successfully.".format(name) + # CTSDL audit trail entry + audit = AuditLogger(storage) + await audit.information('CTSDL', {'message': message}) else: raise StorageServerError(delete_result) else: diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py index 5da79e26cc..4a992ecac1 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py @@ -287,9 +287,7 @@ async def test_good_add_script(self, client): result = await resp.text() json_response = json.loads(result) assert {'name': script_name, 'steps': []} == json_response - args, _ = audit_info_patch.call_args - assert 'CTSAD' == args[0] - assert request_payload == args[1] + audit_info_patch.assert_called_once_with('CTSAD', request_payload) args, _ = insert_tbl_patch.call_args_list[0] assert 'control_script' == args[0] expected = json.loads(args[1]) @@ -346,9 +344,7 @@ def i_result(*args): result = await resp.text() json_response = json.loads(result) assert request_payload == json_response - args, _ = audit_info_patch.call_args - assert 'CTSAD' == args[0] - assert request_payload == args[1] + audit_info_patch.assert_called_once_with('CTSAD', request_payload) @pytest.mark.parametrize("payload, message", [ ({}, "Nothing to update for the given payload."), @@ -516,6 +512,9 @@ async def test_delete_script_along_with_category_and_schedule(self, client): q_result = {"count": 0, "rows": [ {"name": script_name, "steps": [{"delay": {"order": 0, "duration": 9003}}], "acl": ""}]} q_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": script_name}} + q_tbl_payload = {"return": ["name"], "where": {"column": "entity_type", "condition": "=", "value": "script", + "and": {"column": "entity_name", "condition": "=", + "value": script_name}}} delete_payload = {"where": {"column": "name", "condition": "=", "value": script_name}} delete_result = {"response": "deleted", "rows_affected": 1} disable_sch_result = (True, "Schedule successfully disabled") @@ -547,11 +546,13 @@ def d_schedule(*args): get_sch = await mock_schedule(script_name) disable_sch = await mock_coro(disable_sch_result) delete_sch = await mock_coro(delete_sch_result) + arv = await mock_coro(None) else: del_cat_and_child = asyncio.ensure_future(mock_coro(delete_result)) get_sch = asyncio.ensure_future(mock_schedule(script_name)) disable_sch = asyncio.ensure_future(mock_coro(disable_sch_result)) delete_sch = asyncio.ensure_future(mock_coro(delete_sch_result)) + arv = asyncio.ensure_future(mock_coro(None)) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(c_mgr, 'delete_category_and_children_recursively', @@ -566,11 +567,20 @@ def d_schedule(*args): side_effect=query_schedule) as patch_query_tbl: with patch.object(storage_client_mock, 'delete_from_tbl', side_effect=d_schedule) as patch_delete_tbl: - resp = await client.delete('/fledge/control/script/{}'.format(script_name)) - assert 200 == resp.status - result = await resp.text() - json_response = json.loads(result) - assert {'message': message} == json_response + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', + return_value=arv) as audit_info_patch: + resp = await client.delete('/fledge/control/script/{}'.format(script_name)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {'message': message} == json_response + audit_info_patch.assert_called_once_with('CTSDL', {'message': message}) + patch_delete_tbl.assert_called_once_with( + 'control_script', json.dumps(delete_payload)) + args, _ = patch_query_tbl.call_args + assert 'acl_usage' == args[0] + assert json.dumps(q_tbl_payload) == args[1] patch_delete_sch.assert_called_once_with(uuid.UUID(schedule_id)) patch_disable_sch.assert_called_once_with(uuid.UUID(schedule_id)) patch_get_schedules.assert_called_once_with() @@ -585,6 +595,10 @@ async def test_delete_script_acl_not_attached(self, client): q_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": script_name}} delete_payload = {"where": {"column": "name", "condition": "=", "value": script_name}} delete_result = {"response": "deleted", "rows_affected": 1} + message = '{} script deleted successfully.'.format(script_name) + q_tbl_payload = {"return": ["name"], "where": {"column": "entity_type", "condition": "=", "value": "script", + "and": {"column": "entity_name", "condition": "=", + "value": script_name}}} @asyncio.coroutine def query_result(*args): @@ -606,17 +620,27 @@ def d_result(*args): elif table == "acl_usage": return delete_result + arv = await mock_coro(None) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(None)) + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(c_mgr, 'delete_category_and_children_recursively', side_effect=Exception): with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=query_result) as patch_query_tbl: with patch.object(storage_client_mock, 'delete_from_tbl', side_effect=d_result) as patch_delete_tbl: - resp = await client.delete('/fledge/control/script/{}'.format(script_name)) - assert 200 == resp.status - result = await resp.text() - json_response = json.loads(result) - assert {'message': '{} script deleted successfully.'.format(script_name)} == json_response + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', + return_value=arv) as audit_info_patch: + resp = await client.delete('/fledge/control/script/{}'.format(script_name)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {'message': message} == json_response + audit_info_patch.assert_called_once_with('CTSDL', {'message': message}) + patch_delete_tbl.assert_called_once_with('control_script', json.dumps(delete_payload)) + args, _ = patch_query_tbl.call_args + assert 'acl_usage' == args[0] + assert json.dumps(q_tbl_payload) == args[1] @pytest.mark.parametrize("payload, message", [ ({}, "parameters field is required."), From d8f3e70c1eddb1a87213b53e1db2822d9f81d74d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 17 Aug 2023 15:33:06 +0530 Subject: [PATCH 400/499] audit trail entry added for CTSCH logcode Signed-off-by: ashish-jabble --- .../api/control_service/script_management.py | 8 +++- .../control_service/test_script_management.py | 39 ++++++++++++------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index ad1ea924f7..fff1ed0f32 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -363,11 +363,13 @@ async def update(request: web.Request) -> web.Response: raise ValueError('ACL must be a string.') acl = acl.strip() set_values = {} + values = {'steps': [], 'acl': ""} if steps is not None: + values['steps'] = steps set_values["steps"] = _validate_steps_and_convert_to_str(steps) storage = connect.get_storage_async() # Check existence of script record - payload = PayloadBuilder().SELECT("name").WHERE(['name', '=', name]).payload() + payload = PayloadBuilder().SELECT("steps", "acl").WHERE(['name', '=', name]).payload() result = await storage.query_tbl_with_payload('control_script', payload) message = "" if 'rows' in result: @@ -383,6 +385,7 @@ async def update(request: web.Request) -> web.Response: else: raise StorageServerError(acl_result) set_values["acl"] = acl + values["acl"] = acl # Update script record update_query = PayloadBuilder() update_query.SET(**set_values).WHERE(['name', '=', name]) @@ -409,6 +412,9 @@ async def update(request: web.Request) -> web.Response: if 'response' in update_result: if update_result['response'] == "updated": message = "Control script {} updated successfully.".format(name) + # CTSCH audit trail entry + audit = AuditLogger(storage) + await audit.information('CTSCH', {'script': values, 'old_script': result['rows']}) else: raise StorageServerError(update_result) else: diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py index 4a992ecac1..b80b28e139 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py @@ -1,4 +1,5 @@ import asyncio +import copy import json import sys import uuid @@ -376,7 +377,8 @@ async def test_update_script_not_found(self, client): req_payload = {"steps": []} result = {"count": 0, "rows": []} value = await mock_coro(result) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(result)) - query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": script_name}} + query_payload = {"return": ["steps", "acl"], + "where": {"column": "name", "condition": "=", "value": script_name}} message = "No such {} script found.".format(script_name) storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): @@ -396,7 +398,8 @@ async def test_update_script_when_acl_not_found(self, client): acl_name = "blah" payload = {"steps": [{"write": {"order": 1, "speed": 420}}], "acl": acl_name} script_result = {"count": 1, "rows": [{"name": script_name, "steps": [{"write": {"order": 1, "speed": 420}}]}]} - script_query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": script_name}} + script_query_payload = {"return": ["steps", "acl"], + "where": {"column": "name", "condition": "=", "value": script_name}} acl_query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": acl_name}} acl_result = {"count": 0, "rows": []} @@ -429,12 +432,13 @@ def q_result(*args): async def test_update_script(self, client, payload): script_name = "test" acl_name = "testACL" - script_result = {"count": 1, "rows": [{"name": script_name, "steps": [{"write": {"order": 1, "speed": 420}}]}]} + script_result = {"count": 1, "rows": [{"steps": [{"write": {"order": 1, "speed": 420}}]}]} update_result = {"response": "updated", "rows_affected": 1} steps_payload = payload["steps"] update_value = await mock_coro(update_result) if sys.version_info >= (3, 8) else \ asyncio.ensure_future(mock_coro(update_result)) - script_query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": script_name}} + script_query_payload = {"return": ["steps", "acl"], + "where": {"column": "name", "condition": "=", "value": script_name}} acl_query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": acl_name}} acl_result = {"count": 1, "rows": [{"name": acl_name, "service": [], "url": []}]} insert_result = {"response": "inserted", "rows_affected": 1} @@ -462,22 +466,31 @@ def i_result(*args): 'entity_name': script_name} == json.loads(payload_ins) return insert_result + arv = await mock_coro(None) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(None)) storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=q_result): with patch.object(storage_client_mock, 'update_tbl', return_value=update_value) as patch_update_tbl: with patch.object(storage_client_mock, 'insert_into_tbl', side_effect=i_result): - resp = await client.put('/fledge/control/script/{}'.format(script_name), - data=json.dumps(payload)) - assert 200 == resp.status - result = await resp.text() - json_response = json.loads(result) - assert {"message": "Control script {} updated successfully.".format(script_name)}\ - == json_response + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=arv) as audit_info_patch: + resp = await client.put('/fledge/control/script/{}'.format(script_name), + data=json.dumps(payload)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {"message": "Control script {} updated successfully.".format(script_name)}\ + == json_response + args, _ = audit_info_patch.call_args + audit = copy.deepcopy(payload) + if 'acl' not in payload: + audit['acl'] = "" + audit_info_patch.assert_called_once_with( + 'CTSCH', {"script": audit, "old_script": script_result['rows']}) update_args, _ = patch_update_tbl.call_args assert 'control_script' == update_args[0] - update_payload = {"values": payload, "where": {"column": "name", "condition": "=", - "value": script_name}} + update_payload = {"values": payload, + "where": {"column": "name", "condition": "=", "value": script_name}} update_payload["values"]["steps"] = str(steps_payload) assert update_payload == json.loads(update_args[1]) From e777e0294043c6c6872592d3fb10c0985f51e3d9 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 17 Aug 2023 15:38:22 +0530 Subject: [PATCH 401/499] some fixes for ACLDL Signed-off-by: ashish-jabble --- .../services/core/api/control_service/acl_management.py | 2 +- .../core/api/control_service/test_acl_management.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index e1ac0502bd..6743482891 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -258,7 +258,7 @@ async def delete_acl(request: web.Request) -> web.Response: message = "{} ACL deleted successfully.".format(name) # ACLDL audit trail entry audit = AuditLogger(storage) - await audit.information('ACLDL', message) + await audit.information('ACLDL', {"message": message}) else: raise StorageServerError(delete_result) else: diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py index ce5cf46a41..f2ad55af17 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py @@ -156,9 +156,7 @@ async def test_good_add_acl(self, client): result = await resp.text() json_response = json.loads(result) assert {'name': acl_name, 'service': [], 'url': []} == json_response - args, _ = audit_info_patch.call_args - assert 'ACLAD' == args[0] - assert request_payload == args[1] + audit_info_patch.assert_called_once_with('ACLAD', request_payload) args, _ = insert_tbl_patch.call_args_list[0] assert 'control_acl' == args[0] assert {'name': acl_name, 'service': '[]', 'url': '[]'} == json.loads(args[1]) @@ -334,9 +332,7 @@ def q_result(*args): result = await resp.text() json_response = json.loads(result) assert {'message': message} == json_response - args, _ = audit_info_patch.call_args - assert 'ACLDL' == args[0] - assert message == args[1] + audit_info_patch.assert_called_once_with('ACLDL', {'message': message}) delete_args, _ = patch_delete_tbl.call_args assert 'control_acl' == delete_args[0] assert delete_payload == json.loads(delete_args[1]) From 5ae483cbc93a72ec5e3965179a8ca4acaeeb0680 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 17 Aug 2023 15:45:25 +0530 Subject: [PATCH 402/499] audit trail entry added for CTPDL logcode Signed-off-by: ashish-jabble --- .../services/core/api/control_service/pipeline.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index a47490b361..55477e2613 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -8,8 +8,9 @@ import json from aiohttp import web -from fledge.common.logger import FLCoreLogger +from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager +from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.exceptions import StorageServerError from fledge.services.core import connect, server @@ -275,8 +276,11 @@ async def delete(request: web.Request) -> web.Response: _logger.error(ex, "Failed to delete pipeline having ID: <{}>.".format(cpid)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: - return web.json_response( - {"message": "Control Pipeline with ID:<{}> has been deleted successfully.".format(cpid)}) + message = {"message": "Control Pipeline with ID:<{}> has been deleted successfully.".format(cpid)} + # CTPDL audit trail entry + audit = AuditLogger(storage) + await audit.information('CTPDL', message) + return web.json_response(message) async def _get_all_lookups(tbl_name=None): From a3a8b39aad4524db99d709433ef5b94294d752ae Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 17 Aug 2023 16:00:14 +0530 Subject: [PATCH 403/499] audit trail entry added for CTPCH logcode Signed-off-by: ashish-jabble --- python/fledge/services/core/api/control_service/pipeline.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index 55477e2613..c8841aeed5 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -245,6 +245,10 @@ async def update(request: web.Request) -> web.Response: _logger.error(ex, "Failed to update pipeline having ID: <{}>.".format(cpid)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: + # CTPCH audit trail entry + audit = AuditLogger(storage) + updated_pipeline = await _get_pipeline(cpid) + await audit.information('CTPCH', {"pipeline": updated_pipeline, "old_pipeline": pipeline}) return web.json_response( {"message": "Control Pipeline with ID:<{}> has been updated successfully.".format(cpid)}) From e705364745d69ae4044ab69ac01516733b696d51 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 17 Aug 2023 16:03:11 +0530 Subject: [PATCH 404/499] audit trail entry added for CTPAD logcode Signed-off-by: ashish-jabble --- python/fledge/services/core/api/control_service/pipeline.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index c8841aeed5..ef3419fb82 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -137,6 +137,9 @@ async def create(request: web.Request) -> web.Response: _logger.error(ex, "Failed to create pipeline: {}.".format(data.get('name'))) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: + # CTPAD audit trail entry + audit = AuditLogger(storage) + await audit.information('CTPAD', final_result) return web.json_response(final_result) From f0f4fea5ba27429bd1ae9550007087632b29a282 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 17 Aug 2023 17:00:19 +0530 Subject: [PATCH 405/499] rebased downgrade scripts Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/63.sql | 2 +- scripts/plugins/storage/sqlite/downgrade/63.sql | 2 +- scripts/plugins/storage/sqlitelb/downgrade/63.sql | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/plugins/storage/postgres/downgrade/63.sql b/scripts/plugins/storage/postgres/downgrade/63.sql index 9fa42c1245..5b68761180 100644 --- a/scripts/plugins/storage/postgres/downgrade/63.sql +++ b/scripts/plugins/storage/postgres/downgrade/63.sql @@ -1,4 +1,4 @@ -- Drop control flow tables DROP TABLE IF EXISTS fledge.control_api_acl; DROP TABLE IF EXISTS fledge.control_api_parameters; -DROP TABLE IF EXISTS fledge.control_api; +DROP TABLE IF EXISTS fledge.control_api; \ No newline at end of file diff --git a/scripts/plugins/storage/sqlite/downgrade/63.sql b/scripts/plugins/storage/sqlite/downgrade/63.sql index 9fa42c1245..5b68761180 100644 --- a/scripts/plugins/storage/sqlite/downgrade/63.sql +++ b/scripts/plugins/storage/sqlite/downgrade/63.sql @@ -1,4 +1,4 @@ -- Drop control flow tables DROP TABLE IF EXISTS fledge.control_api_acl; DROP TABLE IF EXISTS fledge.control_api_parameters; -DROP TABLE IF EXISTS fledge.control_api; +DROP TABLE IF EXISTS fledge.control_api; \ No newline at end of file diff --git a/scripts/plugins/storage/sqlitelb/downgrade/63.sql b/scripts/plugins/storage/sqlitelb/downgrade/63.sql index 9fa42c1245..5b68761180 100644 --- a/scripts/plugins/storage/sqlitelb/downgrade/63.sql +++ b/scripts/plugins/storage/sqlitelb/downgrade/63.sql @@ -1,4 +1,4 @@ -- Drop control flow tables DROP TABLE IF EXISTS fledge.control_api_acl; DROP TABLE IF EXISTS fledge.control_api_parameters; -DROP TABLE IF EXISTS fledge.control_api; +DROP TABLE IF EXISTS fledge.control_api; \ No newline at end of file From 0a3ac94a3aaff513426562b1732a1abba682564d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 17 Aug 2023 17:41:01 +0530 Subject: [PATCH 406/499] audit ACLCH fixes Signed-off-by: ashish-jabble --- .../services/core/api/control_service/script_management.py | 2 +- .../core/api/control_service/test_script_management.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index fff1ed0f32..b684f8466c 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -363,7 +363,7 @@ async def update(request: web.Request) -> web.Response: raise ValueError('ACL must be a string.') acl = acl.strip() set_values = {} - values = {'steps': [], 'acl': ""} + values = {} if steps is not None: values['steps'] = steps set_values["steps"] = _validate_steps_and_convert_to_str(steps) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py index b80b28e139..8a335b56ac 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py @@ -1,5 +1,4 @@ import asyncio -import copy import json import sys import uuid @@ -482,11 +481,8 @@ def i_result(*args): assert {"message": "Control script {} updated successfully.".format(script_name)}\ == json_response args, _ = audit_info_patch.call_args - audit = copy.deepcopy(payload) - if 'acl' not in payload: - audit['acl'] = "" audit_info_patch.assert_called_once_with( - 'CTSCH', {"script": audit, "old_script": script_result['rows']}) + 'CTSCH', {"script": payload, "old_script": script_result['rows']}) update_args, _ = patch_update_tbl.call_args assert 'control_script' == update_args[0] update_payload = {"values": payload, From c20d38f7d9c1fc2af815d0912f9d2cc454efba83 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 18 Aug 2023 12:46:01 +0530 Subject: [PATCH 407/499] fledge docker installation steps added in quickstart guide Signed-off-by: ashish-jabble --- docs/quick_start/installing.rst | 61 +++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/docs/quick_start/installing.rst b/docs/quick_start/installing.rst index 3aedc2e1bb..864ff95d2c 100644 --- a/docs/quick_start/installing.rst +++ b/docs/quick_start/installing.rst @@ -198,3 +198,64 @@ Also you need to change the value of Storage plugin. See |Configure Storage Plug } Now, it's time to restart Fledge. Thereafter you will see Fledge is running with PostgreSQL. + + +Using Docker Containerizer to install Fledge +############################################# + +Fledge is provided a private registry. No auth/TLS yet on this private registry for docker container. + +Useful instructions are given below for it's setup: + +- Edit the daemon.json file, whose default location is /etc/docker/daemon.json on Linux, If the daemon.json file does not exist, create it. Assuming there are no other settings in the file, it should have the following contents: + +.. code-block:: console + + { "insecure-registries":["52.3.255.136:5000"] } + +- Restart Docker for the changes to take effect. sudo systemctl restart docker.service + +- Check using "sudo docker info" command, you should have following in output: + +.. code-block:: console + + Insecure Registries: + 52.3.255.136:5000 + 127.0.0.0/8 + +You may also refer the docker documentation for the setup `here `_. + +Ubuntu 20.04 +~~~~~~~~~~~~ + +- To pull the docker registry + +.. code-block:: console + + docker pull 54.204.128.201:5000/fledge:latest-ubuntu2004 + +- To run the docker container + +.. code-block:: console + + docker run -d --name fledge -p 8081:8081 -p 1995:1995 -p 8082:80 54.204.128.201:5000/fledge:latest-ubuntu2004 + +Here, GUI is forwarded to host port 8082, it can be any port and omitted if port 80 is free. + +- Now you can see both Fledge and Fledge-GUI is running. You can check below commands in your host machine. + +.. code-block:: console + + Fledge: curl -sX GET http://localhost:8081/fledge/ping + GUI: http://localhost:8082 + +- To attach to running container + +.. code-block:: console + + docker exec -it fledge bash + +.. note:: + For Ubuntu 18.04 setup, you just need to replace ubuntu2004 with ubuntu1804. + At the moment only ubuntu20.04 and ubuntu18.04 (x86_64) images are available. + From 5c6d847c6942a2611fd1bb3728a66730f53a3c66 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 18 Aug 2023 16:17:51 +0530 Subject: [PATCH 408/499] feedback changes Signed-off-by: ashish-jabble --- docs/quick_start/installing.rst | 44 ++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/quick_start/installing.rst b/docs/quick_start/installing.rst index 864ff95d2c..1979de02a4 100644 --- a/docs/quick_start/installing.rst +++ b/docs/quick_start/installing.rst @@ -203,9 +203,9 @@ Now, it's time to restart Fledge. Thereafter you will see Fledge is running with Using Docker Containerizer to install Fledge ############################################# -Fledge is provided a private registry. No auth/TLS yet on this private registry for docker container. +Fledge Docker containers are provided in a private repository. This repository has no authentication or encryption associated with it. -Useful instructions are given below for it's setup: +The following steps describe how to install Fledge using these containers: - Edit the daemon.json file, whose default location is /etc/docker/daemon.json on Linux, If the daemon.json file does not exist, create it. Assuming there are no other settings in the file, it should have the following contents: @@ -213,9 +213,19 @@ Useful instructions are given below for it's setup: { "insecure-registries":["52.3.255.136:5000"] } -- Restart Docker for the changes to take effect. sudo systemctl restart docker.service +- Restart Docker for the changes to take effect -- Check using "sudo docker info" command, you should have following in output: +.. code-block:: console + + sudo systemctl restart docker.service + +- Check using command + +.. code-block:: console + + docker info + +You should see the following output: .. code-block:: console @@ -223,33 +233,40 @@ Useful instructions are given below for it's setup: 52.3.255.136:5000 127.0.0.0/8 -You may also refer the docker documentation for the setup `here `_. +You may also refer to the Docker documentation `here `_. Ubuntu 20.04 ~~~~~~~~~~~~ -- To pull the docker registry +- To pull the Docker registry .. code-block:: console docker pull 54.204.128.201:5000/fledge:latest-ubuntu2004 -- To run the docker container +- To run the Docker container .. code-block:: console docker run -d --name fledge -p 8081:8081 -p 1995:1995 -p 8082:80 54.204.128.201:5000/fledge:latest-ubuntu2004 -Here, GUI is forwarded to host port 8082, it can be any port and omitted if port 80 is free. +Here, The GUI is forwarded to port 8082 on the host machine, it can be any port and omitted if port 80 is free. -- Now you can see both Fledge and Fledge-GUI is running. You can check below commands in your host machine. +- It is possible to check if Fledge and the Fledge GUI are running by using the following commands on the host machine + +*Fledge* + +.. code-block:: console + + curl -sX GET http://localhost:8081/fledge/ping + +*Fledge GUI* .. code-block:: console - Fledge: curl -sX GET http://localhost:8081/fledge/ping - GUI: http://localhost:8082 + http://localhost:8082 -- To attach to running container +- To attach to the running container .. code-block:: console @@ -257,5 +274,4 @@ Here, GUI is forwarded to host port 8082, it can be any port and omitted if port .. note:: For Ubuntu 18.04 setup, you just need to replace ubuntu2004 with ubuntu1804. - At the moment only ubuntu20.04 and ubuntu18.04 (x86_64) images are available. - + Images are currently only available for Ubuntu version 18.04 and 20.04. From 1633a64527cb58c3d00c92654c3599da74f6affe Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 18 Aug 2023 19:32:12 +0100 Subject: [PATCH 409/499] FOGL-7959 Improve error handling for plugin load failure (#1142) * Error reporting improvements Signed-off-by: Mark Riddoch * Improve JSON parse error reporting Signed-off-by: Mark Riddoch * FOGL-7959 Capture some of the common reasons plugins fail to load Signed-off-by: Mark Riddoch * Fix typos Signed-off-by: Mark Riddoch * Removing using namespace from string_utils header file Signed-off-by: Mark Riddoch * Removing using namespace from string_utils header file Signed-off-by: Mark Riddoch * Update test Signed-off-by: Mark Riddoch * Update tests Signed-off-by: Mark Riddoch * Fix unit tests Signed-off-by: Mark Riddoch * Test checkin to add loggign to PR unit tester Signed-off-by: Mark Riddoch * Using -j1 in RunAllTests Signed-off-by: Mark Riddoch * Using -j1 in RunAllTests Signed-off-by: Mark Riddoch * Include strign_utils in postgres test Signed-off-by: Mark Riddoch * Use entire common library for Postgres test Signed-off-by: Mark Riddoch * Add library location Signed-off-by: Mark Riddoch * Add extra library requirements Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/config_category.cpp | 18 +++-- C/common/include/string_utils.h | 18 ++--- C/common/string_utils.cpp | 17 +++++ C/plugins/utils/get_plugin_info.cpp | 11 ++-- .../common/include/binary_plugin_handle.h | 12 ++++ C/services/common/include/plugin_manager.h | 3 +- C/services/common/plugin_manager.cpp | 15 +++-- python/fledge/services/core/api/utils.py | 3 + tests/unit/C/common/test_config_category.cpp | 22 +++++++ tests/unit/C/common/test_string_utils.cpp | 13 +++- tests/unit/C/scripts/RunAllTests.sh | 4 +- .../services/storage/postgres/CMakeLists.txt | 66 +++++++++++++++++-- 12 files changed, 172 insertions(+), 30 deletions(-) diff --git a/C/common/config_category.cpp b/C/common/config_category.cpp index 9746d4d70c..d2db0482e3 100644 --- a/C/common/config_category.cpp +++ b/C/common/config_category.cpp @@ -20,6 +20,7 @@ #include #include #include +#include using namespace std; @@ -44,8 +45,8 @@ ConfigCategories::ConfigCategories(const std::string& json) doc.Parse(json.c_str()); if (doc.HasParseError()) { - Logger::getLogger()->error("Configuration parse error in %s: %s at %d", json.c_str(), - GetParseError_En(doc.GetParseError()), (unsigned)doc.GetErrorOffset()); + Logger::getLogger()->error("Configuration parse error in %s: %s at %d, '%s'", json.c_str(), + GetParseError_En(doc.GetParseError()), (unsigned)doc.GetErrorOffset(), StringAround(json, (unsigned)doc.GetErrorOffset()).c_str()); throw new ConfigMalformed(); } if (doc.HasMember("categories")) @@ -140,9 +141,10 @@ ConfigCategory::ConfigCategory(const string& name, const string& json) : m_name( doc.Parse(json.c_str()); if (doc.HasParseError()) { - Logger::getLogger()->error("Configuration parse error in category '%s', %s: %s at %d", + Logger::getLogger()->error("Configuration parse error in category '%s', %s: %s at %d, '%s'", name.c_str(), json.c_str(), - GetParseError_En(doc.GetParseError()), (unsigned)doc.GetErrorOffset()); + GetParseError_En(doc.GetParseError()), (unsigned)doc.GetErrorOffset(), + StringAround(json, (unsigned)doc.GetErrorOffset())); throw new ConfigMalformed(); } @@ -1117,10 +1119,14 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, check.Parse(m_value.c_str()); if (check.HasParseError()) { + Logger::getLogger()->error("The JSON configuration item %s has a parse error: %s", + m_name.c_str(), GetParseError_En(check.GetParseError())); throw new runtime_error(GetParseError_En(check.GetParseError())); } if (!check.IsObject()) { + Logger::getLogger()->error("The JSON configuration item %s is not a valid JSON objects", + m_name.c_str()); throw new runtime_error("'value' JSON property is not an object"); } } @@ -1221,10 +1227,14 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, check.Parse(m_default.c_str()); if (check.HasParseError()) { + Logger::getLogger()->error("The JSON configuration item %s has a parse error in the default value: %s", + m_name.c_str(), GetParseError_En(check.GetParseError())); throw new runtime_error(GetParseError_En(check.GetParseError())); } if (!check.IsObject()) { + Logger::getLogger()->error("The JSON configuration item %s default is not a valid JSON object", + m_name.c_str()); throw new runtime_error("'default' JSON property is not an object"); } } diff --git a/C/common/include/string_utils.h b/C/common/include/string_utils.h index 0e0aa363f4..85956dbaaf 100644 --- a/C/common/include/string_utils.h +++ b/C/common/include/string_utils.h @@ -14,7 +14,6 @@ #include #include -using namespace std; void StringReplace(std::string& StringToManage, const std::string& StringToSearch, @@ -24,25 +23,28 @@ void StringReplaceAll(std::string& StringToManage, const std::string& StringToSearch, const std::string& StringReplacement); -string StringSlashFix(const string& stringToFix); +std::string StringSlashFix(const std::string& stringToFix); std::string evaluateParentPath(const std::string& path, char separator); std::string extractLastLevel(const std::string& path, char separator); void StringStripCRLF(std::string& StringToManage); -string StringStripWhiteSpacesAll(const std::string& original); -string StringStripWhiteSpacesExtra(const std::string& original); +std::string StringStripWhiteSpacesAll(const std::string& original); +std::string StringStripWhiteSpacesExtra(const std::string& original); void StringStripQuotes(std::string& StringToManage); -string urlEncode(const string& s); -string urlDecode(const string& s); -void StringEscapeQuotes(string& s); +std::string urlEncode(const std::string& s); +std::string urlDecode(const std::string& s); +void StringEscapeQuotes(std::string& s); char *trim(char *str); std::string StringLTrim(const std::string& str); std::string StringRTrim(const std::string& str); std::string StringTrim(const std::string& str); -bool IsRegex(const string &str); +bool IsRegex(const std::string &str); + +std::string StringAround(const std::string& str, unsigned int pos, + unsigned int after = 30, unsigned int before = 10); #endif diff --git a/C/common/string_utils.cpp b/C/common/string_utils.cpp index a6e82d7957..49ae4da234 100644 --- a/C/common/string_utils.cpp +++ b/C/common/string_utils.cpp @@ -445,3 +445,20 @@ bool IsRegex(const string &str) { return (nChar != 0); } + +/** + * Return a new string that extracts from the passed in string either side + * of a position within the string. + * + * @param str The string to return a portion of + * @param pos The position around which to extract a portion + * @param after The number of characters after the position to return, defaults to 30 if omitted + * @param before The number of characters before the position to return, defaults to 10 + */ +std::string StringAround(const std::string& str, unsigned int pos, + unsigned int after, unsigned int before) +{ + size_t start = pos > before ? (pos - before) : 0; + size_t len = before + after; + return str.substr(start, len); +} diff --git a/C/plugins/utils/get_plugin_info.cpp b/C/plugins/utils/get_plugin_info.cpp index cae8eb4bbb..22c83ae886 100644 --- a/C/plugins/utils/get_plugin_info.cpp +++ b/C/plugins/utils/get_plugin_info.cpp @@ -41,14 +41,15 @@ int main(int argc, char *argv[]) exit(1); } + openlog("Fledge PluginInfo", LOG_PID|LOG_CONS, LOG_USER); + setlogmask(LOG_UPTO(LOG_WARNING)); + if (access(argv[1], F_OK|R_OK) != 0) { - fprintf(stderr, "Unable to access library file '%s', exiting...\n", argv[1]); + syslog(LOG_ERR, "Unable to access library file '%s', exiting...\n", argv[1]); exit(2); } - openlog(argv[0], LOG_PID|LOG_CONS, LOG_USER); - setlogmask(LOG_UPTO(LOG_WARNING)); if ((hndl = dlopen(argv[1], RTLD_GLOBAL|RTLD_LAZY)) != NULL) { @@ -56,7 +57,7 @@ int main(int argc, char *argv[]) if (infoEntry == NULL) { // Unable to find plugin_info entry point - fprintf(stderr, "Plugin library %s does not support %s function : %s\n", argv[1], routine, dlerror()); + syslog(LOG_ERR, "Plugin library %s does not support %s function : %s\n", argv[1], routine, dlerror()); dlclose(hndl); closelog(); exit(3); @@ -66,7 +67,7 @@ int main(int argc, char *argv[]) } else { - fprintf(stderr, "dlopen failed: %s\n", dlerror()); + syslog(LOG_ERR, "dlopen failed: %s\n", dlerror()); } closelog(); diff --git a/C/services/common/include/binary_plugin_handle.h b/C/services/common/include/binary_plugin_handle.h index e690621a68..277593ba4d 100644 --- a/C/services/common/include/binary_plugin_handle.h +++ b/C/services/common/include/binary_plugin_handle.h @@ -24,14 +24,26 @@ class BinaryPluginHandle : public PluginHandle public: // for the Storage plugin BinaryPluginHandle(const char *name, const char *path, tPluginType type) { + dlerror(); // Clear the existing error handle = dlopen(path, RTLD_LAZY); + if (!handle) + { + Logger::getLogger()->error("Unable to load storage plugin %s, %s", + name, dlerror()); + } Logger::getLogger()->debug("%s - storage plugin / RTLD_LAZY - name :%s: path :%s:", __FUNCTION__, name, path); } // for all the others plugins BinaryPluginHandle(const char *name, const char *path) { + dlerror(); // Clear the existing error handle = dlopen(path, RTLD_LAZY|RTLD_GLOBAL); + if (!handle) + { + Logger::getLogger()->error("Unable to load plugin %s, %s", + name, dlerror()); + } Logger::getLogger()->debug("%s - other plugin / RTLD_LAZY|RTLD_GLOBAL - name :%s: path :%s:", __FUNCTION__, name, path); } diff --git a/C/services/common/include/plugin_manager.h b/C/services/common/include/plugin_manager.h index 33b58769d2..a53293e4c8 100755 --- a/C/services/common/include/plugin_manager.h +++ b/C/services/common/include/plugin_manager.h @@ -58,6 +58,7 @@ class PluginManager { private: PluginManager(); + std::string findPlugin(std::string name, std::string _type, std::string _plugin_path, PLUGIN_TYPE type); private: std::list plugins; @@ -69,7 +70,7 @@ class PluginManager { std::map pluginHandleMap; Logger* logger; - tPluginType m_pluginType; + tPluginType m_pluginType; }; #endif diff --git a/C/services/common/plugin_manager.cpp b/C/services/common/plugin_manager.cpp index 0a3ad194b8..9b27590dc2 100755 --- a/C/services/common/plugin_manager.cpp +++ b/C/services/common/plugin_manager.cpp @@ -28,6 +28,7 @@ #include "rapidjson/error/en.h" #include #include +#include using namespace std; using namespace rapidjson; @@ -77,7 +78,9 @@ void updateJsonPluginConfig(PLUGIN_INFORMATION *info, string json_plugin_name, s doc.Parse(json_plugin_defaults.c_str()); if (doc.HasParseError()) { - logger->error("doc JSON parsing failed"); + logger->error("Parse error in plugin '%s' defaults: %s at %d '%s'", json_plugin_name.c_str(), + GetParseError_En(doc.GetParseError()), (unsigned)doc.GetErrorOffset(), + StringAround(json_plugin_defaults, (unsigned)doc.GetErrorOffset())); return; } @@ -85,7 +88,9 @@ void updateJsonPluginConfig(PLUGIN_INFORMATION *info, string json_plugin_name, s docBase.Parse(info->config); if (docBase.HasParseError()) { - logger->error("docBase JSON parsing failed"); + logger->error("Parse error in plugin '%s' information defaults: %s at %d '%s'", json_plugin_name.c_str(), + GetParseError_En(doc.GetParseError()), (unsigned)doc.GetErrorOffset(), + StringAround(info->config, (unsigned)doc.GetErrorOffset())); return; } @@ -176,7 +181,9 @@ void updateJsonPluginConfig(PLUGIN_INFORMATION *info, string json_plugin_name, s doc2.Parse(info->config); if (doc2.HasParseError()) { - logger->error("doc2 JSON parsing failed"); + logger->error("Parse error in information returned from plugin: %s at %d '%s'", + GetParseError_En(doc2.GetParseError()), (unsigned)doc2.GetErrorOffset(), + StringAround(info->config, (unsigned)doc2.GetErrorOffset())); } if (doc2.HasMember("plugin")) { @@ -208,7 +215,7 @@ void updateJsonPluginConfig(PLUGIN_INFORMATION *info, string json_plugin_name, s * @param type The plugin type * @return string The absolute path of plugin */ -string findPlugin(string name, string _type, string _plugin_path, PLUGIN_TYPE type) +string PluginManager::findPlugin(string name, string _type, string _plugin_path, PLUGIN_TYPE type) { if (type != BINARY_PLUGIN && type != PYTHON_PLUGIN && type != JSON_PLUGIN) { diff --git a/python/fledge/services/core/api/utils.py b/python/fledge/services/core/api/utils.py index 1226688dcc..292754b656 100644 --- a/python/fledge/services/core/api/utils.py +++ b/python/fledge/services/core/api/utils.py @@ -27,6 +27,9 @@ def get_plugin_info(name, dir): out, err = p.communicate() res = out.decode("utf-8") jdoc = json.loads(res) + except json.decoder.JSONDecodeError as err: + _logger.error("Failed to parse JSON data returned from the plugin information of {}, {} line {} column {}".format(name, err.msg, err.lineno, err.colno)) + return {} except (OSError, ValueError) as err: _logger.error(err, "{} C plugin get info failed.".format(name)) return {} diff --git a/tests/unit/C/common/test_config_category.cpp b/tests/unit/C/common/test_config_category.cpp index ed582bce8c..0bbce88f8a 100644 --- a/tests/unit/C/common/test_config_category.cpp +++ b/tests/unit/C/common/test_config_category.cpp @@ -324,6 +324,23 @@ const char *optionals = const char *json_quotedSpecial = R"QS({ "key" : "test \"a\"", "description" : "Test \"description\"", "value" : {"description" : { "description" : "The description of this \"Fledge\" service", "type" : "string", "value" : "The \"Fledge\" admini\\strative API", "default" : "The \"Fledge\" administra\tive API" }, "name" : { "description" : "The name of this \"Fledge\" service", "type" : "string", "value" : "\"Fledge\"", "default" : "\"Fledge\"" }, "complex" : { "description" : "A JSON configuration parameter", "type" : "json", "value" : {"first":"Fledge","second":"json"}, "default" : {"first":"Fledge","second":"json"} }} })QS"; +const char *json_parse_error = "{\"description\": {" + "\"value\": \"The Fledge administrative API\"," + "\"type\": \"string\"," + "\"default\": \"The Fledge administrative API\"," + "\"description\": \"The description of this Fledge service\"}," + "\"name\": {" + "\"value\": \"Fledge\"," + "\"type\": \"string\"," + "\"default\": \"Fledge\"," + "\"description\": \"The name of this Fledge service\"}," + "error : here," + "\"complex\": {" \ + "\"value\": { \"first\" : \"Fledge\", \"second\" : \"json\" }," + "\"type\": \"json\"," + "\"default\": {\"first\" : \"Fledge\", \"second\" : \"json\" }," + "\"description\": \"A JSON configuration parameter\"}}"; + TEST(CategoriesTest, Count) { ConfigCategories confCategories(categories); @@ -654,3 +671,8 @@ TEST(CategoryTestQuoted, toJSONQuotedSpecial) confCategory.setDescription("Test \"description\""); ASSERT_EQ(0, confCategory.toJSON().compare(json_quotedSpecial)); } + +TEST(Categorytest, parseError) +{ + EXPECT_THROW(ConfigCategory("parseTest", json_parse_error), ConfigMalformed*); +} diff --git a/tests/unit/C/common/test_string_utils.cpp b/tests/unit/C/common/test_string_utils.cpp index 4bcc557a90..57042ff689 100644 --- a/tests/unit/C/common/test_string_utils.cpp +++ b/tests/unit/C/common/test_string_utils.cpp @@ -215,4 +215,15 @@ TEST(TestIsRegex, AllCases) - +TEST(TestAround, Extract) +{ + string longString("not shownpreamble123This part is after the location"); + string s = StringAround(longString, 19); + EXPECT_STREQ(s.c_str(), "preamble123This part is after the locati"); + s = StringAround(longString, 19, 10); + EXPECT_STREQ(s.c_str(), "preamble123This part"); + s = StringAround(longString, 19, 10, 5); + EXPECT_STREQ(s.c_str(), "ble123This part"); + s = StringAround(longString, 5); + EXPECT_STREQ(s.c_str(), "not shownpreamble123This part is after t"); +} diff --git a/tests/unit/C/scripts/RunAllTests.sh b/tests/unit/C/scripts/RunAllTests.sh index 572b8eb484..ec8e31e02f 100755 --- a/tests/unit/C/scripts/RunAllTests.sh +++ b/tests/unit/C/scripts/RunAllTests.sh @@ -34,8 +34,8 @@ if [ ! -d results ] ; then fi if [ -f "./CMakeLists.txt" ] ; then - echo -n "Compiling libraries..." - (rm -rf build && mkdir -p build && cd build && cmake -DCMAKE_BUILD_TYPE=Debug .. && make ${jobs} && cd ..) > /dev/null + echo "Compiling libraries..." + (rm -rf build && mkdir -p build && cd build && cmake -DCMAKE_BUILD_TYPE=Debug .. && make ${jobs} && cd ..) echo "done" fi diff --git a/tests/unit/C/services/storage/postgres/CMakeLists.txt b/tests/unit/C/services/storage/postgres/CMakeLists.txt index 2a218ee9cb..975af18a90 100644 --- a/tests/unit/C/services/storage/postgres/CMakeLists.txt +++ b/tests/unit/C/services/storage/postgres/CMakeLists.txt @@ -7,7 +7,11 @@ set(GCOVR_PATH "$ENV{HOME}/.local/bin/gcovr") project(RunTests) set(CMAKE_CXX_FLAGS "-std=c++11 -O0") - + +set(COMMON_LIB common-lib) +set(SERVICE_COMMON_LIB services-common-lib) +set(PLUGINS_COMMON_LIB plugins-common-lib) + include(CodeCoverage) append_coverage_compiler_flags() @@ -20,17 +24,69 @@ include_directories(../../../../../../C/common/include) include_directories(../../../../../../C/thirdparty/rapidjson/include) file(GLOB test_sources "../../../../../../C/services/storage/configuration.cpp") -file(GLOB logger_sources "../../../../../../C/common/logger.cpp") -file(GLOB config_sources "../../../../../../C/common/config_category.cpp") -file(GLOB utils_sources "../../../../../../C/common/json_utils.cpp") + +link_directories(${PROJECT_BINARY_DIR}/../../../../lib) +# Find python3.x dev/lib package +find_package(PkgConfig REQUIRED) +if(${CMAKE_VERSION} VERSION_LESS "3.12.0") + pkg_check_modules(PYTHON REQUIRED python3) +else() + if("${OS_NAME}" STREQUAL "mendel") + # We will explicitly set include path later for NumPy. + find_package(Python3 REQUIRED COMPONENTS Interpreter Development ) + else() + find_package(Python3 REQUIRED COMPONENTS Interpreter Development NumPy) + endif() +endif() + +# Add Python 3.x header files +if(${CMAKE_VERSION} VERSION_LESS "3.12.0") + include_directories(${PYTHON_INCLUDE_DIRS}) +else() + if("${OS_NAME}" STREQUAL "mendel") + # The following command gets the location of NumPy. + execute_process( + COMMAND python3 + -c "import numpy; print(numpy.get_include())" + OUTPUT_VARIABLE Python3_NUMPY_INCLUDE_DIRS + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + # Now we can add include directories as usual. + include_directories(${Python3_INCLUDE_DIRS} ${Python3_NUMPY_INCLUDE_DIRS}) + else() + include_directories(${Python3_INCLUDE_DIRS} ${Python3_NUMPY_INCLUDE_DIRS}) + endif() +endif() + +if(${CMAKE_VERSION} VERSION_LESS "3.12.0") + link_directories(${PYTHON_LIBRARY_DIRS}) +else() + link_directories(${Python3_LIBRARY_DIRS}) +endif() + # Link runTests with what we want to test and the GTest and pthread library -add_executable(RunTests ${test_sources} ${logger_sources} ${config_sources} ${utils_sources} tests.cpp) +add_executable(RunTests ${test_sources} tests.cpp) #setting BOOST_COMPONENTS to use pthread library only set(BOOST_COMPONENTS thread) find_package(Boost 1.53.0 COMPONENTS ${BOOST_COMPONENTS} REQUIRED) target_link_libraries(RunTests ${GTEST_LIBRARIES} pthread) +target_link_libraries(RunTests ${COMMON_LIB}) +target_link_libraries(RunTests ${SERVICE_COMMON_LIB}) +target_link_libraries(RunTests ${PLUGINS_COMMON_LIB}) + +# Add Python 3.x library +if(${CMAKE_VERSION} VERSION_LESS "3.12.0") + target_link_libraries(RunTests ${PYTHON_LIBRARIES}) +else() + if("${OS_NAME}" STREQUAL "mendel") + target_link_libraries(${PROJECT_NAME} ${Python3_LIBRARIES}) + else() + target_link_libraries(${PROJECT_NAME} ${Python3_LIBRARIES} Python3::NumPy) + endif() +endif() + setup_target_for_coverage_gcovr_html( NAME CoverageHtml EXECUTABLE ${PROJECT_NAME} From 5f3cb823911d29b5f0dcd4b05c6221ba4b65c6f3 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 21 Aug 2023 12:18:39 +0530 Subject: [PATCH 410/499] v2 configuration migration work Signed-off-by: ashish-jabble --- docs/.readthedocs.yaml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/.readthedocs.yaml diff --git a/docs/.readthedocs.yaml b/docs/.readthedocs.yaml new file mode 100644 index 0000000000..4fee1721e0 --- /dev/null +++ b/docs/.readthedocs.yaml @@ -0,0 +1,35 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-20.04 + tools: + python: "3.8.10" + # You can also specify other tool versions: + # nodejs: "20" + # rust: "1.70" + # golang: "1.20" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs + # builder: "dirhtml" + # Fail on all warnings to avoid broken references + # fail_on_warning: true + +# Optionally build your docs in additional formats such as PDF and ePub +# formats: +# - pdf +# - epub + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt From 5629abcce2c53924f59c0216748f076153f94efe Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 21 Aug 2023 12:25:17 +0530 Subject: [PATCH 411/499] path corrected for RTD yaml Signed-off-by: ashish-jabble --- docs/.readthedocs.yaml => .readthedocs.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/.readthedocs.yaml => .readthedocs.yaml (100%) diff --git a/docs/.readthedocs.yaml b/.readthedocs.yaml similarity index 100% rename from docs/.readthedocs.yaml rename to .readthedocs.yaml From bda2e6d48b824d7587554fef60d3fde36beb8c8c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 21 Aug 2023 12:26:46 +0530 Subject: [PATCH 412/499] python version updated as per RTD convention Signed-off-by: ashish-jabble --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 4fee1721e0..385c976c77 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,7 +8,7 @@ version: 2 build: os: ubuntu-20.04 tools: - python: "3.8.10" + python: "3.8" # You can also specify other tool versions: # nodejs: "20" # rust: "1.70" From b1c7ca273ba837a3fa9de3dd9c2f2783963bfe70 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 21 Aug 2023 12:37:24 +0530 Subject: [PATCH 413/499] corrections in quickstart Signed-off-by: ashish-jabble --- docs/quick_start/installing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quick_start/installing.rst b/docs/quick_start/installing.rst index 1979de02a4..23a4aeef21 100644 --- a/docs/quick_start/installing.rst +++ b/docs/quick_start/installing.rst @@ -211,7 +211,7 @@ The following steps describe how to install Fledge using these containers: .. code-block:: console - { "insecure-registries":["52.3.255.136:5000"] } + { "insecure-registries":["54.204.128.201:5000"] } - Restart Docker for the changes to take effect From 3f1fbe6757f904d5eeb614a8e66475f89cd78f0e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 22 Aug 2023 17:37:38 +0530 Subject: [PATCH 414/499] timestamp in UTC for package logs file Signed-off-by: ashish-jabble --- python/fledge/services/core/api/package_log.py | 2 +- python/fledge/services/core/api/plugins/common.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/package_log.py b/python/fledge/services/core/api/package_log.py index 3355d1a341..04309fe94c 100644 --- a/python/fledge/services/core/api/package_log.py +++ b/python/fledge/services/core/api/package_log.py @@ -63,7 +63,7 @@ async def get_logs(request: web.Request) -> web.Response: dt = "{}-{}-{}-{}".format(t3[0], t3[1], t3[2], t3[3]) ts = datetime.strptime(dt, "%y%m%d-%H-%M-%S").strftime('%Y-%m-%d %H:%M:%S') else: - dt = datetime.now() + dt = datetime.utcnow() ts = dt.strftime("%Y-%m-%d %H:%M:%S") result.append({"timestamp": ts, "name": name, "filename": f}) diff --git a/python/fledge/services/core/api/plugins/common.py b/python/fledge/services/core/api/plugins/common.py index ef64671102..f05ef06013 100644 --- a/python/fledge/services/core/api/plugins/common.py +++ b/python/fledge/services/core/api/plugins/common.py @@ -229,7 +229,7 @@ def create_log_file(action: str = "", plugin_name: str = "") -> str: logs_dir = '/logs/' _PATH = _FLEDGE_DATA + logs_dir if _FLEDGE_DATA else _FLEDGE_ROOT + '/data{}'.format(logs_dir) # YYMMDD-HH-MM-SS-{plugin_name}.log - file_spec = datetime.now().strftime('%y%m%d-%H-%M-%S') + file_spec = datetime.utcnow().strftime('%y%m%d-%H-%M-%S') if not action: log_file_name = "{}-{}.log".format(file_spec, plugin_name) if plugin_name else "{}.log".format(file_spec) else: From dba2e671cac1151ce811e7bc8ca06807e6a64f27 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 22 Aug 2023 17:47:38 +0530 Subject: [PATCH 415/499] date timestamp in UTC for backup API Signed-off-by: ashish-jabble --- python/fledge/plugins/storage/common/lib.py | 28 ++++++--------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/python/fledge/plugins/storage/common/lib.py b/python/fledge/plugins/storage/common/lib.py index a914f06212..e078e2dcc0 100644 --- a/python/fledge/plugins/storage/common/lib.py +++ b/python/fledge/plugins/storage/common/lib.py @@ -9,6 +9,7 @@ import os import asyncio import json +import datetime from enum import IntEnum from fledge.common import logger @@ -403,17 +404,10 @@ def sl_backup_status_create(self, _file_name, _type, _status): Returns: Raises: """ - _logger.debug("{func} - file name |{file}| ".format(func="sl_backup_status_create", file=_file_name)) - - payload = payload_builder.PayloadBuilder() \ - .INSERT(file_name=_file_name, - ts="now()", - type=_type, - status=_status, - exit_code=0) \ - .payload() - + payload = payload_builder.PayloadBuilder().INSERT( + file_name=_file_name, ts=datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), type=_type, + status=_status, exit_code=0).payload() asyncio.get_event_loop().run_until_complete(self._storage.insert_into_tbl(self.STORAGE_TABLE_BACKUPS, payload)) def sl_backup_status_update(self, _id, _status, _exit_code): @@ -426,17 +420,11 @@ def sl_backup_status_update(self, _id, _status, _exit_code): Returns: Raises: """ - _logger.debug("{func} - id |{file}| ".format(func="sl_backup_status_update", file=_id)) - - payload = payload_builder.PayloadBuilder() \ - .SET(status=_status, - ts="now()", - exit_code=_exit_code) \ - .WHERE(['id', '=', _id]) \ - .payload() - - asyncio.get_event_loop().run_until_complete( self._storage.update_tbl(self.STORAGE_TABLE_BACKUPS, payload)) + payload = payload_builder.PayloadBuilder().SET( + status=_status, ts=datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), exit_code=_exit_code + ).WHERE(['id', '=', _id]).payload() + asyncio.get_event_loop().run_until_complete(self._storage.update_tbl(self.STORAGE_TABLE_BACKUPS, payload)) def sl_get_backup_details_from_file_name(self, _file_name): """ Retrieves backup information from file name From a4c98033fe0f204c2bd65a805ecdd4dddcaad0b9 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 22 Aug 2023 18:09:30 +0530 Subject: [PATCH 416/499] support bundle filename in UTC format Signed-off-by: ashish-jabble --- python/fledge/services/core/support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fledge/services/core/support.py b/python/fledge/services/core/support.py index 63575de32a..f026f80750 100644 --- a/python/fledge/services/core/support.py +++ b/python/fledge/services/core/support.py @@ -62,7 +62,7 @@ def __init__(self, support_dir): async def build(self): try: - today = datetime.datetime.now() + today = datetime.datetime.utcnow() file_spec = today.strftime('%y%m%d-%H-%M-%S') tar_file_name = self._out_file_path+"/"+"support-{}.tar.gz".format(file_spec) pyz = tarfile.open(tar_file_name, "w:gz") From e52c9d1087f65e96a98c8bbd7c86263c220c4e3f Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 22 Aug 2023 16:11:59 +0100 Subject: [PATCH 417/499] Use sqlite3 in /usr/local rather than /tmp (#1150) * Use sqlite3 in /usr/local rather than /tmp Signed-off-by: Mark Riddoch * Remove TODO that has been done Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/plugins/storage/sqlite/CMakeLists.txt | 4 ++-- C/plugins/storage/sqlitelb/CMakeLists.txt | 4 ++-- C/plugins/storage/sqlitememory/CMakeLists.txt | 4 ++-- requirements.sh | 3 ++- tests/unit/C/cmake_sqlite/Findsqlite3.cmake | 1 + tests/unit/C/cmake_sqliteM/Findsqlite3.cmake | 1 + tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake | 1 + 7 files changed, 11 insertions(+), 7 deletions(-) diff --git a/C/plugins/storage/sqlite/CMakeLists.txt b/C/plugins/storage/sqlite/CMakeLists.txt index 180d56b356..fffec32c9a 100644 --- a/C/plugins/storage/sqlite/CMakeLists.txt +++ b/C/plugins/storage/sqlite/CMakeLists.txt @@ -6,8 +6,8 @@ set(CMAKE_CXX_FLAGS_DEBUG "-O0 -ggdb") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11") set(STORAGE_COMMON_LIB storage-common-lib) -# Path of compiled libsqlite3.a and .h files: /tmp/sqlite3-pkg/src -set(FLEDGE_SQLITE3_LIBS "/tmp/sqlite3-pkg/src" CACHE INTERNAL "") +# Path of compiled libsqlite3.a and .h files: /usr/local +set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") # Find source files file(GLOB SOURCES ./common/*.cpp ./schema/*.cpp *.cpp) diff --git a/C/plugins/storage/sqlitelb/CMakeLists.txt b/C/plugins/storage/sqlitelb/CMakeLists.txt index 4bfcf9f4e9..fea19f9cc3 100644 --- a/C/plugins/storage/sqlitelb/CMakeLists.txt +++ b/C/plugins/storage/sqlitelb/CMakeLists.txt @@ -6,8 +6,8 @@ set(CMAKE_CXX_FLAGS_DEBUG "-O0 -ggdb") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11") set(STORAGE_COMMON_LIB storage-common-lib) -# Path of compiled libsqlite3.a and .h files: /tmp/sqlite3-pkg/src -set(FLEDGE_SQLITE3_LIBS "/tmp/sqlite3-pkg/src" CACHE INTERNAL "") +# Path of compiled libsqlite3.a and .h files: /usr/local +set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") # Find source files file(GLOB SOURCES ./common/*.cpp ../sqlite/schema/*.cpp *.cpp) diff --git a/C/plugins/storage/sqlitememory/CMakeLists.txt b/C/plugins/storage/sqlitememory/CMakeLists.txt index b3495f16f1..d81cc36800 100644 --- a/C/plugins/storage/sqlitememory/CMakeLists.txt +++ b/C/plugins/storage/sqlitememory/CMakeLists.txt @@ -6,8 +6,8 @@ set(CMAKE_CXX_FLAGS_DEBUG "-O0 -ggdb") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11") set(STORAGE_COMMON_LIB storage-common-lib) -# Path of compiled libsqlite3.a and .h files: /tmp/sqlite3-pkg/src -set(FLEDGE_SQLITE3_LIBS "/tmp/sqlite3-pkg/src" CACHE INTERNAL "") +# Path of compiled libsqlite3.a and .h files: /usr/local +set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") # Find source files # Add sqlitelb plugin common files diff --git a/requirements.sh b/requirements.sh index 743889acfa..d2e8d83ec7 100755 --- a/requirements.sh +++ b/requirements.sh @@ -206,7 +206,7 @@ if [[ $YUM_PLATFORM = true ]]; then set -e make - # TODO: Use make install to install sqlite3 as a command + make install fi cd $fledge_location set -e @@ -253,6 +253,7 @@ elif apt --version 2>/dev/null; then sqlite3_build_prepare make + make install apt install -y sqlite3 # make install after sqlite3_build_prepare should be enough to install sqlite3 as a command apt install -y pkg-config diff --git a/tests/unit/C/cmake_sqlite/Findsqlite3.cmake b/tests/unit/C/cmake_sqlite/Findsqlite3.cmake index 93f75fbc13..feb1e1055d 100644 --- a/tests/unit/C/cmake_sqlite/Findsqlite3.cmake +++ b/tests/unit/C/cmake_sqlite/Findsqlite3.cmake @@ -8,6 +8,7 @@ set(SQLITE_MIN_VERSION "3.11.0") # Check wether path of compiled libsqlite3.a and .h files exists +set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") if (EXISTS ${FLEDGE_SQLITE3_LIBS}) find_path(SQLITE_INCLUDE_DIR sqlite3.h PATHS ${FLEDGE_SQLITE3_LIBS}) find_library(SQLITE_LIBRARIES NAMES libsqlite3.a PATHS "${FLEDGE_SQLITE3_LIBS}/.libs") diff --git a/tests/unit/C/cmake_sqliteM/Findsqlite3.cmake b/tests/unit/C/cmake_sqliteM/Findsqlite3.cmake index 93f75fbc13..feb1e1055d 100644 --- a/tests/unit/C/cmake_sqliteM/Findsqlite3.cmake +++ b/tests/unit/C/cmake_sqliteM/Findsqlite3.cmake @@ -8,6 +8,7 @@ set(SQLITE_MIN_VERSION "3.11.0") # Check wether path of compiled libsqlite3.a and .h files exists +set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") if (EXISTS ${FLEDGE_SQLITE3_LIBS}) find_path(SQLITE_INCLUDE_DIR sqlite3.h PATHS ${FLEDGE_SQLITE3_LIBS}) find_library(SQLITE_LIBRARIES NAMES libsqlite3.a PATHS "${FLEDGE_SQLITE3_LIBS}/.libs") diff --git a/tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake b/tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake index 93f75fbc13..feb1e1055d 100644 --- a/tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake +++ b/tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake @@ -8,6 +8,7 @@ set(SQLITE_MIN_VERSION "3.11.0") # Check wether path of compiled libsqlite3.a and .h files exists +set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") if (EXISTS ${FLEDGE_SQLITE3_LIBS}) find_path(SQLITE_INCLUDE_DIR sqlite3.h PATHS ${FLEDGE_SQLITE3_LIBS}) find_library(SQLITE_LIBRARIES NAMES libsqlite3.a PATHS "${FLEDGE_SQLITE3_LIBS}/.libs") From 26da071708dc99b8fb0490d6d4f3094434615219 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 23 Aug 2023 12:02:24 +0530 Subject: [PATCH 418/499] name KV pair added in audit delete scenarios for control script, pipeline and ACL Signed-off-by: ashish-jabble --- .../services/core/api/control_service/acl_management.py | 2 +- .../fledge/services/core/api/control_service/pipeline.py | 4 +++- .../core/api/control_service/script_management.py | 2 +- .../core/api/control_service/test_acl_management.py | 2 +- .../core/api/control_service/test_script_management.py | 8 +++++--- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index 6743482891..34f1b8d960 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -258,7 +258,7 @@ async def delete_acl(request: web.Request) -> web.Response: message = "{} ACL deleted successfully.".format(name) # ACLDL audit trail entry audit = AuditLogger(storage) - await audit.information('ACLDL', {"message": message}) + await audit.information('ACLDL', {"message": message, "name": name}) else: raise StorageServerError(delete_result) else: diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index ef3419fb82..ffd932aa26 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -284,9 +284,11 @@ async def delete(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: message = {"message": "Control Pipeline with ID:<{}> has been deleted successfully.".format(cpid)} + audit_details = message + audit_details["name"] = pipeline['name'] # CTPDL audit trail entry audit = AuditLogger(storage) - await audit.information('CTPDL', message) + await audit.information('CTPDL', audit_details) return web.json_response(message) diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index b684f8466c..8b92e6e7a1 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -479,7 +479,7 @@ async def delete(request: web.Request) -> web.Response: message = "{} script deleted successfully.".format(name) # CTSDL audit trail entry audit = AuditLogger(storage) - await audit.information('CTSDL', {'message': message}) + await audit.information('CTSDL', {'message': message, "name": name}) else: raise StorageServerError(delete_result) else: diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py index f2ad55af17..240c1a52af 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py @@ -332,7 +332,7 @@ def q_result(*args): result = await resp.text() json_response = json.loads(result) assert {'message': message} == json_response - audit_info_patch.assert_called_once_with('ACLDL', {'message': message}) + audit_info_patch.assert_called_once_with('ACLDL', {'message': message, "name": acl_name}) delete_args, _ = patch_delete_tbl.call_args assert 'control_acl' == delete_args[0] assert delete_payload == json.loads(delete_args[1]) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py index 8a335b56ac..5562214de6 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py @@ -584,7 +584,8 @@ def d_schedule(*args): result = await resp.text() json_response = json.loads(result) assert {'message': message} == json_response - audit_info_patch.assert_called_once_with('CTSDL', {'message': message}) + audit_info_patch.assert_called_once_with( + 'CTSDL', {'message': message, "name": script_name}) patch_delete_tbl.assert_called_once_with( 'control_script', json.dumps(delete_payload)) args, _ = patch_query_tbl.call_args @@ -645,8 +646,9 @@ def d_result(*args): result = await resp.text() json_response = json.loads(result) assert {'message': message} == json_response - audit_info_patch.assert_called_once_with('CTSDL', {'message': message}) - patch_delete_tbl.assert_called_once_with('control_script', json.dumps(delete_payload)) + audit_info_patch.assert_called_once_with( + 'CTSDL', {'message': message, "name": script_name}) + patch_delete_tbl.assert_called_once_with('control_script', json.dumps(delete_payload)) args, _ = patch_query_tbl.call_args assert 'acl_usage' == args[0] assert json.dumps(q_tbl_payload) == args[1] From c4d7e3b818270ca31afdf99da4ebd46eb068f3d6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 23 Aug 2023 18:44:33 +0530 Subject: [PATCH 419/499] Path corrected in CMakelists for sqlite3 binary Signed-off-by: ashish-jabble --- C/plugins/storage/sqlite/CMakeLists.txt | 4 ++-- C/plugins/storage/sqlitelb/CMakeLists.txt | 4 ++-- C/plugins/storage/sqlitememory/CMakeLists.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/C/plugins/storage/sqlite/CMakeLists.txt b/C/plugins/storage/sqlite/CMakeLists.txt index fffec32c9a..4a997a02a0 100644 --- a/C/plugins/storage/sqlite/CMakeLists.txt +++ b/C/plugins/storage/sqlite/CMakeLists.txt @@ -6,8 +6,8 @@ set(CMAKE_CXX_FLAGS_DEBUG "-O0 -ggdb") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11") set(STORAGE_COMMON_LIB storage-common-lib) -# Path of compiled libsqlite3.a and .h files: /usr/local -set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") +# Path of compiled sqlite3 file: /usr/local/bin +set(FLEDGE_SQLITE3_LIBS "/usr/local/bin" CACHE INTERNAL "") # Find source files file(GLOB SOURCES ./common/*.cpp ./schema/*.cpp *.cpp) diff --git a/C/plugins/storage/sqlitelb/CMakeLists.txt b/C/plugins/storage/sqlitelb/CMakeLists.txt index fea19f9cc3..ca1c5a900c 100644 --- a/C/plugins/storage/sqlitelb/CMakeLists.txt +++ b/C/plugins/storage/sqlitelb/CMakeLists.txt @@ -6,8 +6,8 @@ set(CMAKE_CXX_FLAGS_DEBUG "-O0 -ggdb") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11") set(STORAGE_COMMON_LIB storage-common-lib) -# Path of compiled libsqlite3.a and .h files: /usr/local -set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") +# Path of compiled sqlite3 file: /usr/local/bin +set(FLEDGE_SQLITE3_LIBS "/usr/local/bin" CACHE INTERNAL "") # Find source files file(GLOB SOURCES ./common/*.cpp ../sqlite/schema/*.cpp *.cpp) diff --git a/C/plugins/storage/sqlitememory/CMakeLists.txt b/C/plugins/storage/sqlitememory/CMakeLists.txt index d81cc36800..259269aefc 100644 --- a/C/plugins/storage/sqlitememory/CMakeLists.txt +++ b/C/plugins/storage/sqlitememory/CMakeLists.txt @@ -6,8 +6,8 @@ set(CMAKE_CXX_FLAGS_DEBUG "-O0 -ggdb") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11") set(STORAGE_COMMON_LIB storage-common-lib) -# Path of compiled libsqlite3.a and .h files: /usr/local -set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") +# Path of compiled sqlite3 file: /usr/local/bin +set(FLEDGE_SQLITE3_LIBS "/usr/local/bin" CACHE INTERNAL "") # Find source files # Add sqlitelb plugin common files From e69b66b36825bf3b11daedcfd01b0345cee5acbe Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 24 Aug 2023 12:44:02 +0530 Subject: [PATCH 420/499] reverted FOGL-8039 changes for C based unit tests Signed-off-by: ashish-jabble --- tests/unit/C/cmake_sqlite/Findsqlite3.cmake | 1 - tests/unit/C/cmake_sqliteM/Findsqlite3.cmake | 1 - tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake | 1 - 3 files changed, 3 deletions(-) diff --git a/tests/unit/C/cmake_sqlite/Findsqlite3.cmake b/tests/unit/C/cmake_sqlite/Findsqlite3.cmake index feb1e1055d..93f75fbc13 100644 --- a/tests/unit/C/cmake_sqlite/Findsqlite3.cmake +++ b/tests/unit/C/cmake_sqlite/Findsqlite3.cmake @@ -8,7 +8,6 @@ set(SQLITE_MIN_VERSION "3.11.0") # Check wether path of compiled libsqlite3.a and .h files exists -set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") if (EXISTS ${FLEDGE_SQLITE3_LIBS}) find_path(SQLITE_INCLUDE_DIR sqlite3.h PATHS ${FLEDGE_SQLITE3_LIBS}) find_library(SQLITE_LIBRARIES NAMES libsqlite3.a PATHS "${FLEDGE_SQLITE3_LIBS}/.libs") diff --git a/tests/unit/C/cmake_sqliteM/Findsqlite3.cmake b/tests/unit/C/cmake_sqliteM/Findsqlite3.cmake index feb1e1055d..93f75fbc13 100644 --- a/tests/unit/C/cmake_sqliteM/Findsqlite3.cmake +++ b/tests/unit/C/cmake_sqliteM/Findsqlite3.cmake @@ -8,7 +8,6 @@ set(SQLITE_MIN_VERSION "3.11.0") # Check wether path of compiled libsqlite3.a and .h files exists -set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") if (EXISTS ${FLEDGE_SQLITE3_LIBS}) find_path(SQLITE_INCLUDE_DIR sqlite3.h PATHS ${FLEDGE_SQLITE3_LIBS}) find_library(SQLITE_LIBRARIES NAMES libsqlite3.a PATHS "${FLEDGE_SQLITE3_LIBS}/.libs") diff --git a/tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake b/tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake index feb1e1055d..93f75fbc13 100644 --- a/tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake +++ b/tests/unit/C/cmake_sqlitelb/Findsqlite3.cmake @@ -8,7 +8,6 @@ set(SQLITE_MIN_VERSION "3.11.0") # Check wether path of compiled libsqlite3.a and .h files exists -set(FLEDGE_SQLITE3_LIBS "/usr/local" CACHE INTERNAL "") if (EXISTS ${FLEDGE_SQLITE3_LIBS}) find_path(SQLITE_INCLUDE_DIR sqlite3.h PATHS ${FLEDGE_SQLITE3_LIBS}) find_library(SQLITE_LIBRARIES NAMES libsqlite3.a PATHS "${FLEDGE_SQLITE3_LIBS}/.libs") From 00ba951bbac47559a837a37491908339ce978a2f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 25 Aug 2023 15:10:14 +0530 Subject: [PATCH 421/499] sqlite3 make install fixes for YUM based as per FOGL-8039 changes Signed-off-by: ashish-jabble --- requirements.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.sh b/requirements.sh index d2e8d83ec7..cb5bf4a3a0 100755 --- a/requirements.sh +++ b/requirements.sh @@ -207,6 +207,8 @@ if [[ $YUM_PLATFORM = true ]]; then set -e make make install + else + make install fi cd $fledge_location set -e From ccca75bc8bb779d0cc886f60b7a47fe4d430c2e5 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 25 Aug 2023 12:12:31 +0100 Subject: [PATCH 422/499] FOGL-7349 Add persistence of SQLiteInMemory between executions (#1152) * FOGL-7349 Add peristance of SQLite in memory database Signed-off-by: Mark Riddoch * Remove extra debug Signed-off-by: Mark Riddoch * Fix unit test cmakefiles to access new sqlite_common include file Signed-off-by: Mark Riddoch * Update following review comments Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- .../storage/sqlite/common/connection.cpp | 2 +- .../sqlite/common/include/connection.h | 17 ---- .../include/{common.h => sqlite_common.h} | 23 +++++ C/plugins/storage/sqlite/common/readings.cpp | 2 +- .../sqlite/common/readings_catalogue.cpp | 2 +- C/plugins/storage/sqlite/plugin.cpp | 2 +- C/plugins/storage/sqlitelb/CMakeLists.txt | 1 + .../storage/sqlitelb/common/connection.cpp | 2 +- .../storage/sqlitelb/common/include/common.h | 39 --------- .../sqlitelb/common/include/connection.h | 4 + .../common/include/connection_manager.h | 10 ++- .../storage/sqlitelb/common/readings.cpp | 2 +- C/plugins/storage/sqlitelb/plugin.cpp | 2 +- C/plugins/storage/sqlitememory/CMakeLists.txt | 1 + C/plugins/storage/sqlitememory/connection.cpp | 79 +++++++++++++++++- .../storage/sqlitememory/include/connection.h | 9 +- .../sqlitememory/include/connection_manager.h | 9 ++ C/plugins/storage/sqlitememory/plugin.cpp | 44 +++++++++- docs/images/sqlitememory_config.png | Bin 39857 -> 50135 bytes docs/tuning_fledge.rst | 2 + tests/unit/C/cmake_sqliteM/CMakeLists.txt | 1 + tests/unit/C/cmake_sqlitelb/CMakeLists.txt | 1 + 22 files changed, 186 insertions(+), 68 deletions(-) rename C/plugins/storage/sqlite/common/include/{common.h => sqlite_common.h} (56%) delete mode 100644 C/plugins/storage/sqlitelb/common/include/common.h diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index 6540b8581d..e5ed8e5bf3 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -7,9 +7,9 @@ * * Author: Massimiliano Pinto */ +#include #include #include -#include #include #include diff --git a/C/plugins/storage/sqlite/common/include/connection.h b/C/plugins/storage/sqlite/common/include/connection.h index 35d1ebac1c..89778eff83 100644 --- a/C/plugins/storage/sqlite/common/include/connection.h +++ b/C/plugins/storage/sqlite/common/include/connection.h @@ -23,28 +23,11 @@ #define TRACK_CONNECTION_USER 0 // Set to 1 to get dianositcs about connection pool use -#define _DB_NAME "/fledge.db" -#define READINGS_DB_NAME_BASE "readings" #define READINGS_DB_FILE_NAME "/" READINGS_DB_NAME_BASE "_1.db" #define READINGS_DB READINGS_DB_NAME_BASE "_1" #define READINGS_TABLE "readings" #define READINGS_TABLE_MEM READINGS_TABLE "_1" -#define LEN_BUFFER_DATE 100 -#define F_TIMEH24_S "%H:%M:%S" -#define F_DATEH24_S "%Y-%m-%d %H:%M:%S" -#define F_DATEH24_M "%Y-%m-%d %H:%M" -#define F_DATEH24_H "%Y-%m-%d %H" -// This is the default datetime format in Fledge: 2018-05-03 18:15:00.622 -#define F_DATEH24_MS "%Y-%m-%d %H:%M:%f" -// Format up to seconds -#define F_DATEH24_SEC "%Y-%m-%d %H:%M:%S" -#define SQLITE3_NOW "strftime('%Y-%m-%d %H:%M:%f', 'now', 'localtime')" -// The default precision is milliseconds, it adds microseconds and timezone -#define SQLITE3_NOW_READING "strftime('%Y-%m-%d %H:%M:%f000+00:00', 'now')" -#define SQLITE3_FLEDGE_DATETIME_TYPE "DATETIME" - -#define DB_CONFIGURATION "PRAGMA busy_timeout = 5000; PRAGMA cache_size = -4000; PRAGMA journal_mode = WAL; PRAGMA secure_delete = off; PRAGMA journal_size_limit = 4096000;" // Set plugin name for log messages #ifndef PLUGIN_LOG_NAME diff --git a/C/plugins/storage/sqlite/common/include/common.h b/C/plugins/storage/sqlite/common/include/sqlite_common.h similarity index 56% rename from C/plugins/storage/sqlite/common/include/common.h rename to C/plugins/storage/sqlite/common/include/sqlite_common.h index a6c168dd9e..c55db37077 100644 --- a/C/plugins/storage/sqlite/common/include/common.h +++ b/C/plugins/storage/sqlite/common/include/sqlite_common.h @@ -23,6 +23,29 @@ #include #include +#define _DB_NAME "/fledge.db" +#define READINGS_DB_NAME_BASE "readings" + +#define DB_CONFIGURATION "PRAGMA busy_timeout = 5000; PRAGMA cache_size = -4000; PRAGMA journal_mode = WAL; PRAGMA secure_delete = off; PRAGMA journal_size_limit = 4096000;" + +#define LEN_BUFFER_DATE 100 +#define F_TIMEH24_S "%H:%M:%S" +#define F_DATEH24_S "%Y-%m-%d %H:%M:%S" +#define F_DATEH24_M "%Y-%m-%d %H:%M" +#define F_DATEH24_H "%Y-%m-%d %H" +// This is the default datetime format in Fledge: 2018-05-03 18:15:00.622 +#define F_DATEH24_MS "%Y-%m-%d %H:%M:%f" +// Format up to seconds +#define F_DATEH24_SEC "%Y-%m-%d %H:%M:%S" +#define SQLITE3_NOW "strftime('%Y-%m-%d %H:%M:%f', 'now', 'localtime')" +// The default precision is milliseconds, it adds microseconds and timezone +#define SQLITE3_NOW_READING "strftime('%Y-%m-%d %H:%M:%f000+00:00', 'now')" +#define SQLITE3_FLEDGE_DATETIME_TYPE "DATETIME" + +#define STORAGE_PURGE_RETAIN_ANY 0x0001U +#define STORAGE_PURGE_RETAIN_ALL 0x0002U +#define STORAGE_PURGE_SIZE 0x0004U + static std::map sqliteDateFormat = { {"HH24:MI:SS", F_TIMEH24_S}, diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index ff7171c2cd..fa7fc27d31 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -9,9 +9,9 @@ */ #include +#include #include #include -#include #include #include #include diff --git a/C/plugins/storage/sqlite/common/readings_catalogue.cpp b/C/plugins/storage/sqlite/common/readings_catalogue.cpp index fc4acfade1..5427a27e4f 100644 --- a/C/plugins/storage/sqlite/common/readings_catalogue.cpp +++ b/C/plugins/storage/sqlite/common/readings_catalogue.cpp @@ -17,7 +17,7 @@ #include #include #include -#include +#include #include "readings_catalogue.h" #include diff --git a/C/plugins/storage/sqlite/plugin.cpp b/C/plugins/storage/sqlite/plugin.cpp index 7bc1b2ebe6..506e3ab1cf 100644 --- a/C/plugins/storage/sqlite/plugin.cpp +++ b/C/plugins/storage/sqlite/plugin.cpp @@ -7,7 +7,7 @@ * * Author: Massimiliano Pinto */ -#include +#include #include #include #include diff --git a/C/plugins/storage/sqlitelb/CMakeLists.txt b/C/plugins/storage/sqlitelb/CMakeLists.txt index ca1c5a900c..3b27455980 100644 --- a/C/plugins/storage/sqlitelb/CMakeLists.txt +++ b/C/plugins/storage/sqlitelb/CMakeLists.txt @@ -16,6 +16,7 @@ file(GLOB SOURCES ./common/*.cpp ../sqlite/schema/*.cpp *.cpp) include_directories(./include) include_directories(./common/include) include_directories(../sqlite/schema/include) +include_directories(../sqlite/common/include) include_directories(../../../common/include) include_directories(../../../services/common/include) include_directories(../common/include) diff --git a/C/plugins/storage/sqlitelb/common/connection.cpp b/C/plugins/storage/sqlitelb/common/connection.cpp index f4d4c5ff59..d37d52d0aa 100644 --- a/C/plugins/storage/sqlitelb/common/connection.cpp +++ b/C/plugins/storage/sqlitelb/common/connection.cpp @@ -9,7 +9,7 @@ */ #include #include -#include +#include #include #ifndef MEMORY_READING_PLUGIN #include diff --git a/C/plugins/storage/sqlitelb/common/include/common.h b/C/plugins/storage/sqlitelb/common/include/common.h deleted file mode 100644 index a6c168dd9e..0000000000 --- a/C/plugins/storage/sqlitelb/common/include/common.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifndef _COMMON_CONNECTION_H -#define _COMMON_CONNECTION_H - -#include -#include -#include -#include "rapidjson/document.h" -#include "rapidjson/writer.h" -#include "rapidjson/stringbuffer.h" -#include "rapidjson/error/error.h" -#include "rapidjson/error/en.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static std::map sqliteDateFormat = { - {"HH24:MI:SS", - F_TIMEH24_S}, - {"YYYY-MM-DD HH24:MI:SS.MS", - F_DATEH24_MS}, - {"YYYY-MM-DD HH24:MI:SS", - F_DATEH24_S}, - {"YYYY-MM-DD HH24:MI", - F_DATEH24_M}, - {"YYYY-MM-DD HH24", - F_DATEH24_H}, - {"", ""} - }; -#endif diff --git a/C/plugins/storage/sqlitelb/common/include/connection.h b/C/plugins/storage/sqlitelb/common/include/connection.h index 448790797b..b2afe25279 100644 --- a/C/plugins/storage/sqlitelb/common/include/connection.h +++ b/C/plugins/storage/sqlitelb/common/include/connection.h @@ -119,6 +119,10 @@ class Connection { bool getNow(std::string& Now); unsigned int purgeReadingsAsset(const std::string& asset); bool vacuum(); +#ifdef MEMORY_READING_PLUGIN + bool loadDatabase(const std::string& filname); + bool saveDatabase(const std::string& filname); +#endif private: #ifndef MEMORY_READING_PLUGIN diff --git a/C/plugins/storage/sqlitelb/common/include/connection_manager.h b/C/plugins/storage/sqlitelb/common/include/connection_manager.h index eda1fb06db..bba0ee42b9 100644 --- a/C/plugins/storage/sqlitelb/common/include/connection_manager.h +++ b/C/plugins/storage/sqlitelb/common/include/connection_manager.h @@ -37,7 +37,13 @@ class ConnectionManager { void setVacuumInterval(long hours) { m_vacuumInterval = 60 * 60 * hours; }; - + void setPersist(bool persist, const std::string& filename = "") + { + m_persist = persist; + m_filename = filename; + } + bool persist() { return m_persist; }; + std::string filename() { return m_filename; }; protected: ConnectionManager(); @@ -54,6 +60,8 @@ class ConnectionManager { bool m_shutdown; std::thread *m_background; long m_vacuumInterval; + bool m_persist; + std::string m_filename; }; #endif diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index 32219c417a..62b3c5a41b 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -11,7 +11,7 @@ #include #include #include -#include +#include #include #include diff --git a/C/plugins/storage/sqlitelb/plugin.cpp b/C/plugins/storage/sqlitelb/plugin.cpp index 10a4a1a973..87dfe8f9b6 100644 --- a/C/plugins/storage/sqlitelb/plugin.cpp +++ b/C/plugins/storage/sqlitelb/plugin.cpp @@ -7,7 +7,7 @@ * * Author: Massimiliano Pinto */ -#include +#include #include #include #include diff --git a/C/plugins/storage/sqlitememory/CMakeLists.txt b/C/plugins/storage/sqlitememory/CMakeLists.txt index 259269aefc..e5d9b3f135 100644 --- a/C/plugins/storage/sqlitememory/CMakeLists.txt +++ b/C/plugins/storage/sqlitememory/CMakeLists.txt @@ -23,6 +23,7 @@ include_directories(../../../thirdparty/rapidjson/include) # Add sqlitelb plugin header files include_directories(../sqlitelb/include) include_directories(../sqlitelb/common/include) +include_directories(../sqlite/common/include) link_directories(${PROJECT_BINARY_DIR}/../../../lib) diff --git a/C/plugins/storage/sqlitememory/connection.cpp b/C/plugins/storage/sqlitememory/connection.cpp index 608fa75fbd..a95a217f86 100644 --- a/C/plugins/storage/sqlitememory/connection.cpp +++ b/C/plugins/storage/sqlitememory/connection.cpp @@ -10,7 +10,9 @@ #include #include -#include +#include +#include +#include /** * SQLite3 storage plugin for Fledge @@ -111,3 +113,78 @@ bool Connection::vacuum() { return true; } + +/** + * Load the in memory database from a file backup + * + * @param filename The name of the file to restore from + * @return bool Success or failure of the backup + */ +bool Connection::loadDatabase(const string& filename) +{ +int rc; +sqlite3 *file; +sqlite3_backup *backup; + + string pathname = getDataDir() + "/"; + pathname.append(filename); + pathname.append(".db"); + if (access(pathname.c_str(), R_OK) != 0) + { + Logger::getLogger()->warn("Persisted database %s does not exist", + pathname.c_str()); + return false; + } + if ((rc = sqlite3_open(pathname.c_str(), &file)) == SQLITE_OK) + { + if (backup = sqlite3_backup_init(dbHandle, READINGS_TABLE_MEM, file, "main")) + { + (void)sqlite3_backup_step(backup, -1); + (void)sqlite3_backup_finish(backup); + Logger::getLogger()->info("Reloaded persisted data to in-memory database"); + } + rc = sqlite3_errcode(dbHandle); + + (void)sqlite3_close(file); + } + return rc == SQLITE_OK; +} + +/** + * Backup the in memory database to a file + * + * @param filename The name of the file to backup to + * @return bool Success or failure of the backup + */ +bool Connection::saveDatabase(const string& filename) +{ +int rc; +sqlite3 *file; +sqlite3_backup *backup; + + string pathname = getDataDir() + "/"; + pathname.append(filename); + pathname.append(".db"); + unlink(pathname.c_str()); + if ((rc = sqlite3_open(pathname.c_str(), &file)) == SQLITE_OK) + { + if (backup = sqlite3_backup_init(file, "main", dbHandle, READINGS_TABLE_MEM)) + { + rc = sqlite3_backup_step(backup, -1); + (void)sqlite3_backup_finish(backup); + Logger::getLogger()->info("Persisted data from in-memory database to %s", pathname.c_str()); + } + rc = sqlite3_errcode(file); + if (rc != SQLITE_OK) + { + Logger::getLogger()->warn("Persisting in-memory database failed: %s", sqlite3_errmsg(file)); + } + + (void)sqlite3_close(file); + } + else + { + Logger::getLogger()->warn("Failed to open database %s to persist in-memory data", pathname.c_str()); + } + return rc == SQLITE_OK; +} diff --git a/C/plugins/storage/sqlitememory/include/connection.h b/C/plugins/storage/sqlitememory/include/connection.h index 974c4d7821..44fc47d3f4 100644 --- a/C/plugins/storage/sqlitememory/include/connection.h +++ b/C/plugins/storage/sqlitememory/include/connection.h @@ -15,6 +15,11 @@ #include #include +WARNING: THIS FILE IS NOT USED + +#define READINGS_TABLE "readings" +#define READINGS_TABLE_MEM READINGS_TABLE "_1" + class Connection { public: Connection(); @@ -30,7 +35,9 @@ class Connection { void setTrace(bool flag) { m_logSQL = flag; }; static bool formatDate(char *formatted_date, size_t formatted_date_size, const char *date); unsigned int purgeReadingsAsset(const std::string& asset); - bool vacuum() { return true; }; + bool vacuum(); + bool loadDatabase(const std::string& filname); + bool saveDatabase(const std::string& filname); private: int SQLexec(sqlite3 *db, const char *sql, int (*callback)(void*,int,char**,char**), diff --git a/C/plugins/storage/sqlitememory/include/connection_manager.h b/C/plugins/storage/sqlitememory/include/connection_manager.h index b2e527235b..24cbf04c04 100644 --- a/C/plugins/storage/sqlitememory/include/connection_manager.h +++ b/C/plugins/storage/sqlitememory/include/connection_manager.h @@ -32,6 +32,13 @@ class MemConnectionManager { { return &lastError; } + void setPersist(bool persist, const std::string& filename = "") + { + m_persist = persist; + m_filename = filename; + } + bool persist() { return m_persist; }; + std::string filename() { return m_filename; }; private: MemConnectionManager(); @@ -43,6 +50,8 @@ class MemConnectionManager { std::mutex errorLock; PLUGIN_ERROR lastError; bool m_trace; + bool m_persist; + std::string m_filename; }; #endif diff --git a/C/plugins/storage/sqlitememory/plugin.cpp b/C/plugins/storage/sqlitememory/plugin.cpp index 88cf174863..f44b443148 100644 --- a/C/plugins/storage/sqlitememory/plugin.cpp +++ b/C/plugins/storage/sqlitememory/plugin.cpp @@ -7,7 +7,7 @@ * * Author: Massimiliano Pinto */ - +#include #include #include #include @@ -25,7 +25,6 @@ #include #include #include -#include using namespace std; using namespace rapidjson; @@ -42,6 +41,21 @@ const char *default_config = QUOTE({ "default" : "5", "displayName" : "Pool Size", "order" : "1" + }, + "filename" : { + "description" : "The name of the file to which the in-memory database should be persisted", + "type" : "string", + "default" : "inmemory", + "displayName" : "Persist File", + "order" : "3", + "validity": "persist == \"true\"" + }, + "persist" : { + "description" : "Enable the persistence of the in-memory database between executions", + "type" : "boolean", + "default" : "false", + "displayName" : "Persist Data", + "order" : "2" } }); @@ -83,6 +97,27 @@ int poolSize = 5; poolSize = strtol(category->getValue("poolSize").c_str(), NULL, 10); } manager->growPool(poolSize); + if (category->itemExists("persist")) + { + string p = category->getValue("persist"); + if (p.compare("true") == 0 && category->itemExists("filename")) + { + manager->setPersist(true, category->getValue("filename")); + } + else + { + manager->setPersist(false); + } + } + else + { + manager->setPersist(false); + } + if (manager->persist()) + { + Connection *connection = manager->allocate(); + connection->loadDatabase(manager->filename()); + } return manager; } /** @@ -190,6 +225,11 @@ bool plugin_shutdown(PLUGIN_HANDLE handle) { ConnectionManager *manager = (ConnectionManager *)handle; + if (manager->persist()) + { + Connection *connection = manager->allocate(); + connection->saveDatabase(manager->filename()); + } manager->shutdown(); return true; } diff --git a/docs/images/sqlitememory_config.png b/docs/images/sqlitememory_config.png index f1da7f4e80483ea7c7dcd55d948f4048ce616758..59c527d1c2937a0827973a19ac388f16cdf229c5 100644 GIT binary patch literal 50135 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 E59!>b>;M1& literal 39857 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 diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index ab297f28bb..fdfaaccb6b 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -315,4 +315,6 @@ The storage plugin configuration can be found in the *Advanced* section of the * Although the pool size denotes the number of parallel operations that can take place, database locking considerations may reduce the number of actual operations in progress at any point in time. + - **Persist Data**: Control the persisting of the in-memory database on shutdown. If enabled the in-memory database will be persisted on shutdown of Fledge and reloaded when Fledge is next started. Selecting this option will slow down the shutdown and startup processing for Fledge. + - **Persist File**: This defines the name of the file to which the in-memory database will be persisted. diff --git a/tests/unit/C/cmake_sqliteM/CMakeLists.txt b/tests/unit/C/cmake_sqliteM/CMakeLists.txt index 0acdcdbfb1..71d2683926 100644 --- a/tests/unit/C/cmake_sqliteM/CMakeLists.txt +++ b/tests/unit/C/cmake_sqliteM/CMakeLists.txt @@ -18,6 +18,7 @@ include_directories(../../../../C/services/common/include) include_directories(../../../../C/plugins/storage/common/include) include_directories(../../../../C/plugins/storage/sqlitelb/include) include_directories(../../../../C/plugins/storage/sqlitelb/common/include) +include_directories(../../../../C/plugins/storage/sqlite/common/include) # Check Sqlite3 required version set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/tests/unit/C/cmake_sqlitelb/CMakeLists.txt b/tests/unit/C/cmake_sqlitelb/CMakeLists.txt index 7ce3b95857..568e90eba9 100644 --- a/tests/unit/C/cmake_sqlitelb/CMakeLists.txt +++ b/tests/unit/C/cmake_sqlitelb/CMakeLists.txt @@ -17,6 +17,7 @@ include_directories(../../../../C/plugins/storage/common/include) include_directories(../../../../C/plugins/storage/sqlite/schema/include) include_directories(../../../../C/plugins/storage/sqlitelb/include) include_directories(../../../../C/plugins/storage/sqlitelb/common/include) +include_directories(../../../../C/plugins/storage/sqlite/common/include) # Check Sqlite3 required version set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}") From f493d693ba86ff67c78b16d2013369963f946fc4 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Wed, 30 Aug 2023 17:57:14 +0530 Subject: [PATCH 423/499] FOGL-7839: Improvements in valgrind test to check memory leakage (#1131) --- tests/system/memory_leak/scripts/setup | 19 ++++++++++++++++--- tests/system/memory_leak/test_memcheck.sh | 16 +++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/tests/system/memory_leak/scripts/setup b/tests/system/memory_leak/scripts/setup index 832f7a4e6c..f95fefbade 100755 --- a/tests/system/memory_leak/scripts/setup +++ b/tests/system/memory_leak/scripts/setup @@ -3,6 +3,7 @@ set -e BRANCH=${2:-develop} # here Branch means branch of fledge repository that is needed to be scanned through valgrind, default is develop +COLLECT_FILES=${3} OS_NAME=`(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g')` ID=$(cat /etc/os-release | grep -w ID | cut -f2 -d"=" | tr -d '"') @@ -40,17 +41,29 @@ valgrind_conf=' --tool=memcheck --leak-check=full --show-leak-kinds=all' psouth_c=${FLEDGE_ROOT}/scripts/services/south_c echo $psouth_c sudo sed -i 's#/usr/local/fledge#'"$FLEDGE_ROOT"'#g' ${psouth_c} -sudo sed -i '/.\/fledge.services.south.*/s/^/valgrind --log-file=\/tmp\/south_valgrind.log '"$valgrind_conf"' /' ${psouth_c} +if [[ "${COLLECT_FILES}" == "LOGS" ]]; then + sudo sed -i '/.\/fledge.services.south.*/s/^/valgrind --log-file=\/tmp\/south_valgrind.log '"$valgrind_conf"' /' ${psouth_c} +else + sudo sed -i '/.\/fledge.services.south.*/s/^/valgrind --xml=yes --xml-file=\/tmp\/south_valgrind_%p.xml --track-origins=yes '"$valgrind_conf"' /' ${psouth_c} +fi pnorth_C=${FLEDGE_ROOT}/scripts/services/north_C echo $pnorth_C sudo sed -i 's#/usr/local/fledge#'"$FLEDGE_ROOT"'#g' ${pnorth_C} -sudo sed -i '/.\/fledge.services.north.*/s/^/valgrind --log-file=\/tmp\/north_valgrind.log '"$valgrind_conf"' /' ${pnorth_C} +if [[ "${COLLECT_FILES}" == "LOGS" ]]; then + sudo sed -i '/.\/fledge.services.north.*/s/^/valgrind --log-file=\/tmp\/north_valgrind.log '"$valgrind_conf"' /' ${pnorth_C} +else + sudo sed -i '/.\/fledge.services.north.*/s/^/valgrind --xml=yes --xml-file=\/tmp\/north_valgrind_%p.xml --track-origins=yes '"$valgrind_conf"' /' ${pnorth_C} +fi pstorage=${FLEDGE_ROOT}/scripts/services/storage echo $pstorage sudo sed -i 's#/usr/local/fledge#'"$FLEDGE_ROOT"'#g' ${pstorage} -sudo sed -i '/\${storageExec} \"\$@\"/s/^/valgrind --log-file=\/tmp\/storage_valgrind.log '"$valgrind_conf"' /' ${pstorage} +if [[ "${COLLECT_FILES}" == "LOGS" ]]; then + sudo sed -i '/\${storageExec} \"\$@\"/s/^/valgrind --log-file=\/tmp\/storage_valgrind.log '"$valgrind_conf"' /' ${pstorage} +else + sudo sed -i '/\${storageExec} \"\$@\"/s/^/valgrind --xml=yes --xml-file=\/tmp\/storage_valgrind_%p.xml --track-origins=yes '"$valgrind_conf"' /' ${pstorage} +fi # cloning plugins based on parameters passed to the script, Currently only installing sinusoid diff --git a/tests/system/memory_leak/test_memcheck.sh b/tests/system/memory_leak/test_memcheck.sh index 80afce0da1..5d9d5308a4 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -9,6 +9,14 @@ export FLEDGE_ROOT=$(pwd)/fledge FLEDGE_TEST_BRANCH="$1" # here fledge_test_branch means branch of fledge repository that is needed to be scanned, default is develop +COLLECT_FILES="${2:-LOGS}" + +if [[ ${COLLECT_FILES} != @(LOGS|XML|) ]] +then + echo "Invalid argument ${COLLECT_FILES}. Please provide valid arguments: XML or LOGS." + exit 1 +fi + cleanup(){ # Removing temporary files, fledge and its plugin repository cloned by previous build of the Job echo "Removing Cloned repository and log files" @@ -17,7 +25,7 @@ cleanup(){ # Setting up Fledge and installing its plugin setup(){ - ./scripts/setup "fledge-south-sinusoid fledge-south-random" "${FLEDGE_TEST_BRANCH}" + ./scripts/setup "fledge-south-sinusoid fledge-south-random" "${FLEDGE_TEST_BRANCH}" "${COLLECT_FILES}" } reset_fledge(){ @@ -114,9 +122,11 @@ collect_data(){ generate_valgrind_logs(){ echo 'Creating reports directory'; - mkdir -p reports/test1 ; ls -lrth + mkdir -p reports/ ; ls -lrth echo 'copying reports ' - cp -rf /tmp/*valgrind*.log /tmp/*valgrind*.xml reports/test1/. && echo 'copied' + extension="xml" + if [[ "${COLLECT_FILES}" == "LOGS" ]]; then extension="log"; fi + cp -rf /tmp/*valgrind*.${extension} reports/. && echo 'copied' } cleanup From 0d4a01e53a3067bde47af3271ea51f99471782f8 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 1 Sep 2023 14:23:10 +0100 Subject: [PATCH 424/499] FOGL-8061 Add indexes to SQLiteInMemory plugin for user_ts and assert/user_ts (#1155) * Add indexes Signed-off-by: Mark Riddoch * Experimental optimisations Signed-off-by: Mark Riddoch * Update audit log Signed-off-by: Mark Riddoch * Don't accumulte rows retained Signed-off-by: Mark Riddoch * Fix tests Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/include/logger.h | 1 + C/common/logger.cpp | 12 ++++ .../storage/sqlitelb/common/readings.cpp | 72 ++++++++++++------- C/plugins/storage/sqlitememory/connection.cpp | 14 ++++ python/fledge/tasks/purge/purge.py | 16 ++++- .../python/fledge/tasks/purge/test_purge.py | 2 +- 6 files changed, 87 insertions(+), 30 deletions(-) diff --git a/C/common/include/logger.h b/C/common/include/logger.h index 1cffa457a3..e64596fcb3 100755 --- a/C/common/include/logger.h +++ b/C/common/include/logger.h @@ -40,6 +40,7 @@ class Logger { std::string *format(const std::string& msg, va_list ap); static Logger *instance; std::string levelString; + int m_level; }; #endif diff --git a/C/common/logger.cpp b/C/common/logger.cpp index dc0126e443..7a21e42262 100755 --- a/C/common/logger.cpp +++ b/C/common/logger.cpp @@ -72,18 +72,22 @@ void Logger::setMinLevel(const string& level) { setlogmask(LOG_UPTO(LOG_INFO)); levelString = level; + m_level = LOG_INFO; } else if (level.compare("warning") == 0) { setlogmask(LOG_UPTO(LOG_WARNING)); levelString = level; + m_level = LOG_WARNING; } else if (level.compare("debug") == 0) { setlogmask(LOG_UPTO(LOG_DEBUG)); levelString = level; + m_level = LOG_DEBUG; } else if (level.compare("error") == 0) { setlogmask(LOG_UPTO(LOG_ERR)); levelString = level; + m_level = LOG_ERR; } else { error("Request to set unsupported log level %s", level.c_str()); @@ -92,6 +96,10 @@ void Logger::setMinLevel(const string& level) void Logger::debug(const string& msg, ...) { + if (m_level == LOG_ERR || m_level == LOG_WARNING || m_level == LOG_INFO) + { + return; + } va_list args; va_start(args, msg); string *fmt = format(msg, args); @@ -111,6 +119,10 @@ void Logger::printLongString(const string& s) void Logger::info(const string& msg, ...) { + if (m_level == LOG_ERR || m_level == LOG_WARNING) + { + return; + } va_list args; va_start(args, msg); string *fmt = format(msg, args); diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index 62b3c5a41b..61e325b7b5 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -1992,19 +1992,22 @@ unsigned int Connection::purgeReadings(unsigned long age, unsentPurged = deletedRows; } + gettimeofday(&endTv, NULL); + unsigned long duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; + ostringstream convert; convert << "{ \"removed\" : " << deletedRows << ", "; convert << " \"unsentPurged\" : " << unsentPurged << ", "; convert << " \"unsentRetained\" : " << unsentRetained << ", "; - convert << " \"readings\" : " << numReadings << " }"; + convert << " \"readings\" : " << numReadings << ", "; + convert << " \"method\" : \"time\", "; + convert << " \"duration\" : " << duration << " }"; result = convert.str(); //logger->debug("Purge result=%s", result.c_str()); - gettimeofday(&endTv, NULL); - unsigned long duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; logger->info("Purge process complete in %d blocks in %lduS", blocks, duration); Logger::getLogger()->debug("%s - age :%lu: flag_retain :%x: sent :%lu: result :%s:", __FUNCTION__, age, flags, flag_retain, result.c_str() ); @@ -2028,10 +2031,12 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, unsigned long rowsAffected; unsigned long deletePoint; bool flag_retain; + struct timeval startTv, endTv; Logger *logger = Logger::getLogger(); + gettimeofday(&startTv, NULL); flag_retain = false; if ( (flags & STORAGE_PURGE_RETAIN_ANY) || (flags & STORAGE_PURGE_RETAIN_ALL) ) @@ -2083,6 +2088,37 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, rowsAffected = 0; deletedRows = 0; bool rowsAvailableToPurge = true; + + // Create the prepared statements + SQLBuffer sqlBuffer; + sqlBuffer.append("select min(id) from " READINGS_DB_NAME_BASE "." READINGS_TABLE ";"); + const char *idquery = sqlBuffer.coalesce(); + + rc = sqlite3_prepare_v2(dbHandle, idquery, -1, &idStmt, NULL); + if (rc != SQLITE_OK) + { + raiseError("purgeReadingsByRows", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", idquery); + delete[] idquery; + return 0; + } + delete[] idquery; + + SQLBuffer sql; + sql.append("delete from " READINGS_DB_NAME_BASE "." READINGS_TABLE " where id <= ? ;"); + const char *delquery = sql.coalesce(); + + rc = sqlite3_prepare_v2(dbHandle, delquery, strlen(delquery), &stmt, NULL); + + if (rc != SQLITE_OK) + { + raiseError("purgeReadingsByRows", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", delquery); + delete[] delquery; + return 0; + } + delete[] delquery; + do { if (rowcount <= rows) @@ -2091,19 +2127,12 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, rowsAvailableToPurge = false; break; } - - SQLBuffer sqlBuffer; - sqlBuffer.append("select min(id) from " READINGS_DB_NAME_BASE "." READINGS_TABLE ";"); - const char *query = sqlBuffer.coalesce(); - - rc = sqlite3_prepare_v2(dbHandle, query, -1, &idStmt, NULL); if (SQLstep(idStmt) == SQLITE_ROW) { minId = sqlite3_column_int(idStmt, 0); } - delete[] query; sqlite3_clear_bindings(idStmt); sqlite3_reset(idStmt); @@ -2129,28 +2158,12 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, } { - SQLBuffer sql; logger->info("RowCount %lu, Max Id %lu, min Id %lu, delete point %lu", rowcount, maxId, minId, deletePoint); - sql.append("delete from " READINGS_DB_NAME_BASE "." READINGS_TABLE " where id <= ? ;"); - const char *query = sql.coalesce(); - - rc = sqlite3_prepare_v2(dbHandle, query, strlen(query), &stmt, NULL); - - if (rc != SQLITE_OK) - { - raiseError("purgeReadingsByRows", sqlite3_errmsg(dbHandle)); - Logger::getLogger()->error("SQL statement: %s", query); - return 0; - } - delete[] query; } sqlite3_bind_int(stmt, 1,(unsigned long) deletePoint); { - //unique_lock lck(db_mutex); -// if (m_writeAccessOngoing) db_cv.wait(lck); - // Exec DELETE query: no callback, no resultset rc = SQLstep(stmt); if (rc == SQLITE_DONE) @@ -2193,12 +2206,17 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, unsentRetained = numReadings - rows; } + gettimeofday(&endTv, NULL); + unsigned long duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; + ostringstream convert; convert << "{ \"removed\" : " << deletedRows << ", "; convert << " \"unsentPurged\" : " << unsentPurged << ", "; convert << " \"unsentRetained\" : " << unsentRetained << ", "; - convert << " \"readings\" : " << numReadings << " }"; + convert << " \"readings\" : " << numReadings << ", "; + convert << " \"method\" : \"rows\", "; + convert << " \"duration\" : " << duration << " }"; result = convert.str(); diff --git a/C/plugins/storage/sqlitememory/connection.cpp b/C/plugins/storage/sqlitememory/connection.cpp index a95a217f86..c4757f6568 100644 --- a/C/plugins/storage/sqlitememory/connection.cpp +++ b/C/plugins/storage/sqlitememory/connection.cpp @@ -52,6 +52,8 @@ Connection::Connection() ");"; const char * createReadingsFk = "CREATE INDEX fki_" READINGS_TABLE_MEM "_fk1 ON " READINGS_TABLE_MEM " (asset_code);"; + const char * createReadingsIdx1 = "CREATE INDEX ix1_" READINGS_TABLE_MEM " ON " READINGS_TABLE_MEM " (asset_code, user_ts desc);"; + const char * createReadingsIdx2 = "CREATE INDEX ix2_" READINGS_TABLE_MEM " ON " READINGS_TABLE_MEM " (user_ts);"; // Allow usage of URI for filename sqlite3_config(SQLITE_CONFIG_URI, 1); @@ -101,6 +103,18 @@ Connection::Connection() NULL, NULL); + // Idx1 + rc = sqlite3_exec(dbHandle, + createReadingsIdx1, + NULL, + NULL, + NULL); + // Idx2 + rc = sqlite3_exec(dbHandle, + createReadingsIdx2, + NULL, + NULL, + NULL); } } diff --git a/python/fledge/tasks/purge/purge.py b/python/fledge/tasks/purge/purge.py index 7adb0155ff..3183407786 100644 --- a/python/fledge/tasks/purge/purge.py +++ b/python/fledge/tasks/purge/purge.py @@ -131,6 +131,8 @@ async def purge_data(self, config): total_rows_removed = 0 unsent_rows_removed = 0 unsent_retained = 0 + duration = 0 + method = None start_time = time.strftime('%Y-%m-%d %H:%M:%S.%s', time.localtime(time.time())) if config['retainUnsent']['value'].lower() == "purge unsent": @@ -205,6 +207,8 @@ async def purge_data(self, config): total_rows_removed = result['removed'] unsent_rows_removed = result['unsentPurged'] unsent_retained = result['unsentRetained'] + duration += result['duration'] + method = result['method'] except ValueError: self._logger.error("purge_data - Configuration item age {} should be integer!".format( config['age']['value'])) @@ -219,7 +223,13 @@ async def purge_data(self, config): if result is not None: total_rows_removed += result['removed'] unsent_rows_removed += result['unsentPurged'] - unsent_retained += result['unsentRetained'] + unsent_retained = result['unsentRetained'] + duration += result['duration'] + if method is None: + method = result['method'] + else: + method += " and " + method += result['method'] except ValueError: self._logger.error("purge_data - Configuration item size {} should be integer!".format( config['size']['value'])) @@ -235,7 +245,9 @@ async def purge_data(self, config): "end_time": end_time, "rowsRemoved": total_rows_removed, "unsentRowsRemoved": unsent_rows_removed, - "rowsRetained": unsent_retained + "rowsRetained": unsent_retained, + "duration": duration, + "method": method }) else: self._logger.info("No rows purged") diff --git a/tests/unit/python/fledge/tasks/purge/test_purge.py b/tests/unit/python/fledge/tasks/purge/test_purge.py index 88b525383b..d211667e0d 100644 --- a/tests/unit/python/fledge/tasks/purge/test_purge.py +++ b/tests/unit/python/fledge/tasks/purge/test_purge.py @@ -106,7 +106,7 @@ async def test_set_configuration(self): async def store_purge(self, **kwargs): if kwargs.get('age') == '-1' or kwargs.get('size') == '-1': raise StorageServerError(400, "Bla", "Some Error") - return {"readings": 10, "removed": 1, "unsentPurged": 2, "unsentRetained": 7} + return {"readings": 10, "removed": 1, "unsentPurged": 2, "unsentRetained": 7, "duration": 100, "method":"mock"} config = {"purgeAgeSize": {"retainUnsent": {"value": "purge unsent"}, "age": {"value": "72"}, "size": {"value": "20"}}, "purgeAge": {"retainUnsent": {"value": "purge unsent"}, "age": {"value": "72"}, "size": {"value": "0"}}, From e2f0c43a83251cd447ecdc8c8787a60825661bd1 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 6 Sep 2023 11:44:02 +0100 Subject: [PATCH 425/499] Add method and diration to all storage plugin puirge results (#1159) Signed-off-by: Mark Riddoch --- C/plugins/storage/postgres/connection.cpp | 30 +++++++++++++------- C/plugins/storage/sqlite/common/readings.cpp | 24 ++++++++++++---- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index 68d07c0173..dfe624e5c3 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -1683,7 +1683,9 @@ unsigned int Connection::purgeReadings(unsigned long age, unsigned int flags, u result = "{ \"removed\" : 0, "; result += " \"unsentPurged\" : 0, "; result += " \"unsentRetained\" : 0, "; - result += " \"readings\" : 0 }"; + result += " \"readings\" : 0, "; + result += " \"method\" : \"age\", "; + result += " \"duration\" : 0 }"; logger->info("Purge starting..."); gettimeofday(&startTv, NULL); @@ -1899,20 +1901,21 @@ unsigned int Connection::purgeReadings(unsigned long age, unsigned int flags, u ostringstream convert; + unsigned long duration; + gettimeofday(&endTv, NULL); + duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; + convert << "{ \"removed\" : " << deletedRows << ", "; convert << " \"unsentPurged\" : " << unsentPurged << ", "; convert << " \"unsentRetained\" : " << unsentRetained << ", "; - convert << " \"readings\" : " << numReadings << " }"; + convert << " \"readings\" : " << numReadings << ", "; + convert << " \"method\" : \"age\", "; + convert << " \"duration\" : " << duration << " }"; result = convert.str(); - { // Timing - unsigned long duration; - gettimeofday(&endTv, NULL); - duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; - duration = duration / 1000; // milliseconds - logger->info("Purge process complete in %d blocks in %ld milliseconds", blocks, duration); - } + duration = duration / 1000; // milliseconds + logger->info("Purge process complete in %d blocks in %ld milliseconds", blocks, duration); Logger::getLogger()->debug("%s - age :%lu: flag_retain :%x: sent :%lu: result :%s:", __FUNCTION__, age, flags, flag_retain, result.c_str() ); @@ -1984,6 +1987,7 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, unsigned long rowcount, minId, maxId; unsigned long rowsAffectedLastComand; unsigned long deletePoint; + struct timeval startTv, endTv; string sqlCommand; bool flag_retain; @@ -1992,6 +1996,7 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, Logger *logger = Logger::getLogger(); + gettimeofday(&startTv, NULL); flag_retain = false; if ( (flags & STORAGE_PURGE_RETAIN_ANY) || (flags & STORAGE_PURGE_RETAIN_ALL) ) @@ -2084,12 +2089,17 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, unsentRetained = numReadings - rows; } + gettimeofday(&endTv, NULL); + unsigned long duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; + ostringstream convert; convert << "{ \"removed\" : " << deletedRows << ", "; convert << " \"unsentPurged\" : " << unsentPurged << ", "; convert << " \"unsentRetained\" : " << unsentRetained << ", "; - convert << " \"readings\" : " << numReadings << " }"; + convert << " \"readings\" : " << numReadings << ", "; + convert << " \"method\" : \"rows\", "; + convert << " \"duration\" : " << duration << " }"; result = convert.str(); diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index fa7fc27d31..fd32b75eaa 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -1826,7 +1826,6 @@ struct timeval startTv, endTv; int blocks = 0; bool flag_retain; char *zErrMsg = NULL; - vector assetCodes; if (m_noReadings) @@ -1859,13 +1858,17 @@ vector assetCodes; { flag_retain = true; } + + Logger::getLogger()->debug("%s - flags %X flag_retain %d sent :%ld:", __FUNCTION__, flags, flag_retain, sent); // Prepare empty result result = "{ \"removed\" : 0, "; result += " \"unsentPurged\" : 0, "; result += " \"unsentRetained\" : 0, "; - result += " \"readings\" : 0 }"; + result += " \"readings\" : 0, "; + result += " \"method\" : \"rows\", "; + result += " \"duration\" : 0 }"; logger->info("Purge starting..."); gettimeofday(&startTv, NULL); @@ -2297,17 +2300,20 @@ vector assetCodes; unsentPurged = deletedRows; } + gettimeofday(&endTv, NULL); + unsigned long duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; + ostringstream convert; convert << "{ \"removed\" : " << deletedRows << ", "; convert << " \"unsentPurged\" : " << unsentPurged << ", "; convert << " \"unsentRetained\" : " << unsentRetained << ", "; - convert << " \"readings\" : " << numReadings << " }"; + convert << " \"readings\" : " << numReadings << ", "; + convert << " \"method\" : \"age\", "; + convert << " \"duration\" : " << duration << " }"; result = convert.str(); - gettimeofday(&endTv, NULL); - unsigned long duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; logger->info("Purge process complete in %d blocks in %lduS", blocks, duration); Logger::getLogger()->debug("%s - age :%lu: flag_retain :%x: sent :%lu: result '%s'", __FUNCTION__, age, flags, flag_retain, result.c_str() ); @@ -2331,6 +2337,7 @@ unsigned long limit = 0; string sql_cmd; vector assetCodes; bool flag_retain; +struct timeval startTv, endTv; // rowidCallback expects unsigned long @@ -2348,6 +2355,7 @@ bool flag_retain; return 0; } + gettimeofday(&startTv, NULL); ostringstream threadId; threadId << std::this_thread::get_id(); ReadingsCatalogue *readCatalogue = ReadingsCatalogue::getInstance(); @@ -2582,13 +2590,17 @@ bool flag_retain; unsentRetained = numReadings - rows; } + gettimeofday(&endTv, NULL); + unsigned long duration = (1000000 * (endTv.tv_sec - startTv.tv_sec)) + endTv.tv_usec - startTv.tv_usec; ostringstream convert; convert << "{ \"removed\" : " << deletedRows << ", "; convert << " \"unsentPurged\" : " << unsentPurged << ", "; convert << " \"unsentRetained\" : " << unsentRetained << ", "; - convert << " \"readings\" : " << numReadings << " }"; + convert << " \"readings\" : " << numReadings << ", "; + convert << " \"method\" : \"rows\", "; + convert << " \"duration\" : " << duration << " }"; result = convert.str(); From 116198abe376846044dcfbbd78f031304a2a952a Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 8 Sep 2023 13:00:41 +0530 Subject: [PATCH 426/499] variables and constants are allowed empty dict for type operation in POST entrypoint request Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index c6f121a48c..2576c9e74d 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -76,7 +76,7 @@ async def _get_destination(identifier): async def _check_parameters(payload, skip_required=False): if not skip_required: - required_keys = {"name", "description", "type", "destination", "constants", "variables"} + required_keys = {"name", "description", "type", "destination"} if not all(k in payload.keys() for k in required_keys): raise KeyError("{} required keys are missing in request payload.".format(required_keys)) final = {} @@ -156,20 +156,24 @@ async def _check_parameters(payload, skip_required=False): constants = payload.get('constants', None) if constants is not None: if not isinstance(constants, dict): - raise ValueError('constants should be an object.') - # TODO: need confirmation on validation - if not constants: + raise ValueError('constants should be dictionary.') + if not constants and _type == EntryPointType.WRITE.name.lower(): raise ValueError('constants should not be empty.') final['constants'] = constants + else: + if _type == EntryPointType.WRITE.name.lower(): + raise ValueError("For type write constants must have passed in payload and cannot have empty value.") variables = payload.get('variables', None) if variables is not None: if not isinstance(variables, dict): - raise ValueError('variables should be an object.') - # TODO: need confirmation on validation - if not variables: + raise ValueError('variables should be a dictionary.') + if not variables and _type == EntryPointType.WRITE.name.lower(): raise ValueError('variables should not be empty.') final['variables'] = variables + else: + if _type == EntryPointType.WRITE.name.lower(): + raise ValueError("For type write variables must have passed in payload and cannot have empty value.") allow = payload.get('allow', None) if allow is not None: @@ -208,14 +212,16 @@ async def create(request: web.Request) -> web.Response: insert_api_result = await storage.insert_into_tbl("control_api", api_insert_payload) if insert_api_result['rows_affected'] == 1: # add if any params data keys in control_api_parameters table - for k, v in payload['constants'].items(): - control_api_params_column_name = {"name": name, "parameter": k, "value": v, "constant": 't'} - api_params_insert_payload = PayloadBuilder().INSERT(**control_api_params_column_name).payload() - await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) - for k, v in payload['variables'].items(): - control_api_params_column_name = {"name": name, "parameter": k, "value": v, "constant": 'f'} - api_params_insert_payload = PayloadBuilder().INSERT(**control_api_params_column_name).payload() - await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) + if 'constants' in payload: + for k, v in payload['constants'].items(): + control_api_params_column_name = {"name": name, "parameter": k, "value": v, "constant": 't'} + api_params_insert_payload = PayloadBuilder().INSERT(**control_api_params_column_name).payload() + await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) + if 'variables' in payload: + for k, v in payload['variables'].items(): + control_api_params_column_name = {"name": name, "parameter": k, "value": v, "constant": 'f'} + api_params_insert_payload = PayloadBuilder().INSERT(**control_api_params_column_name).payload() + await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) # add if any users in control_api_acl table for u in payload['allow']: control_acl_column_name = {"name": name, "user": u} @@ -271,9 +277,9 @@ async def get_by_name(request: web.Request) -> web.Response: response[response['destination']] = response['destination_arg'] del response['destination_arg'] param_result = await storage.query_tbl_with_payload("control_api_parameters", payload) + constants = {} + variables = {} if param_result['rows']: - constants = {} - variables = {} for r in param_result['rows']: if r['constant'] == 't': constants[r['parameter']] = r['value'] @@ -281,6 +287,9 @@ async def get_by_name(request: web.Request) -> web.Response: variables[r['parameter']] = r['value'] response['constants'] = constants response['variables'] = variables + else: + response['constants'] = constants + response['variables'] = variables response['allow'] = "" acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) if acl_result['rows']: From ad341d367d354412f9a8d962f2d2a7a5358ff2e6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 8 Sep 2023 18:22:22 +0530 Subject: [PATCH 427/499] variables and constants are allowed empty dict for type operation in PUT entrypoint request Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 88 ++++++++++++++++--- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 2576c9e74d..af9f6da7e0 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -179,7 +179,7 @@ async def _check_parameters(payload, skip_required=False): if allow is not None: if not isinstance(allow, list): raise ValueError('allow should be an array of list of users.') - # FIXME: get usernames validation + # TODO: FOGL-8037 get usernames validation final['allow'] = allow return final @@ -251,7 +251,7 @@ async def get_all(request: web.Request) -> web.Response: This is on the basis of anonymous flag if true then permitted true If anonymous flag is false then list of allowed users to determine if the specific user can make the call """ - # TODO: verify the user when anonymous is false and set permitted value based on it + # TODO: FOGL-8037 verify the user when anonymous is false and set permitted value based on it entrypoint.append({"name": r['name'], "description": r['description'], "permitted": True if r['anonymous'] == 't' else False}) return web.json_response({"controls": entrypoint}) @@ -262,7 +262,7 @@ async def get_by_name(request: web.Request) -> web.Response: :Example: curl -sX GET http://localhost:8081/fledge/control/manage/SetLatheSpeed """ - # TODO: forbidden when permitted is false on the basis of anonymous + # TODO: FOGL-8037 forbidden when permitted is false on the basis of anonymous name = request.match_info.get('name', None) try: storage = connect.get_storage_async() @@ -349,26 +349,88 @@ async def update(request: web.Request) -> web.Response: try: storage = connect.get_storage_async() payload = PayloadBuilder().WHERE(["name", '=', name]).payload() - result = await storage.query_tbl_with_payload("control_api", payload) - if not result['rows']: - raise KeyError('{} control entrypoint not found.'.format(name)) - data = await request.json() - columns = await _check_parameters(data, skip_required=True) - # TODO: rename + entry_point_result = await storage.query_tbl_with_payload("control_api", payload) + if not entry_point_result['rows']: + msg = '{} control entrypoint not found.'.format(name) + return web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + try: + data = await request.json() + columns = await _check_parameters(data, skip_required=True) + except Exception as ex: + msg = str(ex) + return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + # TODO: FOGL-8037 rename if 'name' in columns: del columns['name'] - # TODO: "constants", "variables", "allow" - possible_keys = {"name", "description", "type", "operation_name", "destination", "destination_arg", "anonymous"} + # TODO: FOGL-8037 "allow", "destination", "destination_arg" + possible_keys = {"name", "description", "type", "operation_name", "destination", "destination_arg", + "anonymous", "constants", "variables"} if 'type' in columns: columns['operation_name'] = columns['operation_name'] if columns['type'] == 1 else "" if 'destination_arg' in columns: dest = await _get_destination(columns['destination']) columns['destination_arg'] = columns[dest] if columns['destination'] else "" + columns[columns['destination']] = columns['destination_arg'] + del columns['destination_arg'] entries_to_remove = set(columns) - set(possible_keys) for k in entries_to_remove: del columns[k] - payload = PayloadBuilder().SET(**columns).WHERE(['name', '=', name]).payload() - await storage.update_tbl("control_api", payload) + + control_api_columns = {} + if columns: + for k, v in columns.items(): + if k == "constants": + constant_payload = PayloadBuilder().WHERE(["name", '=', name]).AND_WHERE( + ["constant", '=', 't']).chain_payload() + if v: + # constants update in any type + for k1, v1 in v.items(): + get_payload = PayloadBuilder(constant_payload).payload() + param_result = await storage.query_tbl_with_payload("control_api_parameters", get_payload) + if param_result['rows']: + update_payload = PayloadBuilder(constant_payload).SET(parameter=k1, value=v1).payload() + await storage.update_tbl("control_api_parameters", update_payload) + else: + control_api_params_column_name = {"name": name, "parameter": k1, "value": v1, + "constant": 't'} + api_params_insert_payload = PayloadBuilder().INSERT( + **control_api_params_column_name).payload() + await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) + else: + # empty only allowed for operation type + if entry_point_result['rows'][0]['type'] == 1: + del_payload = PayloadBuilder(constant_payload).payload() + await storage.delete_from_tbl("control_api_parameters", del_payload) + elif k == "variables": + variable_payload = PayloadBuilder().WHERE(["name", '=', name]).AND_WHERE( + ["constant", '=', 'f']).chain_payload() + if v: + # variables update in any type + for k2, v2 in v.items(): + get_payload = PayloadBuilder(variable_payload).payload() + param_result = await storage.query_tbl_with_payload("control_api_parameters", get_payload) + if param_result['rows']: + update_payload = PayloadBuilder(variable_payload).SET(parameter=k2, value=v2).payload() + await storage.update_tbl("control_api_parameters", update_payload) + else: + control_api_params_column_name = {"name": name, "parameter": k2, "value": v2, + "constant": 'f'} + api_params_insert_payload = PayloadBuilder().INSERT( + **control_api_params_column_name).payload() + await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) + else: + # empty only allowed for operation type + if entry_point_result['rows'][0]['type'] == 1: + del_payload = PayloadBuilder(variable_payload).payload() + await storage.delete_from_tbl("control_api_parameters", del_payload) + else: + control_api_columns[k] = v + if control_api_columns: + payload = PayloadBuilder().SET(**control_api_columns).WHERE(['name', '=', name]).payload() + await storage.update_tbl("control_api", payload) + else: + msg = "Nothing to update. No valid key value pair found in payload." + return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) _logger.error(ex, "Failed to update the details of {} entrypoint.".format(name)) From 0369ce7694a5673b2197dc1771f9afc47452629f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 8 Sep 2023 19:38:20 +0530 Subject: [PATCH 428/499] individual exceptions block added to raise an error with accurate HTTP status code Signed-off-by: ashish-jabble --- .../services/core/api/control_service/entrypoint.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index af9f6da7e0..2cd09350f9 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -352,13 +352,13 @@ async def update(request: web.Request) -> web.Response: entry_point_result = await storage.query_tbl_with_payload("control_api", payload) if not entry_point_result['rows']: msg = '{} control entrypoint not found.'.format(name) - return web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + raise KeyError(msg) try: data = await request.json() columns = await _check_parameters(data, skip_required=True) except Exception as ex: msg = str(ex) - return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + raise ValueError(msg) # TODO: FOGL-8037 rename if 'name' in columns: del columns['name'] @@ -430,7 +430,13 @@ async def update(request: web.Request) -> web.Response: await storage.update_tbl("control_api", payload) else: msg = "Nothing to update. No valid key value pair found in payload." - return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + raise ValueError(msg) + except ValueError as err: + msg = str(err) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) _logger.error(ex, "Failed to update the details of {} entrypoint.".format(name)) From f398697346eebb47e2cafa9c7d5494d569e82fd4 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 11 Sep 2023 11:17:47 +0530 Subject: [PATCH 429/499] fixes for destination and destination arg values Signed-off-by: ashish-jabble --- .../fledge/services/core/api/control_service/entrypoint.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 2cd09350f9..503ba1c781 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -362,7 +362,7 @@ async def update(request: web.Request) -> web.Response: # TODO: FOGL-8037 rename if 'name' in columns: del columns['name'] - # TODO: FOGL-8037 "allow", "destination", "destination_arg" + # TODO: FOGL-8037 "allow" possible_keys = {"name", "description", "type", "operation_name", "destination", "destination_arg", "anonymous", "constants", "variables"} if 'type' in columns: @@ -370,12 +370,9 @@ async def update(request: web.Request) -> web.Response: if 'destination_arg' in columns: dest = await _get_destination(columns['destination']) columns['destination_arg'] = columns[dest] if columns['destination'] else "" - columns[columns['destination']] = columns['destination_arg'] - del columns['destination_arg'] entries_to_remove = set(columns) - set(possible_keys) for k in entries_to_remove: del columns[k] - control_api_columns = {} if columns: for k, v in columns.items(): From 2521d6b0550454d70ed60ee879590529787b4780 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 13 Sep 2023 09:43:29 +0100 Subject: [PATCH 430/499] FOGL-7277 Restructure OMF plugin such that the OMF object can be reused (#1161) * FOGL-7277 Restructure to use OMF object for multiple sends and reduce number of resends of base data types and containers Signed-off-by: Mark Riddoch * FOGL-7277 Fix indent in documentation and add test fro send full structure in linked data types Signed-off-by: Mark Riddoch * Fix a couple of typos Signed-off-by: Mark Riddoch * Correct comment Signed-off-by: Mark Riddoch * Fix typos Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/include/omf.h | 5 + C/plugins/north/OMF/include/omfinfo.h | 183 +++ C/plugins/north/OMF/omf.cpp | 33 +- C/plugins/north/OMF/omfinfo.cpp | 1381 +++++++++++++++++++++++ C/plugins/north/OMF/plugin.cpp | 1489 +------------------------ docs/OMF.rst | 2 +- 6 files changed, 1598 insertions(+), 1495 deletions(-) create mode 100644 C/plugins/north/OMF/include/omfinfo.h create mode 100644 C/plugins/north/OMF/omfinfo.cpp diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index 3ae5731a15..cf23be81a9 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -514,6 +514,11 @@ class OMF * Service name */ const std::string m_name; + + /** + * Have base types been sent to the PI Server + */ + bool m_baseTypesSent; }; /** diff --git a/C/plugins/north/OMF/include/omfinfo.h b/C/plugins/north/OMF/include/omfinfo.h new file mode 100644 index 0000000000..4834fd401f --- /dev/null +++ b/C/plugins/north/OMF/include/omfinfo.h @@ -0,0 +1,183 @@ +#ifndef _OMFINFO_H +#define _OMFINFO_H +/* + * Fledge OSIsoft OMF interface to PI Server. + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "rapidjson/writer.h" +#include "rapidjson/stringbuffer.h" +#include "json_utils.h" +#include "libcurl_https.h" +#include "utils.h" +#include "string_utils.h" +#include + +#include "crypto.hpp" + +#define PLUGIN_NAME "OMF" +#define TYPE_ID_KEY "type-id" +#define SENT_TYPES_KEY "sentDataTypes" +#define DATA_KEY "dataTypes" +#define DATA_KEY_SHORT "dataTypesShort" +#define DATA_KEY_HINT "hintChecksum" +#define NAMING_SCHEME "namingScheme" +#define AFH_HASH "afhHash" +#define AF_HIERARCHY "afHierarchy" +#define AF_HIERARCHY_ORIG "afHierarchyOrig" + + +#define PROPERTY_TYPE "type" +#define PROPERTY_NUMBER "number" +#define PROPERTY_STRING "string" + +#define ENDPOINT_URL_PI_WEB_API "https://HOST_PLACEHOLDER:PORT_PLACEHOLDER/piwebapi/omf" +#define ENDPOINT_URL_CR "https://HOST_PLACEHOLDER:PORT_PLACEHOLDER/ingress/messages" +#define ENDPOINT_URL_OCS "https://REGION_PLACEHOLDER.osisoft.com:PORT_PLACEHOLDER/api/v1/tenants/TENANT_ID_PLACEHOLDER/Namespaces/NAMESPACE_ID_PLACEHOLDER/omf" +#define ENDPOINT_URL_ADH "https://REGION_PLACEHOLDER.datahub.connect.aveva.com:PORT_PLACEHOLDER/api/v1/Tenants/TENANT_ID_PLACEHOLDER/Namespaces/NAMESPACE_ID_PLACEHOLDER/omf" + +#define ENDPOINT_URL_EDS "http://localhost:PORT_PLACEHOLDER/api/v1/tenants/default/namespaces/default/omf" + +static bool s_connected = true; // if true, access to PI Web API is working + +enum OMF_ENDPOINT_PORT { + ENDPOINT_PORT_PIWEB_API=443, + ENDPOINT_PORT_CR=5460, + ENDPOINT_PORT_OCS=443, + ENDPOINT_PORT_EDS=5590, + ENDPOINT_PORT_ADH=443 +}; + +/** + * Plugin specific default configuration + */ + +#define NOT_BLOCKING_ERRORS_DEFAULT QUOTE( \ + { \ + "errors400" : [ \ + "Redefinition of the type with the same ID is not allowed", \ + "Invalid value type for the property", \ + "Property does not exist in the type definition", \ + "Container is not defined", \ + "Unable to find the property of the container of type" \ + ] \ + } \ +) + +#define NOT_BLOCKING_ERRORS_DEFAULT_PI_WEB_API QUOTE( \ + { \ + "EventInfo" : [ \ + "The specified value is outside the allowable range" \ + ] \ + } \ +) + +#define AF_HIERARCHY_RULES QUOTE( \ + { \ + } \ +) + +/** + * A class that holds the configuration information for the OMF plugin. + * + * Note this is the first stage of refactoring the OMF pluigns and represents + * the CONNECTOR_INFO structure of original plugin as a class + */ +class OMFInformation { + public: + OMFInformation(ConfigCategory* configData); + ~OMFInformation(); + void start(const std::string& storedData); + uint32_t send(const vector& readings); + std::string saveData(); + private: + void loadSentDataTypes(rapidjson::Document& JSONData); + long getMaxTypeId(); + int PIWebAPIGetVersion(bool logMessage = true); + int EDSGetVersion(); + void SetOMFVersion(); + std::string OCSRetrieveAuthToken(); + OMF_ENDPOINT identifyPIServerEndpoint(); + std::string saveSentDataTypes(); + unsigned long calcTypeShort(const std::string& dataTypes); + void ParseProductVersion(std::string &versionString, int *major, int *minor); + std::string ParseEDSProductInformation(std::string json); + std::string AuthBasicCredentialsGenerate(std::string& userId, std::string& password); + void AuthKerberosSetup(std::string& keytabEnv, std::string& keytabFileName); + double GetElapsedTime(struct timeval *startTime); + bool IsPIWebAPIConnected(); + + private: + Logger *m_logger; + HttpSender *m_sender; // HTTPS connection + OMF *m_omf; // OMF data protocol + bool m_sendFullStructure; // It sends the minimum OMF structural messages to load data into PI Data Archive if disabled + bool m_compression; // whether to compress readings' data + string m_protocol; // http / https + string m_hostAndPort; // hostname:port for SimpleHttps + unsigned int m_retrySleepTime; // Seconds between each retry + unsigned int m_maxRetry; // Max number of retries in the communication + unsigned int m_timeout; // connect and operation timeout + string m_path; // PI Server application path + long m_typeId; // OMF protocol type-id prefix + string m_producerToken; // PI Server connector token + string m_formatNumber; // OMF protocol Number format + string m_formatInteger; // OMF protocol Integer format + OMF_ENDPOINT m_PIServerEndpoint; // Defines which End point should be used for the communication + NAMINGSCHEME_ENDPOINT + m_NamingScheme; // Define how the object names should be generated - https://fledge-iot.readthedocs.io/en/latest/OMF.html#naming-scheme + string m_DefaultAFLocation; // 1st hierarchy in Asset Framework, PI Web API only. + string m_AFMap; // Defines a set of rules to address where assets should be placed in the AF hierarchy. + // https://fledge-iot.readthedocs.io/en/latest/OMF.html#asset-framework-hierarchy-rules + + string m_prefixAFAsset; // Prefix to generate unique asset id + string m_PIWebAPIProductTitle; + string m_RestServerVersion; + string m_PIWebAPIAuthMethod; // Authentication method to be used with the PI Web API. + string m_PIWebAPICredentials; // Credentials is the base64 encoding of id and password joined by a single colon (:) + string m_KerberosKeytab; // Kerberos authentication keytab file + // stores the environment variable value about the keytab file path + // to allow the environment to persist for all the execution of the plugin + // + // Note : A keytab is a file containing pairs of Kerberos principals + // and encrypted keys (which are derived from the Kerberos password). + // You can use a keytab file to authenticate to various remote systems + // using Kerberos without entering a password. + + string m_OCSNamespace; // OCS configurations + string m_OCSTenantId; + string m_OCSClientId; + string m_OCSClientSecret; + string m_OCSToken; + + vector> + m_staticData; // Static data + // Errors considered not blocking in the communication with the PI Server + std::vector + m_notBlockingErrors; + // Per asset DataTypes + std::map + m_assetsDataTypes; + string m_omfversion; + bool m_legacy; + string m_name; +}; +#endif diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index 3f6a7f5d8b..ac642d1768 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -236,7 +236,8 @@ OMF::OMF(const string& name, m_producerToken(token), m_sender(sender), m_legacy(false), - m_name(name) + m_name(name), + m_baseTypesSent(false) { m_lastError = false; m_changeTypeId = false; @@ -448,7 +449,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) // FIXME The following is too verbose if (error.hasErrors()) { - Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending dta type contianers : %d messages", + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending data type containers : %d messages", error.messageCount()); for (unsigned int i = 0; i < error.messageCount(); i++) { @@ -489,9 +490,8 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) return false; } - if (m_sendFullStructure) { - - + if (m_sendFullStructure) + { // Create header for Static data vector> resStaticData = OMF::createMessageHeader("Data"); // Create data for Static Data message @@ -556,11 +556,8 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) m_connected = false; return false; } - } - if (m_sendFullStructure) - { // Create header for Link data vector> resLinkData = OMF::createMessageHeader("Data"); @@ -1104,13 +1101,17 @@ uint32_t OMF::sendToServer(const vector& readings, gettimeofday(&start, NULL); #endif - if (m_linkedProperties) + if (m_linkedProperties && m_baseTypesSent == false) { if (!sendBaseTypes()) { Logger::getLogger()->error("Unable to send base types, linked assets will not be sent. The system will fall back to using complex types."); m_linkedProperties = false; } + else + { + m_baseTypesSent = true; + } } // Create a superset of all the datapoints for each assetName @@ -1331,8 +1332,8 @@ uint32_t OMF::sendToServer(const vector& readings, } } - if (m_sendFullStructure) { - + if (m_sendFullStructure) + { // The AF hierarchy is created/recreated if an OMF type message is sent // it sends the hierarchy once if (sendDataTypes and ! AFHierarchySent) @@ -1394,7 +1395,7 @@ uint32_t OMF::sendToServer(const vector& readings, auto asset_sent = m_assetSent.find(m_assetName); // Send data for this reading using the new mechanism outData = linkedData.processReading(*reading, AFHierarchyPrefix, hints); - if (asset_sent == m_assetSent.end()) + if (m_sendFullStructure && asset_sent == m_assetSent.end()) { // If the hierarchy has not already been sent then send it if (! AFHierarchySent) @@ -1879,8 +1880,8 @@ const std::string OMF::createTypeData(const Reading& reading, OMFHints *hints) string tData="["; - if (m_sendFullStructure) { - + if (m_sendFullStructure) + { // Add the Static data part tData.append("{ \"type\": \"object\", \"properties\": { "); for (auto it = m_staticData->cbegin(); it != m_staticData->cend(); ++it) @@ -4701,7 +4702,7 @@ std::string OMF::ApplyPIServerNamingRulesPath(const std::string &objName, bool * /** * Send the base types that we use to define all the data point values * - * @return true If the data types were sent correctly. Otherwsie false. + * @return true If the data types were sent correctly. Otherwise false. */ bool OMF::sendBaseTypes() { @@ -4774,7 +4775,7 @@ bool OMF::sendBaseTypes() /** * Create the messages to link the asset into the right place in the AF structure * - * @param reading The reading beign sent + * @param reading The reading being sent * @param hints OMF Hints for this reading */ string OMF::createAFLinks(Reading& reading, OMFHints *hints) diff --git a/C/plugins/north/OMF/omfinfo.cpp b/C/plugins/north/OMF/omfinfo.cpp new file mode 100644 index 0000000000..aa4ad3b1b2 --- /dev/null +++ b/C/plugins/north/OMF/omfinfo.cpp @@ -0,0 +1,1381 @@ +/* + * Fledge OSIsoft OMF interface to PI Server. + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ +#include + +using namespace std; +using namespace rapidjson; +using namespace SimpleWeb; + +/** + * Constructor for the OMFInformation class + */ +OMFInformation::OMFInformation(ConfigCategory *config) : m_sender(NULL), m_omf(NULL) +{ + + m_logger = Logger::getLogger(); + m_name = config->getName(); + + int endpointPort = 0; + + // PIServerEndpoint handling + string PIServerEndpoint = config->getValue("PIServerEndpoint"); + string ADHRegions = config->getValue("ADHRegions"); + string ServerHostname = config->getValue("ServerHostname"); + if (gethostbyname(ServerHostname.c_str()) == NULL) + { + Logger::getLogger()->warn("Unable to resolve server hostname '%s'. This should be a valid hostname or IP Address.", ServerHostname.c_str()); + } + string ServerPort = config->getValue("ServerPort"); + string url; + string NamingScheme = config->getValue("NamingScheme"); + + // Translate the PIServerEndpoint configuration + if(PIServerEndpoint.compare("PI Web API") == 0) + { + Logger::getLogger()->debug("PI-Server end point manually selected - PI Web API "); + m_PIServerEndpoint = ENDPOINT_PIWEB_API; + url = ENDPOINT_URL_PI_WEB_API; + endpointPort = ENDPOINT_PORT_PIWEB_API; + } + else if(PIServerEndpoint.compare("Connector Relay") == 0) + { + Logger::getLogger()->debug("PI-Server end point manually selected - Connector Relay "); + m_PIServerEndpoint = ENDPOINT_CR; + url = ENDPOINT_URL_CR; + endpointPort = ENDPOINT_PORT_CR; + } + else if(PIServerEndpoint.compare("AVEVA Data Hub") == 0) + { + Logger::getLogger()->debug("End point manually selected - AVEVA Data Hub"); + m_PIServerEndpoint = ENDPOINT_ADH; + url = ENDPOINT_URL_ADH; + std::string region = "uswe"; + if(ADHRegions.compare("EU-West") == 0) + region = "euno"; + else if(ADHRegions.compare("Australia") == 0) + region = "auea"; + StringReplace(url, "REGION_PLACEHOLDER", region); + endpointPort = ENDPOINT_PORT_ADH; + } + else if(PIServerEndpoint.compare("OSIsoft Cloud Services") == 0) + { + Logger::getLogger()->debug("End point manually selected - OSIsoft Cloud Services"); + m_PIServerEndpoint = ENDPOINT_OCS; + url = ENDPOINT_URL_OCS; + std::string region = "dat-b"; + if(ADHRegions.compare("EU-West") == 0) + region = "dat-d"; + else if(ADHRegions.compare("Australia") == 0) + Logger::getLogger()->error("OSIsoft Cloud Services are not hosted in Australia"); + StringReplace(url, "REGION_PLACEHOLDER", region); + endpointPort = ENDPOINT_PORT_OCS; + } + else if(PIServerEndpoint.compare("Edge Data Store") == 0) + { + Logger::getLogger()->debug("End point manually selected - Edge Data Store"); + m_PIServerEndpoint = ENDPOINT_EDS; + url = ENDPOINT_URL_EDS; + endpointPort = ENDPOINT_PORT_EDS; + } + ServerPort = (ServerPort.compare("0") == 0) ? to_string(endpointPort) : ServerPort; + + if (endpointPort == ENDPOINT_PORT_PIWEB_API) { + + // Use SendFullStructure ? + string fullStr = config->getValue("SendFullStructure"); + + if (fullStr == "True" || fullStr == "true" || fullStr == "TRUE") + m_sendFullStructure = true; + else + m_sendFullStructure = false; + } else { + m_sendFullStructure = true; + } + + unsigned int retrySleepTime = atoi(config->getValue("OMFRetrySleepTime").c_str()); + unsigned int maxRetry = atoi(config->getValue("OMFMaxRetry").c_str()); + unsigned int timeout = atoi(config->getValue("OMFHttpTimeout").c_str()); + + string producerToken = config->getValue("producerToken"); + + string formatNumber = config->getValue("formatNumber"); + string formatInteger = config->getValue("formatInteger"); + string DefaultAFLocation = config->getValue("DefaultAFLocation"); + string AFMap = config->getValue("AFMap"); + + string PIWebAPIAuthMethod = config->getValue("PIWebAPIAuthenticationMethod"); + string PIWebAPIUserId = config->getValue("PIWebAPIUserId"); + string PIWebAPIPassword = config->getValue("PIWebAPIPassword"); + string KerberosKeytabFileName = config->getValue("PIWebAPIKerberosKeytabFileName"); + + // OCS configurations + string OCSNamespace = config->getValue("OCSNamespace"); + string OCSTenantId = config->getValue("OCSTenantId"); + string OCSClientId = config->getValue("OCSClientId"); + string OCSClientSecret = config->getValue("OCSClientSecret"); + + StringReplace(url, "HOST_PLACEHOLDER", ServerHostname); + StringReplace(url, "PORT_PLACEHOLDER", ServerPort); + + // TENANT_ID_PLACEHOLDER and NAMESPACE_ID_PLACEHOLDER, if present, will be replaced with the values of OCSTenantId and OCSNamespace + StringReplace(url, "TENANT_ID_PLACEHOLDER", OCSTenantId); + StringReplace(url, "NAMESPACE_ID_PLACEHOLDER", OCSNamespace); + + /** + * Extract host, port, path from URL + */ + size_t findProtocol = url.find_first_of(":"); + string protocol = url.substr(0, findProtocol); + + string tmpUrl = url.substr(findProtocol + 3); + size_t findPort = tmpUrl.find_first_of(":"); + string hostName = tmpUrl.substr(0, findPort); + + size_t findPath = tmpUrl.find_first_of("/"); + string port = tmpUrl.substr(findPort + 1, findPath - findPort - 1); + string path = tmpUrl.substr(findPath); + + string hostAndPort(hostName + ":" + port); + + // Set configuration fields + m_protocol = protocol; + m_hostAndPort = hostAndPort; + m_path = path; + m_retrySleepTime = retrySleepTime; + m_maxRetry = maxRetry; + m_timeout = timeout; + m_typeId = TYPE_ID_DEFAULT; + m_producerToken = producerToken; + m_formatNumber = formatNumber; + m_formatInteger = formatInteger; + m_DefaultAFLocation = DefaultAFLocation; + m_AFMap = AFMap; + + // OCS configurations + OCSNamespace = OCSNamespace; + OCSTenantId = OCSTenantId; + OCSClientId = OCSClientId; + OCSClientSecret = OCSClientSecret; + + // PI Web API end-point - evaluates the authentication method requested + if (m_PIServerEndpoint == ENDPOINT_PIWEB_API) + { + if (PIWebAPIAuthMethod.compare("anonymous") == 0) + { + Logger::getLogger()->debug("PI Web API end-point - anonymous authentication"); + m_PIWebAPIAuthMethod = "a"; + } + else if (PIWebAPIAuthMethod.compare("basic") == 0) + { + Logger::getLogger()->debug("PI Web API end-point - basic authentication"); + m_PIWebAPIAuthMethod = "b"; + m_PIWebAPICredentials = AuthBasicCredentialsGenerate(PIWebAPIUserId, PIWebAPIPassword); + } + else if (PIWebAPIAuthMethod.compare("kerberos") == 0) + { + Logger::getLogger()->debug("PI Web API end-point - kerberos authentication"); + m_PIWebAPIAuthMethod = "k"; + AuthKerberosSetup(m_KerberosKeytab, KerberosKeytabFileName); + } + else + { + Logger::getLogger()->error("Invalid authentication method for PI Web API :%s: ", PIWebAPIAuthMethod.c_str()); + } + } + else + { + // For all other endpoint types, set PI Web API authentication to 'anonymous.' + // This prevents the HttpSender from inserting PI Web API authentication headers. + m_PIWebAPIAuthMethod = "a"; + } + + // Use compression ? + string compr = config->getValue("compression"); + if (compr == "True" || compr == "true" || compr == "TRUE") + m_compression = true; + else + m_compression = false; + + // Set the list of errors considered not blocking in the communication + // with the PI Server + if (m_PIServerEndpoint == ENDPOINT_PIWEB_API) + { + JSONStringToVectorString(m_notBlockingErrors, + config->getValue("PIWebAPInotBlockingErrors"), + string("EventInfo")); + } + else + { + JSONStringToVectorString(m_notBlockingErrors, + config->getValue("notBlockingErrors"), + string("errors400")); + } + /** + * Add static data + * Split the string up into each pair + */ + string staticData = config->getValue("StaticData"); + size_t pos = 0; + size_t start = 0; + do { + pos = staticData.find(",", start); + string item = staticData.substr(start, pos); + start = pos + 1; + size_t pos2 = 0; + if ((pos2 = item.find(":")) != string::npos) + { + string name = item.substr(0, pos2); + while (name[0] == ' ') + name = name.substr(1); + string value = item.substr(pos2 + 1); + while (value[0] == ' ') + value = value.substr(1); + pair sData = make_pair(name, value); + m_staticData.push_back(sData); + } + } while (pos != string::npos); + + { + // NamingScheme handling + if(NamingScheme.compare("Concise") == 0) + { + m_NamingScheme = NAMINGSCHEME_CONCISE; + } + else if(NamingScheme.compare("Use Type Suffix") == 0) + { + m_NamingScheme = NAMINGSCHEME_SUFFIX; + } + else if(NamingScheme.compare("Use Attribute Hash") == 0) + { + m_NamingScheme = NAMINGSCHEME_HASH; + } + else if(NamingScheme.compare("Backward compatibility") == 0) + { + m_NamingScheme = NAMINGSCHEME_COMPATIBILITY; + } + Logger::getLogger()->debug("End point naming scheme :%s: ", NamingScheme.c_str() ); + + } + + // Fetch legacy OMF type option + string legacy = config->getValue("Legacy"); + if (legacy == "True" || legacy == "true" || legacy == "TRUE") + m_legacy = true; + else + m_legacy = false; + +} + +/** + * Destructor for the OMFInformation class. + */ +OMFInformation::~OMFInformation() +{ + if (m_sender) + delete m_sender; + if (m_omf) + delete m_omf; + // TODO cleanup the allocated member variables +} + +/** + * The plugin start entry point has been called + * + * @param storedData The data that has been persisted by a previous execution + * of the plugin + */ +void OMFInformation::start(const string& storedData) +{ + + m_logger->info("Host: %s", m_hostAndPort.c_str()); + if ((m_PIServerEndpoint == ENDPOINT_OCS) || (m_PIServerEndpoint == ENDPOINT_ADH)) + { + m_logger->info("Namespace: %s", m_OCSNamespace.c_str()); + } + + // Parse JSON plugin_data + Document JSONData; + JSONData.Parse(storedData.c_str()); + if (JSONData.HasParseError()) + { + m_logger->error("%s plugin error: failure parsing " + "plugin data JSON object '%s'", + PLUGIN_NAME, + storedData.c_str()); + } + else if (JSONData.HasMember(TYPE_ID_KEY) && + (JSONData[TYPE_ID_KEY].IsString() || + JSONData[TYPE_ID_KEY].IsNumber())) + { + // Update type-id in PLUGIN_HANDLE object + if (JSONData[TYPE_ID_KEY].IsNumber()) + { + m_typeId = JSONData[TYPE_ID_KEY].GetInt(); + } + else + { + m_typeId = atol(JSONData[TYPE_ID_KEY].GetString()); + } + } + + // Load sentdataTypes + loadSentDataTypes(JSONData); + + // Log default type-id + if (m_assetsDataTypes.size() == 1 && + m_assetsDataTypes.find(FAKE_ASSET_KEY) != m_assetsDataTypes.end()) + { + // Only one value: we have the FAKE_ASSET_KEY and no other data + Logger::getLogger()->info("%s plugin is using global OMF prefix %s=%d", + PLUGIN_NAME, + TYPE_ID_KEY, + m_typeId); + } + else + { + Logger::getLogger()->info("%s plugin is using per asset OMF prefix %s=%d " + "(max value found)", + PLUGIN_NAME, + TYPE_ID_KEY, + getMaxTypeId()); + } + + // Retrieve the PI Web API Version + s_connected = true; + if (m_PIServerEndpoint == ENDPOINT_PIWEB_API) + { + int httpCode = PIWebAPIGetVersion(); + if (httpCode >= 200 && httpCode < 400) + { + SetOMFVersion(); + Logger::getLogger()->info("%s connected to %s OMF Version: %s", + m_RestServerVersion.c_str(), m_hostAndPort.c_str(), m_omfversion.c_str()); + s_connected = true; + } + else + { + s_connected = false; + } + } + else if (m_PIServerEndpoint == ENDPOINT_EDS) + { + EDSGetVersion(); + SetOMFVersion(); + Logger::getLogger()->info("Edge Data Store %s OMF Version: %s", m_RestServerVersion.c_str(), m_omfversion.c_str()); + } + else + { + SetOMFVersion(); + Logger::getLogger()->info("OMF Version: %s", m_omfversion.c_str()); + } +} + +/** + * Send data to the OMF endpoint + * + * @param readings The block of readings to send + * @return uint32_t The number of readings sent + */ +uint32_t OMFInformation::send(const vector& readings) +{ +#if INSTRUMENT + struct timeval startTime; + gettimeofday(&startTime, NULL); +#endif + string version; + + // Check if the endpoint is PI Web API and if the PI Web API server is available + if (!IsPIWebAPIConnected()) + { + // Error already reported by IsPIWebAPIConnected + return 0; + } + + if (!m_sender) + { + /** + * Select the transport library based on the authentication method and transport encryption + * requirements. + * + * LibcurlHttps is used to integrate Kerberos as the SimpleHttp does not support it + * the Libcurl integration implements only HTTPS not HTTP currently. We use SimpleHttp or + * SimpleHttps, as appropriate for the URL given, if not using Kerberos + * + * + * The handler is allocated using "Hostname : port", connect_timeout and request_timeout. + * Default is no timeout + */ + if (m_PIWebAPIAuthMethod.compare("k") == 0) + { + m_sender = new LibcurlHttps(m_hostAndPort, + m_timeout, + m_timeout, + m_retrySleepTime, + m_maxRetry); + } + else + { + if (m_protocol.compare("http") == 0) + { + m_sender = new SimpleHttp(m_hostAndPort, + m_timeout, + m_timeout, + m_retrySleepTime, + m_maxRetry); + } + else + { + m_sender = new SimpleHttps(m_hostAndPort, + m_timeout, + m_timeout, + m_retrySleepTime, + m_maxRetry); + } + } + + m_sender->setAuthMethod (m_PIWebAPIAuthMethod); + m_sender->setAuthBasicCredentials(m_PIWebAPICredentials); + + // OCS configurations + m_sender->setOCSNamespace (m_OCSNamespace); + m_sender->setOCSTenantId (m_OCSTenantId); + m_sender->setOCSClientId (m_OCSClientId); + m_sender->setOCSClientSecret (m_OCSClientSecret); + } + + // OCS or ADH - retrieves the authentication token + // It is retrieved at every send as it can expire and the configuration is only in OCS and ADH + if (m_PIServerEndpoint == ENDPOINT_OCS || m_PIServerEndpoint == ENDPOINT_ADH) + { + m_OCSToken = OCSRetrieveAuthToken(); + m_sender->setOCSToken (m_OCSToken); + } + + // Allocate the OMF class that implements the PI Server data protocol + if (!m_omf) + { + m_omf = new OMF(m_name, *m_sender, m_path, m_assetsDataTypes, + m_producerToken); + + m_omf->setConnected(s_connected); + m_omf->setSendFullStructure(m_sendFullStructure); + + // Set PIServerEndpoint configuration + m_omf->setNamingScheme(m_NamingScheme); + m_omf->setPIServerEndpoint(m_PIServerEndpoint); + m_omf->setDefaultAFLocation(m_DefaultAFLocation); + m_omf->setAFMap(m_AFMap); + + m_omf->setOMFVersion(m_omfversion); + + // Generates the prefix to have unique asset_id across different levels of hierarchies + string AFHierarchyLevel; + m_omf->generateAFHierarchyPrefixLevel(m_DefaultAFLocation, m_prefixAFAsset, AFHierarchyLevel); + + m_omf->setPrefixAFAsset(m_prefixAFAsset); + + // Set OMF FormatTypes + m_omf->setFormatType(OMF_TYPE_FLOAT, + m_formatNumber); + m_omf->setFormatType(OMF_TYPE_INTEGER, + m_formatInteger); + + m_omf->setStaticData(&m_staticData); + m_omf->setNotBlockingErrors(m_notBlockingErrors); + + if (m_omfversion == "1.1" || m_omfversion == "1.0") + { + Logger::getLogger()->info("Setting LegacyType to be true for OMF Version '%s'. This will force use old style complex types. ", m_omfversion.c_str()); + m_omf->setLegacyMode(true); + } + else + { + m_omf->setLegacyMode(m_legacy); + } + } + // Send the readings data to the PI Server + uint32_t ret = m_omf->sendToServer(readings, m_compression); + + // Detect typeId change in OMF class + if (m_omf->getTypeId() != m_typeId) + { + // Update typeId in plugin handle + m_typeId = m_omf->getTypeId(); + // Log change + Logger::getLogger()->info("%s plugin: a new OMF global %s (%d) has been created.", + PLUGIN_NAME, + TYPE_ID_KEY, + m_typeId); + } + + // Write a warning if the connection to PI Web API has been lost + bool updatedConnected = m_omf->getConnected(); + if (m_PIServerEndpoint == ENDPOINT_PIWEB_API && s_connected && !updatedConnected) + { + Logger::getLogger()->warn("Connection to PI Web API at %s has been lost", m_hostAndPort.c_str()); + } + s_connected = updatedConnected; + +#if INSTRUMENT + Logger::getLogger()->debug("plugin_send elapsed time: %6.3f seconds, NumValues: %u", GetElapsedTime(&startTime), ret); +#endif + + // Return sent data ret code + return ret; +} + +/** + * Return the data to be persisted + * @return string The data to persist + */ +string OMFInformation::saveData() +{ +#if INSTRUMENT + struct timeval startTime; + gettimeofday(&startTime, NULL); +#endif + // Create save data + std::ostringstream saveData; + saveData << "{"; + + // Add sent data types + string typesData = saveSentDataTypes(); + if (!typesData.empty()) + { + // Save datatypes + saveData << typesData; + } + else + { + // Just save type-id + saveData << "\"" << TYPE_ID_KEY << "\": " << to_string(m_typeId); + } + + saveData << "}"; + + // Log saving the plugin configuration + Logger::getLogger()->debug("%s plugin: saving plugin_data '%s'", + PLUGIN_NAME, + saveData.str().c_str()); + + +#if INSTRUMENT + // For debugging: write plugin's JSON data to a file + string jsonFilePath = getDataDir() + string("/logs/OMFSaveData.json"); + ofstream f(jsonFilePath.c_str(), ios_base::trunc); + f << saveData.str(); + f.close(); + + Logger::getLogger()->debug("plugin_shutdown elapsed time: %6.3f seconds", GetElapsedTime(&startTime)); +#endif + + // Return current plugin data to save + return saveData.str(); +} + + +/** + * Load stored data types (already sent to PI server) + * + * Each element, the assetName, has type-id and datatype for each datapoint + * + * If no data exists in the plugin_data table, then a map entry + * with FAKE_ASSET_KEY is made in order to set the start type-id + * sequence with default value set to 1: + * all new created OMF dataTypes have type-id prefix set to the value of 1. + * + * If data like {"type-id": 14} or {"type-id": "14" } is found, a map entry + * with FAKE_ASSET_KEY is made and the start type-id sequence value is set + * to the found value, i.e. 14: + * all new created OMF dataTypes have type-id prefix set to the value of 14. + * + * If proper per asset types data is loaded, the FAKE_ASSET_KEY is not set: + * all new created OMF dataTypes have type-id prefix set to the value of 1 + * while existing (loaded) OMF dataTypes will keep their type-id values. + * + * @param JSONData The JSON document containing all saved data + */ +void OMFInformation::loadSentDataTypes(Document& JSONData) +{ + if (JSONData.HasMember(SENT_TYPES_KEY) && + JSONData[SENT_TYPES_KEY].IsArray()) + { + const Value& cachedTypes = JSONData[SENT_TYPES_KEY]; + for (Value::ConstValueIterator it = cachedTypes.Begin(); + it != cachedTypes.End(); + ++it) + { + if (!it->IsObject()) + { + Logger::getLogger()->warn("%s plugin: current element in '%s' " \ + "property is not an object, ignoring it", + PLUGIN_NAME, + SENT_TYPES_KEY); + continue; + } + + for (Value::ConstMemberIterator itr = it->MemberBegin(); + itr != it->MemberEnd(); + ++itr) + { + string key = itr->name.GetString(); + const Value& cachedValue = itr->value; + + // Add typeId and dataTypes to the in memory cache + long typeId; + if (cachedValue.HasMember(TYPE_ID_KEY) && + cachedValue[TYPE_ID_KEY].IsNumber()) + { + typeId = cachedValue[TYPE_ID_KEY].GetInt(); + } + else + { + Logger::getLogger()->warn("%s plugin: current element '%s'" \ + " doesn't have '%s' property, ignoring it", + PLUGIN_NAME, + key.c_str(), + TYPE_ID_KEY); + continue; + } + + long NamingScheme; + if (cachedValue.HasMember(NAMING_SCHEME) && + cachedValue[NAMING_SCHEME].IsNumber()) + { + NamingScheme = cachedValue[NAMING_SCHEME].GetInt(); + } + else + { + Logger::getLogger()->warn("%s plugin: current element '%s'" \ + " doesn't have '%s' property, handling naming scheme in compatibility mode", + PLUGIN_NAME, + key.c_str(), + NAMING_SCHEME); + NamingScheme = NAMINGSCHEME_COMPATIBILITY; + } + + string AFHHash; + if (cachedValue.HasMember(AFH_HASH) && + cachedValue[AFH_HASH].IsString()) + { + AFHHash = cachedValue[AFH_HASH].GetString(); + } + else + { + Logger::getLogger()->warn("%s plugin: current element '%s'" \ + " doesn't have '%s' property", + PLUGIN_NAME, + key.c_str(), + AFH_HASH); + AFHHash = ""; + } + + string AFHierarchy; + if (cachedValue.HasMember(AF_HIERARCHY) && + cachedValue[AF_HIERARCHY].IsString()) + { + AFHierarchy = cachedValue[AF_HIERARCHY].GetString(); + } + else + { + Logger::getLogger()->warn("%s plugin: current element '%s'" \ + " doesn't have '%s' property", + PLUGIN_NAME, + key.c_str(), + AF_HIERARCHY); + AFHierarchy = ""; + } + + string AFHierarchyOrig; + if (cachedValue.HasMember(AF_HIERARCHY_ORIG) && + cachedValue[AF_HIERARCHY_ORIG].IsString()) + { + AFHierarchyOrig = cachedValue[AF_HIERARCHY_ORIG].GetString(); + } + else + { + Logger::getLogger()->warn("%s plugin: current element '%s'" \ + " doesn't have '%s' property", + PLUGIN_NAME, + key.c_str(), + AF_HIERARCHY_ORIG); + AFHierarchyOrig = ""; + } + + string dataTypes; + if (cachedValue.HasMember(DATA_KEY) && + cachedValue[DATA_KEY].IsObject()) + { + StringBuffer buffer; + Writer writer(buffer); + const Value& types = cachedValue[DATA_KEY]; + types.Accept(writer); + dataTypes = buffer.GetString(); + } + else + { + Logger::getLogger()->warn("%s plugin: current element '%s'" \ + " doesn't have '%s' property, ignoring it", + PLUGIN_NAME, + key.c_str(), + DATA_KEY); + + continue; + } + + unsigned long dataTypesShort; + if (cachedValue.HasMember(DATA_KEY_SHORT) && + cachedValue[DATA_KEY_SHORT].IsString()) + { + string strDataTypesShort = cachedValue[DATA_KEY_SHORT].GetString(); + // The information are stored as string in hexadecimal format + dataTypesShort = stoi (strDataTypesShort,nullptr,16); + } + else + { + dataTypesShort = calcTypeShort(dataTypes); + if (dataTypesShort == 0) + { + Logger::getLogger()->warn("%s plugin: current element '%s'" \ + " doesn't have '%s' property", + PLUGIN_NAME, + key.c_str(), + DATA_KEY_SHORT); + } + else + { + Logger::getLogger()->warn("%s plugin: current element '%s'" \ + " doesn't have '%s' property, calculated '0x%X'", + PLUGIN_NAME, + key.c_str(), + DATA_KEY_SHORT, + dataTypesShort); + } + } + unsigned short hintChecksum = 0; + if (cachedValue.HasMember(DATA_KEY_HINT) && + cachedValue[DATA_KEY_HINT].IsString()) + { + string strHint = cachedValue[DATA_KEY_HINT].GetString(); + // The information are stored as string in hexadecimal format + hintChecksum = stoi (strHint,nullptr,16); + } + OMFDataTypes dataType; + dataType.typeId = typeId; + dataType.types = dataTypes; + dataType.typesShort = dataTypesShort; + dataType.hintChkSum = hintChecksum; + dataType.namingScheme = NamingScheme; + dataType.afhHash = AFHHash; + dataType.afHierarchy = AFHierarchy; + dataType.afHierarchyOrig = AFHierarchyOrig; + + Logger::getLogger()->debug("%s - AFHHash :%s: AFHierarchy :%s: AFHierarchyOrig :%s: ", __FUNCTION__, AFHHash.c_str(), AFHierarchy.c_str() , AFHierarchyOrig.c_str() ); + + + Logger::getLogger()->debug("%s - NamingScheme :%ld: ", __FUNCTION__,NamingScheme ); + + // Add data into the map + m_assetsDataTypes[key] = dataType; + } + } + } + else + { + // There is no stored data when plugin starts first time + if (JSONData.MemberBegin() != JSONData.MemberEnd()) + { + Logger::getLogger()->warn("Persisted data is not of the correct format, ignoring"); + } + + OMFDataTypes dataType; + dataType.typeId = m_typeId; + dataType.types = "{}"; + + // Add default data into the map + m_assetsDataTypes[FAKE_ASSET_KEY] = dataType; + } +} + + + +/** + * Return the maximum value of type-id, among all entries in the map + * + * If the array is empty the m_typeId is returned. + * + * @return The maximum value of type-id found + */ +long OMFInformation::getMaxTypeId() +{ + long maxId = m_typeId; + for (auto it = m_assetsDataTypes.begin(); + it != m_assetsDataTypes.end(); + ++it) + { + if ((*it).second.typeId > maxId) + { + maxId = (*it).second.typeId; + } + } + return maxId; +} + +/** + * Calls the PI Web API to retrieve the version + * + * @param logMessage If true, log error messages (default: true) + * @return httpCode HTTP response code + */ +int OMFInformation::PIWebAPIGetVersion(bool logMessage) +{ + PIWebAPI *_PIWebAPI; + + _PIWebAPI = new PIWebAPI(); + + // Set requested authentication + _PIWebAPI->setAuthMethod (m_PIWebAPIAuthMethod); + _PIWebAPI->setAuthBasicCredentials(m_PIWebAPICredentials); + + int httpCode = _PIWebAPI->GetVersion(m_hostAndPort, m_RestServerVersion, logMessage); + delete _PIWebAPI; + + return httpCode; +} + + + +/** + * Calls the Edge Data Store product information endpoint to get the EDS version + * + * @return HttpCode REST response code + */ +int OMFInformation::EDSGetVersion() +{ + int res; + + HttpSender *endPoint = new SimpleHttp(m_hostAndPort, + m_timeout, + m_timeout, + m_retrySleepTime, + m_maxRetry); + + try + { + string path = "http://" + m_hostAndPort + "/api/v1/diagnostics/productinformation"; + vector> headers; + m_RestServerVersion.clear(); + + res = endPoint->sendRequest("GET", path, headers, std::string("")); + if (res >= 200 && res <= 299) + { + m_RestServerVersion = ParseEDSProductInformation(endPoint->getHTTPResponse()); + } + } + catch (const BadRequest &ex) + { + Logger::getLogger()->error("Edge Data Store productinformation BadRequest exception: %s", ex.what()); + res = 400; + } + catch (const std::exception &ex) + { + Logger::getLogger()->error("Edge Data Store productinformation exception: %s", ex.what()); + res = 400; + } + catch (...) + { + Logger::getLogger()->error("Edge Data Store productinformation generic exception"); + res = 400; + } + + delete endPoint; + return res; +} + +/** + * Set the supported OMF Version for the OMF endpoint + */ +void OMFInformation::SetOMFVersion() +{ + switch (m_PIServerEndpoint) + { + case ENDPOINT_PIWEB_API: + if (m_RestServerVersion.find("2019") != std::string::npos) + { + m_omfversion = "1.0"; + } + else if (m_RestServerVersion.find("2020") != std::string::npos) + { + m_omfversion = "1.1"; + } + else if (m_RestServerVersion.find("2021") != std::string::npos) + { + m_omfversion = "1.2"; + } + else + { + m_omfversion = "1.2"; + } + break; + case ENDPOINT_EDS: + // Edge Data Store versions with supported OMF versions: + // EDS 2020 (1.0.0.609) OMF 1.0, 1.1 + // EDS 2023 (1.1.1.46) OMF 1.0, 1.1, 1.2 + // EDS 2023 Patch 1 (1.1.3.2) OMF 1.0, 1.1, 1.2 + { + int major = 0; + int minor = 0; + ParseProductVersion(m_RestServerVersion, &major, &minor); + if ((major > 1) || (major == 1 && minor > 0)) + { + m_omfversion = "1.2"; + } + else + { + m_omfversion = EDS_OMF_VERSION; + } + } + break; + case ENDPOINT_CR: + m_omfversion = CR_OMF_VERSION; + break; + case ENDPOINT_OCS: + case ENDPOINT_ADH: + default: + m_omfversion = "1.2"; // assume cloud service OMF endpoint types support OMF 1.2 + break; + } +} + +/** + * Calls the OCS API to retrieve the authentication token + * + * @return token Authorization token + */ +string OMFInformation::OCSRetrieveAuthToken() +{ + string token; + OCS *ocs; + + if (m_PIServerEndpoint == ENDPOINT_OCS) + ocs = new OCS(); + else if (m_PIServerEndpoint == ENDPOINT_ADH) + ocs = new OCS(true); + + token = ocs->retrieveToken(m_OCSClientId , m_OCSClientSecret); + + delete ocs; + + return token; +} + +/** + * Evaluate if the endpoint is a PI Web API or a Connector Relay. + * + * @return OMF_ENDPOINT values + */ +OMF_ENDPOINT OMFInformation::identifyPIServerEndpoint() +{ + OMF_ENDPOINT PIServerEndpoint; + + HttpSender *endPoint; + vector> header; + int httpCode; + + + if (m_PIWebAPIAuthMethod.compare("k") == 0) + { + endPoint = new LibcurlHttps(m_hostAndPort, + m_timeout, + m_timeout, + m_retrySleepTime, + m_maxRetry); + } + else + { + endPoint = new SimpleHttps(m_hostAndPort, + m_timeout, + m_timeout, + m_retrySleepTime, + m_maxRetry); + } + + // Set requested authentication + endPoint->setAuthMethod (m_PIWebAPIAuthMethod); + endPoint->setAuthBasicCredentials(m_PIWebAPICredentials); + + try + { + httpCode = endPoint->sendRequest("GET", + m_path, + header, + ""); + + if (httpCode >= 200 && httpCode <= 399) + { + PIServerEndpoint = ENDPOINT_PIWEB_API; + if (m_PIWebAPIAuthMethod == "b") + Logger::getLogger()->debug("PI Web API end-point basic authorization granted"); + } + else + { + PIServerEndpoint = ENDPOINT_CR; + } + + } + catch (exception &ex) + { + Logger::getLogger()->warn("PI-Server end-point discovery encountered the error :%s: " + "trying selecting the Connector Relay as an end-point", ex.what()); + PIServerEndpoint = ENDPOINT_CR; + } + + delete endPoint; + + return (PIServerEndpoint); +} + + +/** + * Return a JSON string with the dataTypes to save in plugin_data + * + * Note: the entry with FAKE_ASSET_KEY is never saved. + * + * @return The string with JSON data + */ +string OMFInformation::saveSentDataTypes() +{ + string ret; + std::ostringstream newData; + + auto it = m_assetsDataTypes.find(FAKE_ASSET_KEY); + if (it != m_assetsDataTypes.end()) + { + // Set typeId in FAKE_ASSET_KEY + m_typeId = (*it).second.typeId; + // Remove the entry + m_assetsDataTypes.erase(it); + } + + + unsigned long tSize = m_assetsDataTypes.size(); + if (tSize) + { + + // Prepare output data (skip empty data types) + newData << "\"" << SENT_TYPES_KEY << "\" : ["; + + bool pendingSeparator = false; + for (auto it = m_assetsDataTypes.begin(); + it != m_assetsDataTypes.end(); + ++it) + { + if (((*it).second).types.compare("{}") != 0) + { + newData << (pendingSeparator ? ", " : ""); + newData << "{\"" << (*it).first << "\" : {\"" << TYPE_ID_KEY << + "\": " << to_string(((*it).second).typeId); + + // The information should be stored as string in hexadecimal format + std::stringstream tmpStream; + tmpStream << std::hex << ((*it).second).typesShort; + std::string typesShort = tmpStream.str(); + + newData << ", \"" << DATA_KEY_SHORT << "\": \"0x" << typesShort << "\""; + std::stringstream hintStream; + hintStream << std::hex << ((*it).second).hintChkSum; + std::string hintChecksum = hintStream.str(); + newData << ", \"" << DATA_KEY_HINT << "\": \"0x" << hintChecksum << "\""; + + long NamingScheme; + NamingScheme = ((*it).second).namingScheme; + newData << ", \"" << NAMING_SCHEME << "\": " << to_string(NamingScheme) << ""; + + string AFHHash; + AFHHash = ((*it).second).afhHash; + newData << ", \"" << AFH_HASH << "\": \"" << AFHHash << "\""; + + string AFHierarchy; + AFHierarchy = ((*it).second).afHierarchy; + newData << ", \"" << AF_HIERARCHY << "\": \"" << AFHierarchy << "\""; + + string AFHierarchyOrig; + AFHierarchyOrig = ((*it).second).afHierarchyOrig; + newData << ", \"" << AF_HIERARCHY_ORIG << "\": \"" << AFHierarchyOrig << "\""; + + Logger::getLogger()->debug("%s - AFHHash :%s: AFHierarchy :%s: AFHierarchyOrig :%s:", __FUNCTION__, AFHHash.c_str(), AFHierarchy.c_str(), AFHierarchyOrig.c_str() ); + Logger::getLogger()->debug("%s - NamingScheme :%ld: ", __FUNCTION__,NamingScheme ); + + newData << ", \"" << DATA_KEY << "\": " << + (((*it).second).types.empty() ? "{}" : ((*it).second).types) << + "}}"; + pendingSeparator = true; + } + } + + tSize = m_assetsDataTypes.size(); + if (!tSize) + { + // DataTypes map is empty + return ret; + } + + newData << "]"; + + ret = newData.str(); + } + + return ret; +} + + +/** + * Calculate the TypeShort in the case it is missing loading type definition + * + * Generate a 64 bit number containing a set of counts, + * number of datapoints in an asset and the number of datapoint of each type we support. + * + */ +unsigned long OMFInformation::calcTypeShort(const string& dataTypes) +{ + union t_typeCount { + struct + { + unsigned char tTotal; + unsigned char tFloat; + unsigned char tString; + unsigned char spare0; + + unsigned char spare1; + unsigned char spare2; + unsigned char spare3; + unsigned char spare4; + } cnt; + unsigned long valueLong = 0; + + } typeCount; + + Document JSONData; + JSONData.Parse(dataTypes.c_str()); + + if (JSONData.HasParseError()) + { + Logger::getLogger()->error("calcTypeShort - unable to calculate TypeShort on :%s: ", dataTypes.c_str()); + return (0); + } + + for (Value::ConstMemberIterator it = JSONData.MemberBegin(); it != JSONData.MemberEnd(); ++it) + { + + string key = it->name.GetString(); + const Value& value = it->value; + + if (value.HasMember(PROPERTY_TYPE) && value[PROPERTY_TYPE].IsString()) + { + string type =value[PROPERTY_TYPE].GetString(); + + // Integer is handled as float in the OMF integration + if (type.compare(PROPERTY_NUMBER) == 0) + { + typeCount.cnt.tFloat++; + } else if (type.compare(PROPERTY_STRING) == 0) + { + typeCount.cnt.tString++; + } else { + + Logger::getLogger()->error("calcTypeShort - unrecognized type :%s: ", type.c_str()); + } + typeCount.cnt.tTotal++; + } + else + { + Logger::getLogger()->error("calcTypeShort - unable to extract the type for :%s: ", key.c_str()); + return (0); + } + } + + return typeCount.valueLong; +} + +/** + * Finds major and minor product version numbers in a version string + * + * @param versionString Version string of the form x.x.x.x where x's are integers + * @param major Major product version returned (first digit) + * @param minor Minor product version returned (second digit) + */ +void OMFInformation::ParseProductVersion(std::string &versionString, int *major, int *minor) +{ + *major = 0; + *minor = 0; + size_t last = 0; + size_t next = versionString.find(".", last); + if (next != string::npos) + { + *major = atoi(versionString.substr(last, next - last).c_str()); + last = next + 1; + next = versionString.find(".", last); + if (next != string::npos) + { + *minor = atoi(versionString.substr(last, next - last).c_str()); + } + } +} + +/** + * Parses the Edge Data Store version string from the /productinformation REST response. + * Note that the response format differs between EDS 2020 and EDS 2023. + * + * @param json REST response from /api/v1/diagnostics/productinformation + * @return version Edge Data Store version string + */ +std::string OMFInformation::ParseEDSProductInformation(std::string json) +{ + std::string version; + + Document doc; + + if (!doc.Parse(json.c_str()).HasParseError()) + { + try + { + if (doc.HasMember("Edge Data Store")) // EDS 2020 response + { + const rapidjson::Value &EDS = doc["Edge Data Store"]; + version = EDS.GetString(); + } + else if (doc.HasMember("Product Version")) // EDS 2023 response + { + const rapidjson::Value &EDS = doc["Product Version"]; + version = EDS.GetString(); + } + } + catch (...) + { + } + } + + Logger::getLogger()->debug("Edge Data Store Version: %s JSON: %s", version.c_str(), json.c_str()); + return version; +} + +/** + * Generate the credentials for the basic authentication + * encoding user id and password joined by a single colon (:) using base64 + * + * @param userId User id to be used for the generation of the credentials + * @param password Password to be used for the generation of the credentials + * @return credentials to be used with the basic authentication + */ +string OMFInformation::AuthBasicCredentialsGenerate(string& userId, string& password) +{ + string Credentials; + + Credentials = Crypto::Base64::encode(userId + ":" + password); + + return (Credentials); +} + +/** + * Configures for Kerberos authentication : + * - set the environment KRB5_CLIENT_KTNAME to the position containing the + * Kerberos keys, the keytab file. + * + * @param out keytabEnv string containing the command to set the + * KRB5_CLIENT_KTNAME environment variable + * @param keytabFileName File name of the keytab file + * + */ +void OMFInformation::AuthKerberosSetup(string& keytabEnv, string& keytabFileName) +{ + string fledgeData = getDataDir (); + string keytabFullPath = fledgeData + "/etc/kerberos" + "/" + keytabFileName; + + keytabEnv = "KRB5_CLIENT_KTNAME=" + keytabFullPath; + putenv((char *) keytabEnv.c_str()); + + if (access(keytabFullPath.c_str(), F_OK) != 0) + { + Logger::getLogger()->error("Kerberos authentication not possible, the keytab file :%s: is missing.", keytabFullPath.c_str()); + } + +} + +/** + * Calculate elapsed time in seconds + * + * @param startTime Start time of the interval to be evaluated + * @return Elapsed time in seconds + */ +double OMFInformation::GetElapsedTime(struct timeval *startTime) +{ + struct timeval endTime, diff; + gettimeofday(&endTime, NULL); + timersub(&endTime, startTime, &diff); + return diff.tv_sec + ((double)diff.tv_usec / 1000000); +} + +/** + * Check if the PI Web API server is available by reading the product version + * + * @return Connection status + */ +bool OMFInformation::IsPIWebAPIConnected() +{ + static std::chrono::steady_clock::time_point nextCheck; + static bool reported = false; // Has the state been reported yet + static bool reportedState; // What was the last reported state + + if (!s_connected && m_PIServerEndpoint == ENDPOINT_PIWEB_API) + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + + if (now >= nextCheck) + { + int httpCode = PIWebAPIGetVersion(false); + if (httpCode >= 400) + { + s_connected = false; + now = std::chrono::steady_clock::now(); + nextCheck = now + std::chrono::seconds(60); + Logger::getLogger()->debug("PI Web API %s is not available. HTTP Code: %d", m_hostAndPort.c_str(), httpCode); + if (reported == false || reportedState == true) + { + reportedState = false; + reported = true; + Logger::getLogger()->error("The PI Web API service %s is not available", + m_hostAndPort.c_str()); + } + } + else + { + s_connected = true; + SetOMFVersion(); + Logger::getLogger()->info("%s reconnected to %s OMF Version: %s", + m_RestServerVersion.c_str(), m_hostAndPort.c_str(), m_omfversion.c_str()); + if (reported == true || reportedState == false) + { + reportedState = true; + reported = true; + Logger::getLogger()->warn("The PI Web API service %s has become available", + m_hostAndPort.c_str()); + } + } + } + } + else + { + // Endpoints other than PI Web API fail quickly when they are unavailable + // so there is no need to check their status in advance. + s_connected = true; + } + + return s_connected; +} diff --git a/C/plugins/north/OMF/plugin.cpp b/C/plugins/north/OMF/plugin.cpp index 189f993b32..6d2d543c71 100755 --- a/C/plugins/north/OMF/plugin.cpp +++ b/C/plugins/north/OMF/plugin.cpp @@ -41,7 +41,6 @@ #include #include #include -#include #include #include #include @@ -56,7 +55,7 @@ #include "utils.h" #include "string_utils.h" #include - +#include #include "crypto.hpp" @@ -67,70 +66,6 @@ using namespace std; using namespace rapidjson; using namespace SimpleWeb; - -#define PLUGIN_NAME "OMF" -#define TYPE_ID_KEY "type-id" -#define SENT_TYPES_KEY "sentDataTypes" -#define DATA_KEY "dataTypes" -#define DATA_KEY_SHORT "dataTypesShort" -#define DATA_KEY_HINT "hintChecksum" -#define NAMING_SCHEME "namingScheme" -#define AFH_HASH "afhHash" -#define AF_HIERARCHY "afHierarchy" -#define AF_HIERARCHY_ORIG "afHierarchyOrig" - - -#define PROPERTY_TYPE "type" -#define PROPERTY_NUMBER "number" -#define PROPERTY_STRING "string" - - -#define ENDPOINT_URL_PI_WEB_API "https://HOST_PLACEHOLDER:PORT_PLACEHOLDER/piwebapi/omf" -#define ENDPOINT_URL_CR "https://HOST_PLACEHOLDER:PORT_PLACEHOLDER/ingress/messages" -#define ENDPOINT_URL_OCS "https://REGION_PLACEHOLDER.osisoft.com:PORT_PLACEHOLDER/api/v1/tenants/TENANT_ID_PLACEHOLDER/Namespaces/NAMESPACE_ID_PLACEHOLDER/omf" -#define ENDPOINT_URL_ADH "https://REGION_PLACEHOLDER.datahub.connect.aveva.com:PORT_PLACEHOLDER/api/v1/Tenants/TENANT_ID_PLACEHOLDER/Namespaces/NAMESPACE_ID_PLACEHOLDER/omf" - -#define ENDPOINT_URL_EDS "http://localhost:PORT_PLACEHOLDER/api/v1/tenants/default/namespaces/default/omf" - -static bool s_connected = true; // if true, access to PI Web API is working - -enum OMF_ENDPOINT_PORT { - ENDPOINT_PORT_PIWEB_API=443, - ENDPOINT_PORT_CR=5460, - ENDPOINT_PORT_OCS=443, - ENDPOINT_PORT_EDS=5590, - ENDPOINT_PORT_ADH=443 -}; - -/** - * Plugin specific default configuration - */ - -#define NOT_BLOCKING_ERRORS_DEFAULT QUOTE( \ - { \ - "errors400" : [ \ - "Redefinition of the type with the same ID is not allowed", \ - "Invalid value type for the property", \ - "Property does not exist in the type definition", \ - "Container is not defined", \ - "Unable to find the property of the container of type" \ - ] \ - } \ -) - -#define NOT_BLOCKING_ERRORS_DEFAULT_PI_WEB_API QUOTE( \ - { \ - "EventInfo" : [ \ - "The specified value is outside the allowable range" \ - ] \ - } \ -) - -#define AF_HIERARCHY_RULES QUOTE( \ - { \ - } \ -) - /* * Note that the properties "group" is used to group related items, these will appear in different tabs, * using the group name, in the GUI. @@ -404,78 +339,6 @@ const char *PLUGIN_DEFAULT_CONFIG_INFO = QUOTE( // "default": "{\"pipeline\": [\"DeltaFilter\"]}" -/** - * Historian PI Server connector info - */ -typedef struct -{ - HttpSender *sender; // HTTPS connection - OMF *omf; // OMF data protocol - bool sendFullStructure; // It sends the minimum OMF structural messages to load data into Data Archive if disabled - bool compression; // whether to compress readings' data - string protocol; // http / https - string hostAndPort; // hostname:port for SimpleHttps - unsigned int retrySleepTime; // Seconds between each retry - unsigned int maxRetry; // Max number of retries in the communication - unsigned int timeout; // connect and operation timeout - string path; // PI Server application path - long typeId; // OMF protocol type-id prefix - string producerToken; // PI Server connector token - string formatNumber; // OMF protocol Number format - string formatInteger; // OMF protocol Integer format - OMF_ENDPOINT PIServerEndpoint; // Defines which End point should be used for the communication - NAMINGSCHEME_ENDPOINT NamingScheme; // Define how the object names should be generated - https://fledge-iot.readthedocs.io/en/latest/OMF.html#naming-scheme - string DefaultAFLocation; // 1st hierarchy in Asset Framework, PI Web API only. - string AFMap; // Defines a set of rules to address where assets should be placed in the AF hierarchy. - // https://fledge-iot.readthedocs.io/en/latest/OMF.html#asset-framework-hierarchy-rules - - string prefixAFAsset; // Prefix to generate unique asste id - string PIWebAPIProductTitle; - string RestServerVersion; - string PIWebAPIAuthMethod; // Authentication method to be used with the PI Web API. - string PIWebAPICredentials; // Credentials is the base64 encoding of id and password joined by a single colon (:) - string KerberosKeytab; // Kerberos authentication keytab file - // stores the environment variable value about the keytab file path - // to allow the environment to persist for all the execution of the plugin - // - // Note : A keytab is a file containing pairs of Kerberos principals - // and encrypted keys (which are derived from the Kerberos password). - // You can use a keytab file to authenticate to various remote systems - // using Kerberos without entering a password. - - string OCSNamespace; // OCS configurations - string OCSTenantId; - string OCSClientId; - string OCSClientSecret; - string OCSToken; - - vector> - staticData; // Static data - // Errors considered not blocking in the communication with the PI Server - std::vector - notBlockingErrors; - // Per asset DataTypes - std::map - assetsDataTypes; - string omfversion; - bool legacy; - string name; -} CONNECTOR_INFO; - -unsigned long calcTypeShort (const string& dataTypes); -string saveSentDataTypes (CONNECTOR_INFO* connInfo); -void loadSentDataTypes (CONNECTOR_INFO* connInfo, Document& JSONData); -long getMaxTypeId (CONNECTOR_INFO* connInfo); -OMF_ENDPOINT identifyPIServerEndpoint (CONNECTOR_INFO* connInfo); -string AuthBasicCredentialsGenerate (string& userId, string& password); -void AuthKerberosSetup (string& keytabFile, string& keytabFileName); -string OCSRetrieveAuthToken (CONNECTOR_INFO* connInfo); -int PIWebAPIGetVersion (CONNECTOR_INFO* connInfo, bool logMessage = true); -int EDSGetVersion (CONNECTOR_INFO* connInfo); -double GetElapsedTime (struct timeval *startTime); -bool IsPIWebAPIConnected (CONNECTOR_INFO* connInfo); -void SetOMFVersion (CONNECTOR_INFO* connInfo); - /** * Return the information about this plugin @@ -523,271 +386,12 @@ PLUGIN_HANDLE plugin_init(ConfigCategory* configData) * Handle the PI Server parameters here */ // Allocate connector struct - CONNECTOR_INFO *connInfo = new CONNECTOR_INFO; - connInfo->name = configData->getName(); - - // PIServerEndpoint handling - string PIServerEndpoint = configData->getValue("PIServerEndpoint"); - string ADHRegions = configData->getValue("ADHRegions"); - string ServerHostname = configData->getValue("ServerHostname"); - if (gethostbyname(ServerHostname.c_str()) == NULL) - { - Logger::getLogger()->warn("Unable to resolve server hostname '%s'. This should be a valid hostname or IP Address.", ServerHostname.c_str()); - } - string ServerPort = configData->getValue("ServerPort"); - string url; - string NamingScheme = configData->getValue("NamingScheme"); - { - // Translate the PIServerEndpoint configuration - if(PIServerEndpoint.compare("PI Web API") == 0) - { - Logger::getLogger()->debug("PI-Server end point manually selected - PI Web API "); - connInfo->PIServerEndpoint = ENDPOINT_PIWEB_API; - url = ENDPOINT_URL_PI_WEB_API; - endpointPort = ENDPOINT_PORT_PIWEB_API; - } - else if(PIServerEndpoint.compare("Connector Relay") == 0) - { - Logger::getLogger()->debug("PI-Server end point manually selected - Connector Relay "); - connInfo->PIServerEndpoint = ENDPOINT_CR; - url = ENDPOINT_URL_CR; - endpointPort = ENDPOINT_PORT_CR; - } - else if(PIServerEndpoint.compare("AVEVA Data Hub") == 0) - { - Logger::getLogger()->debug("End point manually selected - AVEVA Data Hub"); - connInfo->PIServerEndpoint = ENDPOINT_ADH; - url = ENDPOINT_URL_ADH; - std::string region = "uswe"; - if(ADHRegions.compare("EU-West") == 0) - region = "euno"; - else if(ADHRegions.compare("Australia") == 0) - region = "auea"; - StringReplace(url, "REGION_PLACEHOLDER", region); - endpointPort = ENDPOINT_PORT_ADH; - } - else if(PIServerEndpoint.compare("OSIsoft Cloud Services") == 0) - { - Logger::getLogger()->debug("End point manually selected - OSIsoft Cloud Services"); - connInfo->PIServerEndpoint = ENDPOINT_OCS; - url = ENDPOINT_URL_OCS; - std::string region = "dat-b"; - if(ADHRegions.compare("EU-West") == 0) - region = "dat-d"; - else if(ADHRegions.compare("Australia") == 0) - Logger::getLogger()->error("OSIsoft Cloud Services are not hosted in Australia"); - StringReplace(url, "REGION_PLACEHOLDER", region); - endpointPort = ENDPOINT_PORT_OCS; - } - else if(PIServerEndpoint.compare("Edge Data Store") == 0) - { - Logger::getLogger()->debug("End point manually selected - Edge Data Store"); - connInfo->PIServerEndpoint = ENDPOINT_EDS; - url = ENDPOINT_URL_EDS; - endpointPort = ENDPOINT_PORT_EDS; - } - ServerPort = (ServerPort.compare("0") == 0) ? to_string(endpointPort) : ServerPort; - } - - if (endpointPort == ENDPOINT_PORT_PIWEB_API) { - - // Use SendFullStructure ? - string fullStr = configData->getValue("SendFullStructure"); - - if (fullStr == "True" || fullStr == "true" || fullStr == "TRUE") - connInfo->sendFullStructure = true; - else - connInfo->sendFullStructure = false; - } else { - connInfo->sendFullStructure = true; - } - - unsigned int retrySleepTime = atoi(configData->getValue("OMFRetrySleepTime").c_str()); - unsigned int maxRetry = atoi(configData->getValue("OMFMaxRetry").c_str()); - unsigned int timeout = atoi(configData->getValue("OMFHttpTimeout").c_str()); - - string producerToken = configData->getValue("producerToken"); - - string formatNumber = configData->getValue("formatNumber"); - string formatInteger = configData->getValue("formatInteger"); - string DefaultAFLocation = configData->getValue("DefaultAFLocation"); - string AFMap = configData->getValue("AFMap"); - - string PIWebAPIAuthMethod = configData->getValue("PIWebAPIAuthenticationMethod"); - string PIWebAPIUserId = configData->getValue("PIWebAPIUserId"); - string PIWebAPIPassword = configData->getValue("PIWebAPIPassword"); - string KerberosKeytabFileName = configData->getValue("PIWebAPIKerberosKeytabFileName"); - - // OCS configurations - string OCSNamespace = configData->getValue("OCSNamespace"); - string OCSTenantId = configData->getValue("OCSTenantId"); - string OCSClientId = configData->getValue("OCSClientId"); - string OCSClientSecret = configData->getValue("OCSClientSecret"); - - StringReplace(url, "HOST_PLACEHOLDER", ServerHostname); - StringReplace(url, "PORT_PLACEHOLDER", ServerPort); - - // TENANT_ID_PLACEHOLDER and NAMESPACE_ID_PLACEHOLDER, if present, will be replaced with the values of OCSTenantId and OCSNamespace - StringReplace(url, "TENANT_ID_PLACEHOLDER", OCSTenantId); - StringReplace(url, "NAMESPACE_ID_PLACEHOLDER", OCSNamespace); - - /** - * Extract host, port, path from URL - */ - size_t findProtocol = url.find_first_of(":"); - string protocol = url.substr(0, findProtocol); - - string tmpUrl = url.substr(findProtocol + 3); - size_t findPort = tmpUrl.find_first_of(":"); - string hostName = tmpUrl.substr(0, findPort); - - size_t findPath = tmpUrl.find_first_of("/"); - string port = tmpUrl.substr(findPort + 1, findPath - findPort - 1); - string path = tmpUrl.substr(findPath); - - string hostAndPort(hostName + ":" + port); - - // Set configuration fields - connInfo->protocol = protocol; - connInfo->hostAndPort = hostAndPort; - connInfo->path = path; - connInfo->retrySleepTime = retrySleepTime; - connInfo->maxRetry = maxRetry; - connInfo->timeout = timeout; - connInfo->typeId = TYPE_ID_DEFAULT; - connInfo->producerToken = producerToken; - connInfo->formatNumber = formatNumber; - connInfo->formatInteger = formatInteger; - connInfo->DefaultAFLocation = DefaultAFLocation; - connInfo->AFMap = AFMap; - - // OCS configurations - connInfo->OCSNamespace = OCSNamespace; - connInfo->OCSTenantId = OCSTenantId; - connInfo->OCSClientId = OCSClientId; - connInfo->OCSClientSecret = OCSClientSecret; - - // PI Web API end-point - evaluates the authentication method requested - if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) - { - if (PIWebAPIAuthMethod.compare("anonymous") == 0) - { - Logger::getLogger()->debug("PI Web API end-point - anonymous authentication"); - connInfo->PIWebAPIAuthMethod = "a"; - } - else if (PIWebAPIAuthMethod.compare("basic") == 0) - { - Logger::getLogger()->debug("PI Web API end-point - basic authentication"); - connInfo->PIWebAPIAuthMethod = "b"; - connInfo->PIWebAPICredentials = AuthBasicCredentialsGenerate(PIWebAPIUserId, PIWebAPIPassword); - } - else if (PIWebAPIAuthMethod.compare("kerberos") == 0) - { - Logger::getLogger()->debug("PI Web API end-point - kerberos authentication"); - connInfo->PIWebAPIAuthMethod = "k"; - AuthKerberosSetup(connInfo->KerberosKeytab, KerberosKeytabFileName); - } - else - { - Logger::getLogger()->error("Invalid authentication method for PI Web API :%s: ", PIWebAPIAuthMethod.c_str()); - } - } - else - { - // For all other endpoint types, set PI Web API authentication to 'anonymous.' - // This prevents the HttpSender from inserting PI Web API authentication headers. - connInfo->PIWebAPIAuthMethod = "a"; - } - - // Use compression ? - string compr = configData->getValue("compression"); - if (compr == "True" || compr == "true" || compr == "TRUE") - connInfo->compression = true; - else - connInfo->compression = false; - - // Set the list of errors considered not blocking in the communication - // with the PI Server - if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) - { - JSONStringToVectorString(connInfo->notBlockingErrors , - configData->getValue("PIWebAPInotBlockingErrors"), - std::string("EventInfo")); - } - else - { - JSONStringToVectorString(connInfo->notBlockingErrors , - configData->getValue("notBlockingErrors"), - std::string("errors400")); - } - /** - * Add static data - * Split the string up into each pair - */ - string staticData = configData->getValue("StaticData"); - size_t pos = 0; - size_t start = 0; - do { - pos = staticData.find(",", start); - string item = staticData.substr(start, pos); - start = pos + 1; - size_t pos2 = 0; - if ((pos2 = item.find(":")) != string::npos) - { - string name = item.substr(0, pos2); - while (name[0] == ' ') - name = name.substr(1); - string value = item.substr(pos2 + 1); - while (value[0] == ' ') - value = value.substr(1); - pair sData = make_pair(name, value); - connInfo->staticData.push_back(sData); - } - } while (pos != string::npos); - - { - // NamingScheme handling - if(NamingScheme.compare("Concise") == 0) - { - connInfo->NamingScheme = NAMINGSCHEME_CONCISE; - } - else if(NamingScheme.compare("Use Type Suffix") == 0) - { - connInfo->NamingScheme = NAMINGSCHEME_SUFFIX; - } - else if(NamingScheme.compare("Use Attribute Hash") == 0) - { - connInfo->NamingScheme = NAMINGSCHEME_HASH; - } - else if(NamingScheme.compare("Backward compatibility") == 0) - { - connInfo->NamingScheme = NAMINGSCHEME_COMPATIBILITY; - } - Logger::getLogger()->debug("End point naming scheme :%s: ", NamingScheme.c_str() ); - - } - - // Fetch legacy OMF type option - string legacy = configData->getValue("Legacy"); - if (legacy == "True" || legacy == "true" || legacy == "TRUE") - connInfo->legacy = true; - else - connInfo->legacy = false; - -#if VERBOSE_LOG - // Log plugin configuration - Logger::getLogger()->info("%s plugin configured: URL=%s, " - "producerToken=%s, compression=%s", - PLUGIN_NAME, - url.c_str(), - producerToken.c_str(), - connInfo->compression ? "True" : "False"); -#endif + OMFInformation *info = new OMFInformation(configData); #if INSTRUMENT Logger::getLogger()->debug("plugin_init elapsed time: %6.3f seconds", GetElapsedTime(&startTime)); #endif - return (PLUGIN_HANDLE)connInfo; + return (PLUGIN_HANDLE)info; } @@ -812,89 +416,9 @@ void plugin_start(const PLUGIN_HANDLE handle, #endif Logger* logger = Logger::getLogger(); - CONNECTOR_INFO* connInfo = (CONNECTOR_INFO *)handle; - - logger->info("Host: %s", connInfo->hostAndPort.c_str()); - if ((connInfo->PIServerEndpoint == ENDPOINT_OCS) || (connInfo->PIServerEndpoint == ENDPOINT_ADH)) - { - logger->info("Namespace: %s", connInfo->OCSNamespace.c_str()); - } - - // Parse JSON plugin_data - Document JSONData; - JSONData.Parse(storedData.c_str()); - if (JSONData.HasParseError()) - { - logger->error("%s plugin error: failure parsing " - "plugin data JSON object '%s'", - PLUGIN_NAME, - storedData.c_str()); - } - else if (JSONData.HasMember(TYPE_ID_KEY) && - (JSONData[TYPE_ID_KEY].IsString() || - JSONData[TYPE_ID_KEY].IsNumber())) - { - // Update type-id in PLUGIN_HANDLE object - if (JSONData[TYPE_ID_KEY].IsNumber()) - { - connInfo->typeId = JSONData[TYPE_ID_KEY].GetInt(); - } - else - { - connInfo->typeId = atol(JSONData[TYPE_ID_KEY].GetString()); - } - } - - // Load sentdataTypes - loadSentDataTypes(connInfo, JSONData); - - // Log default type-id - if (connInfo->assetsDataTypes.size() == 1 && - connInfo->assetsDataTypes.find(FAKE_ASSET_KEY) != connInfo->assetsDataTypes.end()) - { - // Only one value: we have the FAKE_ASSET_KEY and no other data - Logger::getLogger()->info("%s plugin is using global OMF prefix %s=%d", - PLUGIN_NAME, - TYPE_ID_KEY, - connInfo->typeId); - } - else - { - Logger::getLogger()->info("%s plugin is using per asset OMF prefix %s=%d " - "(max value found)", - PLUGIN_NAME, - TYPE_ID_KEY, - getMaxTypeId(connInfo)); - } + OMFInformation *info = (OMFInformation *)handle; + info->start(storedData); - // Retrieve the PI Web API Version - s_connected = true; - if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) - { - int httpCode = PIWebAPIGetVersion(connInfo); - if (httpCode >= 200 && httpCode < 400) - { - SetOMFVersion(connInfo); - Logger::getLogger()->info("%s connected to %s OMF Version: %s", - connInfo->RestServerVersion.c_str(), connInfo->hostAndPort.c_str(), connInfo->omfversion.c_str()); - s_connected = true; - } - else - { - s_connected = false; - } - } - else if (connInfo->PIServerEndpoint == ENDPOINT_EDS) - { - EDSGetVersion(connInfo); - SetOMFVersion(connInfo); - Logger::getLogger()->info("Edge Data Store %s OMF Version: %s", connInfo->RestServerVersion.c_str(), connInfo->omfversion.c_str()); - } - else - { - SetOMFVersion(connInfo); - Logger::getLogger()->info("OMF Version: %s", connInfo->omfversion.c_str()); - } #if INSTRUMENT Logger::getLogger()->debug("plugin_start elapsed time: %6.3f seconds", GetElapsedTime(&startTime)); @@ -907,152 +431,8 @@ void plugin_start(const PLUGIN_HANDLE handle, uint32_t plugin_send(const PLUGIN_HANDLE handle, const vector& readings) { -#if INSTRUMENT - struct timeval startTime; - gettimeofday(&startTime, NULL); -#endif - CONNECTOR_INFO* connInfo = (CONNECTOR_INFO *)handle; - string version; - - // Check if the endpoint is PI Web API and if the PI Web API server is available - if (!IsPIWebAPIConnected(connInfo)) - { - // Error already reported by IsPIWebAPIConnected - return 0; - } - - /** - * Select the transport library based on the authentication method and transport encryption - * requirements. - * - * LibcurlHttps is used to integrate Kerberos as the SimpleHttp does not support it - * the Libcurl integration implements only HTTPS not HTTP currently. We use SimpleHttp or - * SimpleHttps, as appropriate for the URL given, if not using Kerberos - * - * - * The handler is allocated using "Hostname : port", connect_timeout and request_timeout. - * Default is no timeout - */ - if (connInfo->PIWebAPIAuthMethod.compare("k") == 0) - { - connInfo->sender = new LibcurlHttps(connInfo->hostAndPort, - connInfo->timeout, - connInfo->timeout, - connInfo->retrySleepTime, - connInfo->maxRetry); - } - else - { - if (connInfo->protocol.compare("http") == 0) - { - connInfo->sender = new SimpleHttp(connInfo->hostAndPort, - connInfo->timeout, - connInfo->timeout, - connInfo->retrySleepTime, - connInfo->maxRetry); - } - else - { - connInfo->sender = new SimpleHttps(connInfo->hostAndPort, - connInfo->timeout, - connInfo->timeout, - connInfo->retrySleepTime, - connInfo->maxRetry); - } - } - - connInfo->sender->setAuthMethod (connInfo->PIWebAPIAuthMethod); - connInfo->sender->setAuthBasicCredentials(connInfo->PIWebAPICredentials); - - // OCS configurations - connInfo->sender->setOCSNamespace (connInfo->OCSNamespace); - connInfo->sender->setOCSTenantId (connInfo->OCSTenantId); - connInfo->sender->setOCSClientId (connInfo->OCSClientId); - connInfo->sender->setOCSClientSecret (connInfo->OCSClientSecret); - - // OCS or ADH - retrieves the authentication token - // It is retrieved at every send as it can expire and the configuration is only in OCS and ADH - if (connInfo->PIServerEndpoint == ENDPOINT_OCS || connInfo->PIServerEndpoint == ENDPOINT_ADH) - { - connInfo->OCSToken = OCSRetrieveAuthToken(connInfo); - connInfo->sender->setOCSToken (connInfo->OCSToken); - } - - // Allocate the OMF class that implements the PI Server data protocol - connInfo->omf = new OMF(connInfo->name, - *connInfo->sender, - connInfo->path, - connInfo->assetsDataTypes, - connInfo->producerToken); - - connInfo->omf->setConnected(s_connected); - connInfo->omf->setSendFullStructure(connInfo->sendFullStructure); - - // Set PIServerEndpoint configuration - connInfo->omf->setNamingScheme(connInfo->NamingScheme); - connInfo->omf->setPIServerEndpoint(connInfo->PIServerEndpoint); - connInfo->omf->setDefaultAFLocation(connInfo->DefaultAFLocation); - connInfo->omf->setAFMap(connInfo->AFMap); - - connInfo->omf->setOMFVersion(connInfo->omfversion); - - // Generates the prefix to have unique asset_id across different levels of hierarchies - string AFHierarchyLevel; - connInfo->omf->generateAFHierarchyPrefixLevel(connInfo->DefaultAFLocation, connInfo->prefixAFAsset, AFHierarchyLevel); - - connInfo->omf->setPrefixAFAsset(connInfo->prefixAFAsset); - - // Set OMF FormatTypes - connInfo->omf->setFormatType(OMF_TYPE_FLOAT, - connInfo->formatNumber); - connInfo->omf->setFormatType(OMF_TYPE_INTEGER, - connInfo->formatInteger); - - connInfo->omf->setStaticData(&connInfo->staticData); - connInfo->omf->setNotBlockingErrors(connInfo->notBlockingErrors); - - if (connInfo->omfversion == "1.1" || connInfo->omfversion == "1.0") { - Logger::getLogger()->info("Setting LegacyType to be true for OMF Version '%s'. This will force use old style complex types. ", connInfo->omfversion.c_str()); - connInfo->omf->setLegacyMode(true); - } - else - { - connInfo->omf->setLegacyMode(connInfo->legacy); - } - // Send the readings data to the PI Server - uint32_t ret = connInfo->omf->sendToServer(readings, - connInfo->compression); - - // Detect typeId change in OMF class - if (connInfo->omf->getTypeId() != connInfo->typeId) - { - // Update typeId in plugin handle - connInfo->typeId = connInfo->omf->getTypeId(); - // Log change - Logger::getLogger()->info("%s plugin: a new OMF global %s (%d) has been created.", - PLUGIN_NAME, - TYPE_ID_KEY, - connInfo->typeId); - } - - // Write a warning if the connection to PI Web API has been lost - bool updatedConnected = connInfo->omf->getConnected(); - if (connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API && s_connected && !updatedConnected) - { - Logger::getLogger()->warn("Connection to PI Web API at %s has been lost", connInfo->hostAndPort.c_str()); - } - s_connected = updatedConnected; - - // Delete objects - delete connInfo->sender; - delete connInfo->omf; - -#if INSTRUMENT - Logger::getLogger()->debug("plugin_send elapsed time: %6.3f seconds, NumValues: %u", GetElapsedTime(&startTime), ret); -#endif - - // Return sent data ret code - return ret; + OMFInformation *info = (OMFInformation *)handle; + return info->send(readings); } /** @@ -1068,860 +448,13 @@ uint32_t plugin_send(const PLUGIN_HANDLE handle, */ string plugin_shutdown(PLUGIN_HANDLE handle) { -#if INSTRUMENT - struct timeval startTime; - gettimeofday(&startTime, NULL); -#endif - // Delete the handle - CONNECTOR_INFO* connInfo = (CONNECTOR_INFO *) handle; - - // Create save data - std::ostringstream saveData; - saveData << "{"; - - // Add sent data types - string typesData = saveSentDataTypes(connInfo); - if (!typesData.empty()) - { - // Save datatypes - saveData << typesData; - } - else - { - // Just save type-id - saveData << "\"" << TYPE_ID_KEY << "\": " << to_string(connInfo->typeId); - } - - saveData << "}"; + OMFInformation *info = (OMFInformation *) handle; - // Log saving the plugin configuration - Logger::getLogger()->debug("%s plugin: saving plugin_data '%s'", - PLUGIN_NAME, - saveData.str().c_str()); - - // Delete plugin handle - delete connInfo; - -#if INSTRUMENT - // For debugging: write plugin's JSON data to a file - string jsonFilePath = getDataDir() + string("/logs/OMFSaveData.json"); - ofstream f(jsonFilePath.c_str(), ios_base::trunc); - f << saveData.str(); - f.close(); - - Logger::getLogger()->debug("plugin_shutdown elapsed time: %6.3f seconds", GetElapsedTime(&startTime)); -#endif - - // Return current plugin data to save - return saveData.str(); + string rval = info->saveData(); + delete info; + return rval; } // End of extern "C" }; - -/** - * Return a JSON string with the dataTypes to save in plugin_data - * - * Note: the entry with FAKE_ASSET_KEY is never saved. - * - * @param connInfo The CONNECTOR_INFO data structure - * @return The string with JSON data - */ -string saveSentDataTypes(CONNECTOR_INFO* connInfo) -{ - string ret; - std::ostringstream newData; - - auto it = connInfo->assetsDataTypes.find(FAKE_ASSET_KEY); - if (it != connInfo->assetsDataTypes.end()) - { - // Set typeId in FAKE_ASSET_KEY - connInfo->typeId = (*it).second.typeId; - // Remove the entry - connInfo->assetsDataTypes.erase(it); - } - - - unsigned long tSize = connInfo->assetsDataTypes.size(); - if (tSize) - { - - // Prepare output data (skip empty data types) - newData << "\"" << SENT_TYPES_KEY << "\" : ["; - - bool pendingSeparator = false; - for (auto it = connInfo->assetsDataTypes.begin(); - it != connInfo->assetsDataTypes.end(); - ++it) - { - if (((*it).second).types.compare("{}") != 0) - { - newData << (pendingSeparator ? ", " : ""); - newData << "{\"" << (*it).first << "\" : {\"" << TYPE_ID_KEY << - "\": " << to_string(((*it).second).typeId); - - // The information should be stored as string in hexadecimal format - std::stringstream tmpStream; - tmpStream << std::hex << ((*it).second).typesShort; - std::string typesShort = tmpStream.str(); - - newData << ", \"" << DATA_KEY_SHORT << "\": \"0x" << typesShort << "\""; - std::stringstream hintStream; - hintStream << std::hex << ((*it).second).hintChkSum; - std::string hintChecksum = hintStream.str(); - newData << ", \"" << DATA_KEY_HINT << "\": \"0x" << hintChecksum << "\""; - - long NamingScheme; - NamingScheme = ((*it).second).namingScheme; - newData << ", \"" << NAMING_SCHEME << "\": " << to_string(NamingScheme) << ""; - - string AFHHash; - AFHHash = ((*it).second).afhHash; - newData << ", \"" << AFH_HASH << "\": \"" << AFHHash << "\""; - - string AFHierarchy; - AFHierarchy = ((*it).second).afHierarchy; - newData << ", \"" << AF_HIERARCHY << "\": \"" << AFHierarchy << "\""; - - string AFHierarchyOrig; - AFHierarchyOrig = ((*it).second).afHierarchyOrig; - newData << ", \"" << AF_HIERARCHY_ORIG << "\": \"" << AFHierarchyOrig << "\""; - - Logger::getLogger()->debug("%s - AFHHash :%s: AFHierarchy :%s: AFHierarchyOrig :%s:", __FUNCTION__, AFHHash.c_str(), AFHierarchy.c_str(), AFHierarchyOrig.c_str() ); - Logger::getLogger()->debug("%s - NamingScheme :%ld: ", __FUNCTION__,NamingScheme ); - - newData << ", \"" << DATA_KEY << "\": " << - (((*it).second).types.empty() ? "{}" : ((*it).second).types) << - "}}"; - pendingSeparator = true; - } - } - - tSize = connInfo->assetsDataTypes.size(); - if (!tSize) - { - // DataTypes map is empty - return ret; - } - - newData << "]"; - - ret = newData.str(); - } - - return ret; -} - - -/** - * Calculate the TypeShort in the case it is missing loading type definition - * - * Generate a 64 bit number containing a set of counts, - * number of datapoints in an asset and the number of datapoint of each type we support. - * - */ -unsigned long calcTypeShort(const string& dataTypes) -{ - union t_typeCount { - struct - { - unsigned char tTotal; - unsigned char tFloat; - unsigned char tString; - unsigned char spare0; - - unsigned char spare1; - unsigned char spare2; - unsigned char spare3; - unsigned char spare4; - } cnt; - unsigned long valueLong = 0; - - } typeCount; - - Document JSONData; - JSONData.Parse(dataTypes.c_str()); - - if (JSONData.HasParseError()) - { - Logger::getLogger()->error("calcTypeShort - unable to calculate TypeShort on :%s: ", dataTypes.c_str()); - return (0); - } - - for (Value::ConstMemberIterator it = JSONData.MemberBegin(); it != JSONData.MemberEnd(); ++it) - { - - string key = it->name.GetString(); - const Value& value = it->value; - - if (value.HasMember(PROPERTY_TYPE) && value[PROPERTY_TYPE].IsString()) - { - string type =value[PROPERTY_TYPE].GetString(); - - // Integer is handled as float in the OMF integration - if (type.compare(PROPERTY_NUMBER) == 0) - { - typeCount.cnt.tFloat++; - } else if (type.compare(PROPERTY_STRING) == 0) - { - typeCount.cnt.tString++; - } else { - - Logger::getLogger()->error("calcTypeShort - unrecognized type :%s: ", type.c_str()); - } - typeCount.cnt.tTotal++; - } - else - { - Logger::getLogger()->error("calcTypeShort - unable to extract the type for :%s: ", key.c_str()); - return (0); - } - } - - return typeCount.valueLong; -} - - -/** - * Load stored data types (already sent to PI server) - * - * Each element, the assetName, has type-id and datatype for each datapoint - * - * If no data exists in the plugin_data table, then a map entry - * with FAKE_ASSET_KEY is made in order to set the start type-id - * sequence with default value set to 1: - * all new created OMF dataTypes have type-id prefix set to the value of 1. - * - * If data like {"type-id": 14} or {"type-id": "14" } is found, a map entry - * with FAKE_ASSET_KEY is made and the start type-id sequence value is set - * to the found value, i.e. 14: - * all new created OMF dataTypes have type-id prefix set to the value of 14. - * - * If proper per asset types data is loaded, the FAKE_ASSET_KEY is not set: - * all new created OMF dataTypes have type-id prefix set to the value of 1 - * while existing (loaded) OMF dataTypes will keep their type-id values. - * - * @param connInfo The CONNECTOR_INFO data structure - * @param JSONData The JSON document containing all saved data - */ -void loadSentDataTypes(CONNECTOR_INFO* connInfo, - Document& JSONData) -{ - if (JSONData.HasMember(SENT_TYPES_KEY) && - JSONData[SENT_TYPES_KEY].IsArray()) - { - const Value& cachedTypes = JSONData[SENT_TYPES_KEY]; - for (Value::ConstValueIterator it = cachedTypes.Begin(); - it != cachedTypes.End(); - ++it) - { - if (!it->IsObject()) - { - Logger::getLogger()->warn("%s plugin: current element in '%s' " \ - "property is not an object, ignoring it", - PLUGIN_NAME, - SENT_TYPES_KEY); - continue; - } - - for (Value::ConstMemberIterator itr = it->MemberBegin(); - itr != it->MemberEnd(); - ++itr) - { - string key = itr->name.GetString(); - const Value& cachedValue = itr->value; - - // Add typeId and dataTypes to the in memory cache - long typeId; - if (cachedValue.HasMember(TYPE_ID_KEY) && - cachedValue[TYPE_ID_KEY].IsNumber()) - { - typeId = cachedValue[TYPE_ID_KEY].GetInt(); - } - else - { - Logger::getLogger()->warn("%s plugin: current element '%s'" \ - " doesn't have '%s' property, ignoring it", - PLUGIN_NAME, - key.c_str(), - TYPE_ID_KEY); - continue; - } - - long NamingScheme; - if (cachedValue.HasMember(NAMING_SCHEME) && - cachedValue[NAMING_SCHEME].IsNumber()) - { - NamingScheme = cachedValue[NAMING_SCHEME].GetInt(); - } - else - { - Logger::getLogger()->warn("%s plugin: current element '%s'" \ - " doesn't have '%s' property, handling naming scheme in compatibility mode", - PLUGIN_NAME, - key.c_str(), - NAMING_SCHEME); - NamingScheme = NAMINGSCHEME_COMPATIBILITY; - } - - string AFHHash; - if (cachedValue.HasMember(AFH_HASH) && - cachedValue[AFH_HASH].IsString()) - { - AFHHash = cachedValue[AFH_HASH].GetString(); - } - else - { - Logger::getLogger()->warn("%s plugin: current element '%s'" \ - " doesn't have '%s' property", - PLUGIN_NAME, - key.c_str(), - AFH_HASH); - AFHHash = ""; - } - - string AFHierarchy; - if (cachedValue.HasMember(AF_HIERARCHY) && - cachedValue[AF_HIERARCHY].IsString()) - { - AFHierarchy = cachedValue[AF_HIERARCHY].GetString(); - } - else - { - Logger::getLogger()->warn("%s plugin: current element '%s'" \ - " doesn't have '%s' property", - PLUGIN_NAME, - key.c_str(), - AF_HIERARCHY); - AFHierarchy = ""; - } - - string AFHierarchyOrig; - if (cachedValue.HasMember(AF_HIERARCHY_ORIG) && - cachedValue[AF_HIERARCHY_ORIG].IsString()) - { - AFHierarchyOrig = cachedValue[AF_HIERARCHY_ORIG].GetString(); - } - else - { - Logger::getLogger()->warn("%s plugin: current element '%s'" \ - " doesn't have '%s' property", - PLUGIN_NAME, - key.c_str(), - AF_HIERARCHY_ORIG); - AFHierarchyOrig = ""; - } - - string dataTypes; - if (cachedValue.HasMember(DATA_KEY) && - cachedValue[DATA_KEY].IsObject()) - { - StringBuffer buffer; - Writer writer(buffer); - const Value& types = cachedValue[DATA_KEY]; - types.Accept(writer); - dataTypes = buffer.GetString(); - } - else - { - Logger::getLogger()->warn("%s plugin: current element '%s'" \ - " doesn't have '%s' property, ignoring it", - PLUGIN_NAME, - key.c_str(), - DATA_KEY); - - continue; - } - - unsigned long dataTypesShort; - if (cachedValue.HasMember(DATA_KEY_SHORT) && - cachedValue[DATA_KEY_SHORT].IsString()) - { - string strDataTypesShort = cachedValue[DATA_KEY_SHORT].GetString(); - // The information are stored as string in hexadecimal format - dataTypesShort = stoi (strDataTypesShort,nullptr,16); - } - else - { - dataTypesShort = calcTypeShort(dataTypes); - if (dataTypesShort == 0) - { - Logger::getLogger()->warn("%s plugin: current element '%s'" \ - " doesn't have '%s' property", - PLUGIN_NAME, - key.c_str(), - DATA_KEY_SHORT); - } - else - { - Logger::getLogger()->warn("%s plugin: current element '%s'" \ - " doesn't have '%s' property, calculated '0x%X'", - PLUGIN_NAME, - key.c_str(), - DATA_KEY_SHORT, - dataTypesShort); - } - } - unsigned short hintChecksum = 0; - if (cachedValue.HasMember(DATA_KEY_HINT) && - cachedValue[DATA_KEY_HINT].IsString()) - { - string strHint = cachedValue[DATA_KEY_HINT].GetString(); - // The information are stored as string in hexadecimal format - hintChecksum = stoi (strHint,nullptr,16); - } - OMFDataTypes dataType; - dataType.typeId = typeId; - dataType.types = dataTypes; - dataType.typesShort = dataTypesShort; - dataType.hintChkSum = hintChecksum; - dataType.namingScheme = NamingScheme; - dataType.afhHash = AFHHash; - dataType.afHierarchy = AFHierarchy; - dataType.afHierarchyOrig = AFHierarchyOrig; - - Logger::getLogger()->debug("%s - AFHHash :%s: AFHierarchy :%s: AFHierarchyOrig :%s: ", __FUNCTION__, AFHHash.c_str(), AFHierarchy.c_str() , AFHierarchyOrig.c_str() ); - - - Logger::getLogger()->debug("%s - NamingScheme :%ld: ", __FUNCTION__,NamingScheme ); - - // Add data into the map - connInfo->assetsDataTypes[key] = dataType; - } - } - } - else - { - // There is no stored data when plugin starts first time - if (JSONData.MemberBegin() != JSONData.MemberEnd()) - { - Logger::getLogger()->warn("Persisted data is not of the correct format, ignoring"); - } - - OMFDataTypes dataType; - dataType.typeId = connInfo->typeId; - dataType.types = "{}"; - - // Add default data into the map - connInfo->assetsDataTypes[FAKE_ASSET_KEY] = dataType; - } -} - -/** - * Return the maximum value of type-id, among all entries in the map - * - * If the array is empty the connInfo->typeId is returned. - * - * @param connInfo The CONNECTOR_INFO data structure - * @return The maximum value of type-id found - */ -long getMaxTypeId(CONNECTOR_INFO* connInfo) -{ - long maxId = connInfo->typeId; - for (auto it = connInfo->assetsDataTypes.begin(); - it != connInfo->assetsDataTypes.end(); - ++it) - { - if ((*it).second.typeId > maxId) - { - maxId = (*it).second.typeId; - } - } - return maxId; -} - -/** - * Calls the PI Web API to retrieve the version - * - * @param connInfo The CONNECTOR_INFO data structure which includes version - * @param logMessage If true, log error messages (default: true) - * @return httpCode HTTP response code - */ -int PIWebAPIGetVersion(CONNECTOR_INFO* connInfo, bool logMessage) -{ - PIWebAPI *_PIWebAPI; - - _PIWebAPI = new PIWebAPI(); - - // Set requested authentication - _PIWebAPI->setAuthMethod (connInfo->PIWebAPIAuthMethod); - _PIWebAPI->setAuthBasicCredentials(connInfo->PIWebAPICredentials); - - int httpCode = _PIWebAPI->GetVersion(connInfo->hostAndPort, connInfo->RestServerVersion, logMessage); - delete _PIWebAPI; - - return httpCode; -} - -/** - * Finds major and minor product version numbers in a version string - * - * @param versionString Version string of the form x.x.x.x where x's are integers - * @param major Major product version returned (first digit) - * @param minor Minor product version returned (second digit) - */ -static void ParseProductVersion(std::string &versionString, int *major, int *minor) -{ - *major = 0; - *minor = 0; - size_t last = 0; - size_t next = versionString.find(".", last); - if (next != string::npos) - { - *major = atoi(versionString.substr(last, next - last).c_str()); - last = next + 1; - next = versionString.find(".", last); - if (next != string::npos) - { - *minor = atoi(versionString.substr(last, next - last).c_str()); - } - } -} - -/** - * Parses the Edge Data Store version string from the /productinformation REST response. - * Note that the response format differs between EDS 2020 and EDS 2023. - * - * @param json REST response from /api/v1/diagnostics/productinformation - * @return version Edge Data Store version string - */ -static std::string ParseEDSProductInformation(std::string json) -{ - std::string version; - - Document doc; - - if (!doc.Parse(json.c_str()).HasParseError()) - { - try - { - if (doc.HasMember("Edge Data Store")) // EDS 2020 response - { - const rapidjson::Value &EDS = doc["Edge Data Store"]; - version = EDS.GetString(); - } - else if (doc.HasMember("Product Version")) // EDS 2023 response - { - const rapidjson::Value &EDS = doc["Product Version"]; - version = EDS.GetString(); - } - } - catch (...) - { - } - } - - Logger::getLogger()->debug("Edge Data Store Version: %s JSON: %s", version.c_str(), json.c_str()); - return version; -} - -/** - * Calls the Edge Data Store product information endpoint to get the EDS version - * - * @param connInfo The CONNECTOR_INFO data structure - * @return HttpCode REST response code - */ -int EDSGetVersion(CONNECTOR_INFO *connInfo) -{ - int res; - - HttpSender *endPoint = new SimpleHttp(connInfo->hostAndPort, - connInfo->timeout, - connInfo->timeout, - connInfo->retrySleepTime, - connInfo->maxRetry); - - try - { - string path = "http://" + connInfo->hostAndPort + "/api/v1/diagnostics/productinformation"; - vector> headers; - connInfo->RestServerVersion.clear(); - - res = endPoint->sendRequest("GET", path, headers, std::string("")); - if (res >= 200 && res <= 299) - { - connInfo->RestServerVersion = ParseEDSProductInformation(endPoint->getHTTPResponse()); - } - } - catch (const BadRequest &ex) - { - Logger::getLogger()->error("Edge Data Store productinformation BadRequest exception: %s", ex.what()); - res = 400; - } - catch (const std::exception &ex) - { - Logger::getLogger()->error("Edge Data Store productinformation exception: %s", ex.what()); - res = 400; - } - catch (...) - { - Logger::getLogger()->error("Edge Data Store productinformation generic exception"); - res = 400; - } - - delete endPoint; - return res; -} - -/** - * Set the supported OMF Version for the OMF endpoint - * - * @param connInfo The CONNECTOR_INFO data structure - */ -void SetOMFVersion(CONNECTOR_INFO *connInfo) -{ - switch (connInfo->PIServerEndpoint) - { - case ENDPOINT_PIWEB_API: - if (connInfo->RestServerVersion.find("2019") != std::string::npos) - { - connInfo->omfversion = "1.0"; - } - else if (connInfo->RestServerVersion.find("2020") != std::string::npos) - { - connInfo->omfversion = "1.1"; - } - else if (connInfo->RestServerVersion.find("2021") != std::string::npos) - { - connInfo->omfversion = "1.2"; - } - else - { - connInfo->omfversion = "1.2"; - } - break; - case ENDPOINT_EDS: - // Edge Data Store versions with supported OMF versions: - // EDS 2020 (1.0.0.609) OMF 1.0, 1.1 - // EDS 2023 (1.1.1.46) OMF 1.0, 1.1, 1.2 - // EDS 2023 Patch 1 (1.1.3.2) OMF 1.0, 1.1, 1.2 - { - int major = 0; - int minor = 0; - ParseProductVersion(connInfo->RestServerVersion, &major, &minor); - if ((major > 1) || (major == 1 && minor > 0)) - { - connInfo->omfversion = "1.2"; - } - else - { - connInfo->omfversion = EDS_OMF_VERSION; - } - } - break; - case ENDPOINT_CR: - connInfo->omfversion = CR_OMF_VERSION; - break; - case ENDPOINT_OCS: - case ENDPOINT_ADH: - default: - connInfo->omfversion = "1.2"; // assume cloud service OMF endpoint types support OMF 1.2 - break; - } -} - -/** - * Calls the OCS API to retrieve the authentication token - * - * @param connInfo The CONNECTOR_INFO data structure - * @return token Authorization token - */ -string OCSRetrieveAuthToken(CONNECTOR_INFO* connInfo) -{ - string token; - OCS *ocs; - - if (connInfo->PIServerEndpoint == ENDPOINT_OCS) - ocs = new OCS(); - else if (connInfo->PIServerEndpoint == ENDPOINT_ADH) - ocs = new OCS(true); - - token = ocs->retrieveToken(connInfo->OCSClientId , connInfo->OCSClientSecret); - - delete ocs; - - return token; -} - -/** - * Evaluate if the endpoint is a PI Web API or a Connector Relay. - * - * @param connInfo The CONNECTOR_INFO data structure - * @return OMF_ENDPOINT values - */ -OMF_ENDPOINT identifyPIServerEndpoint(CONNECTOR_INFO* connInfo) -{ - OMF_ENDPOINT PIServerEndpoint; - - HttpSender *endPoint; - vector> header; - int httpCode; - - - if (connInfo->PIWebAPIAuthMethod.compare("k") == 0) - { - endPoint = new LibcurlHttps(connInfo->hostAndPort, - connInfo->timeout, - connInfo->timeout, - connInfo->retrySleepTime, - connInfo->maxRetry); - } - else - { - endPoint = new SimpleHttps(connInfo->hostAndPort, - connInfo->timeout, - connInfo->timeout, - connInfo->retrySleepTime, - connInfo->maxRetry); - } - - // Set requested authentication - endPoint->setAuthMethod (connInfo->PIWebAPIAuthMethod); - endPoint->setAuthBasicCredentials(connInfo->PIWebAPICredentials); - - try - { - httpCode = endPoint->sendRequest("GET", - connInfo->path, - header, - ""); - - if (httpCode >= 200 && httpCode <= 399) - { - PIServerEndpoint = ENDPOINT_PIWEB_API; - if (connInfo->PIWebAPIAuthMethod == "b") - Logger::getLogger()->debug("PI Web API end-point basic authorization granted"); - } - else - { - PIServerEndpoint = ENDPOINT_CR; - } - - } - catch (exception &ex) - { - Logger::getLogger()->warn("PI-Server end-point discovery encountered the error :%s: " - "trying selecting the Connector Relay as an end-point", ex.what()); - PIServerEndpoint = ENDPOINT_CR; - } - - delete endPoint; - - return (PIServerEndpoint); -} - -/** - * Generate the credentials for the basic authentication - * encoding user id and password joined by a single colon (:) using base64 - * - * @param userId User id to be used for the generation of the credentials - * @param password Password to be used for the generation of the credentials - * @return credentials to be used with the basic authentication - */ -string AuthBasicCredentialsGenerate(string& userId, string& password) -{ - string Credentials; - - Credentials = Crypto::Base64::encode(userId + ":" + password); - - return (Credentials); -} - -/** - * Configures for Kerberos authentication : - * - set the environment KRB5_CLIENT_KTNAME to the position containing the - * Kerberos keys, the keytab file. - * - * @param out keytabEnv string containing the command to set the - * KRB5_CLIENT_KTNAME environment variable - * @param keytabFileName File name of the keytab file - * - */ -void AuthKerberosSetup(string& keytabEnv, string& keytabFileName) -{ - string fledgeData = getDataDir (); - string keytabFullPath = fledgeData + "/etc/kerberos" + "/" + keytabFileName; - - keytabEnv = "KRB5_CLIENT_KTNAME=" + keytabFullPath; - putenv((char *) keytabEnv.c_str()); - - if (access(keytabFullPath.c_str(), F_OK) != 0) - { - Logger::getLogger()->error("Kerberos authentication not possible, the keytab file :%s: is missing.", keytabFullPath.c_str()); - } - -} - -/** - * Calculate elapsed time in seconds - * - * @param startTime Start time of the interval to be evaluated - * @return Elapsed time in seconds - */ -double GetElapsedTime(struct timeval *startTime) -{ - struct timeval endTime, diff; - gettimeofday(&endTime, NULL); - timersub(&endTime, startTime, &diff); - return diff.tv_sec + ((double)diff.tv_usec / 1000000); -} - -/** - * Check if the PI Web API server is available by reading the product version - * - * @param connInfo The CONNECTOR_INFO data structure - * @return Connection status - */ -bool IsPIWebAPIConnected(CONNECTOR_INFO* connInfo) -{ - static std::chrono::steady_clock::time_point nextCheck; - static bool reported = false; // Has the state been reported yet - static bool reportedState; // What was the last reported state - - if (!s_connected && connInfo->PIServerEndpoint == ENDPOINT_PIWEB_API) - { - std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); - - if (now >= nextCheck) - { - int httpCode = PIWebAPIGetVersion(connInfo, false); - if (httpCode >= 400) - { - s_connected = false; - now = std::chrono::steady_clock::now(); - nextCheck = now + std::chrono::seconds(60); - Logger::getLogger()->debug("PI Web API %s is not available. HTTP Code: %d", connInfo->hostAndPort.c_str(), httpCode); - if (reported == false || reportedState == true) - { - reportedState = false; - reported = true; - Logger::getLogger()->error("The PI Web API service %s is not available", - connInfo->hostAndPort.c_str()); - } - } - else - { - s_connected = true; - SetOMFVersion(connInfo); - Logger::getLogger()->info("%s reconnected to %s OMF Version: %s", - connInfo->RestServerVersion.c_str(), connInfo->hostAndPort.c_str(), connInfo->omfversion.c_str()); - if (reported == true || reportedState == false) - { - reportedState = true; - reported = true; - Logger::getLogger()->warn("The PI Web API service %s has become available", - connInfo->hostAndPort.c_str()); - } - } - } - } - else - { - // Endpoints other than PI Web API fail quickly when they are unavailable - // so there is no need to check their status in advance. - s_connected = true; - } - - return s_connected; -} diff --git a/docs/OMF.rst b/docs/OMF.rst index 6ffef9fd45..fdbda34529 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -146,7 +146,7 @@ The *Default Configuration* tab contains the most commonly modified items - *statistics* - Fledge's internal statistics. - - **Static Data**: Data to include in every reading sent to OMF. For example, you can use this to specify the location of the devices being monitored by the Fledge server. + - **Static Data**: Data to include in every reading sent to OMF. For example, you can use this to specify the location of the devices being monitored by the Fledge server. Asset Framework From b2c032059284cbb3a994c11942cc8163cc53a3c6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 13 Sep 2023 15:14:06 +0530 Subject: [PATCH 431/499] bypass control update request from middleware for editor/normal user and rejection handling in its handler; also made some comments into code Signed-off-by: ashish-jabble --- python/fledge/common/web/middleware.py | 7 ++++++- .../services/core/api/control_service/entrypoint.py | 10 +++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/python/fledge/common/web/middleware.py b/python/fledge/common/web/middleware.py index a67bfaa4d4..295c31878e 100644 --- a/python/fledge/common/web/middleware.py +++ b/python/fledge/common/web/middleware.py @@ -181,13 +181,18 @@ async def validate_requests(request): - All CRUD's privileges for control pipelines """ user_id = request.user['id'] + # Normal/Editor user if int(request.user["role_id"]) == 2 and request.method != 'GET': - if str(request.rel_url).startswith('/fledge/control'): + # Special case: Allowed control entrypoint update request and handling of rejection in its handler + if str(request.rel_url).startswith('/fledge/control') and not str(request.rel_url).startswith( + '/fledge/control/request'): raise web.HTTPForbidden + # Viewer user elif int(request.user["role_id"]) == 3 and request.method != 'GET': supported_endpoints = ['/fledge/user', '/fledge/user/{}/password'.format(user_id), '/logout'] if not str(request.rel_url).endswith(tuple(supported_endpoints)): raise web.HTTPForbidden + # Data Viewer user elif int(request.user["role_id"]) == 4: if request.method == 'GET': supported_endpoints = ['/fledge/asset', '/fledge/ping', '/fledge/statistics', diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 503ba1c781..04430664f3 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -460,20 +460,28 @@ async def update_request(request: web.Request) -> web.Response: if request.user["role_id"] not in (1, 5): acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) allowed_user = [r['user'] for r in acl_result['rows']] + # TODO: FOGL-8037 - If allowed user list is empty then should we allow to proceed with update request? + # How about viewer and data viewer role access to this route? + # as of now simply reject with Forbidden 403 if request.user["uname"] not in allowed_user: raise ValueError("Operation is not allowed for the {} user.".format(request.user['uname'])) data = await request.json() payload = {"updates": []} for k, v in data.items(): for r in result['rows']: + # TODO: FOGL-8037 - validation of constants and variables key - 400 or simply ignore? if r['parameter'] == k: if isinstance(v, str): - payload_item = PayloadBuilder().SET(value=v).WHERE(["name", "=", name]).AND_WHERE(["parameter", "=", k]).payload() + payload_item = PayloadBuilder().SET(value=v).WHERE(["name", "=", name]).AND_WHERE( + ["parameter", "=", k]).payload() payload['updates'].append(json.loads(payload_item)) break else: raise ValueError("Value should be in string for {} parameter.".format(k)) await storage.update_tbl("control_api_parameters", json.dumps(payload)) + except KeyError as err: + msg = str(err) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except ValueError as err: msg = str(err) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) From 876777bd797ac7e0d947181110a1bfbfbb961a1e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 14 Sep 2023 12:47:52 +0530 Subject: [PATCH 432/499] Control Entrypoint Audit log code added for sqlite engine Signed-off-by: ashish-jabble --- VERSION | 2 +- scripts/plugins/storage/sqlite/downgrade/65.sql | 1 + scripts/plugins/storage/sqlite/init.sql | 3 ++- scripts/plugins/storage/sqlite/upgrade/66.sql | 4 ++++ 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 scripts/plugins/storage/sqlite/downgrade/65.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/66.sql diff --git a/VERSION b/VERSION index 9552e09630..78a7ad3f9d 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.1.0 -fledge_schema=65 +fledge_schema=66 diff --git a/scripts/plugins/storage/sqlite/downgrade/65.sql b/scripts/plugins/storage/sqlite/downgrade/65.sql new file mode 100644 index 0000000000..60e1544ad2 --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/65.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('CTEAD', 'CTECH', 'CTEDL'); diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 3922f18043..a6c8e38ebd 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -779,7 +779,8 @@ INSERT INTO fledge.log_codes ( code, description ) ( 'USRRS', 'User Restored' ), ( 'ACLAD', 'ACL Added' ),( 'ACLCH', 'ACL Changed' ),( 'ACLDL', 'ACL Deleted' ), ( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ), - ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ) + ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ), + ( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ) ; -- diff --git a/scripts/plugins/storage/sqlite/upgrade/66.sql b/scripts/plugins/storage/sqlite/upgrade/66.sql new file mode 100644 index 0000000000..d2b521c76f --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/66.sql @@ -0,0 +1,4 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES ( 'CTEAD', 'Control Entrypoint Added' ), + ( 'CTECH', 'Control Entrypoint Changed' ), + ('CTEDL', 'Control Entrypoint Deleted' ); \ No newline at end of file From f04601341342b9f508236a3600410ef19af0a965 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 14 Sep 2023 12:49:29 +0530 Subject: [PATCH 433/499] Control Entrypoint Audit log code added for sqlitelb engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlitelb/downgrade/65.sql | 1 + scripts/plugins/storage/sqlitelb/init.sql | 3 ++- scripts/plugins/storage/sqlitelb/upgrade/66.sql | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/65.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/66.sql diff --git a/scripts/plugins/storage/sqlitelb/downgrade/65.sql b/scripts/plugins/storage/sqlitelb/downgrade/65.sql new file mode 100644 index 0000000000..60e1544ad2 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/65.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('CTEAD', 'CTECH', 'CTEDL'); diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 984e652654..bdafe7364b 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -779,7 +779,8 @@ INSERT INTO fledge.log_codes ( code, description ) ( 'USRRS', 'User Restored' ), ( 'ACLAD', 'ACL Added' ),( 'ACLCH', 'ACL Changed' ),( 'ACLDL', 'ACL Deleted' ), ( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ), - ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ) + ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ), + ( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ) ; -- diff --git a/scripts/plugins/storage/sqlitelb/upgrade/66.sql b/scripts/plugins/storage/sqlitelb/upgrade/66.sql new file mode 100644 index 0000000000..d2b521c76f --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/66.sql @@ -0,0 +1,4 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES ( 'CTEAD', 'Control Entrypoint Added' ), + ( 'CTECH', 'Control Entrypoint Changed' ), + ('CTEDL', 'Control Entrypoint Deleted' ); \ No newline at end of file From a3913cd6b738f5515e70d2f843fc37837c884c78 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 14 Sep 2023 12:56:53 +0530 Subject: [PATCH 434/499] Control Entrypoint Audit log code added for PostgreSQL engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/65.sql | 1 + scripts/plugins/storage/postgres/init.sql | 3 ++- scripts/plugins/storage/postgres/upgrade/66.sql | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins/storage/postgres/downgrade/65.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/66.sql diff --git a/scripts/plugins/storage/postgres/downgrade/65.sql b/scripts/plugins/storage/postgres/downgrade/65.sql new file mode 100644 index 0000000000..60e1544ad2 --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/65.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('CTEAD', 'CTECH', 'CTEDL'); diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 640b7291d1..b013773496 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -1024,7 +1024,8 @@ INSERT INTO fledge.log_codes ( code, description ) ( 'USRRS', 'User Restored' ), ( 'ACLAD', 'ACL Added' ),( 'ACLCH', 'ACL Changed' ),( 'ACLDL', 'ACL Deleted' ), ( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ), - ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ) + ( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ), + ( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ) ; -- diff --git a/scripts/plugins/storage/postgres/upgrade/66.sql b/scripts/plugins/storage/postgres/upgrade/66.sql new file mode 100644 index 0000000000..d2b521c76f --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/66.sql @@ -0,0 +1,4 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES ( 'CTEAD', 'Control Entrypoint Added' ), + ( 'CTECH', 'Control Entrypoint Changed' ), + ('CTEDL', 'Control Entrypoint Deleted' ); \ No newline at end of file From f945f782091d91e8188bd79d66cb94b36ba190b2 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 14 Sep 2023 13:00:23 +0530 Subject: [PATCH 435/499] audit system API tests updated Signed-off-by: ashish-jabble --- tests/system/python/api/test_audit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/system/python/api/test_audit.py b/tests/system/python/api/test_audit.py index cfa863c113..b8a4b1d328 100644 --- a/tests/system/python/api/test_audit.py +++ b/tests/system/python/api/test_audit.py @@ -32,7 +32,8 @@ def test_get_log_codes(self, fledge_url, reset_and_start_fledge): 'USRAD', 'USRDL', 'USRCH', 'USRRS', 'ACLAD', 'ACLCH', 'ACLDL', 'CTSAD', 'CTSCH', 'CTSDL', - 'CTPAD', 'CTPCH', 'CTPDL' + 'CTPAD', 'CTPCH', 'CTPDL', + 'CTEAD', 'CTECH', 'CTEDL' ] conn = http.client.HTTPConnection(fledge_url) conn.request("GET", '/fledge/audit/logcode') From 0bc73fe3ec68ace5360a6275371823ce559ef180 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 14 Sep 2023 13:19:49 +0530 Subject: [PATCH 436/499] Audit trail entry added for CTEDL logcode Signed-off-by: ashish-jabble --- .../fledge/services/core/api/control_service/entrypoint.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 503ba1c781..15012845ed 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -8,6 +8,7 @@ from aiohttp import web from enum import IntEnum +from fledge.common.audit_logger import AuditLogger from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect, server @@ -337,7 +338,11 @@ async def delete(request: web.Request) -> web.Response: _logger.error(ex, "Failed to delete of {} entrypoint.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: - return web.json_response({"message": "{} control entrypoint has been deleted successfully.".format(name)}) + message = "{} control entrypoint has been deleted successfully.".format(name) + # CTEDL audit trail entry + audit = AuditLogger(storage) + await audit.information('CTEDL', {"message": message, "name": name}) + return web.json_response({"message": message}) async def update(request: web.Request) -> web.Response: From b5e55fe1f72aa38301543c0c456e471069bcafa5 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 14 Sep 2023 14:46:21 +0530 Subject: [PATCH 437/499] Audit trail entry added for CTEAD logcode Signed-off-by: ashish-jabble --- .../fledge/services/core/api/control_service/entrypoint.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 15012845ed..d85df9f30e 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -236,6 +236,13 @@ async def create(request: web.Request) -> web.Response: _logger.error(ex, "Failed to create control entrypoint.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: + # CTEAD audit trail entry + audit = AuditLogger(storage) + if 'constants' not in data: + data['constants'] = {} + if 'variables' not in data: + data['variables'] = {} + await audit.information('CTEAD', data) return web.json_response({"message": "{} control entrypoint has been created successfully.".format(name)}) From 0221b42efa73da6424f9c6514c306d2ae6bde54c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 14 Sep 2023 15:14:59 +0530 Subject: [PATCH 438/499] Audit trail entry added for CTECH logcode Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 83 +++++++++++-------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index d85df9f30e..4553258b00 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -270,41 +270,9 @@ async def get_by_name(request: web.Request) -> web.Response: :Example: curl -sX GET http://localhost:8081/fledge/control/manage/SetLatheSpeed """ - # TODO: FOGL-8037 forbidden when permitted is false on the basis of anonymous - name = request.match_info.get('name', None) try: - storage = connect.get_storage_async() - payload = PayloadBuilder().WHERE(["name", '=', name]).payload() - result = await storage.query_tbl_with_payload("control_api", payload) - if not result['rows']: - raise KeyError('{} control entrypoint not found.'.format(name)) - response = result['rows'][0] - response['type'] = await _get_type(response['type']) - response['destination'] = await _get_destination(response['destination']) - if response['destination'] != "broadcast": - response[response['destination']] = response['destination_arg'] - del response['destination_arg'] - param_result = await storage.query_tbl_with_payload("control_api_parameters", payload) - constants = {} - variables = {} - if param_result['rows']: - for r in param_result['rows']: - if r['constant'] == 't': - constants[r['parameter']] = r['value'] - else: - variables[r['parameter']] = r['value'] - response['constants'] = constants - response['variables'] = variables - else: - response['constants'] = constants - response['variables'] = variables - response['allow'] = "" - acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) - if acl_result['rows']: - users = [] - for r in acl_result['rows']: - users.append(r['user']) - response['allow'] = users + ep_name = request.match_info.get('name', None) + response = await _get_entrypoint(ep_name) except ValueError as err: msg = str(err) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) @@ -313,7 +281,7 @@ async def get_by_name(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _logger.error(ex, "Failed to fetch details of {} entrypoint.".format(name)) + _logger.error(ex, "Failed to fetch details of {} entrypoint.".format(ep_name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response(response) @@ -355,7 +323,7 @@ async def delete(request: web.Request) -> web.Response: async def update(request: web.Request) -> web.Response: """Update a control entrypoint :Example: - curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"name": "Changed"}' + curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"description": "Updated", "anonymous": false}' """ name = request.match_info.get('name', None) try: @@ -371,6 +339,8 @@ async def update(request: web.Request) -> web.Response: except Exception as ex: msg = str(ex) raise ValueError(msg) + + old_entrypoint = await _get_entrypoint(name) # TODO: FOGL-8037 rename if 'name' in columns: del columns['name'] @@ -451,6 +421,10 @@ async def update(request: web.Request) -> web.Response: _logger.error(ex, "Failed to update the details of {} entrypoint.".format(name)) raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: + # CTECH audit trail entry + result = await _get_entrypoint(name) + audit = AuditLogger(storage) + await audit.information('CTECH', {'entrypoint': result, 'old_entrypoint': old_entrypoint}) return web.json_response({"message": "{} control entrypoint has been updated successfully.".format(name)}) @@ -495,3 +469,40 @@ async def update_request(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": "{} control entrypoint URL called.".format(name)}) + + +async def _get_entrypoint(name): + # TODO: FOGL-8037 forbidden when permitted is false on the basis of anonymous + storage = connect.get_storage_async() + payload = PayloadBuilder().WHERE(["name", '=', name]).payload() + result = await storage.query_tbl_with_payload("control_api", payload) + if not result['rows']: + raise KeyError('{} control entrypoint not found.'.format(name)) + response = result['rows'][0] + response['type'] = await _get_type(response['type']) + response['destination'] = await _get_destination(response['destination']) + if response['destination'] != "broadcast": + response[response['destination']] = response['destination_arg'] + del response['destination_arg'] + param_result = await storage.query_tbl_with_payload("control_api_parameters", payload) + constants = {} + variables = {} + if param_result['rows']: + for r in param_result['rows']: + if r['constant'] == 't': + constants[r['parameter']] = r['value'] + else: + variables[r['parameter']] = r['value'] + response['constants'] = constants + response['variables'] = variables + else: + response['constants'] = constants + response['variables'] = variables + response['allow'] = "" + acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) + if acl_result['rows']: + users = [] + for r in acl_result['rows']: + users.append(r['user']) + response['allow'] = users + return response From 1e71cb35fd8552ecad004a708cacee4dd9e3849b Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Thu, 14 Sep 2023 15:15:01 +0530 Subject: [PATCH 439/499] FOGL-7885: Tentative changes Signed-off-by: Amandeep Singh Arora --- .../ingest_callback_pymodule.cpp | 13 ++- .../python/python_plugin_interface.cpp | 13 ++- C/services/south/ingest.cpp | 81 ++++++++++++++++++- 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp index b64d98e39a..53410c39d4 100755 --- a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp +++ b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp @@ -19,7 +19,7 @@ extern "C" { typedef void (*INGEST_CB_DATA)(void *, PythonReadingSet *); -static void filter_plugin_ingest_fn(PyObject *ingest_callback, +static void filter_plugin_async_ingest_fn(PyObject *ingest_callback, PyObject *ingest_obj_ref_data, PyObject *readingsObj); @@ -50,7 +50,7 @@ static PyObject *filter_ingest_callback(PyObject *self, PyObject *args) } // Invoke callback routine - filter_plugin_ingest_fn(callback, + filter_plugin_async_ingest_fn(callback, ingestData, readingList); @@ -106,7 +106,7 @@ PyInit_filter_ingest(void) * @param ingest_obj_ref_data Object parameter for callback routine * @param readingsObj Readongs data as PyObject */ -void filter_plugin_ingest_fn(PyObject *ingest_callback, +void filter_plugin_async_ingest_fn(PyObject *ingest_callback, PyObject *ingest_obj_ref_data, PyObject *readingsObj) { @@ -115,10 +115,11 @@ void filter_plugin_ingest_fn(PyObject *ingest_callback, readingsObj == NULL) { Logger::getLogger()->error("PyC interface error: " - "filter_plugin_ingest_fn: " + "%s: " "filter_ingest_callback=%p, " "ingest_obj_ref_data=%p, " "readingsObj=%p", + __FUNCTION__, ingest_callback, ingest_obj_ref_data, readingsObj); @@ -132,8 +133,10 @@ void filter_plugin_ingest_fn(PyObject *ingest_callback, { try { + PRINT_FUNC; // Get vector of Readings from Python object pyReadingSet = new PythonReadingSet(readingsObj); + PRINT_FUNC; } catch (std::exception e) { @@ -166,8 +169,10 @@ void filter_plugin_ingest_fn(PyObject *ingest_callback, // Get ingest object parameter void *data = PyCapsule_GetPointer(ingest_obj_ref_data, NULL); + Logger::getLogger()->info("%s:%d: cb function at address %p", __FUNCTION__, __LINE__, *cb); // Invoke callback method for ReadingSet filter ingestion (*cb)(data, pyReadingSet); + PRINT_FUNC; } else { diff --git a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp index e657755df9..4c8a381149 100755 --- a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp @@ -262,15 +262,19 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) } Logger::getLogger()->debug("C2Py: filter_plugin_ingest_fn():L%d: data->getCount()=%d", __LINE__, data->getCount()); - + PRINT_FUNC; + // Create a readingList of readings to be filtered PythonReadingSet *pyReadingSet = (PythonReadingSet *) data; + PRINT_FUNC; PyObject* readingsList = pyReadingSet->toPython(); + PRINT_FUNC; PyObject* pReturn = PyObject_CallFunction(pFunc, "OO", handle, readingsList); + PRINT_FUNC; Py_CLEAR(pFunc); // Handle returned data @@ -281,7 +285,9 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) pName.c_str()); logErrorMessage(); } + PRINT_FUNC; +#if 0 PythonReadingSet *filteredReadingSet = NULL; if (pReturn) @@ -316,6 +322,7 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) Py_TYPE(readingsList)->tp_name); } } +#endif // Remove readings to dict Py_CLEAR(readingsList); @@ -324,6 +331,10 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) // Release GIL PyGILState_Release(state); + if(data && data->getAllReadingsPtr()->size()) + Logger::getLogger()->info("%s:%d: data->getCount()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, data->getAllReadingsPtr()->size(), + data->getAllReadings()[0], data->getAllReadings()[0]->toJSON().c_str()); + } /** diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 710ee5e781..9a22dbb8ff 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -717,10 +717,37 @@ void Ingest::processQueue() std::this_thread::sleep_for(std::chrono::milliseconds(150)); } + if(m_data && m_data->size()) + Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); ReadingSet *readingSet = new ReadingSet(m_data); + PRINT_FUNC; m_data->clear(); + PRINT_FUNC; // Pass readingSet to filter chain firstFilter->ingest(readingSet); + PRINT_FUNC; + if(m_data && m_data->size()) + Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); + if(readingSet && readingSet->getCount()) + Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, + readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], + readingSet->getAllReadings()[0]->toJSON().c_str()); + + Logger::getLogger()->info("%s:%d: m_data->size()=%d", __FUNCTION__, __LINE__, m_data->size()); +#if 0 + // Remove the readings in the vector + for(auto & rdng : *m_data) + delete rdng; + PRINT_FUNC; + m_data->clear();// Remove the pointers still in the vector + PRINT_FUNC; + + // move reading vector to ingest + // *(ingest->m_data) = readingSet->getAllReadings(); + m_data = readingSet->moveAllReadings(); + if(m_data && m_data->size()) + Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); +#endif /* * If filtering removed all the readings then simply clean up m_data and @@ -728,21 +755,26 @@ void Ingest::processQueue() */ if (m_data->size() == 0) { + PRINT_FUNC; delete m_data; m_data = NULL; return; } + PRINT_FUNC; } } } - + PRINT_FUNC; + /* * Check the first reading in the list to see if we are meeting the * latency configuration we have been set */ if (m_data) { + if(m_data->size()) + Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); vector::iterator itr = m_data->begin(); if (itr != m_data->cend()) { @@ -779,7 +811,12 @@ void Ingest::processQueue() */ if (m_data && !m_data->empty()) { - if (m_storage.readingAppend(*m_data) == false) + if(m_data->size()) + Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); + bool rv = m_storage.readingAppend(*m_data); + if(m_data->size()) + Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); + if (rv == false) { if (!m_storageFailed) m_logger->warn("Failed to write readings to storage layer, queue for resend"); @@ -791,12 +828,14 @@ void Ingest::processQueue() } else { + PRINT_FUNC; if (m_storageFailed) { m_logger->warn("Storage operational after %d failures", m_storesFailed); m_storageFailed = false; m_storesFailed = 0; } + PRINT_FUNC; m_failCnt = 0; std::map statsEntriesCurrQueue; // check if this requires addition of a new asset tracker tuple @@ -806,6 +845,10 @@ void Ingest::processQueue() string lastAsset; int *lastStat = NULL; std::map > assetDatapointMap; + + if(m_data->size()) + Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); + for (vector::iterator it = m_data->begin(); it != m_data->end(); ++it) { Reading *reading = *it; @@ -899,11 +942,19 @@ void Ingest::processQueue() } } + if(m_data && m_data->size()) + Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); + if (m_data) { + PRINT_FUNC; delete m_data; + PRINT_FUNC; m_data = NULL; + PRINT_FUNC; } + if(m_data && m_data->size()) + Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); signalStatsUpdate(); } while (! m_fullQueues.empty()); } @@ -1001,22 +1052,44 @@ void Ingest::passToOnwardFilter(OUTPUT_HANDLE *outHandle, void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, READINGSET *readingSet) { + PRINT_FUNC; Ingest* ingest = (Ingest *)outHandle; + + if(ingest->m_data->size() && readingSet->getAllReadingsPtr()->size()) + Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), readingSet->getAllReadings()[0]); + if (ingest->m_data != readingSet->getAllReadingsPtr()) { + PRINT_FUNC; if (ingest->m_data) { - ingest->m_data->clear();// Remove any pointers still in the vector - *(ingest->m_data) = readingSet->getAllReadings(); + Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); + // Remove the readings in the vector + for(auto & rdng : *(ingest->m_data)) + delete rdng; + ingest->m_data->clear();// Remove the pointers still in the vector + + // move reading vector to ingest + // *(ingest->m_data) = readingSet->getAllReadings(); + ingest->m_data = readingSet->moveAllReadings(); } else { + PRINT_FUNC; // move reading vector to ingest ingest->m_data = readingSet->moveAllReadings(); } } + else + { + Logger::getLogger()->info("%s:%d: INPUT READINGSET MODIFIED BY FILTER: ingest->m_data=%p, readingSet->getAllReadingsPtr()=%p", + __FUNCTION__, __LINE__, ingest->m_data, readingSet->getAllReadingsPtr()); + } + PRINT_FUNC; readingSet->clear(); + PRINT_FUNC; delete readingSet; + PRINT_FUNC; } /** From 5ff0b8bb5deac69a017dca0e84631940b0639c75 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 14 Sep 2023 12:09:45 +0100 Subject: [PATCH 440/499] FOGL-8108 Add missign duration data in purge for sqlitememory when the (#1163) purge does nothing Signed-off-by: Mark Riddoch --- C/plugins/storage/sqlitelb/common/readings.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index 61e325b7b5..9e558832e8 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -1635,6 +1635,8 @@ unsigned int Connection::purgeReadings(unsigned long age, result += " \"unsentPurged\" : 0, "; result += " \"unsentRetained\" : 0, "; result += " \"readings\" : 0 }"; + result += " \"method\" : \"time\", "; + result += " \"duration\" : 0 }"; logger->info("Purge starting..."); gettimeofday(&startTv, NULL); From e41d97092bdb12593f33272935301c9459da98a4 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 15 Sep 2023 10:03:40 +0100 Subject: [PATCH 441/499] FOGL-8060 Limit size of insert statements sent to Postgres (#1156) Signed-off-by: Mark Riddoch Co-authored-by: Ashish Jabble --- C/plugins/storage/common/include/sql_buffer.h | 1 + C/plugins/storage/common/sql_buffer.cpp | 13 ++++++ C/plugins/storage/postgres/connection.cpp | 39 +++++++++++++++--- .../storage/postgres/connection_manager.cpp | 3 ++ .../storage/postgres/include/connection.h | 13 +++++- .../postgres/include/connection_manager.h | 5 +++ C/plugins/storage/postgres/plugin.cpp | 24 ++++++++++- docs/images/postgres_config.png | Bin 38040 -> 41457 bytes docs/tuning_fledge.rst | 8 ++++ 9 files changed, 98 insertions(+), 8 deletions(-) diff --git a/C/plugins/storage/common/include/sql_buffer.h b/C/plugins/storage/common/include/sql_buffer.h index 5a5d023aae..ea1142651f 100644 --- a/C/plugins/storage/common/include/sql_buffer.h +++ b/C/plugins/storage/common/include/sql_buffer.h @@ -47,6 +47,7 @@ class SQLBuffer { void append(const std::string&); void quote(const std::string&); const char *coalesce(); + void clear(); private: std::list buffers; diff --git a/C/plugins/storage/common/sql_buffer.cpp b/C/plugins/storage/common/sql_buffer.cpp index ad08e71885..7261032c39 100644 --- a/C/plugins/storage/common/sql_buffer.cpp +++ b/C/plugins/storage/common/sql_buffer.cpp @@ -36,6 +36,19 @@ SQLBuffer::~SQLBuffer() } } +/** + * Clear all the buffers from the SQLBuffer and allow it to be reused + */ +void SQLBuffer::clear() +{ + for (list::iterator it = buffers.begin(); it != buffers.end(); ++it) + { + delete *it; + } + buffers.clear(); + buffers.push_front(new SQLBuffer::Buffer()); +} + /** * Append a character to a buffer * diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index dfe624e5c3..837fe36b6d 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -329,7 +329,7 @@ bool Connection::aggregateQuery(const Value& payload, string& resultSet) /** * Create a database connection */ -Connection::Connection() +Connection::Connection() : m_maxReadingRows(INSERT_ROW_LIMIT) { const char *defaultConninfo = "dbname = fledge"; char *connInfo = NULL; @@ -1519,7 +1519,7 @@ bool add_row = false; if (!doc.HasMember("readings")) { raiseError("appendReadings", "Payload is missing a readings array"); - return -1; + return -1; } Value &rdings = doc["readings"]; if (!rdings.IsArray()) @@ -1528,10 +1528,32 @@ bool add_row = false; return -1; } - sql.append("INSERT INTO fledge.readings ( user_ts, asset_code, reading ) VALUES "); + const char *head = "INSERT INTO fledge.readings ( user_ts, asset_code, reading ) VALUES "; + sql.append(head); + int count = 0; for (Value::ConstValueIterator itr = rdings.Begin(); itr != rdings.End(); ++itr) { + if (count == m_maxReadingRows) + { + sql.append(';'); + + const char *query = sql.coalesce(); + logSQL("ReadingsAppend", query); + PGresult *res = PQexec(dbConnection, query); + delete[] query; + if (PQresultStatus(res) != PGRES_COMMAND_OK) + { + raiseError("appendReadings", PQerrorMessage(dbConnection)); + PQclear(res); + return -1; + } + PQclear(res); + + sql.clear(); + sql.append(head); + count = 0; + } if (!itr->IsObject()) { raiseError("appendReadings", @@ -1550,7 +1572,7 @@ bool add_row = false; // Check if the string is a function if (isFunction(str)) { - if (row) + if (count) sql.append(", ("); else sql.append('('); @@ -1567,7 +1589,7 @@ bool add_row = false; } else { - if (row) + if (count) { sql.append(", ("); } @@ -1585,6 +1607,7 @@ bool add_row = false; if (add_row) { row++; + count++; // Handles - asset_code sql.append(",\'"); @@ -1601,6 +1624,12 @@ bool add_row = false; sql.append(')'); } } + + if (count == 0) + { + // No rows in final block + return 0; + } sql.append(';'); const char *query = sql.coalesce(); diff --git a/C/plugins/storage/postgres/connection_manager.cpp b/C/plugins/storage/postgres/connection_manager.cpp index 6a1c7ddc86..025f168033 100644 --- a/C/plugins/storage/postgres/connection_manager.cpp +++ b/C/plugins/storage/postgres/connection_manager.cpp @@ -60,6 +60,7 @@ void ConnectionManager::growPool(unsigned int delta) { Connection *conn = new Connection(); conn->setTrace(m_logSQL); + conn->setMaxReadingRows(m_maxReadingRows); idleLock.lock(); idle.push_back(conn); idleLock.unlock(); @@ -108,6 +109,8 @@ Connection *conn = 0; if (idle.empty()) { conn = new Connection(); + conn->setTrace(m_logSQL); + conn->setMaxReadingRows(m_maxReadingRows); } else { diff --git a/C/plugins/storage/postgres/include/connection.h b/C/plugins/storage/postgres/include/connection.h index 3d0ef33adb..1a99ed9b2c 100644 --- a/C/plugins/storage/postgres/include/connection.h +++ b/C/plugins/storage/postgres/include/connection.h @@ -21,7 +21,13 @@ #define STORAGE_PURGE_RETAIN_ANY 0x0001U #define STORAGE_PURGE_RETAIN_ALL 0x0002U -#define STORAGE_PURGE_SIZE 0x0004U +#define STORAGE_PURGE_SIZE 0x0004U + +/** + * Maximum number of readings to insert in a single + * insert statement + */ +#define INSERT_ROW_LIMIT 5000 class Connection { public: @@ -53,6 +59,10 @@ class Connection { const std::string &name, std::string &resultSet); unsigned int purgeReadingsAsset(const std::string& asset); + void setMaxReadingRows(long rows) + { + m_maxReadingRows = rows; + } private: bool m_logSQL; @@ -75,6 +85,7 @@ class Connection { std::string getIndexName(std::string s); bool checkValidDataType(const std::string &s); + long m_maxReadingRows; typedef struct{ diff --git a/C/plugins/storage/postgres/include/connection_manager.h b/C/plugins/storage/postgres/include/connection_manager.h index 6b2009c4c4..8c6fbf1a62 100644 --- a/C/plugins/storage/postgres/include/connection_manager.h +++ b/C/plugins/storage/postgres/include/connection_manager.h @@ -32,6 +32,10 @@ class ConnectionManager { { return &lastError; } + void setMaxReadingRows(long rows) + { + m_maxReadingRows = rows; + } private: ConnectionManager(); @@ -43,6 +47,7 @@ class ConnectionManager { std::mutex errorLock; PLUGIN_ERROR lastError; bool m_logSQL; + long m_maxReadingRows; }; #endif diff --git a/C/plugins/storage/postgres/plugin.cpp b/C/plugins/storage/postgres/plugin.cpp index 06212cfae3..bdd0902b43 100644 --- a/C/plugins/storage/postgres/plugin.cpp +++ b/C/plugins/storage/postgres/plugin.cpp @@ -22,6 +22,7 @@ #include #include #include +#include using namespace std; using namespace rapidjson; @@ -43,6 +44,13 @@ const char *default_config = QUOTE({ "default" : "5", "displayName" : "Pool Size", "order" : "1" + }, + "maxReadingRows" : { + "description" : "The maximum numebnr of readings to insert in a single statement", + "type" : "integer", + "default" : "5000", + "displayName" : "Max. Insert Rows", + "order" : "2" } }); @@ -71,11 +79,23 @@ PLUGIN_INFORMATION *plugin_info() * In the case of Postgres we also get a pool of connections * to use. */ -PLUGIN_HANDLE plugin_init() +PLUGIN_HANDLE plugin_init(ConfigCategory *category) { ConnectionManager *manager = ConnectionManager::getInstance(); +long poolSize = 5, maxReadingRows = 5000; - manager->growPool(5); + if (category->itemExists("poolSize")) + { + poolSize = strtol(category->getValue("poolSize").c_str(), NULL, 10); + } + if (category->itemExists("maxReadingRows")) + { + long val = strtol(category->getValue("maxReadingRows").c_str(), NULL, 10); + if (val > 0) + maxReadingRows = val; + } + manager->setMaxReadingRows(maxReadingRows); + manager->growPool(poolSize); return manager; } diff --git a/docs/images/postgres_config.png b/docs/images/postgres_config.png index e2528f14f1c5b5fc14247da26d51ca2c0e451ba4..0f2b6a05ef247e0ebaf7df4bb63fd40c5c8d5249 100644 GIT binary patch literal 41457 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 Sp*@fR2s~Z=T-G@yGywp3qaLLI literal 38040 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 diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index fdfaaccb6b..2d6b2000af 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -294,6 +294,14 @@ The storage plugin configuration can be found in the *Advanced* section of the * This pool size is only the initial size, the storage service will grow the pool if required, however setting a realistic initial pool size will improve the ramp up performance of Fledge. + - **Max. Insert Rows**: The maximum number of readings that will be inserted in a single call to Postgres. This is a tuning parameter that has two effects on the system + + - It limits the size, and hence memory requirements, for a single insert statement + + - It prevents very long running insert transactions from blocking access to the readings table + + This parameter is useful on systems with very high data ingest rates or when the ingest contains sporadic large bursts of readings, to limit resource usage and database lock contention. + .. note:: Although the pool size denotes the number of parallel operations that can take place, database locking considerations may reduce the number of actual operations in progress at any point in time. From fd849422a52d8dfd6428cca4799068c2d162d4b6 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Fri, 15 Sep 2023 17:36:55 +0530 Subject: [PATCH 442/499] Further changes Signed-off-by: Amandeep Singh Arora --- C/common/datapoint.cpp | 3 +- C/common/include/reading_set.h | 2 +- .../ingest_callback_pymodule.cpp | 2 +- .../python/python_plugin_interface.cpp | 4 +- C/services/south/ingest.cpp | 101 ++++++++++-------- 5 files changed, 65 insertions(+), 47 deletions(-) diff --git a/C/common/datapoint.cpp b/C/common/datapoint.cpp index 751337d37f..0dad4e3292 100644 --- a/C/common/datapoint.cpp +++ b/C/common/datapoint.cpp @@ -211,7 +211,8 @@ DatapointValue::DatapointValue(const DatapointValue& obj) Datapoint *d = *it; // Add new allocated datapoint to the vector // using copy constructor - m_value.dpa->emplace_back(new Datapoint(*d)); + Datapoint *dpCopy = new Datapoint(*d); + m_value.dpa->emplace_back(dpCopy); } break; diff --git a/C/common/include/reading_set.h b/C/common/include/reading_set.h index 2872303600..f4d59ec1f3 100755 --- a/C/common/include/reading_set.h +++ b/C/common/include/reading_set.h @@ -30,7 +30,7 @@ class ReadingSet { ReadingSet(const std::vector* readings); ~ReadingSet(); - unsigned long getCount() const { return m_count; }; + unsigned long getCount() const { return m_readings.size(); }; const Reading *operator[] (const unsigned int idx) { return m_readings[idx]; }; diff --git a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp index 53410c39d4..918e780b53 100755 --- a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp +++ b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp @@ -144,7 +144,7 @@ void filter_plugin_async_ingest_fn(PyObject *ingest_callback, pyReadingSet = NULL; } - Logger::getLogger()->debug("%s:%d, pyReadingSet=%p, pyReadingSet readings count=%d", + Logger::getLogger()->info("%s:%d, pyReadingSet=%p, pyReadingSet readings count=%d", __FUNCTION__, __LINE__, pyReadingSet, pyReadingSet?pyReadingSet->getCount():0); } else diff --git a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp index 4c8a381149..dde2ce6301 100755 --- a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp @@ -287,9 +287,11 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) } PRINT_FUNC; + data->removeAll(); + delete data; + #if 0 PythonReadingSet *filteredReadingSet = NULL; - if (pReturn) { // Check we have a list of readings diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 9a22dbb8ff..c982c607fa 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -717,37 +717,17 @@ void Ingest::processQueue() std::this_thread::sleep_for(std::chrono::milliseconds(150)); } - if(m_data && m_data->size()) - Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); ReadingSet *readingSet = new ReadingSet(m_data); - PRINT_FUNC; + if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) + Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, + readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); m_data->clear(); - PRINT_FUNC; // Pass readingSet to filter chain firstFilter->ingest(readingSet); - PRINT_FUNC; - if(m_data && m_data->size()) - Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); - if(readingSet && readingSet->getCount()) - Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, - readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], - readingSet->getAllReadings()[0]->toJSON().c_str()); - - Logger::getLogger()->info("%s:%d: m_data->size()=%d", __FUNCTION__, __LINE__, m_data->size()); -#if 0 - // Remove the readings in the vector - for(auto & rdng : *m_data) - delete rdng; - PRINT_FUNC; - m_data->clear();// Remove the pointers still in the vector - PRINT_FUNC; - - // move reading vector to ingest - // *(ingest->m_data) = readingSet->getAllReadings(); - m_data = readingSet->moveAllReadings(); - if(m_data && m_data->size()) - Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); -#endif + + if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) + Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, + readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); /* * If filtering removed all the readings then simply clean up m_data and @@ -809,14 +789,11 @@ void Ingest::processQueue() * 2- some readings removed * 3- New set of readings */ - if (m_data && !m_data->empty()) + PRINT_FUNC; + if (m_data && m_data->size()) { - if(m_data->size()) - Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); - bool rv = m_storage.readingAppend(*m_data); - if(m_data->size()) - Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); - if (rv == false) + PRINT_FUNC; + if (m_storage.readingAppend(*m_data) == false) { if (!m_storageFailed) m_logger->warn("Failed to write readings to storage layer, queue for resend"); @@ -845,12 +822,15 @@ void Ingest::processQueue() string lastAsset; int *lastStat = NULL; std::map > assetDatapointMap; - - if(m_data->size()) + + PRINT_FUNC; + if(m_data && m_data->size()) Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); + PRINT_FUNC; for (vector::iterator it = m_data->begin(); it != m_data->end(); ++it) { + PRINT_FUNC; Reading *reading = *it; string assetName = reading->getAssetName(); const std::vector dpVec = reading->getReadingData(); @@ -867,6 +847,7 @@ void Ingest::processQueue() } temp.clear(); + PRINT_FUNC; // Push them in a set so as to avoid duplication of datapoints // a reading of d1, d2, d3 and another d2,d3,d1 , second will be discarded @@ -879,6 +860,7 @@ void Ingest::processQueue() s.insert(dp); } } + PRINT_FUNC; if (lastAsset.compare(assetName)) { @@ -901,6 +883,7 @@ void Ingest::processQueue() assetName, "Ingest"); } + PRINT_FUNC; lastAsset = assetName; lastStat = &statsEntriesCurrQueue[assetName]; @@ -910,9 +893,19 @@ void Ingest::processQueue() { (*lastStat)++; } - delete reading; + PRINT_FUNC; + // delete reading; + // PRINT_FUNC; } + PRINT_FUNC; + for( auto & rdng : *m_data) + { + delete rdng; + } + PRINT_FUNC; + m_data->clear(); + PRINT_FUNC; for (auto itr : assetDatapointMap) { @@ -939,8 +932,10 @@ void Ingest::processQueue() statsPendingEntries[it.first] += it.second; } } + PRINT_FUNC; } } + PRINT_FUNC; if(m_data && m_data->size()) Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); @@ -953,8 +948,6 @@ void Ingest::processQueue() m_data = NULL; PRINT_FUNC; } - if(m_data && m_data->size()) - Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); signalStatsUpdate(); } while (! m_fullQueues.empty()); } @@ -1024,6 +1017,11 @@ void Ingest::passToOnwardFilter(OUTPUT_HANDLE *outHandle, { // Get next filter in the pipeline FilterPlugin *next = (FilterPlugin *)outHandle; + + if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) + Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, + readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); + // Pass readings to next filter next->ingest(readingSet); } @@ -1055,27 +1053,35 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, PRINT_FUNC; Ingest* ingest = (Ingest *)outHandle; + if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) + Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, + readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); + if(ingest->m_data->size() && readingSet->getAllReadingsPtr()->size()) Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), readingSet->getAllReadings()[0]); if (ingest->m_data != readingSet->getAllReadingsPtr()) { PRINT_FUNC; - if (ingest->m_data) + if (ingest->m_data && ingest->m_data->size()) { Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); + Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), ((*(ingest->m_data))[0])); + Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), + ((*(ingest->m_data))[0]), ((*(ingest->m_data))[0])->toJSON().c_str()); // Remove the readings in the vector for(auto & rdng : *(ingest->m_data)) delete rdng; ingest->m_data->clear();// Remove the pointers still in the vector + // move reading vector to ingest - // *(ingest->m_data) = readingSet->getAllReadings(); - ingest->m_data = readingSet->moveAllReadings(); + *(ingest->m_data) = readingSet->getAllReadings(); + // ingest->m_data = readingSet->moveAllReadings(); + Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), (*(ingest->m_data))[0]->toJSON().c_str()); } else { - PRINT_FUNC; // move reading vector to ingest ingest->m_data = readingSet->moveAllReadings(); } @@ -1085,6 +1091,15 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, Logger::getLogger()->info("%s:%d: INPUT READINGSET MODIFIED BY FILTER: ingest->m_data=%p, readingSet->getAllReadingsPtr()=%p", __FUNCTION__, __LINE__, ingest->m_data, readingSet->getAllReadingsPtr()); } + + if (ingest->m_data) + { + Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); + // Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), ((*(ingest->m_data))[0])); + // Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), + // ((*(ingest->m_data))[0]), ((*(ingest->m_data))[0])->toJSON().c_str()); + } + PRINT_FUNC; readingSet->clear(); PRINT_FUNC; From 803383af8f2498978081460a186849f7f70631ed Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Mon, 18 Sep 2023 13:17:47 +0530 Subject: [PATCH 443/499] Further fix Signed-off-by: Amandeep Singh Arora --- .../python/python_plugin_interface.cpp | 4 ++-- C/services/south/ingest.cpp | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp index dde2ce6301..0e30eaaf2d 100755 --- a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp @@ -333,9 +333,9 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) // Release GIL PyGILState_Release(state); - if(data && data->getAllReadingsPtr()->size()) + /* if(data && data->getAllReadingsPtr()->size()) Logger::getLogger()->info("%s:%d: data->getCount()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, data->getAllReadingsPtr()->size(), - data->getAllReadings()[0], data->getAllReadings()[0]->toJSON().c_str()); + data->getAllReadings()[0], data->getAllReadings()[0]->toJSON().c_str()); */ } diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index c982c607fa..3e5fedef07 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -725,9 +725,9 @@ void Ingest::processQueue() // Pass readingSet to filter chain firstFilter->ingest(readingSet); - if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) + /* if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, - readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); + readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); */ /* * If filtering removed all the readings then simply clean up m_data and From efe86c9cb3979f8d2012ca2cf0cd6c03ec3ee107 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 18 Sep 2023 17:11:02 +0530 Subject: [PATCH 444/499] Invalid JSON fixes Signed-off-by: ashish-jabble --- C/plugins/storage/sqlitelb/common/readings.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index 9e558832e8..d72f13840a 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -1634,7 +1634,7 @@ unsigned int Connection::purgeReadings(unsigned long age, result = "{ \"removed\" : 0, "; result += " \"unsentPurged\" : 0, "; result += " \"unsentRetained\" : 0, "; - result += " \"readings\" : 0 }"; + result += " \"readings\" : 0, "; result += " \"method\" : \"time\", "; result += " \"duration\" : 0 }"; From 30d06e730d660de362b778d08cdb70dff6474272 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 18 Sep 2023 17:11:38 +0530 Subject: [PATCH 445/499] exception handling added for purge when response is invalid JSON Signed-off-by: ashish-jabble --- .../common/storage_client/storage_client.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/python/fledge/common/storage_client/storage_client.py b/python/fledge/common/storage_client/storage_client.py index 04f4b26a9e..dfff9234cf 100644 --- a/python/fledge/common/storage_client/storage_client.py +++ b/python/fledge/common/storage_client/storage_client.py @@ -587,10 +587,16 @@ async def purge(self, age=None, sent_id=0, size=None, flag=None, asset=None): async with aiohttp.ClientSession() as session: async with session.put(url, data=None) as resp: status_code = resp.status - jdoc = await resp.json() - if status_code not in range(200, 209): - _LOGGER.error("PUT url %s, Error code: %d, reason: %s, details: %s", put_url, resp.status, - resp.reason, jdoc) - raise StorageServerError(code=resp.status, reason=resp.reason, error=jdoc) - + try: + jdoc = await resp.json() + if status_code not in range(200, 209): + _LOGGER.error("PUT url %s, Error code: %d, reason: %s, details: %s", put_url, resp.status, + resp.reason, jdoc) + raise StorageServerError(code=resp.status, reason=resp.reason, error=jdoc) + except ValueError as err: + jdoc = None + _LOGGER.error(err, "Failed to parse JSON data returned of purge from the storage reading plugin.") + except Exception as ex: + jdoc = None + _LOGGER.error(ex, "Purge readings is failed.") return jdoc From 60e675fd8e90dfb845eedfd83c28f9faa34a66e3 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 18 Sep 2023 17:12:29 +0530 Subject: [PATCH 446/499] more unit tests added with purge flag and removed unused code from unit tests Signed-off-by: ashish-jabble --- .../storage_client/test_storage_client.py | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/unit/python/fledge/common/storage_client/test_storage_client.py b/tests/unit/python/fledge/common/storage_client/test_storage_client.py index 0223d13245..d87f921d43 100644 --- a/tests/unit/python/fledge/common/storage_client/test_storage_client.py +++ b/tests/unit/python/fledge/common/storage_client/test_storage_client.py @@ -672,6 +672,7 @@ async def test_purge(self, event_loop): assert "{}:{}".format(HOST, PORT) == rsc.base_url RETAINALL_FLAG = "retainall" + PURGE = "purge" with pytest.raises(Exception) as excinfo: kwargs = dict(flag='blah', age=1, sent_id=0, size=None) await rsc.purge(**kwargs) @@ -717,21 +718,17 @@ async def test_purge(self, event_loop): assert excinfo.type is ValueError assert "invalid literal for int() with base 10" in str(excinfo.value) - with pytest.raises(Exception) as excinfo: - with patch.object(_LOGGER, "error") as log_e: - kwargs = dict(age=-1, sent_id=1, size=None, flag=RETAINALL_FLAG) - await rsc.purge(**kwargs) - log_e.assert_called_once_with('PUT url %s, Error code: %d, reason: %s, details: %s', - '/storage/reading/purge?age=-1&sent=1&flags=retain', 400, 'age should not be less than 0', {"key": "value"}) - assert excinfo.type is aiohttp.client_exceptions.ContentTypeError + with patch.object(_LOGGER, "error") as log_e: + kwargs = dict(age=-1, sent_id=1, size=None, flag=RETAINALL_FLAG) + result = await rsc.purge(**kwargs) + assert result is None + log_e.assert_called() - with pytest.raises(Exception) as excinfo: - with patch.object(_LOGGER, "error") as log_e: - kwargs = dict(age=None, sent_id=1, size=4294967296, flag=RETAINALL_FLAG) - await rsc.purge(**kwargs) - log_e.assert_called_once_with('PUT url %s, Error code: %d, reason: %s, details: %s', - '/storage/reading/purge?size=4294967296&sent=1&flags=retain', 500, 'unsigned int range', {"key": "value"}) - assert excinfo.type is aiohttp.client_exceptions.ContentTypeError + with patch.object(_LOGGER, "error") as log_e: + kwargs = dict(age=None, sent_id=1, size=4294967296, flag=RETAINALL_FLAG) + result = await rsc.purge(**kwargs) + assert result is None + log_e.assert_called() kwargs = dict(age=1, sent_id=1, size=0, flag=RETAINALL_FLAG) response = await rsc.purge(**kwargs) @@ -749,4 +746,24 @@ async def test_purge(self, event_loop): response = await rsc.purge(**kwargs) assert 1 == response["called"] + with patch.object(_LOGGER, "error") as log_e: + kwargs = dict(age=-1, sent_id=1, size=None, flag=PURGE) + result = await rsc.purge(**kwargs) + assert result is None + log_e.assert_called() + + with patch.object(_LOGGER, "error") as log_e: + kwargs = dict(age=None, sent_id=1, size=4294967296, flag=PURGE) + result = await rsc.purge(**kwargs) + assert result is None + log_e.assert_called() + + kwargs = dict(age=10, sent_id=1, size=None, flag=PURGE) + response = await rsc.purge(**kwargs) + assert 1 == response["called"] + + kwargs = dict(age=None, sent_id=1, size=100, flag=PURGE) + response = await rsc.purge(**kwargs) + assert 1 == response["called"] + await fake_storage_srvr.stop() From 78673a149668b08f94ecfa109c0e4fcab8d6dea8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 19 Sep 2023 12:08:49 +0530 Subject: [PATCH 447/499] name and type is restricted for STARTUP type schedule while updating schedule from API Signed-off-by: ashish-jabble --- python/fledge/services/core/api/scheduler.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/python/fledge/services/core/api/scheduler.py b/python/fledge/services/core/api/scheduler.py index 14144bdf36..d3d985630a 100644 --- a/python/fledge/services/core/api/scheduler.py +++ b/python/fledge/services/core/api/scheduler.py @@ -648,6 +648,12 @@ async def update_schedule(request): if not sch: raise ScheduleNotFoundError(schedule_id) + # Restrict name and type properties for STARTUP type schedules + if 'name' in data and Schedule.Type(int(sch.schedule_type)).name == "STARTUP": + raise ValueError("{} is a STARTUP schedule type and cannot be renamed.".format(sch.name)) + if 'type' in data and Schedule.Type(int(sch.schedule_type)).name == "STARTUP": + raise ValueError("{} is a STARTUP schedule type and cannot be changed its type.".format(sch.name)) + curr_value = dict() curr_value['schedule_id'] = sch.schedule_id curr_value['schedule_process_name'] = sch.process_name From a028b305ed5bfe340f77411ccdec54eeae0faba2 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 19 Sep 2023 13:10:00 +0530 Subject: [PATCH 448/499] unit tests added for restriction of STARTUP schedule when schedule UPDATE Signed-off-by: ashish-jabble --- .../services/core/api/test_scheduler_api.py | 149 ++++++++++++++++-- 1 file changed, 138 insertions(+), 11 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/test_scheduler_api.py b/tests/unit/python/fledge/services/core/api/test_scheduler_api.py index 4425d8e0b4..ea03613d73 100644 --- a/tests/unit/python/fledge/services/core/api/test_scheduler_api.py +++ b/tests/unit/python/fledge/services/core/api/test_scheduler_api.py @@ -7,11 +7,13 @@ import asyncio import json -from unittest.mock import MagicMock, patch, call from datetime import timedelta, datetime +from unittest.mock import MagicMock, patch, call +from uuid import UUID + +import sys import uuid import pytest -import sys from aiohttp import web from fledge.services.core import routes @@ -586,7 +588,7 @@ async def mock_schedules(): @pytest.mark.parametrize("request_data, expected_response", [ ({"name": "new"}, {'schedule': {'id': '{}'.format(_random_uuid), 'time': 0, 'processName': 'bar', 'repeat': 30.0, - 'exclusive': True, 'enabled': True, 'type': 'STARTUP', 'day': None, 'name': 'new'}}), + 'exclusive': True, 'enabled': True, 'type': 'INTERVAL', 'day': None, 'name': 'new'}}) ]) async def test_update_schedule(self, client, request_data, expected_response): async def mock_coro(): @@ -618,13 +620,13 @@ async def mock_schedules(): return [schedule1, schedule2, schedule3] async def mock_schedule(*args): - schedule = StartUpSchedule() + schedule = IntervalSchedule() schedule.schedule_id = self._random_uuid schedule.exclusive = True schedule.enabled = True schedule.process_name = "bar" schedule.repeat = timedelta(seconds=30) - schedule.time = None + schedule.time = 0 schedule.day = None schedule.name = "foo" if args[0] == 1 else "new" return schedule @@ -659,7 +661,7 @@ async def mock_schedule(*args): assert 2 == patch_get_schedule.call_count assert call(uuid.UUID(str(self._random_uuid))) == patch_get_schedule.call_args arguments, kwargs = patch_save_schedule.call_args - assert isinstance(arguments[0], StartUpSchedule) + assert isinstance(arguments[0], IntervalSchedule) patch_get_schedules.assert_called_once_with() async def test_update_schedule_bad_param(self, client): @@ -671,6 +673,131 @@ async def test_update_schedule_bad_param(self, client): json_response = json.loads(result) assert {'message': error_msg} == json_response + @pytest.mark.parametrize("payload, status_code, message", [ + ({"name": "Updated"}, 400, "South Service is a STARTUP schedule type and cannot be renamed."), + ({"type": 3}, 400, "South Service is a STARTUP schedule type and cannot be changed its type."), + ({"name": "Updated", "type": 3}, 400, "South Service is a STARTUP schedule type and cannot be renamed."), + ({"name": "Updated", "enabled": False}, 400, "South Service is a STARTUP schedule type and cannot be renamed."), + ({"type": 4, "enabled": False}, 400, "South Service is a STARTUP schedule type and cannot be changed its type.") + ]) + async def test_bad_update_startup_schedule(self, client, payload, status_code, message): + uuid = "5affb5d1-96bb-4334-96ea-f91904cacc9b" + + async def mock_schedule(): + schedule = StartUpSchedule() + schedule.schedule_id = self._random_uuid + schedule.exclusive = True + schedule.enabled = True + schedule.process_name = "south_c" + schedule.repeat = 0 + schedule.time = 0 + schedule.day = None + schedule.name = "South Service" + return schedule + + _rv1 = await mock_schedule() if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(mock_schedule()) + with patch.object(server.Server.scheduler, 'get_schedule', return_value=_rv1) as patch_get_schedule: + resp = await client.put('/fledge/schedule/{}'.format(uuid), data=json.dumps(payload)) + assert status_code == resp.status + assert message == resp.reason + patch_get_schedule.assert_called_once_with(UUID(uuid)) + + @pytest.mark.parametrize("payload", [ + ({"enabled": True}), + ({"enabled": False}), + ({"exclusive": False}), + ({"exclusive": True}), + ({"exclusive": True, "enabled": False}), + ({"exclusive": False, "enabled": True}), + ({"exclusive": False, "enabled": False}), + ({"exclusive": True, "enabled": True}), + ]) + async def test_good_update_startup_schedule(self, client, payload): + startup_uuid = "5affb5d1-96bb-4334-96ea-f91904cacc9b" + + async def mock_coro(): + return "" + + async def mock_schedules(): + schedule1 = ManualSchedule() + schedule1.schedule_id = self._random_uuid + schedule1.exclusive = True + schedule1.enabled = True + schedule1.name = "purge" + schedule1.process_name = "purge" + + schedule2 = StartUpSchedule() + schedule2.schedule_id = startup_uuid + schedule2.exclusive = True + schedule2.enabled = True + schedule2.name = "South Service" + schedule2.process_name = "south_c" + schedule2.repeat = 0 + schedule2.time = 0 + schedule2.day = None + + schedule3 = IntervalSchedule() + schedule3.schedule_id = self._random_uuid + schedule3.repeat = timedelta(seconds=15) + schedule3.exclusive = True + schedule3.enabled = True + schedule3.name = "stats collection" + schedule3.process_name = "stats collector" + + return [schedule1, schedule2, schedule3] + + async def mock_schedule(): + sch = await mock_schedules() + return sch[1] + + async def final_schedule(): + schedule = StartUpSchedule() + schedule.schedule_id = startup_uuid + schedule.exclusive = payload['exclusive'] if 'exclusive' in payload else True + schedule.enabled = payload['enabled'] if 'enabled' in payload else True + schedule.name = "South Service" + schedule.process_name = "south_c" + schedule.repeat = 0 + schedule.time = 0 + schedule.day = None + return schedule + + storage_client_mock = MagicMock(StorageClientAsync) + response = {'rows': [{'name': 'SCH'}], 'count': 1} + # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. + if sys.version_info.major == 3 and sys.version_info.minor >= 8: + _rv0 = await mock_coro_response(response) + _rv1 = await mock_schedule() + _rv11 = await final_schedule() + _rv2 = await mock_coro() + _rv3 = await mock_schedules() + else: + _rv0 = asyncio.ensure_future(mock_coro_response(response)) + _rv1 = asyncio.ensure_future(mock_schedule()) + _rv11 = asyncio.ensure_future(final_schedule()) + _rv2 = asyncio.ensure_future(mock_coro()) + _rv3 = asyncio.ensure_future(mock_schedules()) + + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=_rv0): + with patch.object(server.Server.scheduler, 'get_schedule', side_effect=[_rv1, _rv11]): + with patch.object(server.Server.scheduler, 'save_schedule', return_value=_rv2 + ) as patch_save_schedule: + with patch.object(server.Server.scheduler, 'get_schedules', return_value=_rv3 + ) as patch_get_schedules: + resp = await client.put('/fledge/schedule/{}'.format(startup_uuid), data=json.dumps(payload)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + if 'exclusive' in payload: + assert payload['exclusive'] == json_response['schedule']['exclusive'] + if 'enabled' in payload: + assert payload['enabled'] == json_response['schedule']['enabled'] + assert 1 == patch_get_schedules.call_count + arguments, kwargs = patch_save_schedule.call_args + assert isinstance(arguments[0], StartUpSchedule) + async def test_update_schedule_data_not_exist(self, client): async def mock_coro(): return "" @@ -715,14 +842,14 @@ async def mock_coro(): ]) async def test_update_schedule_bad_data(self, client, request_data, response_code, error_message, storage_return): async def mock_coro(): - schedule = StartUpSchedule() + schedule = IntervalSchedule() schedule.schedule_id = self._random_uuid schedule.exclusive = True schedule.enabled = True schedule.name = "foo" schedule.process_name = "bar" schedule.repeat = timedelta(seconds=30) - schedule.time = None + schedule.time = 0 schedule.day = None return schedule @@ -775,13 +902,13 @@ async def mock_schedules(): return [schedule1, schedule2] async def mock_schedule(*args): - schedule = StartUpSchedule() + schedule = ManualSchedule() schedule.schedule_id = self._random_uuid schedule.exclusive = True schedule.enabled = True schedule.process_name = "bar" - schedule.repeat = timedelta(seconds=30) - schedule.time = None + schedule.repeat = 0 + schedule.time = 0 schedule.day = None schedule.name = "foo" if args[0] == 1 else "new" return schedule From 11a64bfb2a83dff75476a0910e57405ab7b1a142 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 19 Sep 2023 16:01:58 +0530 Subject: [PATCH 449/499] dispatcher checks added to control update request in an entrypoint API Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index a1a5abee7a..5841ce76c0 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -4,11 +4,13 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END +import os import json from aiohttp import web from enum import IntEnum from fledge.common.audit_logger import AuditLogger +from fledge.common.common import _FLEDGE_ROOT from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect, server @@ -435,7 +437,19 @@ async def update_request(request: web.Request) -> web.Response: """ name = request.match_info.get('name', None) try: + # check the dispatcher installation + if not os.path.exists(_FLEDGE_ROOT + "/services/fledge.services.dispatcher"): + raise KeyError('Dispatcher service is not installed.') + # check the dispatcher service state storage = connect.get_storage_async() + dispatcher_payload = PayloadBuilder().SELECT("enabled").WHERE(['process_name', '=', 'dispatcher_c']).payload() + dispatcher_result = await storage.query_tbl_with_payload('schedules', dispatcher_payload) + if not dispatcher_result["rows"]: + raise KeyError('Schedule not found for dispatcher service.') + else: + if dispatcher_result["rows"][0]["enabled"] == "f": + raise ValueError('Dispatcher service is in disabled state.') + payload = PayloadBuilder().WHERE(["name", '=', name]).payload() result = await storage.query_tbl_with_payload("control_api", payload) if not result['rows']: From 1996d883f7b9f376b3e7705dc86433bcf02e5c3d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 20 Sep 2023 15:23:45 +0530 Subject: [PATCH 450/499] control request execute implemented as per new specs Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 85 ++++++++++++++----- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 5841ce76c0..16b064b9ee 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -6,6 +6,8 @@ import os import json +import aiohttp + from aiohttp import web from enum import IntEnum @@ -14,6 +16,8 @@ from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect, server +from fledge.services.core.service_registry.service_registry import ServiceRegistry +from fledge.services.core.service_registry import exceptions as service_registry_exceptions __author__ = "Ashish Jabble" @@ -160,7 +164,7 @@ async def _check_parameters(payload, skip_required=False): if constants is not None: if not isinstance(constants, dict): raise ValueError('constants should be dictionary.') - if not constants and _type == EntryPointType.WRITE.name.lower(): + if not constants and _type == EntryPointType.WRITE.name.lower(): raise ValueError('constants should not be empty.') final['constants'] = constants else: @@ -450,35 +454,39 @@ async def update_request(request: web.Request) -> web.Response: if dispatcher_result["rows"][0]["enabled"] == "f": raise ValueError('Dispatcher service is in disabled state.') - payload = PayloadBuilder().WHERE(["name", '=', name]).payload() - result = await storage.query_tbl_with_payload("control_api", payload) - if not result['rows']: - raise KeyError('{} control entrypoint not found.'.format(name)) - result = await storage.query_tbl_with_payload("control_api_parameters", payload) + ep_info = await _get_entrypoint(name) + username = "Anonymous" if request.user is not None: - # Admin and Control roles can always call entrypoints. But for a user it must match in list of allowed users + # Admin and Control role users can always call entrypoints. + # For others it must match from the list of allowed users if request.user["role_id"] not in (1, 5): - acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) - allowed_user = [r['user'] for r in acl_result['rows']] + allowed_user = [r for r in ep_info['allow']] # TODO: FOGL-8037 - If allowed user list is empty then should we allow to proceed with update request? # How about viewer and data viewer role access to this route? # as of now simply reject with Forbidden 403 if request.user["uname"] not in allowed_user: raise ValueError("Operation is not allowed for the {} user.".format(request.user['uname'])) + username = request.user["uname"] + data = await request.json() - payload = {"updates": []} - for k, v in data.items(): - for r in result['rows']: - # TODO: FOGL-8037 - validation of constants and variables key - 400 or simply ignore? - if r['parameter'] == k: - if isinstance(v, str): - payload_item = PayloadBuilder().SET(value=v).WHERE(["name", "=", name]).AND_WHERE( - ["parameter", "=", k]).payload() - payload['updates'].append(json.loads(payload_item)) - break - else: - raise ValueError("Value should be in string for {} parameter.".format(k)) - await storage.update_tbl("control_api_parameters", json.dumps(payload)) + dispatch_payload = {"destination": ep_info['destination'], "source": "API", "source_name": username} + # If destination is broadcast then name KV pair is excluded from dispatch payload + if str(ep_info['destination']).lower() != 'broadcast': + dispatch_payload["name"] = ep_info[ep_info['destination']] + constant_dict = {key: data.get(key, ep_info["constants"][key]) for key in ep_info["constants"]} + variables_dict = {key: data.get(key, ep_info["variables"][key]) for key in ep_info["variables"]} + params = {**constant_dict, **variables_dict} + if not params: + raise ValueError("Nothing to update as given entrypoint do not have the parameters.") + if ep_info['type'] == 'write': + url = "dispatch/write" + dispatch_payload["write"] = params + else: + url = "dispatch/operation" + dispatch_payload["operation"] = {ep_info["operation_name"]: params if params else {}} + _logger.debug("DISPATCH PAYLOAD: {}".format(dispatch_payload)) + svc, bearer_token = await _get_service_record_info_along_with_bearer_token() + await _call_dispatcher_service_api(svc._protocol, svc._address, svc._port, url, bearer_token, dispatch_payload) except KeyError as err: msg = str(err) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) @@ -528,3 +536,36 @@ async def _get_entrypoint(name): users.append(r['user']) response['allow'] = users return response + + +async def _get_service_record_info_along_with_bearer_token(): + try: + service = ServiceRegistry.get(s_type="Dispatcher") + svc_name = service[0]._name + token = ServiceRegistry.getBearerToken(svc_name) + except service_registry_exceptions.DoesNotExist: + msg = "No service available with type Dispatcher." + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + else: + return service[0], token + + +async def _call_dispatcher_service_api(protocol: str, address: str, port: int, uri: str, token: str, payload: dict): + # Custom Request header + headers = {} + if token is not None: + headers['Authorization'] = "Bearer {}".format(token) + url = "{}://{}:{}/{}".format(protocol, address, port, uri) + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, data=json.dumps(payload), headers=headers) as resp: + message = await resp.text() + response = (resp.status, message) + if resp.status not in range(200, 209): + _logger.error("POST Request Error: Http status code: {}, reason: {}, response: {}".format( + resp.status, resp.reason, message)) + except Exception as ex: + raise Exception(str(ex)) + else: + # Return Tuple - (http statuscode, message) + return response From e3029917ff381a53abaac706e3a75cb4c7671ae0 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 20 Sep 2023 16:41:13 +0530 Subject: [PATCH 451/499] dispatcher check added with service registry for execute endpoint Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 16b064b9ee..a06accaf47 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -4,16 +4,15 @@ # See: http://fledge-iot.readthedocs.io/ # FLEDGE_END -import os import json -import aiohttp -from aiohttp import web from enum import IntEnum +import aiohttp +from aiohttp import web from fledge.common.audit_logger import AuditLogger -from fledge.common.common import _FLEDGE_ROOT from fledge.common.logger import FLCoreLogger +from fledge.common.service_record import ServiceRecord from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect, server from fledge.services.core.service_registry.service_registry import ServiceRegistry @@ -441,24 +440,19 @@ async def update_request(request: web.Request) -> web.Response: """ name = request.match_info.get('name', None) try: - # check the dispatcher installation - if not os.path.exists(_FLEDGE_ROOT + "/services/fledge.services.dispatcher"): - raise KeyError('Dispatcher service is not installed.') # check the dispatcher service state - storage = connect.get_storage_async() - dispatcher_payload = PayloadBuilder().SELECT("enabled").WHERE(['process_name', '=', 'dispatcher_c']).payload() - dispatcher_result = await storage.query_tbl_with_payload('schedules', dispatcher_payload) - if not dispatcher_result["rows"]: - raise KeyError('Schedule not found for dispatcher service.') - else: - if dispatcher_result["rows"][0]["enabled"] == "f": - raise ValueError('Dispatcher service is in disabled state.') + try: + service = ServiceRegistry.get(s_type="Dispatcher") + if service[0]._status != ServiceRecord.Status.Running: + raise ValueError('The Dispatcher service is not in Running state.') + except service_registry_exceptions.DoesNotExist: + raise ValueError('Dispatcher service is either not installed or not added.') ep_info = await _get_entrypoint(name) username = "Anonymous" if request.user is not None: # Admin and Control role users can always call entrypoints. - # For others it must match from the list of allowed users + # For others, it must be matched from the list of allowed users if request.user["role_id"] not in (1, 5): allowed_user = [r for r in ep_info['allow']] # TODO: FOGL-8037 - If allowed user list is empty then should we allow to proceed with update request? From 315d09eb4747e06af5aebc0fb3cd94be4c14dfbe Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 21 Sep 2023 14:57:54 +0530 Subject: [PATCH 452/499] allow user column handling added in UPDATE entrypoint API Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index a1a5abee7a..14fd54d471 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -323,7 +323,9 @@ async def delete(request: web.Request) -> web.Response: async def update(request: web.Request) -> web.Response: """Update a control entrypoint :Example: - curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"description": "Updated", "anonymous": false}' + curl -sX PUT "http://localhost:8081/fledge/control/manage/SetLatheSpeed" -d '{"constants": {"x": "486"}, "variables": {"rpm": "1200"}, "description": "Perform lathesim", "anonymous": false, "destination": "script", "script": "S4"}' + curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"description": "Updated", "anonymous": false, "allow": []}' + curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"allow": ["user", "ashish"]}' """ name = request.match_info.get('name', None) try: @@ -339,14 +341,12 @@ async def update(request: web.Request) -> web.Response: except Exception as ex: msg = str(ex) raise ValueError(msg) - old_entrypoint = await _get_entrypoint(name) # TODO: FOGL-8037 rename if 'name' in columns: del columns['name'] - # TODO: FOGL-8037 "allow" possible_keys = {"name", "description", "type", "operation_name", "destination", "destination_arg", - "anonymous", "constants", "variables"} + "anonymous", "constants", "variables", "allow"} if 'type' in columns: columns['operation_name'] = columns['operation_name'] if columns['type'] == 1 else "" if 'destination_arg' in columns: @@ -402,6 +402,19 @@ async def update(request: web.Request) -> web.Response: if entry_point_result['rows'][0]['type'] == 1: del_payload = PayloadBuilder(variable_payload).payload() await storage.delete_from_tbl("control_api_parameters", del_payload) + elif k == "allow": + allowed_users = [u for u in v] + db_allow_users = old_entrypoint["allow"] + insert_case = set(allowed_users) - set(db_allow_users) + for _user in insert_case: + acl_cols = {"name": name, "user": _user} + acl_insert_payload = PayloadBuilder().INSERT(**acl_cols).payload() + await storage.insert_into_tbl("control_api_acl", acl_insert_payload) + delete_case = set(db_allow_users) - set(allowed_users) + for _user in delete_case: + acl_delete_payload = PayloadBuilder().WHERE(["name", '=', name] + ).AND_WHERE(["user", '=', _user]).payload() + await storage.delete_from_tbl("control_api_acl", acl_delete_payload) else: control_api_columns[k] = v if control_api_columns: @@ -506,7 +519,7 @@ async def _get_entrypoint(name): else: response['constants'] = constants response['variables'] = variables - response['allow'] = "" + response['allow'] = [] acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) if acl_result['rows']: users = [] From 36f96f80523a6389b6e18dc57a24f5618f8c414b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 21 Sep 2023 16:02:42 +0530 Subject: [PATCH 453/499] allow user validation as per existing user in control entrypoint flow API Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 14fd54d471..f54a688cc7 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -12,6 +12,7 @@ from fledge.common.logger import FLCoreLogger from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.services.core import connect, server +from fledge.services.core.user_model import User __author__ = "Ashish Jabble" @@ -180,7 +181,12 @@ async def _check_parameters(payload, skip_required=False): if allow is not None: if not isinstance(allow, list): raise ValueError('allow should be an array of list of users.') - # TODO: FOGL-8037 get usernames validation + if allow: + users = await User.Objects.all() + usernames = [u['uname'] for u in users] + invalid_users = list(set(payload['allow']) - set(usernames)) + if invalid_users: + raise ValueError('Invalid user {} found.'.format(invalid_users)) final['allow'] = allow return final @@ -188,7 +194,7 @@ async def _check_parameters(payload, skip_required=False): async def create(request: web.Request) -> web.Response: """Create a control entrypoint :Example: - curl -sX POST http://localhost:8081/fledge/control/manage -d '{"name": "SetLatheSpeed", "description": "Set the speed of the lathe", "type": "write", "destination": "asset", "asset": "lathe", "constants": {"units": "spin"}, "variables": {"rpm": "100"}, "allow":["AJ"], "anonymous": "reject"}' + curl -sX POST http://localhost:8081/fledge/control/manage -d '{"name": "SetLatheSpeed", "description": "Set the speed of the lathe", "type": "write", "destination": "asset", "asset": "lathe", "constants": {"units": "spin"}, "variables": {"rpm": "100"}, "allow":["user"], "anonymous": false}' """ try: data = await request.json() @@ -224,10 +230,11 @@ async def create(request: web.Request) -> web.Response: api_params_insert_payload = PayloadBuilder().INSERT(**control_api_params_column_name).payload() await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) # add if any users in control_api_acl table - for u in payload['allow']: - control_acl_column_name = {"name": name, "user": u} - acl_insert_payload = PayloadBuilder().INSERT(**control_acl_column_name).payload() - await storage.insert_into_tbl("control_api_acl", acl_insert_payload) + if 'allow' in payload: + for u in payload['allow']: + control_acl_column_name = {"name": name, "user": u} + acl_insert_payload = PayloadBuilder().INSERT(**control_acl_column_name).payload() + await storage.insert_into_tbl("control_api_acl", acl_insert_payload) except (KeyError, ValueError) as err: msg = str(err) raise web.HTTPBadRequest(body=json.dumps({"message": msg}), reason=msg) @@ -323,9 +330,9 @@ async def delete(request: web.Request) -> web.Response: async def update(request: web.Request) -> web.Response: """Update a control entrypoint :Example: - curl -sX PUT "http://localhost:8081/fledge/control/manage/SetLatheSpeed" -d '{"constants": {"x": "486"}, "variables": {"rpm": "1200"}, "description": "Perform lathesim", "anonymous": false, "destination": "script", "script": "S4"}' + curl -sX PUT "http://localhost:8081/fledge/control/manage/SetLatheSpeed" -d '{"constants": {"x": "486"}, "variables": {"rpm": "1200"}, "description": "Perform lathesim", "anonymous": false, "destination": "script", "script": "S4", "allow": ["user"]}' curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"description": "Updated", "anonymous": false, "allow": []}' - curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"allow": ["user", "ashish"]}' + curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"allow": ["user"]}' """ name = request.match_info.get('name', None) try: From 9708b58f35152789aaf2889df7c049439893e84f Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Fri, 22 Sep 2023 14:02:31 +0530 Subject: [PATCH 454/499] Removing some debug logs Signed-off-by: Amandeep Singh Arora --- .../ingest_callback_pymodule.cpp | 5 +-- .../python/python_plugin_interface.cpp | 9 ---- C/services/south/ingest.cpp | 42 ++----------------- 3 files changed, 4 insertions(+), 52 deletions(-) diff --git a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp index 918e780b53..30a4e060a2 100755 --- a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp +++ b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp @@ -133,10 +133,8 @@ void filter_plugin_async_ingest_fn(PyObject *ingest_callback, { try { - PRINT_FUNC; // Get vector of Readings from Python object pyReadingSet = new PythonReadingSet(readingsObj); - PRINT_FUNC; } catch (std::exception e) { @@ -144,7 +142,7 @@ void filter_plugin_async_ingest_fn(PyObject *ingest_callback, pyReadingSet = NULL; } - Logger::getLogger()->info("%s:%d, pyReadingSet=%p, pyReadingSet readings count=%d", + Logger::getLogger()->debug("%s:%d, pyReadingSet=%p, pyReadingSet readings count=%d", __FUNCTION__, __LINE__, pyReadingSet, pyReadingSet?pyReadingSet->getCount():0); } else @@ -172,7 +170,6 @@ void filter_plugin_async_ingest_fn(PyObject *ingest_callback, Logger::getLogger()->info("%s:%d: cb function at address %p", __FUNCTION__, __LINE__, *cb); // Invoke callback method for ReadingSet filter ingestion (*cb)(data, pyReadingSet); - PRINT_FUNC; } else { diff --git a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp index 0e30eaaf2d..cdd3cb37b3 100755 --- a/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/filter-plugin-interfaces/python/python_plugin_interface.cpp @@ -262,19 +262,15 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) } Logger::getLogger()->debug("C2Py: filter_plugin_ingest_fn():L%d: data->getCount()=%d", __LINE__, data->getCount()); - PRINT_FUNC; // Create a readingList of readings to be filtered PythonReadingSet *pyReadingSet = (PythonReadingSet *) data; - PRINT_FUNC; PyObject* readingsList = pyReadingSet->toPython(); - PRINT_FUNC; PyObject* pReturn = PyObject_CallFunction(pFunc, "OO", handle, readingsList); - PRINT_FUNC; Py_CLEAR(pFunc); // Handle returned data @@ -285,7 +281,6 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) pName.c_str()); logErrorMessage(); } - PRINT_FUNC; data->removeAll(); delete data; @@ -333,10 +328,6 @@ void filter_plugin_ingest_fn(PLUGIN_HANDLE handle, READINGSET *data) // Release GIL PyGILState_Release(state); - /* if(data && data->getAllReadingsPtr()->size()) - Logger::getLogger()->info("%s:%d: data->getCount()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, data->getAllReadingsPtr()->size(), - data->getAllReadings()[0], data->getAllReadings()[0]->toJSON().c_str()); */ - } /** diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 3e5fedef07..fb73b1a3e6 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -719,34 +719,26 @@ void Ingest::processQueue() ReadingSet *readingSet = new ReadingSet(m_data); if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) - Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, + Logger::getLogger()->debug("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); m_data->clear(); // Pass readingSet to filter chain firstFilter->ingest(readingSet); - /* if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) - Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, - readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); */ - /* * If filtering removed all the readings then simply clean up m_data and * return. */ if (m_data->size() == 0) { - PRINT_FUNC; delete m_data; m_data = NULL; return; } - PRINT_FUNC; } } } - PRINT_FUNC; - /* * Check the first reading in the list to see if we are meeting the * latency configuration we have been set @@ -754,7 +746,7 @@ void Ingest::processQueue() if (m_data) { if(m_data->size()) - Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); + Logger::getLogger()->debug("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); vector::iterator itr = m_data->begin(); if (itr != m_data->cend()) { @@ -789,10 +781,8 @@ void Ingest::processQueue() * 2- some readings removed * 3- New set of readings */ - PRINT_FUNC; if (m_data && m_data->size()) { - PRINT_FUNC; if (m_storage.readingAppend(*m_data) == false) { if (!m_storageFailed) @@ -805,14 +795,12 @@ void Ingest::processQueue() } else { - PRINT_FUNC; if (m_storageFailed) { m_logger->warn("Storage operational after %d failures", m_storesFailed); m_storageFailed = false; m_storesFailed = 0; } - PRINT_FUNC; m_failCnt = 0; std::map statsEntriesCurrQueue; // check if this requires addition of a new asset tracker tuple @@ -823,14 +811,11 @@ void Ingest::processQueue() int *lastStat = NULL; std::map > assetDatapointMap; - PRINT_FUNC; if(m_data && m_data->size()) Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); - PRINT_FUNC; for (vector::iterator it = m_data->begin(); it != m_data->end(); ++it) { - PRINT_FUNC; Reading *reading = *it; string assetName = reading->getAssetName(); const std::vector dpVec = reading->getReadingData(); @@ -847,7 +832,6 @@ void Ingest::processQueue() } temp.clear(); - PRINT_FUNC; // Push them in a set so as to avoid duplication of datapoints // a reading of d1, d2, d3 and another d2,d3,d1 , second will be discarded @@ -860,7 +844,6 @@ void Ingest::processQueue() s.insert(dp); } } - PRINT_FUNC; if (lastAsset.compare(assetName)) { @@ -883,7 +866,6 @@ void Ingest::processQueue() assetName, "Ingest"); } - PRINT_FUNC; lastAsset = assetName; lastStat = &statsEntriesCurrQueue[assetName]; @@ -893,19 +875,14 @@ void Ingest::processQueue() { (*lastStat)++; } - PRINT_FUNC; // delete reading; - // PRINT_FUNC; } - PRINT_FUNC; for( auto & rdng : *m_data) { delete rdng; } - PRINT_FUNC; m_data->clear(); - PRINT_FUNC; for (auto itr : assetDatapointMap) { @@ -932,21 +909,16 @@ void Ingest::processQueue() statsPendingEntries[it.first] += it.second; } } - PRINT_FUNC; } } - PRINT_FUNC; if(m_data && m_data->size()) Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); if (m_data) { - PRINT_FUNC; delete m_data; - PRINT_FUNC; m_data = NULL; - PRINT_FUNC; } signalStatsUpdate(); } while (! m_fullQueues.empty()); @@ -1019,7 +991,7 @@ void Ingest::passToOnwardFilter(OUTPUT_HANDLE *outHandle, FilterPlugin *next = (FilterPlugin *)outHandle; if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) - Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, + Logger::getLogger()->debug("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); // Pass readings to next filter @@ -1050,7 +1022,6 @@ void Ingest::passToOnwardFilter(OUTPUT_HANDLE *outHandle, void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, READINGSET *readingSet) { - PRINT_FUNC; Ingest* ingest = (Ingest *)outHandle; if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) @@ -1062,7 +1033,6 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, if (ingest->m_data != readingSet->getAllReadingsPtr()) { - PRINT_FUNC; if (ingest->m_data && ingest->m_data->size()) { Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); @@ -1095,16 +1065,10 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, if (ingest->m_data) { Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); - // Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), ((*(ingest->m_data))[0])); - // Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), - // ((*(ingest->m_data))[0]), ((*(ingest->m_data))[0])->toJSON().c_str()); } - PRINT_FUNC; readingSet->clear(); - PRINT_FUNC; delete readingSet; - PRINT_FUNC; } /** From 82a5437c998ca74fe7ece1b1f82983fdaff0ff64 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Fri, 22 Sep 2023 14:49:46 +0530 Subject: [PATCH 455/499] More changes Signed-off-by: Amandeep Singh Arora --- .../ingest_callback_pymodule.cpp | 2 +- C/services/south/ingest.cpp | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp index 30a4e060a2..d8c4cd0e6a 100755 --- a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp +++ b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp @@ -167,7 +167,7 @@ void filter_plugin_async_ingest_fn(PyObject *ingest_callback, // Get ingest object parameter void *data = PyCapsule_GetPointer(ingest_obj_ref_data, NULL); - Logger::getLogger()->info("%s:%d: cb function at address %p", __FUNCTION__, __LINE__, *cb); + Logger::getLogger()->debug("%s:%d: cb function at address %p", __FUNCTION__, __LINE__, *cb); // Invoke callback method for ReadingSet filter ingestion (*cb)(data, pyReadingSet); } diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index fb73b1a3e6..b724054f4f 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -812,7 +812,7 @@ void Ingest::processQueue() std::map > assetDatapointMap; if(m_data && m_data->size()) - Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); + Logger::getLogger()->debug("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); for (vector::iterator it = m_data->begin(); it != m_data->end(); ++it) { @@ -913,7 +913,7 @@ void Ingest::processQueue() } if(m_data && m_data->size()) - Logger::getLogger()->info("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); + Logger::getLogger()->debug("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); if (m_data) { @@ -1025,19 +1025,19 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, Ingest* ingest = (Ingest *)outHandle; if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) - Logger::getLogger()->info("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, + Logger::getLogger()->debug("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); if(ingest->m_data->size() && readingSet->getAllReadingsPtr()->size()) - Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), readingSet->getAllReadings()[0]); + Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), readingSet->getAllReadings()[0]); if (ingest->m_data != readingSet->getAllReadingsPtr()) { if (ingest->m_data && ingest->m_data->size()) { - Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); - Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), ((*(ingest->m_data))[0])); - Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), + Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); + Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), ((*(ingest->m_data))[0])); + Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), ((*(ingest->m_data))[0]), ((*(ingest->m_data))[0])->toJSON().c_str()); // Remove the readings in the vector for(auto & rdng : *(ingest->m_data)) @@ -1047,8 +1047,7 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, // move reading vector to ingest *(ingest->m_data) = readingSet->getAllReadings(); - // ingest->m_data = readingSet->moveAllReadings(); - Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), (*(ingest->m_data))[0]->toJSON().c_str()); + Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), (*(ingest->m_data))[0]->toJSON().c_str()); } else { @@ -1064,7 +1063,7 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, if (ingest->m_data) { - Logger::getLogger()->info("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); + Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); } readingSet->clear(); From b7506766e99f74f645937888e00c34ea5bdae77f Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Fri, 22 Sep 2023 14:53:57 +0530 Subject: [PATCH 456/499] More changes Signed-off-by: Amandeep Singh Arora --- C/services/south/ingest.cpp | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index b724054f4f..ac348b3ae6 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -718,9 +718,6 @@ void Ingest::processQueue() } ReadingSet *readingSet = new ReadingSet(m_data); - if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) - Logger::getLogger()->debug("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, - readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); m_data->clear(); // Pass readingSet to filter chain firstFilter->ingest(readingSet); @@ -745,8 +742,6 @@ void Ingest::processQueue() */ if (m_data) { - if(m_data->size()) - Logger::getLogger()->debug("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); vector::iterator itr = m_data->begin(); if (itr != m_data->cend()) { @@ -811,9 +806,6 @@ void Ingest::processQueue() int *lastStat = NULL; std::map > assetDatapointMap; - if(m_data && m_data->size()) - Logger::getLogger()->debug("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); - for (vector::iterator it = m_data->begin(); it != m_data->end(); ++it) { Reading *reading = *it; @@ -912,9 +904,6 @@ void Ingest::processQueue() } } - if(m_data && m_data->size()) - Logger::getLogger()->debug("%s:%d: m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, m_data->size(), (*m_data)[0], (*m_data)[0]->toJSON().c_str()); - if (m_data) { delete m_data; @@ -990,10 +979,6 @@ void Ingest::passToOnwardFilter(OUTPUT_HANDLE *outHandle, // Get next filter in the pipeline FilterPlugin *next = (FilterPlugin *)outHandle; - if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) - Logger::getLogger()->debug("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, - readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); - // Pass readings to next filter next->ingest(readingSet); } @@ -1024,21 +1009,10 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, { Ingest* ingest = (Ingest *)outHandle; - if (readingSet && readingSet->getAllReadingsPtr() && readingSet->getAllReadingsPtr()->size()) - Logger::getLogger()->debug("%s:%d: readingSet->getAllReadingsPtr()->size()=%d, readingSet->getAllReadings()[0] (@ %p)=%s", __FUNCTION__, __LINE__, - readingSet->getAllReadingsPtr()->size(), readingSet->getAllReadings()[0], readingSet->getAllReadings()[0]->toJSON().c_str()); - - if(ingest->m_data->size() && readingSet->getAllReadingsPtr()->size()) - Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), readingSet->getAllReadings()[0]); - if (ingest->m_data != readingSet->getAllReadingsPtr()) { if (ingest->m_data && ingest->m_data->size()) { - Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); - Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p", __FUNCTION__, __LINE__, ingest->m_data->size(), ((*(ingest->m_data))[0])); - Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d, first rdngPtr=%p, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), - ((*(ingest->m_data))[0]), ((*(ingest->m_data))[0])->toJSON().c_str()); // Remove the readings in the vector for(auto & rdng : *(ingest->m_data)) delete rdng; @@ -1047,7 +1021,6 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, // move reading vector to ingest *(ingest->m_data) = readingSet->getAllReadings(); - Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d, first rdng=%s", __FUNCTION__, __LINE__, ingest->m_data->size(), (*(ingest->m_data))[0]->toJSON().c_str()); } else { @@ -1061,11 +1034,6 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, __FUNCTION__, __LINE__, ingest->m_data, readingSet->getAllReadingsPtr()); } - if (ingest->m_data) - { - Logger::getLogger()->debug("%s:%d: ingest->m_data->size()=%d", __FUNCTION__, __LINE__, ingest->m_data->size()); - } - readingSet->clear(); delete readingSet; } From 08bb3c1e57bb5b3e0c37e0dd84ca28e83a654f1f Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 22 Sep 2023 14:11:53 +0100 Subject: [PATCH 457/499] FOGL-7839 clean-up memory deallocation (#1158) * Updated test_memcheck.sh file to generate logs and xml report based on the params passed Signed-off-by: Mohit Singh Tomar * Feedback changes Signed-off-by: Mohit Singh Tomar * Refactored code Signed-off-by: Mohit Singh Tomar * FOGL-7839 Cleanup shutdown of storage service to remove valgrind reports Signed-off-by: Mark Riddoch * North service enhancements to cleanup or exit Signed-off-by: Mark Riddoch * Add virtual destructor to south service and remove copy of readings when ingesting via Python Signed-off-by: Mark Riddoch * Delete north service Signed-off-by: Mark Riddoch * Updates in north service Signed-off-by: Mark Riddoch * Delete empty reading sets Signed-off-by: Mark Riddoch * Make sure reconfigure thread is shutdown Signed-off-by: Mark Riddoch * Add south plugin destructor Signed-off-by: Mark Riddoch * Updates in south service Signed-off-by: Mark Riddoch * Checkpoint Signed-off-by: Mark Riddoch * Cleanup queues Signed-off-by: Mark Riddoch * Fix unitialised data read Signed-off-by: Mark Riddoch * Resolve sharing of readings issue Signed-off-by: Mark Riddoch * Revert to push_back for PythonReadingSet Signed-off-by: Mark Riddoch * Limit north queues Signed-off-by: Mark Riddoch * Experimental checkin Signed-off-by: Mark Riddoch * Experiment with removing virtual from reading set destructor Signed-off-by: Mark Riddoch * Fix dangerous ReadingSet::append that could result in readings being deleted twice Signed-off-by: Mark Riddoch * Reinstate optimal south ingest Signed-off-by: Mark Riddoch * Remove conditional code Signed-off-by: Mark Riddoch * Fix issue with base class calling method in derived class from the destructor Signed-off-by: Mark Riddoch --------- Signed-off-by: Mohit Singh Tomar Signed-off-by: Mark Riddoch Co-authored-by: Mohit Singh Tomar Co-authored-by: Himanshu Vimal <67678828+cyberwalk3r@users.noreply.github.com> --- C/common/asset_tracking.cpp | 8 ++ C/common/include/asset_tracking.h | 2 +- C/common/include/pyruntime.h | 3 + C/common/include/pythonreading.h | 1 + C/common/include/pythonreadingset.h | 1 + C/common/include/reading.h | 2 +- C/common/include/reading_set.h | 5 +- C/common/logger.cpp | 3 + C/common/pyruntime.cpp | 23 ++++ C/common/pythonreading.cpp | 2 + C/common/reading_set.cpp | 40 ++++-- C/services/common/include/service_handler.h | 4 + C/services/common/service_security.cpp | 8 +- .../python/python_plugin_interface.cpp | 18 +-- C/services/north/data_load.cpp | 23 +++- C/services/north/data_send.cpp | 5 + C/services/north/include/north_service.h | 2 +- C/services/north/north.cpp | 26 +++- .../python/python_plugin_interface.cpp | 57 ++++----- C/services/south/CMakeLists.txt | 33 +++++ C/services/south/include/south_service.h | 1 + C/services/south/ingest.cpp | 23 ++++ C/services/south/south.cpp | 114 +++++++++++------- C/services/south/south_plugin.cpp | 26 ++-- C/services/storage/configuration.cpp | 4 +- C/services/storage/pluginconfiguration.cpp | 4 +- C/services/storage/storage.cpp | 1 + C/services/storage/storage_api.cpp | 7 +- C/services/storage/storage_registry.cpp | 16 ++- 29 files changed, 333 insertions(+), 129 deletions(-) diff --git a/C/common/asset_tracking.cpp b/C/common/asset_tracking.cpp index adccb96ce0..2f6e9dedc7 100644 --- a/C/common/asset_tracking.cpp +++ b/C/common/asset_tracking.cpp @@ -112,8 +112,16 @@ AssetTracker::~AssetTracker() m_storageClient = NULL; } + for (auto& item : assetTrackerTuplesCache) + { + delete item; + } assetTrackerTuplesCache.clear(); + for (auto& store : storageAssetTrackerTuplesCache) + { + delete store.first; + } storageAssetTrackerTuplesCache.clear(); } diff --git a/C/common/include/asset_tracking.h b/C/common/include/asset_tracking.h index a54c232ef6..5445bfca4c 100644 --- a/C/common/include/asset_tracking.h +++ b/C/common/include/asset_tracking.h @@ -284,7 +284,7 @@ class AssetTracker { std::string m_service; std::unordered_set, AssetTrackingTuplePtrEqual> assetTrackerTuplesCache; - std::queue m_pending; // Tuples that are not yet written to the storage + std::queue m_pending; // Tuples that are not yet written to the storage std::thread *m_thread; bool m_shutdown; std::condition_variable m_cv; diff --git a/C/common/include/pyruntime.h b/C/common/include/pyruntime.h index 630220e7cf..496e76371e 100644 --- a/C/common/include/pyruntime.h +++ b/C/common/include/pyruntime.h @@ -14,12 +14,15 @@ class PythonRuntime { public: static PythonRuntime *getPythonRuntime(); + static bool initialised() { return m_instance != NULL; }; + static void shutdown(); void execute(const std::string& python); PyObject *call(const std::string& name, const std::string& fmt, ...); PyObject *call(PyObject *module, const std::string& name, const std::string& fmt, ...); PyObject *importModule(const std::string& name); private: PythonRuntime(); + ~PythonRuntime(); PythonRuntime(const PythonRuntime& rhs); PythonRuntime& operator=(const PythonRuntime& rhs); void logException(const std::string& name); diff --git a/C/common/include/pythonreading.h b/C/common/include/pythonreading.h index c6c9f5a2ed..7a4297a6e0 100644 --- a/C/common/include/pythonreading.h +++ b/C/common/include/pythonreading.h @@ -20,6 +20,7 @@ class PythonReading : public Reading { public: PythonReading(PyObject *pyReading); + ~PythonReading() {}; PyObject *toPython(bool changeKeys = false, bool bytesString = false); static std::string errorMessage(); static bool isArray(PyObject *); diff --git a/C/common/include/pythonreadingset.h b/C/common/include/pythonreadingset.h index 7b97c1ae69..b91813da67 100644 --- a/C/common/include/pythonreadingset.h +++ b/C/common/include/pythonreadingset.h @@ -20,6 +20,7 @@ class PythonReadingSet : public ReadingSet { public: PythonReadingSet(PyObject *pySet); + ~PythonReadingSet() {}; PyObject *toPython(bool changeKeys = false); private: void setReadingAttr(Reading* newReading, PyObject *readingList, bool fillIfMissing); diff --git a/C/common/include/reading.h b/C/common/include/reading.h index e3bfbfe865..3b61e14af1 100644 --- a/C/common/include/reading.h +++ b/C/common/include/reading.h @@ -38,7 +38,7 @@ class Reading { Reading(const std::string& asset, const std::string& datapoints); Reading(const Reading& orig); - ~Reading(); + ~Reading(); // This should bbe virtual void addDatapoint(Datapoint *value); Datapoint *removeDatapoint(const std::string& name); Datapoint *getDatapoint(const std::string& name) const; diff --git a/C/common/include/reading_set.h b/C/common/include/reading_set.h index 2872303600..ccd77a6d5a 100755 --- a/C/common/include/reading_set.h +++ b/C/common/include/reading_set.h @@ -28,7 +28,7 @@ class ReadingSet { ReadingSet(); ReadingSet(const std::string& json); ReadingSet(const std::vector* readings); - ~ReadingSet(); + virtual ~ReadingSet(); unsigned long getCount() const { return m_count; }; const Reading *operator[] (const unsigned int idx) { @@ -50,7 +50,7 @@ class ReadingSet { unsigned long getReadingId(uint32_t pos); void append(ReadingSet *); void append(ReadingSet&); - void append(const std::vector &); + void append(std::vector &); void removeAll(); void clear(); bool copy(const ReadingSet& src); @@ -72,6 +72,7 @@ class ReadingSet { class JSONReading : public Reading { public: JSONReading(const rapidjson::Value& json); + ~JSONReading() {}; // Return the reading id unsigned long getId() const { return m_id; }; diff --git a/C/common/logger.cpp b/C/common/logger.cpp index 7a21e42262..1470dd38be 100755 --- a/C/common/logger.cpp +++ b/C/common/logger.cpp @@ -51,6 +51,9 @@ static char ident[80]; Logger::~Logger() { closelog(); + // Stop the getLogger() call returning a deleted instance + if (instance == this) + instance = NULL; } Logger *Logger::getLogger() diff --git a/C/common/pyruntime.cpp b/C/common/pyruntime.cpp index 99e7ca45e7..06acd52f1f 100644 --- a/C/common/pyruntime.cpp +++ b/C/common/pyruntime.cpp @@ -44,6 +44,15 @@ PythonRuntime::PythonRuntime() PyThreadState *save = PyEval_SaveThread(); // Release the GIL } +/** + * Destructor + */ +PythonRuntime::~PythonRuntime() +{ + PyGILState_STATE gstate = PyGILState_Ensure(); + Py_Finalize(); +} + /** * Don't allow a copy constructor to be used */ @@ -319,3 +328,17 @@ PyObject *PythonRuntime::importModule(const string& name) PyGILState_Release(state); return module; } + +/** + * Shutdown an instance of a Python runtime if one + * has been started + */ +void PythonRuntime::shutdown() +{ + if (!m_instance) + { + return; + } + delete m_instance; + m_instance = NULL; +} diff --git a/C/common/pythonreading.cpp b/C/common/pythonreading.cpp index c22bb93aa7..678af30747 100755 --- a/C/common/pythonreading.cpp +++ b/C/common/pythonreading.cpp @@ -131,9 +131,11 @@ PythonReading::PythonReading(PyObject *pyReading) { // Set id m_id = PyLong_AsUnsignedLong(id); + m_has_id = true; } else { + m_has_id = false; m_id = 0; } diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 8749626e15..382d321bc4 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -164,30 +164,44 @@ ReadingSet::~ReadingSet() /** * Append the readings in a second reading set to this reading set. * The readings are removed from the original reading set + * + * @param readings A ReadingSet to append to the current ReadingSet */ void ReadingSet::append(ReadingSet *readings) { - append(readings->getAllReadings()); + vector *vec = readings->getAllReadingsPtr(); + append(*vec); readings->clear(); } /** * Append the readings in a second reading set to this reading set. * The readings are removed from the original reading set + * + * @param readings A ReadingSet to append to the current ReadingSet */ void ReadingSet::append(ReadingSet& readings) { - append(readings.getAllReadings()); + vector *vec = readings.getAllReadingsPtr(); + append(*vec); readings.clear(); } /** - * Append a set of readings to this reading set. + * Append a set of readings to this reading set. The + * readings are not copied, but rather moved from the + * vector, with the resulting vector havign the values + * removed on return. + * + * It is assumed the readings in the vector have been + * created with the new operator. + * + * @param readings A vector of Reading pointers to append to the ReadingSet */ void -ReadingSet::append(const vector& readings) +ReadingSet::append(vector& readings) { for (auto it = readings.cbegin(); it != readings.cend(); it++) { @@ -196,6 +210,7 @@ ReadingSet::append(const vector& readings) m_readings.push_back(*it); m_count++; } + readings.clear(); } /** @@ -295,6 +310,8 @@ ReadingSet::removeAll() delete *it; } m_readings.clear(); + m_count = 0; + m_last_id = 0; } /** @@ -304,6 +321,8 @@ void ReadingSet::clear() { m_readings.clear(); + m_count = 0; + m_last_id = 0; } /** @@ -325,15 +344,16 @@ std::vector* ReadingSet::moveAllReadings() */ Reading* ReadingSet::removeReading(unsigned long id) { - if (id >= m_readings.size()) { - return nullptr; - } + if (id >= m_readings.size()) + { + return nullptr; + } Reading* reading = m_readings[id]; - m_readings.erase(m_readings.begin() + id); - m_count--; + m_readings.erase(m_readings.begin() + id); + m_count--; - return reading; + return reading; } /** diff --git a/C/services/common/include/service_handler.h b/C/services/common/include/service_handler.h index 5899d347bd..2075315ef5 100644 --- a/C/services/common/include/service_handler.h +++ b/C/services/common/include/service_handler.h @@ -35,6 +35,8 @@ class ServiceHandler class ServiceAuthHandler : public ServiceHandler { public: + ServiceAuthHandler() : m_refreshThread(NULL), m_refreshRunning(true) {}; + virtual ~ServiceAuthHandler() { if (m_refreshThread) { m_refreshRunning = false; m_refreshThread->join(); delete m_refreshThread; } }; std::string& getName() { return m_name; }; std::string& getType() { return m_type; }; bool createSecurityCategories(ManagementClient* mgtClient, bool dryRun); @@ -105,6 +107,8 @@ class ServiceAuthHandler : public ServiceHandler ConfigCategory m_security; // Service ACL ACL m_service_acl; + std::thread *m_refreshThread; + bool m_refreshRunning; }; #endif diff --git a/C/services/common/service_security.cpp b/C/services/common/service_security.cpp index ad52f03be5..3504ce7db8 100644 --- a/C/services/common/service_security.cpp +++ b/C/services/common/service_security.cpp @@ -95,7 +95,7 @@ bool ServiceAuthHandler::createSecurityCategories(ManagementClient* mgtClient, b // Start thread for automatic bearer token refresh, before expiration if (this->getType() != "Southbound" && dryRun == false) { - new thread(bearer_token_refresh_thread, this); + m_refreshThread = new thread(bearer_token_refresh_thread, this); } return true; @@ -597,7 +597,7 @@ void ServiceAuthHandler::refreshBearerToken() // and sleeps for a few secods. // When expires_in - DELTA_SECONDS_BEFORE_TOKEN_EXPIRATION seconds is done // then get new token and sleep again - while (this->isRunning()) + while (m_refreshRunning) { if (k >= max_retries) { @@ -606,7 +606,7 @@ void ServiceAuthHandler::refreshBearerToken() Logger::getLogger()->error(msg.c_str()); // Shutdown service - if (this->isRunning()) + if (m_refreshRunning) { Logger::getLogger()->warn("Service is being shut down " \ "due to bearer token refresh error"); @@ -665,7 +665,7 @@ void ServiceAuthHandler::refreshBearerToken() // A shutdown maybe is set, since last check: check it now // refresh_token core API endpoint - if (!this->isRunning()) + if (!m_refreshRunning) { Logger::getLogger()->info("Service is being shut down: " \ "refresh thread does not call " \ diff --git a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp index be8e6c3e36..fdfa7bdf62 100644 --- a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp @@ -415,17 +415,10 @@ uint32_t plugin_send_fn(PLUGIN_HANDLE handle, const std::vector& read } // Create a dict of readings - // 1 create empty ReadingSet - ReadingSet set; + // 1 create a PythonReadingSet object + PythonReadingSet *pyReadingSet = (PythonReadingSet *) &readings; - // 2 append all input readings: - // Note: the readings elements are pointers - set.append(readings); - - // 3 create a PythonReadingSet object - PythonReadingSet *pyReadingSet = (PythonReadingSet *) &set; - - // 4 create PyObject + // 2 create PyObject PyObject* readingsList = pyReadingSet->toPython(true); numReadingsSent = call_plugin_send_coroutine(pFunc, handle, readingsList); @@ -433,11 +426,6 @@ uint32_t plugin_send_fn(PLUGIN_HANDLE handle, const std::vector& read __LINE__, numReadingsSent); - // Remove all elements in readings vector - // without freeing them as the reagings pointers - // will be be freed by the caller of plugin_send_fn - set.clear(); - // Remove python object Py_CLEAR(readingsList); Py_CLEAR(pFunc); diff --git a/C/services/north/data_load.cpp b/C/services/north/data_load.cpp index f7e8eb3291..4eb80ae67e 100755 --- a/C/services/north/data_load.cpp +++ b/C/services/north/data_load.cpp @@ -52,11 +52,20 @@ DataLoad::~DataLoad() m_cv.notify_all(); m_fetchCV.notify_all(); m_thread->join(); + delete m_thread; if (m_pipeline) { m_pipeline->cleanupFilters(m_name); delete m_pipeline; } + // Clear out the queue of readings + unique_lock lck(m_qMutex); // Should not need to do this + while (! m_queue.empty()) + { + ReadingSet *readings = m_queue.front(); + delete readings; + m_queue.pop_front(); + } Logger::getLogger()->info("Data load shutdown complete"); } @@ -186,6 +195,11 @@ ReadingSet *readings = NULL; bufferReadings(readings); return; } + else if (readings) + { + // Delete the empty readings set + delete readings; + } else { // Logger::getLogger()->debug("DataLoad::readBlock(): No readings available"); @@ -293,7 +307,9 @@ unsigned long DataLoad::getLastSentId() // Get column value ResultSet::ColumnValue* theVal = row->getColumn("last_object"); // Set found id - return (unsigned long)theVal->getInteger(); + unsigned long rval = (unsigned long)theVal->getInteger(); + delete lastObjectId; + return rval; } } // Free result set @@ -356,7 +372,10 @@ ReadingSet *DataLoad::fetchReadings(bool wait) } ReadingSet *rval = m_queue.front(); m_queue.pop_front(); - triggerRead(m_blockSize); + if (m_queue.size() < 5) // Read another block if we have less than 5 already queued + { + triggerRead(m_blockSize); + } return rval; } diff --git a/C/services/north/data_send.cpp b/C/services/north/data_send.cpp index 9fc48e39a8..2651372da4 100755 --- a/C/services/north/data_send.cpp +++ b/C/services/north/data_send.cpp @@ -106,6 +106,11 @@ void DataSender::sendThread() readings = NULL; } } + if (readings) + { + // Rremove any readings we had failed to send before shutting down + delete readings; + } m_logger->info("Sending thread shutdown"); } diff --git a/C/services/north/include/north_service.h b/C/services/north/include/north_service.h index ca4bdde943..fc852fcde7 100644 --- a/C/services/north/include/north_service.h +++ b/C/services/north/include/north_service.h @@ -34,7 +34,7 @@ class NorthService : public ServiceAuthHandler { public: NorthService(const std::string& name, const std::string& token = ""); - ~NorthService(); + virtual ~NorthService(); void start(std::string& coreAddress, unsigned short corePort); void stop(); diff --git a/C/services/north/north.cpp b/C/services/north/north.cpp index 5d0df459ae..326992a3b1 100755 --- a/C/services/north/north.cpp +++ b/C/services/north/north.cpp @@ -189,6 +189,8 @@ bool dryRun = false; } Logger::getLogger()->setMinLevel(logLevel); service->start(coreAddress, corePort); + + delete service; return 0; } @@ -277,6 +279,9 @@ int size; */ NorthService::NorthService(const string& myName, const string& token) : m_dataLoad(NULL), + m_dataSender(NULL), + northPlugin(NULL), + m_assetTracker(NULL), m_shutdown(false), m_storage(NULL), m_pluginData(NULL), @@ -284,7 +289,8 @@ NorthService::NorthService(const string& myName, const string& token) : m_token(token), m_allowControl(true), m_dryRun(false), - m_requestRestart() + m_requestRestart(), + m_auditLogger(NULL) { m_name = myName; logger = new Logger(myName); @@ -296,8 +302,23 @@ NorthService::NorthService(const string& myName, const string& token) : */ NorthService::~NorthService() { + if (northPlugin) + delete northPlugin; if (m_storage) delete m_storage; + if (m_dataLoad) + delete m_dataLoad; + if (m_dataSender) + delete m_dataSender; + if (m_pluginData) + delete m_pluginData; + if (m_assetTracker) + delete m_assetTracker; + if (m_auditLogger) + delete m_auditLogger; + if (m_mgtClient) + delete m_mgtClient; + delete logger; } /** @@ -465,8 +486,10 @@ void NorthService::start(string& coreAddress, unsigned short corePort) m_dataLoad->shutdown(); // Forces the data load to return from any blocking fetch call delete m_dataSender; + m_dataSender = NULL; logger->debug("North service data sender has shut down"); delete m_dataLoad; + m_dataLoad = NULL; logger->debug("North service shutting down plugin"); @@ -803,6 +826,7 @@ void NorthService::restartPlugin() } delete northPlugin; + northPlugin = NULL; loadPlugin(); // Deal with persisted data and start the plugin if (northPlugin->persistData()) diff --git a/C/services/south-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/south-plugin-interfaces/python/python_plugin_interface.cpp index b3119c7777..59da71af1e 100755 --- a/C/services/south-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/south-plugin-interfaces/python/python_plugin_interface.cpp @@ -510,46 +510,35 @@ std::vector* plugin_poll_fn(PLUGIN_HANDLE handle) } else { - // Get reading data - PythonReadingSet *pyReadingSet = NULL; + // Get reading data + PythonReadingSet *pyReadingSet = NULL; - // Valid ReadingSet would be in the form of python dict or list - if (PyList_Check(pReturn) || PyDict_Check(pReturn)) - { - try - { - pyReadingSet = new PythonReadingSet(pReturn); - } - catch (std::exception e) - { - Logger::getLogger()->warn("PythonReadingSet c'tor failed, error: %s", e.what()); - pyReadingSet = NULL; - } - } - + // Valid ReadingSet would be in the form of python dict or list + if (PyList_Check(pReturn) || PyDict_Check(pReturn)) + { + try { + pyReadingSet = new PythonReadingSet(pReturn); + } catch (std::exception e) { + Logger::getLogger()->warn("Failed to create a Python ReadingSet from the data returned by the south plugin poll routine, %s", e.what()); + pyReadingSet = NULL; + } + } + // Remove pReturn object Py_CLEAR(pReturn); PyGILState_Release(state); - if (pyReadingSet) - { - std::vector *vec = pyReadingSet->getAllReadingsPtr(); - std::vector *vec2 = new std::vector; - - for (auto & r : *vec) - { - Reading *r2 = new Reading(*r); // Need to copy reading objects here, since "del pyReadingSet" below would remove encapsulated reading objects - vec2->emplace_back(r2); - } - - delete pyReadingSet; - return vec2; - } - else - { - return NULL; - } + if (pyReadingSet) + { + std::vector *vec2 = pyReadingSet->moveAllReadings(); + delete pyReadingSet; + return vec2; + } + else + { + return NULL; + } } } diff --git a/C/services/south/CMakeLists.txt b/C/services/south/CMakeLists.txt index e6a73fd05b..8183ac0d98 100644 --- a/C/services/south/CMakeLists.txt +++ b/C/services/south/CMakeLists.txt @@ -1,6 +1,7 @@ cmake_minimum_required (VERSION 2.8.8) project (South) + set(CMAKE_CXX_FLAGS_DEBUG "-O0 -ggdb -DPy_DEBUG") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -Wall -Wextra -Wsign-conversion") set(DLLIB -ldl) @@ -28,8 +29,40 @@ if(APPLE) set(OPENSSL_ROOT_DIR "/usr/local/opt/openssl") endif() +# Find python3.x dev/lib package +find_package(PkgConfig REQUIRED) +if(${CMAKE_VERSION} VERSION_LESS "3.12.0") + pkg_check_modules(PYTHON REQUIRED python3) +else() + if("${OS_NAME}" STREQUAL "mendel") + # We will explicitly set include path later for NumPy. + find_package(Python3 REQUIRED COMPONENTS Interpreter Development ) + else() + find_package(Python3 REQUIRED COMPONENTS Interpreter Development NumPy) + endif() +endif() + file(GLOB south_src "*.cpp") +# Add Python 3.x header files +if(${CMAKE_VERSION} VERSION_LESS "3.12.0") + include_directories(${PYTHON_INCLUDE_DIRS}) +else() + if("${OS_NAME}" STREQUAL "mendel") + # The following command gets the location of NumPy. + execute_process( + COMMAND python3 + -c "import numpy; print(numpy.get_include())" + OUTPUT_VARIABLE Python3_NUMPY_INCLUDE_DIRS + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + # Now we can add include directories as usual. + include_directories(${Python3_INCLUDE_DIRS} ${Python3_NUMPY_INCLUDE_DIRS}) + else() + include_directories(${Python3_INCLUDE_DIRS} ${Python3_NUMPY_INCLUDE_DIRS}) + endif() +endif() + link_directories(${PROJECT_BINARY_DIR}/../../lib) add_executable(${EXEC} ${south_src} ${common_src} ${services_src}) diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index 34b631b035..145dd8392d 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -47,6 +47,7 @@ class SouthService : public ServiceAuthHandler { public: SouthService(const std::string& name, const std::string& token = ""); + virtual ~SouthService(); void start(std::string& coreAddress, unsigned short corePort); void stop(); diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 710ee5e781..e462785e6a 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -353,7 +353,30 @@ Ingest::~Ingest() m_statsCv.notify_one(); m_statsThread->join(); updateStats(); + // Cleanup and readings left in the various queues + for (auto& reading : *m_queue) + { + delete reading; + } delete m_queue; + for (auto& q : m_resendQueues) + { + for (auto& rq : *q) + { + delete rq; + } + delete q; + } + while (m_fullQueues.size() > 0) + { + vector *q = m_fullQueues.front(); + for (auto& rq : *q) + { + delete rq; + } + delete q; + m_fullQueues.pop(); + } delete m_thread; delete m_statsThread; diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index e56eb17cb5..5e3881affd 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #define SERVICE_TYPE "Southbound" @@ -105,8 +106,12 @@ bool dryrun = false; { service->setDryRun(); } - Logger::getLogger()->setMinLevel(logLevel); + Logger *logger = Logger::getLogger(); + logger->setMinLevel(logLevel); + // Start the service. This will oly return whren the serivce is shutdown service->start(coreAddress, corePort); + delete service; + delete logger; return 0; } @@ -224,14 +229,18 @@ void doIngestV2(Ingest *ingest, ReadingSet *set) * Constructor for the south service */ SouthService::SouthService(const string& myName, const string& token) : + southPlugin(NULL), + m_assetTracker(NULL), m_shutdown(false), m_readingsPerSec(1), m_throttle(false), m_throttled(false), m_token(token), m_repeatCnt(1), + m_pluginData(NULL), m_dryRun(false), m_requestRestart(false), + m_auditLogger(NULL), m_perfMonitor(NULL) { m_name = myName; @@ -244,6 +253,30 @@ SouthService::SouthService(const string& myName, const string& token) : m_reconfThread = new std::thread(reconfThreadMain, this); } +/** + * Destructor for south service + */ +SouthService::~SouthService() +{ + m_cvNewReconf.notify_all(); // Wakeup the reconfigure thread to terminate it + m_reconfThread->join(); + delete m_reconfThread; + if (m_pluginData) + delete m_pluginData; + if (m_perfMonitor) + delete m_perfMonitor; + delete m_assetTracker; + delete m_auditLogger; + delete m_mgtClient; + + // We would like to shutdown the Python environment if it + // was running. However this causes a segmentation fault within Python + // so we currently can not do this +#if PYTHON_SHUTDOWN + PythonRuntime::shutdown(); // Shutdown and release Python resources +#endif +} + /** * Start the south service */ @@ -662,6 +695,8 @@ void SouthService::start(string& coreAddress, unsigned short corePort) { southPlugin->shutdown(); } + delete southPlugin; + southPlugin = NULL; } } @@ -687,9 +722,6 @@ void SouthService::start(string& coreAddress, unsigned short corePort) */ void SouthService::stop() { - delete m_assetTracker; - delete m_auditLogger; - delete m_mgtClient; logger->info("Stopping south service...\n"); } @@ -1008,10 +1040,7 @@ static void reconfThreadMain(void *arg) { SouthService *ss = (SouthService *)arg; Logger::getLogger()->info("reconfThreadMain(): Spawned new thread for plugin reconf"); - while(1) - { - ss->handlePendingReconf(); - } + ss->handlePendingReconf(); Logger::getLogger()->info("reconfThreadMain(): plugin reconf thread exiting"); } @@ -1021,47 +1050,50 @@ static void reconfThreadMain(void *arg) */ void SouthService::handlePendingReconf() { - Logger::getLogger()->debug("SouthService::handlePendingReconf: Going into cv wait"); - mutex mtx; - unique_lock lck(mtx); - m_cvNewReconf.wait(lck); - Logger::getLogger()->debug("SouthService::handlePendingReconf: cv wait has completed; some reconf request(s) has/have been queued up"); - - while(1) + while (isRunning()) { - unsigned int numPendingReconfs = 0; - { - lock_guard guard(m_pendingNewConfigMutex); - numPendingReconfs = m_pendingNewConfig.size(); - if (numPendingReconfs) - Logger::getLogger()->debug("SouthService::handlePendingReconf(): will process %d entries in m_pendingNewConfig", numPendingReconfs); - else - { - Logger::getLogger()->debug("SouthService::handlePendingReconf DONE"); - break; - } - } + Logger::getLogger()->debug("SouthService::handlePendingReconf: Going into cv wait"); + mutex mtx; + unique_lock lck(mtx); + m_cvNewReconf.wait(lck); + Logger::getLogger()->debug("SouthService::handlePendingReconf: cv wait has completed; some reconf request(s) has/have been queued up"); - for (unsigned int i=0; idebug("SouthService::handlePendingReconf(): Handling Configuration change #%d", i); - std::pair *reconfValue = NULL; + unsigned int numPendingReconfs = 0; { lock_guard guard(m_pendingNewConfigMutex); - reconfValue = &m_pendingNewConfig[i]; + numPendingReconfs = m_pendingNewConfig.size(); + if (numPendingReconfs) + Logger::getLogger()->debug("SouthService::handlePendingReconf(): will process %d entries in m_pendingNewConfig", numPendingReconfs); + else + { + Logger::getLogger()->debug("SouthService::handlePendingReconf DONE"); + break; + } } - std::string categoryName = reconfValue->first; - std::string category = reconfValue->second; - processConfigChange(categoryName, category); - logger->debug("SouthService::handlePendingReconf(): Handling of configuration change #%d done", i); - } - - { - lock_guard guard(m_pendingNewConfigMutex); for (unsigned int i=0; idebug("SouthService::handlePendingReconf DONE: first %d entry(ies) removed, m_pendingNewConfig new size=%d", numPendingReconfs, m_pendingNewConfig.size()); + { + logger->debug("SouthService::handlePendingReconf(): Handling Configuration change #%d", i); + std::pair *reconfValue = NULL; + { + lock_guard guard(m_pendingNewConfigMutex); + reconfValue = &m_pendingNewConfig[i]; + } + std::string categoryName = reconfValue->first; + std::string category = reconfValue->second; + processConfigChange(categoryName, category); + + logger->debug("SouthService::handlePendingReconf(): Handling of configuration change #%d done", i); + } + + { + lock_guard guard(m_pendingNewConfigMutex); + for (unsigned int i=0; idebug("SouthService::handlePendingReconf DONE: first %d entry(ies) removed, m_pendingNewConfig new size=%d", numPendingReconfs, m_pendingNewConfig.size()); + } } } } diff --git a/C/services/south/south_plugin.cpp b/C/services/south/south_plugin.cpp index 9f9c69229a..7cd806f0cf 100755 --- a/C/services/south/south_plugin.cpp +++ b/C/services/south/south_plugin.cpp @@ -112,6 +112,13 @@ SouthPlugin::SouthPlugin(PLUGIN_HANDLE handle, const ConfigCategory& category) : } } +/** + * South plugin destructor + */ +SouthPlugin::~SouthPlugin() +{ +} + /** * Call the start method in the plugin */ @@ -183,15 +190,16 @@ ReadingSet* SouthPlugin::pollV2() { lock_guard guard(mtx2); try { - std::vector *vec = this->pluginPollPtrV2(instance); - if(vec) - { - ReadingSet *set = new ReadingSet(vec); - delete vec; - return set; // this->pluginPollPtrV2(instance); - } - else - return NULL; + std::vector *vec = this->pluginPollPtrV2(instance); + if(vec) + { + ReadingSet *set = new ReadingSet(vec); + vec->clear(); + delete vec; + return set; // this->pluginPollPtrV2(instance); + } + else + return NULL; } catch (exception& e) { Logger::getLogger()->fatal("Unhandled exception raised in v2 south plugin poll(), %s", e.what()); diff --git a/C/services/storage/configuration.cpp b/C/services/storage/configuration.cpp index 10464ad4b5..a3253ff684 100644 --- a/C/services/storage/configuration.cpp +++ b/C/services/storage/configuration.cpp @@ -150,7 +150,7 @@ bool StorageConfiguration::setValue(const string& key, const string& value) const char *cstr = value.c_str(); item["value"].SetString(cstr, strlen(cstr), document->GetAllocator()); return true; - } catch (exception e) { + } catch (...) { return false; } } @@ -211,7 +211,7 @@ string cachefile; GetParseError_En(document->GetParseError()), document->GetErrorOffset()); } - } catch (exception ex) { + } catch (exception& ex) { logger->error("Configuration cache failed to read %s.", ex.what()); } } diff --git a/C/services/storage/pluginconfiguration.cpp b/C/services/storage/pluginconfiguration.cpp index 1fac092658..03e5517ee3 100644 --- a/C/services/storage/pluginconfiguration.cpp +++ b/C/services/storage/pluginconfiguration.cpp @@ -82,7 +82,7 @@ bool StoragePluginConfiguration::setValue(const string& key, const string& value const char *cstr = value.c_str(); item["value"].SetString(cstr, strlen(cstr), m_document->GetAllocator()); return true; - } catch (exception e) { + } catch (...) { return false; } } @@ -145,7 +145,7 @@ string cachefile; GetParseError_En(m_document->GetParseError()), m_document->GetErrorOffset()); } - } catch (exception ex) { + } catch (exception& ex) { m_logger->error("Configuration cache failed to read %s.", ex.what()); } } diff --git a/C/services/storage/storage.cpp b/C/services/storage/storage.cpp index f99304536a..8999e21e41 100644 --- a/C/services/storage/storage.cpp +++ b/C/services/storage/storage.cpp @@ -244,6 +244,7 @@ unsigned short servicePort; */ StorageService::~StorageService() { + delete api; delete config; delete logger; } diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp index 3810b9ad9e..146df5c82c 100644 --- a/C/services/storage/storage_api.cpp +++ b/C/services/storage/storage_api.cpp @@ -385,7 +385,7 @@ void storageTableQueryWrapper(shared_ptr response, /** * Construct the singleton Storage API */ -StorageApi::StorageApi(const unsigned short port, const unsigned int threads) : readingPlugin(0), streamHandler(0) +StorageApi::StorageApi(const unsigned short port, const unsigned int threads) : m_thread(NULL), readingPlugin(0), streamHandler(0) { m_port = port; @@ -407,6 +407,11 @@ StorageApi::~StorageApi() { delete m_server; } + m_instance = NULL; + if (m_thread) + { + delete m_thread; + } } /** diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp index 9d4c0bc174..f97798c2e4 100644 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -48,13 +48,14 @@ static void worker(StorageRegistry *registry) * code, or * for all assets, that URL will then be called when new * data arrives for the particular asset. * - * The servce registry maintians a worker thread that is responsible + * The service registry maintians a worker thread that is responsible * for sending these notifications such that the main flow of data into * the storage layer is minimally impacted by the registration and * delivery of these messages to interested microservices. */ StorageRegistry::StorageRegistry() : m_thread(NULL) { + m_running = true; m_thread = new thread(worker, this); } @@ -64,12 +65,22 @@ StorageRegistry::StorageRegistry() : m_thread(NULL) StorageRegistry::~StorageRegistry() { m_running = false; + m_cv.notify_all(); if (m_thread) { - m_thread->join(); + if (m_thread->joinable()) + m_thread->join(); delete m_thread; m_thread = NULL; } + while (!m_queue.empty()) + m_queue.pop(); + while (!m_tableInsertQueue.empty()) + m_tableInsertQueue.pop(); + while (!m_tableUpdateQueue.empty()) + m_tableUpdateQueue.pop(); + while (!m_tableDeleteQueue.empty()) + m_tableDeleteQueue.pop(); } /** @@ -380,7 +391,6 @@ StorageRegistry::unregisterTable(const string& table, const string& payload) void StorageRegistry::run() { - m_running = true; while (m_running) { char *data = NULL; From 43ebc5805ba077687888787b37366665d9b95710 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 25 Sep 2023 15:43:00 +0530 Subject: [PATCH 458/499] Ability to update multiple param support in PUT entrypoint API Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 70 +++++++------------ 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 950d8c6f1e..8f4c048a37 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -282,8 +282,8 @@ async def get_by_name(request: web.Request) -> web.Response: :Example: curl -sX GET http://localhost:8081/fledge/control/manage/SetLatheSpeed """ + ep_name = request.match_info.get('name', None) try: - ep_name = request.match_info.get('name', None) response = await _get_entrypoint(ep_name) except ValueError as err: msg = str(err) @@ -338,6 +338,7 @@ async def update(request: web.Request) -> web.Response: curl -sX PUT "http://localhost:8081/fledge/control/manage/SetLatheSpeed" -d '{"constants": {"x": "486"}, "variables": {"rpm": "1200"}, "description": "Perform lathesim", "anonymous": false, "destination": "script", "script": "S4", "allow": ["user"]}' curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"description": "Updated", "anonymous": false, "allow": []}' curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"allow": ["user"]}' + curl -sX PUT http://localhost:8081/fledge/control/manage/SetLatheSpeed -d '{"variables":{"rpm":"800", "distance": "138"}, "constants": {"x": "640", "y": "480"}}' """ name = request.match_info.get('name', None) try: @@ -371,49 +372,11 @@ async def update(request: web.Request) -> web.Response: if columns: for k, v in columns.items(): if k == "constants": - constant_payload = PayloadBuilder().WHERE(["name", '=', name]).AND_WHERE( - ["constant", '=', 't']).chain_payload() - if v: - # constants update in any type - for k1, v1 in v.items(): - get_payload = PayloadBuilder(constant_payload).payload() - param_result = await storage.query_tbl_with_payload("control_api_parameters", get_payload) - if param_result['rows']: - update_payload = PayloadBuilder(constant_payload).SET(parameter=k1, value=v1).payload() - await storage.update_tbl("control_api_parameters", update_payload) - else: - control_api_params_column_name = {"name": name, "parameter": k1, "value": v1, - "constant": 't'} - api_params_insert_payload = PayloadBuilder().INSERT( - **control_api_params_column_name).payload() - await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) - else: - # empty only allowed for operation type - if entry_point_result['rows'][0]['type'] == 1: - del_payload = PayloadBuilder(constant_payload).payload() - await storage.delete_from_tbl("control_api_parameters", del_payload) + await _update_params( + name, old_entrypoint['constants'], columns['constants'], 't', storage) elif k == "variables": - variable_payload = PayloadBuilder().WHERE(["name", '=', name]).AND_WHERE( - ["constant", '=', 'f']).chain_payload() - if v: - # variables update in any type - for k2, v2 in v.items(): - get_payload = PayloadBuilder(variable_payload).payload() - param_result = await storage.query_tbl_with_payload("control_api_parameters", get_payload) - if param_result['rows']: - update_payload = PayloadBuilder(variable_payload).SET(parameter=k2, value=v2).payload() - await storage.update_tbl("control_api_parameters", update_payload) - else: - control_api_params_column_name = {"name": name, "parameter": k2, "value": v2, - "constant": 'f'} - api_params_insert_payload = PayloadBuilder().INSERT( - **control_api_params_column_name).payload() - await storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) - else: - # empty only allowed for operation type - if entry_point_result['rows'][0]['type'] == 1: - del_payload = PayloadBuilder(variable_payload).payload() - await storage.delete_from_tbl("control_api_parameters", del_payload) + await _update_params( + name, old_entrypoint['variables'], columns['variables'], 'f', storage) elif k == "allow": allowed_users = [u for u in v] db_allow_users = old_entrypoint["allow"] @@ -583,3 +546,24 @@ async def _call_dispatcher_service_api(protocol: str, address: str, port: int, u else: # Return Tuple - (http statuscode, message) return response + + +async def _update_params(ep_name: str, old_param: dict, new_param: dict, is_constant: str, _storage: connect): + insert_case = set(new_param) - set(old_param) + update_case = set(new_param) & set(old_param) + delete_case = set(old_param) - set(new_param) + + for uc in update_case: + update_payload = PayloadBuilder().WHERE(["name", '=', ep_name]).AND_WHERE( + ["constant", '=', is_constant]).AND_WHERE(["parameter", '=', uc]).SET(value=new_param[uc]).payload() + await _storage.update_tbl("control_api_parameters", update_payload) + + for dc in delete_case: + delete_payload = PayloadBuilder().WHERE(["name", '=', ep_name]).AND_WHERE( + ["constant", '=', is_constant]).AND_WHERE(["parameter", '=', dc]).payload() + await _storage.delete_from_tbl("control_api_parameters", delete_payload) + + for ic in insert_case: + column_name = {"name": ep_name, "parameter": ic, "value": new_param[ic], "constant": is_constant} + api_params_insert_payload = PayloadBuilder().INSERT(**column_name).payload() + await _storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) From 71f6bb0d29a86c81d1d3894b3a455d43fb522e1e Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Mon, 25 Sep 2023 12:15:24 +0100 Subject: [PATCH 459/499] FOGL-8122 Remove stray character in north_c script that prevented (#1171) valgrind usage Signed-off-by: Mark Riddoch --- scripts/services/north_C | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/services/north_C b/scripts/services/north_C index 379ca765f5..832113098f 100755 --- a/scripts/services/north_C +++ b/scripts/services/north_C @@ -15,7 +15,7 @@ if [ "$VALGRIND_NORTH" != "" ]; then for i in "$@"; do case $i in --name=*) - name=i"`echo $i | sed -e s/--name=//`" + name="`echo $i | sed -e s/--name=//`" ;; esac done From cf7c22bd2c0b6d944c03e697be9c71cba8374355 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 26 Sep 2023 13:19:52 +0530 Subject: [PATCH 460/499] permitted KV pair handling added in GET ALL entrypoint API Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 8f4c048a37..eac050c793 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -271,9 +271,23 @@ async def get_all(request: web.Request) -> web.Response: This is on the basis of anonymous flag if true then permitted true If anonymous flag is false then list of allowed users to determine if the specific user can make the call """ - # TODO: FOGL-8037 verify the user when anonymous is false and set permitted value based on it - entrypoint.append({"name": r['name'], "description": r['description'], - "permitted": True if r['anonymous'] == 't' else False}) + if request.is_auth_optional is True: + permitted = True + else: + if r['anonymous'] == 't': + permitted = True + else: + if request.user["role_id"] not in (1, 5): + payload = PayloadBuilder().WHERE(["name", '=', r['name']]).payload() + acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) + if acl_result['rows']: + users = [r['user'] for r in acl_result['rows']] + permitted = False if request.user["uname"] not in users else True + else: + permitted = False + else: + permitted = True + entrypoint.append({"name": r['name'], "description": r['description'], "permitted": permitted}) return web.json_response({"controls": entrypoint}) From 69bdfd05bea8d3e7e3dfa4f852cbb183a45aaf54 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 26 Sep 2023 13:23:45 +0530 Subject: [PATCH 461/499] anonymous boolean value in response of GET entrypoint API Signed-off-by: ashish-jabble --- python/fledge/services/core/api/control_service/entrypoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index eac050c793..535efefa7d 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -493,7 +493,6 @@ async def update_request(request: web.Request) -> web.Response: async def _get_entrypoint(name): - # TODO: FOGL-8037 forbidden when permitted is false on the basis of anonymous storage = connect.get_storage_async() payload = PayloadBuilder().WHERE(["name", '=', name]).payload() result = await storage.query_tbl_with_payload("control_api", payload) @@ -505,6 +504,7 @@ async def _get_entrypoint(name): if response['destination'] != "broadcast": response[response['destination']] = response['destination_arg'] del response['destination_arg'] + response['anonymous'] = True if response['anonymous'] == 't' else False param_result = await storage.query_tbl_with_payload("control_api_parameters", payload) constants = {} variables = {} From 477567d57dedc7af1d498f524e6d1a1798bd7629 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 26 Sep 2023 14:44:51 +0530 Subject: [PATCH 462/499] missing name fixes in audit trail entry for script and ACL update operations Signed-off-by: ashish-jabble --- .../services/core/api/control_service/acl_management.py | 6 +++--- .../services/core/api/control_service/script_management.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/python/fledge/services/core/api/control_service/acl_management.py b/python/fledge/services/core/api/control_service/acl_management.py index 34f1b8d960..8f3361098b 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -174,7 +174,7 @@ async def update_acl(request: web.Request) -> web.Response: if url is not None and not isinstance(url, list): raise TypeError('url must be a list.') storage = connect.get_storage_async() - payload = PayloadBuilder().SELECT("service", "url").WHERE(['name', '=', name]).payload() + payload = PayloadBuilder().SELECT("name", "service", "url").WHERE(['name', '=', name]).payload() result = await storage.query_tbl_with_payload('control_acl', payload) message = "" if 'rows' in result: @@ -193,8 +193,8 @@ async def update_acl(request: web.Request) -> web.Response: message = "ACL {} updated successfully.".format(name) # ACLCH audit trail entry audit = AuditLogger(storage) - values = {'service': service, 'url': url} - await audit.information('ACLCH', {'acl': values, 'old_acl': result['rows']}) + values = {'name': name, 'service': service, 'url': url} + await audit.information('ACLCH', {'acl': values, 'old_acl': result['rows'][0]}) else: raise StorageServerError(update_result) else: diff --git a/python/fledge/services/core/api/control_service/script_management.py b/python/fledge/services/core/api/control_service/script_management.py index 8b92e6e7a1..dded07370e 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -363,13 +363,13 @@ async def update(request: web.Request) -> web.Response: raise ValueError('ACL must be a string.') acl = acl.strip() set_values = {} - values = {} + values = {'name': name} if steps is not None: values['steps'] = steps set_values["steps"] = _validate_steps_and_convert_to_str(steps) storage = connect.get_storage_async() # Check existence of script record - payload = PayloadBuilder().SELECT("steps", "acl").WHERE(['name', '=', name]).payload() + payload = PayloadBuilder().SELECT("name", "steps", "acl").WHERE(['name', '=', name]).payload() result = await storage.query_tbl_with_payload('control_script', payload) message = "" if 'rows' in result: @@ -414,7 +414,7 @@ async def update(request: web.Request) -> web.Response: message = "Control script {} updated successfully.".format(name) # CTSCH audit trail entry audit = AuditLogger(storage) - await audit.information('CTSCH', {'script': values, 'old_script': result['rows']}) + await audit.information('CTSCH', {'script': values, 'old_script': result['rows'][0]}) else: raise StorageServerError(update_result) else: From a08d6e03c29fcbf29656e77d912e89ae7400609c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 26 Sep 2023 14:59:54 +0530 Subject: [PATCH 463/499] unit tests updated Signed-off-by: ashish-jabble --- .../core/api/control_service/test_acl_management.py | 8 +++++--- .../api/control_service/test_script_management.py | 11 +++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py index 240c1a52af..0953357de7 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_acl_management.py @@ -183,7 +183,8 @@ async def test_update_acl_not_found(self, client): req_payload = {"service": []} result = {"count": 0, "rows": []} value = await mock_coro(result) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(result)) - query_payload = {"return": ["service", "url"], "where": {"column": "name", "condition": "=", "value": acl_name}} + query_payload = {"return": ["name", "service", "url"], "where": { + "column": "name", "condition": "=", "value": acl_name}} message = "ACL with name {} is not found.".format(acl_name) storage_client_mock = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): @@ -211,7 +212,7 @@ async def test_update_acl(self, client, payload): acl_q_result = {"count": 0, "rows": []} update_result = {"response": "updated", "rows_affected": 1} query_tbl_result = {"count": 1, "rows": [{"name": acl_name, "service": [], "url": []}]} - query_payload = {"return": ["service", "url"], "where": {"column": "name", "condition": "=", "value": acl_name}} + query_payload = {"return": ["name", "service", "url"], "where": {"column": "name", "condition": "=", "value": acl_name}} if sys.version_info >= (3, 8): arv = await mock_coro(None) update_value = await mock_coro(update_result) @@ -251,7 +252,8 @@ def q_result(*args): assert 'ACLCH' == args[0] if 'url' not in payload: payload['url'] = None - assert {"acl": payload, "old_acl": query_tbl_result['rows']} == args[1] + payload['name'] = acl_name + assert {"acl": payload, "old_acl": query_tbl_result['rows'][0]} == args[1] update_args, _ = patch_update_tbl.call_args assert 'control_acl' == update_args[0] diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py index 5562214de6..596762bc91 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py @@ -1,4 +1,5 @@ import asyncio +import copy import json import sys import uuid @@ -376,7 +377,7 @@ async def test_update_script_not_found(self, client): req_payload = {"steps": []} result = {"count": 0, "rows": []} value = await mock_coro(result) if sys.version_info >= (3, 8) else asyncio.ensure_future(mock_coro(result)) - query_payload = {"return": ["steps", "acl"], + query_payload = {"return": ["name", "steps", "acl"], "where": {"column": "name", "condition": "=", "value": script_name}} message = "No such {} script found.".format(script_name) storage_client_mock = MagicMock(StorageClientAsync) @@ -397,7 +398,7 @@ async def test_update_script_when_acl_not_found(self, client): acl_name = "blah" payload = {"steps": [{"write": {"order": 1, "speed": 420}}], "acl": acl_name} script_result = {"count": 1, "rows": [{"name": script_name, "steps": [{"write": {"order": 1, "speed": 420}}]}]} - script_query_payload = {"return": ["steps", "acl"], + script_query_payload = {"return": ["name", "steps", "acl"], "where": {"column": "name", "condition": "=", "value": script_name}} acl_query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": acl_name}} acl_result = {"count": 0, "rows": []} @@ -431,12 +432,14 @@ def q_result(*args): async def test_update_script(self, client, payload): script_name = "test" acl_name = "testACL" + new_script = copy.deepcopy(payload) + new_script['name'] = script_name script_result = {"count": 1, "rows": [{"steps": [{"write": {"order": 1, "speed": 420}}]}]} update_result = {"response": "updated", "rows_affected": 1} steps_payload = payload["steps"] update_value = await mock_coro(update_result) if sys.version_info >= (3, 8) else \ asyncio.ensure_future(mock_coro(update_result)) - script_query_payload = {"return": ["steps", "acl"], + script_query_payload = {"return": ["name", "steps", "acl"], "where": {"column": "name", "condition": "=", "value": script_name}} acl_query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": acl_name}} acl_result = {"count": 1, "rows": [{"name": acl_name, "service": [], "url": []}]} @@ -482,7 +485,7 @@ def i_result(*args): == json_response args, _ = audit_info_patch.call_args audit_info_patch.assert_called_once_with( - 'CTSCH', {"script": payload, "old_script": script_result['rows']}) + 'CTSCH', {"script": new_script, "old_script": script_result['rows'][0]}) update_args, _ = patch_update_tbl.call_args assert 'control_script' == update_args[0] update_payload = {"values": payload, From 1ad7f6a80888222a5ee3298de0e93d2ed841c328 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 27 Sep 2023 16:33:19 +0530 Subject: [PATCH 464/499] permitted KV pair added in get by name control Flow API endpoint; only for GUI purpose to show/hide Execute button on Detail page Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 535efefa7d..8d2102b3e5 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -266,28 +266,9 @@ async def get_all(request: web.Request) -> web.Response: storage = connect.get_storage_async() result = await storage.query_tbl("control_api") entrypoint = [] - for r in result["rows"]: - """permitted: means user is able to make the API call - This is on the basis of anonymous flag if true then permitted true - If anonymous flag is false then list of allowed users to determine if the specific user can make the call - """ - if request.is_auth_optional is True: - permitted = True - else: - if r['anonymous'] == 't': - permitted = True - else: - if request.user["role_id"] not in (1, 5): - payload = PayloadBuilder().WHERE(["name", '=', r['name']]).payload() - acl_result = await storage.query_tbl_with_payload("control_api_acl", payload) - if acl_result['rows']: - users = [r['user'] for r in acl_result['rows']] - permitted = False if request.user["uname"] not in users else True - else: - permitted = False - else: - permitted = True - entrypoint.append({"name": r['name'], "description": r['description'], "permitted": permitted}) + for row in result["rows"]: + permitted = await _get_permitted(request, storage, row) + entrypoint.append({"name": row['name'], "description": row['description'], "permitted": permitted}) return web.json_response({"controls": entrypoint}) @@ -299,11 +280,12 @@ async def get_by_name(request: web.Request) -> web.Response: ep_name = request.match_info.get('name', None) try: response = await _get_entrypoint(ep_name) + response['permitted'] = await _get_permitted(request, None, response) except ValueError as err: msg = str(err) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except KeyError as err: - msg = str(err) + msg = str(err.args[0]) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) @@ -332,7 +314,7 @@ async def delete(request: web.Request) -> web.Response: msg = str(err) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except KeyError as err: - msg = str(err) + msg = str(err.args[0]) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) @@ -581,3 +563,29 @@ async def _update_params(ep_name: str, old_param: dict, new_param: dict, is_cons column_name = {"name": ep_name, "parameter": ic, "value": new_param[ic], "constant": is_constant} api_params_insert_payload = PayloadBuilder().INSERT(**column_name).payload() await _storage.insert_into_tbl("control_api_parameters", api_params_insert_payload) + + +async def _get_permitted(request: web.Request, _storage: connect, ep: dict): + """permitted: means user is able to make the API call + This is on the basis of anonymous flag if true then permitted true + If anonymous flag is false then list of allowed users to determine if the specific user can make the call + Note: In case of authentication optional permitted always true + """ + if _storage is None: + _storage = connect.get_storage_async() + + if request.is_auth_optional is True: + return True + if ep['anonymous'] == 't' or ep['anonymous'] is True: + return True + + permitted = False + if request.user["role_id"] not in (1, 5): # Admin, Control + payload = PayloadBuilder().WHERE(["name", '=', ep['name']]).payload() + acl_result = await _storage.query_tbl_with_payload("control_api_acl", payload) + if acl_result['rows']: + users = [r['user'] for r in acl_result['rows']] + permitted = False if request.user["uname"] not in users else True + else: + permitted = True + return permitted From 84f1e796bdb624f4ed1a98e877ffc85a92fc0b92 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 28 Sep 2023 15:58:56 +0530 Subject: [PATCH 465/499] get all entrypoint unit tests Signed-off-by: ashish-jabble --- .../api/control_service/test_entrypoint.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py new file mode 100644 index 0000000000..8c09c6647f --- /dev/null +++ b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py @@ -0,0 +1,58 @@ +import asyncio +import json +import sys + +from unittest.mock import MagicMock, patch +import pytest +from aiohttp import web + +from fledge.common.audit_logger import AuditLogger +from fledge.common.storage_client.storage_client import StorageClientAsync +from fledge.common.web import middleware +from fledge.services.core import connect, routes +from fledge.services.core.api.control_service import entrypoint + + +__author__ = "Ashish Jabble" +__copyright__ = "Copyright (c) 2023 Dianomic Systems Inc." +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + + +async def mock_coro(*args): + return None if len(args) == 0 else args[0] + + +@pytest.allure.feature("unit") +@pytest.allure.story("api", "entrypoint") +class TestEntrypoint: + """ Control Flow Entrypoint API tests""" + + @pytest.fixture + def client(self, loop, test_client): + app = web.Application(loop=loop, middlewares=[middleware.optional_auth_middleware]) + routes.setup(app) + return loop.run_until_complete(test_client(app)) + + async def test_get_all_entrypoints(self, client): + storage_client_mock = MagicMock(StorageClientAsync) + storage_result = {'count': 3, 'rows': [ + {'name': 'EP1', 'description': 'EP1', 'type': 1, 'operation_name': 'OP1', 'destination': 0, + 'destination_arg': '', 'anonymous': 't'}, + {'name': 'EP2', 'description': 'Ep2', 'type': 0, 'operation_name': '', 'destination': 0, + 'destination_arg': '', 'anonymous': 'f'}, + {'name': 'EP3', 'description': 'EP3', 'type': 1, 'operation_name': 'OP2', 'destination': 0, + 'destination_arg': '', 'anonymous': 'f'}]} + expected_api_response = {"controls": [{"name": "EP1", "description": "EP1", "permitted": True}, + {"name": "EP2", "description": "Ep2", "permitted": True}, + {"name": "EP3", "description": "EP3", "permitted": True}]} + rv = await mock_coro(storage_result) if sys.version_info >= (3, 8) else asyncio.ensure_future( + mock_coro(storage_result)) + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl', return_value=rv) as patch_query_tbl: + resp = await client.get('/fledge/control/manage') + assert 200 == resp.status + json_response = json.loads(await resp.text()) + assert 'controls' in json_response + assert expected_api_response == json_response + patch_query_tbl.assert_called_once_with('control_api') From 68f13dc1b1576d7d924b66d13aa8f934459df56e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 28 Sep 2023 16:00:12 +0530 Subject: [PATCH 466/499] get by name entrypoint control Flow API unit tests Signed-off-by: ashish-jabble --- .../api/control_service/test_entrypoint.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py index 8c09c6647f..89e9ad8676 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py @@ -56,3 +56,42 @@ async def test_get_all_entrypoints(self, client): assert 'controls' in json_response assert expected_api_response == json_response patch_query_tbl.assert_called_once_with('control_api') + + @pytest.mark.parametrize("exception, message, status_code", [ + (ValueError, 'name should be in string.', 400), + (KeyError, 'EP control entrypoint not found.', 404), + (KeyError, '', 404), + (Exception, 'Interval Server error.', 500) + ]) + async def test_bad_get_entrypoint_by_name(self, client, exception, message, status_code): + ep_name = "EP" + with patch.object(entrypoint, '_get_entrypoint', side_effect=exception(message)): + with patch.object(entrypoint._logger, 'error') as patch_logger: + resp = await client.get('/fledge/control/manage/{}'.format(ep_name)) + assert status_code == resp.status + assert message == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": message} == json_response + if exception == Exception: + patch_logger.assert_called() + + async def test_get_entrypoint_by_name(self, client): + ep_name = "EP" + storage_result = {'name': ep_name, 'description': 'EP1', 'type': 'operation', 'operation_name': 'OP1', + 'destination': 'broadcast', 'anonymous': True, 'constants': {'x': '640', 'y': '480'}, + 'variables': {'rpm': '800', 'distance': '138'}, 'allow': ['admin', 'user']} + if sys.version_info >= (3, 8): + rv1 = await mock_coro(storage_result) + rv2 = await mock_coro(True) + else: + rv1 = asyncio.ensure_future(mock_coro(storage_result)) + rv2 = asyncio.ensure_future(mock_coro(True)) + with patch.object(entrypoint, '_get_entrypoint', return_value=rv1): + with patch.object(entrypoint, '_get_permitted', return_value=rv2): + resp = await client.get('/fledge/control/manage/{}'.format(ep_name)) + assert 200 == resp.status + json_response = json.loads(await resp.text()) + assert 'permitted' in json_response + assert storage_result == json_response + From 45257552f8bb2bd7b17900d83bf030df499d7b4e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 28 Sep 2023 16:00:52 +0530 Subject: [PATCH 467/499] create entrypoint control Flow API unit tests Signed-off-by: ashish-jabble --- .../api/control_service/test_entrypoint.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py index 89e9ad8676..3a303484e6 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py @@ -95,3 +95,71 @@ async def test_get_entrypoint_by_name(self, client): assert 'permitted' in json_response assert storage_result == json_response + async def test_create_entrypoint_in_use(self, client): + ep_name = "SetLatheSpeed" + payload = {"name": ep_name, "description": "Set the speed of the lathe", "type": "write", + "destination": "asset", "asset": "lathe", "constants": {"units": "spin"}, + "variables": {"rpm": "100"}, "allow": [], "anonymous": False} + storage_client_mock = MagicMock(StorageClientAsync) + storage_result = {"count": 1, "rows": [{"name": ep_name}]} + rv = await mock_coro(storage_result) if sys.version_info >= (3, 8) else asyncio.ensure_future( + mock_coro(storage_result)) + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl', return_value=rv) as patch_query_tbl: + resp = await client.post('/fledge/control/manage', data=json.dumps(payload)) + assert 400 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {'message': '{} control entrypoint is already in use.'.format(ep_name)} == json_response + patch_query_tbl.assert_called_once_with('control_api') + + async def test_create_entrypoint(self, client): + ep_name = "SetLatheSpeed" + payload = {"name": ep_name, "description": "Set the speed of the lathe", "type": "write", + "destination": "asset", "asset": "lathe", "constants": {"units": "spin"}, + "variables": {"rpm": "100"}, "allow": [], "anonymous": False} + storage_client_mock = MagicMock(StorageClientAsync) + storage_result = {"count": 0, "rows": []} + insert_result = {"response": "inserted", "rows_affected": 1} + + @asyncio.coroutine + def i_result(*args): + table = args[0] + insert_payload = args[1] + if table == 'control_api': + p = {'name': payload['name'], 'description': payload['description'], 'type': 0, 'operation_name': '', + 'destination': 2, 'destination_arg': payload['asset'], + 'anonymous': 'f' if payload['anonymous'] is False else 't'} + assert p == json.loads(insert_payload) + elif table == 'control_api_parameters': + if json.loads(insert_payload)['constant'] == 't': + assert {'name': ep_name, 'parameter': 'units', 'value': 'spin', 'constant': 't' + } == json.loads(insert_payload) + else: + assert {'name': ep_name, 'parameter': 'rpm', 'value': '100', 'constant': 'f' + } == json.loads(insert_payload) + elif table == 'control_api_acl': + # allow is empty in given payload + # TODO: in future + pass + return insert_result + + if sys.version_info >= (3, 8): + rv = await mock_coro(storage_result) + arv = await mock_coro(None) + else: + rv = asyncio.ensure_future(mock_coro(storage_result)) + arv = asyncio.ensure_future(mock_coro(None)) + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl', return_value=rv) as patch_query_tbl: + with patch.object(storage_client_mock, 'insert_into_tbl', side_effect=i_result): + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=arv) as audit_info_patch: + resp = await client.post('/fledge/control/manage', data=json.dumps(payload)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {'message': '{} control entrypoint has been created successfully.'.format(ep_name) + } == json_response + audit_info_patch.assert_called_once_with('CTEAD', payload) + patch_query_tbl.assert_called_once_with('control_api') From 309e00262d77f856b3bfb8cf3fed4047f9a94b3f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 28 Sep 2023 16:02:09 +0530 Subject: [PATCH 468/499] update entrypoint control Flow API unit tests Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 4 +- .../api/control_service/test_entrypoint.py | 71 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 8d2102b3e5..09799473db 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -398,7 +398,7 @@ async def update(request: web.Request) -> web.Response: msg = str(err) raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except KeyError as err: - msg = str(err) + msg = str(err.args[0]) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) @@ -461,7 +461,7 @@ async def update_request(request: web.Request) -> web.Response: svc, bearer_token = await _get_service_record_info_along_with_bearer_token() await _call_dispatcher_service_api(svc._protocol, svc._address, svc._port, url, bearer_token, dispatch_payload) except KeyError as err: - msg = str(err) + msg = str(err.args[0]) raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except ValueError as err: msg = str(err) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py index 3a303484e6..6ae1261f06 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py @@ -163,3 +163,74 @@ def i_result(*args): } == json_response audit_info_patch.assert_called_once_with('CTEAD', payload) patch_query_tbl.assert_called_once_with('control_api') + + async def test_update_entrypoint_not_found(self, client): + ep_name = "EP" + message = '{} control entrypoint not found.'.format(ep_name) + payload = {"where": {"column": "name", "condition": "=", "value": ep_name}} + storage_client_mock = MagicMock(StorageClientAsync) + storage_result = {"count": 0, "rows": []} + rv = await mock_coro(storage_result) if sys.version_info >= (3, 8) else asyncio.ensure_future( + mock_coro(storage_result)) + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=rv + ) as patch_query_tbl: + resp = await client.put('/fledge/control/manage/{}'.format(ep_name)) + assert 404 == resp.status + assert message == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": message} == json_response + args, kwargs = patch_query_tbl.call_args + assert 'control_api' == args[0] + assert payload == json.loads(args[1]) + + async def test_update_entrypoint(self, client): + storage_client_mock = MagicMock(StorageClientAsync) + ep_name = "SetLatheSpeed" + payload = {"description": "Updated"} + query_payload = '{"where": {"column": "name", "condition": "=", "value": "SetLatheSpeed"}}' + storage_result = {"count": 1, "rows": [{"name": ep_name}]} + ep_info = {'name': ep_name, 'description': 'Perform speed of lathe', 'type': 'operation', + 'operation_name': 'Speed', 'destination': 'broadcast', 'anonymous': False, + 'constants': {'x': '640', 'y': '480'}, 'variables': {'rpm': '800', 'distance': '138'}, 'allow': []} + new_ep_info = {'name': ep_name, 'description': payload['description'], 'type': 'operation', + 'operation_name': 'Speed', 'destination': 'broadcast', 'anonymous': False, + 'constants': {'x': '640', 'y': '480'}, 'variables': {'rpm': '800', 'distance': '138'}, + 'allow': []} + + update_payload = ('{"values": {"description": "Updated"}, ' + '"where": {"column": "name", "condition": "=", "value": "SetLatheSpeed"}}') + update_result = {"response": "updated", "rows_affected": 1} + if sys.version_info >= (3, 8): + rv1 = await mock_coro(storage_result) + rv2 = await mock_coro(ep_info) + rv3 = await mock_coro(new_ep_info) + rv4 = await mock_coro(update_result) + arv = await mock_coro(None) + else: + rv1 = asyncio.ensure_future(mock_coro(storage_result)) + arv = asyncio.ensure_future(mock_coro(None)) + rv2 = asyncio.ensure_future(mock_coro(ep_info)) + rv3 = asyncio.ensure_future(mock_coro(new_ep_info)) + rv4 = asyncio.ensure_future(mock_coro(update_result)) + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=rv1 + ) as patch_query_tbl: + with patch.object(entrypoint, '_get_entrypoint', side_effect=[rv2, rv3]) as patch_entrypoint: + with patch.object(storage_client_mock, 'update_tbl', return_value=rv4) as patch_update_tbl: + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=arv + ) as audit_info_patch: + resp = await client.put('/fledge/control/manage/{}'.format(ep_name), + data=json.dumps(payload)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {'message': '{} control entrypoint has been updated successfully.'.format( + ep_name)} == json_response + audit_info_patch.assert_called_once_with( + 'CTECH', {"entrypoint": new_ep_info, "old_entrypoint": ep_info}) + patch_update_tbl.assert_called_once_with('control_api', update_payload) + assert 2 == patch_entrypoint.call_count + patch_query_tbl.assert_called_once_with('control_api', query_payload) From 0c45910d5287ad94eb5e62177e6fc47e2c98965f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 28 Sep 2023 16:02:55 +0530 Subject: [PATCH 469/499] delete entrypoint control Flow API unit tests Signed-off-by: ashish-jabble --- .../api/control_service/test_entrypoint.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py index 6ae1261f06..11059997db 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py @@ -234,3 +234,70 @@ async def test_update_entrypoint(self, client): patch_update_tbl.assert_called_once_with('control_api', update_payload) assert 2 == patch_entrypoint.call_count patch_query_tbl.assert_called_once_with('control_api', query_payload) + + async def test_delete_entrypoint_not_found(self, client): + ep_name = "EP" + message = '{} control entrypoint not found.'.format(ep_name) + payload = {"where": {"column": "name", "condition": "=", "value": ep_name}} + storage_client_mock = MagicMock(StorageClientAsync) + storage_result = {"count": 0, "rows": []} + rv = await mock_coro(storage_result) if sys.version_info >= (3, 8) else asyncio.ensure_future( + mock_coro(storage_result)) + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=rv + ) as patch_query_tbl: + resp = await client.delete('/fledge/control/manage/{}'.format(ep_name)) + assert 404 == resp.status + assert message == resp.reason + result = await resp.text() + json_response = json.loads(result) + assert {"message": message} == json_response + args, kwargs = patch_query_tbl.call_args + assert 'control_api' == args[0] + assert payload == json.loads(args[1]) + + async def test_delete_entrypoint(self, client): + ep_name = "EP" + payload = {"where": {"column": "name", "condition": "=", "value": ep_name}} + storage_result = {"count": 0, "rows": [ + {'name': ep_name, 'description': 'EP1', 'type': 'operation', 'operation_name': 'OP1', + 'destination': 'broadcast', 'anonymous': True, 'constants': {'x': '640', 'y': '480'}, + 'variables': {'rpm': '800', 'distance': '138'}, 'allow': ['admin', 'user']}]} + message = "{} control entrypoint has been deleted successfully.".format(ep_name) + if sys.version_info >= (3, 8): + rv1 = await mock_coro(storage_result) + rv2 = await mock_coro(None) + arv = await mock_coro(None) + else: + rv1 = asyncio.ensure_future(mock_coro(storage_result)) + rv2 = asyncio.ensure_future(mock_coro(None)) + arv = asyncio.ensure_future(mock_coro(None)) + storage_client_mock = MagicMock(StorageClientAsync) + del_payload = {"where": {"column": "name", "condition": "=", "value": ep_name}} + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl_with_payload', return_value=rv1 + ) as patch_query_tbl: + with patch.object(storage_client_mock, 'delete_from_tbl', return_value=rv2 + ) as patch_delete_tbl: + with patch.object(AuditLogger, '__init__', return_value=None): + with patch.object(AuditLogger, 'information', return_value=arv) as audit_info_patch: + resp = await client.delete('/fledge/control/manage/{}'.format(ep_name)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {"message": message} == json_response + audit_info_patch.assert_called_once_with('CTEDL', {'message': message, "name": ep_name}) + assert 3 == patch_delete_tbl.call_count + del_args = patch_delete_tbl.call_args_list + args1, _ = del_args[0] + assert 'control_api_acl' == args1[0] + assert del_payload == json.loads(args1[1]) + args2, _ = del_args[1] + assert 'control_api_parameters' == args2[0] + assert del_payload == json.loads(args2[1]) + args3, _ = del_args[2] + assert 'control_api' == args3[0] + assert del_payload == json.loads(args3[1]) + args, kwargs = patch_query_tbl.call_args + assert 'control_api' == args[0] + assert payload == json.loads(args[1]) From fe2a37b2cbc608ffd0888ee2da8ec615ef96c62b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 28 Sep 2023 16:05:55 +0530 Subject: [PATCH 470/499] update control request entrypoint control Flow API unit tests Signed-off-by: ashish-jabble --- .../api/control_service/test_entrypoint.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py index 11059997db..693e320523 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py @@ -301,3 +301,72 @@ async def test_delete_entrypoint(self, client): args, kwargs = patch_query_tbl.call_args assert 'control_api' == args[0] assert payload == json.loads(args[1]) + + @pytest.mark.parametrize("ep_type", ["operation", "write"]) + async def test_update_request_entrypoint(self, client, ep_type): + from fledge.services.core.service_registry.service_registry import ServiceRegistry + from fledge.common.service_record import ServiceRecord + + ServiceRegistry._registry = [] + + with patch.object(ServiceRegistry._logger, 'info'): + ServiceRegistry.register('Fledge Storage', 'Storage', '127.0.0.1', 1, 1, 'http') + ServiceRegistry.register('Dispatcher Service', 'Dispatcher', '127.0.0.1', 8, 8, 'http') + + ep_name = "SetLatheSpeed" + if ep_type == "operation": + storage_result = {'name': ep_name, 'description': 'Perform speed of lathe', 'type': 'operation', + 'operation_name': 'Speed', 'destination': 'broadcast', 'anonymous': False, + 'constants': {'x': '640', 'y': '480'}, 'variables': {'rpm': '800', 'distance': '138'}, + 'allow': []} + dispatch_payload = {'destination': 'broadcast', 'source': 'API', 'source_name': 'Anonymous', + 'operation': {'Speed': {'x': '420', 'y': '480', 'rpm': '800', 'distance': '200'}}} + payload = {"x": "420", "distance": "200"} + dispatch_endpoint = 'dispatch/operation' + else: + storage_result = {'name': ep_name, 'description': 'Perform speed of lathe', 'type': 'write', + 'destination': 'broadcast', 'anonymous': False, 'constants': {'x': '640', 'y': '480'}, + 'variables': {'rpm': '800', 'distance': '138'}, 'allow': ['admin', 'user']} + payload = {"rpm": "1200"} + dispatch_endpoint = 'dispatch/write' + dispatch_payload = {'destination': 'broadcast', 'source': 'API', 'source_name': 'Anonymous', + 'write': {'x': '640', 'y': '480', 'rpm': '1200', 'distance': '138'}} + + svc_info = (ServiceRecord("d607c5be-792f-4993-96b7-b513674e7d3b", + ep_name, "Dispatcher", "http", "127.0.0.1", "8118", "8118"), "Token") + + if sys.version_info >= (3, 8): + rv1 = await mock_coro(storage_result) + rv2 = await mock_coro(svc_info) + rv3 = await mock_coro(None) + else: + rv1 = asyncio.ensure_future(mock_coro(storage_result)) + rv2 = asyncio.ensure_future(mock_coro(svc_info)) + rv3 = asyncio.ensure_future(mock_coro(None)) + + with patch.object(entrypoint, '_get_entrypoint', return_value=rv1): + with patch.object(entrypoint, '_get_service_record_info_along_with_bearer_token', + return_value=rv2) as patch_service: + with patch.object(entrypoint, '_call_dispatcher_service_api', + return_value=rv3) as patch_call_service: + resp = await client.put('/fledge/control/request/{}'.format(ep_name), data=json.dumps(payload)) + assert 200 == resp.status + result = await resp.text() + json_response = json.loads(result) + assert {'message': '{} control entrypoint URL called.'.format(ep_name)} == json_response + if ep_type == "operation": + op = dispatch_payload['operation']['Speed'] + assert storage_result['constants']['x'] != op['x'] + assert storage_result['constants']['y'] == op['y'] + assert storage_result['variables']['distance'] != op['distance'] + assert storage_result['variables']['rpm'] == op['rpm'] + else: + write = dispatch_payload['write'] + assert storage_result['constants']['x'] == write['x'] + assert storage_result['constants']['y'] == write['y'] + assert storage_result['variables']['distance'] == write['distance'] + assert storage_result['variables']['rpm'] != write['rpm'] + patch_call_service.assert_called_once_with('http', '127.0.0.1', 8118, dispatch_endpoint, + svc_info[1], dispatch_payload) + patch_service.assert_called_once_with() + From a0d9763ff8b3b5b0e54e32bb407636292773c189 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 29 Sep 2023 11:40:18 +0530 Subject: [PATCH 471/499] internal def unit tests added in Control Flow API Signed-off-by: ashish-jabble --- .../core/api/control_service/entrypoint.py | 8 +- .../api/control_service/test_entrypoint.py | 190 +++++++++++++++++- 2 files changed, 188 insertions(+), 10 deletions(-) diff --git a/python/fledge/services/core/api/control_service/entrypoint.py b/python/fledge/services/core/api/control_service/entrypoint.py index 09799473db..1c5942a11a 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -112,7 +112,7 @@ async def _check_parameters(payload, skip_required=False): raise ValueError('Control entrypoint type cannot be empty.') ept_names = [ept.name.lower() for ept in EntryPointType] if _type not in ept_names: - raise ValueError('Possible types are: {}'.format(ept_names)) + raise ValueError('Possible types are: {}.'.format(ept_names)) if _type == EntryPointType.OPERATION.name.lower(): operation_name = payload.get('operation_name', None) if operation_name is not None: @@ -122,7 +122,7 @@ async def _check_parameters(payload, skip_required=False): if len(operation_name) == 0: raise ValueError('Control entrypoint operation name cannot be empty.') else: - raise KeyError('operation_name KV pair is missing') + raise KeyError('operation_name KV pair is missing.') final['operation_name'] = operation_name final['type'] = await _get_type(_type) @@ -135,7 +135,7 @@ async def _check_parameters(payload, skip_required=False): raise ValueError('Control entrypoint destination cannot be empty.') dest_names = [d.name.lower() for d in Destination] if destination not in dest_names: - raise ValueError('Possible destination values are: {}'.format(dest_names)) + raise ValueError('Possible destination values are: {}.'.format(dest_names)) destination_idx = await _get_destination(destination) final['destination'] = destination_idx @@ -163,7 +163,7 @@ async def _check_parameters(payload, skip_required=False): constants = payload.get('constants', None) if constants is not None: if not isinstance(constants, dict): - raise ValueError('constants should be dictionary.') + raise ValueError('constants should be a dictionary.') if not constants and _type == EntryPointType.WRITE.name.lower(): raise ValueError('constants should not be empty.') final['constants'] = constants diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py index 693e320523..13c842ffdc 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_entrypoint.py @@ -26,7 +26,7 @@ async def mock_coro(*args): @pytest.allure.feature("unit") @pytest.allure.story("api", "entrypoint") class TestEntrypoint: - """ Control Flow Entrypoint API tests""" + """ Control Flow Entrypoint API tests """ @pytest.fixture def client(self, loop, test_client): @@ -87,13 +87,15 @@ async def test_get_entrypoint_by_name(self, client): else: rv1 = asyncio.ensure_future(mock_coro(storage_result)) rv2 = asyncio.ensure_future(mock_coro(True)) - with patch.object(entrypoint, '_get_entrypoint', return_value=rv1): - with patch.object(entrypoint, '_get_permitted', return_value=rv2): + with patch.object(entrypoint, '_get_entrypoint', return_value=rv1) as patch_entrypoint: + with patch.object(entrypoint, '_get_permitted', return_value=rv2) as patch_permitted: resp = await client.get('/fledge/control/manage/{}'.format(ep_name)) assert 200 == resp.status json_response = json.loads(await resp.text()) assert 'permitted' in json_response assert storage_result == json_response + assert 1 == patch_permitted.call_count + patch_entrypoint.assert_called_once_with(ep_name) async def test_create_entrypoint_in_use(self, client): ep_name = "SetLatheSpeed" @@ -139,8 +141,6 @@ def i_result(*args): assert {'name': ep_name, 'parameter': 'rpm', 'value': '100', 'constant': 'f' } == json.loads(insert_payload) elif table == 'control_api_acl': - # allow is empty in given payload - # TODO: in future pass return insert_result @@ -152,7 +152,8 @@ def i_result(*args): arv = asyncio.ensure_future(mock_coro(None)) with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): with patch.object(storage_client_mock, 'query_tbl', return_value=rv) as patch_query_tbl: - with patch.object(storage_client_mock, 'insert_into_tbl', side_effect=i_result): + with patch.object(storage_client_mock, 'insert_into_tbl', side_effect=i_result + ) as patch_insert_tbl: with patch.object(AuditLogger, '__init__', return_value=None): with patch.object(AuditLogger, 'information', return_value=arv) as audit_info_patch: resp = await client.post('/fledge/control/manage', data=json.dumps(payload)) @@ -162,6 +163,7 @@ def i_result(*args): assert {'message': '{} control entrypoint has been created successfully.'.format(ep_name) } == json_response audit_info_patch.assert_called_once_with('CTEAD', payload) + assert 3 == patch_insert_tbl.call_count patch_query_tbl.assert_called_once_with('control_api') async def test_update_entrypoint_not_found(self, client): @@ -370,3 +372,179 @@ async def test_update_request_entrypoint(self, client, ep_type): svc_info[1], dispatch_payload) patch_service.assert_called_once_with() + @pytest.mark.parametrize("identifier, identifier_value", [ + (0, 'write'), + (1, 'operation'), + ('write', 0), + ('operation', 1) + ]) + async def test__get_type(self, identifier, identifier_value): + assert identifier_value == await entrypoint._get_type(identifier) + + @pytest.mark.parametrize("identifier, identifier_value", [ + (0, 'broadcast'), + (1, 'service'), + (2, 'asset'), + (3, 'script'), + ('broadcast', 0), + ('service', 1), + ('asset', 2), + ('script', 3) + ]) + async def test__get_destination(self, identifier, identifier_value): + assert identifier_value == await entrypoint._get_destination(identifier) + + async def test__update_params(self): + ep_name = "SetLatheSpeed" + old = {'x': '640', 'y': '480'} + new = {'x': '180', 'z': '90'} + is_constant = 't' + storage_client_mock = MagicMock(StorageClientAsync) + rows_affected = {"response": "updated", "rows_affected": 1} + rv = await mock_coro(rows_affected) if sys.version_info >= (3, 8) else ( + asyncio.ensure_future(mock_coro(rows_affected))) + tbl_name = 'control_api_parameters' + delete_payload = {"where": {"column": "name", "condition": "=", "value": ep_name, + "and": {"column": "constant", "condition": "=", "value": "t", + "and": {"column": "parameter", "condition": "=", "value": list(old)[1]}}}} + insert_payload = {'name': ep_name, 'parameter': 'z', 'value': new['z'], 'constant': 't'} + update_payload = {"where": {"column": "name", "condition": "=", "value": ep_name, + "and": {"column": "constant", "condition": "=", "value": "t", + "and": {"column": "parameter", "condition": "=", "value": "x"}}}, + "values": {"value": new['x']}} + with patch.object(storage_client_mock, 'update_tbl', return_value=rv) as patch_update_tbl: + with patch.object(storage_client_mock, 'delete_from_tbl', return_value=rv) as patch_delete_tbl: + with patch.object(storage_client_mock, 'insert_into_tbl', return_value=rv) as patch_insert_tbl: + await entrypoint._update_params(ep_name, old, new, is_constant, storage_client_mock) + args, _ = patch_insert_tbl.call_args + assert tbl_name == args[0] + assert insert_payload == json.loads(args[1]) + args, _ = patch_delete_tbl.call_args + assert tbl_name == args[0] + assert delete_payload == json.loads(args[1]) + args, _ = patch_update_tbl.call_args + assert tbl_name == args[0] + assert update_payload == json.loads(args[1]) + + async def test__get_entrypoint(self): + ep_name = "SetLatheSpeed" + storage_client_mock = MagicMock(StorageClientAsync) + payload = {"where": {"column": "name", "condition": "=", "value": ep_name}} + storage_result1 = {"count": 1, "rows": [ + {'name': ep_name, 'description': 'Perform lathe Speed', 'type': 'operation', 'operation_name': 'Speed', + 'destination': 'broadcast', 'destination_arg': '', 'anonymous': True, + 'constants': {}, 'variables': {}, + 'allow': []}]} + storage_result2 = {"count": 0, "rows": []} + if sys.version_info >= (3, 8): + rv1 = await mock_coro(storage_result1) + rv2 = await mock_coro(storage_result2) + rv3 = await mock_coro(storage_result2) + else: + rv1 = asyncio.ensure_future(mock_coro(storage_result1)) + rv2 = asyncio.ensure_future(mock_coro(storage_result2)) + rv3 = asyncio.ensure_future(mock_coro(storage_result2)) + with patch.object(connect, 'get_storage_async', return_value=storage_client_mock): + with patch.object(storage_client_mock, 'query_tbl_with_payload', side_effect=[rv1, rv2, rv3] + ) as patch_query_tbl: + await entrypoint._get_entrypoint(ep_name) + assert 3 == patch_query_tbl.call_count + args1 = patch_query_tbl.call_args_list[0] + assert 'control_api' == args1[0][0] + assert payload == json.loads(args1[0][1]) + args2 = patch_query_tbl.call_args_list[1] + assert 'control_api_parameters' == args2[0][0] + assert payload == json.loads(args2[0][1]) + args3 = patch_query_tbl.call_args_list[2] + assert 'control_api_acl' == args3[0][0] + assert payload == json.loads(args3[0][1]) + + @pytest.mark.parametrize("payload", [ + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'operation', + 'operation_name': 'OP', 'destination': 'script', 'script': 'S1', 'anonymous': False}, + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'write', + 'destination': 'script', 'script': 'S1', 'constants': {'unit': 'cm'}, 'variables': {'aperture': 'f/11'}}, + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'operation', + 'operation_name': 'OP', 'destination': 'asset', 'asset': 'AS'}, + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'write', + 'destination': 'asset', 'asset': 'AS', 'constants': {'unit': 'cm'}, 'variables': {'aperture': 'f/11'}}, + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'operation', + 'operation_name': 'OP', 'destination': 'broadcast'}, + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'write', + 'destination': 'broadcast', 'constants': {'unit': 'cm'}, 'variables': {'aperture': 'f/11'}, 'anonymous': True}, + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'operation', + 'operation_name': 'OP', 'destination': 'service', 'service': 'Camera'}, + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'write', + 'destination': 'service', 'service': 'Camera', 'constants': {'unit': 'cm'}, 'variables': {'aperture': 'f/11'}}, + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'operation', + 'operation_name': 'OP', 'destination': 'script', 'script': 'S1', 'anonymous': False, + 'constants': {'unit': 'cm'}, 'variables': {'aperture': 'f/16'}} + ]) + async def test__check_parameters(self, payload): + cols = await entrypoint._check_parameters(payload) + assert isinstance(cols, dict) + + @pytest.mark.parametrize("payload", [ + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'operation', + 'operation_name': 'OP', 'destination': 'script', 'script': 'S1', 'anonymous': False}, + {'name': 'FocusCamera', 'description': 'Perform focus on camera', 'type': 'write', + 'destination': 'script', 'script': 'S1', 'constants': {'unit': 'cm'}, 'variables': {'aperture': 'f/11'}}, + {'anonymous': True}, + {'description': 'updated'}, + {'type': 'operation', 'operation_name': 'Distance'}, + {'type': 'operation', 'operation_name': 'Test', 'constants': {'unit': 'cm'}, 'variables': {'aperture': 'f/11'}}, + {'type': 'write', 'constants': {'unit': 'cm'}, 'variables': {'aperture': 'f/11'}}, + {'destination': 'asset', 'asset': 'AS'}, + {'constants': {'unit': 'cm'}}, + {'variables': {'aperture': 'f/11'}} + ]) + async def test__check_parameters_without_required_keys(self, payload): + cols = await entrypoint._check_parameters(payload, skip_required=True) + assert isinstance(cols, dict) + + @pytest.mark.parametrize("payload, exception_name, error_msg", [ + # ({"a": 1}, KeyError, + # "{'name', 'type', 'destination', 'description'} required keys are missing in request payload.") + ({"name": 1}, ValueError, "Control entrypoint name should be in string."), + ({"name": ""}, ValueError, "Control entrypoint name cannot be empty."), + ({"description": 1}, ValueError, "Control entrypoint description should be in string."), + ({"description": ""}, ValueError, "Control entrypoint description cannot be empty."), + ({"type": 1}, ValueError, "Control entrypoint type should be in string."), + ({"type": ""}, ValueError, "Control entrypoint type cannot be empty."), + ({"type": "Blah"}, ValueError, "Possible types are: ['write', 'operation']."), + ({"type": "operation"}, KeyError, "operation_name KV pair is missing."), + ({"type": "operation", "operation_name": ""}, ValueError, "Control entrypoint operation name cannot be empty."), + ({"type": "operation", "operation_name": 1}, ValueError, + "Control entrypoint operation name should be in string."), + ({"destination": ""}, ValueError, "Control entrypoint destination cannot be empty."), + ({"destination": 1}, ValueError, "Control entrypoint destination should be in string."), + ({"destination": "Blah"}, ValueError, + "Possible destination values are: ['broadcast', 'service', 'asset', 'script']."), + ({"destination": "script", "destination_arg": ""}, KeyError, "script destination argument is missing."), + ({"destination": "script", "script": 1}, ValueError, + "Control entrypoint destination argument should be in string."), + ({"destination": "script", "script": ""}, ValueError, + "Control entrypoint destination argument cannot be empty."), + ({"anonymous": "t"}, ValueError, "anonymous should be a bool."), + ({"constants": "t"}, ValueError, "constants should be a dictionary."), + ({"type": "write", "constants": {}}, ValueError, "constants should not be empty."), + ({"type": "write", "constants": None}, ValueError, + "For type write constants must have passed in payload and cannot have empty value."), + ({"variables": "t"}, ValueError, "variables should be a dictionary."), + ({"type": "write", "constants": {"unit": "cm"}, "variables": {}}, ValueError, "variables should not be empty."), + ({"type": "write", "constants": {"unit": "cm"}, "variables": None}, ValueError, + "For type write variables must have passed in payload and cannot have empty value."), + ({"allow": "user"}, ValueError, "allow should be an array of list of users.") + ]) + async def test_bad__check_parameters(self, payload, exception_name, error_msg): + with pytest.raises(Exception) as exc_info: + await entrypoint._check_parameters(payload, skip_required=True) + assert exc_info.type is exception_name + assert exc_info.value.args[0] == error_msg + + # TODO: add more tests + """ + a) authentication based + b) allow + c) exception handling tests + """ From 65e648ed24585c0f345b807de751fc2693446ebe Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Mon, 2 Oct 2023 14:52:28 +0100 Subject: [PATCH 472/499] FOGL-8119 OMF lookup performance enhancements and string handling pre-allocation (#1176) * Updated test_memcheck.sh file to generate logs and xml report based on the params passed Signed-off-by: Mohit Singh Tomar * Feedback changes Signed-off-by: Mohit Singh Tomar * Refactored code Signed-off-by: Mohit Singh Tomar * FOGL-7839 Cleanup shutdown of storage service to remove valgrind reports Signed-off-by: Mark Riddoch * North service enhancements to cleanup or exit Signed-off-by: Mark Riddoch * Add virtual destructor to south service and remove copy of readings when ingesting via Python Signed-off-by: Mark Riddoch * Delete north service Signed-off-by: Mark Riddoch * Updates in north service Signed-off-by: Mark Riddoch * Delete empty reading sets Signed-off-by: Mark Riddoch * Make sure reconfigure thread is shutdown Signed-off-by: Mark Riddoch * Add south plugin destructor Signed-off-by: Mark Riddoch * Updates in south service Signed-off-by: Mark Riddoch * Checkpoint Signed-off-by: Mark Riddoch * Cleanup queues Signed-off-by: Mark Riddoch * Fix unitialised data read Signed-off-by: Mark Riddoch * Resolve sharing of readings issue Signed-off-by: Mark Riddoch * Revert to push_back for PythonReadingSet Signed-off-by: Mark Riddoch * Limit north queues Signed-off-by: Mark Riddoch * Experimental checkin Signed-off-by: Mark Riddoch * Experiment with removing virtual from reading set destructor Signed-off-by: Mark Riddoch * Fix dangerous ReadingSet::append that could result in readings being deleted twice Signed-off-by: Mark Riddoch * Reinstate optimal south ingest Signed-off-by: Mark Riddoch * Remove conditional code Signed-off-by: Mark Riddoch * Fix issue with base class calling method in derived class from the destructor Signed-off-by: Mark Riddoch * Use unordered_maps for lookup tables Signed-off-by: Mark Riddoch * Updates to OMF linked data creation. Note instrumentation is turned on Signed-off-by: Mark Riddoch * Increase initial string reserve Signed-off-by: Mark Riddoch * Assume version supports linmked data unless otehrwise stated Signed-off-by: Mark Riddoch * Turn on instrument Signed-off-by: Mark Riddoch * FOGL-8119 Strip CR/NL from exception message and add re-creation of sender class Signed-off-by: Mark Riddoch * Fix unitialised variable Signed-off-by: Mark Riddoch --------- Signed-off-by: Mohit Singh Tomar Signed-off-by: Mark Riddoch Co-authored-by: Mohit Singh Tomar Co-authored-by: Himanshu Vimal <67678828+cyberwalk3r@users.noreply.github.com> --- C/plugins/common/simple_https.cpp | 2 ++ C/plugins/north/OMF/include/omf.h | 12 +++++-- C/plugins/north/OMF/include/omfinfo.h | 2 +- C/plugins/north/OMF/include/omflinkeddata.h | 23 +++++++------ C/plugins/north/OMF/linkdata.cpp | 30 ++++++++++++---- C/plugins/north/OMF/omf.cpp | 15 +++++--- C/plugins/north/OMF/omfinfo.cpp | 38 ++++++++++++++------- C/services/north/data_load.cpp | 2 +- 8 files changed, 86 insertions(+), 38 deletions(-) diff --git a/C/plugins/common/simple_https.cpp b/C/plugins/common/simple_https.cpp index 35dce537d6..35c2584c49 100644 --- a/C/plugins/common/simple_https.cpp +++ b/C/plugins/common/simple_https.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #define VERBOSE_LOG 0 @@ -256,6 +257,7 @@ int SimpleHttps::sendRequest( else if (http_code > 401) { std::stringstream error_message; + StringReplace(response, "\r\n", ""); error_message << "HTTP code |" << to_string(http_code) << "| HTTP error |" << response << "|"; throw runtime_error(error_message.str()); diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index cf23be81a9..f3081d0bb6 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -124,6 +125,11 @@ class OMF } }; + void setSender(HttpSender& sender) + { + m_sender = sender; + }; + /** * Send data to PI Server passing a vector of readings. * @@ -482,21 +488,21 @@ class OMF * The container for this asset and data point has been sent in * this session. */ - std::map + std::unordered_map m_containerSent; /** * The data message for this asset and data point has been sent in * this session. */ - std::map + std::unordered_map m_assetSent; /** * The link for this asset and data point has been sent in * this session. */ - std::map + std::unordered_map m_linkSent; /** diff --git a/C/plugins/north/OMF/include/omfinfo.h b/C/plugins/north/OMF/include/omfinfo.h index 4834fd401f..d2d2aeae45 100644 --- a/C/plugins/north/OMF/include/omfinfo.h +++ b/C/plugins/north/OMF/include/omfinfo.h @@ -56,7 +56,6 @@ #define ENDPOINT_URL_EDS "http://localhost:PORT_PLACEHOLDER/api/v1/tenants/default/namespaces/default/omf" -static bool s_connected = true; // if true, access to PI Web API is working enum OMF_ENDPOINT_PORT { ENDPOINT_PORT_PIWEB_API=443, @@ -179,5 +178,6 @@ class OMFInformation { string m_omfversion; bool m_legacy; string m_name; + bool m_connected; }; #endif diff --git a/C/plugins/north/OMF/include/omflinkeddata.h b/C/plugins/north/OMF/include/omflinkeddata.h index 315d028e57..bf12ac4e6b 100644 --- a/C/plugins/north/OMF/include/omflinkeddata.h +++ b/C/plugins/north/OMF/include/omflinkeddata.h @@ -34,9 +34,9 @@ class OMFLinkedData { public: - OMFLinkedData( std::map *containerSent, - std::map *assetSent, - std::map *linkSent, + OMFLinkedData( std::unordered_map *containerSent, + std::unordered_map *assetSent, + std::unordered_map *linkSent, const OMF_ENDPOINT PIServerEndpoint = ENDPOINT_CR) : m_containerSent(containerSent), m_assetSent(assetSent), @@ -73,21 +73,24 @@ class OMFLinkedData private: /** * The container for this asset and data point has been sent in - * this session. + * this session. The key is the asset followed by the datapoint name + * with a '.' delimiter between. The value is the base type used, a + * container will be sent if the base type changes. */ - std::map *m_containerSent; + std::unordered_map *m_containerSent; /** - * The data message for this asset and data point has been sent in - * this session. + * The data message for this asset has been sent in + * this session. The key is the asset name. The value is always true. */ - std::map *m_assetSent; + std::unordered_map *m_assetSent; /** * The link for this asset and data point has been sent in - * this session. + * this session. key is the asset followed by the datapoint name + * with a '.' delimiter between. The value is always true. */ - std::map *m_linkSent; + std::unordered_map *m_linkSent; /** * The endpoint to which we are sending data diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index b260893e50..f1d9236943 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -25,6 +25,14 @@ #include #include +/** + * In order to cut down on the number of string copies made whilst building + * the OMF message for a reading we reseeve a number of bytes in a string and + * each time we get close to filling the string we reserve mode. The value below + * defines the increment we use to grow the string reservation. + */ +#define RESERVE_INCREMENT 100 + using namespace std; /** @@ -63,6 +71,8 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi { string outData; bool changed; + int reserved = RESERVE_INCREMENT * 2; + outData.reserve(reserved); string assetName = reading.getAssetName(); @@ -97,7 +107,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi Logger::getLogger()->debug("Processing %s (%s) using Linked Types", assetName.c_str(), DataPointNamesAsString(reading).c_str()); bool needDelim = false; - if (m_assetSent->find(assetName) == m_assetSent->end()) + if (m_assetSent->count(assetName) == 0) { // Send the data message to create the asset instance outData.append("{ \"typeid\":\"FledgeAsset\", \"values\":[ { \"AssetId\":\""); @@ -114,13 +124,19 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi */ for (vector::const_iterator it = data.begin(); it != data.end(); ++it) { - string dpName = (*it)->getName(); + Datapoint *dp = *it; + if (reserved - outData.size() < RESERVE_INCREMENT / 2) + { + reserved += RESERVE_INCREMENT; + outData.reserve(reserved); + } + string dpName = dp->getName(); if (dpName.compare(OMF_HINT) == 0) { // Don't send the OMF Hint to the PI Server continue; } - if (!isTypeSupported((*it)->getData())) + if (!isTypeSupported(dp->getData())) { skippedDatapoints.push_back(dpName); continue; @@ -157,11 +173,11 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi // Create the link for the asset if not already created string link = assetName + "." + dpName; - string baseType = getBaseType(*it, format); + string baseType = getBaseType(dp, format); auto container = m_containerSent->find(link); if (container == m_containerSent->end()) { - sendContainer(link, *it, hints, baseType); + sendContainer(link, dp, hints, baseType); m_containerSent->insert(pair(link, baseType)); } else if (baseType.compare(container->second) != 0) @@ -177,7 +193,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi } else { - sendContainer(link, *it, hints, baseType); + sendContainer(link, dp, hints, baseType); (*m_containerSent)[link] = baseType; } } @@ -208,7 +224,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi // Base type we are using for this data point outData.append("\"" + baseType + "\": "); // Add datapoint Value - outData.append((*it)->getData().toString()); + outData.append(dp->getData().toString()); outData.append(", "); // Append Z to getAssetDateTime(FMT_STANDARD) outData.append("\"Time\": \"" + reading.getAssetDateUserTime(Reading::FMT_STANDARD) + "Z" + "\""); diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index ac642d1768..bd3bc255fc 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -42,7 +42,7 @@ static bool isTypeSupported(DatapointValue& dataPoint); vector OMF::m_reportedAssets; // 1 enable performance tracking -#define INSTRUMENT 0 +#define INSTRUMENT 1 #define AFHierarchySeparator '/' #define AF_TYPES_SUFFIX "-type" // The asset name is composed by: asset name + AF_TYPES_SUFFIX + incremental id of the type @@ -237,7 +237,8 @@ OMF::OMF(const string& name, m_sender(sender), m_legacy(false), m_name(name), - m_baseTypesSent(false) + m_baseTypesSent(false), + m_linkedProperties(true) { m_lastError = false; m_changeTypeId = false; @@ -258,7 +259,9 @@ OMF::OMF(const string& name, m_OMFDataTypes(&types), m_producerToken(token), m_sender(sender), - m_name(name) + m_name(name), + m_baseTypesSent(false), + m_linkedProperties(true) { // Get starting type-id sequence or set the default value auto it = (*m_OMFDataTypes).find(FAKE_ASSET_KEY); @@ -1114,6 +1117,10 @@ uint32_t OMF::sendToServer(const vector& readings, } } + // TODO We do not need the superset stuff if we are using linked data types, + // this would save us interating over the dat aan extra time and reduce our + // memory footprint + // // Create a superset of all the datapoints for each assetName // the superset[assetName] is then passed to routines which handles // creation of OMF data types. This is used for the initial type @@ -1511,7 +1518,7 @@ uint32_t OMF::sendToServer(const vector& readings, timersub(&t5, &t4, &tm); timeT5 = tm.tv_sec + ((double)tm.tv_usec / 1000000); - Logger::getLogger()->debug("Timing seconds - thread :%s: - superSet :%6.3f: - Loop :%6.3f: - compress :%6.3f: - send data :%6.3f: - readings |%d| - msg size |%d| - msg size compressed |%d| ", + Logger::getLogger()->warn("Timing seconds - thread :%s: - superSet :%6.3f: - Loop :%6.3f: - compress :%6.3f: - send data :%6.3f: - readings |%d| - msg size |%d| - msg size compressed |%d| ", threadId.str().c_str(), timeT1, timeT2, diff --git a/C/plugins/north/OMF/omfinfo.cpp b/C/plugins/north/OMF/omfinfo.cpp index aa4ad3b1b2..f9efe8b01f 100644 --- a/C/plugins/north/OMF/omfinfo.cpp +++ b/C/plugins/north/OMF/omfinfo.cpp @@ -16,7 +16,7 @@ using namespace SimpleWeb; /** * Constructor for the OMFInformation class */ -OMFInformation::OMFInformation(ConfigCategory *config) : m_sender(NULL), m_omf(NULL) +OMFInformation::OMFInformation(ConfigCategory *config) : m_sender(NULL), m_omf(NULL), m_connected(false) { m_logger = Logger::getLogger(); @@ -348,7 +348,7 @@ void OMFInformation::start(const string& storedData) } // Retrieve the PI Web API Version - s_connected = true; + m_connected = true; if (m_PIServerEndpoint == ENDPOINT_PIWEB_API) { int httpCode = PIWebAPIGetVersion(); @@ -357,11 +357,11 @@ void OMFInformation::start(const string& storedData) SetOMFVersion(); Logger::getLogger()->info("%s connected to %s OMF Version: %s", m_RestServerVersion.c_str(), m_hostAndPort.c_str(), m_omfversion.c_str()); - s_connected = true; + m_connected = true; } else { - s_connected = false; + m_connected = false; } } else if (m_PIServerEndpoint == ENDPOINT_EDS) @@ -398,6 +398,14 @@ uint32_t OMFInformation::send(const vector& readings) return 0; } + if (m_sender && m_connected == false) + { + // TODO Make the info when reconnection has been proved to work + Logger::getLogger()->warn("Connection failed creating a new sender"); + delete m_sender; + m_sender = NULL; + } + if (!m_sender) { /** @@ -448,6 +456,12 @@ uint32_t OMFInformation::send(const vector& readings) m_sender->setOCSTenantId (m_OCSTenantId); m_sender->setOCSClientId (m_OCSClientId); m_sender->setOCSClientSecret (m_OCSClientSecret); + + if (m_omf) + { + // Created a new sender after a connection failure + m_omf->setSender(*m_sender); + } } // OCS or ADH - retrieves the authentication token @@ -464,7 +478,7 @@ uint32_t OMFInformation::send(const vector& readings) m_omf = new OMF(m_name, *m_sender, m_path, m_assetsDataTypes, m_producerToken); - m_omf->setConnected(s_connected); + m_omf->setConnected(m_connected); m_omf->setSendFullStructure(m_sendFullStructure); // Set PIServerEndpoint configuration @@ -517,11 +531,11 @@ uint32_t OMFInformation::send(const vector& readings) // Write a warning if the connection to PI Web API has been lost bool updatedConnected = m_omf->getConnected(); - if (m_PIServerEndpoint == ENDPOINT_PIWEB_API && s_connected && !updatedConnected) + if (m_PIServerEndpoint == ENDPOINT_PIWEB_API && m_connected && !updatedConnected) { Logger::getLogger()->warn("Connection to PI Web API at %s has been lost", m_hostAndPort.c_str()); } - s_connected = updatedConnected; + m_connected = updatedConnected; #if INSTRUMENT Logger::getLogger()->debug("plugin_send elapsed time: %6.3f seconds, NumValues: %u", GetElapsedTime(&startTime), ret); @@ -1333,7 +1347,7 @@ bool OMFInformation::IsPIWebAPIConnected() static bool reported = false; // Has the state been reported yet static bool reportedState; // What was the last reported state - if (!s_connected && m_PIServerEndpoint == ENDPOINT_PIWEB_API) + if (!m_connected && m_PIServerEndpoint == ENDPOINT_PIWEB_API) { std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); @@ -1342,7 +1356,7 @@ bool OMFInformation::IsPIWebAPIConnected() int httpCode = PIWebAPIGetVersion(false); if (httpCode >= 400) { - s_connected = false; + m_connected = false; now = std::chrono::steady_clock::now(); nextCheck = now + std::chrono::seconds(60); Logger::getLogger()->debug("PI Web API %s is not available. HTTP Code: %d", m_hostAndPort.c_str(), httpCode); @@ -1356,7 +1370,7 @@ bool OMFInformation::IsPIWebAPIConnected() } else { - s_connected = true; + m_connected = true; SetOMFVersion(); Logger::getLogger()->info("%s reconnected to %s OMF Version: %s", m_RestServerVersion.c_str(), m_hostAndPort.c_str(), m_omfversion.c_str()); @@ -1374,8 +1388,8 @@ bool OMFInformation::IsPIWebAPIConnected() { // Endpoints other than PI Web API fail quickly when they are unavailable // so there is no need to check their status in advance. - s_connected = true; + m_connected = true; } - return s_connected; + return m_connected; } diff --git a/C/services/north/data_load.cpp b/C/services/north/data_load.cpp index 4eb80ae67e..4457a9b365 100755 --- a/C/services/north/data_load.cpp +++ b/C/services/north/data_load.cpp @@ -190,7 +190,7 @@ ReadingSet *readings = NULL; } if (readings && readings->getCount()) { - Logger::getLogger()->debug("DataLoad::readBlock(): Got %d readings from storage client", readings->getCount()); + Logger::getLogger()->debug("DataLoad::readBlock(): Got %d readings from storage client", readings->getCount()); m_lastFetched = readings->getLastId(); bufferReadings(readings); return; From 321cbe592fea1f8ca8e0a42b98f6a02f0d0f7761 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Tue, 3 Oct 2023 15:45:09 +0530 Subject: [PATCH 473/499] FOGL-8133: Initial fix Signed-off-by: Amandeep Singh Arora --- C/common/include/reading_set.h | 2 +- C/common/pythonreading.cpp | 12 +++++++----- C/common/pythonreadingset.cpp | 6 ++++++ C/common/reading_set.cpp | 4 ++-- .../python/python_plugin_interface.cpp | 17 ++++++++++++++--- scripts/services/north_C | 7 ++++--- 6 files changed, 34 insertions(+), 14 deletions(-) mode change 100644 => 100755 C/services/north-plugin-interfaces/python/python_plugin_interface.cpp diff --git a/C/common/include/reading_set.h b/C/common/include/reading_set.h index 2f096da0bd..e5f7982fb3 100755 --- a/C/common/include/reading_set.h +++ b/C/common/include/reading_set.h @@ -50,7 +50,7 @@ class ReadingSet { unsigned long getReadingId(uint32_t pos); void append(ReadingSet *); void append(ReadingSet&); - void append(std::vector &); + void append(const std::vector &); void removeAll(); void clear(); bool copy(const ReadingSet& src); diff --git a/C/common/pythonreading.cpp b/C/common/pythonreading.cpp index 678af30747..8fe51a317e 100755 --- a/C/common/pythonreading.cpp +++ b/C/common/pythonreading.cpp @@ -375,18 +375,20 @@ PyObject *PythonReading::toPython(bool changeKeys, bool useBytesString) // this will be added as the value for key 'readings' PyObject *dataPoints = PyDict_New(); + Logger::getLogger()->info("%s:%d: First dp @ %p", __FUNCTION__, __LINE__, m_values[0]); + // Get all datapoints - for (auto it = m_values.begin(); it != m_values.end(); ++it) + for (auto & it : m_values) { // Pass BytesString switch - PyObject *value = convertDatapoint(*it, useBytesString); + PyObject *value = convertDatapoint(it, useBytesString); // Add Datapoint: key and value if (value) { PyObject *key = useBytesString ? - PyBytes_FromString((*it)->getName().c_str()) + PyBytes_FromString(it->getName().c_str()) : - PyUnicode_FromString((*it)->getName().c_str()); + PyUnicode_FromString(it->getName().c_str()); PyDict_SetItem(dataPoints, key, value); Py_CLEAR(key); @@ -395,7 +397,7 @@ PyObject *PythonReading::toPython(bool changeKeys, bool useBytesString) else { Logger::getLogger()->info("Unable to convert datapoint '%s' of reading '%s' tp Python", - (*it)->getName().c_str(), m_asset.c_str()); + it->getName().c_str(), m_asset.c_str()); } } diff --git a/C/common/pythonreadingset.cpp b/C/common/pythonreadingset.cpp index fa664e1838..c60ea3db2a 100755 --- a/C/common/pythonreadingset.cpp +++ b/C/common/pythonreadingset.cpp @@ -149,6 +149,12 @@ PyObject *PythonReadingSet::toPython(bool changeKeys) for (int i = 0; i < m_readings.size(); i++) { PythonReading *pyReading = (PythonReading *) m_readings[i]; + if(i==0) + { + Logger::getLogger()->info("%s:%d: First reading @ %p", __FUNCTION__, __LINE__, pyReading); + const std::vector dpVec = pyReading->getReadingData(); + Logger::getLogger()->info("%s:%d: First reading: First dp @ %p", __FUNCTION__, __LINE__, dpVec[0]); + } PyList_SetItem(set, i, pyReading->toPython(changeKeys)); } return set; diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 382d321bc4..9a53701279 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -201,7 +201,7 @@ ReadingSet::append(ReadingSet& readings) * @param readings A vector of Reading pointers to append to the ReadingSet */ void -ReadingSet::append(vector& readings) +ReadingSet::append(const vector& readings) { for (auto it = readings.cbegin(); it != readings.cend(); it++) { @@ -210,7 +210,7 @@ ReadingSet::append(vector& readings) m_readings.push_back(*it); m_count++; } - readings.clear(); + // readings.clear(); } /** diff --git a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp old mode 100644 new mode 100755 index fdfa7bdf62..5f320ea118 --- a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp @@ -414,11 +414,22 @@ uint32_t plugin_send_fn(PLUGIN_HANDLE handle, const std::vector& read return numReadingsSent; } + if (readings.size()) + { + Reading *firstRdng = readings[0]; + Logger::getLogger()->info("%s:%d: First reading @ %p", __FUNCTION__, __LINE__, firstRdng); + const std::vector dpVec = firstRdng->getReadingData(); + Logger::getLogger()->info("%s:%d: First reading: First dp @ %p", __FUNCTION__, __LINE__, dpVec[0]); + } + // Create a dict of readings - // 1 create a PythonReadingSet object - PythonReadingSet *pyReadingSet = (PythonReadingSet *) &readings; + // 1. create empty ReadingSet + ReadingSet set(&readings); + + // 2. create a PythonReadingSet object + PythonReadingSet *pyReadingSet = (PythonReadingSet *) &set; - // 2 create PyObject + // 3. create PyObject PyObject* readingsList = pyReadingSet->toPython(true); numReadingsSent = call_plugin_send_coroutine(pFunc, handle, readingsList); diff --git a/scripts/services/north_C b/scripts/services/north_C index 832113098f..8d4b101f78 100755 --- a/scripts/services/north_C +++ b/scripts/services/north_C @@ -10,7 +10,7 @@ if [ ! -d "${FLEDGE_ROOT}" ]; then exit 1 fi -runvalgrind=n +runvalgrind=y if [ "$VALGRIND_NORTH" != "" ]; then for i in "$@"; do case $i in @@ -29,9 +29,10 @@ fi cd "${FLEDGE_ROOT}/services" if [ "$runvalgrind" = "y" ]; then - file=$HOME/north.${name}.valgrind.out + file=/tmp/north.${name}.valgrind.out rm -f $file - valgrind --leak-check=full --trace-children=yes --log-file=$file ./fledge.services.north "$@" + valgrind --leak-check=full --trace-children=yes --show-leak-kinds=all --track-origins=yes --log-file=$file ./fledge.services.north "$@" else ./fledge.services.north "$@" fi + From 7aafca0a180597e1bf44775742fb39f3c5fa4516 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Tue, 3 Oct 2023 15:57:56 +0530 Subject: [PATCH 474/499] Removing debug code/logs Signed-off-by: Amandeep Singh Arora --- C/common/include/reading_set.h | 2 +- C/common/pythonreading.cpp | 12 +++++------- C/common/reading_set.cpp | 4 ++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/C/common/include/reading_set.h b/C/common/include/reading_set.h index e5f7982fb3..2f096da0bd 100755 --- a/C/common/include/reading_set.h +++ b/C/common/include/reading_set.h @@ -50,7 +50,7 @@ class ReadingSet { unsigned long getReadingId(uint32_t pos); void append(ReadingSet *); void append(ReadingSet&); - void append(const std::vector &); + void append(std::vector &); void removeAll(); void clear(); bool copy(const ReadingSet& src); diff --git a/C/common/pythonreading.cpp b/C/common/pythonreading.cpp index 8fe51a317e..678af30747 100755 --- a/C/common/pythonreading.cpp +++ b/C/common/pythonreading.cpp @@ -375,20 +375,18 @@ PyObject *PythonReading::toPython(bool changeKeys, bool useBytesString) // this will be added as the value for key 'readings' PyObject *dataPoints = PyDict_New(); - Logger::getLogger()->info("%s:%d: First dp @ %p", __FUNCTION__, __LINE__, m_values[0]); - // Get all datapoints - for (auto & it : m_values) + for (auto it = m_values.begin(); it != m_values.end(); ++it) { // Pass BytesString switch - PyObject *value = convertDatapoint(it, useBytesString); + PyObject *value = convertDatapoint(*it, useBytesString); // Add Datapoint: key and value if (value) { PyObject *key = useBytesString ? - PyBytes_FromString(it->getName().c_str()) + PyBytes_FromString((*it)->getName().c_str()) : - PyUnicode_FromString(it->getName().c_str()); + PyUnicode_FromString((*it)->getName().c_str()); PyDict_SetItem(dataPoints, key, value); Py_CLEAR(key); @@ -397,7 +395,7 @@ PyObject *PythonReading::toPython(bool changeKeys, bool useBytesString) else { Logger::getLogger()->info("Unable to convert datapoint '%s' of reading '%s' tp Python", - it->getName().c_str(), m_asset.c_str()); + (*it)->getName().c_str(), m_asset.c_str()); } } diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 9a53701279..382d321bc4 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -201,7 +201,7 @@ ReadingSet::append(ReadingSet& readings) * @param readings A vector of Reading pointers to append to the ReadingSet */ void -ReadingSet::append(const vector& readings) +ReadingSet::append(vector& readings) { for (auto it = readings.cbegin(); it != readings.cend(); it++) { @@ -210,7 +210,7 @@ ReadingSet::append(const vector& readings) m_readings.push_back(*it); m_count++; } - // readings.clear(); + readings.clear(); } /** From 71ead21eab2d9cfe7d590778ea600cb9cb09aa5e Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Tue, 3 Oct 2023 16:35:05 +0530 Subject: [PATCH 475/499] Removing debug code/logs Signed-off-by: Amandeep Singh Arora --- C/common/pythonreadingset.cpp | 6 ------ .../python/python_plugin_interface.cpp | 8 -------- 2 files changed, 14 deletions(-) diff --git a/C/common/pythonreadingset.cpp b/C/common/pythonreadingset.cpp index c60ea3db2a..fa664e1838 100755 --- a/C/common/pythonreadingset.cpp +++ b/C/common/pythonreadingset.cpp @@ -149,12 +149,6 @@ PyObject *PythonReadingSet::toPython(bool changeKeys) for (int i = 0; i < m_readings.size(); i++) { PythonReading *pyReading = (PythonReading *) m_readings[i]; - if(i==0) - { - Logger::getLogger()->info("%s:%d: First reading @ %p", __FUNCTION__, __LINE__, pyReading); - const std::vector dpVec = pyReading->getReadingData(); - Logger::getLogger()->info("%s:%d: First reading: First dp @ %p", __FUNCTION__, __LINE__, dpVec[0]); - } PyList_SetItem(set, i, pyReading->toPython(changeKeys)); } return set; diff --git a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp index 5f320ea118..c218466747 100755 --- a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp @@ -414,14 +414,6 @@ uint32_t plugin_send_fn(PLUGIN_HANDLE handle, const std::vector& read return numReadingsSent; } - if (readings.size()) - { - Reading *firstRdng = readings[0]; - Logger::getLogger()->info("%s:%d: First reading @ %p", __FUNCTION__, __LINE__, firstRdng); - const std::vector dpVec = firstRdng->getReadingData(); - Logger::getLogger()->info("%s:%d: First reading: First dp @ %p", __FUNCTION__, __LINE__, dpVec[0]); - } - // Create a dict of readings // 1. create empty ReadingSet ReadingSet set(&readings); From 3dbfb532119cef3dbb0c5852b00b0320ec925f93 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Tue, 3 Oct 2023 17:11:53 +0530 Subject: [PATCH 476/499] Further minor fix Signed-off-by: Amandeep Singh Arora --- .../python/python_plugin_interface.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp index c218466747..002a11785b 100755 --- a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp @@ -428,7 +428,9 @@ uint32_t plugin_send_fn(PLUGIN_HANDLE handle, const std::vector& read Logger::getLogger()->debug("C2Py: plugin_send_fn():L%d: filtered readings sent %d", __LINE__, numReadingsSent); - + + set.clear(); // to avoid deletion of contained Reading objects; they are subsequently accessed in calling function DataSender::send() + // Remove python object Py_CLEAR(readingsList); Py_CLEAR(pFunc); From 23b86faa8108a46a5e574a123dbcbd62d1b11d3e Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Tue, 3 Oct 2023 17:14:16 +0530 Subject: [PATCH 477/499] Cosmetic change Signed-off-by: Amandeep Singh Arora --- .../north-plugin-interfaces/python/python_plugin_interface.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp index 002a11785b..758921612c 100755 --- a/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp +++ b/C/services/north-plugin-interfaces/python/python_plugin_interface.cpp @@ -414,8 +414,7 @@ uint32_t plugin_send_fn(PLUGIN_HANDLE handle, const std::vector& read return numReadingsSent; } - // Create a dict of readings - // 1. create empty ReadingSet + // 1. create a ReadingSet ReadingSet set(&readings); // 2. create a PythonReadingSet object From 76169f1590ce6cde5d49fe19d61ebee0ef6921cc Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Tue, 3 Oct 2023 17:16:20 +0530 Subject: [PATCH 478/499] Disable valgrind run Signed-off-by: Amandeep Singh Arora --- scripts/services/north_C | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/services/north_C b/scripts/services/north_C index 8d4b101f78..80fa8a7968 100755 --- a/scripts/services/north_C +++ b/scripts/services/north_C @@ -10,7 +10,7 @@ if [ ! -d "${FLEDGE_ROOT}" ]; then exit 1 fi -runvalgrind=y +runvalgrind=n if [ "$VALGRIND_NORTH" != "" ]; then for i in "$@"; do case $i in @@ -29,7 +29,7 @@ fi cd "${FLEDGE_ROOT}/services" if [ "$runvalgrind" = "y" ]; then - file=/tmp/north.${name}.valgrind.out + file=${HOME}/north.${name}.valgrind.out rm -f $file valgrind --leak-check=full --trace-children=yes --show-leak-kinds=all --track-origins=yes --log-file=$file ./fledge.services.north "$@" else From ebd9450a1baef8d1d584505e7d88e25f27e95dd5 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 4 Oct 2023 10:48:25 +0100 Subject: [PATCH 479/499] FOGL-8135 Deal with reserved characters in linked data mechanism (#1179) Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/linkdata.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index f1d9236943..edc4771389 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -106,6 +106,8 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi Logger::getLogger()->debug("Processing %s (%s) using Linked Types", assetName.c_str(), DataPointNamesAsString(reading).c_str()); + assetName = OMF::ApplyPIServerNamingRulesObj(assetName, NULL); + bool needDelim = false; if (m_assetSent->count(assetName) == 0) { @@ -136,6 +138,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi // Don't send the OMF Hint to the PI Server continue; } + dpName = OMF::ApplyPIServerNamingRulesObj(dpName, NULL); if (!isTypeSupported(dp->getData())) { skippedDatapoints.push_back(dpName); From 0c664a0d04a971e6f4b3ec4bcc56b42d492445fa Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 4 Oct 2023 17:51:53 +0530 Subject: [PATCH 480/499] Jinja2 and sphinx-rtd-theme pip version added for docs Signed-off-by: ashish-jabble --- docs/requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 7220b25057..281fe1774e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,5 @@ Sphinx==3.5.4 docutils<0.18 -urllib3==1.26.15 \ No newline at end of file +Jinja2<3.1 +urllib3==1.26.15 +sphinx-rtd-theme==1.3.0 From ab1ea0bc50e82ad6055c852117af98115e54a4ef Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 5 Oct 2023 09:24:55 +0100 Subject: [PATCH 481/499] FOGL-8128 Make the reconfigure thread shutdown in dryrun mode (#1181) Signed-off-by: Mark Riddoch --- C/services/south/south.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index 5e3881affd..c105e46b7f 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -676,6 +676,7 @@ void SouthService::start(string& coreAddress, unsigned short corePort) } else { + m_shutdown = true; Logger::getLogger()->info("Dryrun of service, shutting down"); } From dcc9296350afd3a34d0ade1c69f2ac912f839420 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 5 Oct 2023 17:24:31 +0530 Subject: [PATCH 482/499] Control Flow entrypoint API system tests added Signed-off-by: ashish-jabble --- .../api/control_service/test_entrypoint.py | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 tests/system/python/api/control_service/test_entrypoint.py diff --git a/tests/system/python/api/control_service/test_entrypoint.py b/tests/system/python/api/control_service/test_entrypoint.py new file mode 100644 index 0000000000..e5730ea79b --- /dev/null +++ b/tests/system/python/api/control_service/test_entrypoint.py @@ -0,0 +1,198 @@ +import http.client +import json +import pytest +from urllib.parse import quote +from collections import OrderedDict + +__author__ = "Ashish Jabble" +__copyright__ = "Copyright (c) 2023 Dianomic Systems Inc." +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + + +""" Control Flow Entrypoint API tests """ + +EP_1 = "EP #1" +EP_2 = "EP-1" +EP_3 = "EP #_2" +payload1 = {"name": EP_1, "type": "write", "description": "Entry Point 1", "operation_name": "", + "destination": "broadcast", "constants": {"c1": "100"}, "variables": {"v1": "100"}, "anonymous": False, + "allow": []} +payload2 = {"name": EP_2, "type": "operation", "description": "Operation 1", "operation_name": "distance", + "destination": "broadcast", "anonymous": False, "allow": []} +payload3 = {"name": EP_3, "type": "operation", "description": "Operation 2", "operation_name": "distance", + "destination": "broadcast", "constants": {"c1": "100"}, "variables": {"v1": "1200"}, "anonymous": True, + "allow": ["admin", "user"]} + +# TODO: add more tests +""" + a) authentication based + b) update request by installing external service +""" + + +class TestEntrypoint: + def test_empty_get_all(self, fledge_url, reset_and_start_fledge): + jdoc = self._get_all(fledge_url) + assert [] == jdoc + + @pytest.mark.parametrize("payload", [payload1, payload2, payload3]) + def test_create(self, fledge_url, payload): + ep_name = payload['name'] + conn = http.client.HTTPConnection(fledge_url) + conn.request('POST', '/fledge/control/manage', body=json.dumps(payload)) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "Failed to create {} entrypoint!".format(ep_name) + assert 'message' in jdoc + assert '{} control entrypoint has been created successfully.'.format(ep_name) == jdoc['message'] + self.verify_details(conn, payload) + self.verify_audit_details(conn, ep_name, 'CTEAD') + + def test_get_all(self, fledge_url): + jdoc = self._get_all(fledge_url) + assert 3 == len(jdoc) + assert ['name', 'description', 'permitted'] == list(jdoc[0].keys()) + + def test_get_by_name(self, fledge_url): + conn = http.client.HTTPConnection(fledge_url) + conn.request("GET", '/fledge/control/manage/{}'.format(quote(EP_1))) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "{} entrypoint found!".format(EP_1) + assert payload1 == jdoc + assert 'permitted' in jdoc + + @pytest.mark.parametrize("name, payload, old_info", [ + (EP_1, {"anonymous": True}, {"anonymous": False}), + (EP_2, {"description": "Updated", "type": "operation", "operation_name": "focus", "allow": ["user"]}, + {"description": "Operation 1", "type": "operation", "operation_name": "distance", "allow": []}), + (EP_3, {"constants": {"c1": "123", "c2": "100"}, "variables": {"v1": "900"}}, + {"constants": {"c1": "100"}, "variables": {"v1": "1200"}}) + ]) + def test_update(self, fledge_url, name, payload, old_info): + conn = http.client.HTTPConnection(fledge_url) + conn.request('PUT', '/fledge/control/manage/{}'.format(quote(name)), body=json.dumps(payload)) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert 'message' in jdoc + assert '{} control entrypoint has been updated successfully.'.format(name) == jdoc['message'] + + source = 'CTECH' + conn.request("GET", '/fledge/audit?source={}'.format(source)) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert 'audit' in jdoc + assert len(jdoc['audit']) + audit = jdoc['audit'][0] + assert 'INFORMATION' == audit['severity'] + assert source == audit['source'] + assert 'details' in audit + assert 'entrypoint' in audit['details'] + assert 'old_entrypoint' in audit['details'] + audit_old = audit['details']['old_entrypoint'] + audit_new = audit['details']['entrypoint'] + assert name == audit_new['name'] + assert name == audit_old['name'] + + conn.request("GET", '/fledge/control/manage/{}'.format(quote(name))) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "{} entrypoint found!".format(name) + assert name == jdoc['name'] + if name == EP_1: + assert old_info['anonymous'] == audit_old['anonymous'] + assert payload['anonymous'] == audit_new['anonymous'] + assert payload['anonymous'] == jdoc['anonymous'] + elif name == EP_2: + assert old_info['description'] == audit_old['description'] + assert payload['description'] == audit_new['description'] + assert old_info['type'] == audit_old['type'] + assert payload['type'] == audit_new['type'] + assert old_info['operation_name'] == audit_old['operation_name'] + assert payload['operation_name'] == audit_new['operation_name'] + assert old_info['allow'] == audit_old['allow'] + assert payload['allow'] == audit_new['allow'] + assert payload['description'] == jdoc['description'] + assert payload['type'] == jdoc['type'] + assert payload['operation_name'] == jdoc['operation_name'] + assert payload['allow'] == jdoc['allow'] + elif name == EP_3: + assert old_info['constants']['c1'] == audit_old['constants']['c1'] + assert 'c2' not in audit_old['constants'] + assert payload['constants']['c1'] == audit_new['constants']['c1'] + assert payload['constants']['c2'] == audit_new['constants']['c2'] + assert old_info['variables']['v1'] == audit_old['variables']['v1'] + assert payload['variables']['v1'] == audit_new['variables']['v1'] + assert payload['constants']['c1'] == jdoc['constants']['c1'] + assert payload['constants']['c2'] == jdoc['constants']['c2'] + assert payload['variables']['v1'] == jdoc['variables']['v1'] + else: + # Add more scenarios + pass + + @pytest.mark.parametrize("name, count", [(EP_1, 2), (EP_2, 1), (EP_3, 0)]) + def test_delete(self, fledge_url, name, count): + conn = http.client.HTTPConnection(fledge_url) + conn.request("DELETE", '/fledge/control/manage/{}'.format(quote(name))) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "{} entrypoint found!".format(name) + assert 'message' in jdoc + assert '{} control entrypoint has been deleted successfully.'.format(name) == jdoc['message'] + self.verify_audit_details(conn, name, 'CTEDL') + jdoc = self._get_all(fledge_url) + assert count == len(jdoc) + + def verify_audit_details(self, conn, ep_name, source): + conn.request("GET", '/fledge/audit?source={}'.format(source)) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "No audit record entry found!" + assert 'audit' in jdoc + assert ep_name == jdoc['audit'][0]['details']['name'] + assert 'INFORMATION' == jdoc['audit'][0]['severity'] + assert source == jdoc['audit'][0]['source'] + + def verify_details(self, conn, data): + name = data['name'] + conn.request("GET", '/fledge/control/manage/{}'.format(quote(name))) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "{} entrypoint found!".format(name) + data['permitted'] = True + if 'constants' not in data: + data['constants'] = {} + if 'variables' not in data: + data['variables'] = {} + + d1 = OrderedDict(sorted(data.items())) + d2 = OrderedDict(sorted(jdoc.items())) + assert d1 == d2 + + def _get_all(self, url): + conn = http.client.HTTPConnection(url) + conn.request("GET", '/fledge/control/manage') + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "No entrypoint found!" + assert 'controls' in jdoc + return jdoc['controls'] From 164664f5a99017bb6caadf14d578285b884364de Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 5 Oct 2023 17:00:14 +0100 Subject: [PATCH 483/499] FOGL-8139 Update the default for statistics gathering (#1183) * FOGL-8139 Update the default for statistics gathering Signed-off-by: Mark Riddoch * Fix typo Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/plugins/storage/sqlite/common/connection.cpp | 2 +- C/services/south/ingest.cpp | 9 ++++++--- C/services/south/south.cpp | 2 +- docs/tuning_fledge.rst | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index e5ed8e5bf3..a2ee960a4d 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -1395,7 +1395,7 @@ std::size_t arr = data.find("inserts"); sqlite3_reset(stmt); - if (sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) + if (sqlite3_resut == SQLITE_DONE && sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) { if (stmt) { diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index d2aaca8ec4..095d8f65fd 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -52,6 +52,7 @@ static void statsThread(Ingest *ingest) * The key checked/created in the table is "" * * @param assetName Asset name for the plugin that is sending readings + * @return int Return -1 on error, 0 if not required or 1 if the entry exists */ int Ingest::createStatsDbEntry(const string& assetName) { @@ -100,7 +101,7 @@ int Ingest::createStatsDbEntry(const string& assetName) m_logger->error("%s:%d : Unable to create new row in statistics table with key='%s'", __FUNCTION__, __LINE__, statistics_key.c_str()); return -1; } - return 0; + return 1; } /** @@ -177,8 +178,10 @@ void Ingest::updateStats() { if (statsDbEntriesCache.find(it->first) == statsDbEntriesCache.end()) { - createStatsDbEntry(it->first); - statsDbEntriesCache.insert(it->first); + if (createStatsDbEntry(it->first) > 0) + { + statsDbEntriesCache.insert(it->first); + } } if (it->second) diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index c105e46b7f..a7cf318572 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -1200,7 +1200,7 @@ void SouthService::addConfigDefaults(DefaultConfigCategory& defaultConfig) /* Add the set of logging levels to the service */ vector statistics = { "per asset", "per service", "per asset & service" }; defaultConfig.addItem("statistics", "Collect statistics either for every asset ingested, for the service in total or both", - "per asset & service", "per asset & service", statistics); + "per service", "per service", statistics); defaultConfig.setItemDisplayName("statistics", "Statistics Collection"); defaultConfig.addItem("perfmon", "Track and store performance counters", "boolean", "false", "false"); diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index 2d6b2000af..29376bc504 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -75,7 +75,7 @@ The south services within Fledge each have a set of advanced configuration optio | |stats_options| | +-----------------+ - The default, *per asset & per service* setting will collect one statistic per asset ingested and an overall statistic for the entire service. The *per service* option just collects the overall service ingest statistics and the *per asset* option just collects the statistics for each asset and not for the entire service. + The *per asset & per service* setting will collect one statistic per asset ingested and an overall statistic for the entire service. The *per service* option just collects the overall service ingest statistics and the *per asset* option just collects the statistics for each asset and not for the entire service. The default is to collect statistics on a per service basis, use of the per asset or the per asset and service options should be limited to south service that collect a relatively small number of distinct assets. Collecting large number of statistics, for 1000 or more distinct assets will have a significant performance overhead and may overwhelm less well provisioned Fledge instances. .. note:: From eabf974900610fc9668f31eb82d5fb5ae23688ad Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 6 Oct 2023 12:07:31 +0100 Subject: [PATCH 484/499] FOGL-8159 Turn off Instrumentation flag (#1186) Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/omf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index bd3bc255fc..c4f0e4cf06 100644 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -42,7 +42,7 @@ static bool isTypeSupported(DatapointValue& dataPoint); vector OMF::m_reportedAssets; // 1 enable performance tracking -#define INSTRUMENT 1 +#define INSTRUMENT 0 #define AFHierarchySeparator '/' #define AF_TYPES_SUFFIX "-type" // The asset name is composed by: asset name + AF_TYPES_SUFFIX + incremental id of the type From 7d5aea3e481cbd613770afd344b82a5901d91d13 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 6 Oct 2023 13:55:37 +0100 Subject: [PATCH 485/499] FOGL-8163 Improve error reporting for locked or busy database (#1188) Signed-off-by: Mark Riddoch --- .../storage/sqlite/common/connection.cpp | 51 +++++++++++------ .../sqlite/common/include/connection.h | 4 +- C/plugins/storage/sqlite/common/readings.cpp | 16 +++--- .../storage/sqlitelb/common/connection.cpp | 57 ++++++++++++------- .../sqlitelb/common/include/connection.h | 9 +-- .../storage/sqlitelb/common/readings.cpp | 16 +++--- 6 files changed, 94 insertions(+), 59 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index a2ee960a4d..989e607c31 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -113,7 +113,7 @@ bool Connection::getNow(string& Now) string nowSqlCMD = "SELECT " SQLITE3_NOW_READING; - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, "now", nowSqlCMD.c_str(), dateCallback, nowDate, @@ -245,7 +245,7 @@ bool Connection::applyColumnDateTimeFormat(sqlite3_stmt *pStmt, char formattedData[100] = ""; // Exec the format SQL - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, "date", formatStmt.c_str(), dateCallback, formattedData, @@ -525,7 +525,7 @@ Connection::Connection() const char *sqlStmt = attachDb.coalesce(); // Exec the statement - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "database", sqlStmt, NULL, NULL, @@ -568,7 +568,7 @@ Connection::Connection() const char *sqlReadingsStmt = attachReadingsDb.coalesce(); // Exec the statement - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "database", sqlReadingsStmt, NULL, NULL, @@ -1790,7 +1790,7 @@ bool allowZero = false; // Exec the UPDATE statement: no callback, no result set m_writeAccessOngoing.fetch_add(1); - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, table, query, NULL, NULL, @@ -1806,7 +1806,7 @@ bool allowZero = false; sqlite3_free(zErrMsg); if (sqlite3_get_autocommit(dbHandle)==0) // transaction is still open, do rollback { - rc=SQLexec(dbHandle, + rc=SQLexec(dbHandle, table, "ROLLBACK TRANSACTION;", NULL, NULL, @@ -3110,7 +3110,7 @@ void Connection::logSQL(const char *tag, const char *stmt) * @param cbArg Callback 1st argument * @param errmsg Locaiton to write error message */ -int Connection::SQLexec(sqlite3 *db, const char *sql, int (*callback)(void*,int,char**,char**), +int Connection::SQLexec(sqlite3 *db, const string& table, const char *sql, int (*callback)(void*,int,char**,char**), void *cbArg, char **errmsg) { int retries = 0, rc; @@ -3157,7 +3157,7 @@ int retries = 0, rc; { int rc2; char *zErrMsg = NULL; - rc2=SQLexec(db, + rc2=SQLexec(db, table, "ROLLBACK TRANSACTION;", NULL, NULL, @@ -3192,16 +3192,16 @@ int retries = 0, rc; if (rc == SQLITE_LOCKED) { - Logger::getLogger()->error("Database still locked after maximum retries"); + Logger::getLogger()->error("Database still locked after maximum retries, executing %s operation on %s", operation(sql).c_str(), table.c_str()); } if (rc == SQLITE_BUSY) { - Logger::getLogger()->error("Database still busy after maximum retries"); + Logger::getLogger()->error("Database still busy after maximum retries, executing %s operation on %s", operation(sql).c_str(), table.c_str()); } if (rc != SQLITE_OK) { - Logger::getLogger()->error("Database error after maximum retries - dbHandle :%X:", this->getDbHandle()); + Logger::getLogger()->error("Database error after maximum retries, executing %s operation on %s", operation(sql).c_str(), table.c_str()); } return rc; @@ -3334,7 +3334,7 @@ vector asset_codes; // Exec the DELETE statement: no callback, no result set m_writeAccessOngoing.fetch_add(1); - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, table, query, NULL, NULL, @@ -3382,7 +3382,7 @@ int Connection::create_table_snapshot(const string& table, const string& id) logSQL("CreateTableSnapshot", query.c_str()); char* zErrMsg = NULL; - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, table, query.c_str(), NULL, NULL, @@ -3420,7 +3420,7 @@ int Connection::load_table_snapshot(const string& table, const string& id) logSQL("LoadTableSnapshot", query.c_str()); char* zErrMsg = NULL; - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, table, query.c_str(), NULL, NULL, @@ -3439,7 +3439,7 @@ int Connection::load_table_snapshot(const string& table, const string& id) // transaction is still open, do rollback if (sqlite3_get_autocommit(dbHandle) == 0) { - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, table, "ROLLBACK TRANSACTION;", NULL, NULL, @@ -3469,7 +3469,7 @@ int Connection::delete_table_snapshot(const string& table, const string& id) logSQL("DeleteTableSnapshot", query.c_str()); char* zErrMsg = NULL; - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, table, query.c_str(), NULL, NULL, @@ -3833,7 +3833,7 @@ bool Connection::vacuum() { char* zErrMsg = NULL; // Exec the statement - int rc = SQLexec(dbHandle, "VACUUM;", NULL, NULL, &zErrMsg); + int rc = SQLexec(dbHandle, "", "VACUUM;", NULL, NULL, &zErrMsg); // Check result if (rc != SQLITE_OK) @@ -3851,3 +3851,20 @@ bool Connection::vacuum() return true; } #endif + +/** + * Return the first word in a SQL statement, ie the operation that is beign executed. + * + * @param sql The complete SQL statement + * @return string The operation + */ +string Connection::operation(const char *sql) +{ + const char *p1 = sql; + char buf[40], *p2 = buf; + while (*p1 && !isspace(*p1) && p2 - buf < 40) + *p2++ = *p1++; + *p2 = '\0'; + return string(buf); + +} diff --git a/C/plugins/storage/sqlite/common/include/connection.h b/C/plugins/storage/sqlite/common/include/connection.h index 89778eff83..899bc30a96 100644 --- a/C/plugins/storage/sqlite/common/include/connection.h +++ b/C/plugins/storage/sqlite/common/include/connection.h @@ -153,7 +153,7 @@ class Connection { #endif private: - + std::string operation(const char *sql); std::vector m_NewDbIdList; // Newly created databases that should be attached @@ -161,7 +161,7 @@ class Connection { int m_queuing; std::mutex m_qMutex; int SQLPrepare(sqlite3 *dbHandle, const char *sqlCmd, sqlite3_stmt **readingsStmt); - int SQLexec(sqlite3 *db, const char *sql, + int SQLexec(sqlite3 *db, const std::string& table, const char *sql, int (*callback)(void*,int,char**,char**), void *cbArg, char **errmsg); diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index fd32b75eaa..71b214cb0c 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -1902,7 +1902,7 @@ vector assetCodes; ) as readings_1 )"; - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, "readings", sql_cmd.c_str(), rowidCallback, &rowidLimit, @@ -1941,7 +1941,7 @@ vector assetCodes; Logger::getLogger()->debug("%s - SELECT MIN - '%s'", __FUNCTION__, sql_cmd.c_str() ); - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", sql_cmd.c_str(), rowidCallback, &minrowidLimit, @@ -1996,7 +1996,7 @@ vector assetCodes; int purge_readings = 0; // Exec query and get result in 'purge_readings' via 'selectCallback' - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", query, selectCallback, &purge_readings, @@ -2088,7 +2088,7 @@ vector assetCodes; sqlBuffer.append(';'); const char *query = sqlBuffer.coalesce(); - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", query, rowidCallback, &midRowId, @@ -2174,7 +2174,7 @@ vector assetCodes; idBuffer.append(';'); const char *idQuery = idBuffer.coalesce(); - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", idQuery, rowidCallback, &lastPurgedId, @@ -2421,7 +2421,7 @@ struct timeval startTv, endTv; )"; } - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", sql_cmd.c_str(), rowidCallback, &rowcount, @@ -2463,7 +2463,7 @@ struct timeval startTv, endTv; } - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", sql_cmd.c_str(), rowidCallback, &maxId, @@ -2516,7 +2516,7 @@ struct timeval startTv, endTv; logger->debug("%s - SELECT MIN - sql_cmd '%s' ", __FUNCTION__, sql_cmd.c_str() ); } - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", sql_cmd.c_str(), rowidCallback, &minId, diff --git a/C/plugins/storage/sqlitelb/common/connection.cpp b/C/plugins/storage/sqlitelb/common/connection.cpp index d37d52d0aa..f4b5f8b9b0 100644 --- a/C/plugins/storage/sqlitelb/common/connection.cpp +++ b/C/plugins/storage/sqlitelb/common/connection.cpp @@ -119,7 +119,7 @@ bool Connection::getNow(string& Now) string nowSqlCMD = "SELECT " SQLITE3_NOW_READING; - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, "now", nowSqlCMD.c_str(), dateCallback, nowDate, @@ -243,7 +243,7 @@ bool Connection::applyColumnDateTimeFormat(sqlite3_stmt *pStmt, char formattedData[100] = ""; // Exec the format SQL - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, "date", formatStmt.c_str(), dateCallback, formattedData, @@ -526,7 +526,7 @@ Connection::Connection() zErrMsg = NULL; // Exec the statement - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "database", sqlStmt, NULL, NULL, @@ -579,7 +579,7 @@ Connection::Connection() const char *sqlReadingsStmt = attachReadingsDb.coalesce(); // Exec the statement - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "database", sqlReadingsStmt, NULL, NULL, @@ -625,7 +625,7 @@ Connection::Connection() // Exec the statement zErrMsg = NULL; - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings creation", sqlReadingsStmt, NULL, NULL, @@ -658,7 +658,7 @@ Connection::Connection() // Exec the statement zErrMsg = NULL; - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings creation", sqlIndex1Stmt, NULL, NULL, @@ -691,7 +691,7 @@ Connection::Connection() // Exec the statement zErrMsg = NULL; - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings creation", sqlIndex2Stmt, NULL, NULL, @@ -724,7 +724,7 @@ Connection::Connection() // Exec the statement zErrMsg = NULL; - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings creation", sqlIndex3Stmt, NULL, NULL, @@ -1832,7 +1832,7 @@ bool allowZero = false; // Exec the UPDATE statement: no callback, no result set m_writeAccessOngoing.fetch_add(1); - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, table, query, NULL, NULL, @@ -1848,7 +1848,7 @@ bool allowZero = false; sqlite3_free(zErrMsg); if (sqlite3_get_autocommit(dbHandle)==0) // transaction is still open, do rollback { - rc=SQLexec(dbHandle, + rc=SQLexec(dbHandle, table, "ROLLBACK TRANSACTION;", NULL, NULL, @@ -3085,7 +3085,7 @@ void Connection::logSQL(const char *tag, const char *stmt) * @param cbArg Callback 1st argument * @param errmsg Locaiton to write error message */ -int Connection::SQLexec(sqlite3 *db, const char *sql, int (*callback)(void*,int,char**,char**), +int Connection::SQLexec(sqlite3 *db, const string& table, const char *sql, int (*callback)(void*,int,char**,char**), void *cbArg, char **errmsg) { int retries = 0, rc; @@ -3129,7 +3129,7 @@ int interval; { int rc2; char *zErrMsg = NULL; - rc2=SQLexec(db, + rc2=SQLexec(db, table, "ROLLBACK TRANSACTION;", NULL, NULL, @@ -3171,11 +3171,11 @@ int interval; if (rc == SQLITE_LOCKED) { - Logger::getLogger()->error("Database still locked after maximum retries"); + Logger::getLogger()->error("Database still locked after maximum retries, executing %s operation on %s", operation(sql).c_str(), table.c_str()); } if (rc == SQLITE_BUSY) { - Logger::getLogger()->error("Database still busy after maximum retries"); + Logger::getLogger()->error("Database still busy after maximum retries, executing %s operation on %s", operation(sql).c_str(), table.c_str()); } return rc; @@ -3308,7 +3308,7 @@ SQLBuffer sql; // Exec the DELETE statement: no callback, no result set m_writeAccessOngoing.fetch_add(1); - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, table, query, NULL, NULL, @@ -3356,7 +3356,7 @@ int Connection::create_table_snapshot(const string& table, const string& id) logSQL("CreateTableSnapshot", query.c_str()); char* zErrMsg = NULL; - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, table, query.c_str(), NULL, NULL, @@ -3394,7 +3394,7 @@ int Connection::load_table_snapshot(const string& table, const string& id) logSQL("LoadTableSnapshot", query.c_str()); char* zErrMsg = NULL; - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, table, query.c_str(), NULL, NULL, @@ -3413,7 +3413,7 @@ int Connection::load_table_snapshot(const string& table, const string& id) // transaction is still open, do rollback if (sqlite3_get_autocommit(dbHandle) == 0) { - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, table, "ROLLBACK TRANSACTION;", NULL, NULL, @@ -3443,7 +3443,7 @@ int Connection::delete_table_snapshot(const string& table, const string& id) logSQL("DeleteTableSnapshot", query.c_str()); char* zErrMsg = NULL; - int rc = SQLexec(dbHandle, + int rc = SQLexec(dbHandle, table, query.c_str(), NULL, NULL, @@ -3546,7 +3546,7 @@ bool Connection::vacuum() { char* zErrMsg = NULL; // Exec the statement - int rc = SQLexec(dbHandle, "VACUUM;", NULL, NULL, &zErrMsg); + int rc = SQLexec(dbHandle, "database", "VACUUM;", NULL, NULL, &zErrMsg); // Check result if (rc != SQLITE_OK) @@ -3564,3 +3564,20 @@ bool Connection::vacuum() return true; } #endif + +/* + * Return the first word in a SQL statement, ie the operation that is being executed. + * + * @param sql The complete SQL statement + * @return string The operation + */ +string Connection::operation(const char *sql) +{ + const char *p1 = sql; + char buf[40], *p2 = buf; + while (*p1 && !isspace(*p1) && p2 - buf < 40) + *p2++ = *p1++; + *p2 = '\0'; + return string(buf); + +} diff --git a/C/plugins/storage/sqlitelb/common/include/connection.h b/C/plugins/storage/sqlitelb/common/include/connection.h index b2afe25279..703b457b2d 100644 --- a/C/plugins/storage/sqlitelb/common/include/connection.h +++ b/C/plugins/storage/sqlitelb/common/include/connection.h @@ -27,9 +27,9 @@ #define READINGS_TABLE "readings" #define READINGS_TABLE_MEM READINGS_TABLE -#define MAX_RETRIES 80 // Maximum no. of retries when a lock is encountered -#define RETRY_BACKOFF 100 // Multipler to backoff DB retry on lock -#define RETRY_BACKOFF_EXEC 1000 // Multipler to backoff DB retry on lock +#define MAX_RETRIES 80 // Maximum no. of retries when a lock is encountered +#define RETRY_BACKOFF 100 // Multipler to backoff DB retry on lock +#define RETRY_BACKOFF_EXEC 1000 // Multipler to backoff DB retry on lock #define LEN_BUFFER_DATE 100 #define F_TIMEH24_S "%H:%M:%S" @@ -131,7 +131,8 @@ class Connection { bool m_streamOpenTransaction; int m_queuing; std::mutex m_qMutex; - int SQLexec(sqlite3 *db, const char *sql, + std::string operation(const char *sql); + int SQLexec(sqlite3 *db, const std::string& table, const char *sql, int (*callback)(void*,int,char**,char**), void *cbArg, char **errmsg); int SQLstep(sqlite3_stmt *statement); diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index d72f13840a..5620dbbf40 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -1649,7 +1649,7 @@ unsigned int Connection::purgeReadings(unsigned long age, { char *zErrMsg = NULL; int rc; - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", "select max(rowid) from " READINGS_DB_NAME_BASE "." READINGS_TABLE ";", rowidCallback, &rowidLimit, @@ -1667,7 +1667,7 @@ unsigned int Connection::purgeReadings(unsigned long age, { char *zErrMsg = NULL; int rc; - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", "select min(rowid) from " READINGS_DB_NAME_BASE "." READINGS_TABLE ";", rowidCallback, &minrowidLimit, @@ -1697,7 +1697,7 @@ unsigned int Connection::purgeReadings(unsigned long age, int purge_readings = 0; // Exec query and get result in 'purge_readings' via 'selectCallback' - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", query, selectCallback, &purge_readings, @@ -1813,7 +1813,7 @@ unsigned int Connection::purgeReadings(unsigned long age, sqlBuffer.append(" hours');"); const char *query = sqlBuffer.coalesce(); - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", query, rowidCallback, &rowidLimit, @@ -1849,7 +1849,7 @@ unsigned int Connection::purgeReadings(unsigned long age, idBuffer.append(rowidLimit); idBuffer.append(';'); const char *idQuery = idBuffer.coalesce(); - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", idQuery, rowidCallback, &lastPurgedId, @@ -2060,7 +2060,7 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, int rc; sqlite3_stmt *stmt; sqlite3_stmt *idStmt; - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", "select count(rowid) from " READINGS_DB_NAME_BASE "." READINGS_TABLE ";", rowidCallback, &rowcount, @@ -2073,7 +2073,7 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, return 0; } - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", "select max(id) from " READINGS_DB_NAME_BASE "." READINGS_TABLE ";", rowidCallback, &maxId, @@ -2267,7 +2267,7 @@ unsigned int rowsAffected = 0; START_TIME; // Exec DELETE query: no callback, no resultset - rc = SQLexec(dbHandle, + rc = SQLexec(dbHandle, "readings", query, NULL, NULL, From 77eeeb9c77b0b5d77d67ec82d1f3ab92d49116c3 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 6 Oct 2023 15:31:11 +0100 Subject: [PATCH 486/499] FOGL-8120 Update default purge schedule. Note there is no schema change (#1189) as there are no upgrade/downgrade implicaitons in this change Signed-off-by: Mark Riddoch --- scripts/plugins/storage/postgres/init.sql | 2 +- scripts/plugins/storage/sqlite/init.sql | 2 +- scripts/plugins/storage/sqlitelb/init.sql | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index b013773496..c8e1a32c68 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -1094,7 +1094,7 @@ INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, 'purge', -- process_name 3, -- schedule_type (interval) NULL, -- schedule_time - '01:00:00', -- schedule_interval (evey hour) + '00:10:00', -- schedule_interval (evey hour) true, -- exclusive true -- enabled ); diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index a6c8e38ebd..ea55f9a235 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -850,7 +850,7 @@ INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, 'purge', -- process_name 3, -- schedule_type (interval) NULL, -- schedule_time - '01:00:00', -- schedule_interval (evey hour) + '00:10:00', -- schedule_interval (evey hour) 't', -- exclusive 't' -- enabled ); diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index bdafe7364b..b7c2adce2c 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -850,7 +850,7 @@ INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, 'purge', -- process_name 3, -- schedule_type (interval) NULL, -- schedule_time - '01:00:00', -- schedule_interval (evey hour) + '00:10:00', -- schedule_interval (evey hour) 't', -- exclusive 't' -- enabled ); From 1c49a4f33e558075253cba6703ab9abbda8c4543 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 10 Oct 2023 09:26:09 +0100 Subject: [PATCH 487/499] FOGL-8123 Add performance monitors to north service (#1173) * FOGL-8122 Remove stray character in north_c script that prevented valgrind usage Signed-off-by: Mark Riddoch * FOGL-8123 Add performance monitors to north service Signed-off-by: Mark Riddoch * Fix typo in comment and add block utilisation monitor Signed-off-by: Mark Riddoch * Add documentation Signed-off-by: Mark Riddoch * Add south storage counters and fix typo Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/services/north/data_load.cpp | 18 +++- C/services/north/data_send.cpp | 8 +- C/services/north/include/data_load.h | 3 + C/services/north/include/data_sender.h | 3 + C/services/north/include/north_service.h | 2 + C/services/north/north.cpp | 34 ++++++- docs/tuning_fledge.rst | 107 ++++++++++++++++++++++- 7 files changed, 169 insertions(+), 6 deletions(-) diff --git a/C/services/north/data_load.cpp b/C/services/north/data_load.cpp index 4457a9b365..77567dad13 100755 --- a/C/services/north/data_load.cpp +++ b/C/services/north/data_load.cpp @@ -26,7 +26,7 @@ static void threadMain(void *arg) */ DataLoad::DataLoad(const string& name, long streamId, StorageClient *storage) : m_name(name), m_streamId(streamId), m_storage(storage), m_shutdown(false), - m_readRequest(0), m_dataSource(SourceReadings), m_pipeline(NULL) + m_readRequest(0), m_dataSource(SourceReadings), m_pipeline(NULL), m_perfMonitor(NULL) { m_blockSize = DEFAULT_BLOCK_SIZE; @@ -157,6 +157,7 @@ void DataLoad::triggerRead(unsigned int blockSize) void DataLoad::readBlock(unsigned int blockSize) { ReadingSet *readings = NULL; +int n_waits = 0; do { @@ -193,6 +194,11 @@ ReadingSet *readings = NULL; Logger::getLogger()->debug("DataLoad::readBlock(): Got %d readings from storage client", readings->getCount()); m_lastFetched = readings->getLastId(); bufferReadings(readings); + if (m_perfMonitor) + { + m_perfMonitor->collect("No of waits for data", n_waits); + m_perfMonitor->collect("Block utilisation %", (readings->getCount() * 100) / blockSize); + } return; } else if (readings) @@ -208,6 +214,7 @@ ReadingSet *readings = NULL; { // TODO improve this this_thread::sleep_for(chrono::milliseconds(250)); + n_waits++; } } while (m_shutdown == false); } @@ -345,6 +352,15 @@ void DataLoad::bufferReadings(ReadingSet *readings) } unique_lock lck(m_qMutex); m_queue.push_back(readings); + if (m_perfMonitor) + { + m_perfMonitor->collect("Readings added to buffer", readings->getCount()); + m_perfMonitor->collect("Reading sets buffered", m_queue.size()); + long i = 0; + for (auto& set : m_queue) + i += set->getCount(); + m_perfMonitor->collect("Total readings buffered", i); + } Logger::getLogger()->debug("Buffered %d readings for north processing", readings->getCount()); m_fetchCV.notify_all(); } diff --git a/C/services/north/data_send.cpp b/C/services/north/data_send.cpp index 2651372da4..b6ad804052 100755 --- a/C/services/north/data_send.cpp +++ b/C/services/north/data_send.cpp @@ -29,7 +29,7 @@ static void startSenderThread(void *data) * Constructor for the data sending class */ DataSender::DataSender(NorthPlugin *plugin, DataLoad *loader, NorthService *service) : - m_plugin(plugin), m_loader(loader), m_service(service), m_shutdown(false), m_paused(false) + m_plugin(plugin), m_loader(loader), m_service(service), m_shutdown(false), m_paused(false), m_perfMonitor(NULL) { m_logger = Logger::getLogger(); @@ -123,9 +123,15 @@ void DataSender::sendThread() unsigned long DataSender::send(ReadingSet *readings) { blockPause(); + uint32_t to_send = readings->getCount(); uint32_t sent = m_plugin->send(readings->getAllReadings()); releasePause(); unsigned long lastSent = readings->getReadingId(sent); + if (m_perfMonitor) + { + m_perfMonitor->collect("Readings sent", sent); + m_perfMonitor->collect("Percentage readings sent", (100 * sent) / to_send); + } if (sent > 0) { diff --git a/C/services/north/include/data_load.h b/C/services/north/include/data_load.h index 389a16dd48..31cf5b8816 100644 --- a/C/services/north/include/data_load.h +++ b/C/services/north/include/data_load.h @@ -10,6 +10,7 @@ #include #include #include +#include #define DEFAULT_BLOCK_SIZE 100 @@ -47,6 +48,7 @@ class DataLoad : public ServiceHandler { { m_blockSize = blockSize; }; + void setPerfMonitor(PerformanceMonitor *perfMonitor) { m_perfMonitor = perfMonitor; }; private: void readBlock(unsigned int blockSize); @@ -77,5 +79,6 @@ class DataLoad : public ServiceHandler { FilterPipeline *m_pipeline; std::mutex m_pipelineMutex; unsigned long m_blockSize; + PerformanceMonitor *m_perfMonitor; }; #endif diff --git a/C/services/north/include/data_sender.h b/C/services/north/include/data_sender.h index ffd28ae76d..ef850a0871 100644 --- a/C/services/north/include/data_sender.h +++ b/C/services/north/include/data_sender.h @@ -7,6 +7,7 @@ #include #include #include +#include class DataLoad; class NorthService; @@ -19,6 +20,7 @@ class DataSender { void updatePlugin(NorthPlugin *plugin) { m_plugin = plugin; }; void pause(); void release(); + void setPerfMonitor(PerformanceMonitor *perfMonitor) { m_perfMonitor = perfMonitor; }; private: unsigned long send(ReadingSet *readings); void blockPause(); @@ -34,6 +36,7 @@ class DataSender { bool m_sending; std::mutex m_pauseMutex; std::condition_variable m_pauseCV; + PerformanceMonitor *m_perfMonitor; }; #endif diff --git a/C/services/north/include/north_service.h b/C/services/north/include/north_service.h index fc852fcde7..03c12ff936 100644 --- a/C/services/north/include/north_service.h +++ b/C/services/north/include/north_service.h @@ -19,6 +19,7 @@ #include #include #include +#include #define SERVICE_NAME "Fledge North" @@ -81,5 +82,6 @@ class NorthService : public ServiceAuthHandler { bool m_dryRun; bool m_requestRestart; AuditLogger *m_auditLogger; + PerformanceMonitor *m_perfMonitor; }; #endif diff --git a/C/services/north/north.cpp b/C/services/north/north.cpp index 326992a3b1..e9c81fb15e 100755 --- a/C/services/north/north.cpp +++ b/C/services/north/north.cpp @@ -290,7 +290,8 @@ NorthService::NorthService(const string& myName, const string& token) : m_allowControl(true), m_dryRun(false), m_requestRestart(), - m_auditLogger(NULL) + m_auditLogger(NULL), + m_perfMonitor(NULL) { m_name = myName; logger = new Logger(myName); @@ -302,6 +303,8 @@ NorthService::NorthService(const string& myName, const string& token) : */ NorthService::~NorthService() { + if (m_perfMonitor) + delete m_perfMonitor; if (northPlugin) delete northPlugin; if (m_storage) @@ -357,6 +360,7 @@ void NorthService::start(string& coreAddress, unsigned short corePort) northConfig.setDescription("North"); m_mgtClient->addCategory(northConfig, true); + // Fetch Configuration m_config = m_mgtClient->getCategory(m_name); if (!loadPlugin()) { @@ -402,7 +406,18 @@ void NorthService::start(string& coreAddress, unsigned short corePort) m_storage->registerManagement(m_mgtClient); - // Fetch Confguration + // Setup the performance monitor + m_perfMonitor = new PerformanceMonitor(m_name, m_storage); + + if (m_configAdvanced.itemExists("perfmon")) + { + string perf = m_configAdvanced.getValue("perfmon"); + if (perf.compare("true") == 0) + m_perfMonitor->setCollecting(true); + else + m_perfMonitor->setCollecting(false); + } + logger->debug("Initialise the asset tracker"); m_assetTracker = new AssetTracker(m_mgtClient, m_name); AssetTracker::getAssetTracker()->populateAssetTrackingCache(m_name, "Egress"); @@ -444,6 +459,7 @@ void NorthService::start(string& coreAddress, unsigned short corePort) } logger->debug("Create threads for stream %d", streamId); m_dataLoad = new DataLoad(m_name, streamId, m_storage); + m_dataLoad->setPerfMonitor(m_perfMonitor); if (m_config.itemExists("source")) { m_dataLoad->setDataSource(m_config.getValue("source")); @@ -460,6 +476,7 @@ void NorthService::start(string& coreAddress, unsigned short corePort) } } m_dataSender = new DataSender(northPlugin, m_dataLoad, this); + m_dataSender->setPerfMonitor(m_perfMonitor); if (!m_dryRun) { @@ -673,7 +690,7 @@ bool NorthService::loadPlugin() return true; } - } catch (exception e) { + } catch (exception &e) { logger->fatal("Failed to load north plugin: %s\n", e.what()); } return false; @@ -784,6 +801,14 @@ void NorthService::configChange(const string& categoryName, const string& catego m_dataLoad->setBlockSize(newBlock); } } + if (m_configAdvanced.itemExists("perfmon")) + { + string perf = m_configAdvanced.getValue("perfmon"); + if (perf.compare("true") == 0) + m_perfMonitor->setCollecting(true); + else + m_perfMonitor->setCollecting(false); + } } // Update the Security category @@ -887,6 +912,9 @@ void NorthService::addConfigDefaults(DefaultConfigCategory& defaultConfig) std::to_string(DEFAULT_BLOCK_SIZE), std::to_string(DEFAULT_BLOCK_SIZE)); defaultConfig.setItemDisplayName("blockSize", "Data block size"); + defaultConfig.addItem("perfmon", "Track and store performance counters", + "boolean", "false", "false"); + defaultConfig.setItemDisplayName("perfmon", "Performance Counters"); } /** diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index 29376bc504..4b2e17f224 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -59,7 +59,7 @@ The south services within Fledge each have a set of advanced configuration optio - *Fixed Times* polling will issue poll requests at fixed times that are defined by a set of hours, minutes and seconds. These times are defined in the local time zone of the machine that is running the Fledge instance. - - *On Demand* polling will not perform any regular polling, instead it will wait for a control operation to be sent to the service. That operation is named *polll* and takes no arguments. This allow a poll to be trigger by the control mechanisms from notifications, schedules, north services or API requests. + - *On Demand* polling will not perform any regular polling, instead it will wait for a control operation to be sent to the service. That operation is named *poll* and takes no arguments. This allow a poll to be trigger by the control mechanisms from notifications, schedules, north services or API requests. - *Hours* - This defines the hours when a poll request will be made. The hours are expressed using the 24 hour clock, with poll requests being made only when the current hour matches one of the hours in the coma separated list of hours. If the *Hours* field is left blank then poll will be issued during every hour of the day. @@ -81,6 +81,61 @@ The south services within Fledge each have a set of advanced configuration optio The *Statistics Collection* setting will not remove any existing statistics, these will remain and remain to be represented in the statistics history. This only impacts new values that are collected. It is recommended that this be set before a service is started for the first time if the desire it to have no statistics values recorded for either assets or the service. + + - *Performance Counters* - This option allows for the collection of performance counters that can be used to help tune the south service. + +Performance Counters +-------------------- + +A number of performance counters can be collected in the south service to help characterise the performance of the service. This is intended to provide input into the tuning of the service and the collection of these counters should not be left on during production use of the service. + +Performance counters are collected in the service and a report is written once per minute to the storage layer for later retrieval. The values written are + + - The minimum value of the counter observed within the current minute + + - The maximum value of the counter observed within the current minute + + - The average value of the counter observed within the current minute + + - The number of samples of the counter collected within the current minute + +In the current release the performance counters can only be retrieved by director access to the configuration and statistics database, they are stored in the *monitors* table. Future releases will include tools for the retrieval and analysis of these performance counters. + +When collection is enabled the following counters will be collected for the south service that is enabled. + +.. list-table:: + :widths: 15 30 55 + :header-rows: 1 + + * - Counter + - Description + - Causes & Remedial Actions + * - queueLength + - The total number of readings that have been queued within the south service for sending to the storage service. + - Large queues in the south service will mean that the service will have a larger than normal footprint but may not be an issue in itself. However if the queue size grows continuously then there will eventually be a memory allocation failure in the south service. Turning on throttling of the ingest rate will reduce the data that is added to the queue and may be enough to resole the problem, however data will be collected at a reduced rate. A faster storage plugin, perhaps using an in-memory storage engine may be another solution. If your instance has many south services it may be worth considering splitting the south services between multiple instances. + * - ingestCount + - The number of readings ingested in each plugin interaction. + - The counter reflects the number of readings that are returned for each call to the south plugin poll entry point or by the south plugin ingest asynchronous call. Typically this number should be moderately low, if very large numbers are returned in a single call it will result in very large queues building up within the south service and the performance of the system will be degraded with large burst of data that possibly overwhelm other layers interspersed with periods of inactivity. Ideally the peaks should be eliminated and the rate kept 'flat' in order to make the best use of the system. Consider altering the configuration of the south plugin such that it returns less data but more frequently. + * - readLatency + - The longest time a reading has spent in the queue between being returned by the south plugin and sent to the storage layer. + - This counter describes how long, in milliseconds, the oldest reading waiting in the internal south service queue before being sent to the storage layer. This should be less than or equal to the define maximum latency, it may be a little over to allow for queue management times, but should not be significantly higher. If it is significantly higher for long periods of time it would indicate that the storage service is unable to handle the load that is being placed upon it. It may be possible that by tuning the storage layer, changing t a higher performance plugin or one that is better suited to your workload, may resolve the problem. Alternatively consider reducing the load by splitting the south services across multiple Fledge instances. + * - flow controlled + - The number of times the reading rate has been reduced due to excessive queues building up in the south service. + - This is closely related to the queuLength counter and has much the same set of actions that should be taken if the service is frequently flow controlled. Reducing the ingest rate, or adding filtering in the pipeline to reduce the amount of data passed onward to the storage service may alleviate the problem. In general if processing can be done that reduces high bandwidth data into lower bandwidth data that can still characterise the high bandwidth content, then this should be done as close as possible to the source of the data to reduce the overall load on the system. + * - throttled rate + - The rate that data is being ingested at as a result of flow control throttling. + - This counter is more for information as to what might make a reasonable ingest rate the system can sustain with the current configuration. It is useful as it gives a good idea of how far away from your desired performance the current configuration of the system is currently + * - storedReadings + - The readings successfully sent to the storage layer. + - This counter gives an indication of the bandwidth available from the service to the storage engine. This should be at least as high as the ingest rate if data is not to accumulate in buffers within the storage. Altering the maximum latency and maximum buffered readings advanced settings in the south server can impact this throughput. + * - resendQueued + - The number of readings queued for resend. Note that readings may be queued for resend multiple times if the resend also failed. + - This is a good indication of overload conditions within the storage engine. Consistent high values of this counter point to the need to improve the performance of the storage layer. + * - removedReadings + - A count of the readings that have been removed after too many attempts to save them in the storage layer. + - This should normally be zero or close to zero. Any significant values here are a pointer to a critical error with either the south plugin data that is being created or the operation of the storage layer. + + Fixed Time Polling ------------------ @@ -126,6 +181,56 @@ In a similar way to the south services, north services and tasks also have advan - *Data block size* - This defines the number of readings that will be sent to the north plugin for each call to the *plugin_send* entry point. This allows the performance of the north data pipeline to be adjusted, with larger blocks sizes increasing the performance, by reducing overhead, but at the cost of requiring more memory in the north service or task to buffer the data as it flows through the pipeline. Setting this value too high may cause issues for certain of the north plugins that have limitations on the number of messages they can handle within a single block. + - *Performance Counters* - This option allows for collection of performance counters that can be use to help tune the north service. + +Performance Counters +-------------------- + +A number of performance counters can be collected in the north service to help characterise the performance of the service. This is intended to provide input into the tuning of the service and the collection of these counters should not be left on during production use of the service. + +Performance counters are collected in the service and a report is written once per minute to the storage layer for later retrieval. The values written are + + - The minimum value of the counter observed within the current minute + + - The maximum value of the counter observed within the current minute + + - The average value of the counter observed within the current minute + + - The number of samples of the counter collected within the current minute + +In the current release the performance counters can only be retrieved by director access to the configuration and statistics database, they are stored in the *monitors* table. Future releases will include tools for the retrieval and analysis of these performance counters. + +When collection is enabled the following counters will be collected for the south service that is enabled. + +.. list-table:: + :widths: 15 30 55 + :header-rows: 1 + + * - Counter + - Description + - Causes & Remedial Actions + * - No of waits for data + - This counter reports how many times the north service requested data from storage and no data was available. + - If this value is consistently low or zero it indicates the other services are providing data faster than the north service is able to send that data. Improving the throughput of the north service would be advisable to prevent the accumulation of unsent data in the storage service. + * - Block utilisation % + - Data is read by the north service in blocks, the size of this blocks is defined in the advanced configuration of the north service. This counter reflects what percentage of the requested blocks are actually populated with data on each call to the storage service. + - A constantly high utilisation is an indication that more data is available than can be sent, increasing the block size may improve this situation and allow for a high throughput. + * - Reading sets buffered + - This is a counter of the number of blocks that are waiting to be sent in the north service + - if this figure is more than a couple of blocks it is an indication that the north plugin is failing to sent complete blocks of data and that partial blocks are failing. Reducing the block size may improve the situation and reduce the amount of storage required in the north service. + * - Total readings buffered + - This is a count of the total number of readings buffered within the north service. + - This should be equivalent to 2 or 3 blocks size worth of readings. If it is high then it is an indication that the north plugin is not able to sustain a high enough data rate to match the ingest rates of the system. + * - Readings sent + - This gives an indication, for each block, how many readings are sent in the block. + - This should typically match the blocks read, if not it is an indication of failures to send data by the north plugin. + * - Percentage readings sent + - Closely related to the above the s the percentage of each block read that was actually sent. + - In a well tuned system this figure should be close to 100%, if it is not then it may be that the north plugin is failing to send data, possibly because of an issue in an upstream system. Alternatively the block size may be too high for the upstream system to handle and reducing the block size will bring this value closer to 100%. + * - Readings added to buffer + - An absolute count of the number of readings read into each block. + - If this value is significantly less than the block size it is an indication that the block size can be lowered. If it is always close to the block size then consider increasing the block size. + Health Monitoring ================= From f0b1c5ed44c7cdc4db3fdbc4f5cd35fd204c368f Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 11 Oct 2023 14:35:42 +0100 Subject: [PATCH 488/499] FOGL-8170 Add storage related counters (#1192) Signed-off-by: Mark Riddoch --- C/services/south/ingest.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 095d8f65fd..e1e876c096 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -570,6 +570,7 @@ void Ingest::processQueue() q->erase(q->begin()); logDiscardedStat(); } + m_performance->collect("removedFromQueue", 5); if (q->size() == 0) { delete q; @@ -581,6 +582,7 @@ void Ingest::processQueue() else { + m_performance->collect("storedReadings", (long int)(q->size())); if (m_storageFailed) { m_logger->warn("Storage operational after %d failures", m_storesFailed); @@ -810,12 +812,14 @@ void Ingest::processQueue() m_logger->warn("Failed to write readings to storage layer, queue for resend"); m_storageFailed = true; m_storesFailed++; + m_performance->collect("resendQueued", (long int)(m_data->size())); m_resendQueues.push_back(m_data); m_data = NULL; m_failCnt = 1; } else { + m_performance->collect("storedReadings", (long int)(m_data->size())); if (m_storageFailed) { m_logger->warn("Storage operational after %d failures", m_storesFailed); From 8ecc5dd4998f9a8ac1973f753e619732a84c0e54 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 12 Oct 2023 14:46:03 +0100 Subject: [PATCH 489/499] FOGL-8181 Improve performance monitor cleanup (#1194) Signed-off-by: Mark Riddoch --- C/services/common/perfmonitor.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/C/services/common/perfmonitor.cpp b/C/services/common/perfmonitor.cpp index 2612d2edaf..4f40cfc79e 100644 --- a/C/services/common/perfmonitor.cpp +++ b/C/services/common/perfmonitor.cpp @@ -84,6 +84,18 @@ PerformanceMonitor::PerformanceMonitor(const string& service, StorageClient *sto */ PerformanceMonitor::~PerformanceMonitor() { + if (m_collecting) + { + setCollecting(false); + } + // Write thread has now been stopped or + // was never running + for (const auto& it : m_monitors) + { + string name = it.first; + PerfMon *mon = it.second; + delete mon; + } } /** @@ -114,6 +126,7 @@ void PerformanceMonitor::setCollecting(bool state) // Stop the thread to write the monitors to the database m_cv.notify_all(); m_thread->join(); + delete m_thread; m_thread = NULL; } } From 74bae6b6a525c59778887ef5efe43b6c5e0da8f9 Mon Sep 17 00:00:00 2001 From: pintomax Date: Fri, 13 Oct 2023 13:03:55 +0200 Subject: [PATCH 490/499] FOGL-8087: fix for statistics rate in Postgres (#1187) FOGL-8087: fix for statistics rate in Postgres Also fixes FOGL-4102 Added C++ fix to history_ts field in WHERE clause --- C/plugins/storage/postgres/connection.cpp | 18 +++++++++++++----- python/fledge/services/core/api/statistics.py | 5 ----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index 837fe36b6d..f9627faf96 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -2512,10 +2512,10 @@ bool Connection::jsonAggregates(const Value& payload, jsonConstraint.append("'"); sql.append(")::float"); - + sql.append(" END)"); } } - sql.append(" END) AS \""); + sql.append(") AS \""); if (itr->HasMember("alias")) { sql.append((*itr)["alias"].GetString()); @@ -2989,9 +2989,17 @@ bool Connection::jsonWhereClause(const Value& whereClause, sql.append(whereClause["value"].GetInt()); } else if (whereClause["value"].IsString()) { - sql.append('\''); - sql.append(escape(whereClause["value"].GetString())); - sql.append('\''); + if (whereColumnName.compare("history_ts") == 0) { + sql.append("to_timestamp("); + sql.append(whereClause["value"].GetString()); + sql.append(')'); + } + else + { + sql.append('\''); + sql.append(escape(whereClause["value"].GetString())); + sql.append('\''); + } } } } diff --git a/python/fledge/services/core/api/statistics.py b/python/fledge/services/core/api/statistics.py index a59b408e01..391bd8f451 100644 --- a/python/fledge/services/core/api/statistics.py +++ b/python/fledge/services/core/api/statistics.py @@ -207,11 +207,6 @@ async def get_statistics_rate(request: web.Request) -> web.Response: resp = [] for x, y in [(x, y) for x in period_split_list for y in stat_split_list]: time_diff = datetime.datetime.utcnow().astimezone() - datetime.timedelta(minutes=int(x)) - """FIXME: FOGL-4102 once resolved - ERROR: PostgreSQL storage plugin raising error: ERROR: invalid input syntax for type timestamp with - time zone: "1590066814.037321" - "where": {"column": "history_ts", "condition": ">=", "value": "1590066814.037321"} - Therefore, Payload works Only with sqlite engine BUT not with PostgreSQL""" _payload = PayloadBuilder().SELECT("key").AGGREGATE(["sum", "value"]).WHERE( ['history_ts', '>=', str(time_diff.timestamp())]).AND_WHERE(['key', '=', y]).chain_payload() From db19358ea332a2407fb958c2f5c5866b18ca470e Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Mon, 16 Oct 2023 09:50:58 +0100 Subject: [PATCH 491/499] FOGL-8190 Temporarily revert to statistics collection per asset and (#1196) service as the default for south services Signed-off-by: Mark Riddoch --- C/services/south/south.cpp | 2 +- docs/tuning_fledge.rst | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index a7cf318572..c105e46b7f 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -1200,7 +1200,7 @@ void SouthService::addConfigDefaults(DefaultConfigCategory& defaultConfig) /* Add the set of logging levels to the service */ vector statistics = { "per asset", "per service", "per asset & service" }; defaultConfig.addItem("statistics", "Collect statistics either for every asset ingested, for the service in total or both", - "per service", "per service", statistics); + "per asset & service", "per asset & service", statistics); defaultConfig.setItemDisplayName("statistics", "Statistics Collection"); defaultConfig.addItem("perfmon", "Track and store performance counters", "boolean", "false", "false"); diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index 4b2e17f224..0fc0483072 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -75,12 +75,15 @@ The south services within Fledge each have a set of advanced configuration optio | |stats_options| | +-----------------+ - The *per asset & per service* setting will collect one statistic per asset ingested and an overall statistic for the entire service. The *per service* option just collects the overall service ingest statistics and the *per asset* option just collects the statistics for each asset and not for the entire service. The default is to collect statistics on a per service basis, use of the per asset or the per asset and service options should be limited to south service that collect a relatively small number of distinct assets. Collecting large number of statistics, for 1000 or more distinct assets will have a significant performance overhead and may overwhelm less well provisioned Fledge instances. + The *per asset & per service* setting will collect one statistic per asset ingested and an overall statistic for the entire service. The *per service* option just collects the overall service ingest statistics and the *per asset* option just collects the statistics for each asset and not for the entire service. The default is to collect statistics on a per asset & service basis, this is not the best setting if large numbers of distinct assets are ingested by a single south service. Use of the per asset or the per asset and service options should be limited to south service that collect a relatively small number of distinct assets. Collecting large number of statistics, for 1000 or more distinct assets will have a significant performance overhead and may overwhelm less well provisioned Fledge instances. When a large number of assets are ingested by a single south service this value should be set to *per service*. -.. note:: + .. note:: + + The *Statistics Collection* setting will not remove any existing statistics, these will remain and remain to be represented in the statistics history. This only impacts new values that are collected. It is recommended that this be set before a service is started for the first time if the desire it to have no statistics values recorded for either assets or the service. - The *Statistics Collection* setting will not remove any existing statistics, these will remain and remain to be represented in the statistics history. This only impacts new values that are collected. It is recommended that this be set before a service is started for the first time if the desire it to have no statistics values recorded for either assets or the service. + .. note:: + If the *per service* option is used then the UI page that displays the south services will not show the asset names and counts for each of the assets that are ingested by that service. - *Performance Counters* - This option allows for the collection of performance counters that can be used to help tune the south service. From 1156ace5dce4b6c5dcd228b022c0c35eca6df51b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 16 Oct 2023 18:11:02 +0530 Subject: [PATCH 492/499] statistics rate API query fixes Signed-off-by: ashish-jabble --- python/fledge/services/core/api/statistics.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/python/fledge/services/core/api/statistics.py b/python/fledge/services/core/api/statistics.py index 391bd8f451..b0da9ff5ff 100644 --- a/python/fledge/services/core/api/statistics.py +++ b/python/fledge/services/core/api/statistics.py @@ -159,8 +159,8 @@ async def get_statistics_rate(request: web.Request) -> web.Response: (sum(value) / ((60 * period) / stats_collector_interval)) For example: If stats_collector_interval set to 15 seconds then - a) For a 1 minute period should take 4 statistics history values, sum those and then divide by 4 - b) For a 5 minute period should take 20 statistics history values, sum those and then divide by 20 + a) For a 1 minute period should take 4 statistics history values, sum those and then divide by period + b) For a 5 minute period should take 20 statistics history values, sum those and then divide by period Args: request: Returns: @@ -206,16 +206,20 @@ async def get_statistics_rate(request: web.Request) -> web.Response: raise web.HTTPNotFound(reason="No stats collector schedule found") resp = [] for x, y in [(x, y) for x in period_split_list for y in stat_split_list]: - time_diff = datetime.datetime.utcnow().astimezone() - datetime.timedelta(minutes=int(x)) - - _payload = PayloadBuilder().SELECT("key").AGGREGATE(["sum", "value"]).WHERE( - ['history_ts', '>=', str(time_diff.timestamp())]).AND_WHERE(['key', '=', y]).chain_payload() - stats_rate_payload = PayloadBuilder(_payload).GROUP_BY("key").payload() + # Get value column as per given key along with history_ts column order by + _payload = PayloadBuilder().SELECT("value").WHERE(['key', '=', y]).ORDER_BY(["history_ts", "desc"] + ).chain_payload() + # LIMIT set to ((60 * period) / stats_collector_interval)) + calculated_formula = int((60 * int(x) / int(interval_in_secs))) + stats_rate_payload = PayloadBuilder(_payload).LIMIT(calculated_formula).payload() result = await storage_client.query_tbl_with_payload("statistics_history", stats_rate_payload) temp_dict = {y: {x: 0}} if result['rows']: - calculated_formula_str = (int(result['rows'][0]['sum_value']) / ((60 * int(x)) / int(interval_in_secs))) - temp_dict = {y: {x: calculated_formula_str}} + row_sum = 0 + values = [r['value'] for r in result['rows']] + for v in values: + row_sum += v + temp_dict = {y: {x: row_sum / int(x)}} resp.append(temp_dict) rate_dict = {} for d in resp: From 4ab0f576ad5a074fef40cd08020e25f2781a613b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 16 Oct 2023 18:11:24 +0530 Subject: [PATCH 493/499] statistics rate unit tests updated Signed-off-by: ashish-jabble --- .../services/core/api/test_statistics_api.py | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/tests/unit/python/fledge/services/core/api/test_statistics_api.py b/tests/unit/python/fledge/services/core/api/test_statistics_api.py index a5f428cb5d..fe93989a48 100644 --- a/tests/unit/python/fledge/services/core/api/test_statistics_api.py +++ b/tests/unit/python/fledge/services/core/api/test_statistics_api.py @@ -493,35 +493,41 @@ async def test_bad_get_statistics_rate(self, client, params, msg): assert msg == resp.reason async def test_get_statistics_rate(self, client, params='?periods=1,5&statistics=READINGS'): - output = {'rates': {'READINGS': {'1': 24180.5, '5': 4836.1}}} - p1 = {'where': {'value': 'stats collector', 'condition': '=', 'column': 'process_name'}, - 'return': ['schedule_interval']} - p2 = {"return": ["key"], "aggregate": [{"operation": "sum", "column": "value"}], - "where": {"column": "history_ts", "condition": ">=", "value": "1684995048.726104", - "and": {"column": "key", "condition": "=", "value": "READINGS"}}, "group": "key"} - p3 = {"return": ["key"], "aggregate": [{"operation": "sum", "column": "value"}], - "where": {"column": "history_ts", "condition": ">=", "value": "1684994808.726297", - "and": {"column": "key", "condition": "=", "value": "READINGS"}}, "group": "key"} - - @asyncio.coroutine - def q_result(*args): - table = args[0] - payload = args[1] - if table == 'schedules': - assert p1 == json.loads(payload) - return {"rows": [{"schedule_interval": "00:00:15"}]} - - if table == 'statistics_history': - # TODO: datetime patch required which is a bit tricky - # assert p2 == json.loads(payload) - return {"rows": [{'sum_value': 96722, 'count_value': 3210, "key": "READINGS"}], "count": 1} + output = {'rates': {'READINGS': {'1': 45.0, '5': 9.0}}} + p1 = ({"where": {"value": "stats collector", "condition": "=", "column": "process_name"}, + "return": ["schedule_interval"]}) + p2 = {"return": ["value"], "where": {"column": "key", "condition": "=", "value": "READINGS"}, + "sort": {"column": "history_ts", "direction": "desc"}, "limit": 4} + p3 = {"return": ["value"], "where": {"column": "key", "condition": "=", "value": "READINGS"}, + "sort": {"column": "history_ts", "direction": "desc"}, "limit": 20} + + async def async_mock(return_value): + return return_value + + storage_rows = {"rows": [{"value": 15}, {"value": 10}, {"value": 5}, {"value": 15}], "count": 4} + if sys.version_info.major == 3 and sys.version_info.minor >= 8: + _rv1 = await async_mock({"rows": [{"schedule_interval": "00:00:15"}]}) + _rv2 = await async_mock(storage_rows) + else: + _rv1 = asyncio.ensure_future(async_mock({"rows": [{"schedule_interval": "00:00:15"}]})) + _rv2 = asyncio.ensure_future(async_mock(storage_rows)) mock_async_storage_client = MagicMock(StorageClientAsync) with patch.object(connect, 'get_storage_async', return_value=mock_async_storage_client): - with patch.object(mock_async_storage_client, 'query_tbl_with_payload', side_effect=q_result) as query_patch: + with patch.object(mock_async_storage_client, 'query_tbl_with_payload', + side_effect=[_rv1, _rv2, _rv2]) as query_patch: resp = await client.get("/fledge/statistics/rate{}".format(params)) assert 200 == resp.status r = await resp.text() assert output == json.loads(r) assert query_patch.called assert 3 == query_patch.call_count + args, _ = query_patch.call_args_list[0] + assert 'schedules' == args[0] + assert p1 == json.loads(args[1]) + args, _ = query_patch.call_args_list[1] + assert 'statistics_history' == args[0] + assert p2 == json.loads(args[1]) + args, _ = query_patch.call_args_list[2] + assert 'statistics_history' == args[0] + assert p3 == json.loads(args[1]) From aa3ec96afd902884c00bef537f2f0f1401cea7df Mon Sep 17 00:00:00 2001 From: Ashish Jabble Date: Tue, 17 Oct 2023 09:39:27 +0530 Subject: [PATCH 494/499] Revert "FOGL-8087: fix for statistics rate in Postgres (#1187)" This reverts commit 74bae6b6a525c59778887ef5efe43b6c5e0da8f9. --- C/plugins/storage/postgres/connection.cpp | 18 +++++------------- python/fledge/services/core/api/statistics.py | 5 +++++ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index f9627faf96..837fe36b6d 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -2512,10 +2512,10 @@ bool Connection::jsonAggregates(const Value& payload, jsonConstraint.append("'"); sql.append(")::float"); - sql.append(" END)"); + } } - sql.append(") AS \""); + sql.append(" END) AS \""); if (itr->HasMember("alias")) { sql.append((*itr)["alias"].GetString()); @@ -2989,17 +2989,9 @@ bool Connection::jsonWhereClause(const Value& whereClause, sql.append(whereClause["value"].GetInt()); } else if (whereClause["value"].IsString()) { - if (whereColumnName.compare("history_ts") == 0) { - sql.append("to_timestamp("); - sql.append(whereClause["value"].GetString()); - sql.append(')'); - } - else - { - sql.append('\''); - sql.append(escape(whereClause["value"].GetString())); - sql.append('\''); - } + sql.append('\''); + sql.append(escape(whereClause["value"].GetString())); + sql.append('\''); } } } diff --git a/python/fledge/services/core/api/statistics.py b/python/fledge/services/core/api/statistics.py index 391bd8f451..a59b408e01 100644 --- a/python/fledge/services/core/api/statistics.py +++ b/python/fledge/services/core/api/statistics.py @@ -207,6 +207,11 @@ async def get_statistics_rate(request: web.Request) -> web.Response: resp = [] for x, y in [(x, y) for x in period_split_list for y in stat_split_list]: time_diff = datetime.datetime.utcnow().astimezone() - datetime.timedelta(minutes=int(x)) + """FIXME: FOGL-4102 once resolved + ERROR: PostgreSQL storage plugin raising error: ERROR: invalid input syntax for type timestamp with + time zone: "1590066814.037321" + "where": {"column": "history_ts", "condition": ">=", "value": "1590066814.037321"} + Therefore, Payload works Only with sqlite engine BUT not with PostgreSQL""" _payload = PayloadBuilder().SELECT("key").AGGREGATE(["sum", "value"]).WHERE( ['history_ts', '>=', str(time_diff.timestamp())]).AND_WHERE(['key', '=', y]).chain_payload() From 364a84c8eea5a8e20b27b80aba92d0c91d611017 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 17 Oct 2023 12:03:15 +0530 Subject: [PATCH 495/499] skip annotation removed from API tests as per FOGL-7960 Signed-off-by: ashish-jabble --- .../api/test_endpoints_with_different_user_types.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/system/python/api/test_endpoints_with_different_user_types.py b/tests/system/python/api/test_endpoints_with_different_user_types.py index 97312313cd..a0180372f8 100644 --- a/tests/system/python/api/test_endpoints_with_different_user_types.py +++ b/tests/system/python/api/test_endpoints_with_different_user_types.py @@ -225,10 +225,6 @@ def test_login(self, fledge_url, wait_time): ("DELETE", "/fledge/notification/N1/delivery/C1", 403) ]) def test_endpoints(self, fledge_url, method, route_path, http_status_code, storage_plugin): - # FIXME: Once below JIRA is resolved - if storage_plugin == 'postgres': - if route_path == '/fledge/statistics/rate?periods=1&statistics=FOO': - pytest.skip('Due to FOGL-7097') conn = http.client.HTTPConnection(fledge_url) conn.request(method, route_path, headers={"authorization": TOKEN}) r = conn.getresponse() @@ -379,10 +375,6 @@ def test_login(self, fledge_url, wait_time): ("DELETE", "/fledge/notification/N1/delivery/C1", 403) ]) def test_endpoints(self, fledge_url, method, route_path, http_status_code, storage_plugin): - # FIXME: Once below JIRA is resolved - if storage_plugin == 'postgres': - if route_path == '/fledge/statistics/rate?periods=1&statistics=FOO': - pytest.skip('Due to FOGL-7097') conn = http.client.HTTPConnection(fledge_url) conn.request(method, route_path, headers={"authorization": TOKEN}) r = conn.getresponse() @@ -538,10 +530,6 @@ def test_login(self, fledge_url, wait_time): ("DELETE", "/fledge/notification/N1/delivery/C1", 404) ]) def test_endpoints(self, fledge_url, method, route_path, http_status_code, storage_plugin): - # FIXME: Once below JIRA is resolved - if storage_plugin == 'postgres': - if route_path == '/fledge/statistics/rate?periods=1&statistics=FOO': - pytest.skip('Due to FOGL-7097') conn = http.client.HTTPConnection(fledge_url) conn.request(method, route_path, headers={"authorization": TOKEN}) r = conn.getresponse() From 7656f21de6856fbc1e70a93d3a770d31ea619252 Mon Sep 17 00:00:00 2001 From: dianomicbot Date: Tue, 17 Oct 2023 10:31:22 +0000 Subject: [PATCH 496/499] VERSION changed Signed-off-by: dianomicbot --- VERSION | 2 +- docs/91_version_history.rst | 234 ++++++++++++++++++++++++++++++++++++ docs/conf.py | 2 +- 3 files changed, 236 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 78a7ad3f9d..ed70b8404e 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ -fledge_version=2.1.0 +fledge_version=2.2.0 fledge_schema=66 diff --git a/docs/91_version_history.rst b/docs/91_version_history.rst index 14c0d9d4f4..41d06135e1 100644 --- a/docs/91_version_history.rst +++ b/docs/91_version_history.rst @@ -25,6 +25,240 @@ Version History Fledge v2 ========== +v2.2.0 +------- + +Release Date: 2023-10-17 + +- **Fledge Core** + + - New Features: + + - New performance monitors have been added to the north service and the running guide updated to discuss how these can be used. + - The default interval for running the purge process has been reduced, the purge process will not run every 5 minutes. This chance only affects new installations, the purge process will run as before on systems that are upgraded. + - A change has been made to prevent the schedules used to start services from being renamed as this could cause the services to fail. + - The table registration mechanism has been extended to allow services to register for delete operations. + - The public API has been updated to include the ability to make control requests via the public API. + - The support bundle creation process has been updated to include any performance counters available in the system. + - The ability to monitor performance counters has been added to Fledge. Currently only south services offer performance counters that can be captured by the system. These are designed to provide information useful for tuning the south service. + - The audit log entries have been updated to include more information when schedules are updated. + - New audit logs have been added to reflect the creation, update and deletion of access control lists. + - The reporting of issues related to failure to load plugins has been improved. + - A new user role has been added for those users able to update the control features of the platform. + - The public API of the system has been updated to allow selection of readings from the storage buffer for given time intervals. + - The asset tracker component has been optimised in order to improve the ingress and egress performance of Fledge + - The process used to extract log information from the system logs has been updated to improve performance and reduce the system overhead required to extract log data. + - The mechanism used by the south and north services to interact with the audit log has been optimised. This improves the ingress and egress performance of the product at the cost of a small delay before the audit log is updated. + - The ingestion of data from asynchronous south service paid no attention to the advanced configuration option \Throttle\. This meant that very fast asynchronous south plugins could build extremely large queues of data within the south service, using system resources and taking a long time to shutdown. This has now been rectified, with asynchronous south services now be subject to flow control if the 'Throttle \ option is set for the service. Unconstrained input is still available if the \Throttle\ option is not checked. + - A security vulnerability in one of the PIP packages used by some of the Python components used by the system. + - A numeric list data type has been added to the reading ingestion code of the system. + - Audit logs have been add to the user API of the public REST interface. + - An issue which could occasionally result in the bearer token used for authentication between the various services expiring before the completion of the renewal process has been resolved. This could result in the failure of services to communicate with each other. + - Support has been added to allow filters to ingest pass data onwards during a shutdown of the filter. This allows any buffered data to be flushed to the next filter in the pipeline. + - When upgrading the version of a plugin any new configuration items are added to the relevant configuration categories. However the operation was not correctly reported as a configuration change in the audit log. This behaviour has now been corrected. + - New public API Entry Points have been added to allow for the creation and manipulation of control pipelines. + - The public API that is used to retrieve readings data from the storage layer has been updated to allow data for multiple assets to be retrieved in a single call. + - A memory leak in one of the storage plugins has been fixed. This caused the storage service to consume large amounts of memory over time which could result in the operating system killing the service. + - The format of Python back traces has been improved to use multiple lines within the log. This makes the trace easier to understand and prevents the truncation that can occur. + - The setting of log levels fro a service is now also reflected in any Python code loaded by the service. + - The plugin developers guide has been updated to include the mechanism for adding audit trail entries from C++ plugins. + - Plugins that run within the south and north services and north tasks now have access to the audit logging system. + - The documentation regarding handling and updating certificates used for authentication has been updated. + - A number of changes have been made to improve the performance of sending data north from the system. + - The performance of the statistics history task has been improved. It now makes fewer calls to the storage subsystem, improving the overall system performance. + - Changes have been made to the purge process in the sqlitelb and sqliteinmemory plugins in order to improve performance. + - The south plugin now supports three different modes of polling. Polling at fixed intervals from the time started, polling at fixed times or polling on demand via the control mechanisms. + - The support bundle has been updated to include a list of the Python packages installed on the machine. + - The configuration category C++ API has been enhanced in the retrieval and setting of all the attributes of a configuration item. + - The storage service has been updated to allow other services to subscribe the notifications of inserts into the generic tables. + - A change has been made to the configuration of the storage plugin such that rather than having to type correct names for storage plugins the user may now select the plugins to use from a drop down list. Note however that the system must still be restarted for the new storage plugin to take effect. + - A number of optimisations have been made to improve the performance of Python filters within a pipeline. + + + - Bug Fix: + + - An issue that cause cause log messages to not be recorded has been resolved. + - A problem with statistics counter overflow that could cause a crash in the statistics collector has been resolved. + - An issue that could cause the statistics to be displayed with timestamp in the wrong timezone has been resolved. + - An issue with memory usage in Python plugins used in south services has been resolved. + - A problem with converting very long integers from JSON has been resolved. This would have manifested itself as a crash when handling datapoints that contain 64 bit integers above a certain value. + - A number of issues regarding the usage of memory have been resolved, including some small memory leaks. The overall memory footprint of north services should also be reduced in some circumstances. + - The performance of the asset tracker system has been improved, resulting in an improvement in the ingress performance of the system. + - The Python south plugin mechanism has been updated to fix an issue with ingestion of nested data point values. + - A bug in the statistics rate API that would result in incorrect data being returned has been fixed. + - An issue that required the north service to be restarted if the source of data to send was changed in a running services has been resolved. Changing the data source no longer requires a restart of the north service. + - A bug that prevents notification rules being executed for readings with asset codes starting with numeric values has been resolved. + - A problem with incorrect timestamps in the readings graphs when the timezone of the machine was something other than UTC has been resolved. + - An issue with the code update mechanism that could cause multiple updates to occur has been resolved. Only a single update should be executed and then the flag allowing for updates to be applied should be removed. This prevents the update mechanism triggering on each restart of the system. + - A problem that prevented the modbusC south plugin from being updated in the same way as other plugins has been resolved. + - The command line interface to view the status of the system has been updated to correctly show the statistics history collection task when it is running. + - When switch a south plugin from a slow poll rate to a faster one the new poll rate does not take effect until the en dot the current poll cycle. This could be a very long time, this has now been changed such that the south service will take the new poll rate as soon as possible rather than wait for the endow the current poll cycle. + - The data sent to notification rules that register for audit information has been updated to include the complete audit record. This allows for notification rules to be written that trigger on particular auditable operations within the system. + - An empty statistics entry would erroneously be added for an asset or a service if the advanced parameter to control the statistics was modified from the default before the service was started. This has now been resolved. + - The notification service would sometimes shutdown without removing all of the subscriptions it holds with the storage service. This could cause issues for the storage service. Subscriptions are now correctly removed. + - An issue that could sometimes cause a running north service to fail if the configuration for that service is updated has been resolved. + - An issue that limited the update of additional services to jus the notification service has been resolved. The update mechanism can now update any service that is added to the base system installation. + - A problem that prevents an updated service from restarting after an upgrade if HTTPS is used for the interface between services has been resolved. + - An issue with the SQLite In Memory and the SQLite LB storage plugins that could result in incorrect data begin stored has been resolved. + - An issue that prevented FogLAMP Manage from updating services and plugins on a FogLAMP as been resolved. + - An issue in one of the storage plugins that caused spurious warnings to appear in the logs during a backup has been resolved. + - An issue that caused the retrieval of system logs for services with white space in the name of the service has been resolved. + - An erroneous message was being produced when starting the system using the SQLite in memory storage plugin. This has now been resolved. + - Support has been improved for switching between different storage plugins that allows for correct schema creation when using different sqlite plugin variants for configuration and readings storage. + - An update has been done to the default SQLite storage plugin to enable it to handle a large number of distinct asset codes in the readings. Previously the plugin was limited in the number of assets it could support. When the number of asset codes gets large the performance of the plugin will be reduced slightly, however it will continue to ingest data. + - A number of inconsistencies in the timezone returned by API calls have been resolved. All API calls should return timestamps in the UTC timezone unless a timezones explicitly included in the response. + - A issue with trying to create a new user that shares the same user name with a previous user that was removed from the system failing has been resolved. + + +- **GUI** + + - New Features: + + - + - + - New controls have been added to the asset browser to apse the automatic refresh of the data should and to allow shuffling back and forth along the timeline. + - New controls have been added in the menu pane of the GUI to allow nested commands to be collapsed or expanded, resulting in a smaller menu display. + - The ability to select an area on the graph shown in the asset browser and zoom into the time period defined by that area has been added. + - The interface for updating the filters has been improved when multiple filters are being updated at once. + - + - The display of image attributes for image type data points has been added to the latest reading display. + - The reading graph time granularity has been improved in the asset browser. + - A new user interface option has been added to the control menu to create control pipelines. + - The ability to move backwards and forwards in the timeline of the asset browser graph has been added. + - The facility pause the automatic update of the asset browser graph has been added. + - The ability to graph multiple readings on a single graph has been added to the asset browser graph. + - A facility to allow a user to define the default time duration shown in the asset browser graph has been added to the user interface settings page. + - The user interface has been updated such that if the backend system is not available then the user interface components are made insensitive. + - The date format has been made more flexible in the asset and readings graph. + + + - Bug Fix: + + - + - The user interface for configuring plugins has been improved to make it more obvious when mandatory items are missing. + - An issue that allowed view users to update configuration when logged in using certificate based authentication has been resolved. + - The latest reading display issue that resulted in non image data not been shown when one or more image data points are in the reading has been resolved. + - An issue with the handling of script type items whose name was not also script in the user interface that meant that scripts with different names were incorrectly handled has been resolved. + - An occasional error that appeared on the Control Script and ACL pages has been resolved. + - An issue with editing large scripts or JSON items in the configuration has been resolved. + - An issuer that caused services with quotes in the name to disappear from the user interface has been resolved. + - A text wrapping issue in the system log viewer has been resolved. + + +- **Plugins** + + - New Features: + + - The SQLite storage plugins have been updated to improve the error reporting around database contention issues. + - An update has been done to the OMF north plugin to correctly handle the set of reserved characters in PI tag names when using the new linked data method for inserting data in the PI Server. + - Some enhancements have been made to the OMF north plugin to improve the performance when there are large numbers of distinct assets to send to the ePI Server. + - A new tuning parameter has been added to the PostgreSQL storage plugin to allow the maximum number of readings inserted into the database in a single insert to be limited. This is useful when high data rates or large bursts of readings are received as it limits the memory consumption of the plugin and reduces the lock contention on the database. + - When processing data updates from the PI Server at high rates, PI Server Update Manager queue might overflow. This is caused by the PI Server not retrieving data updates until all registrations were complete. To address this, the PI Server South plugin has been updated to interleave registration and retrieval of data updates so that data retrieval begins immediately. + - The documentation for the control notification plugin has been updated to include examples for all destinations of control requests. + - Some devices were not compatible with the optimised block reading of registers performed by the ModbusC south plugin. The ModbusC plugin has been updated to provide controls that can determine how it reads data from the modus device. This allows single register reads, single object reads and the current optimised block reads. + - Compression functionality has been added to the Kafka north plugin. + - The foglap-notify-rest plugin has been enhanced to support separate method URL and payload for clearing a notification + - A new exponentially weighted moving average filter has been added that allows smoothing of data. + - A new FogLAMP plugin has been developed for an ArmField FM51 Pump Demonstration Unit. + - The Kafka north plugin has been updated to allow for username and password authentication to be supplied when connecting to the Kafka server. + - The documentation for the asset filter has been improved to include more examples and explanation for the various uses of the plugin and to include all the different operations that can be performed with the filter. + - The PI Server south plugin now allows the Asset Framework location to be added as a datapoint. This can then be used in the OMFHint filter to create the location in the destination PI server or otters north destination for the data. If going to another PI server the macro substitution feature that has been added to the OMF Hint filter can be used. + - Macro substitution has been added to the OMFHint filter allowing the contents of datapoints and metadata to be incorporated into the values of the OMF Hint, for example in the Asset Framework location can now include data read from the data source in the location. + - Support for fetching files using FTPS has been added to the foglamp-south-file plugin. + - The documentation for the etherip south plugin has been updated. + - The CSV writer filter plugin has been updated to improve the options available for defining the file names to use. + - A new plugin has been implemented for SKF Observer data + - SFTP has been added as a protocol for pulling files in the foglamp-south-file plugin. + - Support for SCP to pull files into the south file plugin has been added. + - The north opcuaclient has been updated to support multiple values in a single write. + - A new north plugin for sending data to the Amazon Web Services IOT core has been created. + - A new south plugin has been added to capture images from RTSP network cameras. + - A new north plugin has been added that allows data to be stored in the Azure Blob Service. + - The north OPCUA client plugin has been updated to support security mode and security policy. + - A new hint plugin for AWS Redshift has been added that allows specific assets t be sent to specific Redshift tables. + - The etherip south plugin has been updated to allow multiple tag items to be ingested in a single poll call. + - The asset filter has been updated to allow it to split assets into multiple assets, with the different data points in the original asset being assigned to one or more of the new assets created. + - The asset filter has been enhanced to allow it to flatten a complex asset structure. This allows nested data to be moved to the root level of the asset. + - The sigma cleanse filter has been enhanced to persist the calculated sigma value when shutdown. The allow the learnt sigma values to be persisted across restarts of FogLAMP or the individual services. + - The asset filter has been enhanced to allow it to remove data points from readings. + - A new north plugin, foglamp-north-aws-redshift has been created that can send data from FogLAMP into the Amazon web services Redshift database. + - A new filter plugin, foglamp-filter-regex has been added that is designed for filtering string type data points and allows string match and replace functionality in the values of the data points using regular expression. + - The S2OPCUA South plugin now supports an optional datapoint in its Readings that shows the full path of the OPC UA Variable in the server's namespace. + - A new south plugin that can use Python script to read file contents has been added. + - The OMF north plugin has been updated to make an additional test for the server hostname wen it is configured. This will give clearer feedback in the error log if a bad hostname is entered or the hostname can not be resolved. This will also confirm that IP addresses entered are in the correct format. + - The option to configure and use a username and password for authentication to the MQTT broker has been added to the south MQTT plugin. + - A new notification rule, foglamp-rule-match which is specifically aimed at use with audit log data and can be used to match certain internal events within the system. + - The average and watchdog rules have been updated to allow selection of data sources other than the readings to be sent to the rules. + - A new notification delivery plugin has been added that will send a message to Microsoft Teams users. + - There have been improvements to the OMF north plugin to prevent an issue that could cause the plugging to stop sending data if the type of an individual datapoint changed repeatedly between integer and floating point values. The logging of the the plugin has also been improved, with clearing messages and less repetition of error conditions that persist for long periods. + - The audit logger has been made available to plugins running within the notification service. + - Support for multiple data centers for OSIsoft Cloud Services (OCS) has been added. OCS is hosted in the US-West and EU-West regions. + - A new generic REST notification plugin has been added to the notification plugins available. + - A problem that prevented filters being used with the notify-north plugin has been fixed. + - The spectrogram filter has been updated to support conditional forwarding. + - A set of Jiras has been created for the S2OPCUA South plugin to fully incorporate all unique features of the FreeOPCUA South plugin. + - ADM LD prediction filter plugin and the autoencoder model inside it have been updated to make use of latest vibration data from more accelerometers and an improved data preprocessing pipeline. + - The SQLite In-Memory storage plugin now has an option that allows the data to be persisted when shutting the system down and reloaded on startup. + - A number of optimisations to the SQLite in-memory storage plugin and the SQLiteLB storage plugin have been added that increase the rate at which readings can be stored with these plugins. + - The documentation of the OMF North plugin has been updated to conform with the latest look and feel of the configuration user interface. It also contains notes regarding the use of complex types verses the OMF 1.2 linked types. + - The Simple Rest south plugin documentation has been updated to include the new authentication options. Th plugin configuration has also been updated to make use of the new tab based display, grouping relating configuration options together. + - The PI Server South plugin new responds properly if AF Attributes are edited or edited, and if PI Points are deleted. Unfortunately, it is not possible to detect PI Point attribute edits. It is not possible to detect creation of new AF Attributes or PI Points that match the query parameters. If this occurs, you must restart your PI Server South instance. + - A new filter plugins has been added that will allow data to be normalised to a particular reading rate within a stream. + - A new filter has been added that will allow two assets to be joined with a pipeline. + - A new feature has been added that allows pipelines to be defined that may be applied to control operations. This allows the filter pipelines to manipulate the control requests that arrived from various sources within Fledge. + - The North service could crash if it retrieved invalid JSON while processing a reconfiguration request. This was addressed by adding an exception handler to prevent the crash. + - Windowed averages in the notification service preserve the type of the input data when creating the averages. This does not work well for integer values and has been changed such that integer values are promoted to floating point when using windowed averages for notification rule input. + - The notification service documentation has been updated to include examples of notifications based on statistics and audit logs. + - The notification mechanism has been updated to accept raw statistics and statistics rates as an input for notification rules. This allows alerts to be raised for pipeline flows and other internal tasks that generate statistics. + - The control dispatcher now has access to the audit logging system. + - Notifications can now register for audit log entries to be sent to notification rules. This allows notification to be made based on internal state changes of the system. + + + - Bug Fix: + + - The PI Server South plugin would start and run normally but would crash during shutdown. The crash did not affect configuration data persisted by the plugin. This has been fixed. + - The north OPCUA client plugin has been updated to support higher data transfer rates. + - An issue with set point control operations occurring before a south plugin is fully ready has been resolved. + - This issue was addressed by changes made in [FOGL-7849|https://scaledb.atlassian.net/browse/FOGL-7849]. + - An issue with reconfiguring a Kafka north plugin has been resolved, this now behaves correctly in all cases. + - The Fledge S2OPCUA South plugin has been updated to support large numbers of Monitored Items. + - An issue with sending data to Kafka that included image data points has been resolved. There is no support in Kafka for images and the will be removed while allowing the remainder of the data to be sent to Kafka. + - A issue with NULL string data being returned from OPCUA servers has been resolved. NULL strings will not be represented in the readings, no data point will be created for the NULL string. + - The PI Server South plugin had a memory leak which causes memory usage to grow signficantly and never recover. This has been fixed. + - A packaging issue with the fog lamp-filter-inage-resize plugin has been resolved. + - A packaging issue with the foglamp-filter-inage-bounding-box plugin has been resolved. + - An issue with using multiple Python based plugins in a north conditional forwarding pipeline has been resolved. + - Various filters summarise data over time, these have been standardised to use the times of the summary calculation. + - A new filter plugin, foglamp-filter-normalise has been added that will time normalise data coming from different sources. + - A problem with the asset delivery plugin that would sometimes result in stopping the notification service has been resolved. + - The OMF North plugin sent basic data type definitions to AVEVA Data Hub (ADH) that could not be processed resulting in a loss of all time series data. This has been fixed. + - An issue that prevented the installation of the person detection plugin on Ubuntu 20 has been resolved. + - Recent changes in the OMF North plugin caused the data streaming to the Edge Data Store (EDS) to fail. This has been fixed. The fix has been tested with EDS 2020 (Version 1.0.0.609). + - An issue with the S2OPCUA South plugin that allowed a negative value to be entered for the minimum reporting interval has been resolved. The plugin has also be updated to use the new tab format for configuration item grouping. + - A problem that prevented the installation of the sigfns plugin has been resolved. + - The notification sent audit log entry was created even when the delivery failed. It should only be created on successful delivery, this has been fixed. + - The threshold filter interface has been tidied up, removing duplicate information. + - The email notification plugin has been updated to allow custom alert messages to be created. + - The email notification delivery plugin has been updated to hide the password from view. + - An issue with the Modbus-TCP & S7 plugins which caused the polling to fail has been resolved. + - An issue with the statistics filter that could cause the median statistics to be incorrect in certain cases has been resolved. + - An issue with the reconfiguration of the statistics filter which caused the filter to always output all statistics has been resolved. + - PI Server South responds properly if the PI Web API or PI Server is not available at plugin startup: it will wait until the server is reachable and then continue. The plugin also handles PI Servers restarting while processing data updates. + - The PI Server South plugin now has a configurable limit on the number of data streams loaded by the plugin. When this limit is reached, the plugin will not load any more streams and will log a warning. Setting the limit to 0 disables this limit check. + - A problem with the J1708 & J1939 plugins that cased them to fail if added disabled and then later enabling them has been resolved. + - The HTTP North C plugin now supports sending audit log data as well as readings and statistics. + - A problem that caused the Azure IoT Core north plugin to fail to send data has been corrected. + - The control map configuration item of the Modbus C plugin was incorrectly described, this has now been resolved. + - A product version check was made incorrectly if the OMF endpoint type was not PI Web API. This has been fixed. + - The OMF North plugin that is used to send Data a to the AVEVA PI Server has been updated to improve the performance of the plugin. + - If a query for AF Attributes includes a search string token that does not exist, PI Web API returns an HTTP 400 error. PI Server South now retrieves error messages if this occurs and logs them. + - The plugin would become unresponsive if the OPC UA server was unavailable or if the server URL was incorrect. The only way to stop the plugin in this state was to shut down Fledge. This has been fixed. + - Documentation of the AF Location OMFHint in the OMF North plugin page has been updated to include an outline of difference in behaviors between Complex Types and the new Linked Types configuration. + - Changing the name of an asset in a notification rule could sometimes cause an error to be incorrectly logged. This has now been resolved. + - An issue related to using averaging with the statistics history input to the notification rules has been fixed. + - The asset notification delivery plugin was not previously creating entries in the asset tracker. This has now been resolved. + + v2.1.0 ------- diff --git a/docs/conf.py b/docs/conf.py index 4069ee0648..18529328ca 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -177,4 +177,4 @@ # Pass Plugin DOCBRANCH argument in Makefile ; by default develop # NOTE: During release time we need to replace DOCBRANCH with actual released version -subprocess.run(["make generated DOCBRANCH='develop'"], shell=True, check=True) +subprocess.run(["make generated DOCBRANCH='2.2.0RC'"], shell=True, check=True) From faf363d2e57b5e8f5f9db915758733dfc7441670 Mon Sep 17 00:00:00 2001 From: Ashish Jabble Date: Wed, 18 Oct 2023 20:24:46 +0530 Subject: [PATCH 497/499] python unit tests fixes when run in suite; issue with scheduler shared instance is not resetting on completion of test scenario (#1203) Signed-off-by: ashish-jabble --- .../core/api/control_service/test_script_management.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py index 596762bc91..b7d2b34517 100644 --- a/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py +++ b/tests/unit/python/fledge/services/core/api/control_service/test_script_management.py @@ -84,6 +84,7 @@ async def test_get_all_scripts(self, client): with patch.object(c_mgr, 'get_category_all_items', return_value=get_cat) as patch_get_all_items: resp = await client.get('/fledge/control/script') assert 200 == resp.status + server.Server.scheduler = None result = await resp.text() json_response = json.loads(result) assert 'scripts' in json_response @@ -162,6 +163,7 @@ async def mock_manual_schedule(name): with patch.object(server.Server.scheduler, 'get_schedule_by_name', return_value=get_sch) as patch_get_schedule_by_name: resp = await client.get('/fledge/control/script/{}'.format(script_name)) + server.Server.scheduler = None assert 200 == resp.status result = await resp.text() json_response = json.loads(result) @@ -583,6 +585,7 @@ def d_schedule(*args): with patch.object(AuditLogger, 'information', return_value=arv) as audit_info_patch: resp = await client.delete('/fledge/control/script/{}'.format(script_name)) + server.Server.scheduler = None assert 200 == resp.status result = await resp.text() json_response = json.loads(result) @@ -739,6 +742,7 @@ async def test_schedule_found_for_configuration_script(self, client): with patch.object(server.Server.scheduler, 'get_schedules', return_value=get_sch) as patch_get_schedules: resp = await client.post('/fledge/control/script/{}/schedule'.format(script_name)) + server.Server.scheduler = None assert 400 == resp.status result = await resp.text() json_response = json.loads(result) @@ -788,6 +792,7 @@ async def test_schedule_configuration_for_script(self, client): with patch.object(server.Server.scheduler, 'queue_task', return_value=queue) as patch_queue_task: resp = await client.post('/fledge/control/script/{}/schedule'.format(script_name)) + server.Server.scheduler = None assert 200 == resp.status result = await resp.text() json_response = json.loads(result) From 9fe57271290ee55dad52407daab790046f2cf23d Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Fri, 20 Oct 2023 01:00:04 +0530 Subject: [PATCH 498/499] Updated 91_version_history file for 2.2.0 release (#1205) * Updated 91_version_history.rst Signed-off-by: Mohit Singh Tomar --- docs/91_version_history.rst | 285 +++++++++++++++--------------------- 1 file changed, 120 insertions(+), 165 deletions(-) diff --git a/docs/91_version_history.rst b/docs/91_version_history.rst index 41d06135e1..3160cb6550 100644 --- a/docs/91_version_history.rst +++ b/docs/91_version_history.rst @@ -34,229 +34,183 @@ Release Date: 2023-10-17 - New Features: - - New performance monitors have been added to the north service and the running guide updated to discuss how these can be used. - - The default interval for running the purge process has been reduced, the purge process will not run every 5 minutes. This chance only affects new installations, the purge process will run as before on systems that are upgraded. - - A change has been made to prevent the schedules used to start services from being renamed as this could cause the services to fail. - - The table registration mechanism has been extended to allow services to register for delete operations. - - The public API has been updated to include the ability to make control requests via the public API. - - The support bundle creation process has been updated to include any performance counters available in the system. - - The ability to monitor performance counters has been added to Fledge. Currently only south services offer performance counters that can be captured by the system. These are designed to provide information useful for tuning the south service. - - The audit log entries have been updated to include more information when schedules are updated. - New audit logs have been added to reflect the creation, update and deletion of access control lists. - - The reporting of issues related to failure to load plugins has been improved. + - New public API Entry Points have been added to allow for the creation and manipulation of control pipelines. - A new user role has been added for those users able to update the control features of the platform. - - The public API of the system has been updated to allow selection of readings from the storage buffer for given time intervals. - - The asset tracker component has been optimised in order to improve the ingress and egress performance of Fledge + - A new tuning parameter has been added to the PostgreSQL storage plugin to allow the maximum number of readings inserted into the database in a single insert to be limited. This is useful when high data rates or large bursts of readings are received as it limits the memory consumption of the plugin and reduces the lock contention on the database. + - The asset tracker component has been optimized in order to improve the ingress and egress performance of Fledge. + - The mechanism used by the south and north services to interact with the audit log has been optimized. This improves the ingress and egress performance of the product at the cost of a small delay before the audit log is updated. + - A number of optimizations have been made to improve the performance of Python filters within a pipeline. + - A number of optimizations to the SQLite in-memory storage plugin and the SQLiteLB storage plugin have been added that increase the rate at which readings can be stored with these plugins. + - The support bundle creation process has been updated to include any performance counters available in the system. + - The ability to monitor performance counters has been added to Fledge. The South and north services now offer performance counters that can be captured by the system. These are designed to provide information useful for tuning the respective services. - The process used to extract log information from the system logs has been updated to improve performance and reduce the system overhead required to extract log data. - - The mechanism used by the south and north services to interact with the audit log has been optimised. This improves the ingress and egress performance of the product at the cost of a small delay before the audit log is updated. - - The ingestion of data from asynchronous south service paid no attention to the advanced configuration option \Throttle\. This meant that very fast asynchronous south plugins could build extremely large queues of data within the south service, using system resources and taking a long time to shutdown. This has now been rectified, with asynchronous south services now be subject to flow control if the 'Throttle \ option is set for the service. Unconstrained input is still available if the \Throttle\ option is not checked. - - A security vulnerability in one of the PIP packages used by some of the Python components used by the system. - - A numeric list data type has been added to the reading ingestion code of the system. - - Audit logs have been add to the user API of the public REST interface. - - An issue which could occasionally result in the bearer token used for authentication between the various services expiring before the completion of the renewal process has been resolved. This could result in the failure of services to communicate with each other. - - Support has been added to allow filters to ingest pass data onwards during a shutdown of the filter. This allows any buffered data to be flushed to the next filter in the pipeline. - - When upgrading the version of a plugin any new configuration items are added to the relevant configuration categories. However the operation was not correctly reported as a configuration change in the audit log. This behaviour has now been corrected. - - New public API Entry Points have been added to allow for the creation and manipulation of control pipelines. - - The public API that is used to retrieve readings data from the storage layer has been updated to allow data for multiple assets to be retrieved in a single call. - - A memory leak in one of the storage plugins has been fixed. This caused the storage service to consume large amounts of memory over time which could result in the operating system killing the service. - - The format of Python back traces has been improved to use multiple lines within the log. This makes the trace easier to understand and prevents the truncation that can occur. - - The setting of log levels fro a service is now also reflected in any Python code loaded by the service. - - The plugin developers guide has been updated to include the mechanism for adding audit trail entries from C++ plugins. - - Plugins that run within the south and north services and north tasks now have access to the audit logging system. - - The documentation regarding handling and updating certificates used for authentication has been updated. - A number of changes have been made to improve the performance of sending data north from the system. - The performance of the statistics history task has been improved. It now makes fewer calls to the storage subsystem, improving the overall system performance. - - Changes have been made to the purge process in the sqlitelb and sqliteinmemory plugins in order to improve performance. + - The performance of the asset tracker system has been improved, resulting in an improvement in the ingress performance of the system. + - Changes have been made to the purge process in the SQLiteLB and SQLite in-memory plugins in order to improve performance. + - The audit log entries have been updated to include more information when schedules are updated. + - Audit logs have been added to the user API of the public REST interface. + - The plugin developers guide has been updated to include the mechanism for adding audit trail entries from C++ plugins. + - Plugins that run within the south and north services and north tasks now have access to the audit logging system. + - The public API has been updated to include the ability to make control requests. + - The public API of the system has been updated to allow selection of readings from the storage buffer for given time intervals. + - The public API that is used to retrieve reading data from the storage layer has been updated to allow data for multiple assets to be retrieved in a single call. + - The SQLite in-memory storage plugin now has an option that allows the data to be persisted when shutting the system down and reloaded on startup. + - The SQLite storage plugins have been updated to improve the error reporting around database contention issues. + - A change has been made to the configuration of the storage plugin such that rather than having to type correct names for storage plugins the user may now select the plugins to use from a drop down list. Note however that the system must still be restarted for the new storage plugin to take effect. + - The storage service has been updated to allow other services to subscribe the notifications of inserts into the generic tables. + - A change has been made to prevent the schedules used to start services from being renamed as this could cause the services to fail. + - The default interval for running the purge process has been reduced, the purge process will now run every 10 minutes. This change only affects new installations, the purge process will run as before on systems that are upgraded. + - The ingestion of data from asynchronous south services paid no attention to the advanced configuration option "Throttle". This meant that very fast asynchronous south plugins could build extremely large queues of data within the south service, using system resources and taking a long time to shutdown. This has now been rectified, with asynchronous south services now subject to flow control if the "Throttle" option is set for the service. Unconstrained input is still available if the "Throttle" option is not checked. - The south plugin now supports three different modes of polling. Polling at fixed intervals from the time started, polling at fixed times or polling on demand via the control mechanisms. + - Support has been added to allow filters to ingest passed data onwards during a shutdown of the filter. This allows any buffered data to be flushed to the next filter in the pipeline. + - A numeric list data type has been added to the reading ingestion code of the system. + - A Python package, used by the system, found to have a security vulnerability. This has been updated. + - The format of Python traceback has been improved to use multiple lines within the log. This makes the trace easier to understand and prevents the truncation that can occur. + - The setting of log levels from a service is now also reflected in any Python code loaded by the service. + - The reporting of issues related to failure to load plugins has been improved. + - When upgrading the version of a plugin any new configuration items are added to the relevant configuration categories. However the operation was not correctly reported as a configuration change in the audit log. This behavior has now been corrected. + - An issue which could occasionally result in the bearer token used for authentication between the various services expiring before the completion of the renewal process has been resolved. This could result in the failure of services to communicate with each other. + - The configuration category C++ API has been enhanced in the retrieval and setting of all the attributes of a configuration item. - The support bundle has been updated to include a list of the Python packages installed on the machine. - - The configuration category C++ API has been enhanced in the retrieval and setting of all the attributes of a configuration item. - - The storage service has been updated to allow other services to subscribe the notifications of inserts into the generic tables. - - A change has been made to the configuration of the storage plugin such that rather than having to type correct names for storage plugins the user may now select the plugins to use from a drop down list. Note however that the system must still be restarted for the new storage plugin to take effect. - - A number of optimisations have been made to improve the performance of Python filters within a pipeline. + - The documentation regarding handling and updating certificates used for authentication has been updated. + - Added documentation for performance counter in the tunning guide. - Bug Fix: - - An issue that cause cause log messages to not be recorded has been resolved. - - A problem with statistics counter overflow that could cause a crash in the statistics collector has been resolved. - - An issue that could cause the statistics to be displayed with timestamp in the wrong timezone has been resolved. + - An issue with the SQLite in-memory and the SQLiteLB storage plugins that could result in incorrect data being stored has been resolved. + - An erroneous message was being produced when starting the system using the SQLite in-memory storage plugin. This has now been resolved. + - Support has been improved for switching between different storage plugins that allows for correct schema creation when using different sqlite plugin variants for configuration and readings storage. + - An issue that could cause health metrics to not be correctly returned when using the Postgres storage engine has been resolved. + - An issue in one of the storage plugins that caused spurious warnings to appear in the logs during a backup has been resolved. + - A memory leak in one of the storage plugins has been fixed. This caused the storage service to consume large amounts of memory over time which could result in the operating system killing the service. + - An update has been done to the default SQLite storage plugin to enable it to handle a large number of distinct asset codes in the readings. Previously the plugin was limited in the number of assets it could support. When the number of asset codes gets large the performance of the plugin will be reduced slightly, however it will continue to ingest data. - An issue with memory usage in Python plugins used in south services has been resolved. - - A problem with converting very long integers from JSON has been resolved. This would have manifested itself as a crash when handling datapoints that contain 64 bit integers above a certain value. - - A number of issues regarding the usage of memory have been resolved, including some small memory leaks. The overall memory footprint of north services should also be reduced in some circumstances. - - The performance of the asset tracker system has been improved, resulting in an improvement in the ingress performance of the system. - - The Python south plugin mechanism has been updated to fix an issue with ingestion of nested data point values. + - A number of issues regarding the usage of memory have been resolved, including some small memory leaks. The overall memory footprint of north services should also be reduced in some circumstances. + - An issue that causes log messages to not be recorded has been resolved. + - An issue that could cause the statistics to be displayed with a timestamp in the wrong timezone has been resolved. - A bug in the statistics rate API that would result in incorrect data being returned has been fixed. - - An issue that required the north service to be restarted if the source of data to send was changed in a running services has been resolved. Changing the data source no longer requires a restart of the north service. - - A bug that prevents notification rules being executed for readings with asset codes starting with numeric values has been resolved. - - A problem with incorrect timestamps in the readings graphs when the timezone of the machine was something other than UTC has been resolved. - - An issue with the code update mechanism that could cause multiple updates to occur has been resolved. Only a single update should be executed and then the flag allowing for updates to be applied should be removed. This prevents the update mechanism triggering on each restart of the system. - - A problem that prevented the modbusC south plugin from being updated in the same way as other plugins has been resolved. - - The command line interface to view the status of the system has been updated to correctly show the statistics history collection task when it is running. - - When switch a south plugin from a slow poll rate to a faster one the new poll rate does not take effect until the en dot the current poll cycle. This could be a very long time, this has now been changed such that the south service will take the new poll rate as soon as possible rather than wait for the endow the current poll cycle. - - The data sent to notification rules that register for audit information has been updated to include the complete audit record. This allows for notification rules to be written that trigger on particular auditable operations within the system. - An empty statistics entry would erroneously be added for an asset or a service if the advanced parameter to control the statistics was modified from the default before the service was started. This has now been resolved. - - The notification service would sometimes shutdown without removing all of the subscriptions it holds with the storage service. This could cause issues for the storage service. Subscriptions are now correctly removed. + - A problem with statistics counter overflow that could cause a crash in the statistics collector has been resolved. + - An issue that caused the retrieval of system logs for services with white space in the name of the service has been resolved. + - The control dispatcher now has access to the audit logging system. + - An issue that required the north service to be restarted if the source of data to send was changed in a running service has been resolved. Changing the data source no longer requires a restart of the north service. - An issue that could sometimes cause a running north service to fail if the configuration for that service is updated has been resolved. - - An issue that limited the update of additional services to jus the notification service has been resolved. The update mechanism can now update any service that is added to the base system installation. - A problem that prevents an updated service from restarting after an upgrade if HTTPS is used for the interface between services has been resolved. - - An issue with the SQLite In Memory and the SQLite LB storage plugins that could result in incorrect data begin stored has been resolved. - - An issue that prevented FogLAMP Manage from updating services and plugins on a FogLAMP as been resolved. - - An issue in one of the storage plugins that caused spurious warnings to appear in the logs during a backup has been resolved. - - An issue that caused the retrieval of system logs for services with white space in the name of the service has been resolved. - - An erroneous message was being produced when starting the system using the SQLite in memory storage plugin. This has now been resolved. - - Support has been improved for switching between different storage plugins that allows for correct schema creation when using different sqlite plugin variants for configuration and readings storage. - - An update has been done to the default SQLite storage plugin to enable it to handle a large number of distinct asset codes in the readings. Previously the plugin was limited in the number of assets it could support. When the number of asset codes gets large the performance of the plugin will be reduced slightly, however it will continue to ingest data. - - A number of inconsistencies in the timezone returned by API calls have been resolved. All API calls should return timestamps in the UTC timezone unless a timezones explicitly included in the response. - - A issue with trying to create a new user that shares the same user name with a previous user that was removed from the system failing has been resolved. + - An issue that limited the update of additional services to just the notification service has been resolved. The update mechanism can now update any service that is added to the base system installation. + - The Python south plugin mechanism has been updated to fix an issue with ingestion of nested data point values. + - When switching a south plugin from a slow poll rate to a faster one the new poll rate does not take effect until the end of the current poll cycle. This could be a very long time. This has now been changed so that the south service will take the new poll rate as soon as possible rather than wait for the end of the current poll cycle. + - A bug that prevented notification rules from being executed for readings with asset codes starting with numeric values has been resolved. + - The data sent to notification rules that register for audit information has been updated to include the complete audit record. This allows for notification rules to be written that trigger on particular auditable operations within the system. + - The notification service would sometimes shutdown without removing all of the subscriptions it holds with the storage service. This could cause issues for the storage service. Subscriptions are now correctly removed. + - The command line interface to view the status of the system has been updated to correctly show the statistics history collection task when it is running. + - The issue of incorrect timestamps in reading graphs due to inconsistent timezones in API calls has been resolved. All API calls now return timestamps in UTC unless explicitly specified in the response. + - An issue with the code update mechanism that could cause multiple updates to occur has been resolved. Only a single update should be executed and then the flag allowing for updates to be applied should be removed. This prevents the update mechanism triggering on each restart of the system. + - A problem that prevented the modbusC south plugin from being updated in the same way as other plugins has been resolved. + - An issue with trying to create a new user that shares the same user name with a previous user that was removed from the system failing has been resolved. + - A problem with converting very long integers from JSON has been resolved. This would have manifested itself as a crash when handling datapoints that contain 64 bit integers above a certain value. + - An update has been made to prevent the creation of services with empty service name. - **GUI** - New Features: - - - - - - New controls have been added to the asset browser to apse the automatic refresh of the data should and to allow shuffling back and forth along the timeline. - New controls have been added in the menu pane of the GUI to allow nested commands to be collapsed or expanded, resulting in a smaller menu display. - - The ability to select an area on the graph shown in the asset browser and zoom into the time period defined by that area has been added. - - The interface for updating the filters has been improved when multiple filters are being updated at once. - - - - The display of image attributes for image type data points has been added to the latest reading display. - - The reading graph time granularity has been improved in the asset browser. - A new user interface option has been added to the control menu to create control pipelines. + - The user interface has been updated such that if the backend system is not available then the user interface components are made insensitive. + - The interface for updating the filters has been improved when multiple filters are being updated at once. + - New controls have been added to the asset browser to pause the automatic refresh of the data and to allow shuffling back and forth along the timeline. - The ability to move backwards and forwards in the timeline of the asset browser graph has been added. - - The facility pause the automatic update of the asset browser graph has been added. + - The facility that pauses the automatic update of the asset browser graph has been added. - The ability to graph multiple readings on a single graph has been added to the asset browser graph. - A facility to allow a user to define the default time duration shown in the asset browser graph has been added to the user interface settings page. - - The user interface has been updated such that if the backend system is not available then the user interface components are made insensitive. - The date format has been made more flexible in the asset and readings graph. + - The display of image attributes for image type data points has been added to the latest reading display. + - The ability to select an area on the graph shown in the asset browser and zoom into the time period defined by that area has been added. + - The reading graph time granularity has been improved in the asset browser. - Bug Fix: - - - The user interface for configuring plugins has been improved to make it more obvious when mandatory items are missing. - An issue that allowed view users to update configuration when logged in using certificate based authentication has been resolved. - - The latest reading display issue that resulted in non image data not been shown when one or more image data points are in the reading has been resolved. - An issue with the handling of script type items whose name was not also script in the user interface that meant that scripts with different names were incorrectly handled has been resolved. - - An occasional error that appeared on the Control Script and ACL pages has been resolved. - An issue with editing large scripts or JSON items in the configuration has been resolved. - - An issuer that caused services with quotes in the name to disappear from the user interface has been resolved. + - An issue that caused services with quotes in the name to disappear from the user interface has been resolved. + - The latest reading display issue that resulted in non image data not being shown when one or more image data points are in the reading has been resolved. - A text wrapping issue in the system log viewer has been resolved. + - An occasional error that appeared on the Control Script and ACL pages has been resolved. -- **Plugins** +- **Services & Plugins** - New Features: - - The SQLite storage plugins have been updated to improve the error reporting around database contention issues. - An update has been done to the OMF north plugin to correctly handle the set of reserved characters in PI tag names when using the new linked data method for inserting data in the PI Server. - - Some enhancements have been made to the OMF north plugin to improve the performance when there are large numbers of distinct assets to send to the ePI Server. - - A new tuning parameter has been added to the PostgreSQL storage plugin to allow the maximum number of readings inserted into the database in a single insert to be limited. This is useful when high data rates or large bursts of readings are received as it limits the memory consumption of the plugin and reduces the lock contention on the database. - - When processing data updates from the PI Server at high rates, PI Server Update Manager queue might overflow. This is caused by the PI Server not retrieving data updates until all registrations were complete. To address this, the PI Server South plugin has been updated to interleave registration and retrieval of data updates so that data retrieval begins immediately. - - The documentation for the control notification plugin has been updated to include examples for all destinations of control requests. - - Some devices were not compatible with the optimised block reading of registers performed by the ModbusC south plugin. The ModbusC plugin has been updated to provide controls that can determine how it reads data from the modus device. This allows single register reads, single object reads and the current optimised block reads. - - Compression functionality has been added to the Kafka north plugin. - - The foglap-notify-rest plugin has been enhanced to support separate method URL and payload for clearing a notification - - A new exponentially weighted moving average filter has been added that allows smoothing of data. - - A new FogLAMP plugin has been developed for an ArmField FM51 Pump Demonstration Unit. - - The Kafka north plugin has been updated to allow for username and password authentication to be supplied when connecting to the Kafka server. - - The documentation for the asset filter has been improved to include more examples and explanation for the various uses of the plugin and to include all the different operations that can be performed with the filter. - - The PI Server south plugin now allows the Asset Framework location to be added as a datapoint. This can then be used in the OMFHint filter to create the location in the destination PI server or otters north destination for the data. If going to another PI server the macro substitution feature that has been added to the OMF Hint filter can be used. + - The OMF north plugin has been updated to make an additional test for the server hostname when it is configured. This will give clearer feedback in the error log if a bad hostname is entered or the hostname can not be resolved. This will also confirm that IP addresses entered are in the correct format. + - Some enhancements have been made to the OMF north plugin to improve the performance when there are large numbers of distinct assets to send to the PI Server. + - There have been improvements to the OMF north plugin to prevent an issue that could cause the plugin to stop sending data if the type of an individual datapoint changed repeatedly between integer and floating point values. The logging of the plugin has also been improved, with clearer messages and less repetition of error conditions that persist for long periods. + - Support for multiple data centers for OSIsoft Cloud Services (OCS) has been added in OMF north plugin. OCS is hosted in the US-West and EU-West regions. + - When processing data updates from the PI Server at high rates, the PI Server Update Manager queue might overflow. This is caused by the PI Server not retrieving data updates until all registrations were complete. To address this, the PI Server South plugin has been updated to interleave registration and retrieval of data updates so that data retrieval begins immediately. - Macro substitution has been added to the OMFHint filter allowing the contents of datapoints and metadata to be incorporated into the values of the OMF Hint, for example in the Asset Framework location can now include data read from the data source in the location. - - Support for fetching files using FTPS has been added to the foglamp-south-file plugin. - - The documentation for the etherip south plugin has been updated. - - The CSV writer filter plugin has been updated to improve the options available for defining the file names to use. - - A new plugin has been implemented for SKF Observer data - - SFTP has been added as a protocol for pulling files in the foglamp-south-file plugin. - - Support for SCP to pull files into the south file plugin has been added. - - The north opcuaclient has been updated to support multiple values in a single write. - - A new north plugin for sending data to the Amazon Web Services IOT core has been created. - - A new south plugin has been added to capture images from RTSP network cameras. - - A new north plugin has been added that allows data to be stored in the Azure Blob Service. - - The north OPCUA client plugin has been updated to support security mode and security policy. - - A new hint plugin for AWS Redshift has been added that allows specific assets t be sent to specific Redshift tables. - - The etherip south plugin has been updated to allow multiple tag items to be ingested in a single poll call. - The asset filter has been updated to allow it to split assets into multiple assets, with the different data points in the original asset being assigned to one or more of the new assets created. - The asset filter has been enhanced to allow it to flatten a complex asset structure. This allows nested data to be moved to the root level of the asset. - - The sigma cleanse filter has been enhanced to persist the calculated sigma value when shutdown. The allow the learnt sigma values to be persisted across restarts of FogLAMP or the individual services. - The asset filter has been enhanced to allow it to remove data points from readings. - - A new north plugin, foglamp-north-aws-redshift has been created that can send data from FogLAMP into the Amazon web services Redshift database. - - A new filter plugin, foglamp-filter-regex has been added that is designed for filtering string type data points and allows string match and replace functionality in the values of the data points using regular expression. - - The S2OPCUA South plugin now supports an optional datapoint in its Readings that shows the full path of the OPC UA Variable in the server's namespace. - - A new south plugin that can use Python script to read file contents has been added. - - The OMF north plugin has been updated to make an additional test for the server hostname wen it is configured. This will give clearer feedback in the error log if a bad hostname is entered or the hostname can not be resolved. This will also confirm that IP addresses entered are in the correct format. - - The option to configure and use a username and password for authentication to the MQTT broker has been added to the south MQTT plugin. - - A new notification rule, foglamp-rule-match which is specifically aimed at use with audit log data and can be used to match certain internal events within the system. - - The average and watchdog rules have been updated to allow selection of data sources other than the readings to be sent to the rules. - - A new notification delivery plugin has been added that will send a message to Microsoft Teams users. - - There have been improvements to the OMF north plugin to prevent an issue that could cause the plugging to stop sending data if the type of an individual datapoint changed repeatedly between integer and floating point values. The logging of the the plugin has also been improved, with clearing messages and less repetition of error conditions that persist for long periods. - - The audit logger has been made available to plugins running within the notification service. - - Support for multiple data centers for OSIsoft Cloud Services (OCS) has been added. OCS is hosted in the US-West and EU-West regions. - - A new generic REST notification plugin has been added to the notification plugins available. - - A problem that prevented filters being used with the notify-north plugin has been fixed. - - The spectrogram filter has been updated to support conditional forwarding. - - A set of Jiras has been created for the S2OPCUA South plugin to fully incorporate all unique features of the FreeOPCUA South plugin. - - ADM LD prediction filter plugin and the autoencoder model inside it have been updated to make use of latest vibration data from more accelerometers and an improved data preprocessing pipeline. - - The SQLite In-Memory storage plugin now has an option that allows the data to be persisted when shutting the system down and reloaded on startup. - - A number of optimisations to the SQLite in-memory storage plugin and the SQLiteLB storage plugin have been added that increase the rate at which readings can be stored with these plugins. - - The documentation of the OMF North plugin has been updated to conform with the latest look and feel of the configuration user interface. It also contains notes regarding the use of complex types verses the OMF 1.2 linked types. - - The Simple Rest south plugin documentation has been updated to include the new authentication options. Th plugin configuration has also been updated to make use of the new tab based display, grouping relating configuration options together. - - The PI Server South plugin new responds properly if AF Attributes are edited or edited, and if PI Points are deleted. Unfortunately, it is not possible to detect PI Point attribute edits. It is not possible to detect creation of new AF Attributes or PI Points that match the query parameters. If this occurs, you must restart your PI Server South instance. - - A new filter plugins has been added that will allow data to be normalised to a particular reading rate within a stream. - - A new filter has been added that will allow two assets to be joined with a pipeline. - - A new feature has been added that allows pipelines to be defined that may be applied to control operations. This allows the filter pipelines to manipulate the control requests that arrived from various sources within Fledge. - - The North service could crash if it retrieved invalid JSON while processing a reconfiguration request. This was addressed by adding an exception handler to prevent the crash. - Windowed averages in the notification service preserve the type of the input data when creating the averages. This does not work well for integer values and has been changed such that integer values are promoted to floating point when using windowed averages for notification rule input. - - The notification service documentation has been updated to include examples of notifications based on statistics and audit logs. - The notification mechanism has been updated to accept raw statistics and statistics rates as an input for notification rules. This allows alerts to be raised for pipeline flows and other internal tasks that generate statistics. - - The control dispatcher now has access to the audit logging system. - Notifications can now register for audit log entries to be sent to notification rules. This allows notification to be made based on internal state changes of the system. + - The north opcuaclient has been updated to support multiple values in a single write. + - The north opcuaclient plugin has been updated to support OPC UA security mode and security policies. + - The HTTP North C plugin now supports sending audit log data as well as readings and statistics. + - The fledge-north-kafka plugin has been updated to allow for username and password authentication to be supplied when connecting to the Kafka server. + - Compression functionality has been added to the fledge-north-kafka. + - The average and watchdog rules have been updated to allow selection of data sources other than the readings to be sent to the rules. + - The email notification plugin has been updated to allow custom alert messages to be created. + - The email notification delivery plugin has been updated to hide the password from view. + - Some devices were not compatible with the optimized block reading of registers performed by the ModbusC south plugin. The ModbusC plugin has been updated to provide controls that can determine how it reads data from the modbus device. This allows single register reads, single object reads and the current optimized block reads. + - The S2OPCUA South plugin now supports an optional datapoint in its Readings that shows the full path of the OPC UA Variable in the server's namespace. + - An issue with the S2OPCUA South plugin that allowed a negative value to be entered for the minimum reporting interval has been resolved. The plugin has also been updated to use the new tab format for configuration item grouping. + - The option to configure and use a username and password for authentication to the MQTT broker has been added to the fledge-south-mqtt plugin. + - The North service could crash if it retrieved invalid JSON while processing a reconfiguration request. This was addressed by adding an exception handler to prevent the crash. + - The audit logger has been made available to plugins running within the notification service. + - The notification service documentation has been updated to include examples of notifications based on statistics and audit logs. + - Documentation of the AF Location OMFHint in the OMF North plugin page has been updated to include an outline of differences in behaviors between Complex Types and the new Linked Types configuration. + - The documentation of the OMF North plugin has been updated to conform with the latest look and feel of the configuration user interface. It also contains notes regarding the use of complex types versus the OMF 1.2 linked types. + - The documentation for the asset filter has been improved to include more examples and explanations for the various uses of the plugin and to include all the different operations that can be performed with the filter. + - The documentation for the control notification plugin has been updated to include examples for all destinations of control requests. - Bug Fix: - - The PI Server South plugin would start and run normally but would crash during shutdown. The crash did not affect configuration data persisted by the plugin. This has been fixed. + - The OMF North plugin that is used to send Data to the AVEVA PI Server has been updated to improve the performance of the plugin. + - The OMF North plugin sent basic data type definitions to AVEVA Data Hub (ADH) that could not be processed resulting in a loss of all time series data. This has been fixed. + - Recent changes in the OMF North plugin caused the data streaming to the Edge Data Store (EDS) to fail. This has been fixed. The fix has been tested with EDS 2020 (Version 1.0.0.609). - The north OPCUA client plugin has been updated to support higher data transfer rates. + - The Fledge S2OPCUA South plugin has been updated to support large numbers of Monitored Items. + - An issue with NULL string data being returned from OPCUA servers has been resolved. NULL strings will not be represented in the readings, no data point will be created for the NULL string. + - The Fledge S2OPCUA South plugin would become unresponsive if the OPC UA server was unavailable or if the server URL was incorrect. The only way to stop the plugin in this state was to shut down Fledge. This has been fixed. - An issue with set point control operations occurring before a south plugin is fully ready has been resolved. - - This issue was addressed by changes made in [FOGL-7849|https://scaledb.atlassian.net/browse/FOGL-7849]. - An issue with reconfiguring a Kafka north plugin has been resolved, this now behaves correctly in all cases. - - The Fledge S2OPCUA South plugin has been updated to support large numbers of Monitored Items. - - An issue with sending data to Kafka that included image data points has been resolved. There is no support in Kafka for images and the will be removed while allowing the remainder of the data to be sent to Kafka. - - A issue with NULL string data being returned from OPCUA servers has been resolved. NULL strings will not be represented in the readings, no data point will be created for the NULL string. - - The PI Server South plugin had a memory leak which causes memory usage to grow signficantly and never recover. This has been fixed. - - A packaging issue with the fog lamp-filter-inage-resize plugin has been resolved. - - A packaging issue with the foglamp-filter-inage-bounding-box plugin has been resolved. - - An issue with using multiple Python based plugins in a north conditional forwarding pipeline has been resolved. - - Various filters summarise data over time, these have been standardised to use the times of the summary calculation. - - A new filter plugin, foglamp-filter-normalise has been added that will time normalise data coming from different sources. - - A problem with the asset delivery plugin that would sometimes result in stopping the notification service has been resolved. - - The OMF North plugin sent basic data type definitions to AVEVA Data Hub (ADH) that could not be processed resulting in a loss of all time series data. This has been fixed. - - An issue that prevented the installation of the person detection plugin on Ubuntu 20 has been resolved. - - Recent changes in the OMF North plugin caused the data streaming to the Edge Data Store (EDS) to fail. This has been fixed. The fix has been tested with EDS 2020 (Version 1.0.0.609). - - An issue with the S2OPCUA South plugin that allowed a negative value to be entered for the minimum reporting interval has been resolved. The plugin has also be updated to use the new tab format for configuration item grouping. - - A problem that prevented the installation of the sigfns plugin has been resolved. - - The notification sent audit log entry was created even when the delivery failed. It should only be created on successful delivery, this has been fixed. - - The threshold filter interface has been tidied up, removing duplicate information. - - The email notification plugin has been updated to allow custom alert messages to be created. - - The email notification delivery plugin has been updated to hide the password from view. + - An issue with sending data to Kafka that included image data points has been resolved. There is no support in Kafka for images and they will be removed while allowing the remainder of the data to be sent to Kafka. - An issue with the Modbus-TCP & S7 plugins which caused the polling to fail has been resolved. - - An issue with the statistics filter that could cause the median statistics to be incorrect in certain cases has been resolved. - - An issue with the reconfiguration of the statistics filter which caused the filter to always output all statistics has been resolved. - - PI Server South responds properly if the PI Web API or PI Server is not available at plugin startup: it will wait until the server is reachable and then continue. The plugin also handles PI Servers restarting while processing data updates. - - The PI Server South plugin now has a configurable limit on the number of data streams loaded by the plugin. When this limit is reached, the plugin will not load any more streams and will log a warning. Setting the limit to 0 disables this limit check. - - A problem with the J1708 & J1939 plugins that cased them to fail if added disabled and then later enabling them has been resolved. - - The HTTP North C plugin now supports sending audit log data as well as readings and statistics. + - A problem with the J1708 & J1939 plugins that caused them to fail if added disabled and then later enabling them has been resolved. - A problem that caused the Azure IoT Core north plugin to fail to send data has been corrected. - - The control map configuration item of the Modbus C plugin was incorrectly described, this has now been resolved. - - A product version check was made incorrectly if the OMF endpoint type was not PI Web API. This has been fixed. - - The OMF North plugin that is used to send Data a to the AVEVA PI Server has been updated to improve the performance of the plugin. - - If a query for AF Attributes includes a search string token that does not exist, PI Web API returns an HTTP 400 error. PI Server South now retrieves error messages if this occurs and logs them. - - The plugin would become unresponsive if the OPC UA server was unavailable or if the server URL was incorrect. The only way to stop the plugin in this state was to shut down Fledge. This has been fixed. - - Documentation of the AF Location OMFHint in the OMF North plugin page has been updated to include an outline of difference in behaviors between Complex Types and the new Linked Types configuration. + - A product version check was made incorrectly if the OMF endpoint type was not PI Web API. This has been fixed. + - The notification sent an audit log entry was created even when the delivery failed. It should only be created on successful delivery, this has been fixed. + - A problem with the asset delivery plugin that would sometimes result in stopping the notification service has been resolved. + - An issue that could cause notification to not trigger correctly when used with conditional forwarding has been resolved. + - An issue with using multiple Python based plugins in a north conditional forwarding pipeline has been resolved. - Changing the name of an asset in a notification rule could sometimes cause an error to be incorrectly logged. This has now been resolved. - An issue related to using averaging with the statistics history input to the notification rules has been fixed. - - The asset notification delivery plugin was not previously creating entries in the asset tracker. This has now been resolved. + - The asset notification delivery plugin was not previously creating entries in the asset tracker. This has now been resolved. + - If a query for AF Attributes includes a search string token that does not exist, PI Web API returns an HTTP 400 error. PI Server South now retrieves error messages if this occurs and logs them. + - Various filters summarize data over time, these have been standardized to use the times of the summary calculation. + - The threshold filter interface has been tidied up, removing duplicate information. + - A problem with installation of the person detection plugin on Ubuntu 20 has been resolved. + - The control map configuration item of the Modbus C plugin was incorrectly described, this has now been resolved. v2.1.0 @@ -308,6 +262,7 @@ Release Date: 2022-12-26 - The S2OPCUA south plugin has been updated to allow the timestamp for readings to be taken from the OPC UA server itself rather than the time that it was received by Fledge. + - Bug Fix: - An issue with building of the DNP3 plugin on the Raspberry Pi platform has been resolved. @@ -385,7 +340,7 @@ Release Date: 2022-09-09 - When the data stream from a south plugin included an OMF Hint of AFLocation, performance of the OMF North plugin would degrade. In addition, process memory would grow over time. These issues have been fixed. - The version of the PostgreSQL database used by the Postgres storage plugin has been updated to PostgreSQL 13. - An enhancement has been added to the North service to allow the user to specify the block size to use when sending data to the plugin. This helps tune the north services and is described in the tuning guide within the documentation. - - The notification server would previously output warning messages when it was starting, these were not an indication of a problem and should have been information messages. This has now been resolved. + - The notification service would previously output warning messages when it was starting. These were not an indication of a problem and should have been information messages. This has now been resolved. - The backup mechanism has been improved to include some external items in the backup and provide a more secure backup. - The purge option that controls if unsent assets can be purged or not has been enhanced to provide options for sent to any destination or sent to all destinations as well as sent to no destinations. - It is now possible to add control features to Python south plugins. @@ -676,7 +631,7 @@ Release Date: 2021-05-27 - The Python 35 filter stated it used the Python version 3.5 always, in reality it uses whatever Python 3 version is installed on your system. The documentation has been updated to reflect this. - Fixed a bug that treated arrays of bytes as if they were strings in the OPC/UA south plugin. - The HTTP North C plugin would not correctly shutdown, this effected reconfiguration when run as an always on service. This issue has now been resolved. - - An issue with the SQLite In Memory storage plugin that caused database locks under high load conditions has been resolved. + - An issue with the SQLite in-memory storage plugin that caused database locks under high load conditions has been resolved. v1.9.0 @@ -835,7 +790,7 @@ Release Date: 2020-05-08 - New Features: - - Documentation has been added for the use of the SQLite In Memory storage plugin. + - Documentation has been added for the use of the SQLite in-memory storage plugin. - The support bundle functionality has been improved to include more detail in order to aid tracking down issues in installations. - Improvements have been made to the documentation of the OMF plugin in line with the enhancements to the code. This includes the documentation of OCS and EDS support as well as PI Web API. - An issue with forwarding data between two Fledge instances in different time zones has been resolved. From 89e9e1a5b48cd1f8b3c19f5436ffcf2a7245664b Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Fri, 20 Oct 2023 08:51:45 +0530 Subject: [PATCH 499/499] 2.2.0 change-log update / feedback fixes (#1206) * feedback fixes Signed-off-by: Praveen Garg * some fixes in version history and also removed CentOS Stream9 from 2.2.0RC documentation Signed-off-by: ashish-jabble --------- Signed-off-by: Praveen Garg Signed-off-by: ashish-jabble Co-authored-by: ashish-jabble --- docs/91_version_history.rst | 65 ++++++++++++++++----------------- docs/quick_start/installing.rst | 37 ------------------- 2 files changed, 31 insertions(+), 71 deletions(-) diff --git a/docs/91_version_history.rst b/docs/91_version_history.rst index 3160cb6550..7c409f4108 100644 --- a/docs/91_version_history.rst +++ b/docs/91_version_history.rst @@ -43,7 +43,7 @@ Release Date: 2023-10-17 - A number of optimizations have been made to improve the performance of Python filters within a pipeline. - A number of optimizations to the SQLite in-memory storage plugin and the SQLiteLB storage plugin have been added that increase the rate at which readings can be stored with these plugins. - The support bundle creation process has been updated to include any performance counters available in the system. - - The ability to monitor performance counters has been added to Fledge. The South and north services now offer performance counters that can be captured by the system. These are designed to provide information useful for tuning the respective services. + - The ability to monitor performance counters has been added to Fledge. The South and North services now offer performance counters that can be captured by the system. These are designed to provide information useful for tuning the respective services. - The process used to extract log information from the system logs has been updated to improve performance and reduce the system overhead required to extract log data. - A number of changes have been made to improve the performance of sending data north from the system. - The performance of the statistics history task has been improved. It now makes fewer calls to the storage subsystem, improving the overall system performance. @@ -75,7 +75,7 @@ Release Date: 2023-10-17 - The configuration category C++ API has been enhanced in the retrieval and setting of all the attributes of a configuration item. - The support bundle has been updated to include a list of the Python packages installed on the machine. - The documentation regarding handling and updating certificates used for authentication has been updated. - - Added documentation for performance counter in the tunning guide. + - Added documentation for the performance counters in the tuning guide. - Bug Fix: @@ -103,15 +103,15 @@ Release Date: 2023-10-17 - The Python south plugin mechanism has been updated to fix an issue with ingestion of nested data point values. - When switching a south plugin from a slow poll rate to a faster one the new poll rate does not take effect until the end of the current poll cycle. This could be a very long time. This has now been changed so that the south service will take the new poll rate as soon as possible rather than wait for the end of the current poll cycle. - A bug that prevented notification rules from being executed for readings with asset codes starting with numeric values has been resolved. - - The data sent to notification rules that register for audit information has been updated to include the complete audit record. This allows for notification rules to be written that trigger on particular auditable operations within the system. + - The data sent to notification rules that register for audit information has been updated to include the complete audit record. This allows for notification rules to be written that trigger on the particular auditable operations within the system. - The notification service would sometimes shutdown without removing all of the subscriptions it holds with the storage service. This could cause issues for the storage service. Subscriptions are now correctly removed. - The command line interface to view the status of the system has been updated to correctly show the statistics history collection task when it is running. - The issue of incorrect timestamps in reading graphs due to inconsistent timezones in API calls has been resolved. All API calls now return timestamps in UTC unless explicitly specified in the response. - An issue with the code update mechanism that could cause multiple updates to occur has been resolved. Only a single update should be executed and then the flag allowing for updates to be applied should be removed. This prevents the update mechanism triggering on each restart of the system. - - A problem that prevented the modbusC south plugin from being updated in the same way as other plugins has been resolved. + - A problem that prevented the fledge-south-modbus plugin from being updated in the same way as other plugins has been resolved. - An issue with trying to create a new user that shares the same user name with a previous user that was removed from the system failing has been resolved. - A problem with converting very long integers from JSON has been resolved. This would have manifested itself as a crash when handling datapoints that contain 64 bit integers above a certain value. - - An update has been made to prevent the creation of services with empty service name. + - An update has been made to prevent the creation of service with empty service name. - **GUI** @@ -120,7 +120,7 @@ Release Date: 2023-10-17 - New controls have been added in the menu pane of the GUI to allow nested commands to be collapsed or expanded, resulting in a smaller menu display. - A new user interface option has been added to the control menu to create control pipelines. - - The user interface has been updated such that if the backend system is not available then the user interface components are made insensitive. + - The user interface has been updated such that if the backend system is not available then the user interface components are made non-interactive & blur. - The interface for updating the filters has been improved when multiple filters are being updated at once. - New controls have been added to the asset browser to pause the automatic refresh of the data and to allow shuffling back and forth along the timeline. - The ability to move backwards and forwards in the timeline of the asset browser graph has been added. @@ -137,7 +137,7 @@ Release Date: 2023-10-17 - The user interface for configuring plugins has been improved to make it more obvious when mandatory items are missing. - An issue that allowed view users to update configuration when logged in using certificate based authentication has been resolved. - - An issue with the handling of script type items whose name was not also script in the user interface that meant that scripts with different names were incorrectly handled has been resolved. + - An issue which prevented the file upload/value update for script type configuration item, unless the name also was script has been resolved. - An issue with editing large scripts or JSON items in the configuration has been resolved. - An issue that caused services with quotes in the name to disappear from the user interface has been resolved. - The latest reading display issue that resulted in non image data not being shown when one or more image data points are in the reading has been resolved. @@ -153,26 +153,24 @@ Release Date: 2023-10-17 - The OMF north plugin has been updated to make an additional test for the server hostname when it is configured. This will give clearer feedback in the error log if a bad hostname is entered or the hostname can not be resolved. This will also confirm that IP addresses entered are in the correct format. - Some enhancements have been made to the OMF north plugin to improve the performance when there are large numbers of distinct assets to send to the PI Server. - There have been improvements to the OMF north plugin to prevent an issue that could cause the plugin to stop sending data if the type of an individual datapoint changed repeatedly between integer and floating point values. The logging of the plugin has also been improved, with clearer messages and less repetition of error conditions that persist for long periods. - - Support for multiple data centers for OSIsoft Cloud Services (OCS) has been added in OMF north plugin. OCS is hosted in the US-West and EU-West regions. + - Support for multiple data centers for OSIsoft Cloud Services (OCS) has been added in the OMF north plugin. OCS is hosted in the US-West and EU-West regions. - When processing data updates from the PI Server at high rates, the PI Server Update Manager queue might overflow. This is caused by the PI Server not retrieving data updates until all registrations were complete. To address this, the PI Server South plugin has been updated to interleave registration and retrieval of data updates so that data retrieval begins immediately. - Macro substitution has been added to the OMFHint filter allowing the contents of datapoints and metadata to be incorporated into the values of the OMF Hint, for example in the Asset Framework location can now include data read from the data source in the location. - - The asset filter has been updated to allow it to split assets into multiple assets, with the different data points in the original asset being assigned to one or more of the new assets created. - - The asset filter has been enhanced to allow it to flatten a complex asset structure. This allows nested data to be moved to the root level of the asset. - - The asset filter has been enhanced to allow it to remove data points from readings. + - The fledge-filter-asset has been updated to allow it to split assets into multiple assets, with the different data points in the original asset being assigned to one or more of the new assets created. + - The fledge-filter-asset has been enhanced to allow it to flatten a complex asset structure. This allows nested data to be moved to the root level of the asset. + - The fledge-filter-asset has been enhanced to allow it to remove data points from readings. - Windowed averages in the notification service preserve the type of the input data when creating the averages. This does not work well for integer values and has been changed such that integer values are promoted to floating point when using windowed averages for notification rule input. - The notification mechanism has been updated to accept raw statistics and statistics rates as an input for notification rules. This allows alerts to be raised for pipeline flows and other internal tasks that generate statistics. - Notifications can now register for audit log entries to be sent to notification rules. This allows notification to be made based on internal state changes of the system. - - The north opcuaclient has been updated to support multiple values in a single write. - - The north opcuaclient plugin has been updated to support OPC UA security mode and security policies. - - The HTTP North C plugin now supports sending audit log data as well as readings and statistics. + - The fledge-north-opcuaclient has been updated to support multiple values in a single write. + - The fledge-north-opcuaclient plugin has been updated to support OPC UA security mode and security policies. + - The fledge-north-httpc plugin now supports sending audit log data as well as readings and statistics. - The fledge-north-kafka plugin has been updated to allow for username and password authentication to be supplied when connecting to the Kafka server. - Compression functionality has been added to the fledge-north-kafka. - The average and watchdog rules have been updated to allow selection of data sources other than the readings to be sent to the rules. - - The email notification plugin has been updated to allow custom alert messages to be created. - - The email notification delivery plugin has been updated to hide the password from view. - - Some devices were not compatible with the optimized block reading of registers performed by the ModbusC south plugin. The ModbusC plugin has been updated to provide controls that can determine how it reads data from the modbus device. This allows single register reads, single object reads and the current optimized block reads. - - The S2OPCUA South plugin now supports an optional datapoint in its Readings that shows the full path of the OPC UA Variable in the server's namespace. - - An issue with the S2OPCUA South plugin that allowed a negative value to be entered for the minimum reporting interval has been resolved. The plugin has also been updated to use the new tab format for configuration item grouping. + - The fledge-notify-email notification delivery plugin has been updated to hide the password from view and also allow custom alert messages to be created. + - Some devices were not compatible with the optimized block reading of registers performed by the fledge-south-modbus plugin. The plugin has been updated to provide controls that can determine how it reads data from the modbus device. This allows single register reads, single object reads and the current optimized block reads. + - The fledge-south-s2opcua now supports an optional datapoint in its Readings that shows the full path of the OPC UA Variable in the server's namespace. It has also to support large numbers of Monitored Items. - The option to configure and use a username and password for authentication to the MQTT broker has been added to the fledge-south-mqtt plugin. - The North service could crash if it retrieved invalid JSON while processing a reconfiguration request. This was addressed by adding an exception handler to prevent the crash. - The audit logger has been made available to plugins running within the notification service. @@ -188,29 +186,28 @@ Release Date: 2023-10-17 - The OMF North plugin that is used to send Data to the AVEVA PI Server has been updated to improve the performance of the plugin. - The OMF North plugin sent basic data type definitions to AVEVA Data Hub (ADH) that could not be processed resulting in a loss of all time series data. This has been fixed. - Recent changes in the OMF North plugin caused the data streaming to the Edge Data Store (EDS) to fail. This has been fixed. The fix has been tested with EDS 2020 (Version 1.0.0.609). - - The north OPCUA client plugin has been updated to support higher data transfer rates. - - The Fledge S2OPCUA South plugin has been updated to support large numbers of Monitored Items. - - An issue with NULL string data being returned from OPCUA servers has been resolved. NULL strings will not be represented in the readings, no data point will be created for the NULL string. - - The Fledge S2OPCUA South plugin would become unresponsive if the OPC UA server was unavailable or if the server URL was incorrect. The only way to stop the plugin in this state was to shut down Fledge. This has been fixed. - - An issue with set point control operations occurring before a south plugin is fully ready has been resolved. - - An issue with reconfiguring a Kafka north plugin has been resolved, this now behaves correctly in all cases. + - The fledge-north-opcuaclient plugin has been updated to support higher data transfer rates. + - An issue with the fledge-south-s2opcua that allowed a negative value to be entered for the minimum reporting interval has been resolved. The plugin has also been updated to use the new tab format for configuration item grouping. + - An issue with NULL string data being returned from OPC UA servers has been resolved. NULL strings will not be represented in the readings, no data point will be created for the NULL string. + - The fledge-south-s2opcua plugin would become unresponsive if the OPC UA server was unavailable or if the server URL was incorrect. The only way to stop the plugin in this state was to shut down Fledge. This has been fixed. + - An issue with fledge-notify-setpoint plugin to control operations occurring before a south plugin is fully ready has been resolved. + - An issue with reconfiguring a fledge-north-kafka plugin has been resolved, this now behaves correctly in all cases. - An issue with sending data to Kafka that included image data points has been resolved. There is no support in Kafka for images and they will be removed while allowing the remainder of the data to be sent to Kafka. - - An issue with the Modbus-TCP & S7 plugins which caused the polling to fail has been resolved. - - A problem with the J1708 & J1939 plugins that caused them to fail if added disabled and then later enabling them has been resolved. - - A problem that caused the Azure IoT Core north plugin to fail to send data has been corrected. + - An issue with the fledge-south-modbustcp & S7 plugins which caused the polling to fail has been resolved. + - A problem with the fledge-south-j1708 & fledge-south-j1939 plugins that caused them to fail if added disabled and then later enabling them has been resolved. + - A problem that caused the fledge-north-azure-iot plugin to fail to send data has been corrected. - A product version check was made incorrectly if the OMF endpoint type was not PI Web API. This has been fixed. - The notification sent an audit log entry was created even when the delivery failed. It should only be created on successful delivery, this has been fixed. - - A problem with the asset delivery plugin that would sometimes result in stopping the notification service has been resolved. + - A problem with the fledge-notify-asset delivery plugin that would sometimes result in stopping the notification service and also it was not previously creating entries in the asset tracker have been resolved. - An issue that could cause notification to not trigger correctly when used with conditional forwarding has been resolved. - An issue with using multiple Python based plugins in a north conditional forwarding pipeline has been resolved. - - Changing the name of an asset in a notification rule could sometimes cause an error to be incorrectly logged. This has now been resolved. + - Changing the name of an asset in a notification rule plugins could sometimes cause an error to be incorrectly logged. This has now been resolved. - An issue related to using averaging with the statistics history input to the notification rules has been fixed. - - The asset notification delivery plugin was not previously creating entries in the asset tracker. This has now been resolved. - If a query for AF Attributes includes a search string token that does not exist, PI Web API returns an HTTP 400 error. PI Server South now retrieves error messages if this occurs and logs them. - Various filters summarize data over time, these have been standardized to use the times of the summary calculation. - - The threshold filter interface has been tidied up, removing duplicate information. - - A problem with installation of the person detection plugin on Ubuntu 20 has been resolved. - - The control map configuration item of the Modbus C plugin was incorrectly described, this has now been resolved. + - The fledge-filter-threshold interface has been tidied up, removing duplicate information. + - A problem with installation of the fledge-south-person-detection plugin on Ubuntu 20 has been resolved. + - The control map configuration item of the fledge-south-modbus plugin was incorrectly described, this has now been resolved. v2.1.0 diff --git a/docs/quick_start/installing.rst b/docs/quick_start/installing.rst index 23a4aeef21..ceaabc6128 100644 --- a/docs/quick_start/installing.rst +++ b/docs/quick_start/installing.rst @@ -111,43 +111,6 @@ You may also install multiple packages in a single command. To install the base sudo DEBIAN_FRONTEND=noninteractive apt -y install fledge fledge-gui fledge-south-sinusoid -CentOS Stream 9 -~~~~~~~~~~~~~~~ - -The CentOS use a different package management system, known as *yum*. Fledge also supports a package management system for the yum package manager. - -To add the fledge repository to the yum package manager run the command - -.. code-block:: console - - sudo rpm --import http://archives.fledge-iot.org/RPM-GPG-KEY-fledge - -CentOS users should then create a file called fledge.repo in the directory /etc/yum.repos.d and add the following content - -.. code-block:: console - - [fledge] - name=fledge Repository - baseurl=http://archives.fledge-iot.org/latest/centos-stream-9/x86_64/ - enabled=1 - gpgkey=http://archives.fledge-iot.org/RPM-GPG-KEY-fledge - gpgcheck=1 - -There are a few pre-requisites that needs to be installed - -.. code-block:: console - - sudo yum install -y epel-release - sudo yum -y check-update - sudo yum -y update - -You can now install and upgrade fledge packages using the yum command. For example to install fledge and the fledge GUI you run the command - -.. code-block:: console - - sudo yum install -y fledge fledge-gui - - Installing Fledge downloaded packages ######################################

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 PF#v(5tDnm{r-UW|3Mhbb diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index 4c29d1d5db..bbef8aab8f 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -150,7 +150,9 @@ postgres In most cases the default *sqlite* storage plugin is perfectly acceptable, however if very high data rates, or huge volumes of data (i.e. large images at a reasonably high rate) are ingested this plugin can start to exhibit issues. This usually exhibits itself by large queues building in the south service or in extreme cases by transaction failure messages in the log for the storage service. If this happens then the recommended course of action is to either switch to a plugin that stores data in memory rather than on external storage, *sqlitememory*, or investigate the media where the data is stored. Low performance storage will adversely impact the *sqlite* plugin. -The *sqlite* plugin may also prove less than optimal if you are ingested many hundreds of different assets in the same Fledge instance. The *sqlite* plugin has been optimized to allow concurrent south services to write to the storage in parallel. This is done by the use of multiple databases to improve the concurrency, however there is a limit, imposed by the number of open databases that can be supported. If this limit is exceeded it is recommend to switch to the *sqlitelb* plugin. There are configuration options regarding how these databases are used that can change the point at which it becomes necessary to switch to the other plugin. +The *sqlite* plugin may also prove less than optimal if you are ingesting many hundreds of different assets in the same Fledge instance. The *sqlite* plugin has been optimized to allow concurrent south services to write to the storage in parallel. This is done by the use of multiple databases to improve the concurrency, however there is a limit, imposed by the number of open databases that can be supported. If this limit is exceeded it is recommend to switch to the *sqlitelb* plugin. There are configuration options regarding how these databases are used that can change the point at which it becomes necessary to switch to the other plugin. + +If you wish to use the same plugin to both store the configuration data and the reading data then you may either choose the same plugin for both or select the option *Use main plugin* for the *Reading Plugin* value. Use the later is perhaps a slightly safer option as changes to the *Storage Plugin* will then automatically cause the readings to use that same plugin. Configuring Storage Plugins ########################### @@ -177,6 +179,8 @@ The storage plugins to use can be selected in the *Advanced* section of the *Con - **Management Port**: Normally the storage service will dynamically create a management port that will be used by the storage service. Setting this to a value other than 0 will cause a fixed port to be used. This can be useful when developing a new storage plugin. +- **Log Level**: This control the level at which the storage plugin will output logs. + Changing will be saved once the *save* button is pressed. Fledge uses a mechanism whereby this data is not only saved in the configuration database, but also cached to a file called *storage.json* in the *etc* directory of the data directory. This is required such that Fledge can find the configuration database during the boot process. If the configuration becomes corrupt for some reason simply removing this file and restarting Fledge will cause the default configuration to be restored. The location of the Fledge data directory will depend upon how you installed Fledge and the environment variables used to run Fledge. - Installation from a package will usually put the data directory in */usr/local/fledge/data*. However this can be overridden by setting the *$FLEDGE_DATA* environment variable to point at a different location. From 926fedeaa122ad21a9443d58f72bb374428438b9 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Wed, 18 Jan 2023 11:11:49 +0530 Subject: [PATCH 064/499] Further changes Signed-off-by: Amandeep Singh Arora --- C/common/include/reading.h | 1 + C/common/reading.cpp | 214 ++++++++++++++++++------------------- 2 files changed, 103 insertions(+), 112 deletions(-) diff --git a/C/common/include/reading.h b/C/common/include/reading.h index 271053cb6f..09f351c32e 100644 --- a/C/common/include/reading.h +++ b/C/common/include/reading.h @@ -67,6 +67,7 @@ class Reading { typedef enum dateTimeFormat { FMT_DEFAULT, FMT_STANDARD, FMT_ISO8601, FMT_ISO8601MS } readingTimeFormat; + void getFormattedDateTimeStr(const time_t *tv_sec, char *date_time, readingTimeFormat dateFormat) const; // Return Reading asset time - ts time const std::string getAssetDateTime(readingTimeFormat datetimeFmt = FMT_DEFAULT, bool addMs = true) const; // Return Reading asset time - user_ts time diff --git a/C/common/reading.cpp b/C/common/reading.cpp index 5654f2fc5a..3feafbf912 100644 --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -225,6 +225,46 @@ ostringstream convert; return convert.str(); } +/** + * Convert time since epoch to a formatted m_timestamp DataTime in UTC + * and use a cache to speed it up + * @param tv_sec Seconds since epoch + * @param date_time Buffer in which to return the formatted timestamp + * @param dateFormat Format: FMT_DEFAULT or FMT_STANDARD + */ +void Reading::getFormattedDateTimeStr(const time_t *tv_sec, char *date_time, readingTimeFormat dateFormat) const +{ + static unsigned long cached_sec_since_epoch = 0; + static char cached_date_time_str[DATE_TIME_BUFFER_LEN] = ""; + + if(strlen(cached_date_time_str) && cached_sec_since_epoch && *tv_sec == cached_sec_since_epoch) + { + strncpy(date_time, cached_date_time_str, DATE_TIME_BUFFER_LEN); + date_time[DATE_TIME_BUFFER_LEN-1] = '\0'; + return; + } + + struct tm timeinfo; + gmtime_r(tv_sec, &timeinfo); + + /** + * Build date_time with format YYYY-MM-DD HH24:MM:SS.MS+00:00 + * this is same as Python3: + * datetime.datetime.now(tz=datetime.timezone.utc) + */ + + // Create datetime with seconds + std::strftime(date_time, DATE_TIME_BUFFER_LEN, + m_dateTypes[dateFormat].c_str(), + &timeinfo); + + // update cache + strncpy(cached_date_time_str, date_time, DATE_TIME_BUFFER_LEN); + cached_date_time_str[DATE_TIME_BUFFER_LEN-1] = '\0'; + cached_sec_since_epoch = *tv_sec; +} + + /** * Return a formatted m_timestamp DataTime in UTC * @param dateFormat Format: FMT_DEFAULT or FMT_STANDARD @@ -236,20 +276,7 @@ char date_time[DATE_TIME_BUFFER_LEN]; char micro_s[10]; char assetTime[DATE_TIME_BUFFER_LEN + 20]; - // Populate tm structure - struct tm timeinfo; - gmtime_r(&m_timestamp.tv_sec, &timeinfo); - - /** - * Build date_time with format YYYY-MM-DD HH24:MM:SS.MS+00:00 - * this is same as Python3: - * datetime.datetime.now(tz=datetime.timezone.utc) - */ - - // Create datetime with seconds - std::strftime(date_time, sizeof(date_time), - m_dateTypes[dateFormat].c_str(), - &timeinfo); + getFormattedDateTimeStr(&m_timestamp.tv_sec, date_time, dateFormat); if (dateFormat != FMT_ISO8601 && addMS) { @@ -293,21 +320,8 @@ const string Reading::getAssetDateUserTime(readingTimeFormat dateFormat, bool ad char micro_s[10]; char assetTime[DATE_TIME_BUFFER_LEN + 20]; - // Populate tm structure with UTC time - struct tm timeinfo; - gmtime_r(&m_userTimestamp.tv_sec, &timeinfo); - - /** - * Build date_time with format YYYY-MM-DD HH24:MM:SS.MS+00:00 - * this is same as Python3: - * datetime.datetime.now(tz=datetime.timezone.utc) - */ - - // Create datetime with seconds - std::strftime(date_time, sizeof(date_time), - m_dateTypes[dateFormat].c_str(), - &timeinfo); - + getFormattedDateTimeStr(&m_userTimestamp.tv_sec, date_time, dateFormat); + if (dateFormat != FMT_ISO8601 && addMS) { // Add microseconds @@ -375,95 +389,71 @@ void Reading::setUserTimestamp(const string& timestamp) */ void Reading::stringToTimestamp(const string& timestamp, struct timeval *ts) { - char date_time [DATE_TIME_BUFFER_LEN]; - - strcpy (date_time, timestamp.c_str()); - - static char cached_timestamp_upto_sec[32] = ""; - static unsigned long cached_sec_since_epoch = 0; - - // auto start = std::chrono::high_resolution_clock::now(); - const int timestamp_str_len_till_sec = 19; - char timestamp_sec[32]; - strncpy(timestamp_sec, date_time, timestamp_str_len_till_sec); - timestamp_sec[timestamp_str_len_till_sec] = '\0'; - if(strlen(cached_timestamp_upto_sec) && cached_sec_since_epoch && (strncmp(timestamp_sec, cached_timestamp_upto_sec, timestamp_str_len_till_sec) == 0)) - { - ts->tv_sec = cached_sec_since_epoch; - // Logger::getLogger()->info("Reading::stringToTimestamp(): cache hit: cached_timestamp_upto_sec=%s, cached_sec_since_epoch=%d", - // cached_timestamp_upto_sec, cached_sec_since_epoch); - } - else - { - // Logger::getLogger()->info("Reading::stringToTimestamp(): cache miss for: timestamp_sec=%s", - // timestamp_sec); + char date_time [DATE_TIME_BUFFER_LEN]; - struct tm tm; - memset(&tm, 0, sizeof(struct tm)); - strptime(date_time, "%Y-%m-%d %H:%M:%S", &tm); - // Convert time to epoch - mktime assumes localtime so most adjust for that - ts->tv_sec = mktime(&tm); + strcpy (date_time, timestamp.c_str()); - extern long timezone; - ts->tv_sec -= timezone; + static char cached_timestamp_upto_min[32] = ""; + static unsigned long cached_sec_since_epoch = 0; - strncpy(cached_timestamp_upto_sec, timestamp_sec, timestamp_str_len_till_sec); - timestamp_sec[timestamp_str_len_till_sec] = '\0'; - cached_sec_since_epoch = ts->tv_sec; - } - - // Now process the fractional seconds - const char *ptr = date_time; - while (*ptr && *ptr != '.') - ptr++; - if (*ptr) - { - char *eptr; - ts->tv_usec = strtol(ptr + 1, &eptr, 10); - int digits = eptr - (ptr + 1); // Number of digits we have - while (digits < 6) - { - digits++; - ts->tv_usec *= 10; - } - } - else + const int timestamp_str_len_till_min = 16; + const int timestamp_str_len_till_sec = 19; + char timestamp_sec[32]; + strncpy(timestamp_sec, date_time, timestamp_str_len_till_sec); + timestamp_sec[timestamp_str_len_till_sec] = '\0'; + if(strlen(cached_timestamp_upto_min) && cached_sec_since_epoch && (strncmp(timestamp_sec, cached_timestamp_upto_min, timestamp_str_len_till_min) == 0)) + { + int sec_part = strtoul(timestamp_sec+timestamp_str_len_till_min+1, NULL, 10); + ts->tv_sec = cached_sec_since_epoch + sec_part; + } + else + { + struct tm tm; + memset(&tm, 0, sizeof(struct tm)); + strptime(date_time, "%Y-%m-%d %H:%M:%S", &tm); + // Convert time to epoch - mktime assumes localtime so most adjust for that + ts->tv_sec = mktime(&tm); + + extern long timezone; + ts->tv_sec -= timezone; + + strncpy(cached_timestamp_upto_min, timestamp_sec, timestamp_str_len_till_min); + cached_timestamp_upto_min[timestamp_str_len_till_min] = '\0'; + cached_sec_since_epoch = ts->tv_sec - tm.tm_sec; // store only for upto-minute part + } + + // Now process the fractional seconds + const char *ptr = date_time; + while (*ptr && *ptr != '.') + ptr++; + if (*ptr) + { + char *eptr; + ts->tv_usec = strtol(ptr + 1, &eptr, 10); + int digits = eptr - (ptr + 1); // Number of digits we have + while (digits < 6) { - ts->tv_usec = 0; + digits++; + ts->tv_usec *= 10; } + } + else + { + ts->tv_usec = 0; + } - // Get the timezone from the string and convert to UTC - ptr = date_time + 10; // Skip date as it contains '-' characters - while (*ptr && *ptr != '-' && *ptr != '+') - ptr++; - if (*ptr) - { - int h, m; - int sign = (*ptr == '+' ? -1 : +1); - ptr++; - sscanf(ptr, "%02d:%02d", &h, &m); - ts->tv_sec += sign * ((3600 * h) + (60 * m)); - } - -#if 0 - auto end = std::chrono::high_resolution_clock::now(); - std::ostringstream os; - os << std::chrono::duration_cast(end - start).count(); - /*Logger::getLogger()->error("Reading::stringToTimestamp(): took %s (%d) microseconds for timestamp=%s", - os.str().c_str(), std::stoul(os.str(), nullptr, 0), timestamp.c_str() ); */ - - acc_time_taken_usec += std::stoul(os.str(), nullptr, 0); - reading_count++; - // Logger::getLogger()->error("Reading::stringToTimestamp(): acc_time_taken_usec=%d microseconds for %d readings", acc_time_taken_usec, reading_count); - - if(reading_count >= 80000) - { - Logger::getLogger()->error("Reading::stringToTimestamp(): took %d usec for %d readings: an average of %f usec per reading", - acc_time_taken_usec, reading_count, float(acc_time_taken_usec)/reading_count); - acc_time_taken_usec = 0; - reading_count = 0; - } -#endif + // Get the timezone from the string and convert to UTC + ptr = date_time + 10; // Skip date as it contains '-' characters + while (*ptr && *ptr != '-' && *ptr != '+') + ptr++; + if (*ptr) + { + int h, m; + int sign = (*ptr == '+' ? -1 : +1); + h = strtoul(ptr+1, NULL, 10); + m = strtoul(ptr+3, NULL, 10); + ts->tv_sec += sign * ((3600 * h) + (60 * m)); + } } From 597c4a102840b81c99b791bfc87d170ea0187fcb Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 18 Jan 2023 09:37:33 +0000 Subject: [PATCH 065/499] FOGL-7362 Only run storage script to create schema if it exists (#937) Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch --- scripts/services/storage | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/services/storage b/scripts/services/storage index dcea1179e9..05131671ed 100755 --- a/scripts/services/storage +++ b/scripts/services/storage @@ -68,7 +68,9 @@ if [[ "$1" != "--readingsPlugin" ]]; then storagePlugin=${res[0]} managedEngine=${res[1]} # Call plugin check: this will create database if not set yet - ${pluginScriptPath}/${storagePlugin}.sh init ${FLEDGE_SCHEMA} ${managedEngine} + if [[ -x ${pluginScriptPath}/${storagePlugin}.sh ]]; then + ${pluginScriptPath}/${storagePlugin}.sh init ${FLEDGE_SCHEMA} ${managedEngine} + fi fi # Run storage service From 0461ee1c8c03882123b8f9d081e02ca71023e3a2 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 18 Jan 2023 10:16:57 +0000 Subject: [PATCH 066/499] FOGL-7328 Optimisations for appending readings (#936) * Improve performance of appendReadings. Also make purge no longer sleep Signed-off-by: Mark Riddoch * Checkpoint Signed-off-by: Mark Riddoch * Checkpoint Signed-off-by: Mark Riddoch * Use insitu JSON parsign in appendReading Signed-off-by: Mark Riddoch * Fix increment error Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch --- C/common/include/storage_client.h | 2 +- C/common/storage_client.cpp | 96 +++- .../storage/sqlitelb/common/readings.cpp | 493 +++++++++++++++--- C/plugins/storage/sqlitememory/plugin.cpp | 16 + C/services/storage/storage_api.cpp | 5 +- C/services/storage/stream_handler.cpp | 169 ++++-- 6 files changed, 637 insertions(+), 144 deletions(-) diff --git a/C/common/include/storage_client.h b/C/common/include/storage_client.h index 21a9dc5104..4b5ab7f59a 100644 --- a/C/common/include/storage_client.h +++ b/C/common/include/storage_client.h @@ -26,7 +26,7 @@ using HttpClient = SimpleWeb::Client; -#define STREAM_BLK_SIZE 50 // Readings to send per write call to a stream +#define STREAM_BLK_SIZE 100 // Readings to send per write call to a stream #define STREAM_THRESHOLD 25 // Switch to streamed mode above this number of readings per second // Backup values for repeated storage client exception messages diff --git a/C/common/storage_client.cpp b/C/common/storage_client.cpp index 2e7b916e6c..5360326f89 100644 --- a/C/common/storage_client.cpp +++ b/C/common/storage_client.cpp @@ -27,7 +27,10 @@ #define EXCEPTION_BUFFER_SIZE 120 -#define INSTRUMENT 0 +#define INSTRUMENT 0 +// Streaming is currently disabled due to an issue that causes the stream to +// hang after a period. Set the followign to 1 in order to enable streaming +#define ENABLE_STREAMING 0 #if INSTRUMENT #include @@ -160,8 +163,8 @@ bool StorageClient::readingAppend(const vector& readings) double timeSpan = dur.tv_sec + ((double)dur.tv_usec / 1000000); double rate = (double)readings.size() / timeSpan; // Stream functionality disabled - // if (rate > STREAM_THRESHOLD) - if (0) +#if ENABLE_STREAMING + if (rate > STREAM_THRESHOLD) { m_logger->info("Reading rate %.1f readings per second above threshold, attmempting to switch to stream mode", rate); if (openStream()) @@ -171,6 +174,7 @@ bool StorageClient::readingAppend(const vector& readings) } m_logger->warn("Failed to switch to streaming mode"); } +#endif static HttpClient *httpClient = this->getHttpClient(); // to initialize m_seqnum_map[thread_id] for this thread try { std::thread::id thread_id = std::this_thread::get_id(); @@ -1151,7 +1155,7 @@ void StorageClient::handleUnexpectedResponse(const char *operation, const string /** * Standard logging method for all interactions * - * @param operation The operation beign undertaken + * @param operation The operation being undertaken * @param responseCode The HTTP response code * @param payload The payload in the response message */ @@ -1371,7 +1375,13 @@ bool StorageClient::unregisterTableNotification(const string& tableName, const s return false; } - +/* + * Attempt to open a streaming connection to the storage service. We use a REST API + * call to create the stream. If successful this call will return a port and a token + * to use when sending data via the stream. + * + * @return bool Return true if the stream was setup + */ bool StorageClient::openStream() { try { @@ -1452,6 +1462,33 @@ bool StorageClient::openStream() /** * Stream a set of readings to the storage service. * + * The stream uses a TCP connection to the storage system, it sends + * blocks of readings to the storage engine and bypasses the usual + * JSON conversion and imoprtantly parsing on the storage system + * side. + * + * A block of readings is introduced by a block header, the block + * header contains a magic number, the block number and the count + * of the number of readings in a block. + * + * Each reading within the block is preceeded by a reading header + * that contains a magic number, a reading number within the block, + * The length of the asset name for the reading, the length of the + * payload within the reading. The reading itself follows the herader + * and consists of the timestamp as a binary timeval structure, the name + * of the asset, including the null terminator. If the asset name length + * is 0 then no asset name is sent and the name of the asset is the same + * as the previous asset in the block. Following this the paylod is included. + * + * Each block is sent to the storage layer in a number of chunks rather + * that a single write per block. The implementation make use of the + * Linux scatter/gather IO calls to reduce the number of copies of data + * that are required. + * + * Currently there is no acknowledement handling as TCP is used as the underlying + * transport and the TCP acknowledgement is assumed to be a good enough + * indication of delivery. + * * TODO Deal with acknowledgements, add error checking/recovery * * @param readings The readings to stream @@ -1462,7 +1499,7 @@ bool StorageClient::streamReadings(const std::vector & readings) RDSBlockHeader blkhdr; RDSReadingHeader rdhdrs[STREAM_BLK_SIZE]; register RDSReadingHeader *phdr; -struct { const void *iov_base; size_t iov_len;} iovs[STREAM_BLK_SIZE * 4], *iovp; +struct iovec iovs[STREAM_BLK_SIZE * 4], *iovp; string payloads[STREAM_BLK_SIZE]; struct timeval tm[STREAM_BLK_SIZE]; ssize_t n, length = 0; @@ -1471,6 +1508,7 @@ string lastAsset; if (!m_streaming) { + m_logger->warn("Attempt to send data via a storage stream when streaming is not setup"); return false; } @@ -1486,7 +1524,7 @@ string lastAsset; { if (errno == EPIPE || errno == ECONNRESET) { - Logger::getLogger()->warn("Storage service has closed stream unexpectedly"); + Logger::getLogger()->error("Storage service has closed stream unexpectedly"); m_streaming = false; } else @@ -1498,7 +1536,7 @@ string lastAsset; /* * Use the writev scatter/gather interface to send the reading headers and reading data. - * We sent chunks of data in order to allow the parallel sendign and unpacking process + * We sent chunks of data in order to allow the parallel sending and unpacking process * at the two ends. The chunk size is STREAM_BLK_SIZE readings. */ iovp = iovs; @@ -1521,7 +1559,7 @@ string lastAsset; phdr->assetLength = assetCode.length() + 1; } - // Alwayts generate the JSON variant of the data points and send + // Always generate the JSON variant of the data points and send payloads[offset] = readings[i]->getDatapointsJSON(); phdr->payloadLength = payloads[offset].length() + 1; @@ -1541,14 +1579,14 @@ string lastAsset; // If the asset code has changed than add that if (phdr->assetLength) { - iovp->iov_base = readings[i]->getAssetName().c_str(); + iovp->iov_base = (void *)(readings[i]->getAssetName().c_str()); // Cast away const due to iovec definition iovp->iov_len = phdr->assetLength; length += iovp->iov_len; iovp++; } // Add the data points themselves - iovp->iov_base = payloads[offset].c_str(); + iovp->iov_base = (void *)(payloads[offset].c_str()); // Cast away const due to iovec definition iovp->iov_len = phdr->payloadLength; length += iovp->iov_len; iovp++; @@ -1556,23 +1594,31 @@ string lastAsset; offset++; if (offset == STREAM_BLK_SIZE - 1) { + if (iovp - iovs > STREAM_BLK_SIZE * 4) + Logger::getLogger()->error("Too many iov blocks %d", iovp - iovs); + // Send a chunk of readings in the block n = writev(m_stream, (const iovec *)iovs, iovp - iovs); - if (n < length) + if (n == -1) { if (errno == EPIPE || errno == ECONNRESET) { Logger::getLogger()->error("Stream has been closed by the storage service"); m_streaming = false; } - else - { - Logger::getLogger()->error("Write of block short, %d < %d: %s", + Logger::getLogger()->error("Write of block %d filed: %s", + m_readingBlock - 1, strerror(errno)); + return false; + } + else if (n < length) + { + Logger::getLogger()->error("Write of block short, %d < %d: %s", n, length, strerror(errno)); - } return false; } else if (n > length) + { Logger::getLogger()->fatal("Long write %d < %d", length, n); + } offset = 0; length = 0; iovp = iovs; @@ -1583,25 +1629,33 @@ string lastAsset; phdr++; } } + if (length) // Remaining data to be sent to finish the block { - if ((n = writev(m_stream, (const iovec *)iovs, iovp - iovs)) < length) + n = writev(m_stream, (const iovec *)iovs, iovp - iovs); + if (n == -1) { if (errno == EPIPE || errno == ECONNRESET) { Logger::getLogger()->error("Stream has been closed by the storage service"); m_streaming = false; } - else - { - Logger::getLogger()->error("Write of block short, %d < %d: %s", + Logger::getLogger()->error("Write of block %d filed: %s", + m_readingBlock - 1, strerror(errno)); + return false; + } + else if (n < length) + { + Logger::getLogger()->error("Write of block short, %d < %d: %s", n, length, strerror(errno)); - } return false; } else if (n > length) + { Logger::getLogger()->fatal("Long write %d < %d", length, n); + } } + Logger::getLogger()->info("Written block of %d readings via streaming connection", readings.size()); return true; } diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index e0adef63fa..3675cc5b97 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -22,8 +22,28 @@ #include #endif +/* + * The number of readings to insert in a single prepared statement + */ +#define APPEND_BATCH_SIZE 100 + +/* + * JSON parsing requires a lot of mempry allocation, which is slow and causes + * bottlenecks with thread synchronisation. RapidJSON supports in-situ parsing + * whereby it will reuse the storage of the string it is parsing to store the + * keys and string values of the parsed JSON. This is destructive on the buffer. + * However it can be quicker to maek a copy of the raw string and then do in-situ + * parsing on that copy of the string. + * See http://rapidjson.org/md_doc_dom.html#InSituParsing + * + * Define a threshold length for the append readings to switch to using in-situ + * parsing of the JSON to save on memory allocation overheads. Define as 0 to + * disable the in-situ parsing. + */ +#define INSITU_THRESHOLD 10240 + // Decode stream data -#define RDS_USER_TIMESTAMP(stream, x) stream[x]->userTs +#define RDS_USER_TIMESTAMP(stream, x) stream[x]->userTs #define RDS_ASSET_CODE(stream, x) stream[x]->assetCode #define RDS_PAYLOAD(stream, x) &(stream[x]->assetCode[0]) + stream[x]->assetCodeLength @@ -392,15 +412,23 @@ int Connection::readingStream(ReadingStream **readings, bool commit) int sleep_time_ms = 0; // SQLite related - sqlite3_stmt *stmt; + sqlite3_stmt *stmt, *batch_stmt; int sqlite3_resut; int rowNumber = -1; - + #if INSTRUMENT struct timeval start, t1, t2, t3, t4, t5; #endif - const char *sql_cmd = "INSERT INTO " READINGS_DB_NAME_BASE ".readings ( asset_code, reading, user_ts ) VALUES (?,?,?)"; + const char *sql_cmd = "INSERT INTO " READINGS_DB_NAME_BASE ".readings ( user_ts, asset_code, reading ) VALUES (?,?,?)"; + string cmd = sql_cmd; + for (int i = 0; i < APPEND_BATCH_SIZE - 1; i++) + { + cmd.append(", (?,?,?)"); + } + + sqlite3_prepare_v2(dbHandle, sql_cmd, strlen(sql_cmd), &stmt, NULL); + sqlite3_prepare_v2(dbHandle, cmd.c_str(), cmd.length(), &batch_stmt, NULL); if (sqlite3_prepare_v2(dbHandle, sql_cmd, strlen(sql_cmd), &stmt, NULL) != SQLITE_OK) { @@ -427,24 +455,144 @@ int Connection::readingStream(ReadingStream **readings, bool commit) gettimeofday(&start, NULL); #endif + int nReadings; + for (nReadings = 0; readings[nReadings]; nReadings++); + try { - for (i = 0; readings[i]; i++) + + unsigned int nBatches = nReadings / APPEND_BATCH_SIZE; + int curReading = 0; + for (int batch = 0; batch < nBatches; batch++) + { + int varNo = 1; + for (int readingNo = 0; readingNo < APPEND_BATCH_SIZE; readingNo++) + { + add_row = true; + + // Handles - asset_code + asset_code = RDS_ASSET_CODE(readings, curReading); + + // Handles - reading + payload = RDS_PAYLOAD(readings, curReading); + reading = escape(payload); + + // Handles - user_ts + memset(&timeinfo, 0, sizeof(struct tm)); + gmtime_r(&RDS_USER_TIMESTAMP(readings, curReading).tv_sec, &timeinfo); + std::strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &timeinfo); + snprintf(micro_s, sizeof(micro_s), ".%06lu", RDS_USER_TIMESTAMP(readings, curReading).tv_usec); + + formatted_date[0] = {0}; + strncat(ts, micro_s, 10); + user_ts = ts; + if (strcmp(user_ts, "now()") == 0) + { + getNow(now); + user_ts = now.c_str(); + } + else + { + if (!formatDate(formatted_date, sizeof(formatted_date), user_ts)) + { + raiseError("streamReadings", "Invalid date |%s|", user_ts); + add_row = false; + } + else + { + user_ts = formatted_date; + } + } + + if (add_row) + { + if (batch_stmt != NULL) + { + sqlite3_bind_text(batch_stmt, varNo++, user_ts, -1, SQLITE_STATIC); + sqlite3_bind_text(batch_stmt, varNo++, asset_code, -1, SQLITE_STATIC); + sqlite3_bind_text(batch_stmt, varNo++, reading.c_str(), -1, SQLITE_STATIC); + } + } + curReading++; + } + // Write the batch + + retries = 0; + sleep_time_ms = 0; + + // Retry mechanism in case SQLlite DB is locked + do { + // Insert the row using a lock to ensure one insert at time + { + m_writeAccessOngoing.fetch_add(1); + //unique_lock lck(db_mutex); + + sqlite3_resut = sqlite3_step(batch_stmt); + + m_writeAccessOngoing.fetch_sub(1); + //db_cv.notify_all(); + } + + if (sqlite3_resut == SQLITE_LOCKED ) + { + sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); + retries++; + + Logger::getLogger()->info("SQLITE_LOCKED - record :%d: - retry number :%d: sleep time ms :%d:",i, retries, sleep_time_ms); + + std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); + } + if (sqlite3_resut == SQLITE_BUSY) + { + ostringstream threadId; + threadId << std::this_thread::get_id(); + + sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); + retries++; + + Logger::getLogger()->info("SQLITE_BUSY - thread :%s: - record :%d: - retry number :%d: sleep time ms :%d:", threadId.str().c_str() ,i , retries, sleep_time_ms); + + std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); + } + } while (retries < PREP_CMD_MAX_RETRIES && (sqlite3_resut == SQLITE_LOCKED || sqlite3_resut == SQLITE_BUSY)); + + if (sqlite3_resut == SQLITE_DONE) + { + rowNumber++; + + sqlite3_clear_bindings(batch_stmt); + sqlite3_reset(batch_stmt); + } + else + { + raiseError("streamReadings", + "Inserting a row into SQLite using a prepared command - asset_code :%s: error :%s: reading :%s: ", + asset_code, + sqlite3_errmsg(dbHandle), + reading.c_str()); + + sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION", NULL, NULL, NULL); + m_streamOpenTransaction = true; + return -1; + } + } + + while (readings[curReading]) { add_row = true; // Handles - asset_code - asset_code = RDS_ASSET_CODE(readings, i); + asset_code = RDS_ASSET_CODE(readings, curReading); // Handles - reading - payload = RDS_PAYLOAD(readings, i); + payload = RDS_PAYLOAD(readings, curReading); reading = escape(payload); // Handles - user_ts memset(&timeinfo, 0, sizeof(struct tm)); - gmtime_r(&RDS_USER_TIMESTAMP(readings, i).tv_sec, &timeinfo); + gmtime_r(&RDS_USER_TIMESTAMP(readings, curReading).tv_sec, &timeinfo); std::strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &timeinfo); - snprintf(micro_s, sizeof(micro_s), ".%06lu", RDS_USER_TIMESTAMP(readings, i).tv_usec); + snprintf(micro_s, sizeof(micro_s), ".%06lu", RDS_USER_TIMESTAMP(readings, curReading).tv_usec); formatted_date[0] = {0}; strncat(ts, micro_s, 10); @@ -458,7 +606,7 @@ int Connection::readingStream(ReadingStream **readings, bool commit) { if (!formatDate(formatted_date, sizeof(formatted_date), user_ts)) { - raiseError("appendReadings", "Invalid date |%s|", user_ts); + raiseError("streamReadings", "Invalid date |%s|", user_ts); add_row = false; } else @@ -469,78 +617,79 @@ int Connection::readingStream(ReadingStream **readings, bool commit) if (add_row) { - if (stmt != NULL) + if (batch_stmt != NULL) { - sqlite3_bind_text(stmt, 1, asset_code, -1, SQLITE_STATIC); - sqlite3_bind_text(stmt, 2, reading.c_str(), -1, SQLITE_STATIC); - sqlite3_bind_text(stmt, 3, user_ts, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 1, user_ts, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, asset_code, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 3, reading.c_str(), -1, SQLITE_STATIC); + } + } + curReading++; - retries =0; - sleep_time_ms = 0; - // Retry mechanism in case SQLlite DB is locked - do { - // Insert the row using a lock to ensure one insert at time - { - m_writeAccessOngoing.fetch_add(1); - //unique_lock lck(db_mutex); + retries =0; + sleep_time_ms = 0; - sqlite3_resut = sqlite3_step(stmt); + // Retry mechanism in case SQLlite DB is locked + do { + // Insert the row using a lock to ensure one insert at time + { + m_writeAccessOngoing.fetch_add(1); + //unique_lock lck(db_mutex); - m_writeAccessOngoing.fetch_sub(1); - //db_cv.notify_all(); - } + sqlite3_resut = sqlite3_step(stmt); - if (sqlite3_resut == SQLITE_LOCKED ) - { - sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); - retries++; - - Logger::getLogger()->info("SQLITE_LOCKED - record :%d: - retry number :%d: sleep time ms :%d:",i, retries, sleep_time_ms); + m_writeAccessOngoing.fetch_sub(1); + //db_cv.notify_all(); + } - std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); - } - if (sqlite3_resut == SQLITE_BUSY) - { - ostringstream threadId; - threadId << std::this_thread::get_id(); + if (sqlite3_resut == SQLITE_LOCKED ) + { + sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); + retries++; - sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); - retries++; + Logger::getLogger()->info("SQLITE_LOCKED - record :%d: - retry number :%d: sleep time ms :%d:",i, retries, sleep_time_ms); - Logger::getLogger()->info("SQLITE_BUSY - thread :%s: - record :%d: - retry number :%d: sleep time ms :%d:", threadId.str().c_str() ,i , retries, sleep_time_ms); + std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); + } + if (sqlite3_resut == SQLITE_BUSY) + { + ostringstream threadId; + threadId << std::this_thread::get_id(); - std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); - } - } while (retries < PREP_CMD_MAX_RETRIES && (sqlite3_resut == SQLITE_LOCKED || sqlite3_resut == SQLITE_BUSY)); + sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); + retries++; - if (sqlite3_resut == SQLITE_DONE) - { - rowNumber++; + Logger::getLogger()->info("SQLITE_BUSY - thread :%s: - record :%d: - retry number :%d: sleep time ms :%d:", threadId.str().c_str() ,i , retries, sleep_time_ms); - sqlite3_clear_bindings(stmt); - sqlite3_reset(stmt); - } - else - { - raiseError("appendReadings", - "Inserting a row into SQLIte using a prepared command - asset_code :%s: error :%s: reading :%s: ", - asset_code, - sqlite3_errmsg(dbHandle), - reading.c_str()); - - sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION", NULL, NULL, NULL); - m_streamOpenTransaction = true; - return -1; - } + std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); } + } while (retries < PREP_CMD_MAX_RETRIES && (sqlite3_resut == SQLITE_LOCKED || sqlite3_resut == SQLITE_BUSY)); + + if (sqlite3_resut == SQLITE_DONE) + { + rowNumber++; + + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); + } + else + { + raiseError("streamReadings", + "Inserting a row into SQLite using a prepared command - asset_code :%s: error :%s: reading :%s: ", + asset_code, + sqlite3_errmsg(dbHandle), + reading.c_str()); + + sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION", NULL, NULL, NULL); + m_streamOpenTransaction = true; + return -1; } } - rowNumber = i; - + rowNumber = curReading; } catch (exception e) { - raiseError("appendReadings", "Inserting a row into SQLIte using a prepared command - error :%s:", e.what()); + raiseError("appendReadings", "Inserting a row into SQLite using a prepared statement - error :%s:", e.what()); sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION", NULL, NULL, NULL); m_streamOpenTransaction = true; @@ -584,12 +733,9 @@ int Connection::readingStream(ReadingStream **readings, bool commit) timersub(&t2, &t1, &tm); timeT2 = tm.tv_sec + ((double)tm.tv_usec / 1000000); - Logger::getLogger()->debug("readingStream row count :%d:", rowNumber); - Logger::getLogger()->debug("readingStream Timing - stream handling %.3f seconds - commit/finalize %.3f seconds", - timeT1, - timeT2 - ); + Logger::getLogger()->warn("readingStream Timing with %d rows - stream handling %.3f seconds - commit/finalize %.3f seconds", + rowNumber, timeT1, timeT2); #endif return rowNumber; @@ -609,7 +755,7 @@ bool add_row = false; const char *user_ts; const char *asset_code; string reading; -sqlite3_stmt *stmt; +sqlite3_stmt *stmt, *batch_stmt; int sqlite3_resut; string now; @@ -621,7 +767,7 @@ int sleep_time_ms = 0; threadId << std::this_thread::get_id(); #if INSTRUMENT - Logger::getLogger()->debug("appendReadings start thread :%s:", threadId.str().c_str()); + Logger::getLogger()->warn("appendReadings start thread :%s:", threadId.str().c_str()); struct timeval start, t1, t2, t3, t4, t5; #endif @@ -630,43 +776,212 @@ int sleep_time_ms = 0; gettimeofday(&start, NULL); #endif - ParseResult ok = doc.Parse(readings); + int len = strlen(readings) + 1; + char *readingsCopy = NULL; + ParseResult ok; +#if INSITU_THRESHOLD + if (len > INSITU_THRESHOLD) + { + readingsCopy = (char *)malloc(len); + memcpy(readingsCopy, readings, len); + ok = doc.ParseInsitu(readingsCopy); + } + else +#endif + { + ok = doc.Parse(readings); + } if (!ok) { raiseError("appendReadings", GetParseError_En(doc.GetParseError())); + if (readingsCopy) + { + free(readingsCopy); + } return -1; } if (!doc.HasMember("readings")) { raiseError("appendReadings", "Payload is missing a readings array"); + if (readingsCopy) + { + free(readingsCopy); + } return -1; } Value &readingsValue = doc["readings"]; if (!readingsValue.IsArray()) { raiseError("appendReadings", "Payload is missing the readings array"); + if (readingsCopy) + { + free(readingsCopy); + } return -1; } const char *sql_cmd="INSERT INTO " READINGS_DB_NAME_BASE ".readings ( user_ts, asset_code, reading ) VALUES (?,?,?)"; + string cmd = sql_cmd; + for (int i = 0; i < APPEND_BATCH_SIZE - 1; i++) + { + cmd.append(", (?,?,?)"); + } sqlite3_prepare_v2(dbHandle, sql_cmd, strlen(sql_cmd), &stmt, NULL); + sqlite3_prepare_v2(dbHandle, cmd.c_str(), cmd.length(), &batch_stmt, NULL); { m_writeAccessOngoing.fetch_add(1); //unique_lock lck(db_mutex); sqlite3_exec(dbHandle, "BEGIN TRANSACTION", NULL, NULL, NULL); #if INSTRUMENT - gettimeofday(&t1, NULL); + gettimeofday(&t1, NULL); #endif - for (Value::ConstValueIterator itr = readingsValue.Begin(); itr != readingsValue.End(); ++itr) + Value::ConstValueIterator itr = readingsValue.Begin(); + SizeType nReadings = readingsValue.Size(); + unsigned int nBatches = nReadings / APPEND_BATCH_SIZE; + Logger::getLogger()->debug("Write %d readings in %d batches of %d", nReadings, nBatches, APPEND_BATCH_SIZE); + for (int batch = 0; batch < nBatches; batch++) + { + int varNo = 1; + for (int readingNo = 0; readingNo < APPEND_BATCH_SIZE; readingNo++) + { + if (!itr->IsObject()) + { + char err[132]; + snprintf(err, sizeof(err), + "Each reading in the readings array must be an object. Reading %d of batch %d", readingNo, batch); + raiseError("appendReadings",err); + sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION;", NULL, NULL, NULL); + if (readingsCopy) + { + free(readingsCopy); + } + return -1; + } + + add_row = true; + + // Handles - user_ts + char formatted_date[LEN_BUFFER_DATE] = {0}; + user_ts = (*itr)["user_ts"].GetString(); + if (strcmp(user_ts, "now()") == 0) + { + getNow(now); + user_ts = now.c_str(); + } + else + { + if (! formatDate(formatted_date, sizeof(formatted_date), user_ts) ) + { + raiseError("appendReadings", "Invalid date |%s|", user_ts); + add_row = false; + } + else + { + user_ts = formatted_date; + } + } + + if (add_row) + { + // Handles - asset_code + asset_code = (*itr)["asset_code"].GetString(); + + // Handles - reading + StringBuffer buffer; + Writer writer(buffer); + (*itr)["reading"].Accept(writer); + reading = escape(buffer.GetString()); + + if (stmt != NULL) + { + + sqlite3_bind_text(batch_stmt, varNo++, user_ts, -1, SQLITE_STATIC); + sqlite3_bind_text(batch_stmt, varNo++, asset_code, -1, SQLITE_STATIC); + sqlite3_bind_text(batch_stmt, varNo++, reading.c_str(), -1, SQLITE_STATIC); + } + } + + itr++; + if (itr == readingsValue.End()) + break; + } + + + retries =0; + sleep_time_ms = 0; + + // Retry mechanism in case SQLlite DB is locked + do { + // Insert the row using a lock to ensure one insert at time + { + + sqlite3_resut = sqlite3_step(batch_stmt); + + } + if (sqlite3_resut == SQLITE_LOCKED ) + { + sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); + retries++; + + Logger::getLogger()->info("SQLITE_LOCKED - record :%d: - retry number :%d: sleep time ms :%d:" ,row ,retries ,sleep_time_ms); + + std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); + } + if (sqlite3_resut == SQLITE_BUSY) + { + ostringstream threadId; + threadId << std::this_thread::get_id(); + + sleep_time_ms = PREP_CMD_RETRY_BASE + (random() % PREP_CMD_RETRY_BACKOFF); + retries++; + + Logger::getLogger()->info("SQLITE_BUSY - thread :%s: - record :%d: - retry number :%d: sleep time ms :%d:", threadId.str().c_str() ,row, retries, sleep_time_ms); + + std::this_thread::sleep_for(std::chrono::milliseconds(sleep_time_ms)); + } + } while (retries < PREP_CMD_MAX_RETRIES && (sqlite3_resut == SQLITE_LOCKED || sqlite3_resut == SQLITE_BUSY)); + + if (sqlite3_resut == SQLITE_DONE) + { + row += APPEND_BATCH_SIZE; + + sqlite3_clear_bindings(batch_stmt); + sqlite3_reset(batch_stmt); + } + else + { + raiseError("appendReadings","Inserting a row into SQLite using a prepared command - asset_code :%s: error :%s: reading :%s: ", + asset_code, + sqlite3_errmsg(dbHandle), + reading.c_str()); + + sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION", NULL, NULL, NULL); + if (readingsCopy) + { + free(readingsCopy); + } + return -1; + } + + + } + + Logger::getLogger()->debug("Now do the remaining readings"); + // Do individual inserts for the remainder of the readings + while (itr != readingsValue.End()) { if (!itr->IsObject()) { raiseError("appendReadings","Each reading in the readings array must be an object"); sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION;", NULL, NULL, NULL); + if (readingsCopy) + { + free(readingsCopy); + } return -1; } @@ -753,16 +1068,21 @@ int sleep_time_ms = 0; } else { - raiseError("appendReadings","Inserting a row into SQLIte using a prepared command - asset_code :%s: error :%s: reading :%s: ", + raiseError("appendReadings","Inserting a row into SQLite using a prepared command - asset_code :%s: error :%s: reading :%s: ", asset_code, sqlite3_errmsg(dbHandle), reading.c_str()); sqlite3_exec(dbHandle, "ROLLBACK TRANSACTION", NULL, NULL, NULL); + if (readingsCopy) + { + free(readingsCopy); + } return -1; } } } + itr++; } sqlite3_resut = sqlite3_exec(dbHandle, "END TRANSACTION", NULL, NULL, NULL); @@ -787,6 +1107,10 @@ int sleep_time_ms = 0; } } + if (readingsCopy) + { + free(readingsCopy); + } #if INSTRUMENT gettimeofday(&t3, NULL); #endif @@ -804,7 +1128,7 @@ int sleep_time_ms = 0; timersub(&t3, &t2, &tm); timeT3 = tm.tv_sec + ((double)tm.tv_usec / 1000000); - Logger::getLogger()->debug("appendReadings end thread :%s: buffer :%10lu: count :%5d: JSON :%6.3f: inserts :%6.3f: finalize :%6.3f:", + Logger::getLogger()->warn("appendReadings end thread :%s: buffer :%10lu: count :%5d: JSON :%6.3f: inserts :%6.3f: finalize :%6.3f:", threadId.str().c_str(), strlen(readings), row, @@ -1512,13 +1836,16 @@ unsigned int Connection::purgeReadings(unsigned long age, unsentPurged = unsent; } } +#if 0 if (m_writeAccessOngoing) { while (m_writeAccessOngoing) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); + logger->warn("Yielding for another write access"); + std::this_thread::yield(); } } +#endif unsigned int deletedRows = 0; char *zErrMsg = NULL; @@ -1562,7 +1889,7 @@ unsigned int Connection::purgeReadings(unsigned long age, if(usecs>150000) { - std::this_thread::sleep_for(std::chrono::milliseconds(100+usecs/10000)); + std::this_thread::yield(); // Give other threads a chance to run } } @@ -1601,7 +1928,7 @@ unsigned int Connection::purgeReadings(unsigned long age, purgeBlockSize = MAX_PURGE_DELETE_BLOCK_SIZE; logger->debug("Changed purgeBlockSize to %d", purgeBlockSize); } - std::this_thread::sleep_for(std::chrono::milliseconds(100)); + std::this_thread::yield(); // Give other threads a chance to run } //Logger::getLogger()->debug("Purge delete block #%d with %d readings", blocks, rowsAffected); } while (rowidMin < rowidLimit); @@ -1772,7 +2099,7 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, unsentPurged += rowsAffected; } } - std::this_thread::sleep_for(std::chrono::milliseconds(1)); + std::this_thread::yield(); // Give other threads a chane to run } while (rowcount > rows); @@ -1824,13 +2151,15 @@ unsigned int rowsAffected = 0; logSQL("ReadingsAssetPurge", query); +#if 0 if (m_writeAccessOngoing) { while (m_writeAccessOngoing) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); + std::this_thread::yield(); } } +#endif START_TIME; // Exec DELETE query: no callback, no resultset diff --git a/C/plugins/storage/sqlitememory/plugin.cpp b/C/plugins/storage/sqlitememory/plugin.cpp index f05a6d4a5d..88cf174863 100644 --- a/C/plugins/storage/sqlitememory/plugin.cpp +++ b/C/plugins/storage/sqlitememory/plugin.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include using namespace std; @@ -97,6 +98,21 @@ Connection *connection = manager->allocate(); return result;; } +/** + * Append a stream of readings to the readings buffer + */ +int plugin_readingStream(PLUGIN_HANDLE handle, ReadingStream **readings, bool commit) +{ + int result = 0; + ConnectionManager *manager = (ConnectionManager *)handle; + Connection *connection = manager->allocate(); + + result = connection->readingStream(readings, commit); + + manager->release(connection); + return result;; +} + /** * Fetch a block of readings from the readings buffer */ diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp index 64e9b235ef..5169fda47c 100644 --- a/C/services/storage/storage_api.cpp +++ b/C/services/storage/storage_api.cpp @@ -1256,14 +1256,11 @@ string responsePayload; /** * Append the readings that have arrived via a stream to the storage plugin * - * @param readings A Null terminiunated array of points to ReadingStream structures + * @param readings A Null terminated array of points to ReadingStream structures * @param commit A flag to commit the readings block */ bool StorageApi::readingStream(ReadingStream **readings, bool commit) { - int c; - for (c = 0; readings[c]; c++); - Logger::getLogger()->debug("ReadingStream called with %d", c); if ((readingPlugin ? readingPlugin : plugin)->hasStreamSupport()) { return (readingPlugin ? readingPlugin : plugin)->readingStream(readings, commit); diff --git a/C/services/storage/stream_handler.cpp b/C/services/storage/stream_handler.cpp index 49cac60f64..66e4e25b0e 100644 --- a/C/services/storage/stream_handler.cpp +++ b/C/services/storage/stream_handler.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -74,11 +73,27 @@ void StreamHandler::handler() } else { - int nfds = epoll_wait(m_pollfd, events, MAX_EVENTS, 1); - for (int i = 0; i < nfds; i++) + /* + * Call epoll_wait with a zero timeout to see if any data is available. + * If not then call with a tiemout. This prevents Linux from scheduling + * us out if there is data on the socket. + */ + int nfds = epoll_wait(m_pollfd, events, MAX_EVENTS, 100); + if (nfds == 0) { - Stream *stream = (Stream *)events[i].data.ptr; - stream->handleEvent(m_pollfd, m_api, events[i].events); + nfds = epoll_wait(m_pollfd, events, MAX_EVENTS, 100); + } + if (nfds == -1) + { + Logger::getLogger()->error("Stream epoll error: %s", strerror(errno)); + } + else + { + for (int i = 0; i < nfds; i++) + { + Stream *stream = (Stream *)events[i].data.ptr; + stream->handleEvent(m_pollfd, m_api, events[i].events); + } } } } @@ -125,6 +140,9 @@ StreamHandler::Stream::~Stream() * will connect to this port and then send the token to verify they are the * service that requested the stream to be connected. * + * The client calls a REST API endpoint in the storage layer to request a streaming + * connection which results in this method beign called. + * * @param epollfd The epoll descriptor * @param token The single use token the client will send in the connect request */ @@ -132,12 +150,14 @@ uint32_t StreamHandler::Stream::create(int epollfd, uint32_t *token) { struct sockaddr_in address; + // Create the memory pool from whuch readings will be allocated if ((m_blockPool = new MemoryPool(BLOCK_POOL_SIZES)) == NULL) { Logger::getLogger()->error("Failed to create memory block pool"); return 0; } + // Open the socket used to listen for the incoming stream connection if ((m_socket = socket(AF_INET, SOCK_STREAM, 0)) < 0) { Logger::getLogger()->error("Failed to create socket: %s", strerror(errno)); @@ -166,13 +186,15 @@ struct sockaddr_in address; } m_status = Listen; + // Create the random token that is used to verify the connection comes from the + // source that requested the streaming connection srand(m_port + (unsigned int)time(0)); m_token = (uint32_t)random() & 0xffffffff; *token = m_token; // Add to epoll set m_event.data.ptr = this; - m_event.events = EPOLLIN | EPOLLRDHUP; + m_event.events = EPOLLIN | EPOLLRDHUP | EPOLLHUP | EPOLLPRI | EPOLLERR; if (epoll_ctl(epollfd, EPOLL_CTL_ADD, m_socket, &m_event) < 0) { Logger::getLogger()->error("Failed to add listening port %d to epoll fileset, %s", m_port, strerror(errno)); @@ -198,6 +220,10 @@ void StreamHandler::Stream::setNonBlocking(int fd) * Handle an epoll event. The precise handling will depend * on the state of the stream. * + * One of the things done here is to handle the streaming protocol, + * reading the block header the individual reading headers and the + * readings themselves. + * * TODO Improve memory handling, use seperate threads for inserts, send acknowledgements * * @param epollfd The epoll file descriptor @@ -211,12 +237,38 @@ ssize_t n; // TODO mark this stream for destruction epoll_ctl(epollfd, EPOLL_CTL_DEL, m_socket, &m_event); close(m_socket); - Logger::getLogger()->warn("Closing stream..."); + Logger::getLogger()->error("Closing stream..."); + m_status = Closed; + } + if (events & EPOLLHUP) + { + // TODO mark this stream for destruction + epoll_ctl(epollfd, EPOLL_CTL_DEL, m_socket, &m_event); + close(m_socket); + Logger::getLogger()->error("Hangup on socket Closing stream..."); + m_status = Closed; + } + if (events & EPOLLPRI) + { + // TODO mark this stream for destruction + epoll_ctl(epollfd, EPOLL_CTL_DEL, m_socket, &m_event); + close(m_socket); + Logger::getLogger()->error("Eceptional condition on socket Closing stream..."); + m_status = Closed; + } + if (events & EPOLLERR) + { + // TODO mark this stream for destruction + epoll_ctl(epollfd, EPOLL_CTL_DEL, m_socket, &m_event); + close(m_socket); + m_status = Closed; + Logger::getLogger()->error("Error condition on socket Closing stream..."); } if (events & EPOLLIN) { if (m_status == Listen) { + // Accept the connection for the streaming data int conn_sock; struct sockaddr addr; socklen_t addrlen = sizeof(addr); @@ -226,15 +278,19 @@ ssize_t n; Logger::getLogger()->info("Accept failed for streaming socket: %s", strerror(errno)); return; } + + // Remove and close the listening socket now we have a connection epoll_ctl(epollfd, EPOLL_CTL_DEL, m_socket, &m_event); close(m_socket); Logger::getLogger()->info("Stream connection established"); m_socket = conn_sock; m_status = AwaitingToken; - setNonBlocking(m_socket); - m_event.events = EPOLLIN | EPOLLRDHUP; + m_event.events = EPOLLIN | EPOLLRDHUP | EPOLLHUP | EPOLLERR | EPOLLPRI | EPOLLET; m_event.data.ptr = this; - epoll_ctl(epollfd, EPOLL_CTL_ADD, m_socket, &m_event); + if (epoll_ctl(epollfd, EPOLL_CTL_ADD, m_socket, &m_event) == -1) + { + Logger::getLogger()->fatal("Failed to add data socket to epoll set: %s", strerror(errno)); + } } else if (m_status == AwaitingToken) { @@ -244,7 +300,10 @@ ssize_t n; return; } if ((n = read(m_socket, &hdr, sizeof(hdr))) != (int)sizeof(hdr)) - Logger::getLogger()->warn("Token exchange: Short read of %d bytes: %s", n, strerror(errno)); + { + Logger::getLogger()->warn("Token exchange failed: Short read of %d bytes: %s", n, strerror(errno)); + return; + } if (hdr.magic == RDS_CONNECTION_MAGIC && hdr.token == m_token) { m_status = Connected; @@ -261,6 +320,18 @@ ssize_t n; } else if (m_status == Connected) { + /* + * We are connected so loop on the available data reading block headers, + * reading headers and the readings themselves. + * + * We use the available method to see if there is enough data before we + * read in order to avoid blocking in a red call. This also allows to + * not have to set the socket to non-blocking mode. meaning that our + * epoll interaction does not need to be edge triggered. + * + * Once we exhaust the data that is aviaabnle we return and allow the + * epoll to inform us when more data becomes available. + */ while (1) { Logger::getLogger()->debug("Connected in protocol state %d, readingNo %d", m_protocolState, m_readingNo); @@ -274,8 +345,7 @@ ssize_t n; } if ((n = read(m_socket, &blkHdr, sizeof(blkHdr))) != (int)sizeof(blkHdr)) { - if (errno == EAGAIN) - return; + // This should never happen as avialable said we had enough data Logger::getLogger()->warn("Block Header: Short read of %d bytes: %s", n, strerror(errno)); return; } @@ -289,26 +359,38 @@ ssize_t n; } if (blkHdr.blockNumber != m_blockNo) { + // Somehow we lost a block } m_blockNo++; m_blockSize = blkHdr.count; m_protocolState = RdHdr; m_readingNo = 0; - Logger::getLogger()->debug("New block %d of %d readings", blkHdr.blockNumber, blkHdr.count); + Logger::getLogger()->info("New block %d of %d readings", blkHdr.blockNumber, blkHdr.count); } else if (m_protocolState == RdHdr) { + // We are expecting a reading header RDSReadingHeader rdhdr; if (available(m_socket) < sizeof(rdhdr)) { - Logger::getLogger()->debug("Not enough bytes for reading header"); + Logger::getLogger()->warn("Not enough bytes %d for reading header %d in block %d (socket %d)", available(m_socket), m_readingNo, m_blockNo - 1, m_socket); + static bool reported = false; + if (!reported) + { + char buf[40]; + int i; + i = recv(m_socket, buf, sizeof(buf), MSG_PEEK); + for (int j = 0; j < i; j++) + Logger::getLogger()->warn("Byte at %d is %x", j, buf[j]); + reported = true; + } return; } - if (read(m_socket, &rdhdr, sizeof(rdhdr)) < (int)sizeof(rdhdr)) + int n; + if ((n = read(m_socket, &rdhdr, sizeof(rdhdr))) < (int)sizeof(rdhdr)) { - if (errno == EAGAIN) - return; - Logger::getLogger()->warn("Not enough bytes for reading header"); + // Should never happen + Logger::getLogger()->warn("Not enough bytes read %d for reading header", n); return; } if (rdhdr.magic != RDS_READING_MAGIC) @@ -341,28 +423,45 @@ ssize_t n; } else if (m_protocolState == RdBody) { + // We are expecting a reading body if (available(m_socket) < m_readingSize) { - Logger::getLogger()->debug("Not enough bytes for reading %d", m_readingSize); + Logger::getLogger()->warn("Not enough bytes %d for reading %d in block %d", m_readingSize, m_readingNo, m_blockNo - 1); return; } - if (m_sameAsset) + struct iovec iov[3]; + + iov[0].iov_base = &m_currentReading->userTs; + iov[0].iov_len = sizeof(struct timeval); + + if (!m_sameAsset) { - if ((n = read(m_socket, &m_currentReading->userTs, sizeof(struct timeval))) != (int)sizeof(struct timeval)) - Logger::getLogger()->warn("Short read of %d bytes for timestamp: %s", n, strerror(errno)); - size_t plen = m_readingSize - sizeof(struct timeval); - uint32_t assetLen = m_currentReading->assetCodeLength; - if ((n = read(m_socket, &m_currentReading->assetCode[assetLen], plen)) < (int)plen) - Logger::getLogger()->warn("Short read of %d bytes for payload: %s", n, strerror(errno)); - memcpy(&m_currentReading->assetCode[0], m_lastAsset.c_str(), assetLen); + iov[1].iov_base = &m_currentReading->assetCode; + iov[1].iov_len = m_currentReading->assetCodeLength; + iov[2].iov_base = &m_currentReading->assetCode[m_currentReading->assetCodeLength]; + iov[2].iov_len = m_currentReading->payloadLength; + int n = readv(m_socket, iov, 3); + if (n != m_currentReading->assetCodeLength + + m_currentReading->payloadLength + sizeof(struct timeval)) + { + Logger::getLogger()->error("Short red for reading"); + } + + m_lastAsset = m_currentReading->assetCode; } else { - if ((n = read(m_socket, &m_currentReading->userTs, m_readingSize)) != (int)m_readingSize) - Logger::getLogger()->warn("Short read of %d bytes for reading: %s", n, strerror(errno)); - m_lastAsset = m_currentReading->assetCode; + iov[1].iov_base = &m_currentReading->assetCode[m_currentReading->assetCodeLength]; + iov[1].iov_len = m_currentReading->payloadLength; + int n = readv(m_socket, iov, 2); + if (n != m_currentReading->payloadLength + sizeof(struct timeval)) + { + Logger::getLogger()->error("Short red for reading"); + } + memcpy(&m_currentReading->assetCode[0], m_lastAsset.c_str(), m_currentReading->assetCodeLength); } m_readingNo++; + m_protocolState = RdHdr; if ((m_readingNo % RDS_BLOCK) == 0) { queueInsert(api, RDS_BLOCK, false); @@ -376,14 +475,12 @@ ssize_t n; queueInsert(api, m_readingNo % RDS_BLOCK, true); for (uint32_t i = 0; i < m_readingNo % RDS_BLOCK; i++) m_blockPool->release(m_readings[i]); - } - if (m_readingNo >= m_blockSize) - { m_protocolState = BlkHdr; + Logger::getLogger()->warn("Waiting for the next block header"); } - else + else if (m_readingNo > m_blockSize) { - m_protocolState = RdHdr; + Logger::getLogger()->error("Too many readings in block"); } } } From d0a57f0071a074abf271e46d336ddac627295f72 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Wed, 18 Jan 2023 16:12:40 +0530 Subject: [PATCH 067/499] Unit test fix Signed-off-by: Amandeep Singh Arora --- C/common/reading.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/C/common/reading.cpp b/C/common/reading.cpp index 3feafbf912..186eb87a20 100644 --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -236,8 +236,9 @@ void Reading::getFormattedDateTimeStr(const time_t *tv_sec, char *date_time, rea { static unsigned long cached_sec_since_epoch = 0; static char cached_date_time_str[DATE_TIME_BUFFER_LEN] = ""; + static readingTimeFormat cachedDateFormat = (readingTimeFormat) 0xff; - if(strlen(cached_date_time_str) && cached_sec_since_epoch && *tv_sec == cached_sec_since_epoch) + if(strlen(cached_date_time_str) && cached_sec_since_epoch && *tv_sec == cached_sec_since_epoch && cachedDateFormat == dateFormat) { strncpy(date_time, cached_date_time_str, DATE_TIME_BUFFER_LEN); date_time[DATE_TIME_BUFFER_LEN-1] = '\0'; @@ -262,6 +263,7 @@ void Reading::getFormattedDateTimeStr(const time_t *tv_sec, char *date_time, rea strncpy(cached_date_time_str, date_time, DATE_TIME_BUFFER_LEN); cached_date_time_str[DATE_TIME_BUFFER_LEN-1] = '\0'; cached_sec_since_epoch = *tv_sec; + cachedDateFormat = dateFormat; } From 4afd57308485ad0d7605e98883d3e35f2cdb98d8 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Wed, 18 Jan 2023 17:10:32 +0530 Subject: [PATCH 068/499] Handling review feedback Signed-off-by: Amandeep Singh Arora --- C/common/asset_tracking.cpp | 3 - C/common/pythonreadingset.cpp | 2 - C/common/reading.cpp | 64 +++++++++++-------- .../ingest_callback_pymodule.cpp | 7 -- .../ingest_callback_pymodule.cpp | 6 -- 5 files changed, 38 insertions(+), 44 deletions(-) diff --git a/C/common/asset_tracking.cpp b/C/common/asset_tracking.cpp index 5a8c0b0153..498f3e9eda 100644 --- a/C/common/asset_tracking.cpp +++ b/C/common/asset_tracking.cpp @@ -53,9 +53,6 @@ void AssetTracker::populateAssetTrackingCache(string /*plugin*/, string /*event* for (AssetTrackingTuple* & rec : vec) { assetTrackerTuplesCache.insert(rec); - - // Logger::getLogger()->debug("Added asset tracker tuple to cache: '%s'", - // rec->assetToString().c_str()); } delete (&vec); } diff --git a/C/common/pythonreadingset.cpp b/C/common/pythonreadingset.cpp index 2bd00db0fc..fa664e1838 100755 --- a/C/common/pythonreadingset.cpp +++ b/C/common/pythonreadingset.cpp @@ -117,7 +117,6 @@ PythonReadingSet::PythonReadingSet(PyObject *set) m_readings.push_back(reading); m_count++; m_last_id = reading->getId(); - // Logger::getLogger()->debug("PythonReadingSet c'tor: LIST: reading->toJSON()='%s' ", reading->toJSON().c_str()); } } else if (PyDict_Check(set)) @@ -129,7 +128,6 @@ PythonReadingSet::PythonReadingSet(PyObject *set) m_readings.push_back(reading); m_count++; m_last_id = reading->getId(); - // Logger::getLogger()->debug("PythonReadingSet c'tor: DICT: reading->toJSON()=%s", reading->toJSON().c_str()); } } else diff --git a/C/common/reading.cpp b/C/common/reading.cpp index 186eb87a20..2b94f1ba9c 100644 --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -237,8 +238,11 @@ void Reading::getFormattedDateTimeStr(const time_t *tv_sec, char *date_time, rea static unsigned long cached_sec_since_epoch = 0; static char cached_date_time_str[DATE_TIME_BUFFER_LEN] = ""; static readingTimeFormat cachedDateFormat = (readingTimeFormat) 0xff; + static std::mutex mtx; - if(strlen(cached_date_time_str) && cached_sec_since_epoch && *tv_sec == cached_sec_since_epoch && cachedDateFormat == dateFormat) + std::unique_lock lck(mtx); + + if(*cached_date_time_str && cached_sec_since_epoch && *tv_sec == cached_sec_since_epoch && cachedDateFormat == dateFormat) { strncpy(date_time, cached_date_time_str, DATE_TIME_BUFFER_LEN); date_time[DATE_TIME_BUFFER_LEN-1] = '\0'; @@ -391,37 +395,45 @@ void Reading::setUserTimestamp(const string& timestamp) */ void Reading::stringToTimestamp(const string& timestamp, struct timeval *ts) { - char date_time [DATE_TIME_BUFFER_LEN]; - - strcpy (date_time, timestamp.c_str()); - + static std::mutex mtx; static char cached_timestamp_upto_min[32] = ""; static unsigned long cached_sec_since_epoch = 0; const int timestamp_str_len_till_min = 16; const int timestamp_str_len_till_sec = 19; - char timestamp_sec[32]; - strncpy(timestamp_sec, date_time, timestamp_str_len_till_sec); - timestamp_sec[timestamp_str_len_till_sec] = '\0'; - if(strlen(cached_timestamp_upto_min) && cached_sec_since_epoch && (strncmp(timestamp_sec, cached_timestamp_upto_min, timestamp_str_len_till_min) == 0)) - { - int sec_part = strtoul(timestamp_sec+timestamp_str_len_till_min+1, NULL, 10); - ts->tv_sec = cached_sec_since_epoch + sec_part; - } - else + + char date_time [DATE_TIME_BUFFER_LEN]; + + strcpy (date_time, timestamp.c_str()); + { - struct tm tm; - memset(&tm, 0, sizeof(struct tm)); - strptime(date_time, "%Y-%m-%d %H:%M:%S", &tm); - // Convert time to epoch - mktime assumes localtime so most adjust for that - ts->tv_sec = mktime(&tm); - - extern long timezone; - ts->tv_sec -= timezone; - - strncpy(cached_timestamp_upto_min, timestamp_sec, timestamp_str_len_till_min); - cached_timestamp_upto_min[timestamp_str_len_till_min] = '\0'; - cached_sec_since_epoch = ts->tv_sec - tm.tm_sec; // store only for upto-minute part + lock_guard guard(mtx); + + char timestamp_sec[32]; + strncpy(timestamp_sec, date_time, timestamp_str_len_till_sec); + timestamp_sec[timestamp_str_len_till_sec] = '\0'; + if(*cached_timestamp_upto_min && cached_sec_since_epoch && (strncmp(timestamp_sec, cached_timestamp_upto_min, timestamp_str_len_till_min) == 0)) + { + // cache hit + int sec_part = strtoul(timestamp_sec+timestamp_str_len_till_min+1, NULL, 10); + ts->tv_sec = cached_sec_since_epoch + sec_part; + } + else + { + // cache miss + struct tm tm; + memset(&tm, 0, sizeof(struct tm)); + strptime(date_time, "%Y-%m-%d %H:%M:%S", &tm); + // Convert time to epoch - mktime assumes localtime so most adjust for that + ts->tv_sec = mktime(&tm); + + extern long timezone; + ts->tv_sec -= timezone; + + strncpy(cached_timestamp_upto_min, timestamp_sec, timestamp_str_len_till_min); + cached_timestamp_upto_min[timestamp_str_len_till_min] = '\0'; + cached_sec_since_epoch = ts->tv_sec - tm.tm_sec; // store only for upto-minute part + } } // Now process the fractional seconds diff --git a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp index 0ebb0fe561..b64d98e39a 100755 --- a/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp +++ b/C/services/filter-plugin-interfaces/python/filter_ingest_pymodule/ingest_callback_pymodule.cpp @@ -124,13 +124,6 @@ void filter_plugin_ingest_fn(PyObject *ingest_callback, readingsObj); return; } - -#if 0 - PyObject* objectsRepresentation = PyObject_Repr(readingsObj); - const char* s = PyUnicode_AsUTF8(objectsRepresentation); - Logger::getLogger()->debug("filter_plugin_ingest_fn:L%d : Py2C: filtered readings=%s", __LINE__, s); - Py_CLEAR(objectsRepresentation); -#endif PythonReadingSet *pyReadingSet = NULL; diff --git a/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp b/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp index db0a66bf0b..3c95ddb8b6 100644 --- a/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp +++ b/C/services/south-plugin-interfaces/python/async_ingest_pymodule/ingest_callback_pymodule.cpp @@ -79,12 +79,6 @@ void plugin_ingest_fn(PyObject *ingest_callback, PyObject *ingest_obj_ref_data, ingest_callback, ingest_obj_ref_data, readingsObj); return; } -#if 0 - PyObject* objectsRepresentation = PyObject_Repr(readingsObj); - const char* s = PyUnicode_AsUTF8(objectsRepresentation); - Logger::getLogger()->debug("%s:%s:L%d : Py2C: filtered readings=%s", __FILE__, __FUNCTION__, __LINE__, s); - Py_CLEAR(objectsRepresentation); -#endif PythonReadingSet *pyReadingSet = NULL; From dcde8358c39e3e184b087bc06ae2a9b5b6960af0 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 19 Jan 2023 09:09:16 +0000 Subject: [PATCH 069/499] FOGL-7366 remove duplication of options (#939) Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch --- C/common/config_category.cpp | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/C/common/config_category.cpp b/C/common/config_category.cpp index 461db781ba..a3dffe5e10 100644 --- a/C/common/config_category.cpp +++ b/C/common/config_category.cpp @@ -1463,17 +1463,6 @@ ostringstream convert; { convert << ", \"file\" : \"" << m_file << "\""; } - if (m_options.size() > 0) - { - convert << ", \"options\" : [ "; - for (int i = 0; i < m_options.size(); i++) - { - if (i > 0) - convert << ","; - convert << "\"" << m_options[i] << "\""; - } - convert << "]"; - } } convert << " }"; From f39c85b201d85b34dd6ec6dd779c8ebe12610fe7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 19 Jan 2023 16:28:43 +0530 Subject: [PATCH 070/499] install steps added for CentOS Stream 9 with package manager yum Signed-off-by: ashish-jabble --- docs/quick_start/installing.rst | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/quick_start/installing.rst b/docs/quick_start/installing.rst index 30eec10b89..c585c752f3 100644 --- a/docs/quick_start/installing.rst +++ b/docs/quick_start/installing.rst @@ -3,6 +3,10 @@ For Debian Platform +.. |RPM PostgreSQL| raw:: html + + For CentOS Stream 9 + .. |Configure Storage Plugin| raw:: html Configure Storage Plugin from GUI @@ -107,6 +111,43 @@ You may also install multiple packages in a single command. To install the base sudo DEBIAN_FRONTEND=noninteractive apt -y install fledge fledge-gui fledge-south-sinusoid +CentOS Stream 9 +~~~~~~~~~~~~~~~ + +The CentOS use a different package management system, known as *yum*. Fledge also supports a package management system for the yum package manager. + +To add the fledge repository to the yum package manager run the command + +.. code-block:: console + + sudo rpm --import http://archives.fledge-iot.org/RPM-GPG-KEY-fledge + +CentOS users should then create a file called fledge.repo in the directory /etc/yum.repos.d and add the following content + +.. code-block:: console + + [fledge] + name=fledge Repository + baseurl=http://archives.fledge-iot.org/latest/centos-stream-9/x86_64/ + enabled=1 + gpgkey=http://archives.fledge-iot.org/RPM-GPG-KEY-fledge + gpgcheck=1 + +There are a few pre-requisites that needs to be installed + +.. code-block:: console + + sudo yum install -y epel-release + sudo yum -y check-update + sudo yum -y update + +You can now install and upgrade fledge packages using the yum command. For example to install fledge and the fledge GUI you run the command + +.. code-block:: console + + sudo yum install -y fledge fledge-gui + + Installing Fledge downloaded packages ###################################### @@ -140,6 +181,7 @@ To start Fledge with PostgreSQL, first you need to install the PostgreSQL packag |Debian PostgreSQL| +|RPM PostgreSQL| Also you need to change the value of Storage plugin. See |Configure Storage Plugin| or with below curl command From 1e56a359cf8289ca4a55090eebd3c727c8ef52ce Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 19 Jan 2023 16:29:02 +0530 Subject: [PATCH 071/499] uninstall steps added for CentOS Stream 9 with package manager yum Signed-off-by: ashish-jabble --- docs/quick_start/uninstalling.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/quick_start/uninstalling.rst b/docs/quick_start/uninstalling.rst index df203627d1..fd159614ff 100644 --- a/docs/quick_start/uninstalling.rst +++ b/docs/quick_start/uninstalling.rst @@ -10,6 +10,13 @@ Use the ``apt`` or the ``apt-get`` command to uninstall Fledge: sudo apt -y purge fledge +CentOS Stream 9 +############### + +.. code-block:: console + + sudo yum -y remove fledge + .. note:: You may notice the warning in the last row of the package removal output: From 799e709c07c3cf6034be8cd17ce8b036b4f10fe7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 19 Jan 2023 16:35:58 +0530 Subject: [PATCH 072/499] PostgreSQL installation steps added for CentOS Stream 9 Signed-off-by: ashish-jabble --- docs/quick_start/installing.rst | 2 +- docs/quick_start/uninstalling.rst | 4 +-- docs/storage.rst | 46 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/quick_start/installing.rst b/docs/quick_start/installing.rst index c585c752f3..3aedc2e1bb 100644 --- a/docs/quick_start/installing.rst +++ b/docs/quick_start/installing.rst @@ -5,7 +5,7 @@ .. |RPM PostgreSQL| raw:: html - For CentOS Stream 9 + For Red Hat Platform .. |Configure Storage Plugin| raw:: html diff --git a/docs/quick_start/uninstalling.rst b/docs/quick_start/uninstalling.rst index fd159614ff..ded329be03 100644 --- a/docs/quick_start/uninstalling.rst +++ b/docs/quick_start/uninstalling.rst @@ -10,8 +10,8 @@ Use the ``apt`` or the ``apt-get`` command to uninstall Fledge: sudo apt -y purge fledge -CentOS Stream 9 -############### +Red Hat Platform +################ .. code-block:: console diff --git a/docs/storage.rst b/docs/storage.rst index a12fc66310..121ab60d1c 100644 --- a/docs/storage.rst +++ b/docs/storage.rst @@ -150,6 +150,52 @@ A more generic command is: sudo -u postgres createuser -d $(whoami) +Red Hat Install +--------------- + +On Red Hat or other yum based distributions to install postgres: + +Add PostgreSQL YUM Repository to your System + +.. code-block:: console + + sudo yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm + +Check whether PostgreSQL 13 is available using the command shown below + +.. code-block:: console + + sudo yum search -y postgresql13 + +Once you have confirmed that PostgreSQL 13 repositories are available on your system. Then, you can proceed to install PostgreSQL 13 + +.. code-block:: console + + sudo yum install -y postgresql13 postgresql13-server + +Before using the PostgreSQL server, you need to first initialize the database service using the command + +.. code-block:: console + + sudo /usr/pgsql-13/bin/postgresql-13-setup initdb + +You can then proceed to start the database server as follows + +.. code-block:: console + + sudo systemctl enable --now postgresql-13 + +Confirm if the just started service above is running by checking its status using the command + +.. code-block:: console + + sudo systemctl status postgresql-13 + +Next, you must create a PostgreSQL user that matches your Linux user. + +.. code-block:: console + + sudo -u postgres createuser -d $(whoami) SQLite Plugin Configuration =========================== From 683992959689bdb1a025263aa2c4f80a9565f705 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 19 Jan 2023 13:35:31 +0000 Subject: [PATCH 073/499] FOGL-7371 Set value of readingPlugin to default if empty (#940) Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch --- C/services/storage/configuration.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/C/services/storage/configuration.cpp b/C/services/storage/configuration.cpp index 87dfde5ddb..ccb4116c67 100644 --- a/C/services/storage/configuration.cpp +++ b/C/services/storage/configuration.cpp @@ -333,6 +333,7 @@ bool forceUpdate = false; rval = "Use main plugin"; } rp["default"].SetString(rval, strlen(rval)); + rp["value"].SetString(rval, strlen(rval)); logger->info("Storage configuration cache is up to date"); return; } From 6c896c7d84a50cd2a10f7437d7a4d8dfc24d0fb8 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 20 Jan 2023 16:40:48 +0000 Subject: [PATCH 074/499] Fix memory allocation error (#943) Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch --- C/services/storage/configuration.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/C/services/storage/configuration.cpp b/C/services/storage/configuration.cpp index ccb4116c67..8a7ea2b627 100644 --- a/C/services/storage/configuration.cpp +++ b/C/services/storage/configuration.cpp @@ -332,8 +332,8 @@ bool forceUpdate = false; { rval = "Use main plugin"; } - rp["default"].SetString(rval, strlen(rval)); - rp["value"].SetString(rval, strlen(rval)); + rp["default"].SetString(strdup(rval), strlen(rval)); + rp["value"].SetString(strdup(rval), strlen(rval)); logger->info("Storage configuration cache is up to date"); return; } From e3c0b006a91f7e93f2a8b6785e011da953c5e96e Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Fri, 20 Jan 2023 19:45:55 -0500 Subject: [PATCH 075/499] Update AF Location OMFHint documentation Updated the Asset Framework Location Hint section to include behavior for both complex types and linked types. Signed-off-by: Ray Verhoeff --- docs/OMF.rst | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/OMF.rst b/docs/OMF.rst index 657f9c256f..1837e52c7e 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -534,8 +534,9 @@ Asset Framework Location Hint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ An Asset Framework location hint can be added to a reading to control -the placement of the asset within the Asset Framework. An Asset Framework -hint would be as follows: +the placement of the asset within the Asset Framework. +This hint overrides the path in the *Default Asset Framework Location* for the reading. +An Asset Framework hint would be as follows: .. code-block:: console @@ -543,21 +544,27 @@ hint would be as follows: Note the following when defining an *AFLocation* hint: -- An asset in a Fledge Reading is used to create a `Container in the OSIsoft Asset Framework `_. - A *Container* is an AF Element with one or more AF Attributes that are mapped to PI Points using the OSIsoft PI Point Data Reference. - The name of the AF Element comes from the Fledge Reading asset name. - The names of the AF Attributes come from the Fledge Reading datapoint names. -- If you edit the AF Location hint, the Container will be moved to the new location in the AF hierarchy. -- If you disable the OMF Hint filter, the Container will not move. -- If you wish to move a Container, you can do this with the PI System Explorer. - Right-click on the AF Element that represents the Container. +- An asset name in a Fledge Reading is used to create an AF Element in the OSIsoft Asset Framework. + Time series data streams become AF Attributes of that AF Element. + This means these AF Attributes are mapped to PI Points using the OSIsoft PI Point Data Reference. +- Deleting the original Reading AF Element is not recommended; + if you delete a Reading AF Element, the OMF North plugin will not recreate it. +- If you wish to move a Reading AF Element, you can do this with the PI System Explorer. + Right-click on the AF Element that represents the Reading AF Element. Choose Copy. - Select the AF Element that will serve as the new parent of the Container. - Right-click and choose *Paste*. - You can then return to the original Container and delete it. + Select the AF Element that will serve as the new parent of the Reading AF Element. + Right-click and choose *Paste* or *Paste Reference*. *Note that PI System Explorer does not have the traditional Cut function for AF Elements*. -- If you move a Container, OMF North will not recreate it. - If you then edit the AF Location hint, the Container will appear in the new location. +- For Linked Types + - If you define an AF Location hint after the Reading AF Element has been created in the default location, + a reference will be created in the location defined by the hint. + - If an AF Location hint was in place when the Reading AF Element was created and you then disable the hint, + the Reading AF Element will not move. A reference to this AF Element will be created in the Default Asset Framework Location. + - If you edit the AF Location hint, the Reading AF Element not move. + A reference to the Reading AF Element will be created in the new location. +- For Complex Types + - If you disable the OMF Hint filter, the Reading AF Element will not move. + - If you edit the AF Location hint, the Reading AF Element will move to the new location in the AF hierarchy. Unit Of Measure Hint ~~~~~~~~~~~~~~~~~~~~ From b7d4501a790e53eedd3c7137cca6557d86c7013a Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Mon, 23 Jan 2023 11:45:34 -0500 Subject: [PATCH 076/499] Updated AF Location Hint Updated the description of the behavior of the AF Location hint. Signed-off-by: Ray Verhoeff --- docs/OMF.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/OMF.rst b/docs/OMF.rst index 1837e52c7e..e46868ba1c 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -559,12 +559,13 @@ Note the following when defining an *AFLocation* hint: - If you define an AF Location hint after the Reading AF Element has been created in the default location, a reference will be created in the location defined by the hint. - If an AF Location hint was in place when the Reading AF Element was created and you then disable the hint, - the Reading AF Element will not move. A reference to this AF Element will be created in the Default Asset Framework Location. + a reference will be created in the *Default Asset Framework Location*. - If you edit the AF Location hint, the Reading AF Element not move. A reference to the Reading AF Element will be created in the new location. - For Complex Types - If you disable the OMF Hint filter, the Reading AF Element will not move. - If you edit the AF Location hint, the Reading AF Element will move to the new location in the AF hierarchy. + - No references are created. Unit Of Measure Hint ~~~~~~~~~~~~~~~~~~~~ From b08a2cb5826b0fdda70ca474820db417aba25c71 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 25 Jan 2023 10:28:35 +0000 Subject: [PATCH 077/499] FOGL-7386 Fixes for running sqlite and another reading plugin (#947) Signed-off-by: Mark Riddoch Signed-off-by: Mark Riddoch --- .../storage/sqlite/common/connection.cpp | 9 ++++ .../sqlite/common/include/connection.h | 2 + C/plugins/storage/sqlite/common/readings.cpp | 43 ++++++++++++++++++- .../sqlite/common/readings_catalogue.cpp | 4 ++ C/plugins/storage/sqlite/plugin.cpp | 10 +++-- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index 21f9b61de5..aec07b0be5 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "readings_catalogue.h" @@ -544,7 +545,14 @@ Connection::Connection() delete[] sqlStmt; // Attach readings database - readings_1 + if (access(dbPathReadings.c_str(), R_OK) != 0) { + Logger::getLogger()->info("No readings database, assuming seperate readings plugin is avialable"); + m_noReadings = true; + } + else + { + m_noReadings = false; SQLBuffer attachReadingsDb; attachReadingsDb.append("ATTACH DATABASE '"); attachReadingsDb.append(dbPathReadings + "' AS readings_1;"); @@ -595,6 +603,7 @@ Connection::Connection() } + if (!m_noReadings) { // Attach all the defined/used databases ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); diff --git a/C/plugins/storage/sqlite/common/include/connection.h b/C/plugins/storage/sqlite/common/include/connection.h index 62cef54a34..85308ee9a9 100644 --- a/C/plugins/storage/sqlite/common/include/connection.h +++ b/C/plugins/storage/sqlite/common/include/connection.h @@ -160,6 +160,7 @@ class Connection { void shutdownAppendReadings(); unsigned int purgeReadingsAsset(const std::string& asset); bool vacuum(); + bool supportsReadings() { return ! m_noReadings; }; private: @@ -213,6 +214,7 @@ class Connection { bool selectColumns(const rapidjson::Value& document, SQLBuffer& sql, int level); bool appendTables(const std::string& schema, const rapidjson::Value& document, SQLBuffer& sql, int level); bool processJoinQueryWhereClause(const rapidjson::Value& query, SQLBuffer& sql, std::vector &asset_codes, int level); + bool m_noReadings; }; #endif diff --git a/C/plugins/storage/sqlite/common/readings.cpp b/C/plugins/storage/sqlite/common/readings.cpp index d729baca6e..5cb3a11aa1 100644 --- a/C/plugins/storage/sqlite/common/readings.cpp +++ b/C/plugins/storage/sqlite/common/readings.cpp @@ -416,6 +416,12 @@ int Connection::readingStream(ReadingStream **readings, bool commit) int sqlite3_resut; int rowNumber = -1; + if (m_noReadings) + { + Logger::getLogger()->error("Attempt to stream readings to plugin that has no storage for readings"); + return 0; + } + ostringstream threadId; threadId << std::this_thread::get_id(); ReadingsCatalogue *readCatalogue = ReadingsCatalogue::getInstance(); @@ -644,7 +650,8 @@ void Connection::setUsedDbId(int dbId) { /** * Wait until all the threads executing the appendReadings are shutted down */ -void Connection::shutdownAppendReadings() { +void Connection::shutdownAppendReadings() +{ ostringstream threadId; threadId << std::this_thread::get_id(); @@ -696,6 +703,12 @@ int stmtArraySize; std::thread::id tid = std::this_thread::get_id(); ostringstream threadId; + if (m_noReadings) + { + Logger::getLogger()->error("Attempt to append readings to plugin that has no storage for readings"); + return 0; + } + threadId << tid; { @@ -1070,6 +1083,12 @@ unsigned int minGlobalId; unsigned int idWindow; unsigned long rowsCount; + if (m_noReadings) + { + Logger::getLogger()->error("Attempt to fetch readings to plugin that has no storage for readings"); + return false; + } + ostringstream threadId; threadId << std::this_thread::get_id(); ReadingsCatalogue *readCatalogue = ReadingsCatalogue::getInstance(); @@ -1278,6 +1297,12 @@ string modifierInt; vector asset_codes; + if (m_noReadings) + { + Logger::getLogger()->error("Attempt to retrieve readings to plugin that has no storage for readings"); + return false; + } + ostringstream threadId; threadId << std::this_thread::get_id(); ReadingsCatalogue *readCatalogue = ReadingsCatalogue::getInstance(); @@ -1736,6 +1761,12 @@ bool flag_retain; vector assetCodes; + if (m_noReadings) + { + Logger::getLogger()->error("Attempt to purge readings from plugin that has no storage for readings"); + return 0; + } + Logger *logger = Logger::getLogger(); ostringstream threadId; @@ -2255,6 +2286,12 @@ bool flag_retain; Logger *logger = Logger::getLogger(); + if (m_noReadings) + { + logger->error("Attempt to purge readings from plugin that has no storage for readings"); + return 0; + } + ostringstream threadId; threadId << std::this_thread::get_id(); ReadingsCatalogue *readCatalogue = ReadingsCatalogue::getInstance(); @@ -2551,6 +2588,10 @@ char *zErrMsg = NULL; int rc; sqlite3_stmt *stmt; + if (m_noReadings) + { + return 0; + } ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); if (readCat == NULL) { diff --git a/C/plugins/storage/sqlite/common/readings_catalogue.cpp b/C/plugins/storage/sqlite/common/readings_catalogue.cpp index 61952824d7..984ab55ab2 100644 --- a/C/plugins/storage/sqlite/common/readings_catalogue.cpp +++ b/C/plugins/storage/sqlite/common/readings_catalogue.cpp @@ -796,6 +796,10 @@ void ReadingsCatalogue::multipleReadingsInit(STORAGE_CONFIGURATION &storageConfi ConnectionManager *manager = ConnectionManager::getInstance(); Connection *connection = manager->allocate(); + if (! connection->supportsReadings()) + { + return; + } dbHandle = connection->getDbHandle(); diff --git a/C/plugins/storage/sqlite/plugin.cpp b/C/plugins/storage/sqlite/plugin.cpp index c4c2b32cde..d1a3f14ae4 100644 --- a/C/plugins/storage/sqlite/plugin.cpp +++ b/C/plugins/storage/sqlite/plugin.cpp @@ -337,10 +337,14 @@ bool plugin_shutdown(PLUGIN_HANDLE handle) ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); - connection->shutdownAppendReadings(); - ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); - readCat->storeGlobalId(); + if (connection->supportsReadings()) + { + connection->shutdownAppendReadings(); + + ReadingsCatalogue *readCat = ReadingsCatalogue::getInstance(); + readCat->storeGlobalId(); + } manager->release(connection); manager->shutdown(); From 6b792e18cac23d9e97aeb3c27773672ed2fddc09 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 27 Jan 2023 16:19:45 +0000 Subject: [PATCH 078/499] FOGL-7376 Add the ability to create a reading using a JSON document to (#951) define the datapoints. Signed-off-by: Mark Riddoch --- C/common/include/reading.h | 3 ++ C/common/reading.cpp | 88 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/C/common/include/reading.h b/C/common/include/reading.h index 09f351c32e..0ce59c5422 100644 --- a/C/common/include/reading.h +++ b/C/common/include/reading.h @@ -14,6 +14,7 @@ #include #include #include +#include #define DEFAULT_DATE_TIME_FORMAT "%Y-%m-%d %H:%M:%S" #define COMBINED_DATE_STANDARD_FORMAT "%Y-%m-%dT%H:%M:%S" @@ -34,6 +35,7 @@ class Reading { Reading(const std::string& asset, Datapoint *value); Reading(const std::string& asset, std::vector values); Reading(const std::string& asset, std::vector values, const std::string& ts); + Reading(const std::string& asset, const std::string& datapoints); Reading(const Reading& orig); ~Reading(); @@ -78,6 +80,7 @@ class Reading { Reading& operator=(Reading const&); void stringToTimestamp(const std::string& timestamp, struct timeval *ts); const std::string escape(const std::string& str) const; + std::vector *JSONtoDatapoints(const rapidjson::Value& json); unsigned long m_id; bool m_has_id; std::string m_asset; diff --git a/C/common/reading.cpp b/C/common/reading.cpp index 2b94f1ba9c..76798ced1f 100644 --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -16,8 +16,10 @@ #include #include #include +#include using namespace std; +using namespace rapidjson; std::vector Reading::m_dateTypes = { DEFAULT_DATE_TIME_FORMAT, @@ -79,6 +81,51 @@ Reading::Reading(const string& asset, vector values, const string& m_userTimestamp = m_timestamp; } +/** + * Construct a reading with datapoints given as JSON + */ +Reading::Reading(const string& asset, const string& datapoints) : m_asset(asset) +{ + Document d; + if (d.Parse(datapoints.c_str()).HasParseError()) + { + throw runtime_error("Failed to parse reading datapoints " + datapoints); + } + for (Value::ConstMemberIterator itr = d.MemberBegin(); itr != d.MemberEnd(); ++itr) + { + string name = itr->name.GetString(); + if (itr->value.IsInt64()) + { + long v = itr->value.GetInt64(); + DatapointValue dpv(v); + m_values.push_back(new Datapoint(name, dpv)); + } + else if (itr->value.IsDouble()) + { + double v = itr->value.GetDouble(); + DatapointValue dpv(v); + m_values.push_back(new Datapoint(name, dpv)); + } + else if (itr->value.IsString()) + { + string v = itr->value.GetString(); + DatapointValue dpv(v); + m_values.push_back(new Datapoint(name, dpv)); + } + else if (itr->value.IsObject()) + { + // Map objects as nested datapoints + vector *values = JSONtoDatapoints(itr->value); + DatapointValue dpv(values, true); + m_values.push_back(new Datapoint(name, dpv)); + } + } + // Store seconds and microseconds + gettimeofday(&m_timestamp, NULL); + // Initialise m_userTimestamp + m_userTimestamp = m_timestamp; +} + /** * Reading copy constructor */ @@ -505,3 +552,44 @@ int bscount = 0; } return rval; } + +/** + * Convert a JSON Value object to a set of data points + * + * @param json The json object to convert + */ +vector *Reading::JSONtoDatapoints(const Value& json) +{ +vector *values = new vector; + + for (Value::ConstMemberIterator itr = json.MemberBegin(); itr != json.MemberEnd(); ++itr) + { + string name = itr->name.GetString(); + if (itr->value.IsInt64()) + { + long v = itr->value.GetInt64(); + DatapointValue dpv(v); + values->push_back(new Datapoint(name, dpv)); + } + else if (itr->value.IsDouble()) + { + double v = itr->value.GetDouble(); + DatapointValue dpv(v); + values->push_back(new Datapoint(name, dpv)); + } + else if (itr->value.IsString()) + { + string v = itr->value.GetString(); + DatapointValue dpv(v); + values->push_back(new Datapoint(name, dpv)); + } + else if (itr->value.IsObject()) + { + // Map objects as nested datapoints + vector *values = JSONtoDatapoints(itr->value); + DatapointValue dpv(values, true); + values->push_back(new Datapoint(name, dpv)); + } + } + return values; +} From 322b22fa56fedfa976c8c5920d751d059daa7e29 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Mon, 30 Jan 2023 11:52:44 +0530 Subject: [PATCH 079/499] FOGL-7385: Fix in TZ string processing Signed-off-by: Amandeep Singh Arora --- C/common/reading.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 C/common/reading.cpp diff --git a/C/common/reading.cpp b/C/common/reading.cpp old mode 100644 new mode 100755 index 2b94f1ba9c..c7eb8d15ec --- a/C/common/reading.cpp +++ b/C/common/reading.cpp @@ -465,7 +465,7 @@ void Reading::stringToTimestamp(const string& timestamp, struct timeval *ts) int h, m; int sign = (*ptr == '+' ? -1 : +1); h = strtoul(ptr+1, NULL, 10); - m = strtoul(ptr+3, NULL, 10); + m = strtoul(ptr+4, NULL, 10); ts->tv_sec += sign * ((3600 * h) + (60 * m)); } } From 60f159decffcc107c912037ff32c162af4fcd529 Mon Sep 17 00:00:00 2001 From: nandan Date: Wed, 1 Feb 2023 12:36:41 +0530 Subject: [PATCH 080/499] FOGL-7354 : Sqlite table insert optimisation Signed-off-by: nandan --- .../storage/sqlite/common/connection.cpp | 205 +++++++++-------- .../storage/sqlitelb/common/connection.cpp | 210 +++++++++--------- 2 files changed, 219 insertions(+), 196 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index aec07b0be5..edb425d068 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -1189,9 +1189,11 @@ vector asset_codes; */ int Connection::insert(const string& schema, const string& table, const string& data) { -SQLBuffer sql; +string sqlCommand = {}; Document document; ostringstream convert; +sqlite3_stmt *stmt; +int rc; std::size_t arr = data.find("inserts"); if (!m_schemaManager->exists(dbHandle, schema)) @@ -1226,13 +1228,11 @@ std::size_t arr = data.find("inserts"); return -1; } - // Start a trabsaction - sql.append("BEGIN TRANSACTION;"); - // Number of inserts int ins = 0; - - // Iterate through insert array + int failedInsertCount = 0; + + // Generate sql query for prepared statement for (Value::ConstValueIterator iter = inserts.Begin(); iter != inserts.End(); ++iter) @@ -1245,14 +1245,9 @@ std::size_t arr = data.find("inserts"); } int col = 0; - SQLBuffer values; - - sql.append("INSERT INTO "); - sql.append(schema); - sql.append('.'); - sql.append(table); - sql.append(" ("); - + + sqlCommand = "INSERT INTO " + schema + "." + table + " ("; + for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); itr != (*iter).MemberEnd(); ++itr) @@ -1260,122 +1255,140 @@ std::size_t arr = data.find("inserts"); // Append column name if (col) { - sql.append(", "); + sqlCommand += ", "; } - sql.append(itr->name.GetString()); - - // Append column value - if (col) + sqlCommand += itr->name.GetString(); + col++; + } + + sqlCommand += ") VALUES ("; + for ( auto i = 0 ; i < col; i++ ) + { + if (i) { - values.append(", "); + sqlCommand += ","; } + sqlCommand += "?"; + } + sqlCommand += ");"; + + + rc = sqlite3_prepare_v2(dbHandle, sqlCommand.c_str(), sqlCommand.size(), &stmt, NULL); + if (rc != SQLITE_OK) + { + raiseError("insert", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", sqlCommand.c_str()); + return -1; + } + + // Bind columns with prepared sql query + int columID = 1; + for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); + itr != (*iter).MemberEnd(); + ++itr) + { + if (itr->value.IsString()) { const char *str = itr->value.GetString(); if (strcmp(str, "now()") == 0) { - values.append(SQLITE3_NOW); + sqlite3_bind_text(stmt, columID, SQLITE3_NOW, -1, SQLITE_TRANSIENT); } else { - values.append('\''); - values.append(escape(str)); - values.append('\''); + sqlite3_bind_text(stmt, columID, escape(str).c_str(), -1, SQLITE_TRANSIENT); } } - else if (itr->value.IsDouble()) - values.append(itr->value.GetDouble()); + else if (itr->value.IsDouble()) { + sqlite3_bind_double(stmt, columID,itr->value.IsDouble()); + } + else if (itr->value.IsInt64()) - values.append((long)itr->value.GetInt64()); + { + sqlite3_bind_int(stmt, columID,(long)itr->value.GetInt64()); + } + else if (itr->value.IsInt()) - values.append(itr->value.GetInt()); + { + sqlite3_bind_int(stmt, columID,itr->value.GetInt()); + } + else if (itr->value.IsObject()) { StringBuffer buffer; Writer writer(buffer); itr->value.Accept(writer); - values.append('\''); - values.append(escape(buffer.GetString())); - values.append('\''); + sqlite3_bind_text(stmt, columID, buffer.GetString(), -1, SQLITE_TRANSIENT); } - col++; + columID++ ; + } + + if (sqlite3_exec(dbHandle, "BEGIN TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) + { + raiseError("insert", sqlite3_errmsg(dbHandle)); + return -1; + } + + m_writeAccessOngoing.fetch_add(1); + + int sqlite3_resut = sqlite3_step(stmt); + + m_writeAccessOngoing.fetch_sub(1); + + if (sqlite3_resut == SQLITE_DONE) + { + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); + } + else + { + failedInsertCount++; + raiseError("insert", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", sqlite3_expanded_sql(stmt)); + + // transaction is still open, do rollback + if (sqlite3_get_autocommit(dbHandle) == 0) + { + rc = sqlite3_exec(dbHandle,"ROLLBACK TRANSACTION;",NULL,NULL,NULL); + if (rc != SQLITE_OK) + { + raiseError("insert rollback", sqlite3_errmsg(dbHandle)); + } + + } + } + + + if (sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) + { + raiseError("insert", sqlite3_errmsg(dbHandle)); + return -1; } - sql.append(") VALUES ("); - const char *vals = values.coalesce(); - sql.append(vals); - delete[] vals; - sql.append(");"); + + sqlCommand.clear(); + // Increment row count ins++; + } - sql.append("COMMIT TRANSACTION;"); - - const char *query = sql.coalesce(); - logSQL("CommonInsert", query); - char *zErrMsg = NULL; - int rc; + sqlite3_finalize(stmt); - // Exec INSERT statement: no callback, no result set - m_writeAccessOngoing.fetch_add(1); - rc = SQLexec(dbHandle, - query, - NULL, - NULL, - &zErrMsg); - m_writeAccessOngoing.fetch_sub(1); if (m_writeAccessOngoing == 0) db_cv.notify_all(); - // Check exec result - if (rc != SQLITE_OK ) + if (failedInsertCount) { - raiseError("insert", zErrMsg); - Logger::getLogger()->error("SQL statement: %s", query); - sqlite3_free(zErrMsg); - - // transaction is still open, do rollback - if (sqlite3_get_autocommit(dbHandle) == 0) - { - rc = SQLexec(dbHandle, - "ROLLBACK TRANSACTION;", - NULL, - NULL, - &zErrMsg); - if (rc != SQLITE_OK) - { - raiseError("insert rollback", zErrMsg); - sqlite3_free(zErrMsg); - } - } - - Logger::getLogger()->error("SQL statement: %s", query); - // Release memory for 'query' var - delete[] query; - - // Failure - return -1; + char buf[100]; + snprintf(buf, sizeof(buf), + "Not all inserts into table '%s.%s' within transaction succeeded", + schema.c_str(), table.c_str()); + raiseError("insert", buf); } - else - { - // Release memory for 'query' var - delete[] query; - - int insert = sqlite3_changes(dbHandle); - - if (insert == 0) - { - char buf[100]; - snprintf(buf, sizeof(buf), - "Not all inserts into table '%s.%s' within transaction succeeded", - schema.c_str(), table.c_str()); - raiseError("insert", buf); - } - // Return the status - return (insert ? ins : -1); - } + return (!failedInsertCount ? ins : -1); } #endif diff --git a/C/plugins/storage/sqlitelb/common/connection.cpp b/C/plugins/storage/sqlitelb/common/connection.cpp index c9a356caaf..b27883ca6e 100644 --- a/C/plugins/storage/sqlitelb/common/connection.cpp +++ b/C/plugins/storage/sqlitelb/common/connection.cpp @@ -1265,17 +1265,16 @@ int Connection::insert(const std::string& schema, const std::string& table, const std::string& data) { -SQLBuffer sql; +string sqlCommand = {}; Document document; ostringstream convert; +sqlite3_stmt *stmt; +int rc; std::size_t arr = data.find("inserts"); if (!m_schemaManager->exists(dbHandle, schema)) { - raiseError("insert", - "Schema %s does not exist, unable to insert into table %s", - schema.c_str(), - table.c_str()); + raiseError("insert", "Schema %s does not exist, unable to insert into table %s", schema.c_str(), table.c_str()); return false; } @@ -1305,13 +1304,11 @@ std::size_t arr = data.find("inserts"); return -1; } - // Start a trabsaction - sql.append("BEGIN TRANSACTION;"); - // Number of inserts int ins = 0; - - // Iterate through insert array + int failedInsertCount = 0; + + // Generate sql query for prepared statement for (Value::ConstValueIterator iter = inserts.Begin(); iter != inserts.End(); ++iter) @@ -1324,14 +1321,9 @@ std::size_t arr = data.find("inserts"); } int col = 0; - SQLBuffer values; - - sql.append("INSERT INTO "); - sql.append(schema); - sql.append('.'); - sql.append(table); - sql.append(" ("); - + + sqlCommand = "INSERT INTO " + schema + "." + table + " ("; + for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); itr != (*iter).MemberEnd(); ++itr) @@ -1339,122 +1331,140 @@ std::size_t arr = data.find("inserts"); // Append column name if (col) { - sql.append(", "); + sqlCommand += ", "; } - sql.append(itr->name.GetString()); - - // Append column value - if (col) + sqlCommand += itr->name.GetString(); + col++; + } + + sqlCommand += ") VALUES ("; + for ( auto i = 0 ; i < col; i++ ) + { + if (i) { - values.append(", "); + sqlCommand += ","; } + sqlCommand += "?"; + } + sqlCommand += ");"; + + + rc = sqlite3_prepare_v2(dbHandle, sqlCommand.c_str(), sqlCommand.size(), &stmt, NULL); + if (rc != SQLITE_OK) + { + raiseError("insert", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", sqlCommand.c_str()); + return -1; + } + + // Bind columns with prepared sql query + int columID = 1; + for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); + itr != (*iter).MemberEnd(); + ++itr) + { + if (itr->value.IsString()) { const char *str = itr->value.GetString(); if (strcmp(str, "now()") == 0) { - values.append(SQLITE3_NOW); + sqlite3_bind_text(stmt, columID, SQLITE3_NOW, -1, SQLITE_TRANSIENT); } else { - values.append('\''); - values.append(escape(str)); - values.append('\''); + sqlite3_bind_text(stmt, columID, escape(str).c_str(), -1, SQLITE_TRANSIENT); } } - else if (itr->value.IsDouble()) - values.append(itr->value.GetDouble()); + else if (itr->value.IsDouble()) { + sqlite3_bind_double(stmt, columID,itr->value.IsDouble()); + } + else if (itr->value.IsInt64()) - values.append((long)itr->value.GetInt64()); + { + sqlite3_bind_int(stmt, columID,(long)itr->value.GetInt64()); + } + else if (itr->value.IsInt()) - values.append(itr->value.GetInt()); + { + sqlite3_bind_int(stmt, columID,itr->value.GetInt()); + } + else if (itr->value.IsObject()) { StringBuffer buffer; Writer writer(buffer); itr->value.Accept(writer); - values.append('\''); - values.append(escape(buffer.GetString())); - values.append('\''); + sqlite3_bind_text(stmt, columID, buffer.GetString(), -1, SQLITE_TRANSIENT); } - col++; + columID++ ; + } + + if (sqlite3_exec(dbHandle, "BEGIN TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) + { + raiseError("insert", sqlite3_errmsg(dbHandle)); + return -1; + } + + m_writeAccessOngoing.fetch_add(1); + + int sqlite3_resut = sqlite3_step(stmt); + + m_writeAccessOngoing.fetch_sub(1); + + if (sqlite3_resut == SQLITE_DONE) + { + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); + } + else + { + failedInsertCount++; + raiseError("insert", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", sqlite3_expanded_sql(stmt)); + + // transaction is still open, do rollback + if (sqlite3_get_autocommit(dbHandle) == 0) + { + rc = sqlite3_exec(dbHandle,"ROLLBACK TRANSACTION;",NULL,NULL,NULL); + if (rc != SQLITE_OK) + { + raiseError("insert rollback", sqlite3_errmsg(dbHandle)); + } + + } + } + + + if (sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) + { + raiseError("insert", sqlite3_errmsg(dbHandle)); + return -1; } - sql.append(") VALUES ("); - const char *vals = values.coalesce(); - sql.append(vals); - delete[] vals; - sql.append(");"); + + sqlCommand.clear(); + // Increment row count ins++; + } - sql.append("COMMIT TRANSACTION;"); + sqlite3_finalize(stmt); - const char *query = sql.coalesce(); - logSQL("CommonInsert", query); - char *zErrMsg = NULL; - int rc; - - // Exec INSERT statement: no callback, no result set - m_writeAccessOngoing.fetch_add(1); - rc = SQLexec(dbHandle, - query, - NULL, - NULL, - &zErrMsg); - m_writeAccessOngoing.fetch_sub(1); if (m_writeAccessOngoing == 0) db_cv.notify_all(); - // Check exec result - if (rc != SQLITE_OK ) + if (failedInsertCount) { - raiseError("insert", zErrMsg); - Logger::getLogger()->error("SQL statement: %s", query); - sqlite3_free(zErrMsg); - - // transaction is still open, do rollback - if (sqlite3_get_autocommit(dbHandle) == 0) - { - rc = SQLexec(dbHandle, - "ROLLBACK TRANSACTION;", - NULL, - NULL, - &zErrMsg); - if (rc != SQLITE_OK) - { - raiseError("insert rollback", zErrMsg); - sqlite3_free(zErrMsg); - } - } - - Logger::getLogger()->error("SQL statement: %s", query); - // Release memory for 'query' var - delete[] query; - - // Failure - return -1; + char buf[100]; + snprintf(buf, sizeof(buf), + "Not all inserts into table '%s.%s' within transaction succeeded", + schema.c_str(), table.c_str()); + raiseError("insert", buf); } - else - { - // Release memory for 'query' var - delete[] query; - - int insert = sqlite3_changes(dbHandle); - - if (insert == 0) - { - char buf[100]; - snprintf(buf, sizeof(buf), - "Not all inserts within transaction '%s.%s' succeeded", - schema.c_str(), table.c_str()); - raiseError("insert", buf); - } - // Return the status - return (insert ? ins : -1); - } + return (!failedInsertCount ? ins : -1); } #endif From 299055d4adc8dbf254fb2a82166b57d379923016 Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 3 Feb 2023 12:20:18 +0530 Subject: [PATCH 081/499] FOGL-7354 : Changed to use SQLBuffer class instead of std::string class Signed-off-by: nandan --- .../storage/sqlite/common/connection.cpp | 207 +++++++++--------- .../storage/sqlitelb/common/connection.cpp | 207 +++++++++--------- 2 files changed, 208 insertions(+), 206 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index edb425d068..11d8199c7b 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -1189,7 +1189,6 @@ vector asset_codes; */ int Connection::insert(const string& schema, const string& table, const string& data) { -string sqlCommand = {}; Document document; ostringstream convert; sqlite3_stmt *stmt; @@ -1244,131 +1243,133 @@ std::size_t arr = data.find("inserts"); return -1; } - int col = 0; - - sqlCommand = "INSERT INTO " + schema + "." + table + " ("; - - for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); - itr != (*iter).MemberEnd(); - ++itr) - { - // Append column name - if (col) - { - sqlCommand += ", "; - } - sqlCommand += itr->name.GetString(); - col++; - } - - sqlCommand += ") VALUES ("; - for ( auto i = 0 ; i < col; i++ ) - { - if (i) - { - sqlCommand += ","; - } - sqlCommand += "?"; - } - sqlCommand += ");"; - - - rc = sqlite3_prepare_v2(dbHandle, sqlCommand.c_str(), sqlCommand.size(), &stmt, NULL); - if (rc != SQLITE_OK) - { - raiseError("insert", sqlite3_errmsg(dbHandle)); - Logger::getLogger()->error("SQL statement: %s", sqlCommand.c_str()); - return -1; - } - - // Bind columns with prepared sql query - int columID = 1; - for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); - itr != (*iter).MemberEnd(); - ++itr) { + int col = 0; + SQLBuffer sql; + SQLBuffer values; + sql.append("INSERT INTO " + schema + "." + table + " ("); - if (itr->value.IsString()) + for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); + itr != (*iter).MemberEnd(); + ++itr) { - const char *str = itr->value.GetString(); - if (strcmp(str, "now()") == 0) + // Append column name + if (col) { - sqlite3_bind_text(stmt, columID, SQLITE3_NOW, -1, SQLITE_TRANSIENT); + sql.append(", "); } - else + sql.append(itr->name.GetString()); + col++; + } + + sql.append(") VALUES ("); + for ( auto i = 0 ; i < col; i++ ) + { + if (i) { - sqlite3_bind_text(stmt, columID, escape(str).c_str(), -1, SQLITE_TRANSIENT); + sql.append(","); } + sql.append("?"); } - else if (itr->value.IsDouble()) { - sqlite3_bind_double(stmt, columID,itr->value.IsDouble()); - } - - else if (itr->value.IsInt64()) + sql.append(");"); + + const char *query = sql.coalesce(); + + rc = sqlite3_prepare_v2(dbHandle, query, -1, &stmt, NULL); + if (rc != SQLITE_OK) { - sqlite3_bind_int(stmt, columID,(long)itr->value.GetInt64()); + raiseError("insert", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", query); + return -1; } - - else if (itr->value.IsInt()) + + // Bind columns with prepared sql query + int columID = 1; + for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); + itr != (*iter).MemberEnd(); + ++itr) { - sqlite3_bind_int(stmt, columID,itr->value.GetInt()); - } - else if (itr->value.IsObject()) + if (itr->value.IsString()) + { + const char *str = itr->value.GetString(); + if (strcmp(str, "now()") == 0) + { + sqlite3_bind_text(stmt, columID, SQLITE3_NOW, -1, SQLITE_TRANSIENT); + } + else + { + sqlite3_bind_text(stmt, columID, escape(str).c_str(), -1, SQLITE_TRANSIENT); + } + } + else if (itr->value.IsDouble()) { + sqlite3_bind_double(stmt, columID,itr->value.IsDouble()); + } + + else if (itr->value.IsInt64()) + { + sqlite3_bind_int(stmt, columID,(long)itr->value.GetInt64()); + } + + else if (itr->value.IsInt()) + { + sqlite3_bind_int(stmt, columID,itr->value.GetInt()); + } + + else if (itr->value.IsObject()) + { + StringBuffer buffer; + Writer writer(buffer); + itr->value.Accept(writer); + sqlite3_bind_text(stmt, columID, buffer.GetString(), -1, SQLITE_TRANSIENT); + } + columID++ ; + } + + if (sqlite3_exec(dbHandle, "BEGIN TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) { - StringBuffer buffer; - Writer writer(buffer); - itr->value.Accept(writer); - sqlite3_bind_text(stmt, columID, buffer.GetString(), -1, SQLITE_TRANSIENT); + raiseError("insert", sqlite3_errmsg(dbHandle)); + return -1; } - columID++ ; - } - - if (sqlite3_exec(dbHandle, "BEGIN TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) - { - raiseError("insert", sqlite3_errmsg(dbHandle)); - return -1; - } - m_writeAccessOngoing.fetch_add(1); - - int sqlite3_resut = sqlite3_step(stmt); - - m_writeAccessOngoing.fetch_sub(1); - - if (sqlite3_resut == SQLITE_DONE) - { - sqlite3_clear_bindings(stmt); - sqlite3_reset(stmt); - } - else - { - failedInsertCount++; - raiseError("insert", sqlite3_errmsg(dbHandle)); - Logger::getLogger()->error("SQL statement: %s", sqlite3_expanded_sql(stmt)); + m_writeAccessOngoing.fetch_add(1); - // transaction is still open, do rollback - if (sqlite3_get_autocommit(dbHandle) == 0) + int sqlite3_resut = sqlite3_step(stmt); + + m_writeAccessOngoing.fetch_sub(1); + + if (sqlite3_resut == SQLITE_DONE) + { + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); + } + else { - rc = sqlite3_exec(dbHandle,"ROLLBACK TRANSACTION;",NULL,NULL,NULL); - if (rc != SQLITE_OK) + failedInsertCount++; + raiseError("insert", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", sqlite3_expanded_sql(stmt)); + + // transaction is still open, do rollback + if (sqlite3_get_autocommit(dbHandle) == 0) { - raiseError("insert rollback", sqlite3_errmsg(dbHandle)); + rc = sqlite3_exec(dbHandle,"ROLLBACK TRANSACTION;",NULL,NULL,NULL); + if (rc != SQLITE_OK) + { + raiseError("insert rollback", sqlite3_errmsg(dbHandle)); + } + } + } + + if (sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) + { + raiseError("insert", sqlite3_errmsg(dbHandle)); + return -1; } - } - - if (sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) - { - raiseError("insert", sqlite3_errmsg(dbHandle)); - return -1; + delete[] query; } - - - sqlCommand.clear(); - // Increment row count ins++; diff --git a/C/plugins/storage/sqlitelb/common/connection.cpp b/C/plugins/storage/sqlitelb/common/connection.cpp index b27883ca6e..13fdc3d375 100644 --- a/C/plugins/storage/sqlitelb/common/connection.cpp +++ b/C/plugins/storage/sqlitelb/common/connection.cpp @@ -1265,7 +1265,6 @@ int Connection::insert(const std::string& schema, const std::string& table, const std::string& data) { -string sqlCommand = {}; Document document; ostringstream convert; sqlite3_stmt *stmt; @@ -1320,131 +1319,133 @@ std::size_t arr = data.find("inserts"); return -1; } - int col = 0; - - sqlCommand = "INSERT INTO " + schema + "." + table + " ("; - - for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); - itr != (*iter).MemberEnd(); - ++itr) - { - // Append column name - if (col) - { - sqlCommand += ", "; - } - sqlCommand += itr->name.GetString(); - col++; - } - - sqlCommand += ") VALUES ("; - for ( auto i = 0 ; i < col; i++ ) - { - if (i) - { - sqlCommand += ","; - } - sqlCommand += "?"; - } - sqlCommand += ");"; - - - rc = sqlite3_prepare_v2(dbHandle, sqlCommand.c_str(), sqlCommand.size(), &stmt, NULL); - if (rc != SQLITE_OK) - { - raiseError("insert", sqlite3_errmsg(dbHandle)); - Logger::getLogger()->error("SQL statement: %s", sqlCommand.c_str()); - return -1; - } - - // Bind columns with prepared sql query - int columID = 1; - for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); - itr != (*iter).MemberEnd(); - ++itr) { + int col = 0; + SQLBuffer sql; + SQLBuffer values; + sql.append("INSERT INTO " + schema + "." + table + " ("); - if (itr->value.IsString()) + for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); + itr != (*iter).MemberEnd(); + ++itr) { - const char *str = itr->value.GetString(); - if (strcmp(str, "now()") == 0) + // Append column name + if (col) { - sqlite3_bind_text(stmt, columID, SQLITE3_NOW, -1, SQLITE_TRANSIENT); + sql.append(", "); } - else + sql.append(itr->name.GetString()); + col++; + } + + sql.append(") VALUES ("); + for ( auto i = 0 ; i < col; i++ ) + { + if (i) { - sqlite3_bind_text(stmt, columID, escape(str).c_str(), -1, SQLITE_TRANSIENT); + sql.append(","); } + sql.append("?"); } - else if (itr->value.IsDouble()) { - sqlite3_bind_double(stmt, columID,itr->value.IsDouble()); - } - - else if (itr->value.IsInt64()) + sql.append(");"); + + const char *query = sql.coalesce(); + + rc = sqlite3_prepare_v2(dbHandle, query, -1, &stmt, NULL); + if (rc != SQLITE_OK) { - sqlite3_bind_int(stmt, columID,(long)itr->value.GetInt64()); + raiseError("insert", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", query); + return -1; } - - else if (itr->value.IsInt()) + + // Bind columns with prepared sql query + int columID = 1; + for (Value::ConstMemberIterator itr = (*iter).MemberBegin(); + itr != (*iter).MemberEnd(); + ++itr) { - sqlite3_bind_int(stmt, columID,itr->value.GetInt()); - } - else if (itr->value.IsObject()) + if (itr->value.IsString()) + { + const char *str = itr->value.GetString(); + if (strcmp(str, "now()") == 0) + { + sqlite3_bind_text(stmt, columID, SQLITE3_NOW, -1, SQLITE_TRANSIENT); + } + else + { + sqlite3_bind_text(stmt, columID, escape(str).c_str(), -1, SQLITE_TRANSIENT); + } + } + else if (itr->value.IsDouble()) { + sqlite3_bind_double(stmt, columID,itr->value.IsDouble()); + } + + else if (itr->value.IsInt64()) + { + sqlite3_bind_int(stmt, columID,(long)itr->value.GetInt64()); + } + + else if (itr->value.IsInt()) + { + sqlite3_bind_int(stmt, columID,itr->value.GetInt()); + } + + else if (itr->value.IsObject()) + { + StringBuffer buffer; + Writer writer(buffer); + itr->value.Accept(writer); + sqlite3_bind_text(stmt, columID, buffer.GetString(), -1, SQLITE_TRANSIENT); + } + columID++ ; + } + + if (sqlite3_exec(dbHandle, "BEGIN TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) { - StringBuffer buffer; - Writer writer(buffer); - itr->value.Accept(writer); - sqlite3_bind_text(stmt, columID, buffer.GetString(), -1, SQLITE_TRANSIENT); + raiseError("insert", sqlite3_errmsg(dbHandle)); + return -1; } - columID++ ; - } - - if (sqlite3_exec(dbHandle, "BEGIN TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) - { - raiseError("insert", sqlite3_errmsg(dbHandle)); - return -1; - } - m_writeAccessOngoing.fetch_add(1); - - int sqlite3_resut = sqlite3_step(stmt); - - m_writeAccessOngoing.fetch_sub(1); - - if (sqlite3_resut == SQLITE_DONE) - { - sqlite3_clear_bindings(stmt); - sqlite3_reset(stmt); - } - else - { - failedInsertCount++; - raiseError("insert", sqlite3_errmsg(dbHandle)); - Logger::getLogger()->error("SQL statement: %s", sqlite3_expanded_sql(stmt)); + m_writeAccessOngoing.fetch_add(1); + + int sqlite3_resut = sqlite3_step(stmt); - // transaction is still open, do rollback - if (sqlite3_get_autocommit(dbHandle) == 0) + m_writeAccessOngoing.fetch_sub(1); + + if (sqlite3_resut == SQLITE_DONE) { - rc = sqlite3_exec(dbHandle,"ROLLBACK TRANSACTION;",NULL,NULL,NULL); - if (rc != SQLITE_OK) + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); + } + else + { + failedInsertCount++; + raiseError("insert", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", sqlite3_expanded_sql(stmt)); + + // transaction is still open, do rollback + if (sqlite3_get_autocommit(dbHandle) == 0) { - raiseError("insert rollback", sqlite3_errmsg(dbHandle)); + rc = sqlite3_exec(dbHandle,"ROLLBACK TRANSACTION;",NULL,NULL,NULL); + if (rc != SQLITE_OK) + { + raiseError("insert rollback", sqlite3_errmsg(dbHandle)); + } + } + } + + if (sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) + { + raiseError("insert", sqlite3_errmsg(dbHandle)); + return -1; } - } - - if (sqlite3_exec(dbHandle, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) - { - raiseError("insert", sqlite3_errmsg(dbHandle)); - return -1; + delete[] query; } - - - sqlCommand.clear(); - // Increment row count ins++; From 865f6d1cf33285f09b7a91eaf3927cb3820efac5 Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 3 Feb 2023 16:10:45 +0530 Subject: [PATCH 082/499] FOGL-7354 : Changed from generic sqlite3_step to SQLstep function Signed-off-by: nandan --- C/plugins/storage/sqlite/common/connection.cpp | 2 +- C/plugins/storage/sqlitelb/common/connection.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection.cpp b/C/plugins/storage/sqlite/common/connection.cpp index 11d8199c7b..2481c286ee 100644 --- a/C/plugins/storage/sqlite/common/connection.cpp +++ b/C/plugins/storage/sqlite/common/connection.cpp @@ -1334,7 +1334,7 @@ std::size_t arr = data.find("inserts"); m_writeAccessOngoing.fetch_add(1); - int sqlite3_resut = sqlite3_step(stmt); + int sqlite3_resut = SQLstep(stmt); m_writeAccessOngoing.fetch_sub(1); diff --git a/C/plugins/storage/sqlitelb/common/connection.cpp b/C/plugins/storage/sqlitelb/common/connection.cpp index 13fdc3d375..8eb4e338b5 100644 --- a/C/plugins/storage/sqlitelb/common/connection.cpp +++ b/C/plugins/storage/sqlitelb/common/connection.cpp @@ -1410,7 +1410,7 @@ std::size_t arr = data.find("inserts"); m_writeAccessOngoing.fetch_add(1); - int sqlite3_resut = sqlite3_step(stmt); + int sqlite3_resut = SQLstep(stmt); m_writeAccessOngoing.fetch_sub(1); From 88804ad40f812cc11fc220d83753d6b74fedf63f Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 3 Feb 2023 17:28:13 +0530 Subject: [PATCH 083/499] FOGL-7355 : Improve purge performance Signed-off-by: nandan --- .../storage/sqlitelb/common/readings.cpp | 171 ++++++++++++------ 1 file changed, 115 insertions(+), 56 deletions(-) diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index 3675cc5b97..0cd4d61316 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -1616,7 +1616,7 @@ unsigned int Connection::purgeReadings(unsigned long age, /* * We fetch the current rowid and limit the purge process to work on just * those rows present in the database when the purge process started. - * This provents us looping in the purge process if new readings become + * This prevents us looping in the purge process if new readings become * eligible for purging at a rate that is faster than we can purge them. */ { @@ -1715,7 +1715,8 @@ unsigned int Connection::purgeReadings(unsigned long age, } unsigned long m=l; - + sqlite3_stmt *idStmt; + bool isMinId = false; while (l <= r) { unsigned long midRowId = 0; @@ -1725,26 +1726,29 @@ unsigned int Connection::purgeReadings(unsigned long age, // e.g. select id from readings where rowid = 219867307 AND user_ts < datetime('now' , '-24 hours', 'utc'); SQLBuffer sqlBuffer; - sqlBuffer.append("select id from " READINGS_DB_NAME_BASE "." READINGS_TABLE " where rowid = "); - sqlBuffer.append(m); - sqlBuffer.append(" AND user_ts < datetime('now' , '-"); - sqlBuffer.append(age); + sqlBuffer.append("select id from " READINGS_DB_NAME_BASE "." READINGS_TABLE " where rowid = ?"); + sqlBuffer.append(" AND user_ts < datetime('now' , '-?"); sqlBuffer.append(" hours');"); + const char *query = sqlBuffer.coalesce(); + rc = sqlite3_prepare_v2(dbHandle, query, -1, &idStmt, NULL); + + sqlite3_bind_int(idStmt, 1,(unsigned long) m); + sqlite3_bind_int(idStmt, 2,(unsigned long) age); - rc = SQLexec(dbHandle, - query, - rowidCallback, - &midRowId, - &zErrMsg); - + if (SQLstep(idStmt) == SQLITE_ROW) + { + midRowId = sqlite3_column_int(idStmt, 0); + isMinId = true; + } delete[] query; + sqlite3_clear_bindings(idStmt); + sqlite3_reset(idStmt); - if (rc != SQLITE_OK) + if (rc == SQLITE_ERROR) { - raiseError("purge - phase 1, fetching midRowId ", zErrMsg); - sqlite3_free(zErrMsg); + raiseError("purge - phase 1, fetching midRowId ", sqlite3_errmsg(dbHandle)); return 0; } @@ -1763,6 +1767,11 @@ unsigned int Connection::purgeReadings(unsigned long age, } } + if(isMinId) + { + sqlite3_finalize(idStmt); + } + rowidLimit = m; { // Fix the value of rowidLimit @@ -1848,9 +1857,10 @@ unsigned int Connection::purgeReadings(unsigned long age, #endif unsigned int deletedRows = 0; - char *zErrMsg = NULL; unsigned int rowsAffected, totTime=0, prevBlocks=0, prevTotTime=0; logger->info("Purge about to delete readings # %ld to %ld", rowidMin, rowidLimit); + sqlite3_stmt *stmt; + bool rowsAvailableToPurge = false; while (rowidMin < rowidLimit) { blocks++; @@ -1859,31 +1869,37 @@ unsigned int Connection::purgeReadings(unsigned long age, { rowidMin = rowidLimit; } - SQLBuffer sql; - sql.append("DELETE FROM " READINGS_DB_NAME_BASE "." READINGS_TABLE " WHERE rowid <= "); - sql.append(rowidMin); - sql.append(';'); - const char *query = sql.coalesce(); - logSQL("ReadingsPurge", query); - + int rc; + { + SQLBuffer sql; + sql.append("DELETE FROM " READINGS_DB_NAME_BASE "." READINGS_TABLE " WHERE rowid <= ? ;"); + const char *query = sql.coalesce(); + + rc = sqlite3_prepare_v2(dbHandle, query, strlen(query), &stmt, NULL); + if (rc != SQLITE_OK) + { + raiseError("purgeReadings", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", query); + return 0; + } + delete[] query; + } + sqlite3_bind_int(stmt, 1,(unsigned long) rowidMin); + rowsAvailableToPurge = true; { //unique_lock lck(db_mutex); // if (m_writeAccessOngoing) db_cv.wait(lck); START_TIME; // Exec DELETE query: no callback, no resultset - rc = SQLexec(dbHandle, - query, - NULL, - NULL, - &zErrMsg); - END_TIME; + rc = SQLstep(stmt); - logger->debug("%s - DELETE - query :%s: rowsAffected :%ld:", __FUNCTION__, query ,rowsAffected); + END_TIME; + + logSQL("ReadingsPurge", sqlite3_expanded_sql(stmt)); - // Release memory for 'query' var - delete[] query; + logger->debug("%s - DELETE - query :%s: rowsAffected :%ld:", __FUNCTION__, sqlite3_expanded_sql(stmt) ,rowsAffected); totTime += usecs; @@ -1892,17 +1908,21 @@ unsigned int Connection::purgeReadings(unsigned long age, std::this_thread::yield(); // Give other threads a chance to run } } - - if (rc != SQLITE_OK) + if (rc == SQLITE_DONE) { - raiseError("purge - phase 3", zErrMsg); - sqlite3_free(zErrMsg); + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); + } + else + { + raiseError("purge - phase 3", sqlite3_errmsg(dbHandle)); return 0; } - + // Get db changes rowsAffected = sqlite3_changes(dbHandle); deletedRows += rowsAffected; + logger->debug("Purge delete block #%d with %d readings", blocks, rowsAffected); if(blocks % RECALC_PURGE_BLOCK_SIZE_NUM_BLOCKS == 0) @@ -1932,7 +1952,12 @@ unsigned int Connection::purgeReadings(unsigned long age, } //Logger::getLogger()->debug("Purge delete block #%d with %d readings", blocks, rowsAffected); } while (rowidMin < rowidLimit); - + + if (rowsAvailableToPurge) + { + sqlite3_finalize(stmt); + } + unsentRetained = maxrowidLimit - rowidLimit; numReadings = maxrowidLimit +1 - minrowidLimit - deletedRows; @@ -2001,6 +2026,8 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, char *zErrMsg = NULL; int rc; + sqlite3_stmt *stmt; + sqlite3_stmt *idStmt; rc = SQLexec(dbHandle, "select count(rowid) from " READINGS_DB_NAME_BASE "." READINGS_TABLE ";", rowidCallback, @@ -2030,24 +2057,35 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, numReadings = rowcount; rowsAffected = 0; deletedRows = 0; - + bool rowsAvailableToPurge = true; do { if (rowcount <= rows) { logger->info("Row count %d is less than required rows %d", rowcount, rows); + rowsAvailableToPurge = false; break; } + + SQLBuffer sqlBuffer; + sqlBuffer.append("select min(id) from " READINGS_DB_NAME_BASE "." READINGS_TABLE ";"); + const char *query = sqlBuffer.coalesce(); - rc = SQLexec(dbHandle, - "select min(id) from " READINGS_DB_NAME_BASE "." READINGS_TABLE ";", - rowidCallback, - &minId, - &zErrMsg); + rc = sqlite3_prepare_v2(dbHandle, query, -1, &idStmt, NULL); - if (rc != SQLITE_OK) + if (SQLstep(idStmt) == SQLITE_ROW) + { + minId = sqlite3_column_int(idStmt, 0); + } + + delete[] query; + + sqlite3_clear_bindings(idStmt); + sqlite3_reset(idStmt); + + if (rc == SQLITE_ERROR) { - raiseError("purge - phaase 0, fetching minimum id", zErrMsg); + raiseError("purge - phaase 0, fetching minimum id", sqlite3_errmsg(dbHandle)); sqlite3_free(zErrMsg); return 0; } @@ -2064,27 +2102,43 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, deletePoint = limit; } } - SQLBuffer sql; - - logger->info("RowCount %lu, Max Id %lu, min Id %lu, delete point %lu", rowcount, maxId, minId, deletePoint); + + { + SQLBuffer sql; + logger->info("RowCount %lu, Max Id %lu, min Id %lu, delete point %lu", rowcount, maxId, minId, deletePoint); + + sql.append("delete from " READINGS_DB_NAME_BASE "." READINGS_TABLE " where id <= ? ;"); + const char *query = sql.coalesce(); + + rc = sqlite3_prepare_v2(dbHandle, query, strlen(query), &stmt, NULL); + + if (rc != SQLITE_OK) + { + raiseError("purgeReadingsByRows", sqlite3_errmsg(dbHandle)); + Logger::getLogger()->error("SQL statement: %s", query); + return 0; + } + delete[] query; + } + sqlite3_bind_int(stmt, 1,(unsigned long) deletePoint); - sql.append("delete from " READINGS_DB_NAME_BASE "." READINGS_TABLE " where id <= "); - sql.append(deletePoint); - const char *query = sql.coalesce(); { //unique_lock lck(db_mutex); // if (m_writeAccessOngoing) db_cv.wait(lck); // Exec DELETE query: no callback, no resultset - rc = SQLexec(dbHandle, query, NULL, NULL, &zErrMsg); + rc = SQLstep(stmt); + if (rc == SQLITE_DONE) + { + sqlite3_clear_bindings(stmt); + sqlite3_reset(stmt); + } rowsAffected = sqlite3_changes(dbHandle); deletedRows += rowsAffected; numReadings -= rowsAffected; rowcount -= rowsAffected; - // Release memory for 'query' var - delete[] query; logger->debug("Deleted %lu rows", rowsAffected); if (rowsAffected == 0) { @@ -2102,7 +2156,12 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, std::this_thread::yield(); // Give other threads a chane to run } while (rowcount > rows); - + if (rowsAvailableToPurge) + { + sqlite3_finalize(idStmt); + sqlite3_finalize(stmt); + } + if (limit) { From 530ef8c96f4bfac754ecab83720cb7a4368141ef Mon Sep 17 00:00:00 2001 From: nandan Date: Fri, 3 Feb 2023 18:56:47 +0530 Subject: [PATCH 084/499] FOGL-7353 : Improve purge performance Signed-off-by: nandan --- C/common/include/reading_set.h | 1 + C/common/reading_set.cpp | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/C/common/include/reading_set.h b/C/common/include/reading_set.h index d38aed4407..304ad7cc76 100755 --- a/C/common/include/reading_set.h +++ b/C/common/include/reading_set.h @@ -48,6 +48,7 @@ class ReadingSet { void append(const std::vector &); void removeAll(); void clear(); + bool copy(const ReadingSet& src); protected: unsigned long m_count; diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 2040d145cb..f4e23b5e6e 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -192,6 +192,45 @@ ReadingSet::append(const vector& readings) } } +/** +* Deep copy a set of readings to this reading set. +*/ +bool +ReadingSet::copy(const ReadingSet& src) +{ + vector *readings = new vector; + bool copyResult = true; + try + { + for ( auto rs : src.getAllReadings()) + { + std::string assetName = rs->getAssetName(); + for ( auto dp : rs->getReadingData()) + { + std::string dataPointName = dp->getName(); + DatapointValue dv = dp->getData(); + Datapoint *value = new Datapoint(dataPointName, dv); + Reading *in = new Reading(assetName, value); + readings->push_back(in); + } + } + } + catch(...) + { + copyResult = false; + readings->clear(); + Logger::getLogger()->error("Copy opeation failed"); + } + + //Append if All elements have been copied successfully + if (copyResult) + { + append(*readings); + } + + return copyResult; +} + /** * Remove all readings from the reading set and delete the memory * After this call the reading set exists but contains no readings. From adcfccad037d641f3cc399ba7297c6c5a702cf41 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 3 Feb 2023 22:19:22 +0000 Subject: [PATCH 085/499] FOGL-7333 Add new poll types to the south service (#956) Signed-off-by: Mark Riddoch --- C/services/south/include/configuration.h | 43 --- C/services/south/include/defaults.h | 4 +- C/services/south/include/south_service.h | 15 + C/services/south/south.cpp | 464 ++++++++++++++++++++--- docs/images/poll_type.png | Bin 0 -> 29498 bytes docs/images/south_advanced.jpg | Bin 91489 -> 86669 bytes docs/tuning_fledge.rst | 46 ++- 7 files changed, 468 insertions(+), 104 deletions(-) delete mode 100644 C/services/south/include/configuration.h create mode 100644 docs/images/poll_type.png diff --git a/C/services/south/include/configuration.h b/C/services/south/include/configuration.h deleted file mode 100644 index 4925d4dfa5..0000000000 --- a/C/services/south/include/configuration.h +++ /dev/null @@ -1,43 +0,0 @@ -#ifndef _CONFIGURATION_H -#define _CONFIGURATION_H -/* - * Fledge storage service. - * - * Copyright (c) 2017 OSisoft, LLC - * - * Released under the Apache 2.0 Licence - * - * Author: Mark Riddoch - */ - -#include -#include -#include - -#define STORAGE_CATEGORY "STORAGE" -#define CONFIGURATION_CACHE_FILE "storage.json" - -/** - * The storage service must handle its own configuration differently - * to other services as it is unable to read the configuration from - * the database. The configuration is required in order to connnect - * to the database. Therefore it keeps a shadow copy in a local file - * and it keeps this local, cached copy up to date by registering - * interest in the category and whenever a chaneg is made writing - * the category to the local cache file. - */ -class StorageConfiguration { - public: - StorageConfiguration(); - const char *getValue(const std::string& key); - bool setValue(const std::string& key, const std::string& value); - void updateCategory(const std::string& json); - private: - void getConfigCache(std::string& cache); - rapidjson::Document document; - void readCache(); - void writeCache(); - Logger *logger; -}; - -#endif diff --git a/C/services/south/include/defaults.h b/C/services/south/include/defaults.h index b5472072bf..d6eefed30e 100644 --- a/C/services/south/include/defaults.h +++ b/C/services/south/include/defaults.h @@ -21,10 +21,10 @@ static struct { "Maximum time to spend filling buffer before sending", "integer", "5000" }, { "bufferThreshold", "Maximum buffered Readings", "Number of readings to buffer before sending", "integer", "100" }, - { "readingsPerSec", "Reading Rate", - "Number of readings to generate per interval", "integer", "1" }, { "throttle", "Throttle", "Enable flow control by reducing the poll rate", "boolean", "false" }, + { "readingsPerSec", "Reading Rate", + "Number of readings to generate per interval", "integer", "1" }, { NULL, NULL, NULL, NULL, NULL } }; #endif diff --git a/C/services/south/include/south_service.h b/C/services/south/include/south_service.h index 7662c48142..3731ab128d 100644 --- a/C/services/south/include/south_service.h +++ b/C/services/south/include/south_service.h @@ -72,6 +72,10 @@ class SouthService : public ServiceAuthHandler { std::string parent_name, std::string current_name); void throttlePoll(); + void processNumberList(const ConfigCategory& cateogry, const std::string& item, std::vector& list); + void calculateTimerRate(); + bool syncToNextPoll(); + bool onDemandPoll(); private: std::thread *m_reconfThread; std::deque> m_pendingNewConfig; @@ -104,6 +108,17 @@ class SouthService : public ServiceAuthHandler { bool m_requestRestart; std::string m_rateUnits; StorageAssetTracker *m_storageAssetTracker; + enum { POLL_INTERVAL, POLL_FIXED, POLL_ON_DEMAND } + m_pollType; + std::vector m_hours; + std::vector m_minutes; + std::vector m_seconds; + std::string m_hoursStr; + std::string m_minutesStr; + std::string m_secondsStr; + std::condition_variable m_pollCV; + std::mutex m_pollMutex; + bool m_doPoll; }; #endif diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index f469ca342d..f1075b927c 100755 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -435,29 +435,7 @@ void SouthService::start(string& coreAddress, unsigned short corePort) // Get and ingest data if (! southPlugin->isAsync()) { - string units = m_configAdvanced.getValue("units"); - unsigned long dividend = 1000000; - if (units.compare("second") == 0) - dividend = 1000000; - else if (units.compare("minute") == 0) - dividend = 60000000; - else if (units.compare("hour") == 0) - dividend = 3600000000; - m_rateUnits = units; - unsigned long usecs = dividend / m_readingsPerSec; - - if (usecs > MAX_SLEEP * 1000000) - { - double x = usecs / (MAX_SLEEP * 1000000); - m_repeatCnt = ceil(x); - usecs /= m_repeatCnt; - } - else - { - m_repeatCnt = 1; - } - m_desiredRate.tv_sec = (int)(usecs / 1000000); - m_desiredRate.tv_usec = (int)(usecs % 1000000); + calculateTimerRate(); m_timerfd = createTimerFd(m_desiredRate); // interval to be passed is in usecs m_currentRate = m_desiredRate; if (m_timerfd < 0) @@ -513,23 +491,36 @@ void SouthService::start(string& coreAddress, unsigned short corePort) while (!m_shutdown) { - uint64_t exp; + uint64_t exp = 0; ssize_t s; - long rep = m_repeatCnt; - while (rep > 0) + if (m_pollType == POLL_FIXED) { - s = read(m_timerfd, &exp, sizeof(uint64_t)); - if ((unsigned int)s != sizeof(uint64_t)) - logger->error("timerfd read()"); - if (exp > 100 && exp > m_readingsPerSec/2) - logger->error("%d expiry notifications accumulated", exp); - rep--; - if (m_shutdown) + if (syncToNextPoll()) + exp = 1; // Perform one poll + } + else if (m_pollType == POLL_INTERVAL) + { + long rep = m_repeatCnt; + while (rep > 0) { - break; + s = read(m_timerfd, &exp, sizeof(uint64_t)); + if ((unsigned int)s != sizeof(uint64_t)) + logger->error("timerfd read()"); + if (exp > 100 && exp > m_readingsPerSec/2) + logger->error("%d expiry notifications accumulated", exp); + rep--; + if (m_shutdown) + { + break; + } } } + else if (m_pollType == POLL_ON_DEMAND) + { + if (onDemandPoll()) + exp = 1; + } if (m_shutdown) { break; @@ -814,7 +805,16 @@ void SouthService::shutdown() /* Stop recieving new requests and allow existing * requests to drain. */ - m_shutdown = true; + if (m_pollType == POLL_ON_DEMAND) + { + lock_guard lk(m_pollMutex); + m_shutdown = true; + m_pollCV.notify_all(); + } + else + { + m_shutdown = true; + } logger->info("South service shutdown in progress."); } @@ -871,34 +871,37 @@ void SouthService::processConfigChange(const string& categoryName, const string& m_readingsPerSec = 1; } string units = m_configAdvanced.getValue("units"); - unsigned long dividend = 1000000; - if (units.compare("second") == 0) - dividend = 1000000; - else if (units.compare("minute") == 0) - dividend = 60000000; - else if (units.compare("hour") == 0) - dividend = 3600000000; - if (newval != m_readingsPerSec || m_rateUnits.compare(units) != 0) + string pollType = m_configAdvanced.getValue("pollType"); + if (pollType.compare("Fixed Times") == 0) + { + m_pollType = POLL_FIXED; + processNumberList(m_configAdvanced, "pollHours", m_hours); + processNumberList(m_configAdvanced, "pollMinutes", m_minutes); + processNumberList(m_configAdvanced, "pollSeconds", m_seconds); + + if (m_minutes.size() == 0 && m_hours.size() != 0) + m_minutes.push_back(0); + if (m_seconds.size() == 0 && m_minutes.size() != 0) + m_seconds.push_back(0); + + m_desiredRate.tv_sec = 1; + m_desiredRate.tv_usec = 0; + } + else if (pollType.compare("Interval") == 0 + && (newval != m_readingsPerSec || m_rateUnits.compare(units) != 0)) { + m_pollType = POLL_INTERVAL; m_readingsPerSec = newval; m_rateUnits = units; close(m_timerfd); - unsigned long usecs = dividend / m_readingsPerSec; - if (usecs > MAX_SLEEP * 1000000) - { - double x = usecs / (MAX_SLEEP * 1000000); - m_repeatCnt = ceil(x); - usecs /= m_repeatCnt; - } - else - { - m_repeatCnt = 1; - } - m_desiredRate.tv_sec = (int)(usecs / 1000000); - m_desiredRate.tv_usec = (int)(usecs % 1000000); + calculateTimerRate(); m_currentRate = m_desiredRate; m_timerfd = createTimerFd(m_desiredRate); // interval to be passed is in usecs } + else if (pollType.compare("On Demand") == 0) + { + m_pollType = POLL_ON_DEMAND; + } } catch (ConfigItemNotFound e) { logger->error("Failed to update poll interval following configuration change"); } @@ -1077,6 +1080,40 @@ void SouthService::addConfigDefaults(DefaultConfigCategory& defaultConfig) defaultConfig.addItem("units", "Reading Rate Per", "second", "second", rateUnits); defaultConfig.setItemDisplayName("units", "Reading Rate Per"); + + /* Now add the fixed time polling option */ + vector pollOptions = { "Interval", "Fixed Times", "On Demand" }; + defaultConfig.addItem("pollType", "Either poll at fixed intervals, at fixed times or when trigger by a poll control operation.", + "Interval", "Interval", pollOptions); + defaultConfig.setItemDisplayName("pollType", "Poll Type"); + + /* Add the validity for interval polling items */ + defaultConfig.setItemAttribute("readingsPerSec", + ConfigCategory::VALIDITY_ATTR, "pollType == \"Interval\""); + defaultConfig.setItemAttribute("units", + ConfigCategory::VALIDITY_ATTR, "pollType == \"Interval\""); + defaultConfig.setItemAttribute("throttle", + ConfigCategory::VALIDITY_ATTR, "pollType == \"Interval\""); + + /* Add the three time specifiers */ + defaultConfig.addItem("pollHours", + "List of hours on which to poll or leave empty for all hours", + "string", "", ""); + defaultConfig.setItemDisplayName("pollHours", "Hours"); + defaultConfig.setItemAttribute("pollHours", + ConfigCategory::VALIDITY_ATTR, "pollType == \"Fixed Times\""); + defaultConfig.addItem("pollMinutes", + "List of minutes on which to poll or leave empty for all minutes", + "string", "", ""); + defaultConfig.setItemDisplayName("pollMinutes", "Minutes"); + defaultConfig.setItemAttribute("pollMinutes", + ConfigCategory::VALIDITY_ATTR, "pollType == \"Fixed Times\""); + defaultConfig.addItem("pollSeconds", + "Seconds on which to poll expressed as a comma seperated list", + "string", "0,15,30,45", "0,15,30,40"); + defaultConfig.setItemDisplayName("pollSeconds", "Seconds"); + defaultConfig.setItemAttribute("pollSeconds", + ConfigCategory::VALIDITY_ATTR, "pollType == \"Fixed Times\""); } if (southPlugin->hasControl()) @@ -1257,7 +1294,21 @@ bool SouthService::setPoint(const string& name, const string& value) */ bool SouthService::operation(const string& operation, vector& params) { - if (southPlugin->hasControl()) + if (operation.compare("poll") == 0) + { + if (m_pollType == POLL_ON_DEMAND) + { + m_doPoll = true; + m_pollCV.notify_all(); + return true; + } + else + { + logger->warn("Received a poll request for a service that is not enabled for on demand polling"); + return false; + } + } + else if (southPlugin->hasControl()) { return southPlugin->operation(operation, params); } @@ -1267,3 +1318,302 @@ bool SouthService::operation(const string& operation, vector return false; } } + +/** + * Process a list of numbers into a vector of integers. + * The list of numbers is obtained from a configuration + * item. + * + * @param category The configuration category + * @param item Name of the configuration item + * @param list The vector to populate + */ +void SouthService::processNumberList(const ConfigCategory& category, + const string& item, vector& list) +{ + list.clear(); + if (!category.itemExists(item)) + { + Logger::getLogger()->warn("Item %s does not exist", item.c_str()); + return; + } + string value = category.getValue(item); + if (value.length() == 0) + { + Logger::getLogger()->info("Item %s is empty", item.c_str()); + return; + } + + const char *ptr = value.c_str(); + char *eptr; + while (*ptr) + { + list.push_back(strtoul(ptr, &eptr, 10)); + ptr = eptr; + if (*ptr == ',') + ptr++; + } +} + +/** + * Calcuate the rate at which the timer should trigger and the repeat + * requirement needed to match the requested poll rate + */ +void SouthService::calculateTimerRate() +{ + string pollType = m_configAdvanced.getValue("pollType"); + if (pollType.compare("Fixed Times") == 0) + { + if (m_pollType == POLL_ON_DEMAND) + { + lock_guard lk(m_pollMutex); + m_pollType = POLL_FIXED; + m_pollCV.notify_all(); + } + m_pollType = POLL_FIXED; + processNumberList(m_configAdvanced, "pollHours", m_hours); + processNumberList(m_configAdvanced, "pollMinutes", m_minutes); + processNumberList(m_configAdvanced, "pollSeconds", m_seconds); + + if (m_minutes.size() == 0 && m_hours.size() != 0) + m_minutes.push_back(0); + if (m_seconds.size() == 0 && m_minutes.size() != 0) + m_seconds.push_back(0); + + m_desiredRate.tv_sec = 1; + m_desiredRate.tv_usec = 0; + } + else if (pollType.compare("On Demand") == 0) + { + m_pollType = POLL_ON_DEMAND; + } + else + { + if (m_pollType == POLL_ON_DEMAND) + { + lock_guard lk(m_pollMutex); + m_pollType = POLL_INTERVAL; + m_pollCV.notify_all(); + } + m_pollType = POLL_INTERVAL; + string units = m_configAdvanced.getValue("units"); + unsigned long dividend = 1000000; + if (units.compare("second") == 0) + dividend = 1000000; + else if (units.compare("minute") == 0) + dividend = 60000000; + else if (units.compare("hour") == 0) + dividend = 3600000000; + m_rateUnits = units; + unsigned long usecs = dividend / m_readingsPerSec; + + if (usecs > MAX_SLEEP * 1000000) + { + double x = usecs / (MAX_SLEEP * 1000000); + m_repeatCnt = ceil(x); + usecs /= m_repeatCnt; + } + else + { + m_repeatCnt = 1; + } + m_desiredRate.tv_sec = (int)(usecs / 1000000); + m_desiredRate.tv_usec = (int)(usecs % 1000000); + } +} + +/** + * Find the next fixed time poll time and wait for that time before returning. + * This method will also return if m_shutdown is set. + * + * @return bool True if the return is doe to a poll being required. + */ +bool SouthService::syncToNextPoll() +{ + time_t tim = time(0); + struct tm tm; + localtime_r(&tim, &tm); + unsigned long waitFor; + + if (m_hours.size() == 0 && m_minutes.size() == 0 && m_seconds.size() == 0) + { + Logger::getLogger()->error("Poll time misconfigured."); + } + else if (m_hours.size() == 0 && m_minutes.size() == 0) + { + // Only looking at seconds + unsigned int i; + for (i = 0; i < m_seconds.size() && m_seconds[i] <= (unsigned)tm.tm_sec; i++) + { + } + if (i == m_seconds.size()) + { + waitFor = (60 - (unsigned)tm.tm_sec) + m_seconds[0]; + } + else + { + waitFor = m_seconds[i] - (unsigned)tm.tm_sec; + } + } + else if (m_hours.size() == 0) + { + unsigned int target_min = (unsigned)tm.tm_min; + unsigned int min, sec; + for (min = 0; min < m_minutes.size() && m_minutes[min] < target_min; min++) + { + } + if (min == m_minutes.size()) // Reset to start of minute list + { + min = 0; + } + + if (m_minutes[min] != target_min) // Not this minute + { + sec = 0; // Always use first setting of seconds + } + else + { + for (sec = 0; sec < m_seconds.size() && m_seconds[sec] <= (unsigned)tm.tm_sec; sec++) + { + } + if (sec == m_seconds.size()) + { + // Too late in this minute use next minute setting + sec = 0; + min++; + if (min >= m_minutes[min]) + { + min = 0; + } + } + } + waitFor = 0; + if (m_minutes[min] > (unsigned)tm.tm_min) + { + waitFor = 60 * (m_minutes[min] - (unsigned)tm.tm_min); + } + else if (m_minutes[min] < (unsigned)tm.tm_min) + { + waitFor = 60 * ((60 - (unsigned)tm.tm_min) + m_minutes[min]); + } + if (m_seconds[sec] > (unsigned)tm.tm_sec) + { + waitFor += ((unsigned)tm.tm_sec - m_seconds[sec]); + } + else + { + waitFor += ((60 - (unsigned)tm.tm_sec) + m_seconds[sec]); + } + } + else // Hours, minutes and seconds + { + unsigned int hour, min, sec; + for (hour = 0; hour < m_hours.size() && m_hours[hour] < (unsigned)tm.tm_hour; hour++) + { + } + if (hour == m_hours.size()) // Reset to start of minute list + { + min = 0; + sec = 0; + hour = 0; + } + else if (m_hours[hour] == (unsigned)tm.tm_hour) // Check for this hour + { + for (min = 0; min < m_minutes.size() && m_minutes[min] < (unsigned)tm.tm_min; min++) + { + } + if (min < m_minutes.size()) // may still be a trogger in this hor + { + for (sec = 0; sec < m_seconds.size() && m_seconds[sec] <= (unsigned)tm.tm_sec; sec++) + { + } + if (sec == m_seconds.size()) + { + // Too late in this minute use next minute setting + sec = 0; + min++; + if (min == m_minutes.size()) + { + min = 0; + sec = 0; + hour++; + if (m_hours.size() == hour) + hour = 0; + } + } + } + else + { + hour++; + min = 0; + sec = 0; + if (m_hours.size() == hour) + hour = 0; + } + } + else + { + hour++; + min = 0; + sec = 0; + if (m_hours.size() == hour) + hour = 0; + } + waitFor = 0; + if (m_hours[hour] > (unsigned)tm.tm_hour) + { + waitFor += 60 * 60 * (m_hours[hour] - (unsigned)tm.tm_hour); + } + else if (m_minutes[min] < (unsigned)tm.tm_min) + { + waitFor += 60 * 60 * ((24 - (unsigned)tm.tm_hour) + m_hours[hour]); + } + if (m_minutes[min] > (unsigned)tm.tm_min) + { + waitFor += 60 * (m_minutes[min] - (unsigned)tm.tm_min); + } + else if (m_minutes[min] < (unsigned)tm.tm_min) + { + waitFor += 60 * ((60 - (unsigned)tm.tm_min) + m_minutes[min]); + } + if (m_seconds[sec] > (unsigned)tm.tm_sec) + { + waitFor += ((unsigned)tm.tm_sec - m_seconds[sec]); + } + else + { + waitFor += ((60 - (unsigned)tm.tm_sec) + m_seconds[sec]); + } + } + + + uint64_t exp; + while (waitFor) + { + read(m_timerfd, &exp, sizeof(uint64_t)); + waitFor--; + if (m_shutdown) + return false; + if (m_pollType != POLL_FIXED) // Configuration has change to the poll type + { + return false; + } + } + return true; +} + +/** + * Wait until either a shutdown request is received or a poll operation + * + * @return bool True if the return is due to a new poll request + */ +bool SouthService::onDemandPoll() +{ + unique_lock lk(m_pollMutex); + if (! m_shutdown) + { + m_doPoll = false; + m_pollCV.wait(lk); + } + return m_doPoll; +} diff --git a/docs/images/poll_type.png b/docs/images/poll_type.png new file mode 100644 index 0000000000000000000000000000000000000000..e7eab1f43b112ff8e2442840a59985844c43662b GIT binary patch literal 29498 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 GkpBZEdz0A! literal 0 HcmV?d00001 diff --git a/docs/images/south_advanced.jpg b/docs/images/south_advanced.jpg index 3a0df711b84bd02e913dcee6829584a23a35f91d..9bf92c88d7390ae2d56f6108680b06a2248244cf 100644 GIT binary patch literal 86669 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