From 8a05407c6172ad96254f3992d0440d0b14aaec6e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 19 Dec 2023 18:49:22 +0530 Subject: [PATCH 001/146] priority column added in scheduled_process table and default entries for SQLite only Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlite/init.sql | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index ea55f9a235..091823a63c 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -470,8 +470,9 @@ CREATE INDEX fki_user_asset_permissions_fk2 -- List of scheduled Processes CREATE TABLE fledge.scheduled_processes ( - name character varying(255) NOT NULL, -- Name of the process - script JSON, -- Full path of the process + name character varying(255) NOT NULL, -- Name of the process + script JSON, -- Full path of the process + priority INTEGER NOT NULL DEFAULT 999, -- priority to run for STARTUP CONSTRAINT scheduled_processes_pkey PRIMARY KEY ( name ) ); -- List of schedules @@ -819,13 +820,13 @@ INSERT INTO fledge.scheduled_processes (name, script) VALUES ('restore', '["task -- South, Notification, North Tasks -- -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'south_c', '["services/south_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'notification_c', '["services/notification_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north_c', '["tasks/north_c"]' ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'south_c', '["services/south_c"]', 100 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'notification_c', '["services/notification_c"]', 30 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'north_c', '["tasks/north_c"]', 200 ); INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north', '["tasks/north"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north_C', '["services/north_C"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'dispatcher_c', '["services/dispatcher_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'bucket_storage_c', '["services/bucket_storage_c"]' ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'north_C', '["services/north_C"]', 200 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'dispatcher_c', '["services/dispatcher_c"]', 20 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'bucket_storage_c', '["services/bucket_storage_c"]', 10 ); -- Automation script tasks -- From ccad4ef734ecd92b674c7b43c36459b320af171f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 19 Dec 2023 18:49:54 +0530 Subject: [PATCH 002/146] scheduler entries as per priority order from scheduled process Signed-off-by: ashish-jabble --- .../services/core/scheduler/scheduler.py | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index 61a5d659ac..67e151db83 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -234,7 +234,7 @@ async def _wait_for_task_completion(self, task_process: _TaskProcess) -> None: task_process.process.pid, exit_code, len(self._task_processes) - 1, - self._process_scripts[schedule.process_name]) + self._process_scripts[schedule.process_name][0]) schedule_execution = self._schedule_executions[schedule.id] del schedule_execution.task_processes[task_process.task_id] @@ -296,7 +296,7 @@ async def _start_task(self, schedule: _ScheduleRow, dryrun=False) -> None: # This check is necessary only if significant time can elapse between "await" and # the start of the awaited coroutine. - args = self._process_scripts[schedule.process_name] + args = self._process_scripts[schedule.process_name][0] # add core management host and port to process script args args_to_exec = args.copy() @@ -674,18 +674,30 @@ async def _get_process_scripts(self): self._logger.debug('Database command: %s', "scheduled_processes") res = await self._storage_async.query_tbl("scheduled_processes") for row in res['rows']: - self._process_scripts[row.get('name')] = row.get('script') + self._process_scripts[row.get('name')] = (row.get('script'), row.get('priority')) except Exception: self._logger.exception('Query failed: %s', "scheduled_processes") raise async def _get_schedules(self): + def _get_schedule_by_priority(sch_list): + schedules_in_order = [] + for sch in sch_list: + sch['priority'] = 999 + for name, priority in self._process_scripts.items(): + if name == sch['process_name']: + sch['priority'] = priority[1] + schedules_in_order.append(sch) + #schedules_in_order.sort(key=lambda x: x['priority']) + sort_sch = sorted(schedules_in_order, key=lambda k: ("priority" not in k, k.get("priority", None))) + self._logger.debug(sort_sch) + return sort_sch + # TODO: Get processes first, then add to Schedule try: self._logger.debug('Database command: %s', 'schedules') res = await self._storage_async.query_tbl("schedules") - - for row in res['rows']: + for row in _get_schedule_by_priority(res['rows']): interval_days, interval_dt = self.extract_day_time_from_interval(row.get('schedule_interval')) interval = datetime.timedelta(days=interval_days, hours=interval_dt.hour, minutes=interval_dt.minute, seconds=interval_dt.second) @@ -878,7 +890,7 @@ async def stop(self): schedule.process_name, task_id, task_process.process.pid, - self._process_scripts[schedule.process_name]) + self._process_scripts[schedule.process_name][0]) try: # We need to terminate the child processes because now all tasks are started vide a script and # this creates two unix processes. Scheduler can store pid of the parent shell script process only @@ -935,7 +947,7 @@ async def get_scheduled_processes(self) -> List[ScheduledProcess]: for (name, script) in self._process_scripts.items(): process = ScheduledProcess() process.name = name - process.script = script + process.script = script[0] processes.append(process) return processes @@ -1139,7 +1151,7 @@ async def save_schedule(self, schedule: Schedule, is_enabled_modified=None, dryr self._logger.debug('Database command: %s', select_payload) res = await self._storage_async.query_tbl_with_payload("scheduled_processes", select_payload) for row in res['rows']: - self._process_scripts[row.get('name')] = row.get('script') + self._process_scripts[row.get('name')] = (row.get('script'), row.get('priority')) except Exception: self._logger.exception('Select failed: %s', select_payload) @@ -1305,7 +1317,7 @@ async def disable_schedule(self, schedule_id: uuid.UUID, bypass_check=None, reco schedule.process_name, task_id, task_process.process.pid, - self._process_scripts[schedule.process_name]) + self._process_scripts[schedule.process_name][0]) # TODO: FOGL-356 track the last time TERM was sent to each task task_process.cancel_requested = time.time() task_future = task_process.future @@ -1588,7 +1600,7 @@ async def cancel_task(self, task_id: uuid.UUID) -> None: schedule.process_name, task_id, task_process.process.pid, - self._process_scripts[schedule.process_name]) + self._process_scripts[schedule.process_name][0]) try: # We need to terminate the child processes because now all tasks are started vide a script and From 7f39c8d92410306cfada441134c8aeccc26d5631 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Wed, 3 Jan 2024 16:49:04 -0500 Subject: [PATCH 003/146] Added AVEVA version information Add the section OMF Version Support to document the version of OMF that will be used to post data to the various versions of PI Web API, Edge Data Store (EDS), and AVEVA Data Hub (ADH). Signed-off-by: Ray Verhoeff --- docs/OMF.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/OMF.rst b/docs/OMF.rst index fdbda34529..e3610bf5b2 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -667,3 +667,28 @@ Versions of this plugin prior to 2.1.0 created a complex type within OMF for eac 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. +OMF Version Support +------------------- + +To date, AVEVA has released three versions of the OSIsoft Message Format (OMF) specification: 1.0, 1.1 and 1.2. +The OMF Plugin supports all three OMF versions. +The plugin will determine the OMF version to use by reading product version information from the AVEVA data destination system. +These are the OMF versions the plugin will use to post data: + ++-----------+----------+---------------------+ +|OMF Version|PI Web API|Edge Data Store (EDS)| ++===========+==========+=====================+ +| 1.2|- 2021 |- 2023 | +| |- 2021 SP1|- 2023 Patch 1 | +| |- 2021 SP2| | +| |- 2021 SP3| | +| |- 2023 | | ++-----------+----------+---------------------+ +| 1.1|- 2019 | | +| |- 2019 SP1| | ++-----------+----------+---------------------+ +| 1.0| |- 2020 | ++-----------+----------+---------------------+ + +The AVEVA Data Hub (ADH) is cloud-deployed and is always at the latest version of OMF support which is 1.2. +This includes the legacy OSIsoft Cloud Services (OCS) endpoints. From 16fd22d4e2f712657f46dc1cf48892fd2a58dd25 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Wed, 3 Jan 2024 17:07:31 -0500 Subject: [PATCH 004/146] Renamed Send full structure configuration boolean Renamed configuration boolean Send full structure to Create AF Structure. Signed-off-by: Ray Verhoeff --- docs/OMF.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/OMF.rst b/docs/OMF.rst index e3610bf5b2..9ee26c68a9 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -132,7 +132,7 @@ The *Default Configuration* tab contains the most commonly modified items - *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. + - **Create AF 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`. From 69a5d83ae3a22de6ec4e5e675accde285deb1e93 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Wed, 3 Jan 2024 17:10:25 -0500 Subject: [PATCH 005/146] Updated OMF_Default Updated OMF_Default to rename "Send full structure" to "Create AF Structure." Signed-off-by: Ray Verhoeff --- docs/images/OMF_Default.jpg | Bin 83230 -> 45838 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/images/OMF_Default.jpg b/docs/images/OMF_Default.jpg index 3d7b17ec09697a330ca91815e24f8e77d8183d10..3f6df81b2eb6e5fa5ecfcd763be00ce7d12772a2 100644 GIT binary patch literal 45838 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 From d3d2816844383073b20ac8c6e98ae627963840e4 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 4 Jan 2024 15:23:31 +0530 Subject: [PATCH 006/146] delay for startup services Signed-off-by: ashish-jabble --- .../services/core/scheduler/scheduler.py | 18 ++++++++++++++++-- scripts/services/dispatcher_c | 14 +++++++++++++- scripts/services/north_C | 14 ++++++++++++-- scripts/services/notification_c | 14 +++++++++++++- scripts/services/south_c | 13 ++++++++++++- 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index 67e151db83..6116588684 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -293,6 +293,16 @@ async def _start_task(self, schedule: _ScheduleRow, dryrun=False) -> None: Raises: EnvironmentError: If the process could not start """ + def _get_delay_in_sec(pname): + if pname == 'dispatcher_c': + val = 3 + elif pname == 'notification_c': + val = 5 + elif pname in ('south_c', 'north_C'): + val = 7 + else: + val = 10 + return val # This check is necessary only if significant time can elapse between "await" and # the start of the awaited coroutine. @@ -319,7 +329,12 @@ async def _start_task(self, schedule: _ScheduleRow, dryrun=False) -> None: startToken = ServiceRegistry.issueStartupToken(schedule.name) # Add startup token to args for services args_to_exec.append("--token={}".format(startToken)) - + + # Delay + self._logger.error("{}-{}".format(schedule.name, schedule.process_name)) + res = _get_delay_in_sec(self._process_scripts[schedule.process_name][0][0].split("/")[1]) + args_to_exec.append("--delay={}".format(res)) + args_to_exec.append("--name={}".format(schedule.name)) if dryrun: args_to_exec.append("--dryrun") @@ -688,7 +703,6 @@ def _get_schedule_by_priority(sch_list): if name == sch['process_name']: sch['priority'] = priority[1] schedules_in_order.append(sch) - #schedules_in_order.sort(key=lambda x: x['priority']) sort_sch = sorted(schedules_in_order, key=lambda k: ("priority" not in k, k.get("priority", None))) self._logger.debug(sort_sch) return sort_sch diff --git a/scripts/services/dispatcher_c b/scripts/services/dispatcher_c index 9ec5b874e1..20fdffbc44 100755 --- a/scripts/services/dispatcher_c +++ b/scripts/services/dispatcher_c @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # Run a Fledge Dispatcher service written in C/C++ if [ "${FLEDGE_ROOT}" = "" ]; then FLEDGE_ROOT=/usr/local/fledge @@ -11,4 +11,16 @@ fi cd "${FLEDGE_ROOT}/services" +# startup with delay +for i in "$@"; + do + PARAM=$(echo $i | cut -f1 -d=) + if [ $PARAM = '--delay' ]; then + PARAM_LENGTH=${#PARAM} + VALUE="${i:$PARAM_LENGTH+1}" + sleep $VALUE + #break + fi +done + ./fledge.services.dispatcher "$@" diff --git a/scripts/services/north_C b/scripts/services/north_C index 3cf728d425..31d13568c3 100755 --- a/scripts/services/north_C +++ b/scripts/services/north_C @@ -1,5 +1,4 @@ -#!/bin/sh - +#!/bin/bash # Run a Fledge north service written in C/C++ if [ "${FLEDGE_ROOT}" = "" ]; then FLEDGE_ROOT=/usr/local/fledge @@ -66,6 +65,17 @@ elif [ "$INTERPOSE_NORTH" != "" ]; then ./fledge.services.north "$@" unset LD_PRELOAD else + # startup with delay + for i in "$@"; + do + PARAM=$(echo $i | cut -f1 -d=) + if [ $PARAM = '--delay' ]; then + PARAM_LENGTH=${#PARAM} + VALUE="${i:$PARAM_LENGTH+1}" + sleep $VALUE + #break + fi + done ./fledge.services.north "$@" fi diff --git a/scripts/services/notification_c b/scripts/services/notification_c index 4481e37d2f..aff0d53be4 100755 --- a/scripts/services/notification_c +++ b/scripts/services/notification_c @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # Run a Fledge notification service written in C/C++ if [ "${FLEDGE_ROOT}" = "" ]; then FLEDGE_ROOT=/usr/local/fledge @@ -11,4 +11,16 @@ fi cd "${FLEDGE_ROOT}/services" +# startup with delay +for i in "$@"; + do + PARAM=$(echo $i | cut -f1 -d=) + if [ $PARAM = '--delay' ]; then + PARAM_LENGTH=${#PARAM} + VALUE="${i:$PARAM_LENGTH+1}" + sleep $VALUE + #break + fi +done + ./fledge.services.notification "$@" diff --git a/scripts/services/south_c b/scripts/services/south_c index 6f3d2c13e9..4ab104fe71 100755 --- a/scripts/services/south_c +++ b/scripts/services/south_c @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # Run a Fledge south service written in C/C++ if [ "${FLEDGE_ROOT}" = "" ]; then FLEDGE_ROOT=/usr/local/fledge @@ -33,6 +33,17 @@ if [ "$runvalgrind" = "y" ]; then rm -f "$file" valgrind --leak-check=full --trace-children=yes --log-file="$file" ./fledge.services.south "$@" else + # startup with delay + for i in "$@"; + do + PARAM=$(echo $i | cut -f1 -d=) + if [ $PARAM = '--delay' ]; then + PARAM_LENGTH=${#PARAM} + VALUE="${i:$PARAM_LENGTH+1}" + sleep $VALUE + #break + fi + done ./fledge.services.south "$@" fi From ce8f3101c7cedc605714069cf0d52684ab53fa46 Mon Sep 17 00:00:00 2001 From: Praveen Garg Date: Thu, 4 Jan 2024 20:20:28 +0530 Subject: [PATCH 007/146] fix env checking and install scripts for bullseye; removed stretch --- tests/system/lab/check_env | 9 +++++---- tests/system/lab/install | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/system/lab/check_env b/tests/system/lab/check_env index ee0b090fe7..126f172c7d 100755 --- a/tests/system/lab/check_env +++ b/tests/system/lab/check_env @@ -2,17 +2,18 @@ ID=$(cat /etc/os-release | grep -w ID | cut -f2 -d"=") -if [[ ${ID} == "raspbian" ]] +# debian for bullseye aarch64 +if [[ ${ID} == "raspbian" || ${ID} == "debian" ]] then echo else - echo "Please test with Raspbian OS."; exit 1; + echo "Please test with Raspberry Pi OS."; exit 1; fi VERSION_CODENAME=$(cat /etc/os-release | grep VERSION_CODENAME | cut -f2 -d"=") -if [[ ${VERSION_CODENAME} == "bullseye" || ${VERSION_CODENAME} == "buster" || ${VERSION_CODENAME} == "stretch" ]] +if [[ ${VERSION_CODENAME} == "bullseye" || ${VERSION_CODENAME} == "buster" ]] then echo "Running test on ${VERSION_CODENAME}" else - echo "This test is specific to RPi bullseye, buster and stretch only!"; exit 1; + echo "This test is specific to RPi bullseye & buster only!"; exit 1; fi diff --git a/tests/system/lab/install b/tests/system/lab/install index 1d5d418ea0..af25164f9a 100755 --- a/tests/system/lab/install +++ b/tests/system/lab/install @@ -29,7 +29,7 @@ fi VERSION_CODENAME=$(cat /etc/os-release | grep VERSION_CODENAME | cut -f2 -d"=") wget -q -O - http://archives.fledge-iot.org/KEY.gpg | sudo apt-key add - -echo "deb http://archives.fledge-iot.org/${BUILD_VERSION}/${VERSION_CODENAME}/armv7l/ /" | sudo tee -a /etc/apt/sources.list +echo "deb http://archives.fledge-iot.org/${BUILD_VERSION}/${VERSION_CODENAME}/$(arch)/ /" | sudo tee -a /etc/apt/sources.list sudo apt update time sudo -E apt install -yq fledge From 4c16571e77ecde4322394168d6c94168e804ee77 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 5 Jan 2024 12:12:49 +0530 Subject: [PATCH 008/146] reset process script priority added Signed-off-by: ashish-jabble --- python/fledge/services/core/api/service.py | 3 +++ .../services/core/scheduler/scheduler.py | 18 +++++++++++++----- scripts/services/dispatcher_c | 2 +- scripts/services/north_C | 2 +- scripts/services/notification_c | 2 +- scripts/services/south_c | 2 +- 6 files changed, 20 insertions(+), 9 deletions(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index e15490d48d..5ec093b8bb 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -486,6 +486,9 @@ async def add_service(request): # if "enabled" is supplied, it gets activated in save_schedule() via is_enabled flag schedule.enabled = False + # Reset startup priority order + server.Server.scheduler.reset_process_script_priority() + # Save schedule await server.Server.scheduler.save_schedule(schedule, is_enabled, dryrun=dryrun) schedule = await server.Server.scheduler.get_schedule_by_name(name) diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index 6116588684..ab9a9dff09 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -104,6 +104,8 @@ def __init__(self): """Maximum age of rows in the task table that have finished, in days""" _DELETE_TASKS_LIMIT = 500 """The maximum number of rows to delete in the tasks table in a single transaction""" + _DEFAULT_PROCESS_SCRIPT_PRIORITY = 999 + """Priority order for process scripts""" _HOUR_SECONDS = 3600 _DAY_SECONDS = 3600 * 24 @@ -330,10 +332,10 @@ def _get_delay_in_sec(pname): # Add startup token to args for services args_to_exec.append("--token={}".format(startToken)) - # Delay - self._logger.error("{}-{}".format(schedule.name, schedule.process_name)) - res = _get_delay_in_sec(self._process_scripts[schedule.process_name][0][0].split("/")[1]) - args_to_exec.append("--delay={}".format(res)) + if self._process_scripts[schedule.process_name][1] != self._DEFAULT_PROCESS_SCRIPT_PRIORITY: + # With startup Delay + res = _get_delay_in_sec(self._process_scripts[schedule.process_name][0][0].split("/")[1]) + args_to_exec.append("--delay={}".format(res)) args_to_exec.append("--name={}".format(schedule.name)) if dryrun: @@ -698,7 +700,7 @@ async def _get_schedules(self): def _get_schedule_by_priority(sch_list): schedules_in_order = [] for sch in sch_list: - sch['priority'] = 999 + sch['priority'] = self._DEFAULT_PROCESS_SCRIPT_PRIORITY for name, priority in self._process_scripts.items(): if name == sch['process_name']: sch['priority'] = priority[1] @@ -1673,3 +1675,9 @@ async def audit_trail_entry(self, old_row, new_row): ) 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}) + + def reset_process_script_priority(self): + for k,v in self._process_scripts.items(): + if isinstance(v, tuple): + updated_tuple = (v[0], self._DEFAULT_PROCESS_SCRIPT_PRIORITY) + self._process_scripts[k] = updated_tuple diff --git a/scripts/services/dispatcher_c b/scripts/services/dispatcher_c index 20fdffbc44..9ec51eee26 100755 --- a/scripts/services/dispatcher_c +++ b/scripts/services/dispatcher_c @@ -19,7 +19,7 @@ for i in "$@"; PARAM_LENGTH=${#PARAM} VALUE="${i:$PARAM_LENGTH+1}" sleep $VALUE - #break + break fi done diff --git a/scripts/services/north_C b/scripts/services/north_C index 31d13568c3..db62edc9c3 100755 --- a/scripts/services/north_C +++ b/scripts/services/north_C @@ -73,7 +73,7 @@ else PARAM_LENGTH=${#PARAM} VALUE="${i:$PARAM_LENGTH+1}" sleep $VALUE - #break + break fi done ./fledge.services.north "$@" diff --git a/scripts/services/notification_c b/scripts/services/notification_c index aff0d53be4..c26f5951cc 100755 --- a/scripts/services/notification_c +++ b/scripts/services/notification_c @@ -19,7 +19,7 @@ for i in "$@"; PARAM_LENGTH=${#PARAM} VALUE="${i:$PARAM_LENGTH+1}" sleep $VALUE - #break + break fi done diff --git a/scripts/services/south_c b/scripts/services/south_c index 4ab104fe71..bcfb73cb35 100755 --- a/scripts/services/south_c +++ b/scripts/services/south_c @@ -41,7 +41,7 @@ else PARAM_LENGTH=${#PARAM} VALUE="${i:$PARAM_LENGTH+1}" sleep $VALUE - #break + break fi done ./fledge.services.south "$@" From bd121c5b7a1f6d86d40dce601ae7ae08f9723a90 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 5 Jan 2024 09:29:06 +0000 Subject: [PATCH 009/146] FOGL-8346 Fix lookup data index when using TagName hints. (#1257) * FOGL-8365 Removed reserved characters from container names Signed-off-by: Mark Riddoch * FOGL-8365 Fix lookup data index when using TagName hints. Also fix AF placement and dynamic changing of TagName hint value such that it recreates containers in an efficient fashion. Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/include/linkedlookup.h | 47 ++++++++++++++++--- C/plugins/north/OMF/linkdata.cpp | 52 +++++++++++++++++----- C/plugins/north/OMF/omf.cpp | 26 ++++++++++- 3 files changed, 106 insertions(+), 19 deletions(-) diff --git a/C/plugins/north/OMF/include/linkedlookup.h b/C/plugins/north/OMF/include/linkedlookup.h index a38cdbba2a..560b3da251 100644 --- a/C/plugins/north/OMF/include/linkedlookup.h +++ b/C/plugins/north/OMF/include/linkedlookup.h @@ -28,20 +28,53 @@ typedef enum { class LALookup { public: LALookup() { m_sentState = 0; m_baseType = OMFBT_UNKNOWN; }; - bool assetState() { return (m_sentState & LAL_ASSET_SENT) != 0; }; - bool linkState() { return (m_sentState & LAL_LINK_SENT) != 0; }; - bool containerState() { return (m_sentState & LAL_CONTAINER_SENT) != 0; }; + bool assetState(const std::string& tagName) + { + return ((m_sentState & LAL_ASSET_SENT) != 0) + && (m_tagName.compare(tagName) == 0); + }; + bool linkState(const std::string& tagName) + { + return ((m_sentState & LAL_LINK_SENT) != 0) + && (m_tagName.compare(tagName) == 0); + }; + bool containerState(const std::string& tagName) + { + return ((m_sentState & LAL_CONTAINER_SENT) != 0) + && (m_tagName.compare(tagName) == 0); + }; bool afLinkState() { return (m_sentState & LAL_AFLINK_SENT) != 0; }; void setBaseType(const std::string& baseType); OMFBaseType getBaseType() { return m_baseType; }; std::string getBaseTypeString(); - void assetSent() { m_sentState |= LAL_ASSET_SENT; }; - void linkSent() { m_sentState |= LAL_LINK_SENT; }; + void assetSent(const std::string& tagName) + { + if (m_tagName.compare(tagName)) + { + m_sentState = LAL_ASSET_SENT; + m_tagName = tagName; + } + else + { + m_sentState |= LAL_ASSET_SENT; + } + }; + void linkSent(const std::string& tagName) + { + if (m_tagName.compare(tagName)) + { + // Force the container to resend if the tagName changes + m_tagName = tagName; + m_sentState &= ~LAL_CONTAINER_SENT; + } + m_sentState |= LAL_LINK_SENT; + }; void afLinkSent() { m_sentState |= LAL_AFLINK_SENT; }; - void containerSent(const std::string& baseType); - void containerSent(OMFBaseType baseType) { m_baseType = baseType; }; + void containerSent(const std::string& tagName, const std::string& baseType); + void containerSent(const std::string& tagName, OMFBaseType baseType); private: uint8_t m_sentState; OMFBaseType m_baseType; + std::string m_tagName; }; #endif diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index 91e4612f28..5e1a83cc85 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -76,6 +76,8 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi string assetName = reading.getAssetName(); + string originalAssetName = OMF::ApplyPIServerNamingRulesObj(assetName, NULL); + // Apply any TagName hints to modify the containerid if (hints) { @@ -109,14 +111,14 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi assetName = OMF::ApplyPIServerNamingRulesObj(assetName, NULL); bool needDelim = false; - auto assetLookup = m_linkedAssetState->find(assetName + "."); + auto assetLookup = m_linkedAssetState->find(originalAssetName + "."); if (assetLookup == m_linkedAssetState->end()) { // Panic Asset lookup not created - Logger::getLogger()->fatal("FIXME: no asset lookup item for %s.", assetName.c_str()); + Logger::getLogger()->error("Internal error: No asset lookup item for %s.", assetName.c_str()); return ""; } - if (m_sendFullStructure && assetLookup->second.assetState() == false) + if (m_sendFullStructure && assetLookup->second.assetState(assetName) == false) { // Send the data message to create the asset instance outData.append("{ \"typeid\":\"FledgeAsset\", \"values\":[ { \"AssetId\":\""); @@ -124,7 +126,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi outData.append(assetName + "\""); outData.append("} ] }"); needDelim = true; - assetLookup->second.assetSent(); + assetLookup->second.assetSent(assetName); } /** @@ -183,17 +185,18 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi // Create the link for the asset if not already created string link = assetName + "." + dpName; - auto dpLookup = m_linkedAssetState->find(link); + string dpLookupName = originalAssetName + "." + dpName; + auto dpLookup = m_linkedAssetState->find(dpLookupName); string baseType = getBaseType(dp, format); if (dpLookup == m_linkedAssetState->end()) { Logger::getLogger()->error("Trying to send a link for a datapoint for which we have not created a base type"); } - else if (dpLookup->second.containerState() == false) + else if (dpLookup->second.containerState(assetName) == false) { sendContainer(link, dp, hints, baseType); - dpLookup->second.containerSent(baseType); + dpLookup->second.containerSent(assetName, baseType); } else if (baseType.compare(dpLookup->second.getBaseTypeString()) != 0) { @@ -210,7 +213,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi else { sendContainer(link, dp, hints, baseType); - dpLookup->second.containerSent(baseType); + dpLookup->second.containerSent(assetName, baseType); } } if (baseType.empty()) @@ -219,7 +222,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi skippedDatapoints.push_back(dpName); continue; } - if (m_sendFullStructure && dpLookup->second.linkState() == false) + if (m_sendFullStructure && dpLookup->second.linkState(assetName) == false) { outData.append("{ \"typeid\":\"__Link\","); outData.append("\"values\":[ { \"source\" : {"); @@ -229,7 +232,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi outData.append("\"containerid\" : \""); outData.append(link); outData.append("\" } } ] },"); - dpLookup->second.linkSent(); + dpLookup->second.linkSent(assetName); } // Convert reading data into the OMF JSON string @@ -595,10 +598,37 @@ void LALookup::setBaseType(const string& baseType) /** * The container has been sent with the specific base type + * + * @param tagName The name of the tag we are using + * @param baseType The baseType we resolve to + */ +void LALookup::containerSent(const std::string& tagName, OMFBaseType baseType) +{ + if (m_tagName.compare(tagName)) + { + // Force a new Link and AF Link to be sent for the new tag name + m_sentState &= ~(LAL_LINK_SENT | LAL_AFLINK_SENT); + } + m_baseType = baseType; + m_tagName = tagName; + m_sentState |= LAL_CONTAINER_SENT; +} + +/** + * The container has been sent with the specific base type + * + * @param tagName The name of the tag we are using + * @param baseType The baseType we resolve to */ -void LALookup::containerSent(const std::string& baseType) +void LALookup::containerSent(const std::string& tagName, const std::string& baseType) { setBaseType(baseType); + if (m_tagName.compare(tagName)) + { + // Force a new Link and AF Link to be sent for the new tag name + m_sentState &= ~(LAL_LINK_SENT | LAL_AFLINK_SENT); + } + m_tagName = tagName; m_sentState |= LAL_CONTAINER_SENT; } diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index 53c1e7c4a9..f7ddc040be 100755 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -2302,6 +2302,7 @@ std::string OMF::createLinkData(const Reading& reading, std::string& AFHierarch long typeId = getAssetTypeId(assetName); + string lData = "{\"typeid\": \"__Link\", \"values\": ["; // Handles the structure for the Connector Relay @@ -2354,8 +2355,31 @@ std::string OMF::createLinkData(const Reading& reading, std::string& AFHierarch } else { + // Get the new asset name after hints are applied for the linked data messages + string newAssetName = assetName; + if (hints) + { + const std::vector omfHints = hints->getHints(); + for (auto it = omfHints.cbegin(); it != omfHints.cend(); it++) + { + if (typeid(**it) == typeid(OMFTagNameHint)) + { + string hintValue = (*it)->getHint(); + Logger::getLogger()->info("Using OMF TagName hint: %s for asset %s", + hintValue.c_str(), assetName.c_str()); + newAssetName = hintValue; + } + if (typeid(**it) == typeid(OMFTagHint)) + { + string hintValue = (*it)->getHint(); + Logger::getLogger()->info("Using OMF Tag hint: %s for asset %s", + hintValue.c_str(), assetName.c_str()); + newAssetName = hintValue; + } + } + } StringReplace(tmpStr, "_placeholder_tgt_type_", "FledgeAsset"); - StringReplace(tmpStr, "_placeholder_tgt_idx_", assetName); + StringReplace(tmpStr, "_placeholder_tgt_idx_", newAssetName); } lData.append(tmpStr); From 15da02e0c1769716c7eacaddee4324f5e547ccbe Mon Sep 17 00:00:00 2001 From: FlorentP42 <45787476+FlorentP42@users.noreply.github.com> Date: Tue, 9 Jan 2024 11:12:54 +0100 Subject: [PATCH 010/146] refs #1210 Forward result of NorthService::sendToDispatcher() to the output of NorthService::operation(). (#1211) Signed-off-by: Florent Peyrusse Co-authored-by: Florent Peyrusse Co-authored-by: Mark Riddoch --- C/services/north/north.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/C/services/north/north.cpp b/C/services/north/north.cpp index d2ed9a792d..d74e15dc4c 100755 --- a/C/services/north/north.cpp +++ b/C/services/north/north.cpp @@ -932,6 +932,7 @@ void NorthService::addConfigDefaults(DefaultConfigCategory& defaultConfig) * @param name Name of the variable to write * @param value Value to write to the variable * @param destination Where to write the value + * @return true if write was succesfully sent to dispatcher, else false */ bool NorthService::write(const string& name, const string& value, const ControlDestination destination) { @@ -961,6 +962,7 @@ bool NorthService::write(const string& name, const string& value, const ControlD * @param value Value to write to the variable * @param destination Where to write the value * @param arg Argument used to determine destination + * @return true if write was succesfully sent to dispatcher, else false */ bool NorthService::write(const string& name, const string& value, const ControlDestination destination, const string& arg) { @@ -1008,6 +1010,7 @@ bool NorthService::write(const string& name, const string& value, const ControlD * @param paramCount The number of parameters * @param parameters The parameters to the operation * @param destination Where to write the value + * @return -1 in case of error on operation destination, 1 if operation was succesfully sent to dispatcher, else 0 */ int NorthService::operation(const string& name, int paramCount, char *names[], char *parameters[], const ControlDestination destination) { @@ -1039,8 +1042,7 @@ int NorthService::operation(const string& name, int paramCount, char *names[], payload += ","; } payload += " } } }"; - sendToDispatcher("/dispatch/operation", payload); - return -1; + return static_cast(sendToDispatcher("/dispatch/operation", payload)); } /** @@ -1051,6 +1053,7 @@ int NorthService::operation(const string& name, int paramCount, char *names[], * @param parameters The parameters to the operation * @param destination Where to write the value * @param arg Argument used to determine destination + * @return 1 if operation was succesfully sent to dispatcher, else 0 */ int NorthService::operation(const string& name, int paramCount, char *names[], char *parameters[], const ControlDestination destination, const string& arg) { @@ -1099,8 +1102,7 @@ int NorthService::operation(const string& name, int paramCount, char *names[], c payload += ","; } payload += "} } }"; - sendToDispatcher("/dispatch/operation", payload); - return -1; + return static_cast(sendToDispatcher("/dispatch/operation", payload)); } /** From 28d48fcddab87cf049eb4dc69e4ff1a9748fef69 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 9 Jan 2024 17:59:59 +0530 Subject: [PATCH 011/146] execute API entrypoint fixes for type operation Signed-off-by: ashish-jabble --- python/fledge/services/core/api/control_service/entrypoint.py | 4 ++-- 1 file changed, 2 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 13a0a216ee..0304bfe1f7 100644 --- a/python/fledge/services/core/api/control_service/entrypoint.py +++ b/python/fledge/services/core/api/control_service/entrypoint.py @@ -443,9 +443,9 @@ async def update_request(request: web.Request) -> web.Response: 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': + if not params: + raise ValueError("Nothing to update as given entrypoint do not have the parameters.") url = "dispatch/write" dispatch_payload["write"] = params else: From fe1ef43b4e04d0ef6b0c731e2ba68619016db5f7 Mon Sep 17 00:00:00 2001 From: Mohit Singh Tomar Date: Tue, 9 Jan 2024 18:43:35 +0530 Subject: [PATCH 012/146] Reverted DOC Branch to develop Signed-off-by: Mohit Singh Tomar --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index de97a54cdc..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.3.0RC'"], shell=True, check=True) +subprocess.run(["make generated DOCBRANCH='develop'"], shell=True, check=True) From 156a4459670f88c5b84e7b7221cacbacc3433d3a Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Tue, 9 Jan 2024 16:40:52 -0500 Subject: [PATCH 013/146] Fix OMF version for PI Web API 2019 Updated the OMF Version Support section to correct the OMF Version used to talk to all releases of PI Web API 2019. Documentation said OMF 1.1. Correct OMF version is 1.0. Signed-off-by: Ray Verhoeff --- docs/OMF.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/OMF.rst b/docs/OMF.rst index 9ee26c68a9..4bf51718e6 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -684,10 +684,10 @@ These are the OMF versions the plugin will use to post data: | |- 2021 SP3| | | |- 2023 | | +-----------+----------+---------------------+ -| 1.1|- 2019 | | -| |- 2019 SP1| | +| 1.1| | | +-----------+----------+---------------------+ -| 1.0| |- 2020 | +| 1.0|- 2019 |- 2020 | +| |- 2019 SP1| | +-----------+----------+---------------------+ The AVEVA Data Hub (ADH) is cloud-deployed and is always at the latest version of OMF support which is 1.2. From 94cee7e8e2c26ee146950bdf1bad4668731fca7c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 15 Jan 2024 13:04:57 +0530 Subject: [PATCH 014/146] upgrade downgrade scripts added for SQLite Signed-off-by: ashish-jabble --- VERSION | 2 +- .../plugins/storage/sqlite/downgrade/66.sql | 23 +++++++++++++++++++ scripts/plugins/storage/sqlite/init.sql | 4 ++-- scripts/plugins/storage/sqlite/upgrade/67.sql | 9 ++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 scripts/plugins/storage/sqlite/downgrade/66.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/67.sql diff --git a/VERSION b/VERSION index ed5d342137..88c7d297a2 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.3.0 -fledge_schema=66 +fledge_schema=67 diff --git a/scripts/plugins/storage/sqlite/downgrade/66.sql b/scripts/plugins/storage/sqlite/downgrade/66.sql new file mode 100644 index 0000000000..cc24dce7e8 --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/66.sql @@ -0,0 +1,23 @@ +-- From: http://www.sqlite.org/faq.html: +-- SQLite has limited ALTER TABLE support that you can use to change type of column. +-- If you want to change the type of any column you will have to recreate the table. +-- You can save existing data to a temporary table and then drop the old table +-- Now, create the new table, then copy the data back in from the temporary table + + +-- Remove priority column in fledge.scheduled_processes + +-- Rename existing table into a temp one +ALTER TABLE fledge.scheduled_processes RENAME TO scheduled_processes_old; + +-- Create new table +CREATE TABLE fledge.scheduled_processes ( + name character varying(255) NOT NULL, -- Name of the process + script JSON, -- Full path of the process + CONSTRAINT scheduled_processes_pkey PRIMARY KEY ( name ) ); + +-- Copy data +INSERT INTO fledge.scheduled_processes ( name, script) SELECT name, script FROM fledge.scheduled_processes_old; + +-- Remote old table +DROP TABLE IF EXISTS fledge.scheduled_processes_old; diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 091823a63c..fc94cfe050 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -470,8 +470,8 @@ CREATE INDEX fki_user_asset_permissions_fk2 -- List of scheduled Processes CREATE TABLE fledge.scheduled_processes ( - name character varying(255) NOT NULL, -- Name of the process - script JSON, -- Full path of the process + name character varying(255) NOT NULL, -- Name of the process + script JSON, -- Full path of the process priority INTEGER NOT NULL DEFAULT 999, -- priority to run for STARTUP CONSTRAINT scheduled_processes_pkey PRIMARY KEY ( name ) ); diff --git a/scripts/plugins/storage/sqlite/upgrade/67.sql b/scripts/plugins/storage/sqlite/upgrade/67.sql new file mode 100644 index 0000000000..d6135a3b0b --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/67.sql @@ -0,0 +1,9 @@ +-- Add new column name 'priority' in scheduled_processes + +ALTER TABLE fledge.scheduled_processes ADD COLUMN priority INTEGER NOT NULL DEFAULT 999; +UPDATE scheduled_processes SET priority = '10' WHERE name = 'bucket_storage_c'; +UPDATE scheduled_processes SET priority = '20' WHERE name = 'dispatcher_c'; +UPDATE scheduled_processes SET priority = '30' WHERE name = 'notification_c'; +UPDATE scheduled_processes SET priority = '100' WHERE name = 'south_c'; +UPDATE scheduled_processes SET priority = '200' WHERE name in ('north_c', 'north_C'); +UPDATE scheduled_processes SET priority = '300' WHERE name = 'management'; From 38b77116209690356c9f95b0c8cc9940690dec36 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 15 Jan 2024 16:29:57 +0530 Subject: [PATCH 015/146] Plugin update API fixes when schedule info doesnot exist Signed-off-by: ashish-jabble --- python/fledge/services/core/api/plugins/update.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/plugins/update.py b/python/fledge/services/core/api/plugins/update.py index 490c322466..2c67b7f112 100644 --- a/python/fledge/services/core/api/plugins/update.py +++ b/python/fledge/services/core/api/plugins/update.py @@ -129,7 +129,7 @@ async def update_package(request: web.Request) -> web.Response: 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': + if sch_info and 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( @@ -271,7 +271,7 @@ async def update_plugin(request: web.Request) -> web.Response: if (name == p['plugin'] and not _type == 'filter') or ( p['plugin'] in filters_used_by and _type == 'filter'): sch_info = await _get_sch_id_and_enabled_by_name(p['service']) - if sch_info[0]['enabled'] == 't': + if sch_info and 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( From b19b6f5a80e8c41d26e385103cf35bb2176f2f69 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 15 Jan 2024 18:35:33 +0530 Subject: [PATCH 016/146] rebased and south/north services separation for delaying to start Signed-off-by: ashish-jabble --- python/fledge/services/core/scheduler/scheduler.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index ab9a9dff09..d2a9295129 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -300,10 +300,12 @@ def _get_delay_in_sec(pname): val = 3 elif pname == 'notification_c': val = 5 - elif pname in ('south_c', 'north_C'): + elif pname == 'south_c': val = 7 + elif pname == 'north_C': + val = 9 else: - val = 10 + val = 12 return val # This check is necessary only if significant time can elapse between "await" and From cf5ed85bc76feffe25fbca06726a2cc9a3ca1713 Mon Sep 17 00:00:00 2001 From: Amandeep Singh Arora Date: Mon, 15 Jan 2024 20:10:24 +0530 Subject: [PATCH 017/146] FOGL-8414: Pass management client URL/port in filter config Signed-off-by: Amandeep Singh Arora --- C/common/filter_pipeline.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/C/common/filter_pipeline.cpp b/C/common/filter_pipeline.cpp index d6229cc654..05d1187041 100755 --- a/C/common/filter_pipeline.cpp +++ b/C/common/filter_pipeline.cpp @@ -261,6 +261,11 @@ bool FilterPipeline::setupFiltersPipeline(void *passToOnwardFilter, void *useFil // Fetch up to date filter configuration updatedCfg = mgtClient->getCategory(filterCategoryName); + // Pass Management client IP:Port to filter so that it may connect to bucket service + updatedCfg.addItem("mgmt_client_url_base", "Management client host and port", + "string", "127.0.0.1:0", + mgtClient->getUrlbase()); + // Add filter category name under service/process config name children.push_back(filterCategoryName); mgtClient->addChildCategories(serviceName, children); From 4f8193a7687775fc2dbe47c4668cc7fe835036ff Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 16 Jan 2024 13:11:15 +0530 Subject: [PATCH 018/146] default priority order fixes for north_c in SQLite engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlite/init.sql | 6 +++--- scripts/plugins/storage/sqlite/upgrade/67.sql | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index fc94cfe050..32df3c0fe0 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -820,10 +820,10 @@ INSERT INTO fledge.scheduled_processes (name, script) VALUES ('restore', '["task -- South, Notification, North Tasks -- -INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'south_c', '["services/south_c"]', 100 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'south_c', '["services/south_c"]', 100 ); INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'notification_c', '["services/notification_c"]', 30 ); -INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'north_c', '["tasks/north_c"]', 200 ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north', '["tasks/north"]' ); +INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north_c', '["tasks/north_c"]' ); +INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north', '["tasks/north"]' ); INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'north_C', '["services/north_C"]', 200 ); INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'dispatcher_c', '["services/dispatcher_c"]', 20 ); INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'bucket_storage_c', '["services/bucket_storage_c"]', 10 ); diff --git a/scripts/plugins/storage/sqlite/upgrade/67.sql b/scripts/plugins/storage/sqlite/upgrade/67.sql index d6135a3b0b..ff3ac1949b 100644 --- a/scripts/plugins/storage/sqlite/upgrade/67.sql +++ b/scripts/plugins/storage/sqlite/upgrade/67.sql @@ -5,5 +5,5 @@ UPDATE scheduled_processes SET priority = '10' WHERE name = 'bucket_storage_c'; UPDATE scheduled_processes SET priority = '20' WHERE name = 'dispatcher_c'; UPDATE scheduled_processes SET priority = '30' WHERE name = 'notification_c'; UPDATE scheduled_processes SET priority = '100' WHERE name = 'south_c'; -UPDATE scheduled_processes SET priority = '200' WHERE name in ('north_c', 'north_C'); +UPDATE scheduled_processes SET priority = '200' WHERE name = 'north_C'; UPDATE scheduled_processes SET priority = '300' WHERE name = 'management'; From bb02455876834ab9a75b91a6b78854a7952a9f16 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 16 Jan 2024 13:07:08 +0530 Subject: [PATCH 019/146] upgrade and downgrade scripts added for SQLitelb engine Signed-off-by: ashish-jabble --- .../plugins/storage/sqlitelb/downgrade/66.sql | 23 +++++++++++++++++++ scripts/plugins/storage/sqlitelb/init.sql | 21 +++++++++-------- .../plugins/storage/sqlitelb/upgrade/67.sql | 9 ++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/66.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/67.sql diff --git a/scripts/plugins/storage/sqlitelb/downgrade/66.sql b/scripts/plugins/storage/sqlitelb/downgrade/66.sql new file mode 100644 index 0000000000..cc24dce7e8 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/66.sql @@ -0,0 +1,23 @@ +-- From: http://www.sqlite.org/faq.html: +-- SQLite has limited ALTER TABLE support that you can use to change type of column. +-- If you want to change the type of any column you will have to recreate the table. +-- You can save existing data to a temporary table and then drop the old table +-- Now, create the new table, then copy the data back in from the temporary table + + +-- Remove priority column in fledge.scheduled_processes + +-- Rename existing table into a temp one +ALTER TABLE fledge.scheduled_processes RENAME TO scheduled_processes_old; + +-- Create new table +CREATE TABLE fledge.scheduled_processes ( + name character varying(255) NOT NULL, -- Name of the process + script JSON, -- Full path of the process + CONSTRAINT scheduled_processes_pkey PRIMARY KEY ( name ) ); + +-- Copy data +INSERT INTO fledge.scheduled_processes ( name, script) SELECT name, script FROM fledge.scheduled_processes_old; + +-- Remote old table +DROP TABLE IF EXISTS fledge.scheduled_processes_old; diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index b7c2adce2c..56d96d9628 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -470,9 +470,10 @@ CREATE INDEX fki_user_asset_permissions_fk2 -- List of scheduled Processes CREATE TABLE fledge.scheduled_processes ( - name character varying(255) NOT NULL, -- Name of the process - script JSON, -- Full path of the process - CONSTRAINT scheduled_processes_pkey PRIMARY KEY ( name ) ); + name character varying(255) NOT NULL, -- Name of the process + script JSON, -- Full path of the process + priority INTEGER NOT NULL DEFAULT 999, -- priority to run for STARTUP + CONSTRAINT scheduled_processes_pkey PRIMARY KEY ( name ) ); -- List of schedules CREATE TABLE fledge.schedules ( @@ -819,13 +820,13 @@ INSERT INTO fledge.scheduled_processes (name, script) VALUES ('restore', '["task -- South, Notification, North Tasks -- -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'south_c', '["services/south_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'notification_c', '["services/notification_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north_c', '["tasks/north_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north', '["tasks/north"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north_C', '["services/north_C"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'dispatcher_c', '["services/dispatcher_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'bucket_storage_c', '["services/bucket_storage_c"]' ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'south_c', '["services/south_c"]', 100 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'notification_c', '["services/notification_c"]', 30 ); +INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north_c', '["tasks/north_c"]' ); +INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north', '["tasks/north"]' ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'north_C', '["services/north_C"]', 200 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'dispatcher_c', '["services/dispatcher_c"]', 20 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'bucket_storage_c', '["services/bucket_storage_c"]', 10 ); -- Automation script tasks -- diff --git a/scripts/plugins/storage/sqlitelb/upgrade/67.sql b/scripts/plugins/storage/sqlitelb/upgrade/67.sql new file mode 100644 index 0000000000..ff3ac1949b --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/67.sql @@ -0,0 +1,9 @@ +-- Add new column name 'priority' in scheduled_processes + +ALTER TABLE fledge.scheduled_processes ADD COLUMN priority INTEGER NOT NULL DEFAULT 999; +UPDATE scheduled_processes SET priority = '10' WHERE name = 'bucket_storage_c'; +UPDATE scheduled_processes SET priority = '20' WHERE name = 'dispatcher_c'; +UPDATE scheduled_processes SET priority = '30' WHERE name = 'notification_c'; +UPDATE scheduled_processes SET priority = '100' WHERE name = 'south_c'; +UPDATE scheduled_processes SET priority = '200' WHERE name = 'north_C'; +UPDATE scheduled_processes SET priority = '300' WHERE name = 'management'; From d914e41a65e9f964c4ff654a70eae86f2281f18c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 16 Jan 2024 15:16:38 +0530 Subject: [PATCH 020/146] upgrade and downgrade scripts added for PostgreSQL engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/66.sql | 2 ++ scripts/plugins/storage/postgres/init.sql | 15 ++++++++------- scripts/plugins/storage/postgres/upgrade/67.sql | 9 +++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 scripts/plugins/storage/postgres/downgrade/66.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/67.sql diff --git a/scripts/plugins/storage/postgres/downgrade/66.sql b/scripts/plugins/storage/postgres/downgrade/66.sql new file mode 100644 index 0000000000..ed10f65b6f --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/66.sql @@ -0,0 +1,2 @@ +--Remove priority column from scheduled_processes table +ALTER TABLE fledge.scheduled_processes DROP COLUMN IF EXISTS priority; diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index c8e1a32c68..8d696bf3b8 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -699,6 +699,7 @@ CREATE INDEX fki_user_asset_permissions_fk2 CREATE TABLE fledge.scheduled_processes ( name character varying(255) NOT NULL, -- Name of the process script jsonb, -- Full path of the process + priority INTEGER NOT NULL DEFAULT 999, -- priority to run for STARTUP CONSTRAINT scheduled_processes_pkey PRIMARY KEY ( name ) ); @@ -1063,13 +1064,13 @@ INSERT INTO fledge.scheduled_processes (name, script) VALUES ('restore', '["task -- South, Notification, North Tasks -- -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'south_c', '["services/south_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'notification_c', '["services/notification_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north_c', '["tasks/north_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north', '["tasks/north"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north_C', '["services/north_C"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'dispatcher_c', '["services/dispatcher_c"]' ); -INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'bucket_storage_c', '["services/bucket_storage_c"]' ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'south_c', '["services/south_c"]', 100 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'notification_c', '["services/notification_c"]', 30 ); +INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north_c', '["tasks/north_c"]' ); +INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'north', '["tasks/north"]' ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'north_C', '["services/north_C"]', 200 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'dispatcher_c', '["services/dispatcher_c"]', 20 ); +INSERT INTO fledge.scheduled_processes (name, script, priority) VALUES ( 'bucket_storage_c', '["services/bucket_storage_c"]', 10 ); -- Automation script tasks -- diff --git a/scripts/plugins/storage/postgres/upgrade/67.sql b/scripts/plugins/storage/postgres/upgrade/67.sql new file mode 100644 index 0000000000..0ac6bffda5 --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/67.sql @@ -0,0 +1,9 @@ +-- Add new column name 'priority' in scheduled_processes + +ALTER TABLE fledge.scheduled_processes ADD COLUMN priority INTEGER NOT NULL DEFAULT 999; +UPDATE fledge.scheduled_processes SET priority = '10' WHERE name = 'bucket_storage_c'; +UPDATE fledge.scheduled_processes SET priority = '20' WHERE name = 'dispatcher_c'; +UPDATE fledge.scheduled_processes SET priority = '30' WHERE name = 'notification_c'; +UPDATE fledge.scheduled_processes SET priority = '100' WHERE name = 'south_c'; +UPDATE fledge.scheduled_processes SET priority = '200' WHERE name = 'north_C'; +UPDATE fledge.scheduled_processes SET priority = '300' WHERE name = 'management'; From b2275d4b974be4796a3a17d3ce3a398fea0c62f4 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 17 Jan 2024 10:04:30 +0000 Subject: [PATCH 021/146] FOGL-8415 Update plugin developers guide (#1268) Signed-off-by: Mark Riddoch --- docs/plugin_developers_guide/02_writing_plugins.rst | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/plugin_developers_guide/02_writing_plugins.rst b/docs/plugin_developers_guide/02_writing_plugins.rst index 2a309f8c1f..f406746d0c 100644 --- a/docs/plugin_developers_guide/02_writing_plugins.rst +++ b/docs/plugin_developers_guide/02_writing_plugins.rst @@ -28,6 +28,17 @@ Writing and Using Plugins A plugin has a small set of external entry points that must exist in order for Fledge to load and execute that plugin. Currently plugins may be written in either Python or C/C++, the set of entry points is the same for both languages. The entry points detailed here will be presented for both languages, a more in depth discussion of writing plugins in C/C++ will then follow. +General Guidance +---------------- + +Before delving into the detail of how to write plugins, what entry points have to be provided and how to build and test them, a few notes of general guidance that all plugin developers should consider that will prevent the plugin writer difficulty. + + - The ethos of Fledge is to provide data pipelines that promote easy building of applications through re-use of small, focused processing components. Always try to make use of existing plugins when at all possible. When writing new plugins do not be tempted to make them too specific to a single application. This will mean it is more likely that at some point in the future you will have all the components in your toolbox that you need to create the next application without having to write new plugins. + + - Filters within Fledge are run within a single process which may be a south or north service, they do not run as separate executable. Therefore make sure that when you write a new plugin service that you do not make use of global variables. Global variables will be shared between all the plugins in a service and may clash with other plugins and will prevent the same plugin being used multiple times within a pipeline. + + - Do not make assumptions about how the data you are processing in your plugin will be used, or by how many upstream components it will be used. For example do not put anything in a south plugin or a filter plugin that assumes the data will be consumed by a particular north plugin or will only be consumed by one north plugin. An example of this might be a south plugin that adds OMF AF Location hints to the data it produces. Whilst this works well if the data is sent to OMF, it does not help if the data is sent to a different destination that also requires location information. Adding options for different destinations only compounds the problem, consider for example that the data might be sent to multiple destinations. A better approach would be to add generic location meta data to the data and have the hints filters for each of the destinations perform the destination specific work. + Common Fledge Plugin API ------------------------- @@ -200,7 +211,7 @@ Plugin Initialization The plugin initialization is called after the service that has loaded the plugin has collected the plugin information and resolved the configuration of the plugin but before any other calls will be made to the plugin. The initialization routine is called with the resolved configuration of the plugin, this includes values as opposed to the defaults that were returned in the *plugin_info* call. -This call is used by the plugin to do any initialization or state creation it needs to do. The call returns a handle which will be passed into each subsequent call of the plugin. The handle allows the plugin to have state information that is maintained and passed to it whilst allowing for multiple instances of the same plugin to be loaded by a service if desired. It is equivalent to a this or self pointer for the plugin, although the plugin is not defined as a class. +This call is used by the plugin to do any initialization or state creation it needs to do. The call returns a handle which will be passed into each subsequent call of the plugin. The handle allows the plugin to create state information that is maintained and passed to it whilst allowing for multiple instances of the same plugin to be loaded by a service if desired. It is equivalent to a this or self pointer for the plugin, although the plugin is not defined as a class. The handle is the only way in which the plugin should retain information between calls to a given entry point and also the only way information should be passed between entry points. In Python a simple example of a sensor that reads a GPIO pin for data, we might choose to use that configured GPIO pin as the handle we pass to other calls. From 7b52ff78c4463e216de18375df4b0f6d86fd093e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 18 Jan 2024 13:52:00 +0530 Subject: [PATCH 022/146] management case handled in POST service schedule process entry with priority on fly Signed-off-by: ashish-jabble --- python/fledge/services/core/api/service.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index 5ec093b8bb..02f77fccfd 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -413,7 +413,10 @@ async def add_service(request): count = await check_scheduled_processes(storage, process_name, script) if count == 0: # Now first create the scheduled process entry for the new service - payload = PayloadBuilder().INSERT(name=process_name, script=script).payload() + column_name = {"name": process_name, "script": script} + if service_type == 'management': + column_name["priority"] = 300 + payload = PayloadBuilder().INSERT(**column_name).payload() try: res = await storage.insert_into_tbl("scheduled_processes", payload) except StorageServerError as ex: From ac6a5e03c00d457085793670c508570572eaf5d8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 18 Jan 2024 14:57:04 +0530 Subject: [PATCH 023/146] python unit tests updated Signed-off-by: ashish-jabble --- tests/unit/python/fledge/services/core/api/test_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 767954568b..59868ae2e6 100644 --- a/tests/unit/python/fledge/services/core/api/test_service.py +++ b/tests/unit/python/fledge/services/core/api/test_service.py @@ -1161,7 +1161,7 @@ async def q_result(*arg): args, kwargs = insert_table_patch.call_args assert 'scheduled_processes' == args[0] p = json.loads(args[1]) - assert {'name': 'management', 'script': '["services/management"]'} == p + assert {'name': 'management', 'priority': 300, 'script': '["services/management"]'} == p patch_get_cat_info.assert_called_once_with(category_name=data['name']) async def test_dupe_management_service_schedule(self, client): @@ -1221,7 +1221,7 @@ def q_result(*arg): args, kwargs = insert_table_patch.call_args assert 'scheduled_processes' == args[0] p = json.loads(args[1]) - assert {'name': 'management', 'script': '["services/management"]'} == p + assert {'name': 'management', 'priority': 300, 'script': '["services/management"]'} == p patch_get_cat_info.assert_called_once_with(category_name=data['name']) @pytest.mark.parametrize("param", [ From f3609ccf4944311eeec2a76239c243c1ea2876e2 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 18 Jan 2024 16:14:17 +0530 Subject: [PATCH 024/146] dispatcher process script priority case handling when installation found and schedule is disable Signed-off-by: ashish-jabble --- python/fledge/services/core/server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index ee4bd61c40..df8e5c88e9 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -1907,6 +1907,9 @@ async def is_dispatcher_running(cls, storage): if sch['process_name'] == 'dispatcher_c' and sch['enabled'] == 'f': _logger.info("Dispatcher service found but not in enabled state. " "Therefore, {} schedule name is enabled".format(sch['schedule_name'])) + # reset process_script priority for the service + update_tuple = (cls.scheduler._process_scripts['dispatcher_c'][0], 999) + cls.scheduler._process_scripts['dispatcher_c'] = update_tuple await cls.scheduler.enable_schedule(uuid.UUID(sch["id"])) return True elif sch['process_name'] == 'dispatcher_c' and sch['enabled'] == 't': From 0a676c6da4644a92638e4e3c4070cd9b9af780e1 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Thu, 18 Jan 2024 16:27:52 -0500 Subject: [PATCH 025/146] Refactor response to loss of connection to PI Web API Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/include/omf.h | 10 ++-- C/plugins/north/OMF/omf.cpp | 86 +++++++++++++------------------ C/plugins/north/OMF/omfinfo.cpp | 56 +++++++++----------- 3 files changed, 62 insertions(+), 90 deletions(-) diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index 68a382bf37..1e2e443ef4 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -146,7 +146,7 @@ class OMF // Method with vector (by reference) of readings uint32_t sendToServer(const std::vector& readings, - bool skipSentDataTypes = true); + bool skipSentDataTypes = true); // never called // Method with vector (by reference) of reading pointers uint32_t sendToServer(const std::vector& readings, @@ -154,11 +154,11 @@ class OMF // Send a single reading (by reference) uint32_t sendToServer(const Reading& reading, - bool skipSentDataTypes = true); + bool skipSentDataTypes = true); // never called // Send a single reading pointer uint32_t sendToServer(const Reading* reading, - bool skipSentDataTypes = true); + bool skipSentDataTypes = true); // never called // Set saved OMF formats void setFormatType(const std::string &key, std::string &value); @@ -231,9 +231,6 @@ class OMF bool getAFMapEmptyNames() const { return m_AFMapEmptyNames; }; bool getAFMapEmptyMetadata() const { return m_AFMapEmptyMetadata; }; - bool getConnected() const { return m_connected; }; - void setConnected(const bool connectionStatus); - void setLegacyMode(bool legacy) { m_legacy = legacy; }; static std::string ApplyPIServerNamingRulesObj(const std::string &objName, bool *changed); @@ -395,7 +392,6 @@ class OMF bool m_AFMapEmptyMetadata; std::string m_AFHierarchyLevel; std::string m_prefixAFAsset; - bool m_connected; // true if calls to PI Web API are working vector m_afhHierarchyAlreadyCreated={ diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index f7ddc040be..e6ec937a1e 100755 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -244,7 +244,6 @@ OMF::OMF(const string& name, m_changeTypeId = false; m_OMFDataTypes = NULL; m_OMFVersion = "1.0"; - m_connected = false; } /** @@ -272,7 +271,6 @@ OMF::OMF(const string& name, m_lastError = false; m_changeTypeId = false; - m_connected = false; } // Destructor @@ -421,7 +419,6 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) string msg = "An error occurred sending the dataType message for the asset " + assetName + ". " + errorMsg; reportAsset(assetName, "error", msg); - m_connected = false; return false; } @@ -488,10 +485,9 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) { string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occurred sending the dataType message for the asset " + assetName + string msg = "An error occurred sending the dataType container message for the asset " + assetName + ". " + errorMsg; reportAsset(assetName, "error", msg); - m_connected = false; return false; } @@ -519,7 +515,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) { OMFError error(m_sender.getHTTPResponse()); @@ -558,7 +554,6 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) string msg = "An error occurred sending the dataType staticData message for the asset " + assetName + ". " + errorMsg; reportAsset(assetName, "debug", msg); - m_connected = false; return false; } @@ -650,7 +645,7 @@ bool OMF::sendDataTypes(const Reading& row, OMFHints *hints) { string errorMsg = errorMessageHandler(e.what()); - string msg = "An error occurred sending the dataType staticData message for the asset " + assetName + string msg = "An error occurred sending the dataType link message for the asset " + assetName + ". " + errorMsg; reportAsset(assetName, "debug", msg); return false; @@ -711,7 +706,6 @@ bool OMF::AFHierarchySendMessage(const string& msgType, string& jsonData, const { success = false; errorMessage = ex.what(); - m_connected = false; } if (! success) @@ -957,7 +951,7 @@ bool OMF::sendAFHierarchy(string AFHierarchy) */ bool OMF::sendAFHierarchyLevels(string parentPath, string path, std::string &lastLevel) { - bool success; + bool success = true; std::string level; std::string previousLevel; @@ -1001,10 +995,14 @@ bool OMF::sendAFHierarchyLevels(string parentPath, string path, std::string &las levelPath = StringSlashFix(levelPath); prefixId = generateUniquePrefixId(levelPath); - success = sendAFHierarchyTypes(level, prefixId); - if (success) + if (!sendAFHierarchyTypes(level, prefixId)) + { + return false; + } + + if (!sendAFHierarchyStatic(level, prefixId)) { - success = sendAFHierarchyStatic(level, prefixId); + return false; } // Creates the link between the AF level @@ -1013,7 +1011,10 @@ bool OMF::sendAFHierarchyLevels(string parentPath, string path, std::string &las parentPathFixed = StringSlashFix(previousLevelPath); prefixIdParent = generateUniquePrefixId(parentPathFixed); - sendAFHierarchyLink(previousLevel, level, prefixIdParent, prefixId); + if (!sendAFHierarchyLink(previousLevel, level, prefixIdParent, prefixId)) + { + return false; + } } previousLevelPath = levelPath; previousLevel = level; @@ -1243,15 +1244,7 @@ uint32_t OMF::sendToServer(const vector& readings, // hint is present it will override any default AFLocation or AF Location rules defined in the north plugin configuration. if ( ! createAFHierarchyOmfHint(m_assetName, OMFHintAFHierarchy) ) { - if (m_connected) - { - if (!evaluateAFHierarchyRules(m_assetName, *reading)) - { - m_lastError = true; - return 0; - } - } - else + if (!evaluateAFHierarchyRules(m_assetName, *reading)) { m_lastError = true; return 0; @@ -1643,7 +1636,6 @@ uint32_t OMF::sendToServer(const vector& readings, // Failure m_lastError = true; - m_connected = false; return 0; } } @@ -1761,7 +1753,6 @@ uint32_t OMF::sendToServer(const vector& readings, m_path.c_str(), jsonData.str().c_str() ); - m_connected = false; return false; } @@ -1853,7 +1844,6 @@ uint32_t OMF::sendToServer(const Reading* reading, m_sender.getHostPort().c_str(), m_path.c_str() ); - m_connected = false; return false; } @@ -2806,6 +2796,10 @@ bool OMF::evaluateAFHierarchyRules(const string& assetName, const Reading& readi ,path.c_str() ,it->second.c_str()); } + else + { + return false; + } } else { Logger::getLogger()->debug( "%s - m_NamesRules skipped pathInitial :%s: path :%s: stored :%s:" @@ -2858,6 +2852,10 @@ bool OMF::evaluateAFHierarchyRules(const string& assetName, const Reading& readi m_AssetNamePrefix[assetName].push_back(item); Logger::getLogger()->debug("%s - m_MetadataRulesExist asset :%s: path added :%s: :%s:" , __FUNCTION__, assetName.c_str(), pathInitial.c_str() , path.c_str() ); } + else + { + return false; + } } else { Logger::getLogger()->debug("%s - m_MetadataRulesExist already created asset :%s: path added :%s: :%s:" , __FUNCTION__, assetName.c_str(), pathInitial.c_str() , path.c_str() ); } @@ -2911,6 +2909,10 @@ bool OMF::evaluateAFHierarchyRules(const string& assetName, const Reading& readi m_AssetNamePrefix[assetName].push_back(item); Logger::getLogger()->debug("%s - m_MetadataRulesNonExist - asset :%s: path added :%s: :%s:" , __FUNCTION__, assetName.c_str(), pathInitial.c_str() , path.c_str() ); } + else + { + return false; + } } else { Logger::getLogger()->debug("%s - m_MetadataRulesNonExist - already created asset :%s: path added :%s: :%s:" , __FUNCTION__, assetName.c_str(), pathInitial.c_str() , path.c_str() ); } @@ -2976,6 +2978,10 @@ bool OMF::evaluateAFHierarchyRules(const string& assetName, const Reading& readi m_AssetNamePrefix[assetName].push_back(item); Logger::getLogger()->debug("%s - m_MetadataRulesEqual asset :%s: path added :%s: :%s:" , __FUNCTION__, assetName.c_str(), pathInitial.c_str() , path.c_str() ); } + else + { + return false; + } } else { Logger::getLogger()->debug("%s - m_MetadataRulesEqual already created asset :%s: path added :%s: :%s:" , __FUNCTION__, assetName.c_str(), pathInitial.c_str() , path.c_str() ); } @@ -3044,6 +3050,10 @@ bool OMF::evaluateAFHierarchyRules(const string& assetName, const Reading& readi m_AssetNamePrefix[assetName].push_back(item); Logger::getLogger()->debug("%s - m_MetadataRulesNotEqual asset :%s: path added :%s: :%s:" , __FUNCTION__, assetName.c_str(), pathInitial.c_str() , path.c_str() ); } + else + { + return false; + } } else { Logger::getLogger()->debug("%s - m_MetadataRulesNotEqual already created asset :%s: path added :%s: :%s:" , __FUNCTION__, assetName.c_str(), pathInitial.c_str() , path.c_str() ); } @@ -4813,7 +4823,6 @@ bool OMF::sendBaseTypes() errorMsg.c_str(), m_sender.getHostPort().c_str(), m_path.c_str()); - m_connected = false; return false; } Logger::getLogger()->debug("Base types successfully sent"); @@ -4902,26 +4911,3 @@ void OMF::reportAsset(const string& asset, const string& level, const string& ms Logger::getLogger()->debug(msg); } } - -/** - * Set the connection state - * - * @param connectionStatus The target connection status - */ -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/omfinfo.cpp b/C/plugins/north/OMF/omfinfo.cpp index ed3c5b7b98..3297f04f73 100644 --- a/C/plugins/north/OMF/omfinfo.cpp +++ b/C/plugins/north/OMF/omfinfo.cpp @@ -461,7 +461,6 @@ uint32_t OMFInformation::send(const vector& readings) { // Created a new sender after a connection failure m_omf->setSender(*m_sender); - m_omf->setConnected(false); } } @@ -479,7 +478,6 @@ uint32_t OMFInformation::send(const vector& readings) m_omf = new OMF(m_name, *m_sender, m_path, m_assetsDataTypes, m_producerToken); - m_omf->setConnected(m_connected); m_omf->setSendFullStructure(m_sendFullStructure); // Set PIServerEndpoint configuration @@ -529,15 +527,6 @@ uint32_t OMFInformation::send(const vector& readings) 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 && m_connected && !updatedConnected) - { - Logger::getLogger()->warn("Connection to PI Web API at %s has been lost", m_hostAndPort.c_str()); - } - m_connected = updatedConnected; - #if INSTRUMENT Logger::getLogger()->debug("plugin_send elapsed time: %6.3f seconds, NumValues: %u", GetElapsedTime(&startTime), ret); @@ -1339,51 +1328,52 @@ double OMFInformation::GetElapsedTime(struct timeval *startTime) } /** - * Check if the PI Web API server is available by reading the product version + * Check if the PI Web API server is available by reading the product version every 60 seconds. + * Log a message if the connection state changes. * * @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 + static std::chrono::steady_clock::time_point nextCheck(std::chrono::steady_clock::time_point::duration::zero()); + static bool lastConnected = m_connected; // Previous value of m_connected - if (!m_connected && m_PIServerEndpoint == ENDPOINT_PIWEB_API) + if (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) + Logger::getLogger()->debug("PIWebAPIGetVersion: %s HTTP Code: %d Connected: %s LastConnected: %s", + m_hostAndPort.c_str(), + httpCode, + m_connected ? "true" : "false", + lastConnected ? "true" : "false"); + + if ((httpCode < 200) || (httpCode >= 400)) { 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); - 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()); + if (lastConnected == true) + { + Logger::getLogger()->error("The PI Web API service %s is not available. HTTP Code: %d", + m_hostAndPort.c_str(), httpCode); + lastConnected = false; } } else { 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()); - if (reported == true || reportedState == false) + if (lastConnected == false) { - reportedState = true; - reported = true; - Logger::getLogger()->warn("The PI Web API service %s has become available", - m_hostAndPort.c_str()); + Logger::getLogger()->warn("%s reconnected to %s OMF Version: %s", + m_RestServerVersion.c_str(), m_hostAndPort.c_str(), m_omfversion.c_str()); + lastConnected = true; } } + + nextCheck = now + std::chrono::seconds(60); } } else From dfafc8ba9082faef2a5fbdb26eb0b5599b36468e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 19 Jan 2024 13:08:57 +0530 Subject: [PATCH 026/146] array within array pipeline support added in PUT pipeline API Signed-off-by: ashish-jabble --- python/fledge/services/core/api/filters.py | 118 ++++++++++++++------- 1 file changed, 82 insertions(+), 36 deletions(-) diff --git a/python/fledge/services/core/api/filters.py b/python/fledge/services/core/api/filters.py index 44ef07c221..ded547fa54 100644 --- a/python/fledge/services/core/api/filters.py +++ b/python/fledge/services/core/api/filters.py @@ -40,7 +40,6 @@ async def create_filter(request: web.Request) -> web.Response: """ Create a new filter with a specific plugin - :Example: curl -X POST http://localhost:8081/fledge/filter -d '{"name": "North_Readings_to_PI_scale_stage_1Filter", "plugin": "scale"}' curl -X POST http://localhost:8081/fledge/filter -d '{"name": "North_Readings_to_PI_scale_stage_1Filter", "plugin": "scale", "filter_config": {"offset":"1","enable":"true"}}' @@ -78,7 +77,6 @@ async def create_filter(request: web.Request) -> web.Response: raise ValueError("This '{}' filter already exists".format(filter_name)) # Load C/Python filter plugin info - #loaded_plugin_info = apiutils.get_plugin_info(plugin_name, dir='filter') try: # Try fetching Python filter plugin_module_path = "{}/python/fledge/plugins/filter/{}".format(_FLEDGE_ROOT, plugin_name) @@ -154,21 +152,18 @@ async def add_filters_pipeline(request: web.Request) -> web.Response: Add filter names to "filter" item in {user_name} PUT /fledge/filter/{user_name}/pipeline - + 'pipeline' is the array of filter category names to set into 'filter' default/value properties :Example: set 'pipeline' for user 'NorthReadings_to_PI' - curl -X PUT http://localhost:8081/fledge/filter/NorthReadings_to_PI/pipeline -d - '{ - "pipeline": ["Scale10Filter", "Python_assetCodeFilter"], - }' + curl -X PUT http://localhost:8081/fledge/filter/NorthReadings_to_PI/pipeline -d '["Scale10Filter", "Python_assetCodeFilter"]' Configuration item 'filter' is added to {user_name} or updated with the pipeline list Returns the filter pipeline on success: - {"pipeline": ["Scale10Filter", "Python_assetCodeFilter"]} + {"pipeline": ["Scale10Filter", "Python_assetCodeFilter"]} Query string parameters: - append_filter=true|false Default false @@ -189,7 +184,7 @@ async def add_filters_pipeline(request: web.Request) -> web.Response: }' Delete pipeline: - curl -X PUT -d '{"pipeline": []}' http://localhost:8081/fledge/filter/NorthReadings_to_PI/pipeline + curl -X PUT -d '{"pipeline": []}' http://localhost:8081/fledge/filter/NorthReadings_to_PI/pipeline NOTE: the method also adds the filters category names under parent category {user_name} @@ -227,13 +222,19 @@ async def add_filters_pipeline(request: web.Request) -> web.Response: if category_info is None: raise ValueError("No such '{}' category found.".format(user_name)) + async def _get_filter(f_name): + payload = PayloadBuilder().WHERE(['name', '=', f_name]).payload() + f_result = await storage.query_tbl_with_payload("filters", payload) + if len(f_result["rows"]) == 0: + raise ValueError("No such '{}' filter found in filters table.".format(f_name)) + # Check and validate if all filters in the list exists in filters table for _filter in filter_list: - payload = PayloadBuilder().WHERE(['name', '=', _filter]).payload() - result = await storage.query_tbl_with_payload("filters", payload) - if len(result["rows"]) == 0: - raise ValueError("No such '{}' filter found in filters table.".format(_filter)) - + if isinstance(_filter, list): + for f in _filter: + await _get_filter(f) + else: + await _get_filter(_filter) config_item = "filter" if config_item in category_info: # Check if config_item key has already been added to the category config @@ -254,7 +255,8 @@ async def add_filters_pipeline(request: web.Request) -> web.Response: # Config update for filter pipeline and a change callback after category children creation await cf_mgr.set_category_item_value_entry(user_name, config_item, {'pipeline': new_list}) else: # No existing filters, hence create new item 'config_item' and add the "pipeline" array as a string - new_item = dict({config_item: {'description': 'Filter pipeline', 'type': 'JSON', 'default': {}, 'readonly':'true'}}) + new_item = dict( + {config_item: {'description': 'Filter pipeline', 'type': 'JSON', 'default': {}, 'readonly': 'true'}}) new_item[config_item]['default'] = json.dumps({'pipeline': filter_list}) await _add_child_filters(storage, cf_mgr, user_name, filter_list) await cf_mgr.create_category(category_name=user_name, category_value=new_item, keep_original_items=True) @@ -267,15 +269,26 @@ async def add_filters_pipeline(request: web.Request) -> web.Response: else: # Create Parent-child relation for standalone filter category with service/username # And that way we have the ability to remove the category when we delete the service - await cf_mgr.create_child_category(user_name, filter_list) + f_c = [] + f_c2 = [] + for _filter in filter_list: + if isinstance(_filter, list): + for f in _filter: + f_c.append(f) + else: + f_c2.append(_filter) + if f_c: + await cf_mgr.create_child_category(user_name, f_c) + if f_c2: + await cf_mgr.create_child_category(user_name, f_c2) return web.json_response( {'result': "Filter pipeline {} updated successfully".format(json.loads(result['value']))}) except ValueError as err: msg = str(err) - raise web.HTTPNotFound(reason=msg) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except TypeError as err: msg = str(err) - raise web.HTTPBadRequest(reason=msg) + raise web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) except StorageServerError as e: msg = e.error _LOGGER.exception("Add filters pipeline, caught storage error: {}".format(msg)) @@ -500,7 +513,8 @@ def _delete_keys_from_dict(dict_del: Dict, lst_keys: List[str], deleted_values: try: if parent is not None: if dict_del['type'] == 'JSON': - i_val = json.loads(dict_del[k]) if isinstance(dict_del[k], str) else json.loads(json.dumps(dict_del[k])) + i_val = json.loads(dict_del[k]) if isinstance(dict_del[k], str) else json.loads( + json.dumps(dict_del[k])) else: i_val = dict_del[k] deleted_values.update({parent: i_val}) @@ -518,47 +532,79 @@ async def _delete_child_filters(storage: StorageClientAsync, cf_mgr: Configurati new_list: List[str], old_list: List[str] = []) -> None: # Difference between pipeline and value from storage lists and then delete relationship as per diff delete_children = _diff(new_list, old_list) - for child in delete_children: + async def _delete_relationship(cat_name): try: - filter_child_category_name = "{}_{}".format(user_name, child) + filter_child_category_name = "{}_{}".format(user_name, cat_name) await cf_mgr.delete_child_category(user_name, filter_child_category_name) await cf_mgr.delete_child_category("{} Filters".format(user_name), filter_child_category_name) except: pass - await _delete_configuration_category(storage, "{}_{}".format(user_name, child)) - payload = PayloadBuilder().WHERE(['name', '=', child]).AND_WHERE(['user', '=', user_name]).payload() + await _delete_configuration_category(storage, "{}_{}".format(user_name, cat_name)) + payload = PayloadBuilder().WHERE(['name', '=', cat_name]).AND_WHERE(['user', '=', user_name]).payload() await storage.delete_from_tbl("filter_users", payload) + for child in delete_children: + if isinstance(child, list): + for c in child: + await _delete_relationship(c) + else: + await _delete_relationship(child) async def _add_child_filters(storage: StorageClientAsync, cf_mgr: ConfigurationManager, user_name: str, filter_list: List[str], old_list: List[str] = []) -> None: # Create children categories. Since create_category() does not expect "value" key to be # present in the payload, we need to remove all "value" keys BUT need to add back these # "value" keys to the new configuration. - for filter_name in filter_list: - filter_config = await cf_mgr.get_category_all_items(category_name="{}_{}".format(user_name, filter_name)) + + async def _create_filter_category(filter_cat_name): + filter_config = await cf_mgr.get_category_all_items(category_name="{}_{}".format( + user_name, filter_cat_name)) # If "username_filter" category does not exist if filter_config is None: - filter_config = await cf_mgr.get_category_all_items(category_name=filter_name) + filter_config = await cf_mgr.get_category_all_items(category_name=filter_cat_name) - filter_desc = "Configuration of {} filter for user {}".format(filter_name, user_name) - new_filter_config, deleted_values = _delete_keys_from_dict(filter_config, ['value'], deleted_values={}, parent=None) - await cf_mgr.create_category(category_name="{}_{}".format(user_name, filter_name), + filter_desc = "Configuration of {} filter for user {}".format(filter_cat_name, user_name) + new_filter_config, deleted_values = _delete_keys_from_dict(filter_config, ['value'], + deleted_values={}, parent=None) + await cf_mgr.create_category(category_name="{}_{}".format(user_name, filter_cat_name), category_description=filter_desc, category_value=new_filter_config, keep_original_items=True) if deleted_values != {}: - await cf_mgr.update_configuration_item_bulk("{}_{}".format(user_name, filter_name), deleted_values) + await cf_mgr.update_configuration_item_bulk("{}_{}".format( + user_name, filter_cat_name), deleted_values) # Remove cat from cache - if filter_name in cf_mgr._cacheManager.cache: - cf_mgr._cacheManager.remove(filter_name) + if filter_cat_name in cf_mgr._cacheManager.cache: + cf_mgr._cacheManager.remove(filter_cat_name) + + # Create filter category + for _fn in filter_list: + if isinstance(_fn, list): + for f in _fn: + await _create_filter_category(f) + else: + await _create_filter_category(_fn) # Create children categories in category_children table - children = ["{}_{}".format(user_name, _filter) for _filter in filter_list] - await cf_mgr.create_child_category(category_name=user_name, children=children) + children = [] + for _filter in filter_list: + if isinstance(_filter, list): + for f in _filter: + child_cat_name = "{}_{}".format(user_name, f) + children.append(child_cat_name) + else: + child_cat_name = "{}_{}".format(user_name, _filter) + children.append(child_cat_name) + await cf_mgr.create_child_category(category_name=user_name, children=children) # Add entries to filter_users table new_added = _diff(old_list, filter_list) for filter_name in new_added: - payload = PayloadBuilder().INSERT(name=filter_name, user=user_name).payload() - await storage.insert_into_tbl("filter_users", payload) + payload = None + if isinstance(filter_name, list): + for f in filter_name: + payload = PayloadBuilder().INSERT(name=f, user=user_name).payload() + else: + payload = PayloadBuilder().INSERT(name=filter_name, user=user_name).payload() + if payload is not None: + await storage.insert_into_tbl("filter_users", payload) From 598bb58696ac1eb41c2c570c7f9e320d64929c0d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 19 Jan 2024 12:06:47 +0530 Subject: [PATCH 027/146] reset startup priority order on enable schedule Signed-off-by: ashish-jabble --- python/fledge/services/core/api/scheduler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/scheduler.py b/python/fledge/services/core/api/scheduler.py index d3d985630a..f5bc729faf 100644 --- a/python/fledge/services/core/api/scheduler.py +++ b/python/fledge/services/core/api/scheduler.py @@ -431,6 +431,8 @@ async def enable_schedule_with_name(request): except (TypeError, ValueError): raise web.HTTPNotFound(reason="No Schedule with ID {}".format(sch_id)) + # Reset startup priority order + server.Server.scheduler.reset_process_script_priority() status, reason = await server.Server.scheduler.enable_schedule(uuid.UUID(sch_id)) schedule = { @@ -508,6 +510,8 @@ async def enable_schedule(request): except ValueError as ex: raise web.HTTPNotFound(reason="Invalid Schedule ID {}".format(schedule_id)) + # Reset startup priority order + server.Server.scheduler.reset_process_script_priority() status, reason = await server.Server.scheduler.enable_schedule(uuid.UUID(schedule_id)) schedule = { @@ -515,7 +519,6 @@ async def enable_schedule(request): 'status': status, 'message': reason } - return web.json_response(schedule) except (ValueError, ScheduleNotFoundError) as ex: raise web.HTTPNotFound(reason=str(ex)) From 1c1fe67703ead8088dfdc51da89b1de460fe84b2 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 19 Jan 2024 17:03:17 +0530 Subject: [PATCH 028/146] feedback fixes Signed-off-by: ashish-jabble --- .../services/core/scheduler/scheduler.py | 3 +- python/fledge/services/core/server.py | 4 +-- scripts/services/dispatcher_c | 26 +++++++++-------- scripts/services/north_C | 28 ++++++++++-------- scripts/services/notification_c | 26 +++++++++-------- scripts/services/south_c | 29 +++++++++++-------- 6 files changed, 64 insertions(+), 52 deletions(-) diff --git a/python/fledge/services/core/scheduler/scheduler.py b/python/fledge/services/core/scheduler/scheduler.py index d2a9295129..37dffe19f5 100644 --- a/python/fledge/services/core/scheduler/scheduler.py +++ b/python/fledge/services/core/scheduler/scheduler.py @@ -1681,5 +1681,4 @@ async def audit_trail_entry(self, old_row, new_row): def reset_process_script_priority(self): for k,v in self._process_scripts.items(): if isinstance(v, tuple): - updated_tuple = (v[0], self._DEFAULT_PROCESS_SCRIPT_PRIORITY) - self._process_scripts[k] = updated_tuple + self._process_scripts[k] = (v[0], self._DEFAULT_PROCESS_SCRIPT_PRIORITY) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index df8e5c88e9..7997174a94 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -1908,8 +1908,8 @@ async def is_dispatcher_running(cls, storage): _logger.info("Dispatcher service found but not in enabled state. " "Therefore, {} schedule name is enabled".format(sch['schedule_name'])) # reset process_script priority for the service - update_tuple = (cls.scheduler._process_scripts['dispatcher_c'][0], 999) - cls.scheduler._process_scripts['dispatcher_c'] = update_tuple + cls.scheduler._process_scripts['dispatcher_c'] = ( + cls.scheduler._process_scripts['dispatcher_c'][0], 999) await cls.scheduler.enable_schedule(uuid.UUID(sch["id"])) return True elif sch['process_name'] == 'dispatcher_c' and sch['enabled'] == 't': diff --git a/scripts/services/dispatcher_c b/scripts/services/dispatcher_c index 9ec51eee26..550016087e 100755 --- a/scripts/services/dispatcher_c +++ b/scripts/services/dispatcher_c @@ -9,18 +9,20 @@ if [ ! -d "${FLEDGE_ROOT}" ]; then exit 1 fi -cd "${FLEDGE_ROOT}/services" - # startup with delay -for i in "$@"; - do - PARAM=$(echo $i | cut -f1 -d=) - if [ $PARAM = '--delay' ]; then - PARAM_LENGTH=${#PARAM} - VALUE="${i:$PARAM_LENGTH+1}" - sleep $VALUE - break - fi -done +delay() { + for ARG in "$@"; + do + PARAM=$(echo $ARG | cut -f1 -d=) + if [ $PARAM = '--delay' ]; then + PARAM_LENGTH=${#PARAM} + VALUE="${ARG:$PARAM_LENGTH+1}" + sleep $VALUE + break + fi + done +} +cd "${FLEDGE_ROOT}/services" +delay "$@" ./fledge.services.dispatcher "$@" diff --git a/scripts/services/north_C b/scripts/services/north_C index db62edc9c3..d94d472e9f 100755 --- a/scripts/services/north_C +++ b/scripts/services/north_C @@ -43,6 +43,20 @@ if [ "$STRACE_NORTH" != "" ]; then done fi +# startup with delay +delay() { + for ARG in "$@"; + do + PARAM=$(echo $ARG | cut -f1 -d=) + if [ $PARAM = '--delay' ]; then + PARAM_LENGTH=${#PARAM} + VALUE="${ARG:$PARAM_LENGTH+1}" + sleep $VALUE + break + fi + done +} + cd "${FLEDGE_ROOT}/services" if [ "$runvalgrind" = "y" ]; then file=${HOME}/north.${name}.valgrind.out @@ -65,17 +79,7 @@ elif [ "$INTERPOSE_NORTH" != "" ]; then ./fledge.services.north "$@" unset LD_PRELOAD else - # startup with delay - for i in "$@"; - do - PARAM=$(echo $i | cut -f1 -d=) - if [ $PARAM = '--delay' ]; then - PARAM_LENGTH=${#PARAM} - VALUE="${i:$PARAM_LENGTH+1}" - sleep $VALUE - break - fi - done - ./fledge.services.north "$@" + delay "$@" + ./fledge.services.north "$@" fi diff --git a/scripts/services/notification_c b/scripts/services/notification_c index c26f5951cc..ae3023cb79 100755 --- a/scripts/services/notification_c +++ b/scripts/services/notification_c @@ -9,18 +9,20 @@ if [ ! -d "${FLEDGE_ROOT}" ]; then exit 1 fi -cd "${FLEDGE_ROOT}/services" - # startup with delay -for i in "$@"; - do - PARAM=$(echo $i | cut -f1 -d=) - if [ $PARAM = '--delay' ]; then - PARAM_LENGTH=${#PARAM} - VALUE="${i:$PARAM_LENGTH+1}" - sleep $VALUE - break - fi -done +delay() { + for ARG in "$@"; + do + PARAM=$(echo $ARG | cut -f1 -d=) + if [ $PARAM = '--delay' ]; then + PARAM_LENGTH=${#PARAM} + VALUE="${ARG:$PARAM_LENGTH+1}" + sleep $VALUE + break + fi + done +} +cd "${FLEDGE_ROOT}/services" +delay "$@" ./fledge.services.notification "$@" diff --git a/scripts/services/south_c b/scripts/services/south_c index bcfb73cb35..0e18e751f8 100755 --- a/scripts/services/south_c +++ b/scripts/services/south_c @@ -9,6 +9,21 @@ if [ ! -d "${FLEDGE_ROOT}" ]; then exit 1 fi + +# startup with delay +delay() { + for ARG in "$@"; + do + PARAM=$(echo $ARG | cut -f1 -d=) + if [ $PARAM = '--delay' ]; then + PARAM_LENGTH=${#PARAM} + VALUE="${ARG:$PARAM_LENGTH+1}" + sleep $VALUE + break + fi + done +} + cd "${FLEDGE_ROOT}/services" runvalgrind=n @@ -33,17 +48,7 @@ if [ "$runvalgrind" = "y" ]; then rm -f "$file" valgrind --leak-check=full --trace-children=yes --log-file="$file" ./fledge.services.south "$@" else - # startup with delay - for i in "$@"; - do - PARAM=$(echo $i | cut -f1 -d=) - if [ $PARAM = '--delay' ]; then - PARAM_LENGTH=${#PARAM} - VALUE="${i:$PARAM_LENGTH+1}" - sleep $VALUE - break - fi - done - ./fledge.services.south "$@" + delay "$@" + ./fledge.services.south "$@" fi From aee2e5ef80b338206e425bc87b2e22a2e84f9387 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 22 Jan 2024 15:41:43 +0530 Subject: [PATCH 029/146] Python based Alert Manager Singleton class added Signed-off-by: ashish-jabble --- python/fledge/common/alert_manager.py | 63 +++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 python/fledge/common/alert_manager.py diff --git a/python/fledge/common/alert_manager.py b/python/fledge/common/alert_manager.py new file mode 100644 index 0000000000..457dbc037f --- /dev/null +++ b/python/fledge/common/alert_manager.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# FLEDGE_BEGIN +# See: http://fledge-iot.readthedocs.io/ +# FLEDGE_END + +from fledge.common.logger import FLCoreLogger +from fledge.common.storage_client.payload_builder import PayloadBuilder + +__author__ = "Ashish Jabble" +__copyright__ = "Copyright (c) 2024 Dianomic Systems Inc." +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + +_logger = FLCoreLogger().get_logger(__name__) + +class AlertManagerSingleton(object): + _shared_state = {} + + def __init__(self): + self.__dict__ = self._shared_state + + +class AlertManager(AlertManagerSingleton): + storage_client = None + alerts = [] + + def __init__(self, storage_client=None): + AlertManagerSingleton.__init__(self) + if not storage_client: + from fledge.services.core import connect + self.storage_client = connect.get_storage_async() + else: + self.storage_client = storage_client + + async def get_all(self): + """ Get all alerts from storage """ + try: + q_payload = PayloadBuilder().SELECT("key", "message", "urgency", "ts").ALIAS( + "return", ("ts", 'timestamp')).FORMAT("return", ("ts", "YYYY-MM-DD HH24:MI:SS.MS")).payload() + results = await self.storage_client.query_tbl_with_payload('alerts', q_payload) + _logger.error("results: {}".format(results)) + if 'rows' in results: + if results['rows']: + self.alerts = results['rows'] + except Exception as ex: + raise Exception(ex) + else: + return self.alerts + + async def acknowledge_alert(self): + """ Delete an entry from storage """ + message = "Noting to acknowledge an alert!" + try: + result = await self.storage_client.delete_from_tbl("alerts") + if 'rows_affected' in result: + if result['response'] == "deleted" and result['rows_affected']: + message = "Acknowledge all alerts!" + self.alerts = [] + except Exception as ex: + raise Exception(ex) + else: + return message From c4fbc54a04efb62339f3eb7676515b72a60a7572 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 22 Jan 2024 15:46:15 +0530 Subject: [PATCH 030/146] Alert table added for SQLite engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlite/init.sql | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 32df3c0fe0..88da76d240 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -710,6 +710,15 @@ CREATE TABLE fledge.monitors ( CREATE INDEX monitors_ix1 ON monitors(service, monitor); +-- Create alerts table + +CREATE TABLE fledge.alerts ( + key character varying(80) NOT NULL, -- Primary key + message character varying(255) NOT NULL, -- Alert Message + urgency SMALLINT NOT NULL, -- 1 Critical - 2 High - 3 Normal - 4 Low + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')), -- Timestamp, updated at every change + CONSTRAINT alerts_pkey PRIMARY KEY (key) ); + ---------------------------------------------------------------------- -- Initialization phase - DML ---------------------------------------------------------------------- From 8fcb09f1ecdccd848ab9e5d8cdfb10c55a0c35bb Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 22 Jan 2024 15:46:55 +0530 Subject: [PATCH 031/146] Alerts admin API added Signed-off-by: ashish-jabble --- python/fledge/services/core/api/alerts.py | 59 +++++++++++++++++++++++ python/fledge/services/core/routes.py | 5 +- python/fledge/services/core/server.py | 13 +++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 python/fledge/services/core/api/alerts.py diff --git a/python/fledge/services/core/api/alerts.py b/python/fledge/services/core/api/alerts.py new file mode 100644 index 0000000000..b82f2e7718 --- /dev/null +++ b/python/fledge/services/core/api/alerts.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +# FLEDGE_BEGIN +# See: http://fledge-iot.readthedocs.io/ +# FLEDGE_END + +import json +from aiohttp import web + +from fledge.common.logger import FLCoreLogger +from fledge.services.core import server + +__author__ = "Ashish Jabble" +__copyright__ = "Copyright (c) 2024, Dianomic Systems Inc." +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + +_help = """ + ---------------------------------------------------------------- + | GET | /fledge/alert | + | DELETE | /fledge/alert/acknowledge | + ---------------------------------------------------------------- +""" +_LOGGER = FLCoreLogger().get_logger(__name__) + +def setup(app): + app.router.add_route('GET', '/fledge/alert', get_all) + app.router.add_route('DELETE', '/fledge/alert/acknowledge', acknowledge_alert) + + +async def get_all(request: web.Request) -> web.Response: + """ GET list of alerts + + :Example: + curl -sX GET http://localhost:8081/fledge/alert + """ + try: + alerts = await server.Server._alert_manager.get_all() + except Exception as ex: + msg = str(ex) + _LOGGER.error(ex, "Failed to get alerts.") + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response({"alerts": alerts}) + +async def acknowledge_alert(request: web.Request) -> web.Response: + """ DELETE all alerts + + :Example: + curl -sX DELETE http://localhost:8081/fledge/alert/acknowledge + """ + try: + response = await server.Server._alert_manager.acknowledge_alert() + except Exception as ex: + msg = str(ex) + _LOGGER.error(ex, "Failed to acknowledge alerts.") + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response({"message": response}) \ No newline at end of file diff --git a/python/fledge/services/core/routes.py b/python/fledge/services/core/routes.py index 469d66af30..cd8806f17b 100644 --- a/python/fledge/services/core/routes.py +++ b/python/fledge/services/core/routes.py @@ -5,7 +5,7 @@ # 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, performance_monitor, python_packages, south, support, service, task, update +from fledge.services.core.api import alerts, asset_tracker, auth, backup_restore, browser, certificate_store, filters, health, notification, north, package_log, performance_monitor, 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 @@ -267,6 +267,9 @@ def setup(app): # Performance Monitor performance_monitor.setup(app) + # Alerts + alerts.setup(app) + # enable cors support enable_cors(app) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 7997174a94..13b028dff4 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -23,6 +23,7 @@ import jwt from fledge.common import logger +from fledge.common.alert_manager import AlertManager from fledge.common.audit_logger import AuditLogger from fledge.common.configuration_manager import ConfigurationManager from fledge.common.storage_client.exceptions import * @@ -321,6 +322,9 @@ class Server: _asset_tracker = None """ Asset tracker """ + _alert_manager = None + """ Alert Manager """ + running_in_safe_mode = False """ Fledge running in Safe mode """ @@ -817,6 +821,11 @@ async def _start_asset_tracker(cls): cls._asset_tracker = AssetTracker(cls._storage_client_async) await cls._asset_tracker.load_asset_records() + @classmethod + async def _get_alerts(cls): + cls._alert_manager = AlertManager(cls._storage_client_async) + await cls._alert_manager.get_all() + @classmethod def _start_core(cls, loop=None): if cls.running_in_safe_mode: @@ -927,6 +936,10 @@ def _start_core(cls, loop=None): if not cls.running_in_safe_mode: # Start asset tracker loop.run_until_complete(cls._start_asset_tracker()) + + # Start Alert Manager + loop.run_until_complete(cls._get_alerts()) + # If dispatcher installation: # a) not found then add it as a StartUp service # b) found then check the status of its schedule and take action From 6b168b07cbe0149b66e2996e2d68962597e7c4a6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 22 Jan 2024 15:47:19 +0530 Subject: [PATCH 032/146] Ping response updated for alerts Signed-off-by: ashish-jabble --- python/fledge/services/core/api/common.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/common.py b/python/fledge/services/core/api/common.py index 44bc68ba12..8b6d185ce8 100644 --- a/python/fledge/services/core/api/common.py +++ b/python/fledge/services/core/api/common.py @@ -105,7 +105,8 @@ def services_health_litmus_test(): 'ipAddresses': ip_addresses, 'health': status_color, 'safeMode': safe_mode, - 'version': version + 'version': version, + 'alerts': len(server.Server._alert_manager.alerts) }) From 69b90324b288b14f5195a28eed34468692882ecc Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 22 Jan 2024 16:25:17 +0530 Subject: [PATCH 033/146] ping tests updated and other fixes Signed-off-by: ashish-jabble --- python/fledge/common/alert_manager.py | 1 - python/fledge/services/core/api/alerts.py | 6 +++--- tests/system/python/api/test_common.py | 1 + .../fledge/services/core/api/test_common_ping.py | 13 ++++++++++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/python/fledge/common/alert_manager.py b/python/fledge/common/alert_manager.py index 457dbc037f..742ed7ba7e 100644 --- a/python/fledge/common/alert_manager.py +++ b/python/fledge/common/alert_manager.py @@ -39,7 +39,6 @@ async def get_all(self): q_payload = PayloadBuilder().SELECT("key", "message", "urgency", "ts").ALIAS( "return", ("ts", 'timestamp')).FORMAT("return", ("ts", "YYYY-MM-DD HH24:MI:SS.MS")).payload() results = await self.storage_client.query_tbl_with_payload('alerts', q_payload) - _logger.error("results: {}".format(results)) if 'rows' in results: if results['rows']: self.alerts = results['rows'] diff --git a/python/fledge/services/core/api/alerts.py b/python/fledge/services/core/api/alerts.py index b82f2e7718..b3e4d8b4e9 100644 --- a/python/fledge/services/core/api/alerts.py +++ b/python/fledge/services/core/api/alerts.py @@ -18,14 +18,14 @@ _help = """ ---------------------------------------------------------------- | GET | /fledge/alert | - | DELETE | /fledge/alert/acknowledge | + | DELETE | /fledge/alert/acknowledge | ---------------------------------------------------------------- """ _LOGGER = FLCoreLogger().get_logger(__name__) def setup(app): app.router.add_route('GET', '/fledge/alert', get_all) - app.router.add_route('DELETE', '/fledge/alert/acknowledge', acknowledge_alert) + app.router.add_route('DELETE', '/fledge/alert/acknowledge', delete) async def get_all(request: web.Request) -> web.Response: @@ -43,7 +43,7 @@ async def get_all(request: web.Request) -> web.Response: else: return web.json_response({"alerts": alerts}) -async def acknowledge_alert(request: web.Request) -> web.Response: +async def delete(request: web.Request) -> web.Response: """ DELETE all alerts :Example: diff --git a/tests/system/python/api/test_common.py b/tests/system/python/api/test_common.py index c60ea04889..69c9e17850 100644 --- a/tests/system/python/api/test_common.py +++ b/tests/system/python/api/test_common.py @@ -85,6 +85,7 @@ def test_ping_default(self, reset_and_start_fledge, fledge_url): assert jdoc['authenticationOptional'] is True assert jdoc['safeMode'] is False assert re.match(SEMANTIC_VERSIONING_REGEX, jdoc['version']) is not None + assert jdoc['alerts'] == 0 def test_ping_when_auth_mandatory_allow_ping_true(self, fledge_url, wait_time, retries): conn = http.client.HTTPConnection(fledge_url) 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 f957e8839e..b0dafea97c 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 @@ -27,9 +27,9 @@ import aiohttp from aiohttp import web -from fledge.services.core import routes -from fledge.services.core import connect +from fledge.services.core import connect, routes, server as core_server from fledge.services.core.api.common import _logger +from fledge.common.alert_manager import AlertManager from fledge.common.web import middleware from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.configuration_manager import ConfigurationManager @@ -85,13 +85,14 @@ 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) + core_server.Server._alert_manager = AlertManager(mock_storage_client_async) + core_server.Server._alert_manager.alerts = [] 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]) # fill route table routes.setup(app) - server = await aiohttp_server(app) await server.start_server(loop=loop) @@ -115,6 +116,7 @@ async def mock_coro(*args, **kwargs): assert content_dict['health'] == "green" assert content_dict['safeMode'] is False assert re.match(SEMANTIC_VERSIONING_REGEX, content_dict['version']) is not None + assert content_dict['alerts'] == 0 query_patch.assert_called_once_with('statistics', payload) log_params = 'Received %s request for %s', 'GET', '/fledge/ping' logger_info.assert_called_once_with(*log_params) @@ -172,6 +174,7 @@ async def mock_coro(*args, **kwargs): assert content_dict['health'] == "green" assert content_dict['safeMode'] is False assert re.search(SEMANTIC_VERSIONING_REGEX, content_dict['version']) is not None + assert content_dict['alerts'] == 0 query_patch.assert_called_once_with('statistics', payload) log_params = 'Received %s request for %s', 'GET', '/fledge/ping' logger_info.assert_called_once_with(*log_params) @@ -236,6 +239,7 @@ async def mock_get_category_item(): assert content_dict['health'] == "green" assert content_dict['safeMode'] is False assert re.match(SEMANTIC_VERSIONING_REGEX, content_dict['version']) is not None + assert content_dict['alerts'] == 0 mock_get_cat.assert_called_once_with('rest_api', 'allowPing') query_patch.assert_called_once_with('statistics', payload) log_params = 'Received %s request for %s', 'GET', '/fledge/ping' @@ -361,6 +365,7 @@ def mock_coro(*args, **kwargs): assert content_dict['health'] == "green" assert content_dict['safeMode'] is False assert re.match(SEMANTIC_VERSIONING_REGEX, content_dict['version']) is not None + assert content_dict['alerts'] == 0 query_patch.assert_called_once_with('statistics', payload) logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/ping') @@ -426,6 +431,7 @@ def mock_coro(*args, **kwargs): assert content_dict['ipAddresses'] == ip_addresses assert content_dict['health'] == "green" assert re.match(SEMANTIC_VERSIONING_REGEX, content_dict['version']) is not None + assert content_dict['alerts'] == 0 query_patch.assert_called_once_with('statistics', payload) logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/ping') @@ -503,6 +509,7 @@ async def mock_get_category_item(): assert content_dict['health'] == "green" assert content_dict['safeMode'] is False assert re.match(SEMANTIC_VERSIONING_REGEX, content_dict['version']) is not None + assert content_dict['alerts'] == 0 mock_get_cat.assert_called_once_with('rest_api', 'allowPing') query_patch.assert_called_once_with('statistics', payload) logger_info.assert_called_once_with('Received %s request for %s', 'GET', '/fledge/ping') From 3d741d35c35e700eb2eb39907b397ade1997f67d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 22 Jan 2024 18:14:32 +0530 Subject: [PATCH 034/146] DELETE by key added Signed-off-by: ashish-jabble --- python/fledge/common/alert_manager.py | 28 ++++++++++++++++++----- python/fledge/services/core/api/alerts.py | 22 +++++++++++++----- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/python/fledge/common/alert_manager.py b/python/fledge/common/alert_manager.py index 742ed7ba7e..b30f680570 100644 --- a/python/fledge/common/alert_manager.py +++ b/python/fledge/common/alert_manager.py @@ -47,16 +47,32 @@ async def get_all(self): else: return self.alerts - async def acknowledge_alert(self): + + async def delete(self, key=None): """ Delete an entry from storage """ - message = "Noting to acknowledge an alert!" try: - result = await self.storage_client.delete_from_tbl("alerts") + payload = {} + message = "Nothing to delete." + key_exists = -1 + if key is not None: + key_exists = [index for index, item in enumerate(self.alerts) if item['key'] == key] + if key_exists: + payload = PayloadBuilder().WHERE(["key", "=", key]).payload() + else: + raise KeyError + result = await self.storage_client.delete_from_tbl("alerts", payload) if 'rows_affected' in result: if result['response'] == "deleted" and result['rows_affected']: - message = "Acknowledge all alerts!" - self.alerts = [] + if key is None: + message = "Delete all alerts." + self.alerts = [] + else: + message = "{} alert is deleted.".format(key) + if key_exists: + del self.alerts[key_exists[0]] + except KeyError: + raise KeyError except Exception as ex: raise Exception(ex) else: - return message + return message \ No newline at end of file diff --git a/python/fledge/services/core/api/alerts.py b/python/fledge/services/core/api/alerts.py index b3e4d8b4e9..9523e39e7b 100644 --- a/python/fledge/services/core/api/alerts.py +++ b/python/fledge/services/core/api/alerts.py @@ -17,15 +17,17 @@ _help = """ ---------------------------------------------------------------- - | GET | /fledge/alert | - | DELETE | /fledge/alert/acknowledge | + | GET DELETE | /fledge/alert | + | DELETE | /fledge/alert/{key} | ---------------------------------------------------------------- """ _LOGGER = FLCoreLogger().get_logger(__name__) def setup(app): app.router.add_route('GET', '/fledge/alert', get_all) - app.router.add_route('DELETE', '/fledge/alert/acknowledge', delete) + app.router.add_route('DELETE', '/fledge/alert', delete) + app.router.add_route('DELETE', '/fledge/alert/{key}', delete) + async def get_all(request: web.Request) -> web.Response: @@ -47,13 +49,21 @@ async def delete(request: web.Request) -> web.Response: """ DELETE all alerts :Example: - curl -sX DELETE http://localhost:8081/fledge/alert/acknowledge + curl -sX DELETE http://localhost:8081/fledge/alert + curl -sX DELETE http://localhost:8081/fledge/alert/{key} """ + key = request.match_info.get('key', None) try: - response = await server.Server._alert_manager.acknowledge_alert() + if key: + response = await server.Server._alert_manager.delete(key=key) + else: + response = await server.Server._alert_manager.delete() + except KeyError: + msg = '{} alert not found.'.format(key) + raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) except Exception as ex: msg = str(ex) - _LOGGER.error(ex, "Failed to acknowledge alerts.") + _LOGGER.error(ex, "Failed to delete alerts.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": response}) \ No newline at end of file From 337a5c8c80ffa68ae3a12afc9330c48a232005b2 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 23 Jan 2024 15:12:06 +0530 Subject: [PATCH 035/146] management alert routes added Signed-off-by: ashish-jabble --- .../fledge/services/common/microservice_management/routes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/fledge/services/common/microservice_management/routes.py b/python/fledge/services/common/microservice_management/routes.py index 9c21704bc4..6a27848b7c 100644 --- a/python/fledge/services/common/microservice_management/routes.py +++ b/python/fledge/services/common/microservice_management/routes.py @@ -73,6 +73,10 @@ def setup(app, obj, is_core=False): app.router.add_route('GET', '/fledge/ACL/{acl_name}', obj.get_control_acl) + # alerts + app.router.add_route('GET', '/fledge/alert/{key}', obj.get_alert) + app.router.add_route('POST', '/fledge/alert', obj.add_alert) + # Proxy API setup for a microservice proxy.setup(app) From ead23ccbd501062febc19ef66829985dd3ce327b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 23 Jan 2024 15:12:35 +0530 Subject: [PATCH 036/146] management alert routes handler added Signed-off-by: ashish-jabble --- python/fledge/common/alert_manager.py | 61 ++++++++++++++++++++++++--- python/fledge/services/core/server.py | 44 +++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/python/fledge/common/alert_manager.py b/python/fledge/common/alert_manager.py index b30f680570..0e3fdc7688 100644 --- a/python/fledge/common/alert_manager.py +++ b/python/fledge/common/alert_manager.py @@ -24,6 +24,7 @@ def __init__(self): class AlertManager(AlertManagerSingleton): storage_client = None alerts = [] + urgency = {"Critical": 1, "High": 2, "Normal": 3, "Low": 4} def __init__(self, storage_client=None): AlertManagerSingleton.__init__(self) @@ -38,15 +39,61 @@ async def get_all(self): try: q_payload = PayloadBuilder().SELECT("key", "message", "urgency", "ts").ALIAS( "return", ("ts", 'timestamp')).FORMAT("return", ("ts", "YYYY-MM-DD HH24:MI:SS.MS")).payload() - results = await self.storage_client.query_tbl_with_payload('alerts', q_payload) - if 'rows' in results: - if results['rows']: - self.alerts = results['rows'] + storage_result = await self.storage_client.query_tbl_with_payload('alerts', q_payload) + result = [] + if 'rows' in storage_result: + for row in storage_result['rows']: + tmp = {"key": row['key'], + "message": row['message'], + "urgency": self._urgency_name_by_value(row['urgency']), + "timestamp": row['timestamp'] + } + result.append(tmp) + self.alerts = result except Exception as ex: raise Exception(ex) else: return self.alerts + async def get_by_key(self, name): + """ Get an alert by key """ + key_found = [a for a in self.alerts if a['key'] == name] + if key_found: + return key_found[0] + try: + q_payload = PayloadBuilder().SELECT("key", "message", "urgency", "ts").ALIAS( + "return", ("ts", 'timestamp')).FORMAT("return", ("ts", "YYYY-MM-DD HH24:MI:SS.MS")).WHERE( + ["key", "=", name]).payload() + results = await self.storage_client.query_tbl_with_payload('alerts', q_payload) + alert = {} + if 'rows' in results: + if len(results['rows']) > 0: + row = results['rows'][0] + alert = {"key": row['key'], + "message": row['message'], + "urgency": self._urgency_name_by_value(row['urgency']), + "timestamp": row['timestamp'] + } + if not alert: + raise KeyError('{} alert not found.'.format(name)) + except KeyError as err: + raise KeyError(err) + else: + return alert + + async def add(self, params): + """ Add an alert """ + response = None + try: + payload = PayloadBuilder().INSERT(**params).payload() + insert_api_result = await self.storage_client.insert_into_tbl('alerts', payload) + if insert_api_result['response'] == 'inserted' and insert_api_result['rows_affected'] == 1: + response = {"alert": params} + self.alerts.append(params) + except Exception as ex: + raise Exception(ex) + else: + return response async def delete(self, key=None): """ Delete an entry from storage """ @@ -75,4 +122,8 @@ async def delete(self, key=None): except Exception as ex: raise Exception(ex) else: - return message \ No newline at end of file + return message + + def _urgency_name_by_value(self, value): + return list(self.urgency.keys())[list(self.urgency.values()).index(value)] + diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 13b028dff4..bc00d2eaf2 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -2003,3 +2003,47 @@ async def get_control_acl(cls, request): request.is_core_mgt = True res = await acl_management.get_acl(request) return res + + @classmethod + async def get_alert(cls, request): + name = request.match_info.get('key', None) + try: + alert = await cls._alert_manager.get_by_key(name) + except KeyError as err: + msg = str(err.args[0]) + return web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response({"alert": alert}) + + @classmethod + async def add_alert(cls, request): + try: + data = await request.json() + key = data.get("key") + message = data.get("message") + urgency = data.get("urgency") + if any(elem is None for elem in [key, message, urgency]): + msg = 'key, message, urgency post params are required to raise an alert.' + return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + if not all(isinstance(i, str) for i in [key, message, urgency]): + msg = 'key or message KV pair should be passed as string.' + return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + urgency = urgency.lower().capitalize() + _logger.error("{}-{}-{}".format(key, message, urgency)) + if urgency not in cls._alert_manager.urgency: + msg = 'Urgency value should be from list {}'.format(list(cls._alert_manager.urgency.keys())) + return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) + key_exists = [a for a in cls._alert_manager.alerts if a['key'] == key] + if key_exists: + # Delete existing key + await cls._alert_manager.delete(key) + param = {"key": key, "message": message, "urgency": cls._alert_manager.urgency[urgency]} + response = await cls._alert_manager.add(param) + if response is None: + raise Exception + except Exception as ex: + msg = str(ex) + _logger.error(ex, "Failed to add an alert.") + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + else: + return web.json_response(response) From c044dc94018e86d8ffa72d193d24034cb262ec09 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 23 Jan 2024 15:13:44 +0530 Subject: [PATCH 037/146] ping response updated as per alert feature in package install tests Signed-off-by: ashish-jabble --- tests/system/python/packages/test_available_and_install_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/system/python/packages/test_available_and_install_api.py b/tests/system/python/packages/test_available_and_install_api.py index 24ace33af1..ad3d34aac9 100644 --- a/tests/system/python/packages/test_available_and_install_api.py +++ b/tests/system/python/packages/test_available_and_install_api.py @@ -78,6 +78,7 @@ def test_ping(self, fledge_url): assert 'green' == jdoc['health'] assert jdoc['authenticationOptional'] is True assert jdoc['safeMode'] is False + assert jdoc['alerts'] == 0 def test_available_plugin_packages(self, fledge_url): conn = http.client.HTTPConnection(fledge_url) From d68e8c8b9df8a91c81bfb2afd889e20db077c86b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 23 Jan 2024 15:28:07 +0530 Subject: [PATCH 038/146] exception handling Signed-off-by: ashish-jabble --- python/fledge/common/alert_manager.py | 6 +++++- python/fledge/services/core/server.py | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/python/fledge/common/alert_manager.py b/python/fledge/common/alert_manager.py index 0e3fdc7688..1bc614dce8 100644 --- a/python/fledge/common/alert_manager.py +++ b/python/fledge/common/alert_manager.py @@ -125,5 +125,9 @@ async def delete(self, key=None): return message def _urgency_name_by_value(self, value): - return list(self.urgency.keys())[list(self.urgency.values()).index(value)] + try: + name = list(self.urgency.keys())[list(self.urgency.values()).index(value)] + except: + name = "UNKNOWN" + return name diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index bc00d2eaf2..fb0c12c07a 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -2012,6 +2012,10 @@ async def get_alert(cls, request): except KeyError as err: msg = str(err.args[0]) return web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg})) + except Exception as ex: + msg = str(ex) + _logger.error(ex, "Failed to get an alert.") + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"alert": alert}) @@ -2029,7 +2033,6 @@ async def add_alert(cls, request): msg = 'key or message KV pair should be passed as string.' return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) urgency = urgency.lower().capitalize() - _logger.error("{}-{}-{}".format(key, message, urgency)) if urgency not in cls._alert_manager.urgency: msg = 'Urgency value should be from list {}'.format(list(cls._alert_manager.urgency.keys())) return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) From cb29873d128a3afd062c3fa46b7b25fbed5aeec0 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 23 Jan 2024 16:39:49 +0530 Subject: [PATCH 039/146] Python management client added for alerts Signed-off-by: ashish-jabble --- python/fledge/common/alert_manager.py | 3 +- .../microservice_management_client.py | 31 +++++++++++++++++++ python/fledge/services/core/server.py | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/python/fledge/common/alert_manager.py b/python/fledge/common/alert_manager.py index 1bc614dce8..ba63125c95 100644 --- a/python/fledge/common/alert_manager.py +++ b/python/fledge/common/alert_manager.py @@ -77,7 +77,8 @@ async def get_by_key(self, name): if not alert: raise KeyError('{} alert not found.'.format(name)) except KeyError as err: - raise KeyError(err) + msg = str(err.args[0]) + raise KeyError(msg) else: return alert 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 247205fe5a..38cb9fbef1 100644 --- a/python/fledge/common/microservice_management_client/microservice_management_client.py +++ b/python/fledge/common/microservice_management_client/microservice_management_client.py @@ -390,3 +390,34 @@ def create_asset_tracker_event(self, asset_event): self._management_client_conn.close() response = json.loads(res) return response + + def get_alert_by_key(self, key): + url = "/fledge/alert/{}".format(key) + self._management_client_conn.request(method='GET', url=url) + r = self._management_client_conn.getresponse() + if r.status != 404: + if r.status in range(400, 500): + _logger.error("For URL: %s, Client error code: %d, Reason: %s", url, r.status, r.reason) + raise client_exceptions.MicroserviceManagementClientError(status=r.status, reason=r.reason) + if r.status in range(500, 600): + _logger.error("For URL: %s, Server error code: %d, Reason: %s", url, r.status, r.reason) + raise client_exceptions.MicroserviceManagementClientError(status=r.status, reason=r.reason) + res = r.read().decode() + self._management_client_conn.close() + response = json.loads(res) + return response + + def add_alert(self, params): + url = '/fledge/alert' + self._management_client_conn.request(method='POST', url=url, body=json.dumps(params)) + r = self._management_client_conn.getresponse() + if r.status in range(401, 500): + _logger.error("For URL: %s, Client error code: %d, Reason: %s", url, r.status, r.reason) + raise client_exceptions.MicroserviceManagementClientError(status=r.status, reason=r.reason) + if r.status in range(500, 600): + _logger.error("For URL: %s, Server error code: %d, Reason: %s", url, r.status, r.reason) + raise client_exceptions.MicroserviceManagementClientError(status=r.status, reason=r.reason) + res = r.read().decode() + self._management_client_conn.close() + response = json.loads(res) + return response \ No newline at end of file diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index fb0c12c07a..87c3824cb1 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -2030,7 +2030,7 @@ async def add_alert(cls, request): msg = 'key, message, urgency post params are required to raise an alert.' return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) if not all(isinstance(i, str) for i in [key, message, urgency]): - msg = 'key or message KV pair should be passed as string.' + msg = 'key, message, urgency KV pair must be passed as string.' return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) urgency = urgency.lower().capitalize() if urgency not in cls._alert_manager.urgency: From ace71ef1ab9d6b68ebc5e63e569c51f11b6a3784 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Tue, 23 Jan 2024 16:26:19 -0500 Subject: [PATCH 040/146] Deprecate OMFHint LegacyType Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/omf.cpp | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index e6ec937a1e..fa82b6e562 100755 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -1095,10 +1095,6 @@ uint32_t OMF::sendToServer(const vector& readings, string AFHierarchyLevel; string measurementId; - string varValue; - string varDefault; - bool variablePresent; - #if INSTRUMENT ostringstream threadId; threadId << std::this_thread::get_id(); @@ -1160,8 +1156,6 @@ 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_linkedAssetState, m_PIServerEndpoint); linkedData.setSendFullStructure(m_sendFullStructure); @@ -1197,14 +1191,8 @@ uint32_t OMF::sendToServer(const vector& readings, Logger::getLogger()->info("Using OMF Tag hint: %s", (*it)->getHint().c_str()); keyComplete.append("_" + (*it)->getHint()); usingTagHint = true; - break; } - - varValue=""; - varDefault=""; - variablePresent=false; - - if (typeid(**it) == typeid(OMFAFLocationHint)) + else if (typeid(**it) == typeid(OMFAFLocationHint)) { OMFHintAFHierarchyTmp = (*it)->getHint(); OMFHintAFHierarchy = variableValueHandle(*reading, OMFHintAFHierarchyTmp); @@ -1214,14 +1202,9 @@ uint32_t OMF::sendToServer(const vector& readings, ,OMFHintAFHierarchyTmp.c_str() ,OMFHintAFHierarchy.c_str() ); } - } - for (auto it = omfHints.cbegin(); it != omfHints.cend(); it++) - { - if (typeid(**it) == typeid(OMFLegacyTypeHint)) + else if (typeid(**it) == typeid(OMFLegacyTypeHint)) { - Logger::getLogger()->info("Using OMF Legacy Type hint: %s", (*it)->getHint().c_str()); - legacyType = true; - break; + Logger::getLogger()->warn("OMFHint LegacyType has been deprecated. The hint value '%s' will be ignored.", (*it)->getHint().c_str()); } } } @@ -1279,7 +1262,7 @@ uint32_t OMF::sendToServer(const vector& readings, // Use old style complex types if the user has forced it via configuration, // 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 (m_legacy || m_PIServerEndpoint == ENDPOINT_EDS || m_PIServerEndpoint == ENDPOINT_CR || m_OMFDataTypes->find(keyComplete) != m_OMFDataTypes->end()) { From 8356819da68c7ea544e0ab92afc0c9f278eb1ff8 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Tue, 23 Jan 2024 16:30:18 -0500 Subject: [PATCH 041/146] Remove OMFHint LegacyType OMFHint LegacyType is deprecated since there is no way to change the definition of a Container after it has been created. Signed-off-by: Ray Verhoeff --- docs/OMF.rst | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/docs/OMF.rst b/docs/OMF.rst index 9ee26c68a9..01897e2624 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -522,17 +522,6 @@ 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 ~~~~~~~~~~~ From b0d5d71c263719a60b0f59add065a3a17f915117 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 24 Jan 2024 11:27:09 +0530 Subject: [PATCH 042/146] schema bumped to 68 and upgrade/downgrade scripts added for SQLite Signed-off-by: ashish-jabble --- VERSION | 2 +- scripts/plugins/storage/sqlite/downgrade/67.sql | 1 + scripts/plugins/storage/sqlite/upgrade/68.sql | 8 ++++++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins/storage/sqlite/downgrade/67.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/68.sql diff --git a/VERSION b/VERSION index 88c7d297a2..a34de07a01 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.3.0 -fledge_schema=67 +fledge_schema=68 diff --git a/scripts/plugins/storage/sqlite/downgrade/67.sql b/scripts/plugins/storage/sqlite/downgrade/67.sql new file mode 100644 index 0000000000..cc80b23116 --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/67.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS fledge.alerts; diff --git a/scripts/plugins/storage/sqlite/upgrade/68.sql b/scripts/plugins/storage/sqlite/upgrade/68.sql new file mode 100644 index 0000000000..f7f79a239b --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/68.sql @@ -0,0 +1,8 @@ +-- Create alerts table + +CREATE TABLE IF NOT EXISTS fledge.alerts ( + key character varying(80) NOT NULL, -- Primary key + message character varying(255) NOT NULL, -- Alert Message + urgency SMALLINT NOT NULL, -- 1 Critical - 2 High - 3 Normal - 4 Low + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')), -- Timestamp, updated at every change + CONSTRAINT alerts_pkey PRIMARY KEY (key) ); From 50920103be18276fd13186f9c8a1551df1085b9d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 24 Jan 2024 11:41:52 +0530 Subject: [PATCH 043/146] SQLitelb changes along with upgrade and downgrade scripts Signed-off-by: ashish-jabble --- scripts/plugins/storage/sqlitelb/downgrade/67.sql | 1 + scripts/plugins/storage/sqlitelb/init.sql | 9 +++++++++ scripts/plugins/storage/sqlitelb/upgrade/68.sql | 8 ++++++++ 3 files changed, 18 insertions(+) create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/67.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/68.sql diff --git a/scripts/plugins/storage/sqlitelb/downgrade/67.sql b/scripts/plugins/storage/sqlitelb/downgrade/67.sql new file mode 100644 index 0000000000..cc80b23116 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/67.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS fledge.alerts; diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 56d96d9628..db50606997 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -710,6 +710,15 @@ CREATE TABLE fledge.monitors ( CREATE INDEX monitors_ix1 ON monitors(service, monitor); +-- Create alerts table + +CREATE TABLE fledge.alerts ( + key character varying(80) NOT NULL, -- Primary key + message character varying(255) NOT NULL, -- Alert Message + urgency SMALLINT NOT NULL, -- 1 Critical - 2 High - 3 Normal - 4 Low + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')), -- Timestamp, updated at every change + CONSTRAINT alerts_pkey PRIMARY KEY (key) ); + ---------------------------------------------------------------------- -- Initialization phase - DML ---------------------------------------------------------------------- diff --git a/scripts/plugins/storage/sqlitelb/upgrade/68.sql b/scripts/plugins/storage/sqlitelb/upgrade/68.sql new file mode 100644 index 0000000000..f7f79a239b --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/68.sql @@ -0,0 +1,8 @@ +-- Create alerts table + +CREATE TABLE IF NOT EXISTS fledge.alerts ( + key character varying(80) NOT NULL, -- Primary key + message character varying(255) NOT NULL, -- Alert Message + urgency SMALLINT NOT NULL, -- 1 Critical - 2 High - 3 Normal - 4 Low + ts DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f+00:00', 'NOW')), -- Timestamp, updated at every change + CONSTRAINT alerts_pkey PRIMARY KEY (key) ); From 8b38fee18f6be57fd0d0acb6f4937ab3805e6873 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 24 Jan 2024 11:50:36 +0530 Subject: [PATCH 044/146] PostgreSQL changes along with upgrade and downgrade scripts Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/67.sql | 1 + scripts/plugins/storage/postgres/init.sql | 9 +++++++++ scripts/plugins/storage/postgres/upgrade/68.sql | 8 ++++++++ 3 files changed, 18 insertions(+) create mode 100644 scripts/plugins/storage/postgres/downgrade/67.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/68.sql diff --git a/scripts/plugins/storage/postgres/downgrade/67.sql b/scripts/plugins/storage/postgres/downgrade/67.sql new file mode 100644 index 0000000000..cc80b23116 --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/67.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS fledge.alerts; diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 8d696bf3b8..b5658e2f27 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -948,6 +948,15 @@ CREATE TABLE fledge.monitors ( CREATE INDEX monitors_ix1 ON fledge.monitors(service, monitor); +-- Create alerts table + +CREATE TABLE fledge.alerts ( + key character varying(80) NOT NULL, -- Primary key + message character varying(255) NOT NULL, -- Alert Message + urgency smallint NOT NULL, -- 1 Critical - 2 High - 3 Normal - 4 Low + ts timestamp(6) with time zone NOT NULL DEFAULT now(), -- Timestamp, updated at every change + CONSTRAINT alerts_pkey PRIMARY KEY (key) ); + -- Grants to fledge schema GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA fledge TO PUBLIC; diff --git a/scripts/plugins/storage/postgres/upgrade/68.sql b/scripts/plugins/storage/postgres/upgrade/68.sql new file mode 100644 index 0000000000..7f1e383b74 --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/68.sql @@ -0,0 +1,8 @@ +-- Create alerts table + +CREATE TABLE IF NOT EXISTS fledge.alerts ( + key character varying(80) NOT NULL, -- Primary key + message character varying(255) NOT NULL, -- Alert Message + urgency smallint NOT NULL, -- 1 Critical - 2 High - 3 Normal - 4 Low + ts timestamp(6) with time zone NOT NULL DEFAULT now(), -- Timestamp, updated at every change + CONSTRAINT alerts_pkey PRIMARY KEY (key) ); From c527e0680f613efc554414afb732d09316dcd077 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 24 Jan 2024 16:44:11 +0000 Subject: [PATCH 045/146] FOGL-8193 Add OMFBuffer class (#1201) * FOGL-8193 Add OMFBuffer class Signed-off-by: Mark Riddoch * Update unit tests Signed-off-by: Mark Riddoch * Correct sense of separator Signed-off-by: Mark Riddoch * Resolve issue with delimiter when asset has no valid data Signed-off-by: Mark Riddoch * Fix typo Signed-off-by: Mark Riddoch * Resolve build issue if instrumentation is turned on Signed-off-by: Mark Riddoch * Fix review comments Signed-off-by: Mark Riddoch * Fix typo in doxygen comment Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/plugins/north/OMF/include/omf.h | 13 +- C/plugins/north/OMF/include/omfbuffer.h | 58 +++ C/plugins/north/OMF/include/omflinkeddata.h | 3 +- C/plugins/north/OMF/linkdata.cpp | 64 ++-- C/plugins/north/OMF/omf.cpp | 162 ++++---- C/plugins/north/OMF/omfbuffer.cpp | 347 ++++++++++++++++++ tests/unit/C/CMakeLists.txt | 1 + .../C/plugins/common/test_omf_translation.cpp | 72 ++-- .../common/test_omf_translation_piwebapi.cpp | 13 +- 9 files changed, 588 insertions(+), 145 deletions(-) create mode 100644 C/plugins/north/OMF/include/omfbuffer.h create mode 100644 C/plugins/north/OMF/omfbuffer.cpp diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index 1e2e443ef4..be487b2c8b 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -17,6 +17,7 @@ #include #include #include +#include #include #define OMF_HINT "OMFHint" @@ -516,19 +517,23 @@ class OMF * The OMFData class. * A reading is formatted with OMF specifications using the original * type creation scheme implemented by the OMF plugin + * + * There is no good reason to retain this class any more, it is here + * mostly to reduce the scope of the change when introducting the OMFBuffer */ class OMFData { public: - OMFData(const Reading& reading, + OMFData(OMFBuffer & payload, + const Reading& reading, string measurementId, + bool needDelim, const OMF_ENDPOINT PIServerEndpoint = ENDPOINT_CR, const std::string& DefaultAFLocation = std::string(), OMFHints *hints = NULL); - - const std::string& OMFdataVal() const; + bool hasData() { return m_hasData; }; private: - std::string m_value; + bool m_hasData; }; #endif diff --git a/C/plugins/north/OMF/include/omfbuffer.h b/C/plugins/north/OMF/include/omfbuffer.h new file mode 100644 index 0000000000..0a8b8aabf8 --- /dev/null +++ b/C/plugins/north/OMF/include/omfbuffer.h @@ -0,0 +1,58 @@ +#ifndef _OMF_BUFFER_H +#define _OMF_BUFFER_H +/* + * Fledge OMF North plugin buffer class + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ + +#include +#include + +#define BUFFER_CHUNK 8192 + +/** + * Buffer class designed to hold OMF payloads that can + * grow as required but have minimal copy semantics. + * + * TODO Add a coalesce and compress public entry point + */ +class OMFBuffer { + class Buffer { + public: + Buffer(); + Buffer(unsigned int); + ~Buffer(); + char *detach(); + char *data; + unsigned int offset; + unsigned int length; + bool attached; + }; + + public: + OMFBuffer(); + ~OMFBuffer(); + + bool isEmpty() { return buffers.empty() || (buffers.size() == 1 && buffers.front()->offset == 0); } + void append(const char); + void append(const char *); + void append(const int); + void append(const unsigned int); + void append(const long); + void append(const unsigned long); + void append(const double); + void append(const std::string&); + void quote(const std::string&); + const char *coalesce(); + void clear(); + + private: + std::list buffers; +}; + +#endif diff --git a/C/plugins/north/OMF/include/omflinkeddata.h b/C/plugins/north/OMF/include/omflinkeddata.h index c25d1ba38a..dca25696d4 100644 --- a/C/plugins/north/OMF/include/omflinkeddata.h +++ b/C/plugins/north/OMF/include/omflinkeddata.h @@ -13,6 +13,7 @@ #include #include #include +#include #include /** @@ -42,7 +43,7 @@ class OMFLinkedData m_doubleFormat("float64"), m_integerFormat("int64") {}; - std::string processReading(const Reading& reading, + bool processReading(OMFBuffer& payload, bool needDelim, const Reading& reading, const std::string& DefaultAFLocation = std::string(), OMFHints *hints = NULL); void buildLookup(const std::vector& reading); diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index 5e1a83cc85..b95f1039e1 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -62,17 +62,17 @@ static std::string DataPointNamesAsString(const Reading& reading) /** * OMFLinkedData constructor, generates the OMF message containing the data * + * @param payload The buffer into which to populate the payload * @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 + * @param delim Add a delimiter before outputting anything * */ -string OMFLinkedData::processReading(const Reading& reading, const string& AFHierarchyPrefix, OMFHints *hints) +bool OMFLinkedData::processReading(OMFBuffer& payload, bool delim, const Reading& reading, const string& AFHierarchyPrefix, OMFHints *hints) { - string outData; + bool rval = false; bool changed; - int reserved = RESERVE_INCREMENT * 2; - outData.reserve(reserved); string assetName = reading.getAssetName(); @@ -110,7 +110,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi assetName = OMF::ApplyPIServerNamingRulesObj(assetName, NULL); - bool needDelim = false; + bool needDelim = delim; auto assetLookup = m_linkedAssetState->find(originalAssetName + "."); if (assetLookup == m_linkedAssetState->end()) { @@ -120,11 +120,14 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi } if (m_sendFullStructure && assetLookup->second.assetState(assetName) == false) { + if (needDelim) + payload.append(','); // Send the data message to create the asset instance - outData.append("{ \"typeid\":\"FledgeAsset\", \"values\":[ { \"AssetId\":\""); - outData.append(assetName + "\",\"Name\":\""); - outData.append(assetName + "\""); - outData.append("} ] }"); + payload.append("{ \"typeid\":\"FledgeAsset\", \"values\":[ { \"AssetId\":\""); + payload.append(assetName + "\",\"Name\":\""); + payload.append(assetName + "\""); + payload.append("} ] }"); + rval = true; needDelim = true; assetLookup->second.assetSent(assetName); } @@ -136,11 +139,6 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi for (vector::const_iterator it = data.begin(); it != data.end(); ++it) { 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) { @@ -157,7 +155,7 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi { if (needDelim) { - outData.append(","); + payload.append(','); } else { @@ -224,29 +222,32 @@ string OMFLinkedData::processReading(const Reading& reading, const string& AFHi } if (m_sendFullStructure && dpLookup->second.linkState(assetName) == false) { - 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("\" } } ] },"); + payload.append("{ \"typeid\":\"__Link\","); + payload.append("\"values\":[ { \"source\" : {"); + payload.append("\"typeid\": \"FledgeAsset\","); + payload.append("\"index\":\"" + assetName); + payload.append("\" }, \"target\" : {"); + payload.append("\"containerid\" : \""); + payload.append(link); + payload.append("\" } } ] },"); + + rval = true; dpLookup->second.linkSent(assetName); } // Convert reading data into the OMF JSON string - outData.append("{\"containerid\": \"" + link); - outData.append("\", \"values\": [{"); + payload.append("{\"containerid\": \"" + link); + payload.append("\", \"values\": [{"); // Base type we are using for this data point - outData.append("\"" + baseType + "\": "); + payload.append("\"" + baseType + "\": "); // Add datapoint Value - outData.append(dp->getData().toString()); - outData.append(", "); + payload.append(dp->getData().toString()); + payload.append(", "); // Append Z to getAssetDateTime(FMT_STANDARD) - outData.append("\"Time\": \"" + reading.getAssetDateUserTime(Reading::FMT_STANDARD) + "Z" + "\""); - outData.append("} ] }"); + payload.append("\"Time\": \"" + reading.getAssetDateUserTime(Reading::FMT_STANDARD) + "Z" + "\""); + payload.append("} ] }"); + rval = true; } } if (skippedDatapoints.size() > 0) @@ -267,8 +268,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 messages %s", outData.c_str()); - return outData; + return rval; } /** diff --git a/C/plugins/north/OMF/omf.cpp b/C/plugins/north/OMF/omf.cpp index fa82b6e562..cb1cebdb55 100755 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -142,9 +142,8 @@ const char *AF_HIERARCHY_1LEVEL_LINK = QUOTE( * @param hints OMF hints for the specific reading for changing the behaviour of the operation * */ -OMFData::OMFData(const Reading& reading, string measurementId, const OMF_ENDPOINT PIServerEndpoint,const string& AFHierarchyPrefix, OMFHints *hints) +OMFData::OMFData(OMFBuffer& payload, const Reading& reading, string measurementId, bool delim, const OMF_ENDPOINT PIServerEndpoint,const string& AFHierarchyPrefix, OMFHints *hints) { - string outData; bool changed; Logger::getLogger()->debug("%s - measurementId :%s: ", __FUNCTION__, measurementId.c_str()); @@ -168,19 +167,11 @@ OMFData::OMFData(const Reading& reading, string measurementId, const OMF_ENDPOIN } } - // Convert reading data into the OMF JSON string - outData.append("{\"containerid\": \"" + measurementId); - outData.append("\", \"values\": [{"); - - // Get reading data const vector data = reading.getReadingData(); - unsigned long skipDatapoints = 0; - /** - * This loop creates: - * "dataName": {"type": "dataType"}, - */ + m_hasData = false; + // Check if there are any datapoints to send for (vector::const_iterator it = data.begin(); it != data.end(); ++it) { string dpName = (*it)->getName(); @@ -189,39 +180,56 @@ OMFData::OMFData(const Reading& reading, string measurementId, const OMF_ENDPOIN // Don't send the OMF Hint to the PI Server continue; } - if (!isTypeSupported((*it)->getData())) + if (isTypeSupported((*it)->getData())) { - skipDatapoints++;; - continue; + m_hasData = true; + break; } - else + } + + if (m_hasData) + { + if (delim) { - // Add datapoint Name - outData.append("\"" + OMF::ApplyPIServerNamingRulesObj(dpName, nullptr) + "\": " + (*it)->getData().toString()); - outData.append(", "); + payload.append(", "); } - } + // Convert reading data into the OMF JSON string + payload.append("{\"containerid\": \"" + measurementId); + payload.append("\", \"values\": [{"); - // Append Z to getAssetDateTime(FMT_STANDARD) - outData.append("\"Time\": \"" + reading.getAssetDateUserTime(Reading::FMT_STANDARD) + "Z" + "\""); - outData.append("}]}"); - // Append all, some or no datapoins - if (!skipDatapoints || - skipDatapoints < data.size()) - { - m_value.append(outData); + /** + * This loop creates: + * "dataName": {"type": "dataType"}, + */ + 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())) + { + continue; + } + else + { + // Add datapoint Name + payload.append("\"" + OMF::ApplyPIServerNamingRulesObj(dpName, nullptr) + "\": " + (*it)->getData().toString()); + payload.append(", "); + } + } + + // Append Z to getAssetDateTime(FMT_STANDARD) + payload.append("\"Time\": \"" + reading.getAssetDateUserTime(Reading::FMT_STANDARD) + "Z" + "\""); + + payload.append("}]}"); } } -/** - * Return the (reference) JSON data in m_value - */ -const string& OMFData::OMFdataVal() const -{ - return m_value; -} /** * OMF constructor @@ -1165,8 +1173,9 @@ uint32_t OMF::sendToServer(const vector& readings, linkedData.buildLookup(readings); bool pendingSeparator = false; - ostringstream jsonData; - jsonData << "["; + + OMFBuffer payload; + payload.append('['); // Fetch Reading* data for (vector::const_iterator elem = readings.begin(); elem != readings.end(); @@ -1258,7 +1267,7 @@ uint32_t OMF::sendToServer(const vector& readings, setAFHierarchy(); } - string outData; + // Use old style complex types if the user has forced it via configuration, // we are running against an EDS endpoint or Connector Relay or we have types defined for this // asset already @@ -1374,7 +1383,10 @@ uint32_t OMF::sendToServer(const vector& readings, measurementId = generateMeasurementId(m_assetName); - outData = OMFData(*reading, measurementId, m_PIServerEndpoint, AFHierarchyPrefix, hints ).OMFdataVal(); + if (OMFData(payload, *reading, measurementId, pendingSeparator, m_PIServerEndpoint, AFHierarchyPrefix, hints).hasData()) + { + pendingSeparator = true; + } } else { @@ -1382,7 +1394,8 @@ uint32_t OMF::sendToServer(const vector& readings, // in the processReading call auto lookup = m_linkedAssetState.find(m_assetName + "."); // Send data for this reading using the new mechanism - outData = linkedData.processReading(*reading, AFHierarchyPrefix, hints); + if (linkedData.processReading(payload, pendingSeparator, *reading, AFHierarchyPrefix, hints)) + pendingSeparator = true; if (m_sendFullStructure && lookup->second.afLinkState() == false) { // If the hierarchy has not already been sent then send it @@ -1399,17 +1412,12 @@ uint32_t OMF::sendToServer(const vector& readings, string af = createAFLinks(*reading, hints); if (! af.empty()) { - outData.append(","); - outData.append(af); + payload.append(','); + payload.append(af); } lookup->second.afLinkSent(); } } - if (!outData.empty()) - { - jsonData << (pendingSeparator ? ", " : "") << outData; - pendingSeparator = true; - } if (hints) { @@ -1424,15 +1432,11 @@ uint32_t OMF::sendToServer(const vector& readings, // Remove all assets supersetDataPoints OMF::unsetMapObjectTypes(m_SuperSetDataPoints); - jsonData << "]"; - - string json = jsonData.str(); - json_not_compressed = json; + payload.append(']'); - if (compression) - { - json = compress_string(json); - } + // TODO Improve this with coalesceCompressed call and avoid string on the stack + // and avoid copy into a string + const char *omfData = payload.coalesce(); #if INSTRUMENT gettimeofday(&t3, NULL); @@ -1462,7 +1466,7 @@ uint32_t OMF::sendToServer(const vector& readings, int res = m_sender.sendRequest("POST", m_path, readingData, - json); + compression ? compress_string(omfData) : omfData); if ( ! (res >= 200 && res <= 299) ) { Logger::getLogger()->error("Sending JSON readings , " @@ -1471,6 +1475,7 @@ uint32_t OMF::sendToServer(const vector& readings, m_sender.getHostPort().c_str(), m_path.c_str() ); + delete[] omfData; m_lastError = true; return 0; } @@ -1500,20 +1505,20 @@ uint32_t OMF::sendToServer(const vector& readings, timersub(&t5, &t4, &tm); timeT5 = tm.tv_sec + ((double)tm.tv_usec / 1000000); - 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| ", + 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", threadId.str().c_str(), timeT1, timeT2, timeT3, timeT4, readings.size(), - json_not_compressed.length(), - json.length() + strlen(omfData) ); #endif + delete[] omfData; // Return number of sent readings to the caller return readings.size(); } @@ -1586,6 +1591,7 @@ uint32_t OMF::sendToServer(const vector& readings, ); } + delete[] omfData; // Reset error indicator m_lastError = false; @@ -1602,6 +1608,7 @@ uint32_t OMF::sendToServer(const vector& readings, m_sender.getHostPort().c_str(), m_path.c_str() ); + delete[] omfData; } // Failure m_lastError = true; @@ -1619,6 +1626,7 @@ uint32_t OMF::sendToServer(const vector& readings, // Failure m_lastError = true; + delete[] omfData; return 0; } } @@ -1661,9 +1669,9 @@ uint32_t OMF::sendToServer(const vector& readings, * - transform a reading to OMF format * - add OMF data to new vector */ - ostringstream jsonData; string measurementId; - jsonData << "["; + OMFBuffer payload; + payload.append('['); // Fetch Reading data for (vector::const_iterator elem = readings.begin(); @@ -1701,19 +1709,22 @@ uint32_t OMF::sendToServer(const vector& readings, } // Add into JSON string the OMF transformed Reading data - jsonData << OMFData(*elem, measurementId, m_PIServerEndpoint, m_AFHierarchyLevel, hints).OMFdataVal() << (elem < (readings.end() -1 ) ? ", " : ""); + if (OMFData(payload, *elem, measurementId, false, m_PIServerEndpoint, m_AFHierarchyLevel, hints).hasData()) + if (elem < (readings.end() -1 )) + payload.append(','); } - jsonData << "]"; + payload.append(']'); // Build headers for Readings data vector> readingData = OMF::createMessageHeader("Data"); + const char *omfData = payload.coalesce(); // Build an HTTPS POST with 'readingData headers and 'allReadings' JSON payload // Then get HTTPS POST ret code and return 0 to client on error try { - int res = m_sender.sendRequest("POST", m_path, readingData, jsonData.str()); + int res = m_sender.sendRequest("POST", m_path, readingData, omfData); if ( ! (res >= 200 && res <= 299) ) { Logger::getLogger()->error("Sending JSON readings data " @@ -1721,8 +1732,9 @@ uint32_t OMF::sendToServer(const vector& readings, res, m_sender.getHostPort().c_str(), m_path.c_str(), - jsonData.str().c_str() ); + omfData); + delete[] omfData; m_lastError = true; return 0; } @@ -1734,10 +1746,12 @@ uint32_t OMF::sendToServer(const vector& readings, e.what(), m_sender.getHostPort().c_str(), m_path.c_str(), - jsonData.str().c_str() ); + omfData); + delete[] omfData; return false; } + delete[] omfData; m_lastError = false; @@ -1768,9 +1782,9 @@ uint32_t OMF::sendToServer(const Reading& reading, uint32_t OMF::sendToServer(const Reading* reading, bool skipSentDataTypes) { - ostringstream jsonData; string measurementId; - jsonData << "["; + OMFBuffer payload; + payload.append('['); m_assetName = ApplyPIServerNamingRulesObj(reading->getAssetName(), nullptr); @@ -1792,18 +1806,19 @@ uint32_t OMF::sendToServer(const Reading* reading, long typeId = OMF::getAssetTypeId(m_assetName); // Add into JSON string the OMF transformed Reading data - jsonData << OMFData(*reading, measurementId, m_PIServerEndpoint, m_AFHierarchyLevel, hints).OMFdataVal(); - jsonData << "]"; + OMFData(payload, *reading, measurementId, false, m_PIServerEndpoint, m_AFHierarchyLevel, hints); + payload.append(']'); // Build headers for Readings data vector> readingData = OMF::createMessageHeader("Data"); + const char *omfData = payload.coalesce(); // Build an HTTPS POST with 'readingData headers and 'allReadings' JSON payload // Then get HTTPS POST ret code and return 0 to client on error try { - int res = m_sender.sendRequest("POST", m_path, readingData, jsonData.str()); + int res = m_sender.sendRequest("POST", m_path, readingData, omfData); if ( ! (res >= 200 && res <= 299) ) { @@ -1812,7 +1827,8 @@ uint32_t OMF::sendToServer(const Reading* reading, res, m_sender.getHostPort().c_str(), m_path.c_str(), - jsonData.str().c_str() ); + omfData); + delete[] omfData; return 0; } @@ -1827,9 +1843,11 @@ uint32_t OMF::sendToServer(const Reading* reading, m_sender.getHostPort().c_str(), m_path.c_str() ); + delete[] omfData; return false; } + delete[] omfData; // Return number of sent readings to the caller return 1; } diff --git a/C/plugins/north/OMF/omfbuffer.cpp b/C/plugins/north/OMF/omfbuffer.cpp new file mode 100644 index 0000000000..4648330980 --- /dev/null +++ b/C/plugins/north/OMF/omfbuffer.cpp @@ -0,0 +1,347 @@ +/* + * Fledge OMF north plugin buffer class + * + * Copyright (c) 2023 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ +#include +#include +#include + +using namespace std; +/** + * Buffer class designed to hold OMF payloads that can + * as required but have minimal copy semantics. + */ + +/** + * OMFBuffer constructor + */ +OMFBuffer::OMFBuffer() +{ + buffers.push_front(new OMFBuffer::Buffer()); +} + +/** + * OMFBuffer destructor + */ +OMFBuffer::~OMFBuffer() +{ + for (list::iterator it = buffers.begin(); it != buffers.end(); ++it) + { + delete *it; + } +} + +/** + * Clear all the buffers from the OMFBuffer and allow it to be reused + */ +void OMFBuffer::clear() +{ + for (list::iterator it = buffers.begin(); it != buffers.end(); ++it) + { + delete *it; + } + buffers.clear(); + buffers.push_front(new OMFBuffer::Buffer()); +} + +/** + * Append a character to a buffer + * + * @param data The character to append to the buffer + */ +void OMFBuffer::append(const char data) +{ +OMFBuffer::Buffer *buffer = buffers.back(); + + if (buffer->offset == buffer->length) + { + buffer = new OMFBuffer::Buffer(); + buffers.push_back(buffer); + } + buffer->data[buffer->offset] = data; + buffer->data[buffer->offset + 1] = 0; + buffer->offset++; +} + +/** + * Append a character string to a buffer + * + * @para data The string to append to the buffer + */ +void OMFBuffer::append(const char *data) +{ +unsigned int len = strlen(data); +OMFBuffer::Buffer *buffer = buffers.back(); + + if (buffer->offset + len >= buffer->length) + { + if (len > BUFFER_CHUNK) + { + buffer = new OMFBuffer::Buffer(len + BUFFER_CHUNK); + } + else + { + buffer = new OMFBuffer::Buffer(); + } + buffers.push_back(buffer); + } + memcpy(&buffer->data[buffer->offset], data, len); + buffer->offset += len; + buffer->data[buffer->offset] = 0; +} + +/** + * Append an integer to a buffer + * + * @param value The value to append to the buffer + */ +void OMFBuffer::append(const int value) +{ +char tmpbuf[80]; +unsigned int len; +OMFBuffer::Buffer *buffer = buffers.back(); + + len = (unsigned int)snprintf(tmpbuf, 80, "%d", value); + if (buffer->offset + len >= buffer->length) + { + buffer = new OMFBuffer::Buffer(); + buffers.push_back(buffer); + } + memcpy(&buffer->data[buffer->offset], tmpbuf, len); + buffer->offset += len; + buffer->data[buffer->offset] = 0; +} + +/** + * Append a long to a buffer + * + * @param value The long value to append to the buffer + */ +void OMFBuffer::append(const long value) +{ +char tmpbuf[80]; +unsigned int len; +OMFBuffer::Buffer *buffer = buffers.back(); + + len = (unsigned int)snprintf(tmpbuf, 80, "%ld", value); + if (buffer->offset + len >= buffer->length) + { + buffer = new OMFBuffer::Buffer(); + buffers.push_back(buffer); + } + memcpy(&buffer->data[buffer->offset], tmpbuf, len); + buffer->offset += len; + buffer->data[buffer->offset] = 0; +} + +/** + * Append an unsigned integer to a buffer + * + * @param value The unsigned long value to append to the buffer + */ +void OMFBuffer::append(const unsigned int value) +{ +char tmpbuf[80]; +unsigned int len; +OMFBuffer::Buffer *buffer = buffers.back(); + + len = (unsigned int)snprintf(tmpbuf, 80, "%u", value); + if (buffer->offset + len >= buffer->length) + { + buffer = new OMFBuffer::Buffer(); + buffers.push_back(buffer); + } + memcpy(&buffer->data[buffer->offset], tmpbuf, len); + buffer->offset += len; + buffer->data[buffer->offset] = 0; +} + +/** + * Append an unsigned long to a buffer + * + * @param value The value to append to the buffer + */ +void OMFBuffer::append(const unsigned long value) +{ +char tmpbuf[80]; +unsigned int len; +OMFBuffer::Buffer *buffer = buffers.back(); + + len = (unsigned int)snprintf(tmpbuf, 80, "%lu", value); + if (buffer->offset + len >= buffer->length) + { + buffer = new OMFBuffer::Buffer(); + buffers.push_back(buffer); + } + memcpy(&buffer->data[buffer->offset], tmpbuf, len); + buffer->offset += len; + buffer->data[buffer->offset] = 0; +} + +/** + * Append a double to a buffer + * + * @param value The double value to append to the buffer + */ +void OMFBuffer::append(const double value) +{ +char tmpbuf[80]; +unsigned int len; +OMFBuffer::Buffer *buffer = buffers.back(); + + len = (unsigned int)snprintf(tmpbuf, 80, "%f", value); + if (buffer->offset + len >= buffer->length) + { + buffer = new OMFBuffer::Buffer(); + buffers.push_back(buffer); + } + memcpy(&buffer->data[buffer->offset], tmpbuf, len); + buffer->offset += len; + buffer->data[buffer->offset] = 0; +} + +/** + * Append a string to a buffer + * + * @param str The string to be appended to the buffer + */ +void OMFBuffer::append(const string& str) +{ +const char *cstr = str.c_str(); +unsigned int len = strlen(cstr); +OMFBuffer::Buffer *buffer = buffers.back(); + + if (buffer->offset + len >= buffer->length) + { + if (len > BUFFER_CHUNK) + { + buffer = new OMFBuffer::Buffer(len + BUFFER_CHUNK); + } + else + { + buffer = new OMFBuffer::Buffer(); + } + buffers.push_back(buffer); + } + memcpy(&buffer->data[buffer->offset], cstr, len); + buffer->offset += len; + buffer->data[buffer->offset] = 0; +} + +/** + * Quote and append a string to a buffer + * + * @param str The string to quote and append to the buffer + */ +void OMFBuffer::quote(const string& str) +{ +string esc = str; +StringEscapeQuotes(esc); +const char *cstr = esc.c_str(); +unsigned int len = strlen(cstr) + 2; +OMFBuffer::Buffer *buffer = buffers.back(); + + if (buffer->offset + len >= buffer->length) + { + if (len > BUFFER_CHUNK) + { + buffer = new OMFBuffer::Buffer(len + BUFFER_CHUNK); + } + else + { + buffer = new OMFBuffer::Buffer(); + } + buffers.push_back(buffer); + } + buffer->data[buffer->offset] = '"'; + memcpy(&buffer->data[buffer->offset + 1], cstr, len - 2); + buffer->data[buffer->offset + len - 1] = '"'; + buffer->offset += len; + buffer->data[buffer->offset] = 0; +} + +/** + * Create a coalesced buffer from the buffer chain + * + * The buffer returned has been created using the new[] operator and must be + * deleted by the caller. + * @return char* The OMF payload in a single buffer + */ +const char *OMFBuffer::coalesce() +{ +unsigned int length = 0, offset = 0; +char *buffer = 0; + + if (buffers.size() == 1) + { + return buffers.back()->detach(); + } + for (list::iterator it = buffers.begin(); it != buffers.end(); ++it) + { + length += (*it)->offset; + } + buffer = new char[length+1]; + for (list::iterator it = buffers.begin(); it != buffers.end(); ++it) + { + memcpy(&buffer[offset], (*it)->data, (*it)->offset); + offset += (*it)->offset; + } + buffer[offset] = 0; + return buffer; +} + +/** + * Construct a buffer with a standard size initial buffer. + */ +OMFBuffer::Buffer::Buffer() : offset(0), length(BUFFER_CHUNK), attached(true) +{ + data = new char[BUFFER_CHUNK+1]; + data[0] = 0; +} + +/** + * Construct a large buffer, passing the size of buffer required. This is useful + * if you know your buffer requirements are large and you wish to reduce the amount + * of allocation required. + * + * @param size The size of the initial buffer to allocate. + */ +OMFBuffer::Buffer::Buffer(unsigned int size) : offset(0), length(size), attached(true) +{ + data = new char[size+1]; + data[0] = 0; +} + +/** + * Buffer destructor, the buffer itself is also deleted by this + * call and any reference to it must no longer be used. + */ +OMFBuffer::Buffer::~Buffer() +{ + if (attached) + { + delete[] data; + data = 0; + } +} + +/** + * Detach the buffer from the OMFBuffer. The reference to the buffer + * is removed from the OMFBuffer but the buffer itself is not deleted. + * This allows the buffer ownership to be taken by external code + * whilst allowing the OMFBuffer to allocate a new buffer. + */ +char *OMFBuffer::Buffer::detach() +{ +char *rval = data; + + attached = false; + length = 0; + data = 0; + return rval; +} diff --git a/tests/unit/C/CMakeLists.txt b/tests/unit/C/CMakeLists.txt index 53ba41dd3a..f4c39c6461 100644 --- a/tests/unit/C/CMakeLists.txt +++ b/tests/unit/C/CMakeLists.txt @@ -95,6 +95,7 @@ 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/omfbuffer.cpp ../../../C/plugins/north/OMF/omfhints.cpp ../../../C/plugins/north/OMF/OMFError.cpp ../../../C/plugins/north/OMF/linkdata.cpp) diff --git a/tests/unit/C/plugins/common/test_omf_translation.cpp b/tests/unit/C/plugins/common/test_omf_translation.cpp index 90eecc6065..698e7a140b 100644 --- a/tests/unit/C/plugins/common/test_omf_translation.cpp +++ b/tests/unit/C/plugins/common/test_omf_translation.cpp @@ -5,6 +5,8 @@ #include #include #include +#include + /* * Fledge Readings to OMF translation unit tests * @@ -240,9 +242,10 @@ TEST(OMF_transation, TwoTranslationsCompareResult) // Build a ReadingSet from JSON ReadingSet readingSet(two_readings); - ostringstream jsonData; - jsonData << "["; + OMFBuffer payload; + payload.append("["); + bool sep = false; // Iterate over Readings via readingSet.getAllReadings() for (vector::const_iterator elem = readingSet.getAllReadings().begin(); elem != readingSet.getAllReadings().end(); @@ -251,13 +254,17 @@ TEST(OMF_transation, TwoTranslationsCompareResult) measurementId = to_string(TYPE_ID) + "measurement_luxometer"; // Add into JSON string the OMF transformed Reading data - jsonData << OMFData(**elem, measurementId).OMFdataVal() << (elem < (readingSet.getAllReadings().end() - 1 ) ? ", " : ""); + if (OMFData(payload, **elem, measurementId, sep).hasData()) + sep = true; } - jsonData << "]"; + payload.append("]"); + const char *data = payload.coalesce(); + string json(data); + delete[] data; // Compare translation - ASSERT_EQ(0, jsonData.str().compare(two_translated_readings)); + ASSERT_EQ(0, json.compare(two_translated_readings)); } // Create ONE reading, convert it and run checks @@ -265,7 +272,6 @@ TEST(OMF_transation, OneReading) { string measurementId; - ostringstream jsonData; string strVal("printer"); DatapointValue value(strVal); // ONE reading @@ -277,17 +283,22 @@ TEST(OMF_transation, OneReading) measurementId = "dummy"; + OMFBuffer payload; // Create the OMF Json data - jsonData << "["; - jsonData << OMFData(lab, measurementId).OMFdataVal(); - jsonData << "]"; + payload.append("["); + OMFData(payload, lab, measurementId, false); + payload.append("]"); + + const char *data = payload.coalesce(); + string json(data); + delete[] data; // "values" key is in the output - ASSERT_NE(jsonData.str().find(string("\"values\" : { ")), 0); + ASSERT_NE(json.find(string("\"values\" : { ")), 0); // Parse JSON of translated data Document doc; - doc.Parse(jsonData.str().c_str()); + doc.Parse(json.c_str()); if (doc.HasParseError()) { ASSERT_FALSE(true); @@ -349,8 +360,8 @@ TEST(OMF_transation, AllReadingsWithUnsupportedTypes) // Build a ReadingSet from JSON ReadingSet readingSet(all_readings_with_unsupported_datapoints_types); - ostringstream jsonData; - jsonData << "["; + OMFBuffer payload; + payload.append("["); bool pendingSeparator = false; // Iterate over Readings via readingSet.getAllReadings() @@ -360,19 +371,19 @@ TEST(OMF_transation, AllReadingsWithUnsupportedTypes) { measurementId = "dummy"; - string rData = OMFData(**elem, measurementId).OMFdataVal(); - // Add into JSON string the OMF transformed Reading data - if (!rData.empty()) - { - jsonData << (pendingSeparator ? ", " : "") << rData; + if (OMFData(payload, **elem, measurementId, pendingSeparator).hasData()) pendingSeparator = true; - } + // Add into JSON string the OMF transformed Reading data } - jsonData << "]"; + payload.append("]"); + + const char *data = payload.coalesce(); + string json(data); + delete[] data; Document doc; - doc.Parse(jsonData.str().c_str()); + doc.Parse(json.c_str()); if (doc.HasParseError()) { ASSERT_FALSE(true); @@ -394,8 +405,8 @@ TEST(OMF_transation, ReadingsWithUnsupportedTypes) // Build a ReadingSet from JSON ReadingSet readingSet(readings_with_unsupported_datapoints_types); - ostringstream jsonData; - jsonData << "["; + OMFBuffer payload; + payload.append("["); bool pendingSeparator = false; // Iterate over Readings via readingSet.getAllReadings() @@ -405,21 +416,19 @@ TEST(OMF_transation, ReadingsWithUnsupportedTypes) { measurementId = "dummy"; - string rData = OMFData(**elem, measurementId).OMFdataVal(); - // Add into JSON string the OMF transformed Reading data - if (!rData.empty()) - { - jsonData << (pendingSeparator ? ", " : "") << rData; + if (OMFData(payload, **elem, measurementId, pendingSeparator).hasData()) pendingSeparator = true; - } + // Add into JSON string the OMF transformed Reading data } - jsonData << "]"; + payload.append("]"); + const char *data = payload.coalesce(); Document doc; - doc.Parse(jsonData.str().c_str()); + doc.Parse(data); if (doc.HasParseError()) { + cout << data << "\n"; ASSERT_FALSE(true); } else @@ -429,6 +438,7 @@ TEST(OMF_transation, ReadingsWithUnsupportedTypes) // Array size is 1 ASSERT_EQ(doc.Size(), 2); } + delete[] data; } // Test the Asset Framework hierarchy fucntionlities diff --git a/tests/unit/C/plugins/common/test_omf_translation_piwebapi.cpp b/tests/unit/C/plugins/common/test_omf_translation_piwebapi.cpp index d72a1e7fa7..d5b467ea58 100644 --- a/tests/unit/C/plugins/common/test_omf_translation_piwebapi.cpp +++ b/tests/unit/C/plugins/common/test_omf_translation_piwebapi.cpp @@ -65,10 +65,11 @@ TEST(PIWEBAPI_OMF_transation, TwoTranslationsCompareResult) // Build a ReadingSet from JSON ReadingSet readingSet(pi_web_api_two_readings); - ostringstream jsonData; - jsonData << "["; + OMFBuffer payload; + payload.append('['); const OMF_ENDPOINT PI_SERVER_END_POINT = ENDPOINT_PIWEB_API; + bool sep = false; // Iterate over Readings via readingSet.getAllReadings() for (vector::const_iterator elem = readingSet.getAllReadings().begin(); @@ -76,13 +77,15 @@ TEST(PIWEBAPI_OMF_transation, TwoTranslationsCompareResult) ++elem) { // Add into JSON string the OMF transformed Reading data - jsonData << OMFData(**elem, CONTAINER_ID, PI_SERVER_END_POINT, AF_HIERARCHY_1LEVEL).OMFdataVal() << (elem < (readingSet.getAllReadings().end() - 1 ) ? ", " : ""); + sep = OMFData(payload, **elem, CONTAINER_ID, sep, PI_SERVER_END_POINT, AF_HIERARCHY_1LEVEL).hasData(); } - jsonData << "]"; + payload.append(']'); + const char *buf = payload.coalesce(); // Compare translation - ASSERT_EQ(jsonData.str(), pi_web_api_two_translated_readings); + ASSERT_STREQ(buf, pi_web_api_two_translated_readings); + delete[] buf; } From 0f78341e58ba4b37b117196819c2bc1f40381c09 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 25 Jan 2024 15:36:56 +0530 Subject: [PATCH 046/146] control pipeline filter order issue fixes Signed-off-by: ashish-jabble --- .../fledge/services/core/api/control_service/pipeline.py | 8 +++----- 1 file changed, 3 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 ffd932aa26..9b4b44593d 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -593,11 +593,9 @@ async def _update_filters(storage, cp_id, cp_name, cp_filters, db_filters=None): cf_mgr = ConfigurationManager(storage) new_filters = [] children = [] - - insert_filters = set(cp_filters) - set(db_filters) - update_filters = set(cp_filters) & set(db_filters) - delete_filters = set(db_filters) - set(cp_filters) - + insert_filters = list(filter(lambda x: x not in db_filters, cp_filters)) + update_filters = list(filter(lambda x: x in cp_filters, db_filters)) + delete_filters = list(filter(lambda x: x not in cp_filters, db_filters)) if insert_filters: for fid, fname in enumerate(insert_filters, start=1): # get plugin config of filter From f4cda34eb44814ba426a59d28609aefa4c83e16f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 25 Jan 2024 15:38:14 +0530 Subject: [PATCH 047/146] wait time increased in API service test when restarting a fledge as per startup ordering of services with delay Signed-off-by: ashish-jabble --- tests/system/python/api/test_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/python/api/test_service.py b/tests/system/python/api/test_service.py index 9eb38d1898..e6e463d476 100644 --- a/tests/system/python/api/test_service.py +++ b/tests/system/python/api/test_service.py @@ -234,7 +234,7 @@ def test_service_on_restart(self, fledge_url, wait_time): assert len(jdoc), "No data found" assert 'Fledge restart has been scheduled.' == jdoc['message'] - time.sleep(wait_time * 4) + time.sleep(wait_time * 7) jdoc = get_service(fledge_url, '/fledge/service') assert len(jdoc), "No data found" assert 4 == len(jdoc['services']) From 774dbfe6c09ad26b00d7aa35c3f523dfadfc09f3 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 30 Jan 2024 10:38:05 +0000 Subject: [PATCH 048/146] FOGL-8454 Support deleet with no payload in storage registry (#1278) Signed-off-by: Mark Riddoch --- C/services/storage/storage_registry.cpp | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/C/services/storage/storage_registry.cpp b/C/services/storage/storage_registry.cpp index f97798c2e4..8e0db3ab98 100644 --- a/C/services/storage/storage_registry.cpp +++ b/C/services/storage/storage_registry.cpp @@ -868,12 +868,20 @@ void StorageRegistry::processDelete(char *tableName, char *payload) { Document doc; + bool allRows = false; - doc.Parse(payload); - if (doc.HasParseError()) + if (! *payload) // Empty { - Logger::getLogger()->error("Unable to parse table delete payload for table %s, request is %s", tableName, payload); - return; + allRows = true; + } + else + { + 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); @@ -889,7 +897,11 @@ StorageRegistry::processDelete(char *tableName, char *payload) { continue; } - if (tblreg->key.empty()) + if (allRows) + { + sendPayload(tblreg->url, payload); + } + else if (tblreg->key.empty()) { // No key to match, send all updates to table sendPayload(tblreg->url, payload); From 57261c09502b4d847dfc53d02273b1d753b8b471 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 1 Feb 2024 16:34:39 +0530 Subject: [PATCH 049/146] more bad cases handling added for POST ACL Signed-off-by: ashish-jabble --- .../api/control_service/acl_management.py | 121 +++++++++++++----- .../control_service/test_acl_management.py | 30 ++++- 2 files changed, 116 insertions(+), 35 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 8f3361098b..fa077cc41d 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -17,13 +17,11 @@ from fledge.services.core import connect from fledge.services.core.api.control_service.exceptions import * - __author__ = "Ashish Jabble, Massimiliano Pinto" __copyright__ = "Copyright (c) 2021 Dianomic Systems Inc." __license__ = "Apache 2.0" __version__ = "${VERSION}" - _help = """ -------------------------------------------------------------- | GET POST | /fledge/ACL | @@ -91,28 +89,10 @@ async def add_acl(request: web.Request) -> web.Response: """ try: data = await request.json() - name = data.get('name', None) - service = data.get('service', None) - url = data.get('url', None) - if name is None: - raise ValueError('ACL name is required.') - else: - if not isinstance(name, str): - raise TypeError('ACL name must be a string.') - name = name.strip() - if name == "": - raise ValueError('ACL name cannot be empty.') - if service is None: - raise ValueError('service parameter is required.') - if not isinstance(service, list): - raise TypeError('service must be a list.') - # check each item in list is an object of, either 'type'| or 'name'| value pair - if url is None: - raise ValueError('url parameter is required.') - if not isinstance(url, list): - raise TypeError('url must be a list.') - # check URLs list has objects with URL and a list of ACL where each acl item here is an object of - # 'type'| value pair + columns = await _check_params(data) + name = columns['name'] + service = columns['service'] + url = columns['url'] result = {} storage = connect.get_storage_async() payload = PayloadBuilder().SELECT("name").WHERE(['name', '=', name]).payload() @@ -153,7 +133,7 @@ async def add_acl(request: web.Request) -> web.Response: @has_permission("admin") async def update_acl(request: web.Request) -> web.Response: """ Update an access control list - Only the service and URL parameters can be updated. + Only the service and URL parameters can be updated. :Example: curl -H "authorization: $AUTH_TOKEN" -sX PUT http://localhost:8081/fledge/ACL/testACL -d '{"service": [{"name": "Sinusoid"}]}' @@ -162,7 +142,7 @@ async def update_acl(request: web.Request) -> web.Response: """ try: name = request.match_info.get('acl_name', None) - + data = await request.json() service = data.get('service', None) url = data.get('url', None) @@ -185,7 +165,7 @@ async def update_acl(request: web.Request) -> web.Response: set_values["service"] = json.dumps(service) if url is not None: 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: @@ -248,7 +228,7 @@ async def delete_acl(request: web.Request) -> web.Response: scripts = await acl_handler.get_all_entities_for_a_acl(name, "script") 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) + " Make sure to remove all the usages of this ACL.".format(name) _logger.warning(message) return web.HTTPConflict(reason=message, body=json.dumps({"message": message})) @@ -343,7 +323,7 @@ async def attach_acl_to_service(request: web.Request) -> web.Response: 'displayName': 'Service ACL', 'default': '' } - } + } # Create category content with ACL default set to '' await cf_mgr.create_category(category_name=security_cat_name, category_description=category_desc, category_value=category_value) @@ -367,7 +347,7 @@ async def attach_acl_to_service(request: web.Request) -> web.Response: # Call service security endpoint with attachACL = acl_name data = {'ACL': acl_name} await cf_mgr.update_configuration_item_bulk(security_cat_name, data) - + return web.json_response({"message": "ACL with name {} attached to {} service successfully.".format( acl_name, svc_name)}) @@ -408,10 +388,10 @@ async def detach_acl_from_service(request: web.Request) -> web.Response: , 'ACL': { - 'description': 'Service ACL for {}'.format(svc_name), - 'type': 'ACL', - 'displayName': 'Service ACL', - 'default': '' + 'description': 'Service ACL for {}'.format(svc_name), + 'type': 'ACL', + 'displayName': 'Service ACL', + 'default': '' } } # Call service security endpoint with detachACL = '' @@ -441,3 +421,76 @@ async def detach_acl_from_service(request: web.Request) -> web.Response: raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: return web.json_response({"message": message}) + +async def _check_params(data): + final = {} + name = data.get('name', None) + service = data.get('service', None) + url = data.get('url', None) + if name is None: + raise ValueError('ACL name is required.') + else: + if not isinstance(name, str): + raise TypeError('ACL name must be a string.') + name = name.strip() + if name == "": + raise ValueError('ACL name cannot be empty.') + final['name'] = name + if service is None: + raise ValueError('service parameter is required.') + if not isinstance(service, list): + raise TypeError('service must be a list.') + if service: + is_type_seen = False + is_name_seen = False + for s in service: + if not isinstance(s, dict): + raise TypeError("service elements must be an object.") + if not s: + raise ValueError('service object cannot be empty.') + if 'type' in list(s.keys()) and not is_type_seen: + if not isinstance(s['type'], str): + raise TypeError("Value must be a string for type key.") + s['type'] = s['type'].strip() + if s['type'] == "": + raise ValueError('Value cannot be empty for type key.') + is_type_seen = True + if 'name' in list(s.keys()) and not is_name_seen: + if not isinstance(s['name'], str): + raise TypeError("Value must be a string for name key.") + s['name'] = s['name'].strip() + if s['name'] == "": + raise ValueError('Value cannot be empty for name key.') + is_name_seen = True + if not is_type_seen and not is_name_seen: + raise ValueError('Either type or name Key-Value Pair is missing.') + final['service'] = service + if url is None: + raise ValueError('url parameter is required.') + if not isinstance(url, list): + raise TypeError('url must be a list.') + if url: + for u in url: + is_url_seen = False + if not isinstance(u, dict): + raise TypeError("url elements must be an object.") + if 'url' in u: + if not isinstance(u['url'], str): + raise TypeError("Value must be a string for url key.") + u['url'] = u['url'].strip() + if u['url'] == "": + raise ValueError('Value cannot be empty for url key.') + is_url_seen = True + if 'acl' in u: + if not isinstance(u['acl'], list): + raise TypeError("Value must be an array for acl key.") + if u['acl']: + for uacl in u['acl']: + if not isinstance(uacl, dict): + raise TypeError("acl elements must be an object.") + if not uacl: + raise ValueError('acl object cannot be empty.') + if not is_url_seen: + raise ValueError('url child Key-Value Pair is missing.') + final['url'] = url + return final 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 0953357de7..00f8dcae68 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 @@ -97,8 +97,36 @@ async def test_good_get_acl_by_name(self, client): ({"name": ""}, "ACL name cannot be empty."), ({"name": "test"}, "service parameter is required."), ({"name": "test", "service": 1}, "service must be a list."), + ({"name": "test", "service": [1]}, "service elements must be an object."), + ({"name": "test", "service": ["1"]}, "service elements must be an object."), + ({"name": "test", "service": ["1", {}]}, "service elements must be an object."), + ({"name": "test", "service": [{}]}, "service object cannot be empty."), + ({"name": "test", "service": [{"foo": "bar"}]}, "Either type or name Key-Value Pair is missing."), + ({"name": "test", "service": [{"type": 1}]}, "Value must be a string for type key."), + ({"name": "test", "service": [{"type": ""}]}, "Value cannot be empty for type key."), + ({"name": "test", "service": [{"name": 1}]}, "Value must be a string for name key."), + ({"name": "test", "service": [{"name": ""}]}, "Value cannot be empty for name key."), ({"name": "test", "service": []}, "url parameter is required."), - ({"name": "test", "service": [], "url": 1}, "url must be a list.") + ({"name": "test", "service": [], "url": 1}, "url must be a list."), + ({"name": "test", "service": [], "url": [{}]}, "url child Key-Value Pair is missing."), + ({"name": "test", "service": [], "url": [{"url": []}]}, "Value must be a string for url key."), + ({"name": "test", "service": [], "url": [{"url": ""}]}, "Value cannot be empty for url key."), + ({"name": "test", "service": [], "url": [{"acl": ""}]}, "Value must be an array for acl key."), + ({"name": "test", "service": [], "url": [{"acl": [1]}]}, "acl elements must be an object."), + ({"name": "test", "service": [], "url": [{"acl": [{}]}]}, "acl object cannot be empty."), + ({"name": "test", "service": [], "url": [{"acl": [{"type": "Core"}]}]}, "url child Key-Value Pair is missing."), + ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": ""}]}, + "Value must be an array for acl key."), + ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, 1]}, + "url elements must be an object."), + ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": []}]}, + "url child Key-Value Pair is missing."), + ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": ""}]}, + "Value must be an array for acl key."), + ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": [1]}]}, + "acl elements must be an object."), + ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": [{}]}]}, + "acl object cannot be empty.") ]) async def test_bad_add_acl(self, client, payload, message): resp = await client.post('/fledge/ACL', data=json.dumps(payload)) From ecf41de3e3eca0d2b5ad85f629bc46959fef0ea3 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 1 Feb 2024 19:18:27 +0530 Subject: [PATCH 050/146] more bad cases handling added for PUT ACL API Signed-off-by: ashish-jabble --- .../api/control_service/acl_management.py | 155 +++++++++--------- .../control_service/test_acl_management.py | 41 ++++- 2 files changed, 118 insertions(+), 78 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 fa077cc41d..e6b5daa2f6 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -89,7 +89,7 @@ async def add_acl(request: web.Request) -> web.Response: """ try: data = await request.json() - columns = await _check_params(data) + columns = await _check_params(data, action="POST") name = columns['name'] service = columns['service'] url = columns['url'] @@ -146,13 +146,7 @@ async def update_acl(request: web.Request) -> web.Response: data = await request.json() service = data.get('service', None) url = data.get('url', None) - if service is None and url is None: - raise ValueError("Nothing to update for the given payload.") - - if service is not None and not isinstance(service, list): - raise TypeError('service must be a list.') - if url is not None and not isinstance(url, list): - raise TypeError('url must be a list.') + await _check_params(data, action="PUT") storage = connect.get_storage_async() payload = PayloadBuilder().SELECT("name", "service", "url").WHERE(['name', '=', name]).payload() result = await storage.query_tbl_with_payload('control_acl', payload) @@ -422,75 +416,86 @@ async def detach_acl_from_service(request: web.Request) -> web.Response: else: return web.json_response({"message": message}) -async def _check_params(data): +async def _check_params(data, action): final = {} name = data.get('name', None) service = data.get('service', None) url = data.get('url', None) - if name is None: - raise ValueError('ACL name is required.') - else: - if not isinstance(name, str): - raise TypeError('ACL name must be a string.') - name = name.strip() - if name == "": - raise ValueError('ACL name cannot be empty.') - final['name'] = name - if service is None: - raise ValueError('service parameter is required.') - if not isinstance(service, list): - raise TypeError('service must be a list.') - if service: - is_type_seen = False - is_name_seen = False - for s in service: - if not isinstance(s, dict): - raise TypeError("service elements must be an object.") - if not s: - raise ValueError('service object cannot be empty.') - if 'type' in list(s.keys()) and not is_type_seen: - if not isinstance(s['type'], str): - raise TypeError("Value must be a string for type key.") - s['type'] = s['type'].strip() - if s['type'] == "": - raise ValueError('Value cannot be empty for type key.') - is_type_seen = True - if 'name' in list(s.keys()) and not is_name_seen: - if not isinstance(s['name'], str): - raise TypeError("Value must be a string for name key.") - s['name'] = s['name'].strip() - if s['name'] == "": - raise ValueError('Value cannot be empty for name key.') - is_name_seen = True - if not is_type_seen and not is_name_seen: - raise ValueError('Either type or name Key-Value Pair is missing.') - final['service'] = service - if url is None: - raise ValueError('url parameter is required.') - if not isinstance(url, list): - raise TypeError('url must be a list.') - if url: - for u in url: - is_url_seen = False - if not isinstance(u, dict): - raise TypeError("url elements must be an object.") - if 'url' in u: - if not isinstance(u['url'], str): - raise TypeError("Value must be a string for url key.") - u['url'] = u['url'].strip() - if u['url'] == "": - raise ValueError('Value cannot be empty for url key.') - is_url_seen = True - if 'acl' in u: - if not isinstance(u['acl'], list): - raise TypeError("Value must be an array for acl key.") - if u['acl']: - for uacl in u['acl']: - if not isinstance(uacl, dict): - raise TypeError("acl elements must be an object.") - if not uacl: - raise ValueError('acl object cannot be empty.') - if not is_url_seen: - raise ValueError('url child Key-Value Pair is missing.') - final['url'] = url + + if action == "PUT": + if service is None and url is None: + raise ValueError("Nothing to update for the given payload.") + + if action == "POST": + if name is None: + raise ValueError('ACL name is required.') + else: + if not isinstance(name, str): + raise TypeError('ACL name must be a string.') + name = name.strip() + if name == "": + raise ValueError('ACL name cannot be empty.') + final['name'] = name + if action == "POST": + if service is None: + raise ValueError('service parameter is required.') + if action == "POST" or (action == "PUT" and service is not None): + if not isinstance(service, list): + raise TypeError('service must be a list.') + if service: + is_type_seen = False + is_name_seen = False + for s in service: + if not isinstance(s, dict): + raise TypeError("service elements must be an object.") + if not s: + raise ValueError('service object cannot be empty.') + if 'type' in list(s.keys()) and not is_type_seen: + if not isinstance(s['type'], str): + raise TypeError("Value must be a string for type key.") + s['type'] = s['type'].strip() + if s['type'] == "": + raise ValueError('Value cannot be empty for type key.') + is_type_seen = True + if 'name' in list(s.keys()) and not is_name_seen: + if not isinstance(s['name'], str): + raise TypeError("Value must be a string for name key.") + s['name'] = s['name'].strip() + if s['name'] == "": + raise ValueError('Value cannot be empty for name key.') + is_name_seen = True + if not is_type_seen and not is_name_seen: + raise ValueError('Either type or name Key-Value Pair is missing.') + final['service'] = service + + if action == "POST": + if url is None: + raise ValueError('url parameter is required.') + if action == "POST" or (action == "PUT" and url is not None): + if not isinstance(url, list): + raise TypeError('url must be a list.') + if url: + for u in url: + is_url_seen = False + if not isinstance(u, dict): + raise TypeError("url elements must be an object.") + if 'url' in u: + if not isinstance(u['url'], str): + raise TypeError("Value must be a string for url key.") + u['url'] = u['url'].strip() + if u['url'] == "": + raise ValueError('Value cannot be empty for url key.') + is_url_seen = True + if 'acl' in u: + if not isinstance(u['acl'], list): + raise TypeError("Value must be an array for acl key.") + if u['acl']: + for uacl in u['acl']: + if not isinstance(uacl, dict): + raise TypeError("acl elements must be an object.") + if not uacl: + raise ValueError('acl object cannot be empty.') + if not is_url_seen: + raise ValueError('url child Key-Value Pair is missing.') + final['url'] = url return final 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 00f8dcae68..a1b6bfbee5 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 @@ -195,7 +195,42 @@ async def test_good_add_acl(self, client): @pytest.mark.parametrize("payload, message", [ ({}, "Nothing to update for the given payload."), ({"service": 1}, "service must be a list."), - ({"url": 1}, "url must be a list.") + ({"url": 1}, "url must be a list."), + ({"service": [1]}, "service elements must be an object."), + ({"service": ["1"]}, "service elements must be an object."), + ({"service": ["1", {}]}, "service elements must be an object."), + ({"service": [{}]}, "service object cannot be empty."), + ({"service": [{"foo": "bar"}]}, "Either type or name Key-Value Pair is missing."), + ({"service": [{"type": 1}]}, "Value must be a string for type key."), + ({"service": [{"type": ""}]}, "Value cannot be empty for type key."), + ({"service": [{"name": 1}]}, "Value must be a string for name key."), + ({"service": [{"name": ""}]}, "Value cannot be empty for name key."), + ({"url": 1}, "url must be a list."), + ({"url": [{}]}, "url child Key-Value Pair is missing."), + ({"url": [{"url": []}]}, "Value must be a string for url key."), + ({"url": [{"url": ""}]}, "Value cannot be empty for url key."), + ({"url": [{"acl": ""}]}, "Value must be an array for acl key."), + ({"url": [{"acl": [1]}]}, "acl elements must be an object."), + ({"url": [{"acl": [{}]}]}, "acl object cannot be empty."), + ({"url": [{"acl": [{"type": "Core"}]}]}, "url child Key-Value Pair is missing."), + ({"url": [{"url": "URI/write", "acl": ""}]}, "Value must be an array for acl key."), + ({"url": [{"url": "URI/write", "acl": []}, 1]}, "url elements must be an object."), + ({"url": [{"url": "URI/write", "acl": []}, {"acl": []}]}, "url child Key-Value Pair is missing."), + ({"url": [{"url": "URI/write", "acl": []}, {"acl": ""}]}, "Value must be an array for acl key."), + ({"url": [{"url": "URI/write", "acl": []}, {"acl": [1]}]}, "acl elements must be an object."), + ({"url": [{"url": "URI/write", "acl": []}, {"acl": [{}]}]}, "acl object cannot be empty."), + ({"service": [{"foo": "bar"}], "url": []}, "Either type or name Key-Value Pair is missing."), + ({"url": [], "service": [{}]}, "service object cannot be empty."), + ({"url": [], "service": [{"type": 1}]}, "Value must be a string for type key."), + ({"url": [], "service": [{"type": ""}]}, "Value cannot be empty for type key."), + ({"url": [], "service": [{"name": 1}]}, "Value must be a string for name key."), + ({"url": [], "service": [{"name": ""}]}, "Value cannot be empty for name key."), + ({"service": [], "url": 1}, "url must be a list."), + ({"service": [{}], "url": 1}, "service object cannot be empty."), + ({"service": [], "url": [{"url": "URI/write", "acl": ""}]}, "Value must be an array for acl key."), + ({"service": [], "url": [{"url": "", "acl": ""}]}, "Value cannot be empty for url key."), + ({"service": [], "url": [{"blah": "", "acl": []}]}, "url child Key-Value Pair is missing."), + ({"service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": [{}]}]}, "acl object cannot be empty.") ]) async def test_bad_update_acl(self, client, payload, message): acl_name = "testACL" @@ -229,10 +264,10 @@ async def test_update_acl_not_found(self, client): @pytest.mark.parametrize("payload", [ {"service": []}, - {"service": [{"service": [{"name": "Sinusoid"}, {"type": "Southbound"}]}]}, + {"service": [{"name": "Sinusoid"}, {"type": "Southbound"}]}, {"service": [], "url": []}, {"service": [], "url": [{"url": "/fledge/south/operation", "acl": [{"type": "Southbound"}]}]}, - {"service": [{"service": [{"name": "Sinusoid"}, {"type": "Southbound"}]}], + {"service": [{"name": "Sinusoid"}, {"type": "Southbound"}], "url": [{"url": "/fledge/south/operation", "acl": [{"type": "Southbound"}]}]} ]) async def test_update_acl(self, client, payload): From bdaf42a6a5e5e66380dc0395dfa61e8aa787e85e Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 2 Feb 2024 13:06:01 +0530 Subject: [PATCH 051/146] service empty list case handling Signed-off-by: ashish-jabble --- .../api/control_service/acl_management.py | 74 +++++++----- .../control_service/test_acl_management.py | 106 +++++++++--------- 2 files changed, 99 insertions(+), 81 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 e6b5daa2f6..93252b4e20 100644 --- a/python/fledge/services/core/api/control_service/acl_management.py +++ b/python/fledge/services/core/api/control_service/acl_management.py @@ -86,6 +86,14 @@ async def add_acl(request: web.Request) -> web.Response: curl -H "authorization: $AUTH_TOKEN" -sX POST http://localhost:8081/fledge/ACL -d '{"name": "testACL", "service": [{"name": "IEC-104"}, {"type": "notification"}], "url": [{"url": "/fledge/south/operation", "acl": [{"type": "Northbound"}]}]}' + curl -H "authorization: $AUTH_TOKEN" -sX POST http://localhost:8081/fledge/ACL -d '{"name": "testACL-2", + "service": [{"name": "IEC-104"}], "url": []}' + curl -H "authorization: $AUTH_TOKEN" -sX POST http://localhost:8081/fledge/ACL -d '{"name": "testACL-3", + "service": [{"type": "Notification"}], "url": []}' + curl -H "authorization: $AUTH_TOKEN" -sX POST http://localhost:8081/fledge/ACL -d '{"name": "testACL-4", + "service": [{"name": "IEC-104"}, {"type": "notification"}], "url": [{"url": "/fledge/south/operation", + "acl": [{"type": "Northbound"}]}, {"url": "/fledge/south/write", + "acl": [{"type": "Northbound"}, {"type": "Southbound"}]}]}' """ try: data = await request.json() @@ -136,9 +144,12 @@ async def update_acl(request: web.Request) -> web.Response: Only the service and URL parameters can be updated. :Example: - curl -H "authorization: $AUTH_TOKEN" -sX PUT http://localhost:8081/fledge/ACL/testACL -d '{"service": [{"name": "Sinusoid"}]}' - curl -H "authorization: $AUTH_TOKEN" -sX PUT http://localhost:8081/fledge/ACL/testACL -d '{"service": [], - "url": [{"url": "/fledge/south/operation", "acl": [{"type": "Southbound"}]}]}' + curl -H "authorization: $AUTH_TOKEN" -sX PUT http://localhost:8081/fledge/ACL/testACL + -d '{"service": [{"name": "Sinusoid"}]}' + curl -H "authorization: $AUTH_TOKEN" -sX PUT http://localhost:8081/fledge/ACL/testACL + -d '{"url": [{"url": "/fledge/south/write", "acl": []}]}' + curl -H "authorization: $AUTH_TOKEN" -sX PUT http://localhost:8081/fledge/ACL/testACL + -d '{"service": [{"type": "core"}], "url": [{"url": "/fledge/south/write", "acl": [{"type": "Northbound"}]}]}' """ try: name = request.match_info.get('acl_name', None) @@ -442,31 +453,32 @@ async def _check_params(data, action): if action == "POST" or (action == "PUT" and service is not None): if not isinstance(service, list): raise TypeError('service must be a list.') - if service: - is_type_seen = False - is_name_seen = False - for s in service: - if not isinstance(s, dict): - raise TypeError("service elements must be an object.") - if not s: - raise ValueError('service object cannot be empty.') - if 'type' in list(s.keys()) and not is_type_seen: - if not isinstance(s['type'], str): - raise TypeError("Value must be a string for type key.") - s['type'] = s['type'].strip() - if s['type'] == "": - raise ValueError('Value cannot be empty for type key.') - is_type_seen = True - if 'name' in list(s.keys()) and not is_name_seen: - if not isinstance(s['name'], str): - raise TypeError("Value must be a string for name key.") - s['name'] = s['name'].strip() - if s['name'] == "": - raise ValueError('Value cannot be empty for name key.') - is_name_seen = True - if not is_type_seen and not is_name_seen: - raise ValueError('Either type or name Key-Value Pair is missing.') - final['service'] = service + if not service: + raise ValueError('service list cannot be empty.') + is_type_seen = False + is_name_seen = False + for s in service: + if not isinstance(s, dict): + raise TypeError("service elements must be an object.") + if not s: + raise ValueError('service object cannot be empty.') + if 'type' in list(s.keys()) and not is_type_seen: + if not isinstance(s['type'], str): + raise TypeError("Value must be a string for service type.") + s['type'] = s['type'].strip() + if s['type'] == "": + raise ValueError('Value cannot be empty for service type.') + is_type_seen = True + if 'name' in list(s.keys()) and not is_name_seen: + if not isinstance(s['name'], str): + raise TypeError("Value must be a string for service name.") + s['name'] = s['name'].strip() + if s['name'] == "": + raise ValueError('Value cannot be empty for service name.') + is_name_seen = True + if not is_type_seen and not is_name_seen: + raise ValueError('Either type or name Key-Value Pair is missing for service.') + final['service'] = service if action == "POST": if url is None: @@ -481,14 +493,14 @@ async def _check_params(data, action): raise TypeError("url elements must be an object.") if 'url' in u: if not isinstance(u['url'], str): - raise TypeError("Value must be a string for url key.") + raise TypeError("Value must be a string for url object.") u['url'] = u['url'].strip() if u['url'] == "": - raise ValueError('Value cannot be empty for url key.') + raise ValueError('Value cannot be empty for url object.') is_url_seen = True if 'acl' in u: if not isinstance(u['acl'], list): - raise TypeError("Value must be an array for acl key.") + raise TypeError("Value must be an array for acl object.") if u['acl']: for uacl in u['acl']: if not isinstance(uacl, dict): 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 a1b6bfbee5..793ac54cd3 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 @@ -97,35 +97,37 @@ async def test_good_get_acl_by_name(self, client): ({"name": ""}, "ACL name cannot be empty."), ({"name": "test"}, "service parameter is required."), ({"name": "test", "service": 1}, "service must be a list."), + ({"name": "test", "service": []}, "service list cannot be empty."), ({"name": "test", "service": [1]}, "service elements must be an object."), ({"name": "test", "service": ["1"]}, "service elements must be an object."), ({"name": "test", "service": ["1", {}]}, "service elements must be an object."), ({"name": "test", "service": [{}]}, "service object cannot be empty."), - ({"name": "test", "service": [{"foo": "bar"}]}, "Either type or name Key-Value Pair is missing."), - ({"name": "test", "service": [{"type": 1}]}, "Value must be a string for type key."), - ({"name": "test", "service": [{"type": ""}]}, "Value cannot be empty for type key."), - ({"name": "test", "service": [{"name": 1}]}, "Value must be a string for name key."), - ({"name": "test", "service": [{"name": ""}]}, "Value cannot be empty for name key."), - ({"name": "test", "service": []}, "url parameter is required."), - ({"name": "test", "service": [], "url": 1}, "url must be a list."), - ({"name": "test", "service": [], "url": [{}]}, "url child Key-Value Pair is missing."), - ({"name": "test", "service": [], "url": [{"url": []}]}, "Value must be a string for url key."), - ({"name": "test", "service": [], "url": [{"url": ""}]}, "Value cannot be empty for url key."), - ({"name": "test", "service": [], "url": [{"acl": ""}]}, "Value must be an array for acl key."), - ({"name": "test", "service": [], "url": [{"acl": [1]}]}, "acl elements must be an object."), - ({"name": "test", "service": [], "url": [{"acl": [{}]}]}, "acl object cannot be empty."), - ({"name": "test", "service": [], "url": [{"acl": [{"type": "Core"}]}]}, "url child Key-Value Pair is missing."), - ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": ""}]}, - "Value must be an array for acl key."), - ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, 1]}, + ({"name": "test", "service": [{"foo": "bar"}]}, "Either type or name Key-Value Pair is missing for service."), + ({"name": "test", "service": [{"type": 1}]}, "Value must be a string for service type."), + ({"name": "test", "service": [{"type": ""}]}, "Value cannot be empty for service type."), + ({"name": "test", "service": [{"name": 1}]}, "Value must be a string for service name."), + ({"name": "test", "service": [{"name": ""}]}, "Value cannot be empty for service name."), + ({"name": "test", "service": [{"type": "T1"}]}, "url parameter is required."), + ({"name": "test", "service": [{"type": "T1"}], "url": 1}, "url must be a list."), + ({"name": "test", "service": [{"type": "T1"}], "url": [{}]}, "url child Key-Value Pair is missing."), + ({"name": "test", "service": [{"type": "T1"}], "url": [{"url": []}]}, "Value must be a string for url object."), + ({"name": "test", "service": [{"type": "T1"}], "url": [{"url": ""}]}, "Value cannot be empty for url object."), + ({"name": "test", "service": [{"type": "T1"}], "url": [{"acl": ""}]}, "Value must be an array for acl object."), + ({"name": "test", "service": [{"type": "T1"}], "url": [{"acl": [1]}]}, "acl elements must be an object."), + ({"name": "test", "service": [{"type": "T1"}], "url": [{"acl": [{}]}]}, "acl object cannot be empty."), + ({"name": "test", "service": [{"type": "T1"}], "url": [{"acl": [{"type": "Core"}]}]}, + "url child Key-Value Pair is missing."), + ({"name": "test", "service": [{"type": "T1"}], "url": [{"url": "URI/write", "acl": ""}]}, + "Value must be an array for acl object."), + ({"name": "test", "service": [{"type": "T1"}], "url": [{"url": "URI/write", "acl": []}, 1]}, "url elements must be an object."), - ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": []}]}, + ({"name": "test", "service": [{"name": "S1"}], "url": [{"url": "URI/write", "acl": []}, {"acl": []}]}, "url child Key-Value Pair is missing."), - ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": ""}]}, - "Value must be an array for acl key."), - ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": [1]}]}, + ({"name": "test", "service": [{"name": "S1"}], "url": [{"url": "URI/write", "acl": []}, {"acl": ""}]}, + "Value must be an array for acl object."), + ({"name": "test", "service": [{"name": "S1"}], "url": [{"url": "URI/write", "acl": []}, {"acl": [1]}]}, "acl elements must be an object."), - ({"name": "test", "service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": [{}]}]}, + ({"name": "test", "service": [{"name": "S1"}], "url": [{"url": "URI/write", "acl": []}, {"acl": [{}]}]}, "acl object cannot be empty.") ]) async def test_bad_add_acl(self, client, payload, message): @@ -138,7 +140,7 @@ async def test_bad_add_acl(self, client, payload, message): async def test_duplicate_add_acl(self, client): acl_name = "testACL" - request_payload = {"name": acl_name, "service": [], "url": []} + request_payload = {"name": acl_name, "service": [{'name': 'Fledge Storage'}], "url": []} result = {'count': 1, 'rows': [ {'name': acl_name, 'service': [{'name': 'Fledge Storage'}, {'type': 'Southbound'}], 'url': [{'url': '/fledge/south/operation', 'acl': [{'type': 'Southbound'}]}]}]} @@ -160,7 +162,7 @@ async def test_duplicate_add_acl(self, client): async def test_good_add_acl(self, client): acl_name = "testACL" - request_payload = {"name": acl_name, "service": [], "url": []} + request_payload = {"name": acl_name, "service": [{"type": "Notification"}], "url": []} result = {"count": 0, "rows": []} insert_result = {"response": "inserted", "rows_affected": 1} acl_query_payload = {"return": ["name"], "where": {"column": "name", "condition": "=", "value": acl_name}} @@ -183,11 +185,11 @@ async def test_good_add_acl(self, client): assert 200 == resp.status result = await resp.text() json_response = json.loads(result) - assert {'name': acl_name, 'service': [], 'url': []} == json_response + assert {'name': acl_name, 'service': [{"type": "Notification"}], 'url': []} == json_response 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]) + assert {'name': acl_name, 'service': '[{"type": "Notification"}]', 'url': '[]'} == json.loads(args[1]) args, _ = query_tbl_patch.call_args_list[0] assert 'control_acl' == args[0] assert acl_query_payload == json.loads(args[1]) @@ -196,41 +198,45 @@ async def test_good_add_acl(self, client): ({}, "Nothing to update for the given payload."), ({"service": 1}, "service must be a list."), ({"url": 1}, "url must be a list."), + ({"service": []}, "service list cannot be empty."), ({"service": [1]}, "service elements must be an object."), ({"service": ["1"]}, "service elements must be an object."), ({"service": ["1", {}]}, "service elements must be an object."), ({"service": [{}]}, "service object cannot be empty."), - ({"service": [{"foo": "bar"}]}, "Either type or name Key-Value Pair is missing."), - ({"service": [{"type": 1}]}, "Value must be a string for type key."), - ({"service": [{"type": ""}]}, "Value cannot be empty for type key."), - ({"service": [{"name": 1}]}, "Value must be a string for name key."), - ({"service": [{"name": ""}]}, "Value cannot be empty for name key."), + ({"service": [{"foo": "bar"}]}, "Either type or name Key-Value Pair is missing for service."), + ({"service": [{"type": 1}]}, "Value must be a string for service type."), + ({"service": [{"type": ""}]}, "Value cannot be empty for service type."), + ({"service": [{"name": 1}]}, "Value must be a string for service name."), + ({"service": [{"name": ""}]}, "Value cannot be empty for service name."), ({"url": 1}, "url must be a list."), ({"url": [{}]}, "url child Key-Value Pair is missing."), - ({"url": [{"url": []}]}, "Value must be a string for url key."), - ({"url": [{"url": ""}]}, "Value cannot be empty for url key."), - ({"url": [{"acl": ""}]}, "Value must be an array for acl key."), + ({"url": [{"url": []}]}, "Value must be a string for url object."), + ({"url": [{"url": ""}]}, "Value cannot be empty for url object."), + ({"url": [{"acl": ""}]}, "Value must be an array for acl object."), ({"url": [{"acl": [1]}]}, "acl elements must be an object."), ({"url": [{"acl": [{}]}]}, "acl object cannot be empty."), ({"url": [{"acl": [{"type": "Core"}]}]}, "url child Key-Value Pair is missing."), - ({"url": [{"url": "URI/write", "acl": ""}]}, "Value must be an array for acl key."), + ({"url": [{"url": "URI/write", "acl": ""}]}, "Value must be an array for acl object."), ({"url": [{"url": "URI/write", "acl": []}, 1]}, "url elements must be an object."), ({"url": [{"url": "URI/write", "acl": []}, {"acl": []}]}, "url child Key-Value Pair is missing."), - ({"url": [{"url": "URI/write", "acl": []}, {"acl": ""}]}, "Value must be an array for acl key."), + ({"url": [{"url": "URI/write", "acl": []}, {"acl": ""}]}, "Value must be an array for acl object."), ({"url": [{"url": "URI/write", "acl": []}, {"acl": [1]}]}, "acl elements must be an object."), ({"url": [{"url": "URI/write", "acl": []}, {"acl": [{}]}]}, "acl object cannot be empty."), - ({"service": [{"foo": "bar"}], "url": []}, "Either type or name Key-Value Pair is missing."), + ({"service": [{"foo": "bar"}], "url": []}, "Either type or name Key-Value Pair is missing for service."), + ({"url": [], "service": []}, "service list cannot be empty."), ({"url": [], "service": [{}]}, "service object cannot be empty."), - ({"url": [], "service": [{"type": 1}]}, "Value must be a string for type key."), - ({"url": [], "service": [{"type": ""}]}, "Value cannot be empty for type key."), - ({"url": [], "service": [{"name": 1}]}, "Value must be a string for name key."), - ({"url": [], "service": [{"name": ""}]}, "Value cannot be empty for name key."), - ({"service": [], "url": 1}, "url must be a list."), + ({"url": [], "service": [{"type": 1}]}, "Value must be a string for service type."), + ({"url": [], "service": [{"type": ""}]}, "Value cannot be empty for service type."), + ({"url": [], "service": [{"name": 1}]}, "Value must be a string for service name."), + ({"url": [], "service": [{"name": ""}]}, "Value cannot be empty for service name."), + ({"service": [{"name": "myService"}], "url": 1}, "url must be a list."), ({"service": [{}], "url": 1}, "service object cannot be empty."), - ({"service": [], "url": [{"url": "URI/write", "acl": ""}]}, "Value must be an array for acl key."), - ({"service": [], "url": [{"url": "", "acl": ""}]}, "Value cannot be empty for url key."), - ({"service": [], "url": [{"blah": "", "acl": []}]}, "url child Key-Value Pair is missing."), - ({"service": [], "url": [{"url": "URI/write", "acl": []}, {"acl": [{}]}]}, "acl object cannot be empty.") + ({"service": [{"name": "SVC"}], "url": [{"url": "URI/write", "acl": ""}]}, + "Value must be an array for acl object."), + ({"service": [{"name": "SVC"}], "url": [{"url": "", "acl": ""}]}, "Value cannot be empty for url object."), + ({"service": [{"name": "SVC"}], "url": [{"blah": "", "acl": []}]}, "url child Key-Value Pair is missing."), + ({"service": [{"name": "SVC"}], "url": [{"url": "URI/write", "acl": []}, {"acl": [{}]}]}, + "acl object cannot be empty.") ]) async def test_bad_update_acl(self, client, payload, message): acl_name = "testACL" @@ -243,7 +249,7 @@ async def test_bad_update_acl(self, client, payload, message): async def test_update_acl_not_found(self, client): acl_name = "testACL" - req_payload = {"service": []} + req_payload = {"service": [{"type": "Notification"}]} 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", "service", "url"], "where": { @@ -263,10 +269,10 @@ async def test_update_acl_not_found(self, client): assert query_payload == json.loads(args[1]) @pytest.mark.parametrize("payload", [ - {"service": []}, {"service": [{"name": "Sinusoid"}, {"type": "Southbound"}]}, - {"service": [], "url": []}, - {"service": [], "url": [{"url": "/fledge/south/operation", "acl": [{"type": "Southbound"}]}]}, + {"service": [{"name": "Sinusoid"}], "url": []}, + {"service": [{"type": "Southbound"}], "url": [{"url": "/fledge/south/operation", + "acl": [{"type": "Southbound"}]}]}, {"service": [{"name": "Sinusoid"}, {"type": "Southbound"}], "url": [{"url": "/fledge/south/operation", "acl": [{"type": "Southbound"}]}]} ]) From 3bac8d83c3507675f13dc010bb841b3f341572d0 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 2 Feb 2024 16:05:39 +0530 Subject: [PATCH 052/146] bad order item fixes in script API Signed-off-by: ashish-jabble --- .../services/core/api/control_service/script_management.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 dded07370e..1e6f8668db 100644 --- a/python/fledge/services/core/api/control_service/script_management.py +++ b/python/fledge/services/core/api/control_service/script_management.py @@ -522,7 +522,7 @@ def _validate_steps_and_convert_to_str(payload: list) -> str: so that any client can use from there itself. For example: GUI client has also prepared this list by their own to show down in the dropdown. - Therefore if any new/update type is introduced with the current scenario both sides needs to be changed + Therefore, if any new/update type is introduced with the current scenario both sides needs to be changed """ steps_supported_types = ["configure", "delay", "operation", "script", "write"] unique_order_items = [] @@ -539,6 +539,10 @@ def _validate_steps_and_convert_to_str(payload: list) -> str: raise ValueError('order key is missing for {} step.'.format(k)) else: if isinstance(v['order'], int): + if v['order'] < 1: + if v['order'] == 0: + raise ValueError('order cannot be zero.') + raise ValueError('order should be a positive number.') if v['order'] not in unique_order_items: unique_order_items.append(v['order']) else: From bb7094dcf2dc34f0bca6a1d0a2660da628089235 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 2 Feb 2024 10:48:19 +0000 Subject: [PATCH 053/146] FOGL-8105 Switching to Postgres (#1279) * FOGL-8105 catch failures to create connection to Postgres Signed-off-by: Mark Riddoch * FOGL-8105 Add execute of plugin start script when the readings plugin differs from the main plugin Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/plugins/storage/postgres/connection.cpp | 1 + .../storage/postgres/connection_manager.cpp | 18 ++-- C/plugins/storage/postgres/plugin.cpp | 92 +++++++++++++++++- docs/images/storage_01.jpg | Bin 57711 -> 61342 bytes docs/storage.rst | 30 +++--- scripts/storage | 9 ++ 6 files changed, 128 insertions(+), 22 deletions(-) diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index 357cb5a008..a7cfa12f29 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -353,6 +353,7 @@ Connection::Connection() : m_maxReadingRows(INSERT_ROW_LIMIT) PQerrorMessage(dbConnection)); connectErrorTime = time(0); } + throw runtime_error("Unable to connect to PostgreSQL database"); } logSQL("Set", "session time zone 'UTC' "); diff --git a/C/plugins/storage/postgres/connection_manager.cpp b/C/plugins/storage/postgres/connection_manager.cpp index 025f168033..5d9586b8a0 100644 --- a/C/plugins/storage/postgres/connection_manager.cpp +++ b/C/plugins/storage/postgres/connection_manager.cpp @@ -9,6 +9,8 @@ */ #include #include +#include +#include ConnectionManager *ConnectionManager::instance = 0; @@ -58,12 +60,16 @@ void ConnectionManager::growPool(unsigned int delta) { while (delta-- > 0) { - Connection *conn = new Connection(); - conn->setTrace(m_logSQL); - conn->setMaxReadingRows(m_maxReadingRows); - idleLock.lock(); - idle.push_back(conn); - idleLock.unlock(); + try { + Connection *conn = new Connection(); + conn->setTrace(m_logSQL); + conn->setMaxReadingRows(m_maxReadingRows); + idleLock.lock(); + idle.push_back(conn); + idleLock.unlock(); + } catch (std::exception& e) { + Logger::getLogger()->error("Failed to create storage connection: %s", e.what()); + } } } diff --git a/C/plugins/storage/postgres/plugin.cpp b/C/plugins/storage/postgres/plugin.cpp index 3892a7b44f..20270d9d38 100644 --- a/C/plugins/storage/postgres/plugin.cpp +++ b/C/plugins/storage/postgres/plugin.cpp @@ -107,6 +107,12 @@ int plugin_common_insert(PLUGIN_HANDLE handle, char *schema, char *table, char * ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return 0; + } + int result = connection->insert(std::string(OR_DEFAULT_SCHEMA(schema)) + "." + std::string(table), std::string(data)); manager->release(connection); @@ -122,6 +128,12 @@ ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); std::string results; + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return NULL; + } + bool rval = connection->retrieve(schema, std::string(OR_DEFAULT_SCHEMA(schema)) + "." + std::string(table), std::string(query), results); manager->release(connection); if (rval) @@ -139,6 +151,12 @@ int plugin_common_update(PLUGIN_HANDLE handle, char *schema, char *table, char * ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return 0; + } + int result = connection->update(std::string(OR_DEFAULT_SCHEMA(schema)) + "." + std::string(table), std::string(data)); manager->release(connection); return result; @@ -152,6 +170,12 @@ int plugin_common_delete(PLUGIN_HANDLE handle, char *schema , char *table, char ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return 0; + } + int result = connection->deleteRows(std::string(OR_DEFAULT_SCHEMA(schema)) + "." + std::string(table), std::string(condition)); manager->release(connection); return result; @@ -165,9 +189,15 @@ int plugin_reading_append(PLUGIN_HANDLE handle, char *readings) ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return 0; + } + int result = connection->appendReadings(readings); manager->release(connection); - return result;; + return result; } /** @@ -179,6 +209,12 @@ ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); std::string resultSet; + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return NULL; + } + connection->fetchReadings(id, blksize, resultSet); manager->release(connection); return strdup(resultSet.c_str()); @@ -193,6 +229,12 @@ ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); std::string results; + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return NULL; + } + connection->retrieveReadings(std::string(condition), results); manager->release(connection); return strdup(results.c_str()); @@ -208,6 +250,12 @@ Connection *connection = manager->allocate(); std::string results; unsigned long age, size; + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return NULL; + } + if (flags & STORAGE_PURGE_SIZE) { (void)connection->purgeReadingsByRows(param, flags, sent, results); @@ -270,6 +318,12 @@ int plugin_create_table_snapshot(PLUGIN_HANDLE handle, ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return -1; + } + int result = connection->create_table_snapshot(std::string(table), std::string(id)); manager->release(connection); @@ -291,6 +345,12 @@ int plugin_load_table_snapshot(PLUGIN_HANDLE handle, ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return -1; + } + int result = connection->load_table_snapshot(std::string(table), std::string(id)); manager->release(connection); @@ -313,6 +373,12 @@ int plugin_delete_table_snapshot(PLUGIN_HANDLE handle, ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return -1; + } + int result = connection->delete_table_snapshot(std::string(table), std::string(id)); manager->release(connection); @@ -333,6 +399,12 @@ ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); std::string results; + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return NULL; + } + bool rval = connection->get_table_snapshots(std::string(table), results); manager->release(connection); @@ -353,6 +425,12 @@ int plugin_createSchema(PLUGIN_HANDLE handle, ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return -1; + } + int result = connection->create_schema(std::string(payload)); manager->release(connection); return result; @@ -364,6 +442,12 @@ int plugin_schema_update(PLUGIN_HANDLE handle, ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return 0; + } + // create_schema handles both create and update schema // schema value gets parsed from the payload int result = connection->create_schema(std::string(payload)); @@ -380,6 +464,12 @@ unsigned int plugin_reading_purge_asset(PLUGIN_HANDLE handle, char *asset) ConnectionManager *manager = (ConnectionManager *)handle; Connection *connection = manager->allocate(); + if (connection == NULL) + { + Logger::getLogger()->fatal("No database connections available"); + return 0; + } + unsigned int deleted = connection->purgeReadingsAsset(asset); manager->release(connection); return deleted; diff --git a/docs/images/storage_01.jpg b/docs/images/storage_01.jpg index 63e992bd3f4f0199386244e2545fc95785592d1e..c53096888e3db838c519aca57f276b93d6667690 100644 GIT binary patch literal 61342 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

dOId+(ac)mq`-aHbNIKp7DbUtb}7NF zTWx%*c?ipow37~Tqr)HR3nY9Jpod2foEq#5A)il0mkgCmMp43=EnZzvazEy?+A(j> z#BfFkmD>rljtT8t%7V)!8mjfB8QRZEgCmwvHUs(msJ|eM+IzO#49d{TezaG&UFyVS zRtH{^dFVB#iwkV}>1a<`Bm_ciWIks9wsfg?u|KH$ZmA@U*wqL1n(DUG?#JzH`$7}i zbV8*I14-|EYwUQeJO*9`z8fcR)i24w(_oL11oi;uE3Ohh-$k=$RDh4Eqe>hxB_#}k_Pmnz(C)Wz$Ngi% z=sj;O$%Y@(DW_#>EL4P63_hnLx|t`(A)Nv>f0@?PeorY5BTL!hg<@$zQyzCuQc1Rc zNwUw;_vOK{B+Dc`{!wd*olLgv^MyUSkE$X6tY&S(^+2QB1W}O(46;dy3Dq8b`4YiW z-qA@u?47jr0P?O@@ubHU*=-khJ#lrA)Xd?9+>LOROABg&bZIzfL9^xpCut8k2f>wz zrLB&m9a&8?3zmsZR1rh93+Q%rFll?nJ)#SwIw}>dd=AY(YRgiIvvJomwxFRA$}rkUffivwTQ%ey zrtef#AM|2fL>d?8%?8A)UT9ohNwTzdACFW`YTI`1!Lz&eyOe~9OlUx9HhH6;LApGi z{NSh6Au9ZFqeO;)N%n}x#tl)2;51vCyhqKJcQsuYyBZ8l9{D&R zo2nB{(up=QoIpsApwsMVCHcY)Z6&3YAXcBRr0 zL#3Q8_OzvDjyM>TgZ*4<6S0&9#{1=1YCobp*Y;gFbT01HWrm5@!Q_RTbDsig5|%z% zoGXrxwjpP1BHYXfB~*lk7kSW%;5FgF_r7cYv}B*!n7h)ilOH3c>an=wnohNfe04!D z>>~OCKY%-kJpvvZFO7kT;gARc5H&7vqlOR$1J> zoy(MnnP8Iypco4+X2@DH5e!-Bwb=Wp!qC-*fi}7ZUwT%~LP}fHmrD&Gs z4)1DLwQ$`PzdU6mhWLyk;zG#VIAXJn`vy3QGlZcvC;b5>rvb#LBt+$`xT%D&wyM9_ zkHapG@nP^}<+~Qf0TZ}yYmD1qqG78nD}aLbgM)Vd26Ct}Jp;4=itKN4NJ6=u{z#lngwi z#RIpUGA8wDiwX(7rZW7 z)T(GZ_Av>ku`BR#!wqt!9JA-wfbNIwQ4%&fPVH;I^Ia%1-DyyweBrA3;@4BH(npkv31x;IUM`Fm(Ac&U``K8f zDyr)eJs@Je5aCiTaCWRs6I(sc`JU#VG|NbA+k9c6xUJBd$oW!X@OJMp zg`+*)3Anvu%3*Nbn}PDYbq9@Cd8SwY557SEal$Xzg%0JMA}O)41WqE{(dutlqtahm z(`UZ119V(Cs+`2x5^TG`^p#|2c z`U7P_9&Y)wq8^t>j_w;VjfJS&&zv@^^to2s�!z7+EUR1wLBQz9N1c`Ue}K+Qz$C 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 OiU(6ZdkzaMzWpngTN|tZ literal 57711 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�nj5?7AX@N?=&W&JGs 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 Date: Fri, 2 Feb 2024 13:52:52 -0500 Subject: [PATCH 054/146] Fix AF link failure when AF path or AFLocation hint changes Signed-off-by: Ray Verhoeff --- C/plugins/north/OMF/include/omf.h | 1 + C/plugins/north/OMF/linkdata.cpp | 8 +- C/plugins/north/OMF/omf.cpp | 196 ++++++++++++++++++++++-------- 3 files changed, 149 insertions(+), 56 deletions(-) diff --git a/C/plugins/north/OMF/include/omf.h b/C/plugins/north/OMF/include/omf.h index be487b2c8b..9c47972dbf 100644 --- a/C/plugins/north/OMF/include/omf.h +++ b/C/plugins/north/OMF/include/omf.h @@ -354,6 +354,7 @@ class OMF // Start of support for using linked containers bool sendBaseTypes(); + bool sendAFLinks(Reading& reading, OMFHints *hints); // End of support for using linked containers // string createAFLinks(Reading &reading, OMFHints *hints); diff --git a/C/plugins/north/OMF/linkdata.cpp b/C/plugins/north/OMF/linkdata.cpp index b95f1039e1..f78326f95c 100644 --- a/C/plugins/north/OMF/linkdata.cpp +++ b/C/plugins/north/OMF/linkdata.cpp @@ -28,7 +28,7 @@ /** * 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 + * each time we get close to filling the string we reserve more. The value below * defines the increment we use to grow the string reservation. */ #define RESERVE_INCREMENT 100 @@ -378,7 +378,7 @@ string OMFLinkedData::getBaseType(Datapoint *dp, const string& format) } /** - * Send the container message for the linked datapoint + * Create a container message for the linked datapoint * * @param linkName The name to use for the container * @param dp The datapoint to process @@ -490,7 +490,7 @@ void OMFLinkedData::sendContainer(string& linkName, Datapoint *dp, OMFHints * hi /** * Flush the container definitions that have been built up * - * @return true if the containers where succesfully flushed + * @return true if the containers were successfully flushed */ bool OMFLinkedData::flushContainers(HttpSender& sender, const string& path, vector >& header) { @@ -558,7 +558,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 OMF endpoint, %s - %s %s", + Logger::getLogger()->error("An exception occurred when sending container information to 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 cb1cebdb55..cdd6ebb7a1 100755 --- a/C/plugins/north/OMF/omf.cpp +++ b/C/plugins/north/OMF/omf.cpp @@ -1097,6 +1097,7 @@ uint32_t OMF::sendToServer(const vector& readings, bool compression, bool skipSentDataTypes) { bool AFHierarchySent = false; + bool sendLinkedTypes = false; bool sendDataTypes; string keyComplete; string AFHierarchyPrefix; @@ -1267,7 +1268,6 @@ uint32_t OMF::sendToServer(const vector& readings, setAFHierarchy(); } - // Use old style complex types if the user has forced it via configuration, // we are running against an EDS endpoint or Connector Relay or we have types defined for this // asset already @@ -1333,7 +1333,7 @@ uint32_t OMF::sendToServer(const vector& readings, { // The AF hierarchy is created/recreated if an OMF type message is sent // it sends the hierarchy once - if (sendDataTypes and ! AFHierarchySent) + if (sendDataTypes and !AFHierarchySent) { if (!handleAFHierarchy()) { @@ -1387,6 +1387,8 @@ uint32_t OMF::sendToServer(const vector& readings, { pendingSeparator = true; } + + sendLinkedTypes = false; } else { @@ -1396,27 +1398,8 @@ uint32_t OMF::sendToServer(const vector& readings, // Send data for this reading using the new mechanism if (linkedData.processReading(payload, pendingSeparator, *reading, AFHierarchyPrefix, hints)) pendingSeparator = true; - if (m_sendFullStructure && lookup->second.afLinkState() == false) - { - // If the hierarchy has not already been sent then send it - if (! AFHierarchySent) - { - if (!handleAFHierarchy()) - { - m_lastError = true; - return 0; - } - AFHierarchySent = true; - } - string af = createAFLinks(*reading, hints); - if (! af.empty()) - { - payload.append(','); - payload.append(af); - } - lookup->second.afLinkSent(); - } + sendLinkedTypes = true; } if (hints) @@ -1519,8 +1502,6 @@ uint32_t OMF::sendToServer(const vector& readings, delete[] omfData; - // Return number of sent readings to the caller - return readings.size(); } // Exception raised for HTTP 400 Bad Request catch (const BadRequest& e) @@ -1629,6 +1610,50 @@ uint32_t OMF::sendToServer(const vector& readings, delete[] omfData; return 0; } + + // Create the AF Links between assets if AF structure creation with linked types is requested + if (sendLinkedTypes && m_sendFullStructure) + { + for (Reading *reading : readings) + { + OMFHints *hints = NULL; + Datapoint *hintsdp = reading->getDatapoint("OMFHint"); + if (hintsdp) + { + hints = new OMFHints(hintsdp->getData().toString()); + } + + m_assetName = ApplyPIServerNamingRulesObj(reading->getAssetName(), nullptr); + auto lookup = m_linkedAssetState.find(m_assetName + "."); + if (lookup->second.afLinkState() == false) + { + // If the hierarchy has not already been sent then send it + if (!AFHierarchySent) + { + if (!handleAFHierarchy()) + { + m_lastError = true; + delete hints; + return 0; + } + AFHierarchySent = true; + } + + if (!sendAFLinks(*reading, hints)) + { + m_lastError = true; + delete hints; + return 0; + } + lookup->second.afLinkSent(); + } + + delete hints; + } + } + + // Return number of sent readings to the caller + return readings.size(); } /** @@ -2111,8 +2136,8 @@ const std::string OMF::createContainerData(const Reading& reading, OMFHints *hin /** * Generate the container id for the given asset * - * @param assetName Asset for quick the container id should be generated - * @return Container it for the requested asset + * @param assetName Asset for which the container id should be generated + * @return Container id for the requested asset */ std::string OMF::generateMeasurementId(const string& assetName) { @@ -2165,7 +2190,7 @@ std::string OMF::generateMeasurementId(const string& assetName) /** * Generate a suffix for the given asset in relation to the selected naming schema and the value of the type id * - * @param assetName Asset for quick the suffix should be generated + * @param assetName Asset for which the suffix should be generated * @param typeId Type id of the asset * @return Suffix to be used for the given asset */ @@ -2275,8 +2300,8 @@ 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 AFHierarchyLevel The AF element we are placing the reading in + * @param AFHierarchyPrefix The prefix we use for the AF Element * @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 @@ -2498,7 +2523,7 @@ void OMF::retrieveAFHierarchyFullPrefixAssetName(const string& assetName, string } /** - * Handle the OMF hint AFLocation to defined a position of the asset into the AF hierarchy + * Handle the OMF hint AFLocation to define a position of the asset into the AF hierarchy * * @param assetName AssetName to handle * @param OmfHintHierarchy Position of the asset into the AF hierarchy @@ -2659,12 +2684,12 @@ bool OMF::extractVariable(string &strToHandle, string &variable, string &value, } /** - * Evaulate the AF hierarchy provided and expand the variables in the form ${room:unknown} + * Evaluate the AF hierarchy provided and expand the variables in the form ${room:unknown} * - * @param reading Asset reading that should be considered from which extract the metadata values + * @param reading Asset reading that should be considered from which to extract the metadata values * @param AFHierarchy AF hierarchy containing the variable to be expanded * - * @return True if variable were found and expanded + * @return True if variables were found and expanded */ std::string OMF::variableValueHandle(const Reading& reading, std::string &AFHierarchy) { @@ -3872,7 +3897,7 @@ long OMF::getAssetTypeId(const string& assetName) * Retrieve the naming scheme for the given asset in relation to the end point selected the default naming scheme selected * and the naming scheme of the asset itself * - * @param assetName Asset for quick the naming schema should be retrieved + * @param assetName Asset for which the naming schema should be retrieved * @return Naming schema of the given asset */ long OMF::getNamingScheme(const string& assetName) @@ -3935,7 +3960,7 @@ long OMF::getNamingScheme(const string& assetName) /** * Retrieve the hash for the given asset in relation to the end point selected * - * @param assetName Asset for quick the hash should be retrieved + * @param assetName Asset for which the hash should be retrieved * @return Hash of the given asset */ string OMF::getHashStored(const string& assetName) @@ -3990,7 +4015,7 @@ string OMF::getHashStored(const string& assetName) /** * Retrieve the current AF hierarchy for the given asset * - * @param assetName Asset for quick the path should be retrieved + * @param assetName Asset for which the path should be retrieved * @return Path of the given asset */ string OMF::getPathStored(const string& assetName) @@ -4044,7 +4069,7 @@ string OMF::getPathStored(const string& assetName) /** * Retrieve the AF hierarchy in which given asset was created * - * @param assetName Asset for quick the path should be retrieved + * @param assetName Asset for which the path should be retrieved * @return Path of the given asset */ string OMF::getPathOrigStored(const string& assetName) @@ -4099,7 +4124,7 @@ string OMF::getPathOrigStored(const string& assetName) /** * Stores the current AF hierarchy for the given asset * - * @param assetName Asset for quick the path should be retrieved + * @param assetName Asset for which the path should be retrieved * @param afHierarchy Current AF hierarchy of the asset * * @return True if the operation has success @@ -4644,11 +4669,6 @@ std::string OMF::ApplyPIServerNamingRulesObj(const std::string &objName, bool *c nameFixed = StringTrim(objName); - if (objName.compare(nameFixed) != 0) - { - Logger::getLogger()->debug("%s - original :%s: trimmed :%s:", __FUNCTION__, objName.c_str(), nameFixed.c_str()); - } - if (nameFixed.empty ()) { Logger::getLogger()->debug("%s - object name empty", __FUNCTION__); @@ -4681,7 +4701,10 @@ std::string OMF::ApplyPIServerNamingRulesObj(const std::string &objName, bool *c *changed = true; } - Logger::getLogger()->debug("%s - final :%s: ", __FUNCTION__, nameFixed.c_str()); + if (objName.compare(nameFixed) != 0) + { + Logger::getLogger()->debug("%s - original :%s: trimmed :%s:", __FUNCTION__, objName.c_str(), nameFixed.c_str()); + } return (nameFixed); } @@ -4699,7 +4722,7 @@ std::string OMF::ApplyPIServerNamingRulesObj(const std::string &objName, bool *c * Names on PI-Server side are not case sensitive * * @param objName The object name to verify - * @param changed if not null, it is set to true if a change occur + * @param changed if not null, it is set to true if a change occurred * @return Object name following the PI Server naming rules */ std::string OMF::ApplyPIServerNamingRulesPath(const std::string &objName, bool *changed) @@ -4711,8 +4734,6 @@ std::string OMF::ApplyPIServerNamingRulesPath(const std::string &objName, bool * nameFixed = StringTrim(objName); - Logger::getLogger()->debug("%s - original :%s: trimmed :%s:", __FUNCTION__, objName.c_str(), nameFixed.c_str()); - if (nameFixed.empty ()) { Logger::getLogger()->debug("%s - path empty", __FUNCTION__); @@ -4752,12 +4773,14 @@ std::string OMF::ApplyPIServerNamingRulesPath(const std::string &objName, bool * } - Logger::getLogger()->debug("%s - final :%s: ", __FUNCTION__, nameFixed.c_str()); + if (objName.compare(nameFixed) != 0) + { + Logger::getLogger()->debug("%s - original :%s: trimmed :%s:", __FUNCTION__, objName.c_str(), nameFixed.c_str()); + } return (nameFixed); } - /** * Send the base types that we use to define all the data point values * @@ -4831,10 +4854,80 @@ bool OMF::sendBaseTypes() } /** - * Create the messages to link the asset into the right place in the AF structure + * Send a message to link the asset into the right place in the AF structure + * + * @param reading The reading being sent + * @param hints OMF Hints for this reading + * @return true if the message was sent correctly, otherwise false. + */ +bool OMF::sendAFLinks(Reading &reading, OMFHints *hints) +{ + bool success = true; + std::string afLinks = createAFLinks(reading, hints); + if (afLinks.empty()) + { + return success; + } + + try + { + std::string action = (this->m_OMFVersion.compare("1.2") == 0) ? "update" : "create"; + vector> messageHeader = OMF::createMessageHeader("Data", action); + afLinks = "[" + afLinks + "]"; + + int res = m_sender.sendRequest("POST", + m_path, + messageHeader, + afLinks); + if (res >= 200 && res <= 299) + { + Logger::getLogger()->debug("AF Link message sent successfully: %s", afLinks.c_str()); + success = true; + } + else + { + Logger::getLogger()->error("Sending AF Link Data message, HTTP code %d - %s %s", + res, + m_sender.getHostPort().c_str(), + m_path.c_str()); + success = false; + } + } + catch (const BadRequest &e) // HTTP 400 + { + OMFError error(m_sender.getHTTPResponse()); + if (error.hasErrors()) + { + Logger::getLogger()->warn("The OMF endpoint reported a bad request when sending AF Link: %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; + } + catch (const std::exception &e) + { + string errorMsg = errorMessageHandler(e.what()); + + Logger::getLogger()->error("AF Link send message exception, %s - %s %s", + errorMsg.c_str(), + m_sender.getHostPort().c_str(), + m_path.c_str()); + success = false; + } + + return success; +} + +/** + * Create the messages to link the asset holding the container to its parent asset * * @param reading The reading being sent * @param hints OMF Hints for this reading + * @return OMF JSON snippet to create the AF Link */ string OMF::createAFLinks(Reading& reading, OMFHints *hints) { @@ -4877,12 +4970,11 @@ string AFDataMessage; // 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()); + Logger::getLogger()->error("AF hierarchy is not defined for the asset Name |%s|", assetName.c_str()); } } return AFDataMessage; From 53c13c88f275b46e5391719072928345de7ebdc8 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 5 Feb 2024 13:36:02 +0530 Subject: [PATCH 055/146] User alerts API system test added Signed-off-by: ashish-jabble --- python/fledge/services/core/server.py | 1 + tests/system/python/api/test_alerts.py | 144 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/system/python/api/test_alerts.py diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 87c3824cb1..9922642f87 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -2049,4 +2049,5 @@ async def add_alert(cls, request): _logger.error(ex, "Failed to add an alert.") raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: + response['alert']['urgency'] = cls._alert_manager._urgency_name_by_value(response['alert']['urgency']) return web.json_response(response) diff --git a/tests/system/python/api/test_alerts.py b/tests/system/python/api/test_alerts.py new file mode 100644 index 0000000000..ef0b19c4cc --- /dev/null +++ b/tests/system/python/api/test_alerts.py @@ -0,0 +1,144 @@ +import http.client +import json +import pytest + +__author__ = "Ashish Jabble" +__copyright__ = "Copyright (c) 2024 Dianomic Systems Inc." +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + +""" User Alerts API tests """ + + +def verify_alert_in_ping(url, alert_count): + conn = http.client.HTTPConnection(url) + conn.request("GET", '/fledge/ping') + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "No Ping data found." + assert jdoc['alerts'] == alert_count + + +def create_alert(url, payload): + svc_conn = http.client.HTTPConnection(url) + svc_conn.request("GET", '/fledge/service?type=Core') + resp = svc_conn.getresponse() + assert 200 == resp.status + resp = resp.read().decode() + svc_jdoc = json.loads(resp) + + svc_details = svc_jdoc["services"][0] + url = "{}:{}".format(svc_details['address'], svc_details['management_port']) + conn = http.client.HTTPConnection(url) + conn.request('POST', '/fledge/alert', 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 alert!" + return jdoc + +class TestAlerts: + + def test_get_default_alerts(self, fledge_url, reset_and_start_fledge): + verify_alert_in_ping(fledge_url, alert_count=0) + + conn = http.client.HTTPConnection(fledge_url) + conn.request("GET", '/fledge/alert') + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "No alerts found." + assert 'alerts' in jdoc + assert jdoc['alerts'] == [] + + def test_no_delete_alert(self, fledge_url): + conn = http.client.HTTPConnection(fledge_url) + conn.request("DELETE", '/fledge/alert') + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert 'message' in jdoc + assert {"message": "Nothing to delete."} == jdoc + + def test_bad_delete_alert_by_key(self, fledge_url): + key = "blah" + conn = http.client.HTTPConnection(fledge_url) + conn.request("DELETE", '/fledge/alert/{}'.format(key)) + r = conn.getresponse() + assert 404 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert 'message' in jdoc + assert {"message": "{} alert not found.".format(key)} == jdoc + + @pytest.mark.parametrize("payload, count", [ + ({"key": "updates", "urgency": "normal", "message": "Fledge new version is available."}, 1), + ({"key": "updates", "urgency": "normal", "message": "Fledge new version is available."}, 1) + ]) + def test_create_alert(self, fledge_url, payload, count): + jdoc = create_alert(fledge_url, payload) + assert 'alert' in jdoc + alert_jdoc = jdoc['alert'] + payload['urgency'] = 'Normal' + assert payload == alert_jdoc + + verify_alert_in_ping(fledge_url, alert_count=count) + + def test_get_all_alerts(self, fledge_url): + verify_alert_in_ping(fledge_url, alert_count=1) + + conn = http.client.HTTPConnection(fledge_url) + conn.request("GET", '/fledge/alert') + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert len(jdoc), "No alerts found." + assert 'alerts' in jdoc + assert 1 == len(jdoc['alerts']) + alert_jdoc = jdoc['alerts'][0] + assert 'key' in alert_jdoc + assert 'updates' == alert_jdoc['key'] + assert 'message' in alert_jdoc + assert 'Fledge new version is available.' == alert_jdoc['message'] + assert 'urgency' in alert_jdoc + assert 'Normal' == alert_jdoc['urgency'] + assert 'timestamp' in alert_jdoc + + def test_delete_alert_by_key(self, fledge_url): + payload = {"key": "Sine", "message": "The service has restarted 4 times", "urgency": "critical"} + jdoc = create_alert(fledge_url, payload) + assert 'alert' in jdoc + alert_jdoc = jdoc['alert'] + payload['urgency'] = 'Critical' + assert payload == alert_jdoc + + verify_alert_in_ping(fledge_url, alert_count=2) + + conn = http.client.HTTPConnection(fledge_url) + conn.request("DELETE", '/fledge/alert/{}'.format(payload['key'])) + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert 'message' in jdoc + assert {'message': '{} alert is deleted.'.format(payload['key'])} == jdoc + + verify_alert_in_ping(fledge_url, alert_count=1) + + def test_delete_alert(self, fledge_url): + conn = http.client.HTTPConnection(fledge_url) + conn.request("DELETE", '/fledge/alert') + r = conn.getresponse() + assert 200 == r.status + r = r.read().decode() + jdoc = json.loads(r) + assert 'message' in jdoc + assert {'message': 'Delete all alerts.'} == jdoc + + verify_alert_in_ping(fledge_url, alert_count=0) From 2cb2d2b2543beae188d7c38592b4960761766f48 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 7 Feb 2024 12:03:22 +0530 Subject: [PATCH 056/146] alerts API endpoints test data added in API tests Signed-off-by: ashish-jabble --- .../api/test_endpoints_with_different_user_types.py | 12 +++++++++--- 1 file changed, 9 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 a0180372f8..5f14c6d88f 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 @@ -222,7 +222,9 @@ def test_login(self, fledge_url, wait_time): ("POST", "/fledge/notification", 403), ("PUT", "/fledge/notification/N1", 403), ("DELETE", "/fledge/notification/N1", 403), ("GET", "/fledge/notification/N1/delivery", 404), ("POST", "/fledge/notification/N1/delivery", 403), ("GET", "/fledge/notification/N1/delivery/C1", 404), - ("DELETE", "/fledge/notification/N1/delivery/C1", 403) + ("DELETE", "/fledge/notification/N1/delivery/C1", 403), + # alerts + ("GET", "/fledge/alert", 200), ("DELETE", "/fledge/alert", 403), ("DELETE", "/fledge/alert/blah", 403) ]) def test_endpoints(self, fledge_url, method, route_path, http_status_code, storage_plugin): conn = http.client.HTTPConnection(fledge_url) @@ -372,7 +374,9 @@ def test_login(self, fledge_url, wait_time): ("POST", "/fledge/notification", 403), ("PUT", "/fledge/notification/N1", 403), ("DELETE", "/fledge/notification/N1", 403), ("GET", "/fledge/notification/N1/delivery", 403), ("POST", "/fledge/notification/N1/delivery", 403), ("GET", "/fledge/notification/N1/delivery/C1", 403), - ("DELETE", "/fledge/notification/N1/delivery/C1", 403) + ("DELETE", "/fledge/notification/N1/delivery/C1", 403), + # alerts + ("GET", "/fledge/alert", 403), ("DELETE", "/fledge/alert", 403), ("DELETE", "/fledge/alert/blah", 403) ]) def test_endpoints(self, fledge_url, method, route_path, http_status_code, storage_plugin): conn = http.client.HTTPConnection(fledge_url) @@ -527,7 +531,9 @@ def test_login(self, fledge_url, wait_time): ("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) + ("DELETE", "/fledge/notification/N1/delivery/C1", 404), + # alerts + ("GET", "/fledge/alert", 200), ("DELETE", "/fledge/alert", 200), ("DELETE", "/fledge/alert/blah", 404) ]) def test_endpoints(self, fledge_url, method, route_path, http_status_code, storage_plugin): conn = http.client.HTTPConnection(fledge_url) From a5ac237462fd9a20f58e27e84eb6d6f38ab56077 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 7 Feb 2024 12:13:06 +0530 Subject: [PATCH 057/146] performance monoitors API endpoints test data added in API tests Signed-off-by: ashish-jabble --- .../api/test_endpoints_with_different_user_types.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 5f14c6d88f..4ad36b8dea 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 @@ -223,6 +223,10 @@ def test_login(self, fledge_url, wait_time): ("DELETE", "/fledge/notification/N1", 403), ("GET", "/fledge/notification/N1/delivery", 404), ("POST", "/fledge/notification/N1/delivery", 403), ("GET", "/fledge/notification/N1/delivery/C1", 404), ("DELETE", "/fledge/notification/N1/delivery/C1", 403), + # performance monitors + ("GET", "/fledge/monitors", 200), ("GET", "/fledge/monitors/SVC", 200), + ("GET", "/fledge/monitors/Svc/Counter", 200), ("DELETE", "/fledge/monitors", 403), + ("DELETE", "/fledge/monitors/SVC", 403), ("DELETE", "/fledge/monitors/Svc/Counter", 403), # alerts ("GET", "/fledge/alert", 200), ("DELETE", "/fledge/alert", 403), ("DELETE", "/fledge/alert/blah", 403) ]) @@ -375,6 +379,10 @@ def test_login(self, fledge_url, wait_time): ("DELETE", "/fledge/notification/N1", 403), ("GET", "/fledge/notification/N1/delivery", 403), ("POST", "/fledge/notification/N1/delivery", 403), ("GET", "/fledge/notification/N1/delivery/C1", 403), ("DELETE", "/fledge/notification/N1/delivery/C1", 403), + # performance monitors + ("GET", "/fledge/monitors", 403), ("GET", "/fledge/monitors/SVC", 403), + ("GET", "/fledge/monitors/Svc/Counter", 403), ("DELETE", "/fledge/monitors", 403), + ("DELETE", "/fledge/monitors/SVC", 403), ("DELETE", "/fledge/monitors/Svc/Counter", 403), # alerts ("GET", "/fledge/alert", 403), ("DELETE", "/fledge/alert", 403), ("DELETE", "/fledge/alert/blah", 403) ]) @@ -532,6 +540,10 @@ def test_login(self, fledge_url, wait_time): ("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), + # performance monitors + ("GET", "/fledge/monitors", 200), ("GET", "/fledge/monitors/SVC", 200), + ("GET", "/fledge/monitors/Svc/Counter", 200), ("DELETE", "/fledge/monitors", 200), + ("DELETE", "/fledge/monitors/SVC", 200), ("DELETE", "/fledge/monitors/Svc/Counter", 200), # alerts ("GET", "/fledge/alert", 200), ("DELETE", "/fledge/alert", 200), ("DELETE", "/fledge/alert/blah", 404) ]) From ca00fd3ebc9741afd2c77897ea5beded1c6bae35 Mon Sep 17 00:00:00 2001 From: gnandan <111729765+gnandan@users.noreply.github.com> Date: Fri, 9 Feb 2024 13:48:26 +0530 Subject: [PATCH 058/146] FOGL-8441 : method to getalert and raise alert added to management client (#1282) * FOGL-8441 : method to getalert and raise alert added to management cleint Signed-off-by: nandan --- C/common/include/management_client.h | 2 + C/common/management_client.cpp | 65 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/C/common/include/management_client.h b/C/common/include/management_client.h index 9fc8bf2123..97f16e7c53 100644 --- a/C/common/include/management_client.h +++ b/C/common/include/management_client.h @@ -117,6 +117,8 @@ class ManagementClient { const std::string& event); int validateDatapoints(std::string dp1, std::string dp2); AssetTrackingTable *getDeprecatedAssetTrackingTuples(); + std::string getAlertByKey(const std::string& key); + bool raiseAlert(const std::string& key, const std::string& message, const std::string& urgency="normal"); private: std::ostringstream m_urlbase; diff --git a/C/common/management_client.cpp b/C/common/management_client.cpp index 55c0ebfa79..0f8d771a60 100644 --- a/C/common/management_client.cpp +++ b/C/common/management_client.cpp @@ -2059,3 +2059,68 @@ int ManagementClient::validateDatapoints(std::string dp1, std::string dp2) return temp.compare(dp2); } + +/** + * Get an alert by specific key + * + * @param key Key to get alert + * @return string Alert + */ +std::string ManagementClient::getAlertByKey(const std::string& key) +{ + std::string response = "Status: 404 Not found"; + try + { + std::string url = "/fledge/alert/" + urlEncode(key) ; + auto res = this->getHttpClient()->request("GET", url.c_str()); + std::string statusCode = res->status_code; + if (statusCode.compare("200 OK")) + { + m_logger->error("Get alert failed %s.", statusCode.c_str()); + response = "Status: " + statusCode; + return response; + } + + response = res->content.string(); + } + catch (const SimpleWeb::system_error &e) { + m_logger->error("Get alert failed %s.", e.what()); + } + return response; +} + + +/** + * Raise an alert + * + * @param key Alert key + * @param message Alert message + * @param urgency Alert urgency + * @return whether operation was successful + */ +bool ManagementClient::raiseAlert(const std::string& key, const std::string& message, const std::string& urgency) +{ + try + { + std::string url = "/fledge/alert" ; + ostringstream payload; + payload << "{\"key\":\"" << key << "\"," + << "\"message\":\"" << message << "\"," + << "\"urgency\":\"" << urgency << "\"}"; + + auto res = this->getHttpClient()->request("POST", url.c_str(), payload.str()); + std::string statusCode = res->status_code; + if (statusCode.compare("200 OK")) + { + m_logger->error("Raise alert failed %s.", statusCode.c_str()); + return false; + } + + return true; + } + catch (const SimpleWeb::system_error &e) { + m_logger->error("Raise alert failed %s.", e.what()); + return false; + } +} + From c8242638b7fa97f92050e4d19280050c9555ab6a Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 9 Feb 2024 15:21:41 +0530 Subject: [PATCH 059/146] alert raised on restarting a service Signed-off-by: ashish-jabble --- .../services/core/service_registry/monitor.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/python/fledge/services/core/service_registry/monitor.py b/python/fledge/services/core/service_registry/monitor.py index 503fd60529..ae9c4112a5 100644 --- a/python/fledge/services/core/service_registry/monitor.py +++ b/python/fledge/services/core/service_registry/monitor.py @@ -192,6 +192,32 @@ async def restart_service(self, service_record): schedule = await server.Server.scheduler.get_schedule_by_name(service_record._name) await server.Server.scheduler.queue_task(schedule.schedule_id) self.restarted_services.remove(service_record._id) + # Raise an alert during restart service + await self.raise_an_alert(server.Server, service_record._name) + + async def raise_an_alert(self, obj, svc_name): + async def _new_alert_entry(restart_count=1): + param = {"key": svc_name, "message": 'The Service {} restarted {} times'.format( + svc_name, restart_count), "urgency": "3"} + await obj._alert_manager.add(param) + + try: + alert = await obj._alert_manager.get_by_key(svc_name) + message = alert['message'].strip() + key = alert['key'] + if message.startswith('The Service {} restarted'.format(key)) and message.endswith("times"): + result = [int(s) for s in message.split() if s.isdigit()] + if result: + await obj._alert_manager.delete(key) + await _new_alert_entry(result[-1:][0] + 1) + else: + await _new_alert_entry() + else: + await _new_alert_entry() + except KeyError: + await _new_alert_entry() + except Exception as ex: + self._logger.error(ex, "Failed to raise an alert on restarting {} service.".format(svc_name)) async def start(self): await self._read_config() From 284380441ce8cbb27cfc7533e5aed4ec02843279 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 9 Feb 2024 17:22:38 +0530 Subject: [PATCH 060/146] On deleting a service user alerts got deleted as well Signed-off-by: ashish-jabble --- python/fledge/services/core/api/service.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index 02f77fccfd..cee91d0d71 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -176,6 +176,12 @@ async def delete_service(request): # Update deprecated timestamp in asset_tracker await update_deprecated_ts_in_asset_tracker(storage, svc) + + # Delete user alerts + try: + await server.Server._alert_manager.delete(svc) + except: + pass except Exception as ex: raise web.HTTPInternalServerError(reason=str(ex)) else: From a65c658f672ca1f08aa46cb0f66e3b93f96dd216 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 13 Feb 2024 13:13:58 +0530 Subject: [PATCH 061/146] list configuration type support added Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 57d1885bca..6365202f16 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -35,7 +35,7 @@ # MAKE UPPER_CASE _valid_type_strings = sorted(['boolean', 'integer', 'float', 'string', 'IPv4', 'IPv6', 'X509 certificate', 'password', - 'JSON', 'URL', 'enumeration', 'script', 'code', 'northTask', 'ACL', 'bucket']) + 'JSON', 'URL', 'enumeration', 'script', 'code', 'northTask', 'ACL', 'bucket', 'list']) _optional_items = sorted(['readonly', 'order', 'length', 'maximum', 'minimum', 'rule', 'deprecated', 'displayName', 'validity', 'mandatory', 'group']) RESERVED_CATG = ['South', 'North', 'General', 'Advanced', 'Utilities', 'rest_api', 'Security', 'service', 'SCHEDULER', @@ -331,6 +331,42 @@ def get_entry_val(k): raise TypeError('For {} category, entry value must be a string for item name {} and ' 'entry name {}; got {}'.format(category_name, item_name, entry_name, type(entry_val))) + # Validate list type and mandatory items + elif 'type' in item_val and get_entry_val("type") == 'list': + if not isinstance(entry_val, str): + raise TypeError('For {} category, entry value must be a string for item name {} and ' + 'entry name {}; got {}'.format(category_name, item_name, entry_name, + type(entry_val))) + if 'items' not in item_val: + raise KeyError('For {} category, items KV pair must be required ' + 'for item name {}.'.format(category_name, item_name)) + if entry_name == 'items': + if entry_val not in ("string", "float", "integer"): + raise ValueError("For {} category, items value should either be in string, " + "float or integer for item name {}".format(category_name, item_name)) + default_val = get_entry_val("default") + try: + eval_default_val = ast.literal_eval(default_val) + except: + raise ValueError("For {} category, default value should be passed an array list in " + "string format for item name {}".format(category_name, item_name)) + type_check = str + if entry_val == 'integer': + type_check = int + elif entry_val == 'float': + type_check = float + type_mismatched_message = ("For {} category, all elements should be of same {} type " + "in default value for item name {}").format(category_name, + type_check, item_name) + for s in eval_default_val: + try: + eval_s = s if entry_val == "string" else ast.literal_eval(s) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) + d = {entry_name: entry_val} + expected_item_entries.update(d) else: if type(entry_val) is not str: raise TypeError('For {} category, entry value must be a string for item name {} and ' From 30310425802d53c4c023fc4f22acfec673050c3f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 13 Feb 2024 13:14:51 +0530 Subject: [PATCH 062/146] unit tests added for list configuration type Signed-off-by: ashish-jabble --- .../common/test_configuration_manager.py | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 3587e48c00..60b008229e 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -35,7 +35,7 @@ def reset_singleton(self): def test_supported_validate_type_strings(self): expected_types = ['IPv4', 'IPv6', 'JSON', 'URL', 'X509 certificate', 'boolean', 'code', 'enumeration', 'float', 'integer', - 'northTask', 'password', 'script', 'string', 'ACL', 'bucket'] + 'northTask', 'password', 'script', 'string', 'ACL', 'bucket', 'list'] assert len(expected_types) == len(_valid_type_strings) assert sorted(expected_types) == _valid_type_strings @@ -577,7 +577,61 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re set_value_val_from_default_val=False) assert excinfo.type is exc_name assert reason == str(excinfo.value) - + + @pytest.mark.parametrize("config, exc_name, reason", [ + ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A"}}, KeyError, + "'For {} category, items KV pair must be required for item name {}.'".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": []}}, TypeError, + "For {} category, entry value must be a string for item name {} and entry name items; " + "got ".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "str"}}, ValueError, + "For {} category, items value should either be in string, float or integer for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "float"}}, ValueError, + "For {} category, default value should be passed an array list in string format for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"AJ\"]", "items": "float"}}, ValueError, + "For {} category, all elements should be of same type in default value for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"13\", \"AJ\"]", "items": "integer"}}, + ValueError, "For {} category, all elements should be of same type in default " + "value for item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"13\", \"1.04\"]", "items": "integer"}}, + ValueError, "For {} category, all elements should be of same type in default " + "value for item name {}".format(CAT_NAME, ITEM_NAME)), + ({"include": {"description": "multiple", "type": "list", "default": "[\"135\", \"1111\"]", "items": "integer", + "value": "1"}, + ITEM_NAME: {"description": "test", "type": "list", "default": "[\"13\", \"1\"]", "items": "float"}}, + ValueError, "For {} category, all elements should be of same type in default " + "value for item name {}".format(CAT_NAME, ITEM_NAME)), + ]) + async def test__validate_category_val_list_type_bad(self, config, exc_name, reason): + storage_client_mock = MagicMock(spec=StorageClientAsync) + c_mgr = ConfigurationManager(storage_client_mock) + with pytest.raises(Exception) as excinfo: + await c_mgr._validate_category_val(category_name=CAT_NAME, category_val=config, + set_value_val_from_default_val=False) + assert excinfo.type is exc_name + assert reason == str(excinfo.value) + + @pytest.mark.parametrize("config", [ + {"include": {"description": "A list of variables to include", "type": "list", "items": "string", + "default": "[]"}}, + {"include": {"description": "A list of variables to include", "type": "list", "items": "string", + "default": "[\"first\", \"second\"]"}}, + {"include": {"description": "A list of variables to include", "type": "list", "items": "integer", + "default": "[\"1\", \"0\"]"}}, + {"include": {"description": "A list of variables to include", "type": "list", "items": "float", + "default": "[\"0.5\", \"123.57\"]"}} + ]) + async def test__validate_category_val_list_type_good(self, config): + storage_client_mock = MagicMock(spec=StorageClientAsync) + c_mgr = ConfigurationManager(storage_client_mock) + res = await c_mgr._validate_category_val(category_name=CAT_NAME, category_val=config, + set_value_val_from_default_val=True) + assert config['include']['default'] == res['include']['default'] + assert config['include']['default'] == res['include']['value'] + @pytest.mark.parametrize("_type, value, from_default_val", [ ("integer", " ", False), ("string", "", False), From 5205e8896cf21b0fd256cb8c551d01005d8fb643 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 14 Feb 2024 14:42:17 +0530 Subject: [PATCH 063/146] listSize optional attribute support added in configuration manager Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 63 ++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 6365202f16..4179fd29d0 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -37,7 +37,7 @@ _valid_type_strings = sorted(['boolean', 'integer', 'float', 'string', 'IPv4', 'IPv6', 'X509 certificate', 'password', 'JSON', 'URL', 'enumeration', 'script', 'code', 'northTask', 'ACL', 'bucket', 'list']) _optional_items = sorted(['readonly', 'order', 'length', 'maximum', 'minimum', 'rule', 'deprecated', 'displayName', - 'validity', 'mandatory', 'group']) + 'validity', 'mandatory', 'group', 'listSize']) RESERVED_CATG = ['South', 'North', 'General', 'Advanced', 'Utilities', 'rest_api', 'Security', 'service', 'SCHEDULER', 'SMNTR', 'PURGE_READ', 'Notifications'] @@ -268,7 +268,7 @@ async def _validate_category_val(self, category_name, category_val, set_value_va optional_item_entries = {'readonly': 0, 'order': 0, 'length': 0, 'maximum': 0, 'minimum': 0, 'deprecated': 0, 'displayName': 0, 'rule': 0, 'validity': 0, 'mandatory': 0, - 'group': 0} + 'group': 0, 'listSize': 0} expected_item_entries = {'description': 0, 'default': 0, 'type': 0} if require_entry_value: @@ -345,10 +345,30 @@ def get_entry_val(k): raise ValueError("For {} category, items value should either be in string, " "float or integer for item name {}".format(category_name, item_name)) default_val = get_entry_val("default") + list_size = -1 + if 'listSize' in item_val: + list_size = item_val['listSize'] + if not isinstance(list_size, str): + raise TypeError('For {} category, listSize type must be a string for item name {}; ' + 'got {}'.format(category_name, item_name, type(list_size))) + if self._validate_type_value('listSize', list_size) is False: + raise ValueError('For {} category, listSize value must be an integer value ' + 'for item name {}'.format(category_name, item_name)) + list_size = int(item_val['listSize']) try: eval_default_val = ast.literal_eval(default_val) + if len(eval_default_val) > len(set(eval_default_val)): + raise ArithmeticError("For {} category, default value array elements are not " + "unique for item name {}".format(category_name, item_name)) + if list_size >= 0: + if len(eval_default_val) != list_size: + raise ArithmeticError("For {} category, default value array list size limit to " + "{} for item name {}".format(category_name, + list_size, item_name)) + except ArithmeticError as err: + raise ValueError(err) except: - raise ValueError("For {} category, default value should be passed an array list in " + raise TypeError("For {} category, default value should be passed an array list in " "string format for item name {}".format(category_name, item_name)) type_check = str if entry_val == 'integer': @@ -386,8 +406,8 @@ def get_entry_val(k): 'For {} category, A default value must be given for {}'.format(category_name, item_name)) elif entry_name == 'minimum' or entry_name == 'maximum': - if (self._validate_type_value('integer', entry_val) or self._validate_type_value('float', - entry_val)) is False: + if (self._validate_type_value('integer', entry_val) or + self._validate_type_value('float', entry_val)) is False: raise ValueError('For {} category, entry value must be an integer or float for item name ' '{}; got {}'.format(category_name, entry_name, type(entry_val))) elif entry_name in ('displayName', 'group', 'rule', 'validity'): @@ -395,7 +415,8 @@ def get_entry_val(k): raise ValueError('For {} category, entry value must be string for item name {}; got {}' .format(category_name, entry_name, type(entry_val))) else: - if self._validate_type_value('integer', entry_val) is False: + if (self._validate_type_value('integer', entry_val) or + self._validate_type_value('listSize', entry_val)) is False: raise ValueError('For {} category, entry value must be an integer for item name {}; got {}' .format(category_name, entry_name, type(entry_val))) @@ -1659,7 +1680,7 @@ def _str_to_ipaddress(item_val): if _type == 'boolean': return _str_to_bool(_value) - elif _type == 'integer': + elif _type in ('integer', 'listSize'): return _str_to_int(_value) elif _type == 'float': return _str_to_float(_value) @@ -1771,4 +1792,30 @@ def in_range(n, start, end): _max_value = float(storage_value_entry['maximum']) if _new_value > _max_value: raise TypeError('For config item {} you cannot set the new value, above {}'.format(item_name, - _max_value)) \ No newline at end of file + _max_value)) + if config_item_type == "list": + eval_new_val = ast.literal_eval(new_value_entry) + if len(eval_new_val) > len(set(eval_new_val)): + raise ValueError("For config item {} elements are not unique".format(item_name)) + if 'listSize' in storage_value_entry: + list_size = int(storage_value_entry['listSize']) + if list_size >= 0: + if len(eval_new_val) != list_size: + raise TypeError("For config item {} value array list size limit to {}".format( + item_name, list_size)) + + type_mismatched_message = "For config item {} all elements should be of same {} type".format( + item_name, storage_value_entry['items']) + type_check = str + if storage_value_entry['items'] == 'integer': + type_check = int + elif storage_value_entry['items'] == 'float': + type_check = float + + for s in eval_new_val: + try: + eval_s = s if storage_value_entry['items'] == "string" else ast.literal_eval(s) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) \ No newline at end of file From ea5b87f0fdb0e597b6c9f9fa8f68c261bff9103d Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 14 Feb 2024 14:42:58 +0530 Subject: [PATCH 064/146] listSize configuration optional attribute unit tests added Signed-off-by: ashish-jabble --- .../common/test_configuration_manager.py | 138 +++++++++++++++--- 1 file changed, 114 insertions(+), 24 deletions(-) diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 60b008229e..99cc790196 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -40,9 +40,10 @@ def test_supported_validate_type_strings(self): assert sorted(expected_types) == _valid_type_strings def test_supported_optional_items(self): - assert 11 == len(_optional_items) - assert ['deprecated', 'displayName', 'group', 'length', 'mandatory', 'maximum', 'minimum', 'order', - 'readonly', 'rule', 'validity'] == _optional_items + expected_types = ['deprecated', 'displayName', 'group', 'length', 'mandatory', 'maximum', 'minimum', 'order', + 'readonly', 'rule', 'validity', 'listSize'] + assert len(expected_types) == len(_optional_items) + assert sorted(expected_types) == _optional_items def test_constructor_no_storage_client_defined_no_storage_client_passed( self, reset_singleton): @@ -587,7 +588,7 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "str"}}, ValueError, "For {} category, items value should either be in string, float or integer for item name {}".format( CAT_NAME, ITEM_NAME)), - ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "float"}}, ValueError, + ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "float"}}, TypeError, "For {} category, default value should be passed an array list in string format for item name {}".format( CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"AJ\"]", "items": "float"}}, ValueError, @@ -604,6 +605,27 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re ITEM_NAME: {"description": "test", "type": "list", "default": "[\"13\", \"1\"]", "items": "float"}}, ValueError, "For {} category, all elements should be of same type in default " "value for item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[]", "items": "float", "listSize": 1}}, + TypeError, "For {} category, listSize type must be a string for item name {}; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[]", "items": "float", "listSize": ""}}, + ValueError, "For {} category, listSize value must be an integer value for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[]", "items": "float", "listSize": "1"}}, + ValueError, "For {} category, default value array list size limit to 1 for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"1\"]", "items": "integer", + "listSize": "0"}}, ValueError, "For {} category, default value array list size limit to 0 " + "for item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"6e7777\", \"1.79e+308\"]", + "items": "float", "listSize": "3"}}, ValueError, + "For {} category, default value array list size limit to 3 for item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"1\", \"2\", \"1\"]", "items": "integer", + "listSize": "3"}}, ValueError, "For {} category, default value array elements are not unique " + "for item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"a\", \"b\", \"ab\", \"a\"]", + "items": "string"}}, ValueError, "For {} category, default value array elements are not unique " + "for item name {}".format(CAT_NAME, ITEM_NAME)) ]) async def test__validate_category_val_list_type_bad(self, config, exc_name, reason): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -622,7 +644,13 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas {"include": {"description": "A list of variables to include", "type": "list", "items": "integer", "default": "[\"1\", \"0\"]"}}, {"include": {"description": "A list of variables to include", "type": "list", "items": "float", - "default": "[\"0.5\", \"123.57\"]"}} + "default": "[\"0.5\", \"123.57\"]"}}, + {"include": {"description": "A list of variables to include", "type": "list", "items": "float", + "default": "[\".5\", \"1.79e+308\"]", "listSize": "2"}}, + {"include": {"description": "A list of variables to include", "type": "list", "items": "string", + "default": "[\"var1\", \"var2\"]", "listSize": "2"}}, + {"include": {"description": "A list of variables to include", "type": "list", "items": "integer", + "default": "[\"10\", \"100\", \"200\", \"300\"]", "listSize": "4"}}, ]) async def test__validate_category_val_list_type_good(self, config): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -640,13 +668,17 @@ async def test__validate_category_val_list_type_good(self, config): ("JSON", " ", False), ("bucket", "", False), ("bucket", " ", False), + ("list", "", False), + ("list", " ", False), ("integer", " ", True), ("string", "", True), ("string", " ", True), ("JSON", "", True), ("JSON", " ", True), ("bucket", "", True), - ("bucket", " ", True) + ("bucket", " ", True), + ("list", "", True), + ("list", " ", True) ]) async def test__validate_category_val_with_optional_mandatory(self, _type, value, from_default_val): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -655,6 +687,9 @@ async def test__validate_category_val_with_optional_mandatory(self, _type, value "mandatory": "true"}} if _type == "bucket": test_config[ITEM_NAME]['properties'] = {"key": "foo"} + elif _type == "list": + test_config[ITEM_NAME]['items'] = "string" + with pytest.raises(Exception) as excinfo: await c_mgr._validate_category_val(category_name=CAT_NAME, category_val=test_config, set_value_val_from_default_val=from_default_val) @@ -3251,7 +3286,9 @@ async def test__clean(self, item_type, item_val, result): ("URL", "coaps://host:6683", True), ("password", "not implemented", None), ("X509 certificate", "not implemented", None), - ("northTask", "valid_north_task", True) + ("northTask", "valid_north_task", True), + ("listSize", "5", True), + ("listSize", "0", True) ]) async def test__validate_type_value(self, item_type, item_val, result): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -3283,7 +3320,9 @@ async def test__validate_type_value(self, item_type, item_val, result): ("JSON", None), ("URL", "blah"), ("URL", "example.com"), - ("URL", "123:80") + ("URL", "123:80"), + ("listSize", "Blah"), + ("listSize", "None") # TODO: can not use urlopen hence we may want to check # result.netloc with some regex, but limited # ("URL", "http://somevalue.a"), @@ -3624,34 +3663,67 @@ async def async_mock(return_value): assert 1 == log_exc.call_count log_exc.assert_called_once_with('Unable to set optional %s entry based on category_name %s and item_name %s and value_item_entry %s', optional_key_name, 'catname', 'itemname', new_value_entry) - @pytest.mark.parametrize("new_value_entry, storage_value_entry, exc_msg", [ + @pytest.mark.parametrize("new_value_entry, storage_value_entry, exc_msg, exc_type", [ ("Fledge", {'default': 'FOG', 'length': '3', 'displayName': 'Length Test', 'value': 'fog', 'type': 'string', - 'description': 'Test value '}, 'beyond the length 3'), + 'description': 'Test value '}, + 'For config item {} you cannot set the new value, beyond the length 3', TypeError), ("0", {'order': '4', 'default': '10', 'minimum': '10', 'maximum': '19', 'displayName': 'RangeMin Test', - 'value': '15', 'type': 'integer', 'description': 'Test value'}, 'beyond the range (10,19)'), + 'value': '15', 'type': 'integer', 'description': 'Test value'}, + 'For config item {} you cannot set the new value, beyond the range (10,19)', TypeError), ("20", {'order': '4', 'default': '10', 'minimum': '10', 'maximum': '19', 'displayName': 'RangeMax Test', - 'value': '19', 'type': 'integer', 'description': 'Test value'}, 'beyond the range (10,19)'), + 'value': '19', 'type': 'integer', 'description': 'Test value'}, + 'For config item {} you cannot set the new value, beyond the range (10,19)', TypeError), ("1", {'order': '5', 'default': '2', 'minimum': '2', 'displayName': 'MIN', 'value': '10', 'type': 'integer', - 'description': 'Test value '}, 'below 2'), + 'description': 'Test value '}, 'For config item {} you cannot set the new value, below 2', TypeError), ("11", {'default': '10', 'maximum': '10', 'displayName': 'MAX', 'value': '10', 'type': 'integer', - 'description': 'Test value'}, 'above 10'), + 'description': 'Test value'}, 'For config item {} you cannot set the new value, above 10', TypeError), ("19.0", {'default': '19.3', 'minimum': '19.1', 'maximum': '19.5', 'displayName': 'RangeMin Test', - 'value': '19.1', 'type': 'float', 'description': 'Test val'}, 'beyond the range (19.1,19.5)'), + 'value': '19.1', 'type': 'float', 'description': 'Test val'}, + 'For config item {} you cannot set the new value, beyond the range (19.1,19.5)', TypeError), ("19.6", {'default': '19.4', 'minimum': '19.1', 'maximum': '19.5', 'displayName': 'RangeMax Test', - 'value': '19.5', 'type': 'float', 'description': 'Test val'}, 'beyond the range (19.1,19.5)'), + 'value': '19.5', 'type': 'float', 'description': 'Test val'}, + 'For config item {} you cannot set the new value, beyond the range (19.1,19.5)', TypeError), ("20", {'order': '8', 'default': '10.1', 'maximum': '19.8', 'displayName': 'MAX Test', 'value': '10.1', - 'type': 'float', 'description': 'Test value'}, 'above 19.8'), + 'type': 'float', 'description': 'Test value'}, + 'For config item {} you cannot set the new value, above 19.8', TypeError), ("0.7", {'order': '9', 'default': '0.9', 'minimum': '0.8', 'displayName': 'MIN Test', 'value': '0.9', - 'type': 'float', 'description': 'Test value'}, 'below 0.8') + 'type': 'float', 'description': 'Test value'}, + 'For config item {} you cannot set the new value, below 0.8', TypeError), + ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"1\"]', 'order': '2', + 'items': 'integer', 'listSize': '2', 'value': '[\"1\", \"2\"]'}, + "For config item {} value array list size limit to 2", TypeError), + ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', + 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, + "For config item {} value array list size limit to 1", TypeError), + ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.4\", \".03\", \"50.67\"]', 'order': '2', + 'items': 'float', 'listSize': '3', 'value': '[\"1.4\", \".03\", \"50.67\"]'}, + "For config item {} value array list size limit to 3", TypeError), + ("[\"10\", \"10\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"2\"]', 'order': '2', + 'items': 'integer', 'value': '[\"3\", \"4\"]'}, "For config item {} elements are not unique", ValueError), + ("[\"foo\", \"foo\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a\", \"c\"]', 'order': '2', + 'items': 'string', 'value': '[\"abc\", \"def\"]'}, + "For config item {} elements are not unique", ValueError), + ("[\".002\", \".002\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.2\", \"1.4\"]', + 'order': '2', 'items': 'float', 'value': '[\"5.67\", \"12.0\"]'}, + "For config item {} elements are not unique", ValueError), + ("[\"10\", \"foo\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"2\"]', 'order': '2', + 'items': 'integer', 'value': '[\"3\", \"4\"]'}, + "For config item {} all elements should be of same integer type", ValueError), + ("[\"foo\", 1]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a\", \"c\"]', 'order': '2', + 'items': 'string', 'value': '[\"abc\", \"def\"]'}, + "For config item {} all elements should be of same string type", ValueError), + ("[\"1\", \"2\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.2\", \"1.4\"]', + 'order': '2', 'items': 'float', 'value': '[\"5.67\", \"12.0\"]'}, + "For config item {} all elements should be of same float type", ValueError) ]) - def test_bad__validate_value_per_optional_attribute(self, new_value_entry, storage_value_entry, exc_msg): - message = "For config item {} you cannot set the new value, {}".format(ITEM_NAME, exc_msg) + def test_bad__validate_value_per_optional_attribute(self, new_value_entry, storage_value_entry, exc_msg, exc_type): storage_client_mock = MagicMock(spec=StorageClientAsync) c_mgr = ConfigurationManager(storage_client_mock) - with pytest.raises(Exception) as excinfo: + with pytest.raises(Exception) as exc_info: c_mgr._validate_value_per_optional_attribute(ITEM_NAME, storage_value_entry, new_value_entry) - assert excinfo.type is TypeError - assert message == str(excinfo.value) + assert exc_info.type is exc_type + msg = exc_msg.format(ITEM_NAME) + assert msg == str(exc_info.value) @pytest.mark.parametrize("new_value_entry, storage_value_entry", [ ("Fledge", {'default': 'FOG', 'length': '7', 'displayName': 'Length Test', 'value': 'fledge', @@ -3669,7 +3741,25 @@ def test_bad__validate_value_per_optional_attribute(self, new_value_entry, stora ("19", {'order': '4', 'default': '10', 'minimum': '10', 'maximum': '19', 'displayName': 'RangeMax Test', 'value': '15', 'type': 'integer', 'description': 'Test value'}), ("15", {'order': '4', 'default': '10', 'minimum': '10', 'maximum': '19', 'displayName': 'Range Test', - 'value': '15', 'type': 'integer', 'description': 'Test value'}) + 'value': '15', 'type': 'integer', 'description': 'Test value'}), + ("[\"10\", \"20\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"2\"]', 'order': '2', + 'items': 'integer', 'value': '[\"3\", \"4\"]'}), + ("[\"foo\", \"bar\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a\", \"c\"]', 'order': '2', + 'items': 'string', 'value': '[\"abc\", \"def\"]'}), + ("[\".002\", \"1.002\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.2\", \"1.4\"]', + 'order': '2', 'items': 'float', 'value': '[\"5.67\", \"12.0\"]'}), + ("[\"10\", \"20\", \"30\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"2\"]', + 'order': '2', 'items': 'integer', 'listSize': "3", 'value': '[\"3\", \"4\"]'}), + ("[\"new string\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a\", \"c\"]', 'order': '2', + 'items': 'string', 'listSize': "1", 'value': '[\"abc\", \"def\"]'}), + ("[\"6.523e-07\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.2\", \"1.4\"]', + 'order': '2', 'items': 'float', 'listSize': "1", 'value': '[\"5.67\", \"12.0\"]'}), + ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"2\"]', + 'order': '2', 'items': 'integer', 'listSize': "0", 'value': '[\"3\", \"4\"]'}), + ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a\", \"c\"]', 'order': '2', + 'items': 'string', 'listSize': "0", 'value': '[\"abc\", \"def\"]'}), + ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.2\", \"1.4\"]', + 'order': '2', 'items': 'float', 'listSize': "0", 'value': '[\"5.67\", \"12.0\"]'}) ]) def test_good__validate_value_per_optional_attribute(self, new_value_entry, storage_value_entry): storage_client_mock = MagicMock(spec=StorageClientAsync) From 8cb513233aca857b7dec9ed6515e3980de880166 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 14 Feb 2024 16:56:29 +0530 Subject: [PATCH 065/146] length, max, min validations added with item list type along with unit tests Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 45 +++++++++----- .../common/test_configuration_manager.py | 61 ++++++++++++++++++- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 4179fd29d0..dcdb790d3d 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -1752,21 +1752,20 @@ def _validate_value_per_optional_attribute(self, item_name, storage_value_entry, def in_range(n, start, end): return start <= n <= end # start and end inclusive - config_item_type = storage_value_entry['type'] - if config_item_type == 'string': + def _validate_length(val): if 'length' in storage_value_entry: - if len(new_value_entry) > int(storage_value_entry['length']): + if len(val) > int(storage_value_entry['length']): raise TypeError('For config item {} you cannot set the new value, beyond the length {}'.format( item_name, storage_value_entry['length'])) - if config_item_type == 'integer' or config_item_type == 'float': + def _validate_min_max(_type, val): if 'minimum' in storage_value_entry and 'maximum' in storage_value_entry: - if config_item_type == 'integer': - _new_value = int(new_value_entry) + if _type == 'integer': + _new_value = int(val) _min_value = int(storage_value_entry['minimum']) _max_value = int(storage_value_entry['maximum']) else: - _new_value = float(new_value_entry) + _new_value = float(val) _min_value = float(storage_value_entry['minimum']) _max_value = float(storage_value_entry['maximum']) @@ -1774,25 +1773,34 @@ def in_range(n, start, end): raise TypeError('For config item {} you cannot set the new value, beyond the range ({},{})'.format( item_name, storage_value_entry['minimum'], storage_value_entry['maximum'])) elif 'minimum' in storage_value_entry: - if config_item_type == 'integer': - _new_value = int(new_value_entry) + if _type == 'integer': + _new_value = int(val) _min_value = int(storage_value_entry['minimum']) else: - _new_value = float(new_value_entry) + _new_value = float(val) _min_value = float(storage_value_entry['minimum']) if _new_value < _min_value: raise TypeError('For config item {} you cannot set the new value, below {}'.format(item_name, _min_value)) elif 'maximum' in storage_value_entry: - if config_item_type == 'integer': - _new_value = int(new_value_entry) + if _type == 'integer': + _new_value = int(val) _max_value = int(storage_value_entry['maximum']) else: - _new_value = float(new_value_entry) + _new_value = float(val) _max_value = float(storage_value_entry['maximum']) if _new_value > _max_value: raise TypeError('For config item {} you cannot set the new value, above {}'.format(item_name, _max_value)) + + + config_item_type = storage_value_entry['type'] + if config_item_type == 'string': + _validate_length(new_value_entry) + + if config_item_type == 'integer' or config_item_type == 'float': + _validate_min_max(config_item_type, new_value_entry) + if config_item_type == "list": eval_new_val = ast.literal_eval(new_value_entry) if len(eval_new_val) > len(set(eval_new_val)): @@ -1814,8 +1822,15 @@ def in_range(n, start, end): for s in eval_new_val: try: - eval_s = s if storage_value_entry['items'] == "string" else ast.literal_eval(s) + eval_s = s + if storage_value_entry['items'] in ("integer", "float"): + eval_s = ast.literal_eval(s) + _validate_min_max(storage_value_entry['items'], eval_s) + elif storage_value_entry['items'] == 'string': + _validate_length(eval_s) + except TypeError as err: + raise ValueError(err) except: raise ValueError(type_mismatched_message) if not isinstance(eval_s, type_check): - raise ValueError(type_mismatched_message) \ No newline at end of file + raise ValueError(type_mismatched_message) diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 99cc790196..9bf56e81b9 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -3714,7 +3714,40 @@ async def async_mock(return_value): "For config item {} all elements should be of same string type", ValueError), ("[\"1\", \"2\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.2\", \"1.4\"]', 'order': '2', 'items': 'float', 'value': '[\"5.67\", \"12.0\"]'}, - "For config item {} all elements should be of same float type", ValueError) + "For config item {} all elements should be of same float type", ValueError), + ("[\"100\", \"2\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"34\", \"48\"]', 'order': '2', + 'items': 'integer', 'listSize': '2', 'value': '[\"34\", \"48\"]', 'minimum': '20'}, + "For config item {} you cannot set the new value, below 20", ValueError), + ("[\"50\", \"49\", \"51\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"34\", \"48\"]', + 'order': '2', 'items': 'integer', 'listSize': '3', + 'value': '[\"34\", \"48\"]', 'maximum': '50'}, + "For config item {} you cannot set the new value, above 50", ValueError), + ("[\"50\", \"49\", \"46\"]", {'description': 'Simple list', 'type': 'list', 'default': + '[\"50\", \"48\", \"49\"]', 'order': '2', 'items': 'integer', 'listSize': '3', + 'value': '[\"47\", \"48\", \"49\"]', 'maximum': '50', 'minimum': '47'}, + "For config item {} you cannot set the new value, beyond the range (47,50)", ValueError), + ("[\"50\", \"49\", \"51\"]", {'description': 'Simple list', 'type': 'list', 'default': + '[\"50\", \"48\", \"49\"]', 'order': '2', 'items': 'integer', 'listSize': '3', + 'value': '[\"47\", \"48\", \"49\"]', 'maximum': '50', 'minimum': '47'}, + "For config item {} you cannot set the new value, beyond the range (47,50)", ValueError), + ("[\"foo\", \"bars\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a1\", \"c1\"]', + 'order': '2', 'items': 'string', 'value': '[\"ab\", \"de\"]', 'listSize': '2', + 'length': '3'}, + "For config item {} you cannot set the new value, beyond the length 3", ValueError), + ("[\"2.6\", \"1.002\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"5.2\", \"2.5\"]', + 'order': '2', 'items': 'float', 'value': '[\"5.67\", \"2.5\"]', 'minimum': '2.5', + 'listSize': '2'}, "For config item {} you cannot set the new value, below 2.5", + ValueError), + ("[\"2.6\", \"1.002\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"2.2\", \"2.5\"]', + 'order': '2', 'items': 'float', 'value': '[\"1.67\", \"2.5\"]', 'maximum': '2.5', + 'listSize': '2'}, "For config item {} you cannot set the new value, above 2.5", + ValueError), + ("[\"2.6\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"2.2\"]', 'order': '2', + 'items': 'float', 'value': '[\"2.5\"]', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}, + "For config item {} you cannot set the new value, beyond the range (2,2.5)", ValueError), + ("[\"1.999\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"2.2\"]', 'order': '2', + 'items': 'float', 'value': '[\"2.5\"]', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}, + "For config item {} you cannot set the new value, beyond the range (2,2.5)", ValueError) ]) def test_bad__validate_value_per_optional_attribute(self, new_value_entry, storage_value_entry, exc_msg, exc_type): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -3759,7 +3792,31 @@ def test_bad__validate_value_per_optional_attribute(self, new_value_entry, stora ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a\", \"c\"]', 'order': '2', 'items': 'string', 'listSize': "0", 'value': '[\"abc\", \"def\"]'}), ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.2\", \"1.4\"]', - 'order': '2', 'items': 'float', 'listSize': "0", 'value': '[\"5.67\", \"12.0\"]'}) + 'order': '2', 'items': 'float', 'listSize': "0", 'value': '[\"5.67\", \"12.0\"]'}), + ("[\"100\", \"20\"]", {'description': 'SL', 'type': 'list', 'default': '[\"34\", \"48\"]', 'order': '2', + 'items': 'integer', 'listSize': '2', 'value': '[\"34\", \"48\"]', 'minimum': '20'}), + ("[\"50\", \"49\", \"0\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"34\", \"48\"]', + 'order': '2', 'items': 'integer', 'listSize': '3', + 'value': '[\"34\", \"48\"]', 'maximum': '50'}), + ("[\"50\", \"49\", \"47\"]", {'description': 'Simple list', 'type': 'list', 'default': + '[\"50\", \"48\", \"49\"]', 'order': '2', 'items': 'integer', 'listSize': '3', + 'value': '[\"47\", \"48\", \"49\"]', 'maximum': '50', 'minimum': '47'}), + ("[\"50\", \"49\", \"48\"]", {'description': 'Simple list', 'type': 'list', 'default': + '[\"50\", \"48\", \"49\"]', 'order': '2', 'items': 'integer', 'listSize': '3', + 'value': '[\"47\", \"48\", \"49\"]', 'maximum': '50', 'minimum': '47'}), + ("[\"foo\", \"bar\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a1\", \"c1\"]', + 'order': '2', 'items': 'string', 'value': '[\"ab\", \"de\"]', 'listSize': '2', + 'length': '3'}), + ("[\"2.6\", \"13.002\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"5.2\", \"2.5\"]', + 'order': '2', 'items': 'float', 'value': '[\"5.67\", \"2.5\"]', 'minimum': '2.5', + 'listSize': '2'}), + ("[\"2.4\", \"1.002\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"2.2\", \"2.5\"]', + 'order': '2', 'items': 'float', 'value': '[\"1.67\", \"2.5\"]', 'maximum': '2.5', + 'listSize': '2'}), + ("[\"2.0\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"2.2\"]', 'order': '2', + 'items': 'float', 'value': '[\"2.5\"]', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}), + ("[\"2.5\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"2.2\"]', 'order': '2', + 'items': 'float', 'value': '[\"2.5\"]', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}) ]) def test_good__validate_value_per_optional_attribute(self, new_value_entry, storage_value_entry): storage_client_mock = MagicMock(spec=StorageClientAsync) From f831a20dcadd43f609466565b30802d1352b2ecf Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 14 Feb 2024 19:05:45 +0530 Subject: [PATCH 066/146] empty value case handling in simple list config type on validating optional routine Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 6 +++++- .../python/fledge/common/test_configuration_manager.py | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index dcdb790d3d..323c0d5855 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -1802,7 +1802,11 @@ def _validate_min_max(_type, val): _validate_min_max(config_item_type, new_value_entry) if config_item_type == "list": - eval_new_val = ast.literal_eval(new_value_entry) + try: + eval_new_val = ast.literal_eval(new_value_entry) + except: + raise TypeError("For config item {} value should be passed an array list in string format".format( + item_name)) if len(eval_new_val) > len(set(eval_new_val)): raise ValueError("For config item {} elements are not unique".format(item_name)) if 'listSize' in storage_value_entry: diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 9bf56e81b9..cc4f6bedfe 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -3689,9 +3689,18 @@ async def async_mock(return_value): ("0.7", {'order': '9', 'default': '0.9', 'minimum': '0.8', 'displayName': 'MIN Test', 'value': '0.9', 'type': 'float', 'description': 'Test value'}, 'For config item {} you cannot set the new value, below 0.8', TypeError), + ("", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"1\"]', 'order': '2', + 'items': 'integer', 'listSize': '2', 'value': '[\"1\", \"2\"]'}, + "For config item {} value should be passed an array list in string format", TypeError), ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"1\"]', 'order': '2', 'items': 'integer', 'listSize': '2', 'value': '[\"1\", \"2\"]'}, "For config item {} value array list size limit to 2", TypeError), + ("", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', + 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, + "For config item {} value should be passed an array list in string format", TypeError), + ("", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', + 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, + "For config item {} value should be passed an array list in string format", TypeError), ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, "For config item {} value array list size limit to 1", TypeError), From f1c5dcf92beaf2ebad856dafdfb546d8309267a2 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 16 Feb 2024 12:20:40 +0530 Subject: [PATCH 067/146] kvlist configuration type support added Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 323c0d5855..30105d2154 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -35,7 +35,8 @@ # MAKE UPPER_CASE _valid_type_strings = sorted(['boolean', 'integer', 'float', 'string', 'IPv4', 'IPv6', 'X509 certificate', 'password', - 'JSON', 'URL', 'enumeration', 'script', 'code', 'northTask', 'ACL', 'bucket', 'list']) + 'JSON', 'URL', 'enumeration', 'script', 'code', 'northTask', 'ACL', 'bucket', + 'list', 'kvlist']) _optional_items = sorted(['readonly', 'order', 'length', 'maximum', 'minimum', 'rule', 'deprecated', 'displayName', 'validity', 'mandatory', 'group', 'listSize']) RESERVED_CATG = ['South', 'North', 'General', 'Advanced', 'Utilities', 'rest_api', 'Security', 'service', 'SCHEDULER', @@ -332,7 +333,7 @@ def get_entry_val(k): 'entry name {}; got {}'.format(category_name, item_name, entry_name, type(entry_val))) # Validate list type and mandatory items - elif 'type' in item_val and get_entry_val("type") == 'list': + elif 'type' in item_val and get_entry_val("type") in ('list', 'kvlist'): if not isinstance(entry_val, str): raise TypeError('For {} category, entry value must be a string for item name {} and ' 'entry name {}; got {}'.format(category_name, item_name, entry_name, @@ -355,21 +356,22 @@ def get_entry_val(k): raise ValueError('For {} category, listSize value must be an integer value ' 'for item name {}'.format(category_name, item_name)) list_size = int(item_val['listSize']) + msg = "array" if item_val['type'] == 'list' else "KV pair" try: eval_default_val = ast.literal_eval(default_val) if len(eval_default_val) > len(set(eval_default_val)): - raise ArithmeticError("For {} category, default value array elements are not " - "unique for item name {}".format(category_name, item_name)) + raise ArithmeticError("For {} category, default value {} elements are not " + "unique for item name {}".format(category_name, msg, item_name)) if list_size >= 0: if len(eval_default_val) != list_size: - raise ArithmeticError("For {} category, default value array list size limit to " - "{} for item name {}".format(category_name, + raise ArithmeticError("For {} category, default value {} list size limit to " + "{} for item name {}".format(category_name, msg, list_size, item_name)) except ArithmeticError as err: raise ValueError(err) except: - raise TypeError("For {} category, default value should be passed an array list in " - "string format for item name {}".format(category_name, item_name)) + raise TypeError("For {} category, default value should be passed {} list in string " + "format for item name {}".format(category_name, msg, item_name)) type_check = str if entry_val == 'integer': type_check = int @@ -378,13 +380,25 @@ def get_entry_val(k): type_mismatched_message = ("For {} category, all elements should be of same {} type " "in default value for item name {}").format(category_name, type_check, item_name) - for s in eval_default_val: - try: - eval_s = s if entry_val == "string" else ast.literal_eval(s) - except: - raise ValueError(type_mismatched_message) - if not isinstance(eval_s, type_check): - raise ValueError(type_mismatched_message) + if item_val['type'] == 'kvlist': + if not isinstance(eval_default_val, dict): + raise TypeError("For {} category, KV pair invalid in default value for item name {}" + "".format(category_name, item_name)) + for k, v in eval_default_val.items(): + try: + eval_s = v if entry_val == "string" else ast.literal_eval(v) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) + else: + for s in eval_default_val: + try: + eval_s = s if entry_val == "string" else ast.literal_eval(s) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) d = {entry_name: entry_val} expected_item_entries.update(d) else: From 557f6607522f7c1b73f24be346b8702fda3c02c6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 16 Feb 2024 12:20:59 +0530 Subject: [PATCH 068/146] unit tests added for kvlist configuration type Signed-off-by: ashish-jabble --- .../common/test_configuration_manager.py | 94 ++++++++++++++++++- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index cc4f6bedfe..954aabb113 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -34,8 +34,9 @@ def reset_singleton(self): ConfigurationManagerSingleton._shared_state = {} def test_supported_validate_type_strings(self): - expected_types = ['IPv4', 'IPv6', 'JSON', 'URL', 'X509 certificate', 'boolean', 'code', 'enumeration', 'float', 'integer', - 'northTask', 'password', 'script', 'string', 'ACL', 'bucket', 'list'] + expected_types = ['IPv4', 'IPv6', 'JSON', 'URL', 'X509 certificate', 'boolean', 'code', 'enumeration', + 'float', 'integer', 'northTask', 'password', 'script', 'string', 'ACL', 'bucket', + 'list', 'kvlist'] assert len(expected_types) == len(_valid_type_strings) assert sorted(expected_types) == _valid_type_strings @@ -589,7 +590,7 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re "For {} category, items value should either be in string, float or integer for item name {}".format( CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "float"}}, TypeError, - "For {} category, default value should be passed an array list in string format for item name {}".format( + "For {} category, default value should be passed array list in string format for item name {}".format( CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"AJ\"]", "items": "float"}}, ValueError, "For {} category, all elements should be of same type in default value for item name {}".format( @@ -625,6 +626,71 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re "for item name {}".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"a\", \"b\", \"ab\", \"a\"]", "items": "string"}}, ValueError, "For {} category, default value array elements are not unique " + "for item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A"}}, KeyError, + "'For {} category, items KV pair must be required for item name {}.'".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": []}}, TypeError, + "For {} category, entry value must be a string for item name {} and entry name items; " + "got ".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": "str"}}, ValueError, + "For {} category, items value should either be in string, float or integer for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": "string"}}, TypeError, + "For {} category, default value should be passed KV pair list in string format for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\"}", "items": "string"}}, + TypeError, "For {} category, KV pair invalid in default value for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1\"}", "items": "float"}}, + ValueError, "For {} category, all elements should be of same type in default value for " + "item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"AJ\"}", + "items": "integer"}}, ValueError, + "For {} category, all elements should be of same type in default value for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key1\": \"13\", \"key2\": \"1.04\"}" + , "items": "integer"}}, ValueError, "For {} category, all elements should be of same type in " + "default value for item name {}".format(CAT_NAME, ITEM_NAME)), + ({"include": {"description": "expression", "type": "kvlist", + "default": "{\"key1\": \"135\", \"key2\": \"1111\"}", "items": "integer", "value": "1"}, + ITEM_NAME: {"description": "expression", "type": "kvlist", + "default": "{\"key1\": \"135\", \"key2\": \"1111\"}", "items": "float"}}, ValueError, + "For {} category, all elements should be of same type in default value for item name " + "{}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "[]", "items": "float", "listSize": 1}}, + TypeError, "For {} category, listSize type must be a string for item name {}; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "[]", "items": "float", + "listSize": "blah"}}, ValueError, "For {} category, listSize value must be an integer value for " + "item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "[\"1\"]", "items": "float", + "listSize": "1"}}, TypeError, "For {} category, KV pair invalid in default value for item name " + "{}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"1\"}", "items": "float", + "listSize": "1"}}, TypeError, "For {} category, KV pair invalid in default value for item name " + "{}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": {} }", "items": "float", + "listSize": "1"}}, ValueError, "For {} category, all elements should be of same " + "type in default value for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", + "default": "{\"key\": \"1.0\", \"key2\": \"val2\"}", "items": "float", "listSize": "1"}}, + ValueError, "For {} category, default value KV pair list size limit to 1 for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", + "default": "{\"key\": \"1.0\", \"key\": \"val2\"}", "items": "float", "listSize": "2"}}, + ValueError, "For {} category, default value KV pair list size limit to 2 for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", + "default": "{\"key\": \"1.0\", \"key1\": \"val2\"}", "items": "float", "listSize": "2"}}, + ValueError, "For {} category, all elements should be of same type in default value for " + "item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", + "default": "{\"key\": \"1.0\", \"key1\": \"val2\", \"key3\": \"val2\"}", "items": "float", + "listSize": "2"}}, ValueError, "For {} category, default value KV pair list size limit to 2 for" + " item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\"}", "items": "float", + "listSize": "0"}}, ValueError, "For {} category, default value KV pair list size limit to 0 " "for item name {}".format(CAT_NAME, ITEM_NAME)) ]) async def test__validate_category_val_list_type_bad(self, config, exc_name, reason): @@ -651,6 +717,20 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas "default": "[\"var1\", \"var2\"]", "listSize": "2"}}, {"include": {"description": "A list of variables to include", "type": "list", "items": "integer", "default": "[\"10\", \"100\", \"200\", \"300\"]", "listSize": "4"}}, + {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "string", + "default": "{}", "order": "1", "displayName": "labels"}}, + {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "string", + "default": "{\"key\": \"value\"}", "order": "1", "displayName": "labels"}}, + {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "integer", + "default": "{\"key\": \"13\"}", "order": "1", "displayName": "labels"}}, + {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "float", + "default": "{\"key\": \"13.13\"}", "order": "1", "displayName": "labels"}}, + {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "string", + "default": "{\"key\": \"value\"}", "order": "1", "displayName": "labels", "listSize": "1"}}, + {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "integer", + "default": "{\"key\": \"13\"}", "order": "1", "displayName": "labels", "listSize": "1"}}, + {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "float", + "default": "{\"key\": \"13.13\"}", "order": "1", "displayName": "labels", "listSize": "1"}} ]) async def test__validate_category_val_list_type_good(self, config): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -670,6 +750,8 @@ async def test__validate_category_val_list_type_good(self, config): ("bucket", " ", False), ("list", "", False), ("list", " ", False), + ("kvlist", "", False), + ("kvlist", " ", False), ("integer", " ", True), ("string", "", True), ("string", " ", True), @@ -678,7 +760,9 @@ async def test__validate_category_val_list_type_good(self, config): ("bucket", "", True), ("bucket", " ", True), ("list", "", True), - ("list", " ", True) + ("list", " ", True), + ("kvlist", "", True), + ("kvlist", " ", True) ]) async def test__validate_category_val_with_optional_mandatory(self, _type, value, from_default_val): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -687,7 +771,7 @@ async def test__validate_category_val_with_optional_mandatory(self, _type, value "mandatory": "true"}} if _type == "bucket": test_config[ITEM_NAME]['properties'] = {"key": "foo"} - elif _type == "list": + elif _type in ("list", "kvlist"): test_config[ITEM_NAME]['items'] = "string" with pytest.raises(Exception) as excinfo: From 02d95f79b92666441f9985c42161a4cb27a2e0d7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 16 Feb 2024 14:55:58 +0530 Subject: [PATCH 069/146] length, max, min validations added with item kvlist type configuration along with unit tests Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 58 +++++--- .../common/test_configuration_manager.py | 134 +++++++++++++++++- 2 files changed, 169 insertions(+), 23 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 30105d2154..760b0b6788 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -1815,20 +1815,21 @@ def _validate_min_max(_type, val): if config_item_type == 'integer' or config_item_type == 'float': _validate_min_max(config_item_type, new_value_entry) - if config_item_type == "list": + if config_item_type in ("list", "kvlist"): + msg = "array" if config_item_type == 'list' else "KV pair" try: eval_new_val = ast.literal_eval(new_value_entry) except: - raise TypeError("For config item {} value should be passed an array list in string format".format( - item_name)) + raise TypeError("For config item {} value should be passed {} list in string format".format( + item_name, msg)) if len(eval_new_val) > len(set(eval_new_val)): raise ValueError("For config item {} elements are not unique".format(item_name)) if 'listSize' in storage_value_entry: list_size = int(storage_value_entry['listSize']) if list_size >= 0: if len(eval_new_val) != list_size: - raise TypeError("For config item {} value array list size limit to {}".format( - item_name, list_size)) + raise TypeError("For config item {} value {} list size limit to {}".format( + item_name, msg, list_size)) type_mismatched_message = "For config item {} all elements should be of same {} type".format( item_name, storage_value_entry['items']) @@ -1838,17 +1839,36 @@ def _validate_min_max(_type, val): elif storage_value_entry['items'] == 'float': type_check = float - for s in eval_new_val: - try: - eval_s = s - if storage_value_entry['items'] in ("integer", "float"): - eval_s = ast.literal_eval(s) - _validate_min_max(storage_value_entry['items'], eval_s) - elif storage_value_entry['items'] == 'string': - _validate_length(eval_s) - except TypeError as err: - raise ValueError(err) - except: - raise ValueError(type_mismatched_message) - if not isinstance(eval_s, type_check): - raise ValueError(type_mismatched_message) + if config_item_type == 'kvlist': + if not isinstance(eval_new_val, dict): + raise TypeError("For config item {} KV pair invalid".format(item_name)) + for k, v in eval_new_val.items(): + try: + eval_s = v + if storage_value_entry['items'] in ("integer", "float"): + eval_s = ast.literal_eval(v) + _validate_min_max(storage_value_entry['items'], eval_s) + elif storage_value_entry['items'] == 'string': + _validate_length(eval_s) + except TypeError as err: + raise ValueError(err) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) + else: + for s in eval_new_val: + try: + eval_s = s + if storage_value_entry['items'] in ("integer", "float"): + eval_s = ast.literal_eval(s) + _validate_min_max(storage_value_entry['items'], eval_s) + elif storage_value_entry['items'] == 'string': + _validate_length(eval_s) + except TypeError as err: + raise ValueError(err) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) + diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 954aabb113..483a3b11c9 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -3775,16 +3775,16 @@ async def async_mock(return_value): 'For config item {} you cannot set the new value, below 0.8', TypeError), ("", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"1\"]', 'order': '2', 'items': 'integer', 'listSize': '2', 'value': '[\"1\", \"2\"]'}, - "For config item {} value should be passed an array list in string format", TypeError), + "For config item {} value should be passed array list in string format", TypeError), ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"1\"]', 'order': '2', 'items': 'integer', 'listSize': '2', 'value': '[\"1\", \"2\"]'}, "For config item {} value array list size limit to 2", TypeError), ("", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, - "For config item {} value should be passed an array list in string format", TypeError), + "For config item {} value should be passed array list in string format", TypeError), ("", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, - "For config item {} value should be passed an array list in string format", TypeError), + "For config item {} value should be passed array list in string format", TypeError), ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, "For config item {} value array list size limit to 1", TypeError), @@ -3840,6 +3840,66 @@ async def async_mock(return_value): "For config item {} you cannot set the new value, beyond the range (2,2.5)", ValueError), ("[\"1.999\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"2.2\"]', 'order': '2', 'items': 'float', 'value': '[\"2.5\"]', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}, + "For config item {} you cannot set the new value, beyond the range (2,2.5)", ValueError), + ("", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', + 'items': 'integer', 'listSize': '1', 'value': '{\"key\": \"val\"}'}, + "For config item {} value should be passed KV pair list in string format", TypeError), + ("[]", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', + 'items': 'integer', 'listSize': '1', 'value': '{\"key\": \"val\"}'}, + "For config item {} value KV pair list size limit to 1", TypeError), + ("", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', + 'items': 'string', 'listSize': '1', 'value': '{\"key\": \"val\"}'}, + "For config item {} value should be passed KV pair list in string format", TypeError), + ("", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', + 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, + "For config item {} value should be passed KV pair list in string format", TypeError), + ("{}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', + 'items': 'string', 'listSize': '1', 'value': '{\"key\": \"val\"}'}, + "For config item {} value KV pair list size limit to 1", TypeError), + ("{}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', + 'items': 'float', 'listSize': '3', 'value': '{\"key\": \"val\"}'}, + "For config item {} value KV pair list size limit to 3", TypeError), + ("{\"key\": \"val\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"1\"}', 'order': '2', + 'items': 'integer', 'value': '{\"key\": \"13\"}'}, + "For config item {} all elements should be of same integer type", ValueError), + ("{\"key\": 1}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"a\": \"c\"}', 'order': '2', + 'items': 'string', 'value': '{\"abc\", \"def\"}'}, + "For config item {} all elements should be of same string type", ValueError), + ("{\"key\": \"2\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"1.4\"}', + 'order': '2', 'items': 'float', 'value': '{\"key\": \"12.0\"}'}, + "For config item {} all elements should be of same float type", ValueError), + ("{\"key\": \"2\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"48\"}', + 'items': 'integer', 'listSize': '1', 'value': '{\"key\": \"48\"}', 'minimum': '20'}, + "For config item {} you cannot set the new value, below 20", ValueError), + ("{\"key\": \"100\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"48\"}', + 'items': 'integer', 'listSize': '1', 'value': '{\"key\": \"48\"}', 'maximum': '50'}, + "For config item {} you cannot set the new value, above 50", ValueError), + ("{\"key\": \"46\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"50\"}', + 'items': 'integer', 'listSize': '1', 'value': '{\"key\": \"48\"}', 'maximum': '50', + 'minimum': '47'}, "For config item {} you cannot set the new value, beyond the " + "range (47,50)", ValueError), + ("{\"key\": \"100\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"48\"}', + 'items': 'integer', 'listSize': '1', 'value': '{\"key\": \"48\"}', 'maximum': '50', + 'minimum': '47'}, + "For config item {} you cannot set the new value, beyond the range (47,50)", ValueError), + ("{\"foo\": \"bars\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"a1\": \"c1\"}', + 'items': 'string', 'value': '[\"ab\", \"de\"]', 'listSize': '1', 'length': '3'}, + "For config item {} you cannot set the new value, beyond the length 3", ValueError), + ("{\"key\": \"1.002\", \"key2\": \"2.6\"}", {'description': 'expression', 'type': 'kvlist', + 'default': '{\"key\", \"2.5\"}', 'items': 'float', + 'value': '{\"key\", \"2.5\"}', 'minimum': '2.5', 'listSize': '2'}, + "For config item {} you cannot set the new value, below 2.5", ValueError), + ("{\"key\": \"2.6\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"2.5\"}', + 'items': 'float', 'value': '{\"key\": \"2.5\"}', 'maximum': '2.5', + 'listSize': '1'}, "For config item {} you cannot set the new value, above 2.5", + ValueError), + ("{\"key\": \"2.6\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"2.2\"}', + 'items': 'float', 'value': '{\"key\": \"2.5\"}', 'listSize': '1', 'minimum': '2', + 'maximum': '2.5'}, + "For config item {} you cannot set the new value, beyond the range (2,2.5)", ValueError), + ("{\"key\": \"1.999\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"2.2\"}', + 'items': 'float', 'value': '{\"key\": \"2.5\"}', 'listSize': '1', 'minimum': '2', + 'maximum': '2.5'}, "For config item {} you cannot set the new value, beyond the range (2,2.5)", ValueError) ]) def test_bad__validate_value_per_optional_attribute(self, new_value_entry, storage_value_entry, exc_msg, exc_type): @@ -3909,7 +3969,73 @@ def test_bad__validate_value_per_optional_attribute(self, new_value_entry, stora ("[\"2.0\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"2.2\"]', 'order': '2', 'items': 'float', 'value': '[\"2.5\"]', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}), ("[\"2.5\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"2.2\"]', 'order': '2', - 'items': 'float', 'value': '[\"2.5\"]', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}) + 'items': 'float', 'value': '[\"2.5\"]', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}), + ("{\"key\": \"bar\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"c\"}', + 'order': '2', + 'items': 'string', 'value': '{\"key\": \"def\"}'}), + ("{\"key\": \"1.002\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"1.4\"}', + 'order': '2', 'items': 'float', 'value': '{\"key\": \"12.0\"}'}), + ("{\"key\": \"10\", \"key1\": \"20\", \"key2\": \"30\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', + 'default': '{\"key\": \"10\", \"key1\": \"20\", \"key2\": \"30\"}', + 'order': '2', 'items': 'integer', 'listSize': "3", + 'value': '{\"key\": \"1\", \"key1\": \"2\", \"key2\": \"3\"}'}), + ("{\"key\": \"new string\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"c\"}', + 'order': '2', + 'items': 'string', 'listSize': "1", 'value': '{\"key\": \"def\"}'}), + ("{\"key\": \"6.523e-07\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"1.4\"}', + 'order': '2', 'items': 'float', 'listSize': "1", 'value': '{\"key\": \"12.0\"}'}), + ("{}", {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"1\": \"2\"}', + 'order': '2', 'items': 'integer', 'listSize': "0", 'value': '{\"3\": \"4\"}'}), + ("{}", {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"a\": \"c\"}', + 'order': '2', + 'items': 'string', 'listSize': "0", 'value': '{\"abc\": \"def\"}'}), + ("{}", {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"1.4\"}', + 'order': '2', 'items': 'float', 'listSize': "0", 'value': '{\"key\": \"12.0\"}'}), + + ("{\"key\": \"100\", \"key2\": \"20\"}", + {'description': 'SL', 'type': 'kvlist', 'default': '{\"key\": \"100\", \"key2\": \"48\"}', 'order': '2', + 'items': 'integer', 'listSize': '2', 'value': '{\"key\": \"34\", \"key2\": \"20\"}', 'minimum': '20'}), + ("{\"key\": \"50\", \"key2\": \"0\", \"key3\": \"49\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', + 'default': '{\"key\": \"47\", \"key2\": \"48\", \"key3\": \"49\"}', + 'order': '2', 'items': 'integer', 'listSize': '3', + 'value': '{\"key\": \"47\", \"key2\": \"48\", \"key3\": \"49\"}', 'maximum': '50'}), + ("{\"key\": \"50\", \"key2\": \"48\", \"key3\": \"49\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': + '{\"key\": \"50\", \"key2\": \"48\", \"key3\": \"49\"}', 'order': '2', 'items': 'integer', 'listSize': '3', + 'value': '{\"key\": \"47\", \"key2\": \"48\", \"key3\": \"49\"}', 'maximum': '50', 'minimum': '47'}), + ("{\"key\": \"50\", \"key2\": \"48\", \"key3\": \"49\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': + '{\"key\": \"47\", \"key2\": \"48\", \"key3\": \"49\"}', 'order': '2', 'items': 'integer', 'listSize': '3', + 'value': '{\"key\": \"47\", \"key2\": \"48\", \"key3\": \"49\"}', 'maximum': '50', 'minimum': '47'}), + ("{\"key\": \"foo\", \"key2\": \"bar\"}", {'description': 'A list of expressions and values', 'type': 'kvlist', + 'default': '{\"key\": \"a1\", \"key2\": \"c1\"}', + 'order': '2', 'items': 'string', + 'value': '{\"key\": \"ab\", \"key2\": \"de\"}', 'listSize': '2', + 'length': '3'}), + ("{\"key\": \"2.6\", \"key2\": \"13.002\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', + 'default': '{\"key\": \"5.2\", \"key2\": \"2.5\"}', + 'order': '2', 'items': 'float', 'value': '{\"key\": \"5.67\", \"key2\": \"2.5\"}', 'minimum': '2.5', + 'listSize': '2'}), + ("{\"key\": \"2.4\", \"key2\": \"1.002\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', + 'default': '{\"key\": \"2.2\", \"key2\": \"2.5\"}', + 'order': '2', 'items': 'float', 'value': '{\"key\": \"1.67\", \"key2\": \"2.5\"}', 'maximum': '2.5', + 'listSize': '2'}), + ("{\"key\": \"2.0\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"2.2\"}', + 'order': '2', + 'items': 'float', 'value': '{\"2.5\"}', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}), + ("{\"key\": \"2.5\"}", + {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"2.2\"}', + 'order': '2', + 'items': 'float', 'value': '{\"2.5\"}', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}) ]) def test_good__validate_value_per_optional_attribute(self, new_value_entry, storage_value_entry): storage_client_mock = MagicMock(spec=StorageClientAsync) From 8117ffd39da7adcf19840788f02fde66c421d455 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 19 Feb 2024 12:35:32 +0530 Subject: [PATCH 070/146] unique KV pair case handled in configuration manager for kvlist type Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 47 ++++++++++++++++--- .../common/test_configuration_manager.py | 8 ++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 760b0b6788..64f32b9e79 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -359,9 +359,29 @@ def get_entry_val(k): msg = "array" if item_val['type'] == 'list' else "KV pair" try: eval_default_val = ast.literal_eval(default_val) - if len(eval_default_val) > len(set(eval_default_val)): - raise ArithmeticError("For {} category, default value {} elements are not " - "unique for item name {}".format(category_name, msg, item_name)) + if item_val['type'] == 'list': + if len(eval_default_val) > len(set(eval_default_val)): + raise ArithmeticError("For {} category, default value {} elements are not " + "unique for item name {}".format(category_name, msg, + item_name)) + else: + if isinstance(eval_default_val, dict) and eval_default_val: + nv = default_val.replace("{", "") + unique_list = [] + for pair in nv.split(','): + if pair: + k, v = pair.split(':') + ks = k.strip() + if ks not in unique_list: + unique_list.append(ks) + else: + raise ArithmeticError("For category {}, duplicate KV pair found for " + "item name {}".format(category_name, + item_name)) + else: + raise ArithmeticError("For {} category, KV pair invalid in default value " + "for item name {}".format(category_name, + item_name)) if list_size >= 0: if len(eval_default_val) != list_size: raise ArithmeticError("For {} category, default value {} list size limit to " @@ -1807,7 +1827,6 @@ def _validate_min_max(_type, val): raise TypeError('For config item {} you cannot set the new value, above {}'.format(item_name, _max_value)) - config_item_type = storage_value_entry['type'] if config_item_type == 'string': _validate_length(new_value_entry) @@ -1822,8 +1841,24 @@ def _validate_min_max(_type, val): except: raise TypeError("For config item {} value should be passed {} list in string format".format( item_name, msg)) - if len(eval_new_val) > len(set(eval_new_val)): - raise ValueError("For config item {} elements are not unique".format(item_name)) + + if config_item_type == 'list': + if len(eval_new_val) > len(set(eval_new_val)): + raise ValueError("For config item {} elements are not unique".format(item_name)) + else: + if isinstance(eval_new_val, dict) and eval_new_val: + nv = new_value_entry.replace("{", "") + unique_list = [] + for pair in nv.split(','): + if pair: + k, v = pair.split(':') + ks = k.strip() + if ks not in unique_list: + unique_list.append(ks) + else: + raise TypeError("For config item {} duplicate KV pair found".format(item_name)) + else: + raise TypeError("For config item {} KV pair invalid".format(item_name)) if 'listSize' in storage_value_entry: list_size = int(storage_value_entry['listSize']) if list_size >= 0: diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 483a3b11c9..2f4fabf3d3 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -679,8 +679,7 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\", \"key\": \"val2\"}", "items": "float", "listSize": "2"}}, - ValueError, "For {} category, default value KV pair list size limit to 2 for item name {}".format( - CAT_NAME, ITEM_NAME)), + ValueError, "For category {}, duplicate KV pair found for item name {}".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\", \"key1\": \"val2\"}", "items": "float", "listSize": "2"}}, ValueError, "For {} category, all elements should be of same type in default value for " @@ -3859,7 +3858,10 @@ async def async_mock(return_value): ("{}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', 'items': 'float', 'listSize': '3', 'value': '{\"key\": \"val\"}'}, "For config item {} value KV pair list size limit to 3", TypeError), - ("{\"key\": \"val\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"1\"}', 'order': '2', + ("{\"key\": \"1.2\", \"key\": \"1.23\"}", {'description': 'Simple list', 'type': 'kvlist', 'default': '{\"key\": \"11.12\"}', + 'order': '2', 'items': 'float', 'value': '{\"key\": \"1.4\"}'}, + "For config item {} duplicate KV pair found", TypeError), + ("{\"key\": \"val\"}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"1\"}', 'items': 'integer', 'value': '{\"key\": \"13\"}'}, "For config item {} all elements should be of same integer type", ValueError), ("{\"key\": 1}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"a\": \"c\"}', 'order': '2', From 95640829bbe9a7d101b027927ca94825312ac1b6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 19 Feb 2024 16:43:12 +0530 Subject: [PATCH 071/146] object items support added for list and kvlist configuration type along with unit tests Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 285 ++++++++++-------- .../common/test_configuration_manager.py | 49 ++- 2 files changed, 196 insertions(+), 138 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 64f32b9e79..5dd3db7d26 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -334,7 +334,7 @@ def get_entry_val(k): type(entry_val))) # Validate list type and mandatory items elif 'type' in item_val and get_entry_val("type") in ('list', 'kvlist'): - if not isinstance(entry_val, str): + if entry_name != 'properties' and not isinstance(entry_val, str): raise TypeError('For {} category, entry value must be a string for item name {} and ' 'entry name {}; got {}'.format(category_name, item_name, entry_name, type(entry_val))) @@ -342,9 +342,21 @@ def get_entry_val(k): raise KeyError('For {} category, items KV pair must be required ' 'for item name {}.'.format(category_name, item_name)) if entry_name == 'items': - if entry_val not in ("string", "float", "integer"): - raise ValueError("For {} category, items value should either be in string, " - "float or integer for item name {}".format(category_name, item_name)) + if entry_val not in ("string", "float", "integer", "object"): + raise ValueError("For {} category, items value should either be in string, float, integer " + "or object for item name {}".format(category_name, item_name)) + if entry_val == 'object': + if 'properties' not in item_val: + raise KeyError('For {} category, properties KV pair must be required for item name {}'.format(category_name, item_name)) + prop_val = get_entry_val('properties') + if not isinstance(prop_val, dict): + raise ValueError( + 'For {} category, properties must be JSON object for item name {}; got {}' + .format(category_name, item_name, type(prop_val))) + if not prop_val: + raise ValueError( + 'For {} category, properties JSON object cannot be empty for item name {}' + ''.format(category_name, item_name)) default_val = get_entry_val("default") list_size = -1 if 'listSize' in item_val: @@ -357,68 +369,72 @@ def get_entry_val(k): 'for item name {}'.format(category_name, item_name)) list_size = int(item_val['listSize']) msg = "array" if item_val['type'] == 'list' else "KV pair" - try: - eval_default_val = ast.literal_eval(default_val) - if item_val['type'] == 'list': - if len(eval_default_val) > len(set(eval_default_val)): - raise ArithmeticError("For {} category, default value {} elements are not " - "unique for item name {}".format(category_name, msg, - item_name)) - else: - if isinstance(eval_default_val, dict) and eval_default_val: - nv = default_val.replace("{", "") - unique_list = [] - for pair in nv.split(','): - if pair: - k, v = pair.split(':') - ks = k.strip() - if ks not in unique_list: - unique_list.append(ks) + if entry_name == 'items' and entry_val != "object": + try: + eval_default_val = ast.literal_eval(default_val) + if item_val['type'] == 'list': + if len(eval_default_val) > len(set(eval_default_val)): + raise ArithmeticError("For {} category, default value {} elements are not " + "unique for item name {}".format(category_name, msg, + item_name)) + else: + if isinstance(eval_default_val, dict) and eval_default_val: + nv = default_val.replace("{", "") + unique_list = [] + for pair in nv.split(','): + if pair: + k, v = pair.split(':') + ks = k.strip() + if ks not in unique_list: + unique_list.append(ks) + else: + raise ArithmeticError("For category {}, duplicate KV pair found " + "for item name {}".format( + category_name, item_name)) else: - raise ArithmeticError("For category {}, duplicate KV pair found for " - "item name {}".format(category_name, - item_name)) - else: - raise ArithmeticError("For {} category, KV pair invalid in default value " - "for item name {}".format(category_name, - item_name)) - if list_size >= 0: - if len(eval_default_val) != list_size: - raise ArithmeticError("For {} category, default value {} list size limit to " - "{} for item name {}".format(category_name, msg, - list_size, item_name)) - except ArithmeticError as err: - raise ValueError(err) - except: - raise TypeError("For {} category, default value should be passed {} list in string " - "format for item name {}".format(category_name, msg, item_name)) - type_check = str - if entry_val == 'integer': - type_check = int - elif entry_val == 'float': - type_check = float - type_mismatched_message = ("For {} category, all elements should be of same {} type " - "in default value for item name {}").format(category_name, - type_check, item_name) - if item_val['type'] == 'kvlist': - if not isinstance(eval_default_val, dict): - raise TypeError("For {} category, KV pair invalid in default value for item name {}" - "".format(category_name, item_name)) - for k, v in eval_default_val.items(): - try: - eval_s = v if entry_val == "string" else ast.literal_eval(v) - except: - raise ValueError(type_mismatched_message) - if not isinstance(eval_s, type_check): - raise ValueError(type_mismatched_message) - else: - for s in eval_default_val: - try: - eval_s = s if entry_val == "string" else ast.literal_eval(s) - except: - raise ValueError(type_mismatched_message) - if not isinstance(eval_s, type_check): - raise ValueError(type_mismatched_message) + raise ArithmeticError("For {} category, KV pair invalid in default " + "value for item name {}".format( + category_name, item_name)) + if list_size >= 0: + if len(eval_default_val) != list_size: + raise ArithmeticError("For {} category, default value {} list size limit to " + "{} for item name {}".format(category_name, msg, + list_size, item_name)) + except ArithmeticError as err: + raise ValueError(err) + except: + raise TypeError("For {} category, default value should be passed {} list in string " + "format for item name {}".format(category_name, msg, item_name)) + type_check = str + if entry_val == 'integer': + type_check = int + elif entry_val == 'float': + type_check = float + type_mismatched_message = ("For {} category, all elements should be of same {} type " + "in default value for item name {}").format(category_name, + type_check, item_name) + if item_val['type'] == 'kvlist': + if not isinstance(eval_default_val, dict): + raise TypeError("For {} category, KV pair invalid in default value for item name {}" + "".format(category_name, item_name)) + for k, v in eval_default_val.items(): + try: + eval_s = v if entry_val == "string" else ast.literal_eval(v) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) + else: + for s in eval_default_val: + try: + eval_s = s if entry_val == "string" else ast.literal_eval(s) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) + d = {entry_name: entry_val} + expected_item_entries.update(d) + if entry_name == 'properties': d = {entry_name: entry_val} expected_item_entries.update(d) else: @@ -1835,75 +1851,76 @@ def _validate_min_max(_type, val): _validate_min_max(config_item_type, new_value_entry) if config_item_type in ("list", "kvlist"): - msg = "array" if config_item_type == 'list' else "KV pair" - try: - eval_new_val = ast.literal_eval(new_value_entry) - except: - raise TypeError("For config item {} value should be passed {} list in string format".format( - item_name, msg)) + if storage_value_entry['items'] != 'object': + msg = "array" if config_item_type == 'list' else "KV pair" + try: + eval_new_val = ast.literal_eval(new_value_entry) + except: + raise TypeError("For config item {} value should be passed {} list in string format".format( + item_name, msg)) - if config_item_type == 'list': - if len(eval_new_val) > len(set(eval_new_val)): - raise ValueError("For config item {} elements are not unique".format(item_name)) - else: - if isinstance(eval_new_val, dict) and eval_new_val: - nv = new_value_entry.replace("{", "") - unique_list = [] - for pair in nv.split(','): - if pair: - k, v = pair.split(':') - ks = k.strip() - if ks not in unique_list: - unique_list.append(ks) + if config_item_type == 'list': + if len(eval_new_val) > len(set(eval_new_val)): + raise ValueError("For config item {} elements are not unique".format(item_name)) + else: + if isinstance(eval_new_val, dict) and eval_new_val: + nv = new_value_entry.replace("{", "") + unique_list = [] + for pair in nv.split(','): + if pair: + k, v = pair.split(':') + ks = k.strip() + if ks not in unique_list: + unique_list.append(ks) + else: + raise TypeError("For config item {} duplicate KV pair found".format(item_name)) else: - raise TypeError("For config item {} duplicate KV pair found".format(item_name)) - else: - raise TypeError("For config item {} KV pair invalid".format(item_name)) - if 'listSize' in storage_value_entry: - list_size = int(storage_value_entry['listSize']) - if list_size >= 0: - if len(eval_new_val) != list_size: - raise TypeError("For config item {} value {} list size limit to {}".format( - item_name, msg, list_size)) - - type_mismatched_message = "For config item {} all elements should be of same {} type".format( - item_name, storage_value_entry['items']) - type_check = str - if storage_value_entry['items'] == 'integer': - type_check = int - elif storage_value_entry['items'] == 'float': - type_check = float - - if config_item_type == 'kvlist': - if not isinstance(eval_new_val, dict): - raise TypeError("For config item {} KV pair invalid".format(item_name)) - for k, v in eval_new_val.items(): - try: - eval_s = v - if storage_value_entry['items'] in ("integer", "float"): - eval_s = ast.literal_eval(v) - _validate_min_max(storage_value_entry['items'], eval_s) - elif storage_value_entry['items'] == 'string': - _validate_length(eval_s) - except TypeError as err: - raise ValueError(err) - except: - raise ValueError(type_mismatched_message) - if not isinstance(eval_s, type_check): - raise ValueError(type_mismatched_message) - else: - for s in eval_new_val: - try: - eval_s = s - if storage_value_entry['items'] in ("integer", "float"): - eval_s = ast.literal_eval(s) - _validate_min_max(storage_value_entry['items'], eval_s) - elif storage_value_entry['items'] == 'string': - _validate_length(eval_s) - except TypeError as err: - raise ValueError(err) - except: - raise ValueError(type_mismatched_message) - if not isinstance(eval_s, type_check): - raise ValueError(type_mismatched_message) + raise TypeError("For config item {} KV pair invalid".format(item_name)) + if 'listSize' in storage_value_entry: + list_size = int(storage_value_entry['listSize']) + if list_size >= 0: + if len(eval_new_val) != list_size: + raise TypeError("For config item {} value {} list size limit to {}".format( + item_name, msg, list_size)) + + type_mismatched_message = "For config item {} all elements should be of same {} type".format( + item_name, storage_value_entry['items']) + type_check = str + if storage_value_entry['items'] == 'integer': + type_check = int + elif storage_value_entry['items'] == 'float': + type_check = float + + if config_item_type == 'kvlist': + if not isinstance(eval_new_val, dict): + raise TypeError("For config item {} KV pair invalid".format(item_name)) + for k, v in eval_new_val.items(): + try: + eval_s = v + if storage_value_entry['items'] in ("integer", "float"): + eval_s = ast.literal_eval(v) + _validate_min_max(storage_value_entry['items'], eval_s) + elif storage_value_entry['items'] == 'string': + _validate_length(eval_s) + except TypeError as err: + raise ValueError(err) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) + else: + for s in eval_new_val: + try: + eval_s = s + if storage_value_entry['items'] in ("integer", "float"): + eval_s = ast.literal_eval(s) + _validate_min_max(storage_value_entry['items'], eval_s) + elif storage_value_entry['items'] == 'string': + _validate_length(eval_s) + except TypeError as err: + raise ValueError(err) + except: + raise ValueError(type_mismatched_message) + if not isinstance(eval_s, type_check): + raise ValueError(type_mismatched_message) diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 2f4fabf3d3..baede1c506 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -587,7 +587,7 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re "For {} category, entry value must be a string for item name {} and entry name items; " "got ".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "str"}}, ValueError, - "For {} category, items value should either be in string, float or integer for item name {}".format( + "For {} category, items value should either be in string, float, integer or object for item name {}".format( CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "float"}}, TypeError, "For {} category, default value should be passed array list in string format for item name {}".format( @@ -627,13 +627,28 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"a\", \"b\", \"ab\", \"a\"]", "items": "string"}}, ValueError, "For {} category, default value array elements are not unique " "for item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", + "property": {}}}, KeyError, "'For {} category, properties KV pair must be required for item name " + "{}'".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": 1}}, ValueError, + "For {} category, properties must be JSON object for item name {}; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": ""}}, ValueError, + "For {} category, properties must be JSON object for item name {}; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": {}}}, ValueError, + "For {} category, properties JSON object cannot be empty for item name {}".format( + CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A"}}, KeyError, "'For {} category, items KV pair must be required for item name {}.'".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": []}}, TypeError, "For {} category, entry value must be a string for item name {} and entry name items; " "got ".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": "str"}}, ValueError, - "For {} category, items value should either be in string, float or integer for item name {}".format( + "For {} category, items value should either be in string, float, integer or object for item name {}".format( CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": "string"}}, TypeError, "For {} category, default value should be passed KV pair list in string format for item name {}".format( @@ -690,7 +705,25 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re " item name {}".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\"}", "items": "float", "listSize": "0"}}, ValueError, "For {} category, default value KV pair list size limit to 0 " - "for item name {}".format(CAT_NAME, ITEM_NAME)) + "for item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\"}", "items": "object" + }}, KeyError, "'For {} category, properties KV pair must be required for item name {}'".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\"}", "items": "object", + "property": {}}}, KeyError, "'For {} category, properties KV pair must be required for item name " + "{}'".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": 1}}, ValueError, + "For {} category, properties must be JSON object for item name {}; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": ""}}, ValueError, + "For {} category, properties must be JSON object for item name {}; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": {}}}, ValueError, + "For {} category, properties JSON object cannot be empty for item name {}".format( + CAT_NAME, ITEM_NAME)) ]) async def test__validate_category_val_list_type_bad(self, config, exc_name, reason): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -716,6 +749,10 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas "default": "[\"var1\", \"var2\"]", "listSize": "2"}}, {"include": {"description": "A list of variables to include", "type": "list", "items": "integer", "default": "[\"10\", \"100\", \"200\", \"300\"]", "listSize": "4"}}, + {"include": {"description": "A list of variables to include", "type": "list", "items": "object", + "default": "[{\"datapoint\": \"voltage\"}]", + "properties": {"datapoint": {"description": "The datapoint name to create", "displayName": + "Datapoint", "type": "string", "default": ""}}}}, {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "string", "default": "{}", "order": "1", "displayName": "labels"}}, {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "string", @@ -729,7 +766,11 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "integer", "default": "{\"key\": \"13\"}", "order": "1", "displayName": "labels", "listSize": "1"}}, {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "float", - "default": "{\"key\": \"13.13\"}", "order": "1", "displayName": "labels", "listSize": "1"}} + "default": "{\"key\": \"13.13\"}", "order": "1", "displayName": "labels", "listSize": "1"}}, + {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "object", + "default": "{\"register\": {\"width\": \"2\"}}", "order": "1", "displayName": "labels", + "properties": {"width": {"description": "Number of registers to read", "displayName": "Width", + "type": "integer", "maximum": "4", "default": "1"}}}} ]) async def test__validate_category_val_list_type_good(self, config): storage_client_mock = MagicMock(spec=StorageClientAsync) From 3f58c0a857fc47297a4d3fef379e8874fe5a906c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 19 Feb 2024 19:09:07 +0530 Subject: [PATCH 072/146] properties validation added for configuration list type along with unit tests Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 18 ++++++++++- .../common/test_configuration_manager.py | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 5dd3db7d26..07afbfc40d 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -347,7 +347,8 @@ def get_entry_val(k): "or object for item name {}".format(category_name, item_name)) if entry_val == 'object': if 'properties' not in item_val: - raise KeyError('For {} category, properties KV pair must be required for item name {}'.format(category_name, item_name)) + raise KeyError('For {} category, properties KV pair must be required for item name {}' + ''.format(category_name, item_name)) prop_val = get_entry_val('properties') if not isinstance(prop_val, dict): raise ValueError( @@ -357,6 +358,21 @@ def get_entry_val(k): raise ValueError( 'For {} category, properties JSON object cannot be empty for item name {}' ''.format(category_name, item_name)) + for kp, vp in prop_val.items(): + if isinstance(vp, dict): + prop_keys = list(vp.keys()) + if not prop_keys: + raise ValueError('For {} category, {} properties cannot be empty for ' + 'item name {}'.format(category_name, kp, item_name)) + diff = {'description', 'default', 'type'} - set(prop_keys) + if diff: + raise ValueError('For {} category, {} properties must have type, description, ' + 'default keys for item name {}'.format(category_name, + kp, item_name)) + else: + raise TypeError('For {} category, Properties must be a JSON object for {} key ' + 'for item name {}'.format(category_name, kp, item_name)) + default_val = get_entry_val("default") list_size = -1 if 'listSize' in item_val: diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index baede1c506..8cec705ecb 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -723,6 +723,38 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\"}", "items": "object", "properties": {}}}, ValueError, "For {} category, properties JSON object cannot be empty for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": {"width": 1}}}, TypeError, + "For {} category, Properties must be a JSON object for width key for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"width\": \"12\"}", "items": + "object", "properties": {"width": {}}}}, ValueError, + "For {} category, width properties cannot be empty for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"width\": \"12\"}", "items": + "object","properties": {"width": {"type": ""}}}}, ValueError, + "For {} category, width properties must have type, description, default keys for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"width\": \"12\"}", "items": + "object", "properties": {"width": {"description": ""}}}}, ValueError, + "For {} category, width properties must have type, description, default keys for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"width\": \"12\"}", "items": + "object", "properties": {"width": {"default": ""}}}}, ValueError, + "For {} category, width properties must have type, description, default keys for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"width\": \"12\"}", "items": + "object", "properties": {"width": {"type": "", "description": ""}}}}, ValueError, + "For {} category, width properties must have type, description, default keys for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"width\": \"12\"}", "items": + "object", "properties": {"width": {"type": "", "default": ""}}}}, ValueError, + "For {} category, width properties must have type, description, default keys for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"width\": \"12\"}", "items": + "object", "properties": {"width": {"description": "", "default": ""}}}}, ValueError, + "For {} category, width properties must have type, description, default keys for item name {}".format( CAT_NAME, ITEM_NAME)) ]) async def test__validate_category_val_list_type_bad(self, config, exc_name, reason): From 13a456daa685574c9b487afd2562b424a249cf2c Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 20 Feb 2024 13:26:36 +0530 Subject: [PATCH 073/146] items enumeration added for configuration list type along with unit tests Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 29 ++++++++--- .../common/test_configuration_manager.py | 52 +++++++++++++++++-- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 07afbfc40d..b8c961f0e1 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -334,7 +334,7 @@ def get_entry_val(k): type(entry_val))) # Validate list type and mandatory items elif 'type' in item_val and get_entry_val("type") in ('list', 'kvlist'): - if entry_name != 'properties' and not isinstance(entry_val, str): + if entry_name not in ('properties', 'options') and not isinstance(entry_val, str): raise TypeError('For {} category, entry value must be a string for item name {} and ' 'entry name {}; got {}'.format(category_name, item_name, entry_name, type(entry_val))) @@ -342,9 +342,10 @@ def get_entry_val(k): raise KeyError('For {} category, items KV pair must be required ' 'for item name {}.'.format(category_name, item_name)) if entry_name == 'items': - if entry_val not in ("string", "float", "integer", "object"): - raise ValueError("For {} category, items value should either be in string, float, integer " - "or object for item name {}".format(category_name, item_name)) + if entry_val not in ("string", "float", "integer", "object", "enumeration"): + raise ValueError("For {} category, items value should either be in string, float, " + "integer, object or enumeration for item name {}".format( + category_name, item_name)) if entry_val == 'object': if 'properties' not in item_val: raise KeyError('For {} category, properties KV pair must be required for item name {}' @@ -372,7 +373,19 @@ def get_entry_val(k): else: raise TypeError('For {} category, Properties must be a JSON object for {} key ' 'for item name {}'.format(category_name, kp, item_name)) - + if entry_val == 'enumeration': + if 'options' not in item_val: + raise KeyError('For {} category, options required for item name {}'.format( + category_name, item_name)) + options = item_val['options'] + if type(options) is not list: + raise TypeError('For {} category, entry value must be a list for item name {} and ' + 'entry name {}; got {}'.format(category_name, item_name, + entry_name, type(options))) + if not options: + raise ValueError( + 'For {} category, options cannot be empty list for item_name {} and ' + 'entry_name {}'.format(category_name, item_name, entry_name)) default_val = get_entry_val("default") list_size = -1 if 'listSize' in item_val: @@ -385,7 +398,7 @@ def get_entry_val(k): 'for item name {}'.format(category_name, item_name)) list_size = int(item_val['listSize']) msg = "array" if item_val['type'] == 'list' else "KV pair" - if entry_name == 'items' and entry_val != "object": + if entry_name == 'items' and entry_val not in ("object", "enumeration"): try: eval_default_val = ast.literal_eval(default_val) if item_val['type'] == 'list': @@ -450,7 +463,7 @@ def get_entry_val(k): raise ValueError(type_mismatched_message) d = {entry_name: entry_val} expected_item_entries.update(d) - if entry_name == 'properties': + if entry_name in ('properties', 'options'): d = {entry_name: entry_val} expected_item_entries.update(d) else: @@ -1867,7 +1880,7 @@ def _validate_min_max(_type, val): _validate_min_max(config_item_type, new_value_entry) if config_item_type in ("list", "kvlist"): - if storage_value_entry['items'] != 'object': + if storage_value_entry['items'] not in ('object', 'enumeration'): msg = "array" if config_item_type == 'list' else "KV pair" try: eval_new_val = ast.literal_eval(new_value_entry) diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 8cec705ecb..ab52a1c2bf 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -587,8 +587,8 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re "For {} category, entry value must be a string for item name {} and entry name items; " "got ".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "str"}}, ValueError, - "For {} category, items value should either be in string, float, integer or object for item name {}".format( - CAT_NAME, ITEM_NAME)), + "For {} category, items value should either be in string, float, integer, object or enumeration for " + "item name {}".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test description", "type": "list", "default": "A", "items": "float"}}, TypeError, "For {} category, default value should be passed array list in string format for item name {}".format( CAT_NAME, ITEM_NAME)), @@ -642,14 +642,33 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re "properties": {}}}, ValueError, "For {} category, properties JSON object cannot be empty for item name {}".format( CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"integer\"]", + "items": "enumeration"}}, KeyError, + "'For {} category, options required for item name {}'".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"integer\"]", + "items": "enumeration", "options": 1}}, TypeError, + "For {} category, entry value must be a list for item name {} and entry name items; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"integer\"]", + "items": "enumeration", "options": []}}, ValueError, + "For {} category, options cannot be empty list for item_name {} and entry_name items".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"integer\"]", + "items": "enumeration", "options": ["integer"], "listSize": 1}}, TypeError, + "For {} category, listSize type must be a string for item name {}; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"integer\"]", + "items": "enumeration", "options": ["integer"], "listSize": "blah"}}, ValueError, + "For {} category, listSize value must be an integer value for item name {}".format( + CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A"}}, KeyError, "'For {} category, items KV pair must be required for item name {}.'".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": []}}, TypeError, "For {} category, entry value must be a string for item name {} and entry name items; " "got ".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": "str"}}, ValueError, - "For {} category, items value should either be in string, float, integer or object for item name {}".format( - CAT_NAME, ITEM_NAME)), + "For {} category, items value should either be in string, float, integer, object or enumeration for " + "item name {}".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": "string"}}, TypeError, "For {} category, default value should be passed KV pair list in string format for item name {}".format( CAT_NAME, ITEM_NAME)), @@ -755,6 +774,25 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"width\": \"12\"}", "items": "object", "properties": {"width": {"description": "", "default": ""}}}}, ValueError, "For {} category, width properties must have type, description, default keys for item name {}".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key1\": \"integer\"}", + "items": "enumeration"}}, KeyError, + "'For {} category, options required for item name {}'".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key1\": \"integer\"}", + "items": "enumeration", "options": 1}}, TypeError, + "For {} category, entry value must be a list for item name {} and entry name items; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key1\": \"integer\"}", + "items": "enumeration", "options": []}}, ValueError, + "For {} category, options cannot be empty list for item_name {} and entry_name items".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key1\": \"integer\"}", + "items": "enumeration", "options": ["integer"], "listSize": 1}}, TypeError, + "For {} category, listSize type must be a string for item name {}; got ".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key1\": \"integer\"}", + "items": "enumeration", "options": ["integer"], "listSize": "blah"}}, ValueError, + "For {} category, listSize value must be an integer value for item name {}".format( CAT_NAME, ITEM_NAME)) ]) async def test__validate_category_val_list_type_bad(self, config, exc_name, reason): @@ -785,6 +823,8 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas "default": "[{\"datapoint\": \"voltage\"}]", "properties": {"datapoint": {"description": "The datapoint name to create", "displayName": "Datapoint", "type": "string", "default": ""}}}}, + {"include": {"description": "A simple list", "type": "list", "default": "[\"integer\", \"float\"]", + "items": "enumeration", "options": ["integer", "float"]}}, {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "string", "default": "{}", "order": "1", "displayName": "labels"}}, {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "string", @@ -802,7 +842,9 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "object", "default": "{\"register\": {\"width\": \"2\"}}", "order": "1", "displayName": "labels", "properties": {"width": {"description": "Number of registers to read", "displayName": "Width", - "type": "integer", "maximum": "4", "default": "1"}}}} + "type": "integer", "maximum": "4", "default": "1"}}}}, + {"include": {"description": "A list of expressions and values ", "type": "kvlist", "default": + "{\"key1\": \"integer\", \"key2\": \"float\"}", "items": "enumeration", "options": ["integer", "float"]}} ]) async def test__validate_category_val_list_type_good(self, config): storage_client_mock = MagicMock(spec=StorageClientAsync) From e4f3eec101a4806084a4a0caa8b879c84a67c3b6 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 20 Feb 2024 11:58:02 +0000 Subject: [PATCH 074/146] FOGL-8512 Improving logging for south service send latency (#1291) Signed-off-by: Mark Riddoch --- C/services/south/include/ingest.h | 2 + C/services/south/ingest.cpp | 16 +++- docs/tuning_fledge.rst | 132 +++++++++++++++++++++++++++++- 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/C/services/south/include/ingest.h b/C/services/south/include/ingest.h index 3b5dfbf157..0f629ab6f8 100644 --- a/C/services/south/include/ingest.h +++ b/C/services/south/include/ingest.h @@ -144,6 +144,8 @@ class Ingest : public ServiceHandler { std::unordered_set statsDbEntriesCache; // confirmed stats table entries std::map statsPendingEntries; // pending stats table entries bool m_highLatency; // Flag to indicate we are exceeding latency request + bool m_10Latency; // Latency within 10% + time_t m_reportedLatencyTime;// Last tiem we reported high latency int m_failCnt; bool m_storageFailed; int m_storesFailed; diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index e1e876c096..345ebff0de 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -782,14 +782,28 @@ void Ingest::processQueue() 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); + m_logger->warn("Current send latency of %ldms exceeds requested maximum latency of %dmS", latency, m_timeout); m_highLatency = true; + m_10Latency = false; + m_reportedLatencyTime = time(0); } else if (latency <= m_timeout / 1000 && m_highLatency) { m_logger->warn("Send latency now within requested limits"); m_highLatency = false; } + else if (m_highLatency && latency > m_timeout + (m_timeout / 10) && time(0) - m_reportedLatencyTime > 60) + { + // Report again every minute if we are outside the latency + // target by more than 10% + m_logger->warn("Current send latency of %ldms still significantly exceeds requested maximum latency of %dmS", latency, m_timeout); + m_reportedLatencyTime = time(0); + } + else if (m_highLatency && latency < m_timeout + (m_timeout / 10) && m_10Latency == false) + { + m_logger->warn("Send latency of %ldms is now less than 10%% from target", latency); + m_10Latency = true; + } } } diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index 0fc0483072..a12f954e60 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -102,7 +102,9 @@ Performance counters are collected in the service and a report is written once p - 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. +In the current release the performance counters can only be retrieved by direct access to the configuration and statistics database, they are stored in the *monitors* table. Or via the REST API. Future releases will include tools for the retrieval and analysis of these performance counters. + +To access the performance counters via the REST API use the entry point /fledge/monitors to retrieve all counters, or /fledge/monitor/{service name} to retrieve counters for a single service. When collection is enabled the following counters will be collected for the south service that is enabled. @@ -201,7 +203,133 @@ Performance counters are collected in the service and a report is written once p - 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. +In the current release the performance counters can only be retrieved by direct 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. + +To access the performance counters via the REST API use the entry point */fledge/monitors* to retrieve all counters, or */fledge/monitor/{service name}* to retrieve counters for a single service. + +.. code-block:: bash + + $ curl -s http://localhost:8081/fledge/monitors | jq + { + "monitors": [ + { + "monitor": "storedReadings", + "values": [ + { + "average": 102, + "maximum": 102, + "minimum": 102, + "samples": 20, + "timestamp": "2024-02-19 16:33:46.690", + "service": "si" + }, + { + "average": 102, + "maximum": 102, + "minimum": 102, + "samples": 20, + "timestamp": "2024-02-19 16:34:46.713", + "service": "si" + }, + { + "average": 102, + "maximum": 102, + "minimum": 102, + "samples": 20, + "timestamp": "2024-02-19 16:35:46.736", + "service": "si" + } + ] + }, + { + "monitor": "readLatency", + "values": [ + { + "average": 2055, + "maximum": 2064, + "minimum": 2055, + "samples": 20, + "timestamp": "2024-02-19 16:33:46.698", + "service": "si" + }, + { + "average": 2056, + "maximum": 2068, + "minimum": 2053, + "samples": 20, + "timestamp": "2024-02-19 16:34:46.719", + "service": "si" + }, + { + "average": 2058, + "maximum": 2079, + "minimum": 2056, + "samples": 20, + "timestamp": "2024-02-19 16:35:46.743", + "service": "si" + } + ] + }, + { + "monitor": "ingestCount", + "values": [ + { + "average": 34, + "maximum": 34, + "minimum": 34, + "samples": 60, + "timestamp": "2024-02-19 16:33:46.702", + "service": "si" + }, + { + "average": 34, + "maximum": 34, + "minimum": 34, + "samples": 60, + "timestamp": "2024-02-19 16:34:46.724", + "service": "si" + }, + { + "average": 34, + "maximum": 34, + "minimum": 34, + "samples": 60, + "timestamp": "2024-02-19 16:35:46.748", + "service": "si" + } + ] + }, + { + "monitor": "queueLength", + "values": [ + { + "average": 55, + "maximum": 100, + "minimum": 34, + "samples": 60, + "timestamp": "2024-02-19 16:33:46.706", + "service": "si" + }, + { + "average": 55, + "maximum": 100, + "minimum": 34, + "samples": 60, + "timestamp": "2024-02-19 16:34:46.729", + "service": "si" + }, + { + "average": 55, + "maximum": 100, + "minimum": 34, + "samples": 60, + "timestamp": "2024-02-19 16:35:46.753", + "service": "si" + } + ] + } + ] + } When collection is enabled the following counters will be collected for the south service that is enabled. From edb8b74c74d830ddf95bbb0a21856b43cd657200 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 21 Feb 2024 15:20:57 +0530 Subject: [PATCH 075/146] caching fixes on installing a service package Signed-off-by: ashish-jabble --- python/fledge/services/core/api/service.py | 2 +- tests/unit/python/fledge/services/core/api/test_service.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/fledge/services/core/api/service.py b/python/fledge/services/core/api/service.py index cee91d0d71..a9aa89819b 100644 --- a/python/fledge/services/core/api/service.py +++ b/python/fledge/services/core/api/service.py @@ -295,7 +295,7 @@ async def add_service(request): return web.HTTPBadRequest(reason=msg, body=json.dumps({"message": msg})) # Check If requested service is available for configured repository - services, log_path = await common.fetch_available_packages("service") + services, log_path = await common.fetch_available_packages() if name not in services: raise KeyError('{} service is not available for the given repository'.format(name)) 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 59868ae2e6..feaf4fa4b3 100644 --- a/tests/unit/python/fledge/services/core/api/test_service.py +++ b/tests/unit/python/fledge/services/core/api/test_service.py @@ -972,7 +972,7 @@ async def async_mock(return_value): assert 'install' == actual['action'] assert -1 == actual['status'] assert '' == actual['log_file_uri'] - patch_fetch_available_package.assert_called_once_with('service') + patch_fetch_available_package.assert_called_once_with() args, kwargs = query_tbl_patch.call_args_list[0] assert 'packages' == args[0] actual = json.loads(args[1]) @@ -1019,7 +1019,7 @@ async def async_mock(return_value): resp = await client.post('/fledge/service?action=install', data=json.dumps(param)) assert 404 == resp.status assert "'{} service is not available for the given repository'".format(pkg_name) == resp.reason - patch_fetch_available_package.assert_called_once_with('service') + patch_fetch_available_package.assert_called_once_with() args, kwargs = query_tbl_patch.call_args_list[0] assert 'packages' == args[0] assert payload == json.loads(args[1]) From 6fdde214e8886a908ba8d0582597d14d29cf8537 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Thu, 22 Feb 2024 17:31:27 -0500 Subject: [PATCH 076/146] Add section Numeric Data Types New section describes problems that occur if data type configurations are changed. Signed-off-by: Ray Verhoeff --- docs/OMF.rst | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/docs/OMF.rst b/docs/OMF.rst index 727a95ed7f..12eaa504c4 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -235,6 +235,7 @@ 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. +See the section *Numeric Data Types* below for more information on configuring data types. +--------------+ | |OMF_Format| | @@ -464,6 +465,7 @@ Number Format Hints A number format hint tells the plugin what number format to use when inserting data into the PI Server. The following will cause all numeric data within the asset to be written using the format *float32*. +See the section *Numeric Data Types* below. .. code-block:: console @@ -477,10 +479,11 @@ Integer Format Hints An integer format hint tells the plugin what integer format to use when inserting data into the PI Server. The following will cause all integer data within the asset to be written using the format *integer32*. +See the section *Numeric Data Types* below. .. code-block:: console - "OMFHint" : { "number" : "integer32" } + "OMFHint" : { "integer" : "integer32" } The value of the *number* hint may be any numeric format that is supported by the PI Server. @@ -647,6 +650,68 @@ 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|. +Numeric Data Types +------------------ + +Configuring Numeric Data Types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is possible to configure the exact data types used to send data to the PI Server using OMF. +To configure the data types for all integers and numbers (that is, floating point values), you can use the *Formats & Types* tab in the Fledge GUI. +To influence the data types for specific assets or datapoints, you can create an OMFHint of type *number* or *integer*. + +You must create your data type configurations before starting your OMF North plugin instance. +After your plugin has run for the first time, +OMF messages sent by the plugin to the PI Server will cause AF Attributes and PI Points to be created using data types defined by your configuration. +The data types of the AF Attributes and PI Points will not change if you edit your OMF North plugin instance configuration. +For example, if you disable an *integer* OMFHint, +you will change the OMF messages sent to PI but the data in the messages will no longer match the AF Attributes and PI Points in your PI Server. + +Detecting the Data Type Mismatch Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Editing your data type choices in OMF North will cause the following messages to appear in the System Log: + +.. code-block:: console + + WARNING: The OMF endpoint reported a conflict when sending containers: 1 messages + WARNING: Message 0: Error, A container with the supplied ID already exists, but does not match the supplied container., + +These errors will cause the plugin to retry sending container information a number of times determined the *Maximum Retry* count on the *Connection* tab in the Fledge GUI. +The default is 3. +The plugin will then send numeric data values to PI continuously. +Unfortunately, the PI Web API returns no HTTP error when this happens so no messages are logged. +In PI, you will see that timestamps are correct but all numeric values are zero. + +Recovering from the Data Type Mismatch Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As you experiment with configurations, you may discover that your original assumptions about your data types were not correct and need to be changed. +It is possible to repair your PI Server so that you do not need to discard your AF Database and start over. +This is the procedure: + +- Shut down your OMF North instance. +- Using PI System Explorer, locate the problematic PI Points. + These are points with a value of zero. + The PI Points are mapped to AF Attributes using the PI Point Data Reference. + For each AF Attribute, you can see the name of the PI Point in the Settings pane. +- Using PI System Management Tools (PI SMT), open the Point Builder tool (under Points) and locate the problematic PI Points. +- In the General tab in the Point Builder, locate the Extended Descriptor (*Exdesc*). + It will contain a long character string with several OMF tokens such as *OmfPropertyIndexer*, *OmfContainerId* and *OmfTypeId*. + Clear the *Excdesc* field completely and save your change. +- Start up your OMF North instance. + +Clearing the Extended Descriptor will cause OMF to "adopt" the PI Point. +OMF will update the Extended Descriptor with new values of the OMF tokens. +Watch the System Log during startup to see if any problems occur. + +Further Troubleshooting +~~~~~~~~~~~~~~~~~~~~~~~ + +If you are unable to locate your problematic PI Points using the PI System Explorer, or if there are simply too many of them, there are advanced techniques available to troubleshoot +and repair your system. +Contact Technical Support for assistance. + .. _Linked_Types: Linked Types From 9d092fc25f6291f515d17529c46986e5c636a5f4 Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Tue, 27 Feb 2024 16:52:29 +0530 Subject: [PATCH 077/146] Updated README.rst file for documentation of Code Coverage Report Signed-off-by: Mohit04tomar --- tests/README.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/README.rst b/tests/README.rst index 47c9db148e..ba939e59a0 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -16,6 +16,10 @@ pytest +.. |pytest-cov docs| raw:: html + + pytest-cov + .. _Unit: unit\\python\\ .. _System: system\\ .. _here: ..\\README.rst @@ -85,3 +89,25 @@ If you want to contribute towards adding a new tests in Fledge, make sure you fo - Test file name should begin with the word ``test_`` to enable pytest auto test discovery. - Make sure you are placing your test file in the correct test directory. For example, if you are writing a unit test, it should be located under ``$FLEDGE_ROOT/tests/unit/python/fledge/`` where component is the name of the component for which you are writing the unit tests. For more information of type of test, refer to the test categories. + +Code Coverage +------------- + +Python Tests +++++++++++++ + +Fledge uses PyTest-cov Framework of Pytest as the code coverage measuring tool for python tests, For more information on pytest please refer to |pytest-cov docs|. + +To install PyTest-cov Framework along with Pytest Framework use the following command: +:: + python3 -m pip install pytest==3.6.4 pytest-cov==2.9.0 + +Running the python tests: + +- ``pytest test_filename.py --cov=. --cov-report xml:xml_filepath --cov-report html:html_directorypath`` - This will execute all tests in the file named test_filename.py and generate the code coverage report in XML as well as the HTML format at the specified path in the command. + + +C Tests ++++++++ + +#TODO: FOGL-8497 Add documentation of Code Coverage of C Based tests From f95e02f5b13b42b073a79940c834e42b6daa092e Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Tue, 27 Feb 2024 17:00:01 +0530 Subject: [PATCH 078/146] Minor Updates Signed-off-by: Mohit04tomar --- tests/README.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/README.rst b/tests/README.rst index ba939e59a0..d166719f6d 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -96,7 +96,7 @@ Code Coverage Python Tests ++++++++++++ -Fledge uses PyTest-cov Framework of Pytest as the code coverage measuring tool for python tests, For more information on pytest please refer to |pytest-cov docs|. +Fledge uses PyTest-cov Framework of Pytest as the code coverage measuring tool for python tests, For more information on PyTest-cov please refer to |pytest-cov docs|. To install PyTest-cov Framework along with Pytest Framework use the following command: :: @@ -104,7 +104,10 @@ To install PyTest-cov Framework along with Pytest Framework use the following co Running the python tests: +- ``pytest --cov=. --cov-report xml:xml_filepath --cov-report html:html_directorypath`` - This will execute all the python test files in the given directory and sub-directories and generate the code coverage report in XML as well as the HTML format at the specified path in the command. - ``pytest test_filename.py --cov=. --cov-report xml:xml_filepath --cov-report html:html_directorypath`` - This will execute all tests in the file named test_filename.py and generate the code coverage report in XML as well as the HTML format at the specified path in the command. +- ``pytest test_filename.py::TestClass --cov=. --cov-report xml:xml_filepath --cov-report html:html_directorypath`` - This will execute all test methods in a single class TestClass in file test_filename.py and generate the code coverage report in XML as well as the HTML format at the specified path in the command. +- ``pytest test_filename.py::TestClass::test_case --cov=. --cov-report xml:xml_filepath --cov-report html:html_directorypath`` - This will execute test method test_case in class TestClass in file test_filename.py and generate the code coverage report in XML as well as the HTML format at the specified path in the command. C Tests From 6f36d584ead5093776fc5c82b31d1be44a5b5017 Mon Sep 17 00:00:00 2001 From: Ray Verhoeff Date: Tue, 27 Feb 2024 08:10:18 -0500 Subject: [PATCH 079/146] Add reference to Numeric Data Types Change mention of the Numeric Data Types section to a reference link. Signed-off-by: Ray Verhoeff --- docs/OMF.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/OMF.rst b/docs/OMF.rst index 12eaa504c4..48c9d90460 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -235,7 +235,7 @@ 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. -See the section *Numeric Data Types* below for more information on configuring data types. +See the section :ref:`Numeric Data Types` for more information on configuring data types. +--------------+ | |OMF_Format| | @@ -465,7 +465,7 @@ Number Format Hints A number format hint tells the plugin what number format to use when inserting data into the PI Server. The following will cause all numeric data within the asset to be written using the format *float32*. -See the section *Numeric Data Types* below. +See the section :ref:`Numeric Data Types`. .. code-block:: console @@ -479,7 +479,7 @@ Integer Format Hints An integer format hint tells the plugin what integer format to use when inserting data into the PI Server. The following will cause all integer data within the asset to be written using the format *integer32*. -See the section *Numeric Data Types* below. +See the section :ref:`Numeric Data Types`. .. code-block:: console From fd11341e7b0c0ee79f62ddf025c7fe6870e0bc50 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 28 Feb 2024 11:35:37 +0000 Subject: [PATCH 080/146] FOGL-8461 Interim checkin to make safe if pipeline branches are created (#1292) * dd checks for pipelien elements Signed-off-by: Mark Riddoch * Update comments Signed-off-by: Mark Riddoch * FOGL-8461 Add messages if branches or destinations are seen in the filter pipeline. This is an interim checkin to prevent iossues if users create such pipelines and will be replaced as soon as backend support is available. Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/filter_pipeline.cpp | 55 +++++++++++++++++++---------- C/common/include/pipeline_element.h | 54 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 C/common/include/pipeline_element.h diff --git a/C/common/filter_pipeline.cpp b/C/common/filter_pipeline.cpp index 05d1187041..ad046bd64b 100755 --- a/C/common/filter_pipeline.cpp +++ b/C/common/filter_pipeline.cpp @@ -134,31 +134,48 @@ bool FilterPipeline::loadFilters(const string& categoryName) // Try loading all filter plugins: abort on any error for (Value::ConstValueIterator itr = filterList.Begin(); itr != filterList.End(); ++itr) { - // Get "plugin" item fromn filterCategoryName - string filterCategoryName = itr->GetString(); - ConfigCategory filterDetails = mgtClient->getCategory(filterCategoryName); - if (!filterDetails.itemExists("plugin")) + if (itr->IsString()) { - string errMsg("loadFilters: 'plugin' item not found "); - errMsg += "in " + filterCategoryName + " category"; - Logger::getLogger()->fatal(errMsg.c_str()); - throw runtime_error(errMsg); + // Get "plugin" item fromn filterCategoryName + string filterCategoryName = itr->GetString(); + ConfigCategory filterDetails = mgtClient->getCategory(filterCategoryName); + if (!filterDetails.itemExists("plugin")) + { + string errMsg("loadFilters: 'plugin' item not found "); + errMsg += "in " + filterCategoryName + " category"; + Logger::getLogger()->fatal(errMsg.c_str()); + throw runtime_error(errMsg); + } + string filterName = filterDetails.getValue("plugin"); + PLUGIN_HANDLE filterHandle; + // Load filter plugin only: we don't call any plugin method right now + filterHandle = loadFilterPlugin(filterName); + if (!filterHandle) + { + string errMsg("Cannot load filter plugin '" + filterName + "'"); + Logger::getLogger()->fatal(errMsg.c_str()); + throw runtime_error(errMsg); + } + else + { + // Save filter handler: key is filterCategoryName + filterInfo.push_back(pair + (filterCategoryName, filterHandle)); + } } - string filterName = filterDetails.getValue("plugin"); - PLUGIN_HANDLE filterHandle; - // Load filter plugin only: we don't call any plugin method right now - filterHandle = loadFilterPlugin(filterName); - if (!filterHandle) + else if (itr->IsArray()) { - string errMsg("Cannot load filter plugin '" + filterName + "'"); - Logger::getLogger()->fatal(errMsg.c_str()); - throw runtime_error(errMsg); + // Sub pipeline + Logger::getLogger()->warn("This version of Fledge does not support branching of pipelines. The branch will be ignored."); + } + else if (itr->IsObject()) + { + // An object, probably the write destination + Logger::getLogger()->warn("This version of Fledge does not support pipelines with different destinations. The destination will be ignored and the data written to the default storage service."); } else { - // Save filter handler: key is filterCategoryName - filterInfo.push_back(pair - (filterCategoryName, filterHandle)); + Logger::getLogger()->error("Unexpected object in pipeline definition %s, ignoring", categoryName.c_str()); } } diff --git a/C/common/include/pipeline_element.h b/C/common/include/pipeline_element.h new file mode 100644 index 0000000000..1badf33403 --- /dev/null +++ b/C/common/include/pipeline_element.h @@ -0,0 +1,54 @@ +#ifndef _PIPELINE_ELEMENT_H +#define _PIPELINE_ELEMENT_H +/* + * Fledge filter pipeline elements. + * + * Copyright (c) 2024 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Mark Riddoch + */ + +/** + * The base pipeline element class + */ +class PipelineElement { + public: + PipelineElement(); + void setNext(PipelineElement *next) + { + m_next = next; + } + private: + PipelineElement *m_next; + +}; + +/** + * A pipeline element the runs a filter plugin + */ +class PipelineFilter : public PipelineElement { + public: + PipelineFilter(const std::string& name); + private: + FilterPlugin *m_plugin; +}; + +/** + * A pipeline element that represents a branch in the pipeline + */ +class PipelineBranch : public PipelineElement { + public: + PipelineBranch(); +}; + +/** + * A pipeline element that writes to a storage service or buffer + */ +class PipelineWriter : public PipelineElement { + public: + PipelineWriter(); +}; + +#endif From 0fa2f208259ee1a200d0bdb31b69773cb12832f7 Mon Sep 17 00:00:00 2001 From: gnandan <111729765+gnandan@users.noreply.github.com> Date: Fri, 1 Mar 2024 11:48:11 +0530 Subject: [PATCH 081/146] FOGL-8442 : periodic task for update alerts has been added (#1283) * FOGL-8441 : Method to getalert and raise alert added to management client Signed-off-by: nandan --- C/tasks/check_updates/CMakeLists.txt | 40 ++++ C/tasks/check_updates/check_updates.cpp | 189 ++++++++++++++++++ C/tasks/check_updates/include/check_updates.h | 39 ++++ C/tasks/check_updates/main.cpp | 43 ++++ CMakeLists.txt | 1 + Makefile | 13 +- VERSION | 2 +- .../plugins/storage/postgres/downgrade/68.sql | 4 + scripts/plugins/storage/postgres/init.sql | 13 ++ .../plugins/storage/postgres/upgrade/69.sql | 15 ++ .../plugins/storage/sqlite/downgrade/68.sql | 5 + scripts/plugins/storage/sqlite/init.sql | 15 ++ scripts/plugins/storage/sqlite/upgrade/69.sql | 17 ++ .../plugins/storage/sqlitelb/downgrade/68.sql | 5 + scripts/plugins/storage/sqlitelb/init.sql | 14 ++ .../plugins/storage/sqlitelb/upgrade/69.sql | 17 ++ scripts/tasks/check_updates | 25 +++ 17 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 C/tasks/check_updates/CMakeLists.txt create mode 100644 C/tasks/check_updates/check_updates.cpp create mode 100644 C/tasks/check_updates/include/check_updates.h create mode 100644 C/tasks/check_updates/main.cpp create mode 100644 scripts/plugins/storage/postgres/downgrade/68.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/69.sql create mode 100644 scripts/plugins/storage/sqlite/downgrade/68.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/69.sql create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/68.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/69.sql create mode 100755 scripts/tasks/check_updates diff --git a/C/tasks/check_updates/CMakeLists.txt b/C/tasks/check_updates/CMakeLists.txt new file mode 100644 index 0000000000..0895d5f9b9 --- /dev/null +++ b/C/tasks/check_updates/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required (VERSION 2.8.8) +project (check_updates) + +set(CMAKE_CXX_FLAGS_DEBUG "-O0 -ggdb") +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -Wall -Wextra -Wsign-conversion") +set(COMMON_LIB common-lib) +set(PLUGINS_COMMON_LIB plugins-common-lib) + +find_package(Threads REQUIRED) + +set(BOOST_COMPONENTS system thread) + +find_package(Boost 1.53.0 COMPONENTS ${BOOST_COMPONENTS} REQUIRED) +include_directories(SYSTEM ${Boost_INCLUDE_DIR}) + +include_directories(.) +include_directories(include) +include_directories(../../thirdparty/Simple-Web-Server) +include_directories(../../thirdparty/rapidjson/include) +include_directories(../../common/include) + +file(GLOB check_updates_src "*.cpp") + +link_directories(${PROJECT_BINARY_DIR}/../../lib) + +add_executable(${PROJECT_NAME} ${check_updates_src} ${common_src}) +target_link_libraries(${PROJECT_NAME} ${Boost_LIBRARIES}) +target_link_libraries(${PROJECT_NAME} ${CMAKE_THREAD_LIBS_INIT}) +target_link_libraries(${PROJECT_NAME} ${COMMON_LIB}) +target_link_libraries(${PROJECT_NAME} ${PLUGINS_COMMON_LIB}) + + +install(TARGETS check_updates RUNTIME DESTINATION fledge/tasks) + +if(MSYS) #TODO: Is MSYS true when MSVC is true? + target_link_libraries(check_updates ws2_32 wsock32) + if(OPENSSL_FOUND) + target_link_libraries(check_updates ws2_32 wsock32) + endif() +endif() diff --git a/C/tasks/check_updates/check_updates.cpp b/C/tasks/check_updates/check_updates.cpp new file mode 100644 index 0000000000..52d43c5f6f --- /dev/null +++ b/C/tasks/check_updates/check_updates.cpp @@ -0,0 +1,189 @@ +/* + * Fledge Check Updates + * + * Copyright (c) 2024 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Devki Nandan Ghildiyal + */ + +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace std; + +volatile std::sig_atomic_t signalReceived = 0; + +static void signalHandler(int signal) +{ + signalReceived = signal; +} + + +/** + * Constructor for CheckUpdates + */ +CheckUpdates::CheckUpdates(int argc, char** argv) : FledgeProcess(argc, argv) +{ + std::string paramName; + paramName = getName(); + m_logger = new Logger(paramName); + m_logger->info("CheckUpdates starting - parameters name :%s:", paramName.c_str() ); + m_mgtClient = this->getManagementClient(); + +} + +/** + * Destructor for CheckUpdates + */ +CheckUpdates::~CheckUpdates() +{ +} + +/** + * CheckUpdates run method, called by the base class to start the process and do the actual work. + */ +void CheckUpdates::run() +{ + // We handle these signals, add more if needed + std::signal(SIGINT, signalHandler); + std::signal(SIGSTOP, signalHandler); + std::signal(SIGTERM, signalHandler); + + + if (!m_dryRun) + { + raiseAlerts(); + } + processEnd(); +} + +/** + * Execute the raiseAlerts, create an alert for all the packages for which update is available + */ +void CheckUpdates::raiseAlerts() +{ + m_logger->debug("raiseAlerts running"); + try + { + for (auto key: getUpgradablePackageList()) + { + std::string message = "A newer version of " + key + " is available for upgrade"; + std::string urgency = "normal"; + if (!m_mgtClient->raiseAlert(key,message,urgency)) + { + m_logger->error("Failed to raise an alert for key=%s,message=%s,urgency=%s", key.c_str(), message.c_str(), urgency.c_str()); + } + } + + } + catch (...) + { + try + { + std::exception_ptr p = std::current_exception(); + std::rethrow_exception(p); + } + catch(const std::exception& e) + { + m_logger->error("Failed to raise alert : %s", e.what()); + } + + } +} + +/** + * Logs process end message + */ + +void CheckUpdates::processEnd() +{ + m_logger->debug("raiseAlerts completed"); +} + +/** + * Fetch package manager name + */ + +std::string CheckUpdates::getPackageManager() +{ + std::string command = "command -v yum || command -v apt-get"; + std::string result = ""; + char buffer[128]; + + // Open pipe to file + FILE* pipe = popen(command.c_str(), "r"); + if (!pipe) + { + m_logger->error("getPackageManager: popen call failed : %s",strerror(errno)); + return ""; + } + // read till end of process: + while (!feof(pipe)) + { + if (fgets(buffer, 128, pipe) != NULL) + result += buffer; + } + + pclose(pipe); + + if (result.find("apt") != std::string::npos) + return "apt"; + if (result.find("yum") != std::string::npos) + return "yum"; + + m_logger->warn("Unspported environment %s", result.c_str() ); + return ""; +} + +/** + * Fetch a list of all the package name for which upgrade is available + */ +std::vector CheckUpdates::getUpgradablePackageList() +{ + std::string packageManager = getPackageManager(); + std::vector packageList; + if(!packageManager.empty()) + { + std::string command = "(sudo apt update && sudo apt list --upgradeable) 2>/dev/null | grep '^fledge' | cut -d'/' -f1 "; + if (packageManager.find("yum") != std::string::npos) + { + command = "(sudo yum check-update && sudo yum list updates) 2>/dev/null | grep '^fledge' | cut -d' ' -f1 "; + } + + FILE* pipe = popen(command.c_str(), "r"); + if (!pipe) + { + m_logger->error("getUpgradablePackageList: popen call failed : %s",strerror(errno)); + return packageList; + } + + char buffer[128]; + while (!feof(pipe)) + { + if (fgets(buffer, 128, pipe) != NULL) + { + //strip out newline characher + int len = strlen(buffer) - 1; + if (*buffer && buffer[len] == '\n') + buffer[len] = '\0'; + + packageList.emplace_back(buffer); + + } + } + + pclose(pipe); + } + + return packageList; +} + diff --git a/C/tasks/check_updates/include/check_updates.h b/C/tasks/check_updates/include/check_updates.h new file mode 100644 index 0000000000..869961673c --- /dev/null +++ b/C/tasks/check_updates/include/check_updates.h @@ -0,0 +1,39 @@ +#ifndef _CHECK_UPDATES_H +#define _CHECK_UPDATES_H + +/* + * Fledge Check Updates + * + * Copyright (c) 2024 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Devki Nandan Ghildiyal + */ + +#include + +#define LOG_NAME "check_updates" + +/** + * CheckUpdates class + */ + +class CheckUpdates : public FledgeProcess +{ + public: + CheckUpdates(int argc, char** argv); + ~CheckUpdates(); + void run(); + + private: + Logger *m_logger; + ManagementClient *m_mgtClient; + + void raiseAlerts(); + std::string getPackageManager(); + std::vector getUpgradablePackageList(); + void processEnd(); + +}; +#endif diff --git a/C/tasks/check_updates/main.cpp b/C/tasks/check_updates/main.cpp new file mode 100644 index 0000000000..4e7c5fcab5 --- /dev/null +++ b/C/tasks/check_updates/main.cpp @@ -0,0 +1,43 @@ +/* + * Fledge Check Updates + * + * Copyright (c) 2024 Dianomic Systems + * + * Released under the Apache 2.0 Licence + * + * Author: Devki Nandan Ghildiyal + */ + +#include +#include + +using namespace std; + +int main(int argc, char** argv) +{ + Logger *logger = new Logger(LOG_NAME); + + try + { + CheckUpdates check(argc, argv); + + check.run(); + } + catch (...) + { + try + { + std::exception_ptr p = std::current_exception(); + std::rethrow_exception(p); + } + catch(const std::exception& e) + { + logger->error("An error occurred during the execution : %s", e.what()); + } + + exit(1); + } + + // Return success + exit(0); +} diff --git a/CMakeLists.txt b/CMakeLists.txt index 8ab13abe33..4198f82ae3 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/check_updates) add_subdirectory(C/tasks/statistics_history) add_subdirectory(C/plugins/utils) add_subdirectory(C/plugins/north/OMF) diff --git a/Makefile b/Makefile index 3ce471339c..2b27031369 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_CHECK_UPDATES_BINARY := $(CMAKE_TASKS_DIR)/check_updates/check_updates 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 @@ -71,6 +72,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_CHECK_UPDATES_BINARY := $(DEV_TASKS_DIR)/check_updates 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* @@ -132,6 +134,7 @@ DISPATCHER_C_SCRIPT_SRC := scripts/services/dispatcher_c BUCKET_STORAGE_C_SCRIPT_SRC := scripts/services/bucket_storage_c PURGE_SCRIPT_SRC := scripts/tasks/purge PURGE_C_SCRIPT_SRC := scripts/tasks/purge_system +CHECK_UPDATES_SCRIPT_SRC := scripts/tasks/check_updates STATISTICS_SCRIPT_SRC := scripts/tasks/statistics BACKUP_SRC := scripts/tasks/backup RESTORE_SRC := scripts/tasks/restore @@ -168,7 +171,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_STATISTICS_BINARY) $(SYMLINK_PLUGINS_DIR) \ + c_build $(SYMLINK_STORAGE_BINARY) $(SYMLINK_SOUTH_BINARY) $(SYMLINK_NORTH_SERVICE_BINARY) $(SYMLINK_NORTH_BINARY) $(SYMLINK_PURGE_SYSTEM_BINARY) $(SYMLINK_CHECK_UPDATES_BINARY) $(SYMLINK_STATISTICS_BINARY) $(SYMLINK_PLUGINS_DIR) \ python_build python_requirements_user apply_version : @@ -291,6 +294,10 @@ $(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 check_updates binary +$(SYMLINK_CHECK_UPDATES_BINARY) : $(DEV_TASKS_DIR) + $(LN) $(CMAKE_CHECK_UPDATES_BINARY) $(SYMLINK_CHECK_UPDATES_BINARY) + # create symlink to purge_system binary $(SYMLINK_STATISTICS_BINARY) : $(DEV_TASKS_DIR) $(LN) $(CMAKE_STATISTICS_BINARY) $(SYMLINK_STATISTICS_BINARY) @@ -354,6 +361,7 @@ scripts_install : $(SCRIPTS_INSTALL_DIR) \ install_dispatcher_c_script \ install_bucket_storage_c_script \ install_purge_script \ + install_check_updates_script \ install_statistics_script \ install_storage_script \ install_backup_script \ @@ -425,6 +433,9 @@ install_purge_script : $(SCRIPT_TASKS_INSTALL_DIR) $(PURGE_SCRIPT_SRC) $(CP) $(PURGE_SCRIPT_SRC) $(SCRIPT_TASKS_INSTALL_DIR) $(CP) $(PURGE_C_SCRIPT_SRC) $(SCRIPT_TASKS_INSTALL_DIR) +install_check_updates_script : $(SCRIPT_TASKS_INSTALL_DIR) $(CHECK_UPDATES_SCRIPT_SRC) + $(CP) $(CHECK_UPDATES_SCRIPT_SRC) $(SCRIPT_TASKS_INSTALL_DIR) + install_statistics_script : $(SCRIPT_TASKS_INSTALL_DIR) $(STATISTICS_SCRIPT_SRC) $(CP) $(STATISTICS_SCRIPT_SRC) $(SCRIPT_TASKS_INSTALL_DIR) diff --git a/VERSION b/VERSION index a34de07a01..eb559d2113 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.3.0 -fledge_schema=68 +fledge_schema=69 diff --git a/scripts/plugins/storage/postgres/downgrade/68.sql b/scripts/plugins/storage/postgres/downgrade/68.sql new file mode 100644 index 0000000000..c9555e6911 --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/68.sql @@ -0,0 +1,4 @@ +DELETE FROM fledge.schedules WHERE process_name = 'update checker'; +DELETE FROM fledge.scheduled_processes WHERE name = 'update checker'; + +COMMIT; diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index b5658e2f27..92f2f8a994 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -1065,6 +1065,7 @@ INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'purge_system', INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'stats collector', '["tasks/statistics"]' ); INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'FledgeUpdater', '["tasks/update"]' ); INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'certificate checker', '["tasks/check_certs"]' ); +INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'update checker', '["tasks/check_updates"]'); -- Storage Tasks -- @@ -1139,6 +1140,18 @@ INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, true -- enabled ); +-- Update checker +INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, + schedule_time, schedule_interval, exclusive, enabled ) + VALUES ( '852cd8e4-3c29-440b-89ca-2c7691b0450d', -- id + 'update checker', -- schedule_name + 'update checker', -- process_name + 2, -- schedule_type (timed) + '00:05:00', -- schedule_time + '00:00:00', -- schedule_interval + true, -- exclusive + true -- enabled + ); -- Check for expired certificates INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, diff --git a/scripts/plugins/storage/postgres/upgrade/69.sql b/scripts/plugins/storage/postgres/upgrade/69.sql new file mode 100644 index 0000000000..90999a7677 --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/69.sql @@ -0,0 +1,15 @@ +--- Insert update checker schedule and process entry + +INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'update checker', '["tasks/check_updates"]' ); +INSERT INTO fledge.schedules (id, schedule_name, process_name, schedule_type, schedule_time, schedule_interval, exclusive, enabled) +VALUES ('852cd8e4-3c29-440b-89ca-2c7691b0450d', -- id + 'update checker', -- schedule_name + 'update checker', -- process_name + 2, -- schedule_type (timed) + '00:05:00', -- schedule_time + '00:00:00', -- schedule_interval (evey 24 hours) + 'true', -- exclusive + 'true' -- enabled + ); + +COMMIT; diff --git a/scripts/plugins/storage/sqlite/downgrade/68.sql b/scripts/plugins/storage/sqlite/downgrade/68.sql new file mode 100644 index 0000000000..eb7732de6b --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/68.sql @@ -0,0 +1,5 @@ +DELETE FROM fledge.schedules WHERE process_name = 'update checker'; +DELETE FROM fledge.scheduled_processes WHERE name = 'update checker'; + +COMMIT; + diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 88da76d240..490fff5836 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -821,6 +821,7 @@ INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'purge_system', INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'stats collector', '["tasks/statistics"]' ); INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'FledgeUpdater', '["tasks/update"]' ); INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'certificate checker', '["tasks/check_certs"]' ); +INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'update checker', '["tasks/check_updates"]'); -- Storage Tasks -- @@ -895,6 +896,20 @@ INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, 't' -- enabled ); +-- Check Updates +INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, + schedule_time, schedule_interval, exclusive, enabled ) + VALUES ( '852cd8e4-3c29-440b-89ca-2c7691b0450d', -- id + 'update checker', -- schedule_name + 'update checker', -- process_name + 2, -- schedule_type (timed) + '00:05:00', -- schedule_time + '00:00:00', -- schedule_interval + 't', -- exclusive + 't' -- enabled + ); + + -- Check for expired certificates INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, schedule_time, schedule_interval, exclusive, enabled ) diff --git a/scripts/plugins/storage/sqlite/upgrade/69.sql b/scripts/plugins/storage/sqlite/upgrade/69.sql new file mode 100644 index 0000000000..a696da7878 --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/69.sql @@ -0,0 +1,17 @@ +--- Insert check updates schedule and process entry + +INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'update checker', '["tasks/check_updates"]' ); +INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, + schedule_time, schedule_interval, exclusive, enabled ) + VALUES ( '852cd8e4-3c29-440b-89ca-2c7691b0450d', -- id + 'update checker', -- schedule_name + 'update checker', -- process_name + 2, -- schedule_type (timed) + '00:05:00', -- schedule_time + '00:00:00', -- schedule_interval + 't', -- exclusive + 't' -- enabled + ); + + +COMMIT; diff --git a/scripts/plugins/storage/sqlitelb/downgrade/68.sql b/scripts/plugins/storage/sqlitelb/downgrade/68.sql new file mode 100644 index 0000000000..eb7732de6b --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/68.sql @@ -0,0 +1,5 @@ +DELETE FROM fledge.schedules WHERE process_name = 'update checker'; +DELETE FROM fledge.scheduled_processes WHERE name = 'update checker'; + +COMMIT; + diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index db50606997..782a07ef0a 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -821,6 +821,7 @@ INSERT INTO fledge.scheduled_processes (name, script) VALUES ( 'purge_system', INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'stats collector', '["tasks/statistics"]' ); INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'FledgeUpdater', '["tasks/update"]' ); INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'certificate checker', '["tasks/check_certs"]' ); +INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'update checker', '["tasks/check_updates"]'); -- Storage Tasks -- @@ -895,6 +896,19 @@ INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, 't' -- enabled ); +-- Update checker +INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, + schedule_time, schedule_interval, exclusive, enabled ) + VALUES ( '852cd8e4-3c29-440b-89ca-2c7691b0450d', -- id + 'update checker', -- schedule_name + 'update checker', -- process_name + 2, -- schedule_type (timed) + '00:05:00', -- schedule_time + '00:00:00', -- schedule_interval + 't', -- exclusive + 't' -- enabled + ); + -- Check for expired certificates INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, schedule_time, schedule_interval, exclusive, enabled ) diff --git a/scripts/plugins/storage/sqlitelb/upgrade/69.sql b/scripts/plugins/storage/sqlitelb/upgrade/69.sql new file mode 100644 index 0000000000..5cc5fa2e25 --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/69.sql @@ -0,0 +1,17 @@ +--- Insert update checker schedule and process entry + +INSERT INTO fledge.scheduled_processes ( name, script ) VALUES ( 'update checker', '["tasks/check_updates"]' ); +INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, + schedule_time, schedule_interval, exclusive, enabled ) + VALUES ( '852cd8e4-3c29-440b-89ca-2c7691b0450d', -- id + 'update checker', -- schedule_name + 'update checker', -- process_name + 2, -- schedule_type (timed) + '00:05:00', -- schedule_time + '00:00:00', -- schedule_interval + 't', -- exclusive + 't' -- enabled + ); + + +COMMIT; diff --git a/scripts/tasks/check_updates b/scripts/tasks/check_updates new file mode 100755 index 0000000000..3623db9a31 --- /dev/null +++ b/scripts/tasks/check_updates @@ -0,0 +1,25 @@ +#!/bin/sh +# Run a Fledge task written in C + +# Bash Script to invoke Installed packages available upgrade checks binary and raise alerts + +if [ -z ${FLEDGE_ROOT+x} ]; then + # Set FLEDGE_ROOT as the default directory + FLEDGE_ROOT="/usr/local/fledge" + export FLEDGE_ROOT +fi + +# Check if the default directory exists +if [[ ! -d "${FLEDGE_ROOT}" ]]; then + echo "Fledge cannot be executed: ${FLEDGE_ROOT} is not a valid directory." + echo "Create the enviroment variable FLEDGE_ROOT before using check_updates." + echo "Specify the base directory for Fledge and set the variable with:" + echo "export FLEDGE_ROOT=" + exit 1 +fi + + +cd "${FLEDGE_ROOT}" + +./tasks/check_updates "$@" + From fbe738dd2d9e904d5c293554fbf75b6b52e5c319 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 1 Mar 2024 12:07:03 +0530 Subject: [PATCH 082/146] unit tests added for alerts API Signed-off-by: ashish-jabble --- .../fledge/services/core/api/test_alerts.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/unit/python/fledge/services/core/api/test_alerts.py diff --git a/tests/unit/python/fledge/services/core/api/test_alerts.py b/tests/unit/python/fledge/services/core/api/test_alerts.py new file mode 100644 index 0000000000..7bf9928430 --- /dev/null +++ b/tests/unit/python/fledge/services/core/api/test_alerts.py @@ -0,0 +1,84 @@ +import asyncio +import json +import sys +from unittest.mock import MagicMock, patch +import pytest +from aiohttp import web + +from fledge.common.storage_client.storage_client import StorageClientAsync +from fledge.common.alert_manager import AlertManager +from fledge.services.core import connect, routes, server +from fledge.services.core.api import alerts + + +__author__ = "Ashish Jabble" +__copyright__ = "Copyright (c) 2024 Dianomic Systems Inc." +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + +class TestAlerts: + """ Alerts API tests """ + + @pytest.fixture + def client(self, loop, test_client): + app = web.Application(loop=loop) + routes.setup(app) + return loop.run_until_complete(test_client(app)) + + async def async_mock(self, return_value): + return return_value + + def setup_method(self): + storage_client_mock = MagicMock(StorageClientAsync) + server.Server._alert_manager = AlertManager(storage_client_mock) + + def teardown_method(self): + server.Server._alert_manager = None + + async def test_get_all(self, client): + rv = await self.async_mock([]) if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(self.async_mock([])) + + with patch.object(server.Server._alert_manager, 'get_all', return_value=rv): + resp = await client.get('/fledge/alert') + assert 200 == resp.status + json_response = json.loads(await resp.text()) + assert 'alerts' in json_response + assert [] == json_response['alerts'] + + async def test_bad_get_all(self, client): + with patch.object(server.Server._alert_manager, 'get_all', side_effect=Exception): + with patch.object(alerts._LOGGER, 'error') as patch_logger: + resp = await client.get('/fledge/alert') + assert 500 == resp.status + assert '' == resp.reason + json_response = json.loads(await resp.text()) + assert 'message' in json_response + assert '' == json_response['message'] + assert 1 == patch_logger.call_count + + async def test_delete(self, client): + rv = await self.async_mock("Nothing to delete.") \ + if sys.version_info.major == 3 and sys.version_info.minor >= 8 else ( + asyncio.ensure_future(self.async_mock("Nothing to delete."))) + with patch.object(server.Server._alert_manager, 'delete', return_value=rv): + resp = await client.delete('/fledge/alert') + assert 200 == resp.status + json_response = json.loads(await resp.text()) + assert 'message' in json_response + assert "Nothing to delete." == json_response['message'] + + @pytest.mark.parametrize("url, msg, exception, status_code, log_count", [ + ('/fledge/alert', '', Exception, 500, 1), + ('/fledge/alert/blah', 'blah alert not found.', KeyError, 404, 0) + ]) + async def test_bad_delete(self, client, url, msg, exception, status_code, log_count): + with patch.object(server.Server._alert_manager, 'delete', side_effect=exception): + with patch.object(alerts._LOGGER, 'error') as patch_logger: + resp = await client.delete(url) + assert status_code == resp.status + assert msg == resp.reason + json_response = json.loads(await resp.text()) + assert 'message' in json_response + assert msg == json_response['message'] + assert log_count == patch_logger.call_count From 4565e5c26e74baa91b4c99502a5b725e67cbcb69 Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Fri, 1 Mar 2024 12:34:16 +0530 Subject: [PATCH 083/146] Feedback changes Signed-off-by: Mohit04tomar --- tests/README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/README.rst b/tests/README.rst index d166719f6d..6811814d26 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -96,9 +96,9 @@ Code Coverage Python Tests ++++++++++++ -Fledge uses PyTest-cov Framework of Pytest as the code coverage measuring tool for python tests, For more information on PyTest-cov please refer to |pytest-cov docs|. +Fledge uses pytest-cov Framework of pytest as the code coverage measuring tool for python tests, For more information on pytest-cov please refer to |pytest-cov docs|. -To install PyTest-cov Framework along with Pytest Framework use the following command: +To install pytest-cov Framework along with pytest Framework use the following command: :: python3 -m pip install pytest==3.6.4 pytest-cov==2.9.0 @@ -113,4 +113,4 @@ Running the python tests: C Tests +++++++ -#TODO: FOGL-8497 Add documentation of Code Coverage of C Based tests +TODO: FOGL-8497 Add documentation of Code Coverage of C Based tests From 647ba06a687dd67795111b5c4c7ef34ce0e48fbe Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Fri, 1 Mar 2024 14:48:24 +0530 Subject: [PATCH 084/146] Added Asset and Rename filter Signed-off-by: Mohit04tomar --- tests/system/memory_leak/test_memcheck.sh | 52 +++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/tests/system/memory_leak/test_memcheck.sh b/tests/system/memory_leak/test_memcheck.sh index 5d9d5308a4..f5a6917389 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -25,7 +25,7 @@ cleanup(){ # Setting up Fledge and installing its plugin setup(){ - ./scripts/setup "fledge-south-sinusoid fledge-south-random" "${FLEDGE_TEST_BRANCH}" "${COLLECT_FILES}" + ./scripts/setup "fledge-south-sinusoid fledge-south-random fledge-filter-asset fledge-filter-rename" "${FLEDGE_TEST_BRANCH}" "${COLLECT_FILES}" } reset_fledge(){ @@ -39,7 +39,7 @@ add_sinusoid(){ "name": "Sine", "type": "south", "plugin": "sinusoid", - "enabled": true, + "enabled": "true", "config": {} }' echo @@ -51,6 +51,29 @@ add_sinusoid(){ echo } +add_filter_asset_to_sine(){ + echo 'Setting Asset Filter' + curl -sX POST "$FLEDGE_URL/filter" -d \ + '{ + "name":"assset1", + "plugin":"asset", + "filter_config":{ + "enable":"true", + "config":{ + "rules":[ + {"asset_name":"sinusoid","action":"rename","new_asset_name":"sinner"} + ] + } + } + }' + + curl -sX PUT "$FLEDGE_URL/filter/Sine/pipeline?allow_duplicates=true&append_filter=true" -d \ + '{ + "pipeline":["assset1"], + "files":[] + }' +} + add_random(){ echo -e INFO: "Add South Random" curl -sX POST "$FLEDGE_URL/service" -d \ @@ -58,7 +81,7 @@ add_random(){ "name": "Random", "type": "south", "plugin": "Random", - "enabled": true, + "enabled": "true", "config": {} }' echo @@ -70,6 +93,27 @@ add_random(){ echo } + +add_filter_rename_to_random(){ + echo 'Setting Rename Filter' + curl -sX POST "$FLEDGE_URL/filter" -d \ + '{ + "name":"re1", + "plugin":"rename", + "filter_config":{ + "find":"Random", + "replaceWith":"Randomizer", + "enable":"true" + } + }' + + curl -sX PUT "$FLEDGE_URL/filter/Random/pipeline?allow_duplicates=true&append_filter=true" -d \ + '{ + "pipeline":["re1"], + "files":[] + }' +} + setup_north_pi_egress () { # Add PI North as service echo 'Setting up North' @@ -133,7 +177,9 @@ cleanup setup reset_fledge add_sinusoid +add_filter_asset_to_sine add_random +add_filter_rename_to_random setup_north_pi_egress collect_data generate_valgrind_logs From 9493d8780c1b1e8b7239014f0a90afecac9556c4 Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Fri, 1 Mar 2024 15:52:35 +0530 Subject: [PATCH 085/146] Minor update Signed-off-by: Mohit04tomar --- tests/system/memory_leak/config.sh | 3 ++- tests/system/memory_leak/test_memcheck.sh | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/system/memory_leak/config.sh b/tests/system/memory_leak/config.sh index 83e3d50d9c..1340e4e420 100644 --- a/tests/system/memory_leak/config.sh +++ b/tests/system/memory_leak/config.sh @@ -2,4 +2,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 +PI_PASSWORD="password" +READINGSRATE="1000" # It is the readings rate per second per service \ 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 f5a6917389..d48097831f 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -47,7 +47,7 @@ add_sinusoid(){ sleep 60 - curl -sX PUT "$FLEDGE_URL/category/SineAdvanced" -d '{ "readingsPerSec": "100"}' + curl -sX PUT "$FLEDGE_URL/category/SineAdvanced" -d '{ "readingsPerSec": "'${READINGSRATE}'"}' echo } @@ -89,7 +89,7 @@ add_random(){ sleep 60 - curl -sX PUT "$FLEDGE_URL/category/RandomAdvanced" -d '{ "readingsPerSec": "100"}' + curl -sX PUT "$FLEDGE_URL/category/RandomAdvanced" -d '{ "readingsPerSec": "'${READINGSRATE}'"}' echo } From c880746a066bfdc13280e104a8a8f801eb3aa72b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 1 Mar 2024 16:17:45 +0530 Subject: [PATCH 086/146] Alert Manager unit tests added Signed-off-by: ashish-jabble --- .../fledge/common/test_alert_manager.py | 186 ++++++++++++++++++ .../fledge/services/core/api/test_alerts.py | 3 +- 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 tests/unit/python/fledge/common/test_alert_manager.py diff --git a/tests/unit/python/fledge/common/test_alert_manager.py b/tests/unit/python/fledge/common/test_alert_manager.py new file mode 100644 index 0000000000..ac7a7a26c5 --- /dev/null +++ b/tests/unit/python/fledge/common/test_alert_manager.py @@ -0,0 +1,186 @@ +import asyncio +import json +import sys + +from unittest.mock import MagicMock, patch +import pytest +from fledge.common.storage_client.storage_client import StorageClientAsync +from fledge.common.alert_manager import AlertManager + + +__author__ = "Ashish Jabble" +__copyright__ = "Copyright (c) 2024 Dianomic Systems Inc." +__license__ = "Apache 2.0" +__version__ = "${VERSION}" + +class TestAlertManager: + """ Alert Manager """ + alert_manager = None + + async def async_mock(self, ret_val): + return ret_val + + def setup_method(self): + storage_client_mock = MagicMock(StorageClientAsync) + self.alert_manager = AlertManager(storage_client=storage_client_mock) + self.alert_manager.storage_client = storage_client_mock + #self.alert_manager.alerts = [] + + def teardown_method(self): + self.alert_manager.alerts = [] + self.alert_manager = None + + async def test_urgency(self): + urgencies = self.alert_manager.urgency + assert 4 == len(urgencies) + assert ['Critical', 'High', 'Normal', 'Low'] == list(urgencies.keys()) + + @pytest.mark.parametrize("urgency_index, urgency", [ + ('1', 'UNKNOWN'), + ('High', 'UNKNOWN'), + (0, 'UNKNOWN'), + (1, 'Critical'), + (2, 'High'), + (3, 'Normal'), + (4, 'Low') + ]) + async def test__urgency_name_by_value(self, urgency_index, urgency): + value = self.alert_manager._urgency_name_by_value(value=urgency_index) + assert urgency == value + + @pytest.mark.parametrize("storage_result, response", [ + ({"rows": [], 'count': 0}, []), + ({"rows": [{"key": "RW", "message": "The Service RW restarted 1 times", "urgency": 3, + "timestamp": "2024-03-01 09:40:34.482"}], 'count': 1}, [{"key": "RW", "message": + "The Service RW restarted 1 times", "urgency": "Normal", "timestamp": "2024-03-01 09:40:34.482"}]) + ]) + async def test_get_all(self, storage_result, response): + rv = await self.async_mock(storage_result) if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(self.async_mock(storage_result)) + with patch.object(self.alert_manager.storage_client, 'query_tbl_with_payload', return_value=rv + ) as patch_query_tbl: + result = await self.alert_manager.get_all() + assert response == result + args, _ = patch_query_tbl.call_args + assert 'alerts' == args[0] + assert {"return": ["key", "message", "urgency", {"column": "ts", "alias": "timestamp", + "format": "YYYY-MM-DD HH24:MI:SS.MS"}]} == json.loads(args[1]) + + + async def test_bad_get_all(self): + storage_result = {"rows": [{}], 'count': 1} + rv = await self.async_mock(storage_result) if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(self.async_mock(storage_result)) + with patch.object(self.alert_manager.storage_client, 'query_tbl_with_payload', return_value=rv + ) as patch_query_tbl: + with pytest.raises(Exception) as ex: + await self.alert_manager.get_all() + assert "'key'" == str(ex.value) + args, _ = patch_query_tbl.call_args + assert 'alerts' == args[0] + assert {"return": ["key", "message", "urgency", {"column": "ts", "alias": "timestamp", + "format": "YYYY-MM-DD HH24:MI:SS.MS"}]} == json.loads(args[1]) + + async def test_get_by_key_when_in_cache(self): + self.alert_manager.alerts = [{"key": "RW", "message": "The Service RW restarted 1 times", "urgency": 3, + "timestamp": "2024-03-01 09:40:34.482"}] + key = "RW" + result = await self.alert_manager.get_by_key(key) + assert self.alert_manager.alerts[0] == result + + async def test_get_by_key_not_found(self): + key = "Sine" + storage_result = {"rows": [], 'count': 1} + rv = await self.async_mock(storage_result) if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(self.async_mock(storage_result)) + with patch.object(self.alert_manager.storage_client, 'query_tbl_with_payload', return_value=rv + ) as patch_query_tbl: + with pytest.raises(Exception) as ex: + await self.alert_manager.get_by_key(key) + assert ex.type is KeyError + assert "'{} alert not found.'".format(key) == str(ex.value) + args, _ = patch_query_tbl.call_args + assert 'alerts' == args[0] + assert {"return": ["key", "message", "urgency", {"column": "ts", "alias": "timestamp", + "format": "YYYY-MM-DD HH24:MI:SS.MS"}], + "where": {"column": "key", "condition": "=", "value": key}} == json.loads(args[1]) + + async def test_get_by_key_when_not_in_cache(self): + key = 'update' + storage_result = {"rows": [{"key": "RW", "message": "The Service RW restarted 1 times", "urgency": 3, + "timestamp": "2024-03-01 09:40:34.482"}], 'count': 1} + rv = await self.async_mock(storage_result) if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(self.async_mock(storage_result)) + with patch.object(self.alert_manager.storage_client, 'query_tbl_with_payload', return_value=rv + ) as patch_query_tbl: + result = await self.alert_manager.get_by_key(key) + storage_result['rows'][0]['urgency'] = 'Normal' + assert storage_result['rows'][0] == result + args, _ = patch_query_tbl.call_args + assert 'alerts' == args[0] + assert {"return": ["key", "message", "urgency", {"column": "ts", "alias": "timestamp", + "format": "YYYY-MM-DD HH24:MI:SS.MS"}], + "where": {"column": "key", "condition": "=", "value": key}} == json.loads(args[1]) + + async def test_add(self): + params = {"key": "update", 'message': 'New version available', 'urgency': 'High'} + storage_result = {'rows_affected': 1, "response": "inserted"} + rv = await self.async_mock(storage_result) if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(self.async_mock(storage_result)) + with patch.object(self.alert_manager.storage_client, 'insert_into_tbl', return_value=rv + ) as insert_tbl_patch: + result = await self.alert_manager.add(params) + assert 'alert' in result + assert params == result['alert'] + args, _ = insert_tbl_patch.call_args + assert 'alerts' == args[0] + assert params == json.loads(args[1]) + + async def test_bad_add(self): + params = {"key": "update", 'message': 'New version available', 'urgency': 'High'} + storage_result = {} + rv = await self.async_mock(storage_result) if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(self.async_mock(storage_result)) + with patch.object(self.alert_manager.storage_client, 'insert_into_tbl', return_value=rv + ) as insert_tbl_patch: + with pytest.raises(Exception) as ex: + await self.alert_manager.add(params) + assert "'response'" == str(ex.value) + args, _ = insert_tbl_patch.call_args + assert 'alerts' == args[0] + assert params == json.loads(args[1]) + + async def test_delete(self): + storage_result = {'rows_affected': 1, "response": "deleted"} + rv = await self.async_mock(storage_result) if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(self.async_mock(storage_result)) + with patch.object(self.alert_manager.storage_client, 'delete_from_tbl', return_value=rv + ) as delete_tbl_patch: + result = await self.alert_manager.delete() + assert 'alert' in result + assert "Delete all alerts." == result + args, _ = delete_tbl_patch.call_args + assert 'alerts' == args[0] + + async def test_delete_by_key(self): + key = "RW" + self.alert_manager.alerts = [{"key": key, "message": "The Service RW restarted 1 times", "urgency": 3, + "timestamp": "2024-03-01 09:40:34.482"}] + storage_result = {'rows_affected': 1, "response": "deleted"} + rv = await self.async_mock(storage_result) if sys.version_info.major == 3 and sys.version_info.minor >= 8 \ + else asyncio.ensure_future(self.async_mock(storage_result)) + with patch.object(self.alert_manager.storage_client, 'delete_from_tbl', return_value=rv + ) as delete_tbl_patch: + result = await self.alert_manager.delete(key) + assert 'alert' in result + assert "{} alert is deleted.".format(key) == result + args, _ = delete_tbl_patch.call_args + assert 'alerts' == args[0] + assert {"where": {"column": "key", "condition": "=", "value": key}} == json.loads(args[1]) + + async def test_bad_delete(self): + with pytest.raises(Exception) as ex: + await self.alert_manager.delete("Update") + assert ex.type is KeyError + assert "" == str(ex.value) + diff --git a/tests/unit/python/fledge/services/core/api/test_alerts.py b/tests/unit/python/fledge/services/core/api/test_alerts.py index 7bf9928430..c02d8e7903 100644 --- a/tests/unit/python/fledge/services/core/api/test_alerts.py +++ b/tests/unit/python/fledge/services/core/api/test_alerts.py @@ -17,7 +17,7 @@ __version__ = "${VERSION}" class TestAlerts: - """ Alerts API tests """ + """ Alerts API """ @pytest.fixture def client(self, loop, test_client): @@ -82,3 +82,4 @@ async def test_bad_delete(self, client, url, msg, exception, status_code, log_co assert 'message' in json_response assert msg == json_response['message'] assert log_count == patch_logger.call_count + From 3899f1af0ff78da1135bac652024945396403bdf Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 1 Mar 2024 19:18:16 +0530 Subject: [PATCH 087/146] upgrade fixes with schema 69 as incomplete query Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/68.sql | 2 -- scripts/plugins/storage/postgres/upgrade/69.sql | 2 -- scripts/plugins/storage/sqlite/downgrade/68.sql | 3 --- scripts/plugins/storage/sqlite/upgrade/69.sql | 3 --- scripts/plugins/storage/sqlitelb/downgrade/68.sql | 3 --- scripts/plugins/storage/sqlitelb/upgrade/69.sql | 3 --- 6 files changed, 16 deletions(-) diff --git a/scripts/plugins/storage/postgres/downgrade/68.sql b/scripts/plugins/storage/postgres/downgrade/68.sql index c9555e6911..78bc486212 100644 --- a/scripts/plugins/storage/postgres/downgrade/68.sql +++ b/scripts/plugins/storage/postgres/downgrade/68.sql @@ -1,4 +1,2 @@ DELETE FROM fledge.schedules WHERE process_name = 'update checker'; DELETE FROM fledge.scheduled_processes WHERE name = 'update checker'; - -COMMIT; diff --git a/scripts/plugins/storage/postgres/upgrade/69.sql b/scripts/plugins/storage/postgres/upgrade/69.sql index 90999a7677..dd6b131266 100644 --- a/scripts/plugins/storage/postgres/upgrade/69.sql +++ b/scripts/plugins/storage/postgres/upgrade/69.sql @@ -11,5 +11,3 @@ VALUES ('852cd8e4-3c29-440b-89ca-2c7691b0450d', -- id 'true', -- exclusive 'true' -- enabled ); - -COMMIT; diff --git a/scripts/plugins/storage/sqlite/downgrade/68.sql b/scripts/plugins/storage/sqlite/downgrade/68.sql index eb7732de6b..78bc486212 100644 --- a/scripts/plugins/storage/sqlite/downgrade/68.sql +++ b/scripts/plugins/storage/sqlite/downgrade/68.sql @@ -1,5 +1,2 @@ DELETE FROM fledge.schedules WHERE process_name = 'update checker'; DELETE FROM fledge.scheduled_processes WHERE name = 'update checker'; - -COMMIT; - diff --git a/scripts/plugins/storage/sqlite/upgrade/69.sql b/scripts/plugins/storage/sqlite/upgrade/69.sql index a696da7878..6a7d056fbd 100644 --- a/scripts/plugins/storage/sqlite/upgrade/69.sql +++ b/scripts/plugins/storage/sqlite/upgrade/69.sql @@ -12,6 +12,3 @@ INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, 't', -- exclusive 't' -- enabled ); - - -COMMIT; diff --git a/scripts/plugins/storage/sqlitelb/downgrade/68.sql b/scripts/plugins/storage/sqlitelb/downgrade/68.sql index eb7732de6b..78bc486212 100644 --- a/scripts/plugins/storage/sqlitelb/downgrade/68.sql +++ b/scripts/plugins/storage/sqlitelb/downgrade/68.sql @@ -1,5 +1,2 @@ DELETE FROM fledge.schedules WHERE process_name = 'update checker'; DELETE FROM fledge.scheduled_processes WHERE name = 'update checker'; - -COMMIT; - diff --git a/scripts/plugins/storage/sqlitelb/upgrade/69.sql b/scripts/plugins/storage/sqlitelb/upgrade/69.sql index 5cc5fa2e25..cd17b27a83 100644 --- a/scripts/plugins/storage/sqlitelb/upgrade/69.sql +++ b/scripts/plugins/storage/sqlitelb/upgrade/69.sql @@ -12,6 +12,3 @@ INSERT INTO fledge.schedules ( id, schedule_name, process_name, schedule_type, 't', -- exclusive 't' -- enabled ); - - -COMMIT; From ef1fbeebde9a779bc631cea25ab6287f2c38fe8f Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Mon, 4 Mar 2024 15:32:00 +0530 Subject: [PATCH 088/146] Feedback changes Signed-off-by: Mohit04tomar --- tests/system/memory_leak/test_memcheck.sh | 49 ++++++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/tests/system/memory_leak/test_memcheck.sh b/tests/system/memory_leak/test_memcheck.sh index d48097831f..da0ed2700f 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -1,6 +1,27 @@ #!/bin/bash + +__author__="Mohit Singh Tomar" +__copyright__="Copyright (c) 2024 Dianomic Systems Inc." +__license__="Apache 2.0" +__version__="1.0.0" + +####################################################################################################################### +# Script Name: test_memcheck.sh +# Description: Tests for checking memory leaks in Fledge. +# Usage: ./test_memcheck.sh FLEDGE_TEST_BRANCH COLLECT_FILES [OPTIONS] +# +# Parameters: +# FLEDGE_TEST_BRANCH (str): Branch of Fledge Repository on which valgrind test will run. +# COLLECT_FILES (str): Type of report file needs to be collected from valgrind test, default is LOGS otherwise XML. +# +# Options: +# --use-filters: If passed, add filters to South Services. # -# Tests for checking meomory leaks. +# Example: +# ./test_memcheck.sh develop LOGS +# ./test_memcheck.sh develop LOGS --use-filters +# +######################################################################################################################### set -e source config.sh @@ -8,8 +29,12 @@ 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 develop - COLLECT_FILES="${2:-LOGS}" +USE_FILTER="False" + +if [ "$3" = "--use-filters" ]; then + USE_FILTER="True" +fi if [[ ${COLLECT_FILES} != @(LOGS|XML|) ]] then @@ -51,11 +76,11 @@ add_sinusoid(){ echo } -add_filter_asset_to_sine(){ +add_asset_filter_to_sine(){ echo 'Setting Asset Filter' curl -sX POST "$FLEDGE_URL/filter" -d \ '{ - "name":"assset1", + "name":"asset #1", "plugin":"asset", "filter_config":{ "enable":"true", @@ -69,7 +94,7 @@ add_filter_asset_to_sine(){ curl -sX PUT "$FLEDGE_URL/filter/Sine/pipeline?allow_duplicates=true&append_filter=true" -d \ '{ - "pipeline":["assset1"], + "pipeline":["asset #1"], "files":[] }' } @@ -94,11 +119,11 @@ add_random(){ } -add_filter_rename_to_random(){ - echo 'Setting Rename Filter' +add_rename_filter_to_random(){ + echo -e "\nSetting Rename Filter" curl -sX POST "$FLEDGE_URL/filter" -d \ '{ - "name":"re1", + "name":"rename #1", "plugin":"rename", "filter_config":{ "find":"Random", @@ -109,7 +134,7 @@ add_filter_rename_to_random(){ curl -sX PUT "$FLEDGE_URL/filter/Random/pipeline?allow_duplicates=true&append_filter=true" -d \ '{ - "pipeline":["re1"], + "pipeline":["rename #1"], "files":[] }' } @@ -177,9 +202,11 @@ cleanup setup reset_fledge add_sinusoid -add_filter_asset_to_sine add_random -add_filter_rename_to_random +if [ "${USE_FILTER}" = "True" ]; then + add_asset_filter_to_sine + add_rename_filter_to_random +fi setup_north_pi_egress collect_data generate_valgrind_logs From 76091686264bbcc81c9aac0ad5f0651a7c05d4d1 Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Tue, 5 Mar 2024 15:36:16 +0530 Subject: [PATCH 089/146] Fixed issue in report generation, updated reset script to add funcitonality of choosing readingsdb Signed-off-by: Mohit04tomar --- tests/system/memory_leak/config.sh | 4 ++- tests/system/memory_leak/scripts/reset | 32 ++++++++++++++++++++--- tests/system/memory_leak/test_memcheck.sh | 17 +++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/tests/system/memory_leak/config.sh b/tests/system/memory_leak/config.sh index 1340e4e420..12f6fb7981 100644 --- a/tests/system/memory_leak/config.sh +++ b/tests/system/memory_leak/config.sh @@ -3,4 +3,6 @@ TEST_RUN_TIME=3600 PI_IP="localhost" PI_USER="Administrator" PI_PASSWORD="password" -READINGSRATE="1000" # It is the readings rate per second per service \ No newline at end of file +READINGSRATE="100" # It is the readings rate per second per service +STORAGE='sqlite' # postgres, sqlite-in-memory, sqlitelb +READING_PLUGIN_DB='Use main plugin' \ No newline at end of file diff --git a/tests/system/memory_leak/scripts/reset b/tests/system/memory_leak/scripts/reset index 2cc67c71f1..093d89459f 100755 --- a/tests/system/memory_leak/scripts/reset +++ b/tests/system/memory_leak/scripts/reset @@ -1,12 +1,36 @@ #!/usr/bin/env bash - -echo "Stopping Fledge" export FLEDGE_ROOT=$1 +echo "${FLEDGE_ROOT}" -cd ${1}/scripts/ && ./fledge stop +install_postgres() { + sudo apt install -y postgresql + sudo -u postgres createuser -d "$(whoami)" +} + +_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 +} -echo 'resetting fledge' +# check for storage plugin +. ./config.sh + +if [[ ${STORAGE} == @(sqlite|postgres|sqlitelb) && ${READING_PLUGIN_DB} == @(Use main plugin|sqlitememory|sqlite|postgres|sqlitelb) ]] +then + _config_reading_db "${STORAGE}" "${READING_PLUGIN_DB}" +else + echo "Invalid Storage Configuration" + exit 1 +fi + +echo "Stopping Fledge" +cd ${1}/scripts/ && ./fledge stop +echo 'Resetting Fledge' echo -e "YES\nYES" | ./fledge reset || exit 1 echo echo "Starting Fledge" diff --git a/tests/system/memory_leak/test_memcheck.sh b/tests/system/memory_leak/test_memcheck.sh index da0ed2700f..f0df4d74de 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -64,7 +64,7 @@ add_sinusoid(){ "name": "Sine", "type": "south", "plugin": "sinusoid", - "enabled": "true", + "enabled": "false", "config": {} }' echo @@ -77,7 +77,7 @@ add_sinusoid(){ } add_asset_filter_to_sine(){ - echo 'Setting Asset Filter' + echo 'Adding Asset Filter to Sinusoid Service' curl -sX POST "$FLEDGE_URL/filter" -d \ '{ "name":"asset #1", @@ -106,7 +106,7 @@ add_random(){ "name": "Random", "type": "south", "plugin": "Random", - "enabled": "true", + "enabled": "false", "config": {} }' echo @@ -120,7 +120,7 @@ add_random(){ } add_rename_filter_to_random(){ - echo -e "\nSetting Rename Filter" + echo -e "\nAdding Rename Filter to Random Service" curl -sX POST "$FLEDGE_URL/filter" -d \ '{ "name":"rename #1", @@ -139,6 +139,14 @@ add_rename_filter_to_random(){ }' } +enable_services(){ + echo -e "\nEnable Services" + curl -sX PUT "$FLEDGE_URL/schedule/enable" -d '{"schedule_name":"Sine"}' + sleep 20 + curl -sX PUT "$FLEDGE_URL/schedule/enable" -d '{"schedule_name": "Random"}' + sleep 20 +} + setup_north_pi_egress () { # Add PI North as service echo 'Setting up North' @@ -207,6 +215,7 @@ if [ "${USE_FILTER}" = "True" ]; then add_asset_filter_to_sine add_rename_filter_to_random fi +enable_services setup_north_pi_egress collect_data generate_valgrind_logs From 858bef83bed1427dd376bc4a3a3c1259e186f38f Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Tue, 5 Mar 2024 16:42:53 +0530 Subject: [PATCH 090/146] Added Code to collect support bundle and syslog Signed-off-by: Mohit04tomar --- tests/system/memory_leak/test_memcheck.sh | 31 ++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/tests/system/memory_leak/test_memcheck.sh b/tests/system/memory_leak/test_memcheck.sh index f0df4d74de..2b52a4c22e 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -31,6 +31,7 @@ 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}" USE_FILTER="False" +SCRIPT_DIR=$(dirname "$(readlink -f "$0")") if [ "$3" = "--use-filters" ]; then USE_FILTER="True" @@ -188,19 +189,37 @@ setup_north_pi_egress () { } # This Function keep the fledge and its plugin running state for the "TEST_RUN_TIME" seconds then stop the fledge, So that data required for mem check be collected. -collect_data(){ - sleep ${TEST_RUN_TIME} - # TODO: remove set +e / set -e - # FOGL-6840 fledge stop returns exit code 1 +collect_data() { + echo "Collecting Data and Generating reports" + sleep "${TEST_RUN_TIME}" set +e - ${FLEDGE_ROOT}/scripts/fledge stop && echo $? + + echo "===================== COLLECTING SUPPORT BUNDLE / SYSLOG ============================" + mkdir -p reports/ && ls -lrth + BUNDLE=$(curl -sX POST "$FLEDGE_URL/support") + # Check if the bundle is created using jq + if jq -e 'has("bundle created")' <<< "$BUNDLE" > /dev/null; then + echo "Support Bundle Created" + # Use proper quoting for variable expansion + cp -r "$FLEDGE_ROOT/data/support/"* reports/ && \ + echo "Support bundle has been saved to path: $SCRIPT_DIR/reports" + else + echo "Failed to Create support bundle" + # Use proper quoting for variable expansion + cp /var/log/syslog reports/ && \ + echo "Syslog Saved to path: $SCRIPT_DIR/reports" + fi + echo "===================== COLLECTED SUPPORT BUNDLE / SYSLOG ============================" + # Use proper quoting for variable expansion + "${FLEDGE_ROOT}/scripts/fledge" stop && echo $? set -e } + generate_valgrind_logs(){ echo 'Creating reports directory'; mkdir -p reports/ ; ls -lrth - echo 'copying reports ' + echo 'copying reports' extension="xml" if [[ "${COLLECT_FILES}" == "LOGS" ]]; then extension="log"; fi cp -rf /tmp/*valgrind*.${extension} reports/. && echo 'copied' From 64c8ff4022023097f5d0e21bb68444b97841402c Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Tue, 5 Mar 2024 18:11:35 +0530 Subject: [PATCH 091/146] Added log analyzer script Signed-off-by: Mohit04tomar --- tests/system/memory_leak/scripts/log_analyzer | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100755 tests/system/memory_leak/scripts/log_analyzer diff --git a/tests/system/memory_leak/scripts/log_analyzer b/tests/system/memory_leak/scripts/log_analyzer new file mode 100755 index 0000000000..412149606f --- /dev/null +++ b/tests/system/memory_leak/scripts/log_analyzer @@ -0,0 +1,48 @@ +#!/bin/bash + +log_directory="${1}" +error_tolerance="${2}" +leak_tolerance="${3}" + +for log_file in "$log_directory"/*.log; do + echo "Analyzing $log_file..." + + error_summary=$(grep -o "ERROR SUMMARY: [0-9]* errors" "$log_file" | tail -n 1 | cut -d ' ' -f 3) + leak_summary=$(sed -n '/LEAK SUMMARY:/,/ERROR SUMMARY:/p' "$log_file" | grep -E "definitely lost|indirectly lost|possibly lost|still reachable" | tail -n 4) + + if [ -n "$error_summary" ]; then + if [ "$error_summary" -gt "${error_tolerance}" ]; then + echo "Valgrind detected $error_summary error(s) in the log file: $log_file" + exit 1 + else + echo "Valgrind did not detected any errors in the log file: $log_file" + fi + else + echo "No error summary found in the log file." + fi + + if [ -n "$leak_summary" ]; then + echo "Valgrind detected memory leaks in the log file." + definitely_lost=$(echo "$leak_summary" | grep -o "definitely lost: [0-9,]* bytes" | awk '{print $3}' | tr -d ',') + indirectly_lost=$(echo "$leak_summary" | grep -o "indirectly lost: [0-9,]* bytes" | awk '{print $3}' | tr -d ',') + possibly_lost=$(echo "$leak_summary" | grep -o "possibly lost: [0-9,]* bytes" | awk '{print $3}' | tr -d ',') + still_reachable=$(echo "$leak_summary" | grep -o "still reachable: [0-9,]* bytes" | awk '{print $3}' | tr -d ',') + + echo "Definitely Lost: $definitely_lost" + echo "Indirectly Lost: $indirectly_lost" + echo "Possibly Lost: $possibly_lost" + echo "Still Reachable: $still_reachable" + + if [ "$definitely_lost" -gt "$leak_tolerance" ] || [ "$indirectly_lost" -gt "$leak_tolerance" ] || [ "$possibly_lost" -gt "$leak_tolerance" ] || [ "$still_reachable" -gt "$leak_tolerance" ]; then + echo "Memory leak is higher than the tolerable value: $log_file" + exit 1 + else + echo "Valgrind did not detect any errors in the log file: $log_file" + fi + + else + echo "No memory leaks detected by Valgrind in the log file." + fi + + echo "==============================" +done From c0f53aebffa2a01f5595f3ba864795626e71beb7 Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Wed, 6 Mar 2024 16:46:52 +0530 Subject: [PATCH 092/146] Add purge configurations/schedule and some minor update to log analyzer script Signed-off-by: Mohit04tomar --- tests/system/memory_leak/config.sh | 1 + tests/system/memory_leak/scripts/log_analyzer | 4 ++-- tests/system/memory_leak/test_memcheck.sh | 22 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/system/memory_leak/config.sh b/tests/system/memory_leak/config.sh index 12f6fb7981..05ff72a619 100644 --- a/tests/system/memory_leak/config.sh +++ b/tests/system/memory_leak/config.sh @@ -4,5 +4,6 @@ PI_IP="localhost" PI_USER="Administrator" PI_PASSWORD="password" READINGSRATE="100" # It is the readings rate per second per service +PURGE_INTERVAL_SECONDS="180" STORAGE='sqlite' # postgres, sqlite-in-memory, sqlitelb READING_PLUGIN_DB='Use main plugin' \ No newline at end of file diff --git a/tests/system/memory_leak/scripts/log_analyzer b/tests/system/memory_leak/scripts/log_analyzer index 412149606f..aaa45575a0 100755 --- a/tests/system/memory_leak/scripts/log_analyzer +++ b/tests/system/memory_leak/scripts/log_analyzer @@ -1,8 +1,8 @@ #!/bin/bash log_directory="${1}" -error_tolerance="${2}" -leak_tolerance="${3}" +error_tolerance=$(printf '%d' "${2}" 2>/dev/null) +leak_tolerance=$(printf '%d' "${3}" 2>/dev/null) for log_file in "$log_directory"/*.log; do echo "Analyzing $log_file..." diff --git a/tests/system/memory_leak/test_memcheck.sh b/tests/system/memory_leak/test_memcheck.sh index 2b52a4c22e..6df4894602 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -58,6 +58,27 @@ reset_fledge(){ ./scripts/reset ${FLEDGE_ROOT} ; } +configure_purge(){ + # This function is for updating purge configuration and schedule of python based purge. + echo -e "Updating Purge Configuration \n" + row_count="$(printf "%.0f" "$(echo "${READINGSRATE} * 2 * ${PURGE_INTERVAL_SECONDS}"| bc)")" + curl -X PUT "$FLEDGE_URL/category/PURGE_READ" -d "{\"size\":\"${row_count}\"}" + echo + echo -e "Updated Purge Configuration \n" + echo -e "Updating Purge Schedule \n" + echo > enable_purge.json + cat enable_purge.json + curl -X PUT "$FLEDGE_URL/schedule/cea17db8-6ccc-11e7-907b-a6006ad3dba0" -d \ + '{ + "name": "purge", + "type": 3, + "repeat": '"${PURGE_INTERVAL_SECONDS}"', + "exclusive": true, + "enabled": true + }' + echo -e "Updated Purge Schedule \n" +} + add_sinusoid(){ echo -e INFO: "Add South Sinusoid" curl -sX POST "$FLEDGE_URL/service" -d \ @@ -228,6 +249,7 @@ generate_valgrind_logs(){ cleanup setup reset_fledge +configure_purge add_sinusoid add_random if [ "${USE_FILTER}" = "True" ]; then From 5378fd7265a60e647c90651395770b88c422e474 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 7 Mar 2024 12:07:35 +0530 Subject: [PATCH 093/146] reference link fixes in OMF doc Signed-off-by: ashish-jabble --- docs/OMF.rst | 2 ++ docs/scripts/plugin_and_service_documentation | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/OMF.rst b/docs/OMF.rst index 48c9d90460..3979bfbe1a 100644 --- a/docs/OMF.rst +++ b/docs/OMF.rst @@ -650,6 +650,8 @@ 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|. +.. _Numeric Data Types: + Numeric Data Types ------------------ diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index 523122b066..7fa8e39c04 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -144,7 +144,7 @@ do 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 + 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/' -e 's/.. _Numeric Data Types/.. _Numeric_Data_Types/' plugins/${name}/index.rst elif [[ $repo == "fledge-rule-DataAvailability" ]]; then name="fledge-rule-DataAvailability" echo " ${name}/index" >> $dest From 4939a03cd03c4f2d182ca402a9843f8f05aaed0d Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 7 Mar 2024 14:53:11 +0000 Subject: [PATCH 094/146] FOGL-8563 Add ability to tune asset tracker update frequency (#1304) * FOGL-8563 Add asset tracker tunign to the south service Signed-off-by: Mark Riddoch * FOGL-8563 Add asset tracker tunign to north service Signed-off-by: Mark Riddoch * Address review comments Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/asset_tracking.cpp | 25 +++++++++++++++++++++++-- C/common/include/asset_tracking.h | 4 ++++ C/services/north/north.cpp | 24 ++++++++++++++++++++++++ C/services/south/include/defaults.h | 3 +++ C/services/south/south.cpp | 14 ++++++++++++++ docs/tuning_fledge.rst | 4 ++++ 6 files changed, 72 insertions(+), 2 deletions(-) diff --git a/C/common/asset_tracking.cpp b/C/common/asset_tracking.cpp index 2f6e9dedc7..8e040affa1 100644 --- a/C/common/asset_tracking.cpp +++ b/C/common/asset_tracking.cpp @@ -43,7 +43,7 @@ AssetTracker *AssetTracker::getAssetTracker() * @param service Service name */ AssetTracker::AssetTracker(ManagementClient *mgtClient, string service) - : m_mgtClient(mgtClient), m_service(service) + : m_mgtClient(mgtClient), m_service(service), m_updateInterval(MIN_ASSET_TRACKER_UPDATE) { instance = this; m_shutdown = false; @@ -338,6 +338,27 @@ void AssetTracker::queue(TrackingTuple *tuple) m_cv.notify_all(); } +/** + * Set the update interval for the asset tracker. + * + * @param interval The number of milliseconds between update of the asset tracker + * @return bool Was the update accepted + */ +bool AssetTracker::tune(unsigned long interval) +{ + unique_lock lck(m_mutex); + if (interval >= MIN_ASSET_TRACKER_UPDATE) + { + m_updateInterval = interval; + } + else + { + Logger::getLogger()->error("Attempt to set asset tracker update to less than minimum interval"); + return false; + } + return true; +} + /** * The worker thread that will flush any pending asset tuples to * the database. @@ -347,7 +368,7 @@ void AssetTracker::workerThread() unique_lock lck(m_mutex); while (m_pending.empty() && m_shutdown == false) { - m_cv.wait_for(lck, chrono::milliseconds(500)); + m_cv.wait_for(lck, chrono::milliseconds(m_updateInterval)); processQueue(); } // Process any items left in the queue at shutdown diff --git a/C/common/include/asset_tracking.h b/C/common/include/asset_tracking.h index 5445bfca4c..9b70cb6683 100644 --- a/C/common/include/asset_tracking.h +++ b/C/common/include/asset_tracking.h @@ -21,6 +21,8 @@ #include #include +#define MIN_ASSET_TRACKER_UPDATE 500 // The minimum interval for asset tracker updates + /** * Tracking abstract base class to be passed in the process data queue */ @@ -268,6 +270,7 @@ class AssetTracker { void updateCache(std::set dpSet, StorageAssetTrackingTuple* ptr); std::set *getStorageAssetTrackingCacheData(StorageAssetTrackingTuple* tuple); + bool tune(unsigned long updateInterval); private: std::string @@ -292,6 +295,7 @@ class AssetTracker { std::string m_fledgeName; StorageClient *m_storageClient; StorageAssetCacheMap storageAssetTrackerTuplesCache; + unsigned int m_updateInterval; }; /** diff --git a/C/services/north/north.cpp b/C/services/north/north.cpp index d74e15dc4c..4f47a4dc42 100755 --- a/C/services/north/north.cpp +++ b/C/services/north/north.cpp @@ -475,6 +475,15 @@ void NorthService::start(string& coreAddress, unsigned short corePort) m_dataLoad->setBlockSize(newBlock); } } + if (m_configAdvanced.itemExists("assetTrackerInterval")) + { + unsigned long interval = strtoul( + m_configAdvanced.getValue("assetTrackerInterval").c_str(), + NULL, + 10); + if (m_assetTracker) + m_assetTracker->tune(interval); + } m_dataSender = new DataSender(northPlugin, m_dataLoad, this); m_dataSender->setPerfMonitor(m_perfMonitor); @@ -810,6 +819,15 @@ void NorthService::configChange(const string& categoryName, const string& catego m_dataLoad->setBlockSize(newBlock); } } + if (m_configAdvanced.itemExists("assetTrackerInterval")) + { + unsigned long interval = strtoul( + m_configAdvanced.getValue("assetTrackerInterval").c_str(), + NULL, + 10); + if (m_assetTracker) + m_assetTracker->tune(interval); + } if (m_configAdvanced.itemExists("perfmon")) { string perf = m_configAdvanced.getValue("perfmon"); @@ -921,6 +939,12 @@ 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("assetTrackerInterval", + "Number of milliseconds between updates of the asset tracker information", + "integer", std::to_string(MIN_ASSET_TRACKER_UPDATE), + std::to_string(MIN_ASSET_TRACKER_UPDATE)); + defaultConfig.setItemDisplayName("assetTrackerInterval", + "Asset Tracker Update"); defaultConfig.addItem("perfmon", "Track and store performance counters", "boolean", "false", "false"); defaultConfig.setItemDisplayName("perfmon", "Performance Counters"); diff --git a/C/services/south/include/defaults.h b/C/services/south/include/defaults.h index d6eefed30e..10f7d7cd78 100644 --- a/C/services/south/include/defaults.h +++ b/C/services/south/include/defaults.h @@ -25,6 +25,9 @@ static struct { "Enable flow control by reducing the poll rate", "boolean", "false" }, { "readingsPerSec", "Reading Rate", "Number of readings to generate per interval", "integer", "1" }, + { "assetTrackerInterval", "Asset Tracker Update", + "Number of milliseconds between updates of the asset tracker information", + "integer", "500" }, { NULL, NULL, NULL, NULL, NULL } }; #endif diff --git a/C/services/south/south.cpp b/C/services/south/south.cpp index c105e46b7f..530b40ec0d 100644 --- a/C/services/south/south.cpp +++ b/C/services/south/south.cpp @@ -423,6 +423,13 @@ void SouthService::start(string& coreAddress, unsigned short corePort) } m_assetTracker = new AssetTracker(m_mgtClient, m_name); + if (m_configAdvanced.itemExists("assetTrackerInterval")) + { + string interval = m_configAdvanced.getValue("assetTrackerInterval"); + unsigned long i = strtoul(interval.c_str(), NULL, 10); + if (m_assetTracker) + m_assetTracker->tune(i); + } { // Instantiate the Ingest class @@ -1024,6 +1031,13 @@ void SouthService::processConfigChange(const string& categoryName, const string& m_throttle = false; } } + if (m_configAdvanced.itemExists("assetTrackerInterval")) + { + string interval = m_configAdvanced.getValue("assetTrackerInterval"); + unsigned long i = strtoul(interval.c_str(), NULL, 10); + if (m_assetTracker) + m_assetTracker->tune(i); + } } // Update the Security category diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index a12f954e60..174f122f06 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -47,6 +47,8 @@ The south services within Fledge each have a set of advanced configuration optio - *Reading Rate* - The rate at which polling occurs for this south service. This parameter only has effect if your south plugin is polled, asynchronous south services do not use this parameter. The units are defined by the setting of the *Reading Rate Per* item. + - *Asset Tracker Update* - This control how frequently the asset tracker flushes the cache of asset tracking information to the storage layer. It is a value expressed in milliseconds. The asset tracker only write updates, therefore if you have a fixed set of assets flowing in a pipeline the asset tracker will only write any data the first time each asset is seen and will then perform no further writes. If you have variablility in your assets or asset structure the asset tracker will be more active and it becomes more useful to tune this parameter. + - *Reading Rate Per* - This defines the units to be used in the *Reading Rate* value. It allows the selection of per *second*, *minute* or *hour*. - *Poll Type* - This defines the mechanism used to control the poll requests that will be sent to the plugin. Three options are currently available, interval polling and fixed time polling and polling on demand. @@ -186,6 +188,8 @@ 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. + - *Asset Tracker Update* - This control how frequently the asset tracker flushes the cache of asset tracking information to the storage layer. It is a value expressed in milliseconds. The asset tracker only write updates, therefore if you have a fixed set of assets flowing in a pipeline the asset tracker will only write any data the first time each asset is seen and will then perform no further writes. If you have variablility in your assets or asset structure the asset tracker will be more active and it becomes more useful to tune this parameter. + - *Performance Counters* - This option allows for collection of performance counters that can be use to help tune the north service. Performance Counters From 3c85e951f6ce6134f1232c56709183755e871a65 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 8 Mar 2024 12:19:19 +0530 Subject: [PATCH 095/146] pytest-cov pip dep version bumped to 2.9.0 Signed-off-by: ashish-jabble --- python/requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/requirements-test.txt b/python/requirements-test.txt index 0b679c3b9f..4e6ba3ddf8 100644 --- a/python/requirements-test.txt +++ b/python/requirements-test.txt @@ -3,7 +3,7 @@ pytest==3.6.4 pytest-allure-adaptor==1.7.10 pytest-asyncio==0.10.0 pytest-mock==1.10.3 -pytest-cov==2.5.1 +pytest-cov==2.9.0 pytest-aiohttp==0.3.0 # Common - REST interface From 368f4223a2a3a46a1f609344a3bd31cb53662a4b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 8 Mar 2024 15:06:09 +0530 Subject: [PATCH 096/146] coveragerc file added for tests with some omit directories and files; as some dir's are unused one's Signed-off-by: ashish-jabble --- python/.coveragerc | 12 ------------ tests/unit/python/.coveragerc | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 12 deletions(-) delete mode 100644 python/.coveragerc create mode 100644 tests/unit/python/.coveragerc diff --git a/python/.coveragerc b/python/.coveragerc deleted file mode 100644 index 7ed5194bfd..0000000000 --- a/python/.coveragerc +++ /dev/null @@ -1,12 +0,0 @@ -# .coveragerc to control coverage.py -[run] -omit = - /*/tests/* - /*/venv/* - /*/.tox/* - __template__.py - -[report] - -[html] -directory = htmlcov \ No newline at end of file diff --git a/tests/unit/python/.coveragerc b/tests/unit/python/.coveragerc new file mode 100644 index 0000000000..50655c4268 --- /dev/null +++ b/tests/unit/python/.coveragerc @@ -0,0 +1,16 @@ +[run] +omit = + # Ignore files + */__init__.py + */__template__.py + */setup.py + */python/fledge/tasks/north/sending_process.py + # Omit directory + */python/fledge/plugins/common/* + */python/fledge/plugins/filter/* + */python/fledge/plugins/north/* + */python/fledge/plugins/south/* + */python/fledge/plugins/notificationDelivery/* + */python/fledge/plugins/notificationRule/* + */python/fledge/services/south/* + tests/* From a9754d75b2793aa338386fec47816f2f68deae9e Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 8 Mar 2024 09:40:32 +0000 Subject: [PATCH 097/146] FOGL-8525 Fix type for service in control pipeline source/destination (#1305) table Signed-off-by: Mark Riddoch --- docs/control.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control.rst b/docs/control.rst index f52c4ab997..4428720a99 100644 --- a/docs/control.rst +++ b/docs/control.rst @@ -625,7 +625,7 @@ The control pipelines are not defined against a particular end point as they are - Source - The request is either originating from a script or being sent to a script. * - Service - - Source + - 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. From 6ee64b94b6a0c4f48a900f11d7f10541213e2698 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 8 Mar 2024 15:32:25 +0530 Subject: [PATCH 098/146] pytest settings configuration file added Signed-off-by: ashish-jabble --- tests/unit/python/.pytest.ini | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/unit/python/.pytest.ini diff --git a/tests/unit/python/.pytest.ini b/tests/unit/python/.pytest.ini new file mode 100644 index 0000000000..41b5713e8b --- /dev/null +++ b/tests/unit/python/.pytest.ini @@ -0,0 +1,3 @@ +[pytest] +minversion = 3.6.4 +norecursedirs=tests/unit/python/fledge/plugins/north tests/unit/python/fledge/services/south From c71a681867694e3d96fc82ac4007131a28edfcd8 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 8 Mar 2024 10:08:15 +0000 Subject: [PATCH 099/146] FOGL-8557 Add storage plugin developers documentation (#1301) * FOGL-8557 Add storage plugin developers documentation Signed-off-by: Mark Riddoch * Address review comments Signed-off-by: Mark Riddoch * note directive fixes in storage doc Signed-off-by: ashish-jabble --------- Signed-off-by: Mark Riddoch Signed-off-by: ashish-jabble Co-authored-by: ashish-jabble --- docs/plugin_developers_guide/08_storage.rst | 1258 +++++++++++++++++++ docs/plugin_developers_guide/index.rst | 1 + 2 files changed, 1259 insertions(+) create mode 100644 docs/plugin_developers_guide/08_storage.rst diff --git a/docs/plugin_developers_guide/08_storage.rst b/docs/plugin_developers_guide/08_storage.rst new file mode 100644 index 0000000000..185c3f8222 --- /dev/null +++ b/docs/plugin_developers_guide/08_storage.rst @@ -0,0 +1,1258 @@ +Storage Service And Plugins +=========================== + +The storage component provides a level of abstraction of the database layer used within Fledge. The storage abstract is explicitly not a SQL layer, and the interface it offers to the clients of the storage layer; the device service, API and send process, is very deliberately not a SQL interface to facilitate the replacement of the underlying storage with any no-SQL storage mechanism or even a simple file storage mechanism. Different plugins may be used for the structured and unstructured data that is stored by the storage layer. + +The three requirements that have resulted in the plugin architecture and separation of the database access into a microservice within Fledge are: + + - A desire to be able to support different storage mechanisms as the deployment and customer requirements dictate. E.g. SQL, no-SQL, in-memory, backing store (disk, SD card etc.) or simple file based mechanisms. + + - The ability to separate the storage from the south and north services of Fledge and to allow for distribution of Fledge across multiple physical hardware components. + + - To provide flexibility to allow components to be removed from a Fledge deployment, e.g. remove the buffering and have a simple forwarding router implementation of Fledge without storage. + +Use of JSON +----------- + +There are three distinct reasons that JSON is used within the storage layer, these are; + + - The REST API uses JSON to encode the payloads within each API entry point. This is the preferred payload type for all REST interfaces in Fledge. The option to use XML has been considered and rejected as the vast majority of REST interfaces now use JSON and not XML. JSON is generally more compact and easier to read than XML. + + - The interface between the generic storage layer and the plugin also passes requests and results as JSON. This is partly to make it compatible with the REST payloads and partly to give the plugin implementer flexibility and the ability to push functionality down to the plugin layer to be able to exploit storage system specific features for greatest efficiency. + + - Some of the structures that are persisted are themselves JSON encoded documents. The assumption is that in this case they will remain as JSON all the way to the storage system itself and be persisted as JSON rather than being translated. These JSON structures are transported within the JSON structure of a request (or response) payload and will be sent as objects within that payload although they are not interpreted as anything other than data to be stored by the storage layer. + + +Requirements +~~~~~~~~~~~~ + +The storage layer represents the interface to persist data for the Fledge appliance, all persisted data will be read or written via this storage layer. This includes: + + - Configuration data - this is a set of JSON documents indexed by a key. + + - Readings data - the readings coming from the device that have buffered for a period of time. + + - User & credential data - this is username, passwords and certificates related to the users of the Fledge API. + + - Audit trail data - this is a log of significant events during the lifetime of Fledge. + + - Metrics - various modules will hold performance metrics, such as readings in, readings out etc. These will be periodically written by those models as cumulative totals. These will be collected by the statistics gatherer and interval statistics of the values will be written to the persistent storage. + + - Task records - status and history of the tasks that have been scheduled within Fledge. + + - Flexible schemas - the storage layer should be written that the schema, assuming there is a schema based underlying storage mechanism, is not fixed by the storage layer itself, but by the implementation of the storage and the application (Fledge). In particular the set of tables and columns in those tables is not preconfigured in the storage layer component (assuming a schema based underlying data store). + +Implementation Language +~~~~~~~~~~~~~~~~~~~~~~~ + +The core of the Fledge platform has to date been written using Python, for the storage layer however a decision has been taken to implement this in C/C++. There are a number of factors that need to be taken into account as a result of this decision. + + - Library choices made for the Python implementation are no longer valid and a choice has to be made for C/C++. + + - Common code, such as the microservices management API can not be reused and a C/C++ implementation is required. + +The storage service differs from the other services within Fledge as it only supports plugins compiled to shared objects that have the prescribed C interface. The plugin's code itself may be in other languages, but it must compile to a C compatible shared object using the C calling conventions. + +Language Choice Reasons +####################### + +Initially it was envisaged that the entire Fledge product would be written in Python, after the initial demo implementation issues were starting to surface regarding the validity of this choice for implementation of a product such as Fledge. These issues are; + + - Scalability - Python is essentially a single threaded language due to the Global Interpreter Lock (GIL) which only allows a single Python statement to be executing at any one time. + + - Portability - As we started working more with OSIsoft and with ARM it became clear that the option to port Fledge or some of its components to embedded hardware was going to become more of a requirement for us. In particular the ARM mbed platform is one that has been discussed. Python is not available on this platform or numerous other embedded platforms. + +If Python was not to be the language in which to implement in future then it was decided that the storage layer, as something that has yet to be started, might be best implemented in a different way. Since the design is based on micro-services with REST API’s between them, then it is possible to mix and match the implementation of different components amongst different languages. + +The storage layer is a separate micro-service and not directly linked to any Python code, linkage is only via a REST API. Therefore the storage layer can implement a threading model that best suits it and is not tied to the Python threading model in use in other microservices. + +The choice of C/C++ is based on what is commonly available on all the platforms on which we now envisage Fledge might need to run in the foreseeable future and on the experience available within the team. + +Library Choice +############## + +One of the key libraries that will need to be chosen for C/C++ is the JSON library since there is no native support for this in the language. There are numerous libraries that exist for this purpose, for example, rapidjson, Jansson and many more. Some investigation is required to find the most suitable. The factors to be considered in the choice of library are, in order of importance; + + - Functionality - clearly any library chosen must offer the feature we need. + + - Footprint - Footprint is a major concern for Fledge as we wish to run in constrained devices with the likelihood that in future the device we want to run on may become even smaller than we are considering today. + + - Thread safety - It is assumed that for reasons of scalability and the nature of a REST interface that multiple threads will be employed in the implementation, so hence thread safety is a major concern when choosing a library. + + - Performance - Any library chosen should be reasonably performant at the job it does in order to be considered. We need to avoid choosing libraries that are slow or bloated as part of our drive to run on highly constrained hardware. + +The choice of the JSON library is also something to be considered; since JSON objects are passed across the plugin interface, choosing a C++ library would limit both the microservice and the plugins to use C++. It may be preferable to use a C based library and thus have the flexibility to have a C or C++ implementation for either the service itself or for the plugin. + +Another key library choice, in order to support the REST interface, is an HTTP library capable of being used to support the REST interface development and able to support custom header fields and HTTPS. Once again these are numerous, libmicrohttpd, Simple-Web-Server, Proxygen. A choice must be made here also using the same criteria outlined above. + +Thread safety is likely to be important also as it is assumed the storage layer will be multi-threaded and almost certainly utilise asynchronous I/O operations. + +Classes of Data Stored +---------------------- + +There are two classes of data that Fledge needs to store: + + - Internally generated data + + - Data that emanates from sensors + +The first of these are essentially Fledges configuration, state and lookup data it needs to function. The pattern of access to this data is the classic create, retrieve, update and delete operations that are common to most databases. Access is random by nature and usually via some form of indexes and keys. + +The second class of data that is stored, and the one which is the primary function of Fledge to store, is the data that it receives from sensors. Here the pattern of access is very different; + + - New data is always appended to the stored data + + - No updates are supported on this data + + - Data is predominately read in sequential blocks (main use case) + + - Random access is rare and confined to display and analytics within the user interface or by clients of the public API + + - Deletion of data is done based solely on age and entries will not be removed other than in chronological order. + +Given the difference in the nature of the two classes of data and the possibility that this will result in different storage implementations for the two, the interface is split between these two classes of data. This allows; + + - Different plugins to be used for each type, perhaps a SQL database for the internal data storage and a specialised time series database or document store for the sensor readings. + + - A single plugin can choose to only implement a subset of the plugin API, e.g. the common data access methods or the readings methods. Or both. + + - Plugins can choose where and how they store the readings to optimize the implementation. E.g. a SQL data can store the JSON in a table or a series of tables if preferred. + + - The plugins are not forced to store the JSON data in a particular way. For example, a SQL database does not have to use JSON data types in a single column if it does not support them. + +These two classes of data are referred to in this documentation as “common data access” and “readings data”. + +Common Data Access Methods +-------------------------- + +Most of these types of data can be accessed by the classic create, update, retrieve and delete methods and consist of data in JSON format with an associated key and timestamp. In this case a simple create with a key and JSON value, an update with the same key and value, a retrieve with an optional key (which returns an array of JSON objects) and a delete with the key is all that is required. Configuration, metrics, task records, audit trail and user data all fall into this category. Readings however do not and have to be treated differently. + +Readings Data Access +-------------------- + +Readings work differently from other data, both in the way they are created, retrieved and removed. There is no update functionality required for readings currently, in particular there is no method to update readings data. + +The other difference with readings data from the other data that is managed by the storage layer is related to the volume and use of the data. Readings data is by far the largest volume of data that is managed by Fledge, and has a somewhat different lifecycle and use. The data streams in from external devices, lives within the storage layer for a period of time and is then removed. It may also be retrieved by other processes during the period of time in lives within the buffer. + +Another characteristic of the readings data is the ability to trigger processing based on the arrival of new data. This could be from a process that blocks, waiting for data to arrive or as an optimisation when a process wishes to process the new data as it arrives and not retrieve it explicitly from the storage layer. In this later case the storage data would still be buffered in the storage layer using the usual rules for storage and purging of that data. + +Reading Creation +~~~~~~~~~~~~~~~~ + +Readings come from the device component of Fledge and are a time series stream of JSON documents. They should be appended to the storage device with unique keys and a timestamp. The appending of readings can be considered as a queuing mechanism into the storage layer. + +Managing Blocked Retrievals +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Various components, most notably the sending process and north service, read blocks of readings from the storage layer. These components may request a notification when new readings are available, for example the sending process may request a new block of data when there are no more blocks available. This will be registered with the storage layer and the storage layer will notify the sending process that new data is available and that a subsequent call will return a new block of data. + +This is an advantage feature that may be omitted from the first version. It is intended to allow a process that is fetching and processing readings data to have an efficient way to know that new data is available to be processed. One scenario would be a sending process that has sent all of the readings that are available; it wishes to be informed when new readings are available to it for sending. Rather than poll the storage layer requesting new readings, it may request the storage layer to call it when a number of readings are available beyond the id that process last fetched. + +Bypassing Database Storage +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +One potential optimisation which the storage layer should be built to allow as a future optimization is to architect the storage layer such that a publish/subscribe mechanism could be used to allow the data that flows into the storage layer and be directed to both the storage plugin itself and also send it to other services such as the sending process. + +Reading Retrieval +~~~~~~~~~~~~~~~~~ + +Readings may be retrieved via one of two mechanism + + - By the sending process that will request readings within a time window + + - From the API layer for analysis within the edge device or an external entity that is retrieving the data via the Fledge user REST API. + +The sending process and north service may require large volumes of data to be sent, in order to reduce the memory footprint required and to improve reliability, the sending module will require the readings in controllable “chunks”, therefore it will request readings between two timestamps in blocks of x readings and then request each block sequentially. It is the responsibility of the sending process to ensure that it requests blocks of a reasonable size. Since the REST interface is by definition stateless the storage layer does not need to maintain any information about previous fetches of data. + +The API access to data will be similar, except it will have a limitation on the number of readings, it will request ordered readings between timestamps and ask for readings between the n-th and m-th reading. E.g. Return readings between 21:00 on 10th June 2017 and 21:00 on the 11th June limited to the 100th and 150th reading in that time. The API layer will enforce a maximum number of readings that can be returned in order to make sure result sets are small. + +Reading Removal +~~~~~~~~~~~~~~~ + +The reading removal is done via the purge process, this process will request readings before a given time to be removed from the storage device based on the timestamp of each reading. Introducing the storage layer and removing the pure SQL interface will alter the nature of the purge process and essentially move the logic of the purge process into the storage layer. + +Storage Plugin +-------------- + +One of the requirements that drives the desire to have a storage layer is to isolate the other services and users of the storage layer from the technology that provides that storage. The upper level of the storage service offers a consistent API to the client of the storage service and provides the common infrastructure to communicate with the other services within Fledge, whilst the lower layer provides the interface to the storage technology that will actually store the data. Since we have a desire to be able to switch between different storage layers this lower layer will use a plugin mechanism that will allow a common storage service to dynamically load one or more storage plugins. + +The ability to use multiple plugins within a single storage layer would allow a different plugin to be used for each class of data, see Classes of Data Stored. This would give the flexibility to store Fledges internal data in generic database whilst storing the readings data in something that was tailored specifically to time series or JSON data. There is no requirement to have multiple plugins in any specific deployment, however if the option is to be made available the code that is initially developed should be aware of this future requirement and be implemented appropriately. It is envisaged that the first version will have a single plugin for both classes of data. The incremental effort for supporting more than one plugin is virtually zero, hence the inclusion here. + +Entry Points +~~~~~~~~~~~~ + +The storage plugin exposes a number of entry points in a similar way to the Python plugins used for the translator interface and the device interface. In the C/C++ environment the mechanism is slightly different from that of Python. A plugin is a shared library that is included with the installation or may be installed later into a known location. The library is use by use the dlopen() C library function and each entry point is retrieved using the dlsym() call. + +The plugin interface is modeled as a set of C functions rather than as a C++ class in order to give the plugin writer the flexibility to implement the plugin in C or C++ as desired. + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Entry Point + - Summary + * - plugin_info + - Return information about the plugin. + * - plugin_init + - Initialise the plugin. + * - plugin_common_insert + - Insert a row into a data set (table). + * - plugin_common_retrieve + - Retrieve a result set from a table. + * - plugin_common_update + - Update data in a data set. + * - plugin_common_delete + - Delete data from a data set. + * - plugin_reading_append + - Append one or more readings or the readings table. + * - plugin_reading_fetch + - Retrieve a block of readings from the readings table. + * - plugin_reading_retrieve + - Generic retrieve to retrieve data from the readings table based on query parameters. + * - plugin_reading_purge + - Purge readings from the readings table. + * - plugin_release + - Release a result set previously returned by the plugin to the plugin, so that it may be freed. + * - plugin_last_error + - Return information on the last error that occurred within the plugin. + * - plugin_shutdown + - Called prior to the device service being shut down. + + +Plugin Error Handling +~~~~~~~~~~~~~~~~~~~~~ + +Errors that occur within the plugin must be propagated to the generic storage layer with sufficient information to allow the generic layer to report those errors and take appropriate remedial action. The interface to the plugin has been deliberately chosen not to use C++ classes or interfaces so that plugin implementers are not forced to implement plugins in C++. Therefore the error propagation mechanism can not be C++ exceptions and a much simpler, language agnostic approach must be taken. To that end errors will be indicated by the return status of each call into the interface and a specific plugin entry point will be used to retrieve more details on errors that occur. + +Plugin API Header File +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + #ifndef _PLUGIN_API + #define _PLUGIN_API + + typedef struct { + char *name; + char *version; + unsigned int options; + char *type; + char *interface; + char *config; + } PLUGIN_INFORMATION; + + typedef struct { + char *message; + char *entryPoint; + boolean retryable; + } PLUGIN_ERROR; + + typedef void * PLUGIN_HANDLE; + + /** + * Plugin options bitmask values + */ + #define SP_COMMON 0x0001 + #define SP_READINGS 0x0002 + + /** + * Plugin types + */ + #define PLUGIN_TYPE_STORAGE "storage" + + /** + * Readings purge flags + */ + #define PLUGIN_PURGE_UNSENT 0x0001 + + extern PLUGIN_INFORMATION *plugin_info(); + extern PLUGIN_HANDLE plugin_init(); + extern boolean plugin_common_insert(PLUGIN_HANDLE handle, char *table, JSON *data); + extern JSON *plugin_common_retrieve(PLUGIN_HANDLE handle, char *table, JSON *query); + extern boolean plugin_common_update(PLUGIN_HANDLE handle, char *table, JSON *data); + extern boolean plugin_common_delete(PLUGIN_HANDLE handle, char *table, JSON *condition); + extern boolean plugin_reading_append(PLUGIN_HANDLE handle, JSON *reading); + extern JSON *plugin_reading_fetch(PLUGIN_HANDLE handle, unsigned long id, unsigned int blksize); + extern JSON *plugin_reading_retrieve(PLUGIN_HANDLE handle, JSON *condition); + extern unsigned int plugin_reading_purge(PLUGIN_HANDLE handle, unsigned long age, unsigned int flags, unsigned long sent); + extern plugin_release(PLUGIN_HANDLE handle, JSON *results); + extern PLUGIN_ERROR *plugin_last_error(PLUGIN_HANDLE); + extern boolean plugin_shutdown(PLUGIN_HANDLE handle) + #endif + + +Plugin Support +~~~~~~~~~~~~~~ + +A storage plugin may support either or both of the two data access methods; common data access methods and readings access methods. The storage service can use the mechanism to have one plugin for the common data access methods, and hence a storage system for the general tables and configuration information. It then may load a second plugin in order to support the storage and retrieval of readings. + +Plugin Information +~~~~~~~~~~~~~~~~~~ + +The plugin information entry point, plugin_info() allows the device service to retrieve information from the plugin. This information comes back as a C structure (PLUGIN_INFORMATION). The PLUGIN_INFORMATION will include a number of fields with information that will be used by the storage service. + +.. list-table:: + :header-rows: 1 + :widths: 20 60 20 + + * - Property + - Description + - Example + * - name + - A printable name that can be used to identify the plugin. + - Postgres Plugin + * - version + - A version number of the plugin, again used for diagnostics and status reporting + - 1.0.2 + * - options + - A bitmask of options that describes the level of support offered by this plugin. + Currently two options are available; SP_COMMON and SP_READINGS. Each of these bits represents support for the set of common data access methods and the readings access method. See Plugin Support for details. + - SP_COMMON|SP_READINGS + * - type + - The type of the plugin, this is used to distinguish a storage API plugin from any other type of plugin in Fledge. This should always be the string “storage”. + - storage + * - interface + - The interface version that the plugin implements. Currently the version is 1.0. + - 1.0 + + +This is the first call that will be made to the plugin after it has been loaded, it is designed to give the loader enough information to know how to interact with the plugin and to allow it to confirm the plugin is of the correct type. + +Plugin Initialisation +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern PLUGIN_HANDLE plugin_init(); + +Called after the plugin has been loaded and the plugin information has been successfully retrieved. This will only be called once and should perform the initialisation necessary for the sensor communication. + +The plugin initialisation call returns a handle, of type void \*, which will be used in future calls to the plugin. This may be used to hold instance or state information that would be needed for any future calls. The handle should be used in preference to global variables within the plugin. + +If the initialisation fails the routine should raise an exception. After this exception is raised the plugin will not be used further. + +Plugin Common Insert +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern boolean plugin_common_insert(PLUGIN_HANDLE handle, char *table, JSON *data); + +Insert data that is represented by the JSON structure that is passed into the call to the specified table. + +The handle is the value returned by the call to plugin_init(). + +The table is the name of the table, or data set, into which the data is to be inserted. + +The data is a JSON document with a number of property name/value pairs. For example, if the plugin is storing the data in a SQL database; the names are the column names in an equivalent SQL database and the values are the values to write to that column. Plugins for non-SQL, such as document databases may choose to store the data as it is represented in the JSON document or in a very different structure. Note that the value may be of different types, represented by JSON type and may be JSON objects themselves. The plugin should do whatever conversation is needed for the particular storage layer based on the JSON type. + +The return value of this call is a boolean that represents success or value of the insert. + +Plugin Common Retrieve +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern JSON *plugin_common_retrieve(PLUGIN_HANDLE handle, char *table, JSON *query); + +Retrieve a data set from a named table. + +The handle is the value returned by the call to plugin_init(). + +The table is the name of the table, or data set, from which the data is to be retrieved. + +The query is a JSON document that encodes the predicates for the query, the where condition in the case of a SQL layer. See Encoding Query Predicates in JSON for details of how this JSON is encoded. + +The return value is the result set of the query encoded as a JSON structure. This encoding takes the form of an array of JSON object, one per row in the result set. Each object represents a row encoded as name/value pair properties. In addition a property count is included that returns the number of rows in the result set. + +An query that returns two rows with columns named “c1”, “c2” and “c3” would be represented as + +.. code-block:: JSON + + { + "count" : 2, + "rows" : [ + { + "c1" : 1, + "c2" : 5, + "c3" : 9 + }, + { + "c1" : 8, + "c2" : 2, + "c3" : 15 + } + ] + } + +The pointer return to the caller must be released when the caller has finished with the result set. This is done by calling the plugin_release() call with the plugin_handle and the pointer returned from this call. + +Plugin Common Update +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern boolean plugin_common_update(PLUGIN_HANDLE handle, char *table, JSON *data); + + +Update the contents of a set of rows in the given table. + +The handle is the value returned by the call to plugin_init(). + +The table is the name of the table, or data set, into which the data is to be updated. + +The data item is a JSON document that encodes but the values to set in the table and the condition used to select the data. The object contains two properties, a condition, the value of which is a JSON encoded where clause as defined in Encoding Query Predicates in JSON and a values object. The values object is a set of name/value pairs where the name matches column names within the data and the value defines the value to set for that column. + +The following JSON example + +.. code-block:: JSON + + { + "condition" : { + "column" : "c1", + "condition" : "=", + "value" : 15 + }, + "values" : { + "c2" : 20, + "c3" : "Updated" + } + } + + +would map to a SQL update statement + +.. code-block:: SQL + + UPDATE SET c2 = 20, c3 = "Updated" where c1 = 15; + +Plugin Common Delete +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern boolean plugin_common_delete(PLUGIN_HANDLE handle, char *table, JSON *condition); + + +Update the contents of a set of rows in the given table. + +The handle is the value returned by the call to plugin_init(). + +The table is the name of the table, or data set, into which the data is to be removed. +The condition JSON element defines the condition clause which will select the rows of data to be removed. This condition object follows the same JSON encoding scheme defined in the section Encoding Query Predicates in JSON. A condition object containing + +.. code-block:: JSON + + { + "column" : "c1", + "condition" : "=", + "value" : 15 + } + +would delete all rows where the value of c1 is 15. + +Plugin Reading Append +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern boolean plugin_reading_append(PLUGIN_HANDLE handle, JSON *reading); + +The handle is the value returned by the call to plugin_init(). + +The reading JSON object is an array of one or more readings objects that should be appended to the readings storage device. + +The return status indicates if the readings have been successfully appended to the storage device or not. + +Plugin Reading Fetch +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern JSON *plugin_reading_fetch(PLUGIN_HANDLE handle, unsigned long id, unsigned int blksize); + +Fetch a block of readings, starting from a given id and return them as a JSON object. + +This call will be used by the sending process to retrieve readings that have been buffered and send them to the historian. The process of sending readings will read a set of consecutive readings from the database and send them as a block rather than send all readings in a single transaction with the historian. This allows the sending process to rate limit the send and also to provide improved error recovery in the case of transmission failure. + +The handle is the value returned by the call to plugin_init(). + +The id passed in is the id of the first record to return in the block. + +The blksize is the maximum number of records to return in the block. If there are no sufficient readings to return a complete block of readings then a smaller number of readings will be returned. If no reading can be returned then a NULL pointer is returned. This call will not block waiting for new readings. + +Plugin Reading Retrieve +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern JSON *plugin_reading_retrieve(PLUGIN_HANDLE handle, JSON *condition); + +Return a set of readings as a JSON object based on a query to select those readings. + +The handle is the value returned by the call to plugin_init(). + +The condition is a JSON encoded query using the same mechanisms as defined in the section Encoding Query Predicates in JSON. In this case it is expected that the JSON condition would include not just selection criteria but also grouping and aggregation options. + +Plugin Reading Purge +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern unsigned int plugin_reading_purge(PLUGIN_HANDLE handle, unsigned long age, unsigned int flags, unsigned long sent); + +The removal of readings data based on the age of the data with an optional limit to prevent purging of data that has not been sent out of the Fledge device for external storage/processing. + +The handle is the value returned by the call to plugin_init(). + +The age defines the maximum age of data that is to be retained + +The flags define if the sent or unsent status of data should be considered or not. If the flags specify that unsent data should not be purged then the value of the sent parameter is used to determine what data has not been sent and readings with an id greater than the sent id will not be purged. + +Plugin Release +~~~~~~~~~~~~~~ + +.. code-block:: C + + extern boolean plugin_release(PLUGIN_HANDLE handle, JSON *json) + +This call is used by the storage service to release a result set or other JSON object that has been returned previously from the plugin to the storage service. JSON structures should only be released to the plugin when the storage service has finished with them as the plugin will most likely free the memory resources associated with the JSON structure. + +Plugin Error Retrieval +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern PLUGIN_ERROR *plugin_last_error(PLUGIN_HANDLE) + +Return more details on the last error that occurred within this instance of a plugin. The returned pointer points to a static area of memory that will be overwritten when the next error occurs within the plugin. There is no requirement for the caller to free any memory returned. + +Plugin Shutdown +~~~~~~~~~~~~~~~ + +.. code-block:: C + + extern boolean plugin_shutdown(PLUGIN_HANDLE handle) + +Shutdown the plugin, this is called with the plugin handle returned from plugin_init and is the last operation that will be performed on the plugin. It is designed to allow the plugin to complete any outstanding operations it may have, close connections to storage layers and generally release resources. + +Once this call has completed the plugin handle that was previously given out by the plugin should be considered to be invalid and any future calls using that handle should fail. + +Encoding Query Predicates in JSON +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +One particular issue with a storage layer API is how to encode the query predicates in a JSON structure that are as expression as the SQL predicates whilst not making the JSON document too complex whilst still maintaining the flexibility to be able to implement storage plugins that are not based on SQL databases. In traditional REST API’s the HTTP GET operation should be used to retrieve data, however the GET operation does not strictly support body content and therefore any modifiers or queries have to be encoded in the URL. Encoding complex query predicates in a URL quickly becomes an issue, therefore this API layer will not take this approach, it will allow simple predicates in the URL, but will use JSON documents and PUT operations to encode more complex predicates in the body of the PUT operation. + +The same JSON encoding will be used in the storage layer to the plugin interface for all retrieval operations. + +The predicates will be encoded in a JSON object that contains a where clause, other optional properties may be added to control aggregation, grouping and sorting of the selected data. + +The where object contains a column name, operation and value to match, it may also optionally contain an and property and an or property. The values of the and and or property, if they exist, are themselves where objects. + +As an example the following JSON object + +.. code-block:: JSON + + { + "where" : { + "column" : "c1", + "condition" : "=", + "value" : "mine", + "and" : { + "column" : "c2", + "condition" : "<", + "value" : 20 + } + } + } + +would result in a SQL where clause of the form + +.. code-block:: console + + WHERE c1 = “mine” AND c2 < 20 + +An example of a more complex example, using an and and an or condition, would be + +.. code-block:: JSON + + { + "where" : { + "column" : "id", + "condition" : "<", + "value" : "3", + "or" : { + "column" : "id", + "condition" : ">", + "value" : "7", + "and" : { + "column" : "description", + "condition" : "=", + "value" : "A test row" + } + } + } + } + +Which would yield a traditional SQL query of + +.. code-block:: console + + WHERE id < 3 OR id > 7 AND description = “A test row” + +.. note:: + + It is currently not possible to introduce bracketed conditions. + +Aggregation +########### + +In some cases adding aggregation of the results of a record selection is also required. Within the JSON this is represented using an optional aggregate object. + +.. code-block:: console + + "aggregate" : { + "operation" : "" + "column" : "" + } + +Valid operations for aggregations are; min, max, avg, sum and count. + +As an example the following JSON object + +.. code-block:: JSON + + { + "where" : { + "column" : "room", + "condition" : "=", + "value" : "kitchen" + }, + "aggregate" : { + "operation" : "avg", + "column" : "temperature" + } + } + +Multiple aggregates may be applied, in which case the aggregate property becomes an array of objects rather than a single object. + +.. code-block:: JSON + + { + "where" : { + "column" : "room", + "condition" : "=", + "value" : "kitchen" + }, + "aggregate" : [ + { + "operation" : "avg", + "column" : "temperature" + }, + { + "operation" : "min", + "column" : "temperature" + }, + { + "operation" : "max", + "column" : "temperature" + } + ] + } + +The result set JSON that is created for aggregates will have properties with names that are a concatenation of the column and operation. For example, the where clause defined above would result in a response similar to below. + +.. code-block:: JSON + + { + "count": 1, + "rows" : [ + { + "avg_temperature" : 21.8, + "min_temperature" : 18.4, + "max_temperature" : 22.6 + } + ] + } + +Alternatively an “alias” property may be added to aggregates to control the naming of the property in the JSON document that is produced. + +.. code-block:: JSON + + { + "where" : { + "column" : "room", + "condition" : "=", + "value" : "kitchen" + }, + "aggregate" : [ + { + "operation" : "avg", + "column" : "temperature", + "alias" : "Average" + }, + { + "operation" : "min", + "column" : "temperature", + "alias" : "Minimum" + }, + { + "operation" : "max", + "column" : "temperature", + "alias" : "Maximum" + } + ] + } + +Would result in the following output + +.. code-block:: JSON + + { + "count": 1, + "rows" : [ + { + "Average" : 21.8, + "Minimum" : 18.4, + "Maximum" : 22.6 + } + ] + } + +When the column that is being aggregated contains a JSON document rather than a simple value then the column property is replaced with a json property and the object defines the properties within the json document in the database field that will be used for aggregation. + +The following is an example of a payload that will query the readings data and return aggregations of the JSON property rate from within the column reading. The column reading is a JSON blob within the database. + +.. code-block:: JSON + + { + "where" : { + "column" : "asset_code", + "condition" : "=", + "value" : "MyAsset" + }, + "aggregate" : [ + { + "operation" : "min", + "json" : { + "column" : "reading", + "properties" : "rate" + }, + "alias" : "Minimum" + }, + { + "operation" : "max", + "json" : { + "column" : "reading", + "properties" : "rate" + }, + "alias" : "Maximum" + }, + { + "operation" : "avg", + "json" : { + "column" : "reading", + "properties" : "rate" + }, + "alias" : "Average" + } + ], + "group" : "asset_code" + } + +Grouping +######## + +Grouping of records can be achieved by adding a group property to the JSON document, the value of the group property is the column name to group on. + +.. code-block:: console + + "group" : "" + +Sorting +####### + +Where the output is required to be sorted a sort object may be added to the JSON document. This contains a column to sort on and a direction for the sort “asc” or “desc”. + +.. code-block:: console + + "sort" : { + "column" : "c1", + "direction" : "asc" + } + +It is also possible to apply multiple sort operations, in which case the sort property becomes an ordered array of objects rather than a single object + +.. code-block:: console + + "sort" : [ + { + "column" : "c1", + "direction" : "asc" + }, + { + "column" : "c3", + "direction" : "asc" + } + ] + +.. note:: + + The direction property is optional and if omitted will default to ascending order. + +Limit +##### + +A limit property can be included that will limit the number of rows returned to no more than the value of the limit property. + +.. code-block:: console + + "limit" : + + +Creating Time Series Data +######################### + +The timebucket mechanism in the storage layer allows data that includes a timestamp value to be extracted in timestamp order, grouped over a fixed period of time. + +The time bucket directive allows a timestamp column to be defined, the size of each time bucket, in seconds, an optional date format for the timestamp written in the results and an optional alias for the timestamp property that is written. + +.. code-block:: console + + "timebucket" : { + "timestamp" : "user_ts", + "size" : "5", + "format" : "DD-MM-YYYY HH24:MI:SS", + "alias" : "bucket" + } + +If no size element is present then the default time bucket size is 1 second. + +This produces a grouping of data results, therefore it is expected to be used in conjunction with aggregates to extract data results. The following example is the complete payload that would be used to extract assets from the readings interface + +.. code-block:: JSON + + { + "where" : { + "column" : "asset_code", + "condition" : "=", + "value" : "MyAsset" + }, + "aggregate" : [ + { + "operation" : "min", + "json" : { + "column" : "reading", + "properties" : "rate" + }, + "alias" : "Minimum" + }, + { + "operation" : "max", + "json" : { + "column" : "reading", + "properties" : "rate" + }, + "alias" : "Maximum" + }, + { + "operation" : "avg", + "json" : { + "column" : "reading", + "properties" : "rate" + }, + "alias" : "Average" + } + ], + "timebucket" : { + "timestamp" : "user_ts", + "size" : "30", + "format" : "DD-MM-YYYY HH24:MI:SS", + "alias" : "Time" + } + } + +In this case the payload would be sent in a PUT request to the URL /storage/reading/query and the returned values would contain the reading data for the asset called MyAsset which has a sensor value rate in the JSON payload it returns. The data would be aggregated in 30 second time buckets and the return values would be in the JSON format shown below. + +.. code-block:: JSON + + { + "count":2, + "Rows":[ + { + "Minimum" : 2, + "Maximum" : 96, + "Average" : 47.9523809523809, + "asset_code" : "MyAsset", + "Time" : "11-10-20177 15:10:50" + }, + { + "Minimum" : 1, + "Maximum" : 98, + "Average" : 53.7721518987342, + "asset_code" : "MyAsset", + "Time" : "11-10-20177 15:11:20" + } + ] + } + +Joining Tables +############## + +Joins can be created between tables using the join object. The JSON object contains a table name, a column to join on in the table of the query itself and an optional column in the joined table. It also allows a query to be added that may define a where condition to select columns in the joined table and a returns object to define which rows should be used from that table and how to name them. + +The following example joins the table called attributes to the table given in the URL of the request. It uses a column called parent_id in the attributes table to join to the column id in the table given in the request. If the column name in both tables is the same then there is no need to give the column field in the table object, the column name can be given in the on field instead. + +.. code-block:: JSON + + { + "join" : { + "table" : { + "name" : "attributes", + "column" : "parent_id" + }, + "on" : "id", + "query" : { + "where" : { + "column" : "name", + "condition" : "=", + "value" : "MyName" + + }, + "return" : [ + "parent_id", + { + "column" : "name", + "alias" : "attribute_name" + }, + { + "column" : "value", + "alias" : "attribute_value" + } + ] + } + } + } + +Assuming no additional where conditions or return constraints on the main table query, this would yields SQL of the form + +.. code-block:: SQL + + select t1.*, t2.parent_id, t2.name as "attribute_name", t2.value as "attribute_value" from parent t1, attributes t2 where t1.id = t2.parent_id and t2.name = "MyName"; + +Joins may be nested, allowing more than two tables to be joined. Assume again we have a parent table that contains items and an attributes table that contains attributes of those items. We wish to return the items that have an attribute called MyName and a colour. We need to join the attributes table twice to get the requests we require. The JSON payload would be as follows + +.. code-block:: JSON + + { + "join" : { + "table" : { + "name" : "attributes", + "column" : "parent_id" + }, + "on" : "id", + "query" : { + "where" : { + "column" : "name", + "condition" : "=", + "value" : "MyName" + + }, + "return" : [ + "parent_id", + { + "column" : "value", + "alias" : "my_name" + } + ] + "join" : { + "table" : { + "name" : "attributes", + "column" : "parent_id" + }, + "on" : "id", + "query" : { + "where" : { + "column" : "name", + "condition" : "=", + "value" : "colour" + + }, + "return" : [ + "parent_id", + { + "column" : "value", + "alias" : "colour" + } + ] + } + } + } + } + } + +And the resultant SQL query would be + +.. code-block:: SQL + + select t1.*, t2.parent_id, t2.value as "my_name", t3.value as "colour" from parent t1, attributes t2, attributes t3 where t1.id = t2.parent_id and t2.name = "MyName" and t1.id = t3.parent_id and t3.name = "colour"; + +JSON Predicate Schema +##################### + +The following is the JSON schema definition for the predicate encoding. + +.. code-block:: JSON + + { + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "properties": { + "group": { + "id": "/properties/group", + "type": "string" + }, + "sort": { + "id": "/properties/sort", + "properties": { + "column": { + "id": "/properties/sort/properties/column", + "type": "string" + }, + "direction": { + "id": "/properties/sort/properties/direction", + "type": "string" + } + }, + "type": "object" + }, + "aggregate": { + "id": "/properties/aggregate", + "properties": { + "column": { + "id": "/properties/aggregate/properties/column", + "type": "string" + }, + "operation": { + "id": "/properties/sort/properties/operation", + "type": "string" + } + }, + "type": "object" + }, + "properties": { + "limit": { + "id": "/properties/limit", + "type": "number" + } + "where": { + "id": "/properties/where", + "properties": { + "and": { + "id": "/properties/where/properties/and", + "properties": { + "column": { + "id": "/properties/where/properties/and/properties/column", + "type": "string" + }, + "condition": { + "id": "/properties/where/properties/and/properties/condition", + "type": "string" + }, + "value": { + "id": "/properties/where/properties/and/properties/value", + "type": "string" + } + }, + "type": "object" + }, + "column": { + "id": "/properties/where/properties/column", + "type": "string" + }, + "condition": { + "id": "/properties/where/properties/condition", + "type": "string" + }, + "or": { + "id": "/properties/where/properties/or", + "properties": { + "column": { + "id": "/properties/where/properties/or/properties/column", + "type": "string" + }, + "condition": { + "id": "/properties/where/properties/or/properties/condition", + "type": "string" + }, + "value": { + "id": "/properties/where/properties/or/properties/value", + "type": "string" + } + }, + "type": "object" + }, + "value": { + "id": "/properties/where/properties/value", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + +Controlling Returned Values +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The common retrieval API and the reading retrieval API can be controlled to return subsets of the data by defining the “columns” to be returned in an optional “return” object in the JSON payload of these entry points. + +Returning Limited Set of Columns +################################ + +An optional “returns” object may be followed by a JSON array that contains the names of the columns to return. + +.. code-block:: console + + "return" : [ "column1", "column2", "column3" ] + +The array may be simple strings that the columns to return or they may be JSON objects which give the column and and an alias for that column + +.. code-block:: console + + "return : [ "column1", { + "column" : "column2", + "alias" : "SecondColumn" + } + ] + + +Individual array items may also be mixed as in the example above. + +Formatting Columns +################## + +When a return object is specified it is also possible to format the returned data, this is particularly applicable to dates. Formatting is done by adding a format property to the column object to be returned. + +.. code-block:: console + + "return" : [ "key", "description", + { + "column" : "ts", + "format" : "DD Mon YYYY", + "alias" : "date" + } + ] + +The format string may be for dates or numeric values. The content of the string for dates is a template pattern consisting of a combination of the following. + +.. list-table:: + :widths: 20 80 + :header-rows: 1 + + * - Pattern + - Description + * - HH + - Hour of the day in 12 hour clock + * - HH24 + - Hour of the day in 24 hour clock + * - MI + - Minute value + * - SS + - Seconds value + * - MS + - Milliseconds value + * - US + - Microseconds value + * - SSSS + - Seconds since midnight + * - YYYY + - Year as 4 digits + * - YY + - Year as 2 digits + * - Month + - Full month name + * - Mon + - Month name abbreviated to 3 characters + * - MM + - Month number + * - Day + - Day of the week + * - Dy + - Abbreviated data of the week + * - DDD + - Day of the year + * - DD + - Day of the month + * - D + - Day of the week + * - W + - Week of the year + * - am + - am/pm meridian + + +Return JSON Document Content +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The returns mechanism may also be used to return the properties within a JSON document stored within the database. + +.. code-block:: JSON + + { + "return" : [ + "code", + { + "column" : "ts", + "alias" : "timestamp" + }, + { + "json" : { + "column" : "log", + "properties" : "reason" + }, + "alias" : "myJson" + } + ] + } + +In the example above a database column called json contains a JSON document with the property reason at the base level of the JSON document. The above statement extracts the JSON properties value and returns it in the result set using the property name myJSON. + +To access properties nested more deeply in the JSON document the properties property in the above example can also be an array of JSON property names for each level in the hierarchy. If the column contains a JSON document as below, + +.. code-block:: console + + { + "building" : { + "floor" : { + "room" : { + "number" : 432, + ... + }, + }, + } + } + +To access the room number a return fragment as shown below would be used. + +.. code-block:: JSON + + { + "return" : [ + { + "json" : { + "column" : "street", + "properties" : [ + "building", + "floor", + "room", + "number" + ] + }, + "alias" : "RoomNumber" + } + ] + } + diff --git a/docs/plugin_developers_guide/index.rst b/docs/plugin_developers_guide/index.rst index 9ece7c92df..c455755403 100644 --- a/docs/plugin_developers_guide/index.rst +++ b/docs/plugin_developers_guide/index.rst @@ -18,6 +18,7 @@ Plugin Developer Guide 05_storage_plugins 06_filter_plugins 08_notify_plugins.rst + 08_storage.rst 09_packaging.rst 10_testing 11_WSL2.rst From f6266b7f6f0b4d8a9b84ab9cce8aff58f3f2142c Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 8 Mar 2024 10:36:32 +0000 Subject: [PATCH 100/146] FOGL-8554 Fix issue if a configuration category has an item called (#1299) * FOGL-8554 Fix issue if a configuration category has an item called message. Signed-off-by: Mark Riddoch * FOGL-8554 Trap failure to create base64 encoded items Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/management_client.cpp | 20 ++++++++++++++++---- C/common/reading_set.cpp | 28 +++++++++++++++++++++------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/C/common/management_client.cpp b/C/common/management_client.cpp index 0f8d771a60..9057c90da2 100644 --- a/C/common/management_client.cpp +++ b/C/common/management_client.cpp @@ -602,8 +602,12 @@ ConfigCategory ManagementClient::getCategory(const string& categoryName) try { string url = "/fledge/service/category/" + urlEncode(categoryName); auto res = this->getHttpClient()->request("GET", url.c_str()); - Document doc; string response = res->content.string(); + if (res->status_code.compare("200 OK") == 0) + { + return ConfigCategory(categoryName, response); + } + Document doc; doc.Parse(response.c_str()); if (doc.HasParseError()) { @@ -613,7 +617,7 @@ ConfigCategory ManagementClient::getCategory(const string& categoryName) categoryName.c_str(), response.c_str()); throw new exception(); } - else if (doc.HasMember("message")) + else if (doc.HasMember("message") && doc["message"].IsString()) { m_logger->error("Failed to fetch configuration category: %s.", doc["message"].GetString()); @@ -621,7 +625,9 @@ ConfigCategory ManagementClient::getCategory(const string& categoryName) } else { - return ConfigCategory(categoryName, response); + m_logger->error("Failed to fetch configuration category: %s.", + response.c_str()); + throw new exception(); } } catch (const SimpleWeb::system_error &e) { m_logger->error("Get config category failed %s.", e.what()); @@ -649,6 +655,10 @@ string ManagementClient::setCategoryItemValue(const string& categoryName, auto res = this->getHttpClient()->request("PUT", url.c_str(), payload); Document doc; string response = res->content.string(); + if (res->status_code.compare("200 OK") == 0) + { + return response; + } doc.Parse(response.c_str()); if (doc.HasParseError()) { @@ -666,7 +676,9 @@ string ManagementClient::setCategoryItemValue(const string& categoryName, } else { - return response; + m_logger->error("Failed to set configuration category item value: %s.", + response.c_str()); + throw new exception(); } } catch (const SimpleWeb::system_error &e) { m_logger->error("Get config category failed %s.", e.what()); diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index df01437359..349dbf6dac 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -550,15 +550,25 @@ Datapoint *rval = NULL; size_t pos = str.find_first_of(':'); if (str.compare(2, 10, "DATABUFFER") == 0) { - DataBuffer *databuffer = new Base64DataBuffer(str.substr(pos + 1)); - DatapointValue value(databuffer); - rval = new Datapoint(name, value); + try { + DataBuffer *databuffer = new Base64DataBuffer(str.substr(pos + 1)); + DatapointValue value(databuffer); + rval = new Datapoint(name, value); + } catch (exception& e) { + Logger::getLogger()->error("Unable to create datapoint %s as the base 64 encoded data is incorrect, %s", + name.c_str(), e.what()); + } } else if (str.compare(2, 7, "DPIMAGE") == 0) { - DPImage *image = new Base64DPImage(str.substr(pos + 1)); - DatapointValue value(image); - rval = new Datapoint(name, value); + try { + DPImage *image = new Base64DPImage(str.substr(pos + 1)); + DatapointValue value(image); + rval = new Datapoint(name, value); + } catch (exception& e) { + Logger::getLogger()->error("Unable to create datapoint %s as the base 64 encoded data is incorrect, %s", + name.c_str(), e.what()); + } } } @@ -648,7 +658,11 @@ Datapoint *rval = NULL; vector *obj = new vector; for (auto &mo : item.GetObject()) { - obj->push_back(datapoint(mo.name.GetString(), mo.value)); + Datapoint *dp = datapoint(mo.name.GetString(), mo.value); + if (dp) + { + obj->push_back(dp); + } } DatapointValue value(obj, true); rval = new Datapoint(name, value); From 54491a59746a0500ac69bd1e9cf0133d3bfa5468 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 8 Mar 2024 17:30:05 +0530 Subject: [PATCH 101/146] README updated Signed-off-by: ashish-jabble --- tests/README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/README.rst b/tests/README.rst index 6811814d26..84892b8b22 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -108,6 +108,7 @@ Running the python tests: - ``pytest test_filename.py --cov=. --cov-report xml:xml_filepath --cov-report html:html_directorypath`` - This will execute all tests in the file named test_filename.py and generate the code coverage report in XML as well as the HTML format at the specified path in the command. - ``pytest test_filename.py::TestClass --cov=. --cov-report xml:xml_filepath --cov-report html:html_directorypath`` - This will execute all test methods in a single class TestClass in file test_filename.py and generate the code coverage report in XML as well as the HTML format at the specified path in the command. - ``pytest test_filename.py::TestClass::test_case --cov=. --cov-report xml:xml_filepath --cov-report html:html_directorypath`` - This will execute test method test_case in class TestClass in file test_filename.py and generate the code coverage report in XML as well as the HTML format at the specified path in the command. +- ``pytest -s -vv tests/unit/python/fledge/ --cov=. --cov-report=html --cov-config $FLEDGE_ROOT/tests/unit/python/.coveragerc`` - This will execute all the python tests and generate the code coverage report in the HTML format on the basis of settings in the configuration file. C Tests From c8eb88a044e9f75e6810c1e6d7e8ac1835af952e Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Mon, 11 Mar 2024 18:56:02 +0530 Subject: [PATCH 102/146] Code refactor Signed-off-by: Mohit04tomar --- tests/system/memory_leak/test_memcheck.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/system/memory_leak/test_memcheck.sh b/tests/system/memory_leak/test_memcheck.sh index 6df4894602..4d082d384f 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -66,8 +66,6 @@ configure_purge(){ echo echo -e "Updated Purge Configuration \n" echo -e "Updating Purge Schedule \n" - echo > enable_purge.json - cat enable_purge.json curl -X PUT "$FLEDGE_URL/schedule/cea17db8-6ccc-11e7-907b-a6006ad3dba0" -d \ '{ "name": "purge", From d280d67b4f9eac7690d0302ebaf1c07702b09cf6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 12 Mar 2024 09:08:32 +0530 Subject: [PATCH 103/146] minor fixes Signed-off-by: ashish-jabble --- tests/unit/python/.coveragerc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/python/.coveragerc b/tests/unit/python/.coveragerc index 50655c4268..8b4adad8ff 100644 --- a/tests/unit/python/.coveragerc +++ b/tests/unit/python/.coveragerc @@ -4,7 +4,6 @@ omit = */__init__.py */__template__.py */setup.py - */python/fledge/tasks/north/sending_process.py # Omit directory */python/fledge/plugins/common/* */python/fledge/plugins/filter/* @@ -13,4 +12,5 @@ omit = */python/fledge/plugins/notificationDelivery/* */python/fledge/plugins/notificationRule/* */python/fledge/services/south/* + */python/fledge/tasks/north/sending_process.py tests/* From 6c3d59b0bf03db6a736bb5a67b16272dc324a11a Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 12 Mar 2024 11:40:39 +0000 Subject: [PATCH 104/146] FOGL-8166 Add purge tuning to SQLite plugins (#1190) * FOGL-8166 Add purge tuning to SQLite plugins Signed-off-by: Mark Riddoch * purge tests fixes and updated as per FOGL-8166 Signed-off-by: ashish-jabble * Fix typo Signed-off-by: Mark Riddoch * Format change Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch Signed-off-by: ashish-jabble Co-authored-by: ashish-jabble Co-authored-by: Praveen Garg --- .../sqlite/common/connection_manager.cpp | 2 +- .../storage/sqlitelb/common/connection.cpp | 2 +- .../sqlitelb/common/connection_manager.cpp | 17 +++++- .../sqlitelb/common/include/connection.h | 5 ++ .../common/include/connection_manager.h | 8 +-- .../storage/sqlitelb/common/readings.cpp | 4 +- C/plugins/storage/sqlitelb/plugin.cpp | 16 +++++- .../storage/sqlitememory/include/connection.h | 5 ++ .../sqlitememory/include/connection_manager.h | 2 + C/plugins/storage/sqlitememory/plugin.cpp | 14 +++++ C/services/storage/pluginconfiguration.cpp | 1 - docs/tuning_fledge.rst | 9 ++++ python/fledge/tasks/purge/purge.py | 32 ++++++------ .../python/fledge/tasks/purge/test_purge.py | 52 +++++++++++++++++-- 14 files changed, 140 insertions(+), 29 deletions(-) diff --git a/C/plugins/storage/sqlite/common/connection_manager.cpp b/C/plugins/storage/sqlite/common/connection_manager.cpp index d841322ac5..d20141478d 100644 --- a/C/plugins/storage/sqlite/common/connection_manager.cpp +++ b/C/plugins/storage/sqlite/common/connection_manager.cpp @@ -489,7 +489,7 @@ int ConnectionManager::SQLExec(sqlite3 *dbHandle, const char *sqlCmd, char **err /** * Background thread used to execute periodic tasks and oversee the database activity. * - * We will runt he SQLite vacuum command periodically to allow space to be reclaimed + * We will run the SQLite vacuum command periodically to allow space to be reclaimed */ void ConnectionManager::background() { diff --git a/C/plugins/storage/sqlitelb/common/connection.cpp b/C/plugins/storage/sqlitelb/common/connection.cpp index d271811078..aaf947cf56 100644 --- a/C/plugins/storage/sqlitelb/common/connection.cpp +++ b/C/plugins/storage/sqlitelb/common/connection.cpp @@ -426,7 +426,7 @@ bool retCode; /** * Create a SQLite3 database connection */ -Connection::Connection() +Connection::Connection() : m_purgeBlockSize(10000) { string dbPath, dbPathReadings; const char *defaultConnection = getenv("DEFAULT_SQLITE_DB_FILE"); diff --git a/C/plugins/storage/sqlitelb/common/connection_manager.cpp b/C/plugins/storage/sqlitelb/common/connection_manager.cpp index 4da5090368..466b246cdf 100644 --- a/C/plugins/storage/sqlitelb/common/connection_manager.cpp +++ b/C/plugins/storage/sqlitelb/common/connection_manager.cpp @@ -25,7 +25,7 @@ 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_purgeBlockSize(10000) { lastError.message = NULL; lastError.entryPoint = NULL; @@ -61,6 +61,20 @@ ConnectionManager *ConnectionManager::getInstance() return instance; } +/** + * Set the purge block size in each of the connections + * + * @param purgeBlockSize The requested purgeBlockSize + */ +void ConnectionManager::setPurgeBlockSize(unsigned long purgeBlockSize) +{ + m_purgeBlockSize = purgeBlockSize; + idleLock.lock(); + for (auto& c : idle) + c->setPurgeBlockSize(purgeBlockSize); + idleLock.unlock(); +} + /** * Grow the connection pool by the number of connections * specified. @@ -72,6 +86,7 @@ void ConnectionManager::growPool(unsigned int delta) while (delta-- > 0) { Connection *conn = new Connection(); + conn->setPurgeBlockSize(m_purgeBlockSize); if (m_trace) conn->setTrace(true); idleLock.lock(); diff --git a/C/plugins/storage/sqlitelb/common/include/connection.h b/C/plugins/storage/sqlitelb/common/include/connection.h index ad1ac00aa4..b22d23697d 100644 --- a/C/plugins/storage/sqlitelb/common/include/connection.h +++ b/C/plugins/storage/sqlitelb/common/include/connection.h @@ -124,6 +124,10 @@ class Connection { bool loadDatabase(const std::string& filname); bool saveDatabase(const std::string& filname); #endif + void setPurgeBlockSize(unsigned long purgeBlockSize) + { + m_purgeBlockSize = purgeBlockSize; + }; private: #ifndef MEMORY_READING_PLUGIN @@ -132,6 +136,7 @@ class Connection { bool m_streamOpenTransaction; int m_queuing; std::mutex m_qMutex; + unsigned long m_purgeBlockSize; std::string operation(const char *sql); int SQLexec(sqlite3 *db, const std::string& table, const char *sql, int (*callback)(void*,int,char**,char**), diff --git a/C/plugins/storage/sqlitelb/common/include/connection_manager.h b/C/plugins/storage/sqlitelb/common/include/connection_manager.h index bba0ee42b9..214581285d 100644 --- a/C/plugins/storage/sqlitelb/common/include/connection_manager.h +++ b/C/plugins/storage/sqlitelb/common/include/connection_manager.h @@ -41,9 +41,10 @@ class ConnectionManager { { m_persist = persist; m_filename = filename; - } - bool persist() { return m_persist; }; - std::string filename() { return m_filename; }; + } + bool persist() { return m_persist; }; + std::string filename() { return m_filename; }; + void setPurgeBlockSize(unsigned long purgeBlockSize); protected: ConnectionManager(); @@ -62,6 +63,7 @@ class ConnectionManager { long m_vacuumInterval; bool m_persist; std::string m_filename; + unsigned long m_purgeBlockSize; }; #endif diff --git a/C/plugins/storage/sqlitelb/common/readings.cpp b/C/plugins/storage/sqlitelb/common/readings.cpp index fb6cbad35a..78290b9700 100644 --- a/C/plugins/storage/sqlitelb/common/readings.cpp +++ b/C/plugins/storage/sqlitelb/common/readings.cpp @@ -2163,7 +2163,7 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, return 0; } - deletePoint = minId + 10000; + deletePoint = minId + m_purgeBlockSize; if (maxId - deletePoint < rows || deletePoint > maxId) deletePoint = maxId - rows; @@ -2210,7 +2210,7 @@ unsigned int Connection::purgeReadingsByRows(unsigned long rows, unsentPurged += rowsAffected; } } - std::this_thread::yield(); // Give other threads a chane to run + std::this_thread::yield(); // Give other threads a chance to run } while (rowcount > rows); if (rowsAvailableToPurge) diff --git a/C/plugins/storage/sqlitelb/plugin.cpp b/C/plugins/storage/sqlitelb/plugin.cpp index 87dfe8f9b6..03b20f648d 100644 --- a/C/plugins/storage/sqlitelb/plugin.cpp +++ b/C/plugins/storage/sqlitelb/plugin.cpp @@ -49,7 +49,16 @@ const char *default_config = QUOTE({ "default" : "6", "displayName" : "Vacuum Interval", "order" : "2" - } + }, + "purgeBlockSize" : { + "description" : "The number of rows to purge in each delete statement", + "type" : "integer", + "default" : "10000", + "displayName" : "Purge Block Size", + "order" : "3", + "minimum" : "1000", + "maximum" : "100000" + } }); /** @@ -91,6 +100,11 @@ int poolSize = 5; { manager->setVacuumInterval(strtol(category->getValue("vacuumInterval").c_str(), NULL, 10)); } + if (category->itemExists("purgeBlockSize")) + { + unsigned long purgeBlockSize = strtoul(category->getValue("purgeBlockSize").c_str(), NULL, 10); + manager->setPurgeBlockSize(purgeBlockSize); + } return manager; } diff --git a/C/plugins/storage/sqlitememory/include/connection.h b/C/plugins/storage/sqlitememory/include/connection.h index 44fc47d3f4..ed90ff13c4 100644 --- a/C/plugins/storage/sqlitememory/include/connection.h +++ b/C/plugins/storage/sqlitememory/include/connection.h @@ -38,6 +38,10 @@ class Connection { bool vacuum(); bool loadDatabase(const std::string& filname); bool saveDatabase(const std::string& filname); + void setPurgeBlockSize(unsigned long purgeBlockSize) + { + m_purgeBlockSize = purgeBlockSize; + } private: int SQLexec(sqlite3 *db, const char *sql, int (*callback)(void*,int,char**,char**), @@ -60,5 +64,6 @@ class Connection { int i, std::string& newDate); void logSQL(const char *, const char *); + unsigned long m_purgeBlockSize; }; #endif diff --git a/C/plugins/storage/sqlitememory/include/connection_manager.h b/C/plugins/storage/sqlitememory/include/connection_manager.h index 24cbf04c04..5b73617b4e 100644 --- a/C/plugins/storage/sqlitememory/include/connection_manager.h +++ b/C/plugins/storage/sqlitememory/include/connection_manager.h @@ -39,6 +39,7 @@ class MemConnectionManager { } bool persist() { return m_persist; }; std::string filename() { return m_filename; }; + void setPurgeBlockSize(unsigned long purgeBlockSize); private: MemConnectionManager(); @@ -52,6 +53,7 @@ class MemConnectionManager { bool m_trace; bool m_persist; std::string m_filename; + unsigned long m_purgeBlockSize; }; #endif diff --git a/C/plugins/storage/sqlitememory/plugin.cpp b/C/plugins/storage/sqlitememory/plugin.cpp index f44b443148..68c805b09a 100644 --- a/C/plugins/storage/sqlitememory/plugin.cpp +++ b/C/plugins/storage/sqlitememory/plugin.cpp @@ -56,6 +56,15 @@ const char *default_config = QUOTE({ "default" : "false", "displayName" : "Persist Data", "order" : "2" + }, + "purgeBlockSize" : { + "description" : "The number of rows to purge in each delete statement", + "type" : "integer", + "default" : "10000", + "displayName" : "Purge Block Size", + "order" : "3", + "minimum" : "1000", + "maximum" : "100000" } }); @@ -118,6 +127,11 @@ int poolSize = 5; Connection *connection = manager->allocate(); connection->loadDatabase(manager->filename()); } + if (category->itemExists("purgeBlockSize")) + { + unsigned long purgeBlockSize = strtoul(category->getValue("purgeBlockSize").c_str(), NULL, 10); + manager->setPurgeBlockSize(purgeBlockSize); + } return manager; } /** diff --git a/C/services/storage/pluginconfiguration.cpp b/C/services/storage/pluginconfiguration.cpp index 03e5517ee3..30772a89ad 100644 --- a/C/services/storage/pluginconfiguration.cpp +++ b/C/services/storage/pluginconfiguration.cpp @@ -19,7 +19,6 @@ #include #include - using namespace std; using namespace rapidjson; diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index 174f122f06..ce59ed2bcf 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -500,6 +500,8 @@ The storage plugin configuration can be found in the *Advanced* section of the * - **Purge Exclusion**: This is not a performance settings, but allows a number of assets to be exempted from the purge process. This value is a comma separated list of asset names that will be excluded from the purge operation. +- **Vacuum Interval**: The interval between execution of vacuum operations on the database, expressed in hours. A vacuum operation is used to reclaim space occupied in the database by data that has been deleted. + sqlitelb Configuration ###################### @@ -521,6 +523,10 @@ 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. +- **Vacuum Interval**: The interval between execution of vacuum operations on the database, expressed in hours. A vacuum operation is used to reclaim space occupied in the database by data that has been deleted. + +- **Purge Block Size**: The maximum number of rows that will be deleted within a single transactions when performing a purge operation on the readings data. Large block sizes are potential the most efficient in terms of the time to complete the purge operation, however this will increase database contention as a database lock is required that will cause any ingest operations to be stalled until the purge completes. By setting a lower block size the purge will take longer, nut ingest operations can be interleaved with the purging of blocks. + postgres Configuration ###################### @@ -566,3 +572,6 @@ The storage plugin configuration can be found in the *Advanced* section of the * - **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. + + - **Purge Block Size**: The maximum number of rows that will be deleted within a single transactions when performing a purge operation on the readings data. Large block sizes are potential the most efficient in terms of the time to complete the purge operation, however this will increase database contention as a database lock is required that will cause any ingest operations to be stalled until the purge completes. By setting a lower block size the purge will take longer, nut ingest operations can be interleaved with the purging of blocks. + diff --git a/python/fledge/tasks/purge/purge.py b/python/fledge/tasks/purge/purge.py index 3183407786..6316301ecc 100644 --- a/python/fledge/tasks/purge/purge.py +++ b/python/fledge/tasks/purge/purge.py @@ -199,40 +199,42 @@ async def purge_data(self, config): self._logger.debug("purge_data - flag :{}: last_id :{}: count :{}: operation_type :{}:".format( flag, last_id, result["count"], operation_type)) + # Do the purge by rows first as it is cheaper than doing the purge by age and + # may result in less rows for purge by age to operate on. try: - if int(config['age']['value']) != 0: - result = await self._readings_storage_async.purge(age=config['age']['value'], sent_id=last_id, + if int(config['size']['value']) != 0: + result = await self._readings_storage_async.purge(size=config['size']['value'], sent_id=last_id, flag=flag) if result is not None: total_rows_removed = result['removed'] unsent_rows_removed = result['unsentPurged'] unsent_retained = result['unsentRetained'] - duration += result['duration'] - method = result['method'] + 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 age {} should be integer!".format( - config['age']['value'])) + self._logger.error("purge_data - Configuration item size {} should be integer!".format( + config['size']['value'])) except StorageServerError: # skip logging as its already done in details for this operation in case of error # FIXME: check if ex.error jdoc has retryable True then retry the operation else move on pass try: - if int(config['size']['value']) != 0: - result = await self._readings_storage_async.purge(size=config['size']['value'], sent_id=last_id, + if int(config['age']['value']) != 0: + result = await self._readings_storage_async.purge(age=config['age']['value'], sent_id=last_id, flag=flag) if result is not None: total_rows_removed += result['removed'] unsent_rows_removed += result['unsentPurged'] unsent_retained = result['unsentRetained'] duration += result['duration'] - if method is None: - method = result['method'] - else: - method += " and " - method += result['method'] + method = result['method'] except ValueError: - self._logger.error("purge_data - Configuration item size {} should be integer!".format( - config['size']['value'])) + self._logger.error("purge_data - Configuration item age {} should be integer!".format( + config['age']['value'])) except StorageServerError: # skip logging as its already done in details for this operation in case of error # FIXME: check if ex.error jdoc has retryable True then retry the operation else move on diff --git a/tests/unit/python/fledge/tasks/purge/test_purge.py b/tests/unit/python/fledge/tasks/purge/test_purge.py index d211667e0d..679b04b41f 100644 --- a/tests/unit/python/fledge/tasks/purge/test_purge.py +++ b/tests/unit/python/fledge/tasks/purge/test_purge.py @@ -118,10 +118,8 @@ async def store_purge(self, **kwargs): } @pytest.mark.parametrize("conf, expected_return, expected_calls", [ - (config["purgeAgeSize"], (2, 4), {'sent_id': 0, 'size': '20', 'flag': 'purge'}), (config["purgeAge"], (1, 2), {'sent_id': 0, 'age': '72', 'flag': 'purge'}), (config["purgeSize"], (1, 2), {'sent_id': 0, 'size': '100', 'flag': 'purge'}), - (config["retainAgeSize"], (2, 4), {'sent_id': 0, 'size': '20', 'flag': 'retainall'}), (config["retainAge"], (1, 2), {'sent_id': 0, 'age': '72', 'flag': 'retainall'}), (config["retainSize"], (1, 2), {'sent_id': 0, 'size': '100', 'flag': 'retainall'}), (config["retainSizeAny"], (1, 2), {'sent_id': 0, 'size': '100', 'flag': 'retainany'}) @@ -158,13 +156,59 @@ async def test_purge_data(self, conf, expected_return, expected_calls): side_effect=self.store_purge) as mock_storage_purge: with patch.object(audit, 'information', return_value=_rv2) as audit_info: # Test the positive case when all if conditions in purge_data pass - t_expected_return = await p.purge_data(conf) assert expected_return == await p.purge_data(conf) assert audit_info.called _, kwargs = mock_storage_purge.call_args assert kwargs == expected_calls assert patch_storage.called - assert 4 == patch_storage.call_count + assert 2 == patch_storage.call_count + args, _ = patch_storage.call_args + assert 'streams' == args[0] + assert payload == json.loads(args[1]) + + @pytest.mark.parametrize("conf, expected_return, expected_calls", [ + (config["purgeAgeSize"], (2, 4), [{'sent_id': 0, 'size': '20', 'flag': 'purge'}, + {'sent_id': 0, 'age': '72', 'flag': 'purge'}]), + (config["retainAgeSize"], (2, 4), [{'sent_id': 0, 'size': '20', 'flag': 'retainall'}, + {'sent_id': 0, 'age': '72', 'flag': 'retainall'}]) + ]) + async def test_data_with_age_and_size(self, conf, expected_return, expected_calls): + mock_storage_client_async = MagicMock(spec=StorageClientAsync) + mock_audit_logger = AuditLogger(mock_storage_client_async) + mock_stream_result = q_result('streams') + payload = {"aggregate": {"operation": "min", "column": "last_object"}} + # 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_stream_result + _rv2 = await mock_value("") + else: + _rv1 = asyncio.ensure_future(mock_stream_result) + _rv2 = asyncio.ensure_future(mock_value("")) + + with patch.object(FledgeProcess, '__init__'): + with patch.object(mock_audit_logger, "__init__", return_value=None): + p = Purge() + p._logger = FLCoreLogger + p._logger.info = MagicMock() + p._logger.error = MagicMock() + p._logger.debug = MagicMock() + p._storage_async = MagicMock(spec=StorageClientAsync) + p._readings_storage_async = MagicMock(spec=ReadingsStorageClientAsync) + audit = p._audit + with patch.object(p._storage_async, "query_tbl_with_payload", return_value=_rv1 + ) as patch_storage: + with patch.object(p._readings_storage_async, 'purge', + side_effect=self.store_purge) as mock_storage_purge: + with patch.object(audit, 'information', return_value=_rv2) as audit_info: + assert expected_return == await p.purge_data(conf) + assert audit_info.called + assert 2 == mock_storage_purge.call_count + args, kwargs = mock_storage_purge.call_args_list[0] + assert expected_calls[0] == kwargs + args, kwargs = mock_storage_purge.call_args_list[1] + assert expected_calls[1] == kwargs + assert patch_storage.called + assert 2 == patch_storage.call_count args, _ = patch_storage.call_args assert 'streams' == args[0] assert payload == json.loads(args[1]) From 7e8983a6cfa4c7a19ac9cd77a8e96be7cabd47af Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 14 Mar 2024 01:26:49 +0530 Subject: [PATCH 105/146] alert count fixed to 0 in case of safe mode in ping request Signed-off-by: ashish-jabble --- python/fledge/services/core/api/common.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/common.py b/python/fledge/services/core/api/common.py index 8b6d185ce8..6087d51592 100644 --- a/python/fledge/services/core/api/common.py +++ b/python/fledge/services/core/api/common.py @@ -93,7 +93,11 @@ def services_health_litmus_test(): return 'green' status_color = services_health_litmus_test() - safe_mode = True if server.Server.running_in_safe_mode else False + safe_mode = True + alert_count = 0 + if not server.Server.running_in_safe_mode: + safe_mode = False + alert_count = len(server.Server._alert_manager.alerts) version = get_version() return web.json_response({'uptime': int(since_started), 'dataRead': data_read, @@ -106,7 +110,7 @@ def services_health_litmus_test(): 'health': status_color, 'safeMode': safe_mode, 'version': version, - 'alerts': len(server.Server._alert_manager.alerts) + 'alerts': alert_count }) From 897c1204c3792fd71c7a13d3dd00545f0ffee1b4 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 14 Mar 2024 09:20:05 +0000 Subject: [PATCH 106/146] FOGL-8571 Add support for list and kvlist item types (#1312) Signed-off-by: Mark Riddoch --- C/common/config_category.cpp | 204 ++++++++++++++++++- C/common/include/config_category.h | 28 ++- tests/unit/C/common/test_config_category.cpp | 54 +++++ 3 files changed, 283 insertions(+), 3 deletions(-) diff --git a/C/common/config_category.cpp b/C/common/config_category.cpp index 1dbeb64b1b..cde31d36c7 100755 --- a/C/common/config_category.cpp +++ b/C/common/config_category.cpp @@ -144,7 +144,7 @@ ConfigCategory::ConfigCategory(const string& name, const string& json) : m_name( 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(), - StringAround(json, (unsigned)doc.GetErrorOffset())); + StringAround(json, (unsigned)doc.GetErrorOffset()).c_str()); throw new ConfigMalformed(); } @@ -439,6 +439,114 @@ string ConfigCategory::getValue(const string& name) const throw new ConfigItemNotFound(); } +/** + * Return the value of the configuration category item list, this + * is a convience function used when simple lists are defined + * and allows for central processing of the list values + * + * @param name The name of the configuration item to return + * @return string The configuration item name + * @throws exception if the item does not exist in the category + */ +vector ConfigCategory::getValueList(const string& name) const +{ + for (unsigned int i = 0; i < m_items.size(); i++) + { + if (name.compare(m_items[i]->m_name) == 0) + { + if (m_items[i]->m_type.compare("list")) + { + throw new ConfigItemNotAList(); + } + Document d; + vector list; + d.Parse(m_items[i]->m_value.c_str()); + if (d.HasParseError()) + { + Logger::getLogger()->error("The JSON value for a list item %s has a parse error: %s, %s", + name.c_str(), GetParseError_En(d.GetParseError()), m_items[i]->m_value.c_str()); + return list; + } + if (d.IsArray()) + { + for (auto& v : d.GetArray()) + { + if (v.IsString()) + { + list.push_back(v.GetString()); + } + } + } + else + { + Logger::getLogger()->error("The value of the list item %s should be a JSON array and it is not", name.c_str()); + } + return list; + } + } + throw new ConfigItemNotFound(); +} + +/** + * Return the value of the configuration category item kvlist, this + * is a convience function used when key/value lists are defined + * and allows for central processing of the list values + * + * @param name The name of the configuration item to return + * @return string The configuration item name + * @throws exception if the item does not exist in the category + */ +map ConfigCategory::getValueKVList(const string& name) const +{ + for (unsigned int i = 0; i < m_items.size(); i++) + { + if (name.compare(m_items[i]->m_name) == 0) + { + if (m_items[i]->m_type.compare("kvlist")) + { + throw new ConfigItemNotAList(); + } + map list; + Document d; + d.Parse(m_items[i]->m_value.c_str()); + if (d.HasParseError()) + { + Logger::getLogger()->error("The JSON value for a kvlist item %s has a parse error: %s, %s", + name.c_str(), GetParseError_En(d.GetParseError()), m_items[i]->m_value.c_str()); + return list; + } + for (auto& v : d.GetObject()) + { + string key = v.name.GetString(); + string value = to_string(v.value); + list.insert(pair(key, value)); + } + return list; + } + } + throw new ConfigItemNotFound(); +} + +/** + * Convert a RapidJSON value to a string + * + * @param v The RapidJSON value + */ +std::string ConfigCategory::to_string(const rapidjson::Value& v) const +{ + if (v.IsString()) + { + return { v.GetString(), v.GetStringLength() }; + } + else + { + StringBuffer strbuf; + Writer writer(strbuf); + v.Accept(writer); + return { strbuf.GetString(), strbuf.GetLength() }; + } +} + /** * Return the requested attribute of a configuration category item * @@ -478,6 +586,10 @@ string ConfigCategory::getItemAttribute(const string& itemName, return m_items[i]->m_rule; case BUCKET_PROPERTIES_ATTR: return m_items[i]->m_bucketProperties; + case LIST_SIZE_ATTR: + return m_items[i]->m_listSize; + case ITEM_TYPE_ATTR: + return m_items[i]->m_listItemType; default: throw new ConfigItemAttributeNotFound(); } @@ -546,6 +658,12 @@ bool ConfigCategory::setItemAttribute(const string& itemName, case BUCKET_PROPERTIES_ATTR: m_items[i]->m_bucketProperties = value; return true; + case LIST_SIZE_ATTR: + m_items[i]->m_listSize = value; + return true; + case ITEM_TYPE_ATTR: + m_items[i]->m_listItemType = value; + return true; default: return false; } @@ -882,6 +1000,44 @@ bool ConfigCategory::isDeprecated(const string& name) const throw new ConfigItemNotFound(); } +/** + * Return if the configuration item is a list item + * + * @param name The name of the item to test + * @return bool True if the item is a Numeric type + * @throws exception If the item was not found in the configuration category + */ +bool ConfigCategory::isList(const string& name) const +{ + for (unsigned int i = 0; i < m_items.size(); i++) + { + if (name.compare(m_items[i]->m_name) == 0) + { + return (m_items[i]->m_type.compare("list") == 0); + } + } + throw new ConfigItemNotFound(); +} + +/** + * Return if the configuration item is a kvlist item + * + * @param name The name of the item to test + * @return bool True if the item is a Numeric type + * @throws exception If the item was not found in the configuration category + */ +bool ConfigCategory::isKVList(const string& name) const +{ + for (unsigned int i = 0; i < m_items.size(); i++) + { + if (name.compare(m_items[i]->m_name) == 0) + { + return (m_items[i]->m_type.compare("kvlist") == 0); + } + } + throw new ConfigItemNotFound(); +} + /** * Set the description for the configuration category * @@ -1047,6 +1203,14 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, { m_itemType = BucketItem; } + if (m_type.compare("list") == 0) + { + m_itemType = ListItem; + } + if (m_type.compare("kvlist") == 0) + { + m_itemType = KVListItem; + } if (item.HasMember("deprecated")) { @@ -1131,6 +1295,33 @@ ConfigCategory::CategoryItem::CategoryItem(const string& name, } } + if (item.HasMember("items")) + { + if (item["items"].IsString()) + { + m_listItemType = item["items"].GetString(); + } + else + { + throw new runtime_error("Items configuration item property is not a string"); + } + } + else if (m_itemType == ListItem || m_itemType == KVListItem) + { + throw new runtime_error("List configuration item is missing the \"items\" attribute"); + } + if (item.HasMember("listSize")) + { + if (item["listSize"].IsString()) + { + m_listSize = item["listSize"].GetString(); + } + else + { + throw new runtime_error("ListSize configuration item property is not a string"); + } + } + std::string m_typeUpperCase = m_type; for (auto & c: m_typeUpperCase) c = toupper(c); @@ -1414,6 +1605,8 @@ ConfigCategory::CategoryItem::CategoryItem(const CategoryItem& rhs) m_group = rhs.m_group; m_rule = rhs.m_rule; m_bucketProperties = rhs.m_bucketProperties; + m_listSize = rhs.m_listSize; + m_listItemType = rhs.m_listItemType; } /** @@ -1518,6 +1711,15 @@ ostringstream convert; { convert << ", \"file\" : \"" << m_file << "\""; } + + if (!m_listSize.empty()) + { + convert << ", \"listSize\" : \"" << m_listSize << "\""; + } + if (!m_listItemType.empty()) + { + convert << ", \"items\" : \"" << m_listItemType << "\""; + } } convert << " }"; diff --git a/C/common/include/config_category.h b/C/common/include/config_category.h index b95220e653..60ff0c9a0e 100755 --- a/C/common/include/config_category.h +++ b/C/common/include/config_category.h @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -65,7 +66,9 @@ class ConfigCategory { ScriptItem, CategoryType, CodeItem, - BucketItem + BucketItem, + ListItem, + KVListItem }; ConfigCategory(const std::string& name, const std::string& json); @@ -93,6 +96,8 @@ class ConfigCategory { bool itemExists(const std::string& name) const; bool setItemDisplayName(const std::string& name, const std::string& displayName); std::string getValue(const std::string& name) const; + std::vector getValueList(const std::string& name) const; + std::map getValueKVList(const std::string& name) const; std::string getType(const std::string& name) const; std::string getDescription(const std::string& name) const; std::string getDefault(const std::string& name) const; @@ -110,6 +115,8 @@ class ConfigCategory { bool isBool(const std::string& name) const; bool isNumber(const std::string& name) const; bool isDouble(const std::string& name) const; + bool isList(const std::string& name) const; + bool isKVList(const std::string& name) const; bool isDeprecated(const std::string& name) const; std::string toJSON(const bool full=false) const; std::string itemsToJSON(const bool full=false) const; @@ -118,6 +125,7 @@ class ConfigCategory { void setItemsValueFromDefault(); void checkDefaultValuesOnly() const; std::string itemToJSON(const std::string& itemName) const; + std::string to_string(const rapidjson::Value& v) const; enum ItemAttribute { ORDER_ATTR, READONLY_ATTR, @@ -131,7 +139,9 @@ class ConfigCategory { DISPLAY_NAME_ATTR, DEPRECATED_ATTR, RULE_ATTR, - BUCKET_PROPERTIES_ATTR + BUCKET_PROPERTIES_ATTR, + LIST_SIZE_ATTR, + ITEM_TYPE_ATTR }; std::string getItemAttribute(const std::string& itemName, ItemAttribute itemAttribute) const; @@ -180,6 +190,8 @@ class ConfigCategory { std::string m_group; std::string m_rule; std::string m_bucketProperties; + std::string m_listSize; + std::string m_listItemType; }; std::vector m_items; std::string m_name; @@ -276,4 +288,16 @@ class ConfigItemAttributeNotFound : public std::exception { return "Configuration item attribute not found in configuration category"; } }; + +/** + * An attempt has been made to access a configuration item as a list when the + * item is not of type list + */ +class ConfigItemNotAList : public std::exception { + public: + virtual const char *what() const throw() + { + return "Configuration item is not a list type item"; + } +}; #endif diff --git a/tests/unit/C/common/test_config_category.cpp b/tests/unit/C/common/test_config_category.cpp index 0bbce88f8a..5a36334001 100644 --- a/tests/unit/C/common/test_config_category.cpp +++ b/tests/unit/C/common/test_config_category.cpp @@ -341,6 +341,27 @@ const char *json_parse_error = "{\"description\": {" "\"default\": {\"first\" : \"Fledge\", \"second\" : \"json\" }," "\"description\": \"A JSON configuration parameter\"}}"; +const char *listConfig = "{ \"name\": {" + "\"type\": \"list\"," + "\"items\" : \"string\"," + "\"default\": \"[ \\\"Fledge\\\" ]\"," + "\"value\" : \"[ \\\"one\\\", \\\"two\\\" ]\"," + "\"description\": \"A simple list\"} }"; + +const char *kvlistConfig = "{ \"name\": {" + "\"type\": \"kvlist\"," + "\"items\" : \"string\"," + "\"default\": \"{ }\"," + "\"value\" : \"{ \\\"a\\\" : \\\"first\\\", \\\"b\\\" : \\\"second\\\" }\"," + "\"description\": \"A simple list\"} }"; + +const char *kvlistObjectConfig = "{ \"name\": {" + "\"type\": \"kvlist\"," + "\"items\" : \"object\"," + "\"default\": \"{ }\"," + "\"value\" : \"{ \\\"a\\\" : { \\\"one\\\" : \\\"first\\\"}, \\\"b\\\" : { \\\"two\\\" :\\\"second\\\" } }\"," + "\"description\": \"A simple list\"} }"; + TEST(CategoriesTest, Count) { ConfigCategories confCategories(categories); @@ -676,3 +697,36 @@ TEST(Categorytest, parseError) { EXPECT_THROW(ConfigCategory("parseTest", json_parse_error), ConfigMalformed*); } + +TEST(CategoryTest, listItem) +{ + ConfigCategory category("list", listConfig); + ASSERT_EQ(true, category.isList("name")); + ASSERT_EQ(0, category.getItemAttribute("name", ConfigCategory::ITEM_TYPE_ATTR).compare("string")); + std::vector v = category.getValueList("name"); + ASSERT_EQ(2, v.size()); + ASSERT_EQ(0, v[0].compare("one")); + ASSERT_EQ(0, v[1].compare("two")); +} + +TEST(CategoryTest, kvlistItem) +{ + ConfigCategory category("list", kvlistConfig); + ASSERT_EQ(true, category.isKVList("name")); + ASSERT_EQ(0, category.getItemAttribute("name", ConfigCategory::ITEM_TYPE_ATTR).compare("string")); + std::map v = category.getValueKVList("name"); + ASSERT_EQ(2, v.size()); + ASSERT_EQ(0, v["a"].compare("first")); + ASSERT_EQ(0, v["b"].compare("second")); +} + +TEST(CategoryTest, kvlistObjectItem) +{ + ConfigCategory category("list", kvlistObjectConfig); + ASSERT_EQ(true, category.isKVList("name")); + ASSERT_EQ(0, category.getItemAttribute("name", ConfigCategory::ITEM_TYPE_ATTR).compare("object")); + std::map v = category.getValueKVList("name"); + ASSERT_EQ(2, v.size()); + ASSERT_EQ(0, v["a"].compare("{\"one\":\"first\"}")); + ASSERT_EQ(0, v["b"].compare("{\"two\":\"second\"}")); +} From 4dc6eeb5bb4388e98b3ceed890fec5b9b3a93ad1 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 14 Mar 2024 14:23:17 +0000 Subject: [PATCH 107/146] FOGL-8521 Add configuration item type information (#1307) * FOGL-8521 Add configuration item type information Signed-off-by: Mark Riddoch * Addressed review comments Signed-off-by: Mark Riddoch * Add object type and reorder sections Signed-off-by: Mark Riddoch * Address further review comments and add note on groups Signed-off-by: Mark Riddoch * JSON highlighting issue and other fixes Signed-off-by: ashish-jabble --------- Signed-off-by: Mark Riddoch Signed-off-by: ashish-jabble Co-authored-by: ashish-jabble --- .../02_writing_plugins.rst | 144 +++++++++++++++++- 1 file changed, 141 insertions(+), 3 deletions(-) diff --git a/docs/plugin_developers_guide/02_writing_plugins.rst b/docs/plugin_developers_guide/02_writing_plugins.rst index f406746d0c..c70ccf5320 100644 --- a/docs/plugin_developers_guide/02_writing_plugins.rst +++ b/docs/plugin_developers_guide/02_writing_plugins.rst @@ -343,7 +343,132 @@ The configuration items within a category are JSON object, the object key is the "default" : "5" } -We have used the properties *type* and *default* to define properties of the configuration item *MaxRetries*. These are not the only properties that a configuration item can have, the full set of properties are +We have used the properties *type* and *default* to define properties of the configuration item *MaxRetries*. These are not the only properties that a configuration item can have, the full set of item types and properties are shown below + +Types +~~~~~ + +The configuration items within a configuration category can each be defined as one of a set of types. The types currently supported by Fledge are + +.. list-table:: + :header-rows: 1 + + * - Type + - Description + * - integer + - An integer numeric value. The value may be positive or negative but may not contain any fractional part. The *minimum* and *maximum* properties may be used to control the limits of the values assigned to an integer. + * - float + - A floating point numeric item. The *minimum* and *maximum* properties may be used to control the limits of the values assigned to a floating point item. + * - string + - An alpha-numeric array of characters that may contain any printable characters. The *length* property can be used to constrain the maximum length of the string. + * - boolean + - A boolean value that can be assigned the values *true* or *false*. + * - IPv4 + - An IP version 4 address. + * - IPv6 + - An IP version 6 address. + * - X509 certificate + - An X509 certificate + * - password + - A string that is used as a password. There is no difference between this or a string type other than user interfaces do not show this in plain text. + * - JSON + - A JSON document. The value is checked to ensure it is a valid JSON document. + * - URL + - A universal resource locator string. The API will check for correct URL formatting of the value. + * - enumeration + - The item can be assigned one of a fixed set of values. These values are defined in the *options* property of the item. + * - script + - A block of text that is executed as a script. The script type should be used for larger blocks of code to be executed. + * - code + - A block of text that is executed as Python code. This is used for small snippets of Python rather than when larger scripts. + * - northTask + - The name of a north task. The API will check that the value matches the name of an existing north task. + * - ACL + - An access control list. The value is the string name of an access control list that has been created within Fledge. + * - list + - A list of items, the items can be of type *string*, *integer*, *float*, *enumeration* or *object*. The type of the items within the list must all be the same, and this is defined via the *items* property of the list. A limit on the maximum number of entries allowed in the list can be enforced by use of the *listSize* property. + * - kvlist + - A key value pair list. The key is a string value always but the value of the item in the list may be of type *string*, *enumeration*, *float*, *integer* or *object*. The type of the values in the kvlist is defined by the *items* property of the configuration item. A limit on the maximum number of entries allowed in the list can be enforced by use of the *listSize* property. + * - object + - A complex configuration type with multiple elements that may be used within *list* and *kvlist* items only, it is not possible to have *object* type items outside of a list. Object type configuration items have a set of *properties* defined, each of which is itself a configuration item. + +Key/Value List +############## + +A key/value list is a way of storing tagged item pairs within a list. For example, to create a list of labels and expressions we can use a kvlist that stores the expressions as string values in the kvlist. + +.. code-block:: JSON + + "expressions" : { + "description" : "A set of expressions used to evaluate and label data", + "type" : "kvlist", + "items" : "string", + "default" : "{\"idle\" : \"speed == 0\"}", + "order" : "4", + "displayName" : "Labels" + } + +The key values must be unique within a kvlist, as the data is stored as a JSON object with the key becoming the property name and the value of the property the corresponding value for the key. + +Lists of Objects +################ + +Object type items may be used in lists and are a mechanism to allow for list of groups of configuration items. The object list type items must specify a property called *properties*. The value of this is a JSON object that contains a list of configuration items that are grouped into the object. + +An example use of an object list might allow for a map structure to be built for accessing a device like a PLC. The following shows the definitions of a key/value pair list where the value is an object. + +.. code-block:: JSON + + "map": { + "description": "A list of datapoints to read and PLC register definitions", + "type": "kvlist", + "items" : "object", + "default": "{\"speed\" : {\"register\" : \"10\", \"width\" : \"1\", \"type\" : \"integer\"}}", + "order" : "3", + "displayName" : "PLC Map", + "properties" : { + "register" : { + "description" : "The register number to read", + "displayName" : "Register", + "type" : "integer", + "default" : "0" + }, + "width" : { + "description" : "Number of registers to read", + "displayName" : "Width", + "type" : "integer", + "maximum" : "4", + "default" : "1" + }, + "type" : { + "description" : "The data type to read", + "displayName" : "Data Type", + "type" : "enumeration", + "options" : [ "integer","float", "boolean" ], + "default" : "integer" + } + } + } + +The *value* and *default* properties for a list of objects is returned as a JSON structure. An example of the above list with two elements in the list, voltage and current would be returned as follows: + +.. code-block:: JSON + + { + "voltage" : { + "register" : "10", + "width" : "2", + "type" : "integer" + }, + "current" : { + "register" : "14", + "width" : "4", + "type" : "float" + } + } + +Properties +~~~~~~~~~~ .. list-table:: :header-rows: 1 @@ -358,8 +483,12 @@ We have used the properties *type* and *default* to define properties of the con - A description of the configuration item used in the user interface to give more details of the item. Commonly used as a mouse over help prompt. * - displayName - The string to use in the user interface when presenting the configuration item. Generally a more user friendly form of the item name. Item names are referenced within the code. + * - items + - The type of the items in a list or kvlist configuration item. * - length - The maximum length of the string value of the item. + * - listSize + - The maximum number of entries allowed in a list or kvlist item. * - mandatory - A boolean flag to indicate that this item can not be left blank. * - maximum @@ -382,8 +511,17 @@ We have used the properties *type* and *default* to define properties of the con - An expression used to determine if the configuration item is valid. Used in the UI to gray out one value based on the value of others. * - value - The current value of the configuration item. This is not included when defining a set of default configuration in, for example, a plugin. + * - properties + - A set of items that are used in list and kvlist type items to create a list of groups of configuration items. + +Of the above properties of a configuration item *type*, *default* and *description* are mandatory, all others are optional. + +.. note:: + + It is strongly advised to include a *displayName* and an *order* in every item to improve the GUI rendering of configuration screens. If a configuration category is very large it is also recommended to use the *group* property to group together related items. These grouped items are displayed within separate tabs in the current Fledge GUI. -Of the above properties of a configuration item *type*, *default* and *description* are mandatory, all other may be omitted. +Management +~~~~~~~~~~ Configuration data is stored by the storage service and is maintained by the configuration in the core Fledge service. When code requires configuration it would create a configuration category with a set of items as a JSON document. It would then register that configuration category with the configuration manager. The configuration manager is responsible for storing the data in the storage layer, as it does this it first checks to see if there is already a configuration category from a previous execution of the code. If one does exist then the two are merged, this merging process allows updates to the software to extend the configuration category whilst maintaining any changes in values made by the user. @@ -437,7 +575,7 @@ The configuration in *default_config* is assumed to have an enumeration item cal Note the use of the *Manual* option to allow entry of devices that could not be discovered. -The *discover* method does the actually discovery and manipulates the JSON configuration to add the the *options* element of the configuration item. +The *discover* method does the actually discovery and manipulates the JSON configuration to add the *options* element of the configuration item. The code that connects to the device should then look at the *discovered* configuration item, if it finds it set to *Manual* then it will get an IP address from the *IP* configuration item. Otherwise it uses the information in the *discovered* item to connect, note that this need not just be an IP address, you can format the data in a way that is more user friendly and have the connection code extract what it needs or create a table in the *discover* method to allow for user meaningful strings to be mapped to network addresses. From 004e467201fd458753d112454f653af74686086d Mon Sep 17 00:00:00 2001 From: gnandan <111729765+gnandan@users.noreply.github.com> Date: Fri, 15 Mar 2024 18:20:28 +0530 Subject: [PATCH 108/146] FOGL-8562 : Handle quotes in JSON data (#1313) Signed-off-by: nandan --- C/plugins/storage/postgres/connection.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/C/plugins/storage/postgres/connection.cpp b/C/plugins/storage/postgres/connection.cpp index a7cfa12f29..65b53c86e6 100644 --- a/C/plugins/storage/postgres/connection.cpp +++ b/C/plugins/storage/postgres/connection.cpp @@ -3302,6 +3302,12 @@ const string Connection::escape_double_quotes(const string& str) *p2++ = '\"'; p1++; } + else if (*p1 == '\\' ) // Take care of previously escaped quotes + { + *p2++ = '\\'; + *p2++ = '\\'; + p1++; + } else { *p2++ = *p1++; From 4feb07d761e9c02506b094ab954a42bf3e1e1447 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 15 Mar 2024 13:45:41 +0000 Subject: [PATCH 109/146] FOGL-8540 Add documentation for the update checker (#1308) * Initial checkin Signed-off-by: Mark Riddoch * FOGL-8540 Add update process data Signed-off-by: Mark Riddoch * Add installing updates section Signed-off-by: Mark Riddoch * Address review comments Signed-off-by: Mark Riddoch * Update with review comment Signed-off-by: Mark Riddoch * apt upgrade command fixes Signed-off-by: ashish-jabble --------- Signed-off-by: Mark Riddoch Signed-off-by: ashish-jabble Co-authored-by: ashish-jabble --- docs/images/alert.jpg | Bin 0 -> 3558 bytes docs/quick_start/index.rst | 1 + docs/quick_start/update.rst | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 docs/images/alert.jpg create mode 100644 docs/quick_start/update.rst diff --git a/docs/images/alert.jpg b/docs/images/alert.jpg new file mode 100644 index 0000000000000000000000000000000000000000..24513befd006d228228d5a52530b8a858a126826 GIT binary patch literal 3558 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 literal 0 HcmV?d00001 diff --git a/docs/quick_start/index.rst b/docs/quick_start/index.rst index f5b34e472d..13210f62aa 100644 --- a/docs/quick_start/index.rst +++ b/docs/quick_start/index.rst @@ -14,4 +14,5 @@ Quick Start Guide ../OMF backup support + update uninstalling diff --git a/docs/quick_start/update.rst b/docs/quick_start/update.rst new file mode 100644 index 0000000000..67cdba90e7 --- /dev/null +++ b/docs/quick_start/update.rst @@ -0,0 +1,45 @@ +.. Images +.. |alert| image:: ../images/alert.jpg + +Package Updates +=============== + +Fledge will periodically check for updates to the various packages that are installed. If updates are available then this will be indicated by a status indicating on the bar at the top of the Fledge GUI. + ++---------+ +| |alert| | ++---------+ + +Clicking on the *bell* icon will display the current system alerts, including the details of the packages available to be updated. + +Installing Updates +------------------ + +Updates must either be installed manually from the command line or via the Fledge API. To update via the API a call to the */fledge/update* should be made using the PUT method. + +.. code-block:: console + + curl -X PUT http://localhost:8081/fledge/update + +If the Fledge instance has been configured to require authentication then a valid authentication token must be passed in the request header and that authentication token must by for a user with administration rights on the instance. + +.. code-block:: console + + curl -H "authorization: " -X PUT http://localhost:8081/fledge/update + +Manual updates can be down from the command line using the appropriate package manager for your Linux host. If using the *apt* package manager then the command would be + +.. code-block:: console + + apt install --only-upgrade 'fledge*' + +Or for the *yum* package manager + +.. code-block:: console + + yum upgrade 'fledge*' + +.. note:: + + These commands should be executed as the root user or using the sudo command. + From c7d08b9537ba35b1bc5ad38a8d2fd51414fc2949 Mon Sep 17 00:00:00 2001 From: Aman <40791522+AmandeepArora@users.noreply.github.com> Date: Fri, 15 Mar 2024 20:17:46 +0530 Subject: [PATCH 110/146] FOGL-8517: Fix for memory leaks seen in FOGL-8511 & FOGL-8517 (#1303) * FOGL-8517: Tentative fix Signed-off-by: Amandeep Singh Arora * Minor fix Signed-off-by: Amandeep Singh Arora * Further changes Signed-off-by: Amandeep Singh Arora * Removed unnecessary debug logs Signed-off-by: Amandeep Singh Arora * Cosmetic change Signed-off-by: Amandeep Singh Arora --------- Signed-off-by: Amandeep Singh Arora --- C/services/south/ingest.cpp | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/C/services/south/ingest.cpp b/C/services/south/ingest.cpp index 345ebff0de..993b90e542 100755 --- a/C/services/south/ingest.cpp +++ b/C/services/south/ingest.cpp @@ -459,7 +459,7 @@ unsigned int nFullQueues = 0; // Get the readings in the set for (auto & rdng : *vec) { - m_queue->push_back(rdng); + m_queue->emplace_back(rdng); } if (m_queue->size() >= m_queueSizeThreshold || m_running == false) { @@ -1055,27 +1055,26 @@ void Ingest::useFilteredData(OUTPUT_HANDLE *outHandle, if (ingest->m_data != readingSet->getAllReadingsPtr()) { - if (ingest->m_data && ingest->m_data->size()) + if (ingest->m_data) { - // 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(); + // Remove the readings in the vector + for(auto & rdngPtr : *(ingest->m_data)) + delete rdngPtr; + + ingest->m_data->clear();// Remove any pointers still in the vector + delete ingest->m_data; + ingest->m_data = readingSet->moveAllReadings(); } else { - // move reading vector to ingest - ingest->m_data = readingSet->moveAllReadings(); + // 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()); + Logger::getLogger()->info("%s:%d: Input readingSet modified by filter: ingest->m_data=%p, readingSet->getAllReadingsPtr()=%p", + __FUNCTION__, __LINE__, ingest->m_data, readingSet->getAllReadingsPtr()); } readingSet->clear(); From 258fd852cfe8e3305134f5f12894925f5f11e109 Mon Sep 17 00:00:00 2001 From: gnandan <111729765+gnandan@users.noreply.github.com> Date: Fri, 15 Mar 2024 22:32:22 +0530 Subject: [PATCH 111/146] FOGL-8552 : Filtered foglamp-manage packages from update alerts (#1309) * FOGL-8552 : Filtered foglamp-manage packages from update alerts Signed-off-by: nandan --- C/tasks/check_updates/check_updates.cpp | 42 +++++++++++++++---- C/tasks/check_updates/include/check_updates.h | 1 + 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/C/tasks/check_updates/check_updates.cpp b/C/tasks/check_updates/check_updates.cpp index 52d43c5f6f..d42dba460e 100644 --- a/C/tasks/check_updates/check_updates.cpp +++ b/C/tasks/check_updates/check_updates.cpp @@ -17,6 +17,7 @@ #include #include #include +#include using namespace std; @@ -74,9 +75,16 @@ void CheckUpdates::raiseAlerts() m_logger->debug("raiseAlerts running"); try { - for (auto key: getUpgradablePackageList()) + for (auto item: getUpgradablePackageList()) { - std::string message = "A newer version of " + key + " is available for upgrade"; + std::string key = ""; + std::string version = ""; + std::istringstream iss(item); + iss >> key; + iss >> version; + removeSubstring(key,"/", " "); + + std::string message = "A newer version " + version + " of " + key + " is available for upgrade"; std::string urgency = "normal"; if (!m_mgtClient->raiseAlert(key,message,urgency)) { @@ -153,10 +161,10 @@ std::vector CheckUpdates::getUpgradablePackageList() std::vector packageList; if(!packageManager.empty()) { - std::string command = "(sudo apt update && sudo apt list --upgradeable) 2>/dev/null | grep '^fledge' | cut -d'/' -f1 "; + std::string command = "(sudo apt update && sudo apt list --upgradeable) 2>/dev/null | grep -v '^fledge-manage' | grep '^fledge' | tr -s ' ' | cut -d' ' -f-1,2 "; if (packageManager.find("yum") != std::string::npos) { - command = "(sudo yum check-update && sudo yum list updates) 2>/dev/null | grep '^fledge' | cut -d' ' -f1 "; + command = "(sudo yum check-update && sudo yum list updates) 2>/dev/null | grep -v '^fledge-manage' | grep '^fledge' | tr -s ' ' | cut -d' ' -f-1,2 "; } FILE* pipe = popen(command.c_str(), "r"); @@ -166,12 +174,12 @@ std::vector CheckUpdates::getUpgradablePackageList() return packageList; } - char buffer[128]; + char buffer[1024]; while (!feof(pipe)) { - if (fgets(buffer, 128, pipe) != NULL) + if (fgets(buffer, sizeof(buffer), pipe) != NULL) { - //strip out newline characher + //strip out newline character int len = strlen(buffer) - 1; if (*buffer && buffer[len] == '\n') buffer[len] = '\0'; @@ -187,3 +195,23 @@ std::vector CheckUpdates::getUpgradablePackageList() return packageList; } +/** + * Remove substring + */ +void CheckUpdates::removeSubstring(std::string& str, const std::string& startDelimiter, const std::string& endDelimiter) +{ + size_t pos = str.find(startDelimiter); + while (pos != std::string::npos) + { + size_t end_pos = str.find(endDelimiter, pos + 1); + if (end_pos != std::string::npos) + { + str.erase(pos, end_pos - pos + 1); + } + else + { + str.erase(pos); // Remove until the end if space not found + } + pos = str.find(startDelimiter); + } +} diff --git a/C/tasks/check_updates/include/check_updates.h b/C/tasks/check_updates/include/check_updates.h index 869961673c..b93c789bb5 100644 --- a/C/tasks/check_updates/include/check_updates.h +++ b/C/tasks/check_updates/include/check_updates.h @@ -34,6 +34,7 @@ class CheckUpdates : public FledgeProcess std::string getPackageManager(); std::vector getUpgradablePackageList(); void processEnd(); + void removeSubstring(std::string& str, const std::string& startDelimiter, const std::string& endDelimiter); }; #endif From 110112c04e040859a868438268af761e3d58d128 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 18 Mar 2024 16:10:28 +0530 Subject: [PATCH 112/146] bucket audit log codes added to individual init.sql storage engine Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/init.sql | 3 ++- scripts/plugins/storage/sqlite/init.sql | 3 ++- scripts/plugins/storage/sqlitelb/init.sql | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/plugins/storage/postgres/init.sql b/scripts/plugins/storage/postgres/init.sql index 92f2f8a994..7b858b7c74 100644 --- a/scripts/plugins/storage/postgres/init.sql +++ b/scripts/plugins/storage/postgres/init.sql @@ -1035,7 +1035,8 @@ INSERT INTO fledge.log_codes ( code, description ) ( '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' ), - ( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ) + ( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ), + ( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' ) ; -- diff --git a/scripts/plugins/storage/sqlite/init.sql b/scripts/plugins/storage/sqlite/init.sql index 490fff5836..f10f540b90 100644 --- a/scripts/plugins/storage/sqlite/init.sql +++ b/scripts/plugins/storage/sqlite/init.sql @@ -790,7 +790,8 @@ INSERT INTO fledge.log_codes ( code, description ) ( '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' ), - ( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ) + ( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ), + ( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' ) ; -- diff --git a/scripts/plugins/storage/sqlitelb/init.sql b/scripts/plugins/storage/sqlitelb/init.sql index 782a07ef0a..ff92c281f2 100644 --- a/scripts/plugins/storage/sqlitelb/init.sql +++ b/scripts/plugins/storage/sqlitelb/init.sql @@ -790,7 +790,8 @@ INSERT INTO fledge.log_codes ( code, description ) ( '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' ), - ( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ) + ( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ), + ( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' ) ; -- From ec5f19d8efbb6050de17d6621cf77d6569e0714a Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 18 Mar 2024 16:11:13 +0530 Subject: [PATCH 113/146] VERSION schema bumped to 70 Signed-off-by: ashish-jabble --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index eb559d2113..9cb99d227c 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ fledge_version=2.3.0 -fledge_schema=69 +fledge_schema=70 From 3c26f5a1895b5d4a09eb998caea9aa4d387379f6 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 18 Mar 2024 16:19:57 +0530 Subject: [PATCH 114/146] upgrade and downgrade scripts added for version 70 Signed-off-by: ashish-jabble --- scripts/plugins/storage/postgres/downgrade/69.sql | 1 + scripts/plugins/storage/postgres/upgrade/70.sql | 2 ++ scripts/plugins/storage/sqlite/downgrade/69.sql | 1 + scripts/plugins/storage/sqlite/upgrade/70.sql | 2 ++ scripts/plugins/storage/sqlitelb/downgrade/69.sql | 1 + scripts/plugins/storage/sqlitelb/upgrade/70.sql | 2 ++ 6 files changed, 9 insertions(+) create mode 100644 scripts/plugins/storage/postgres/downgrade/69.sql create mode 100644 scripts/plugins/storage/postgres/upgrade/70.sql create mode 100644 scripts/plugins/storage/sqlite/downgrade/69.sql create mode 100644 scripts/plugins/storage/sqlite/upgrade/70.sql create mode 100644 scripts/plugins/storage/sqlitelb/downgrade/69.sql create mode 100644 scripts/plugins/storage/sqlitelb/upgrade/70.sql diff --git a/scripts/plugins/storage/postgres/downgrade/69.sql b/scripts/plugins/storage/postgres/downgrade/69.sql new file mode 100644 index 0000000000..feab9f1d6e --- /dev/null +++ b/scripts/plugins/storage/postgres/downgrade/69.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('BUCAD', 'BUCCH', 'BUCDL'); diff --git a/scripts/plugins/storage/postgres/upgrade/70.sql b/scripts/plugins/storage/postgres/upgrade/70.sql new file mode 100644 index 0000000000..fdec1bec8f --- /dev/null +++ b/scripts/plugins/storage/postgres/upgrade/70.sql @@ -0,0 +1,2 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES ( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' ); diff --git a/scripts/plugins/storage/sqlite/downgrade/69.sql b/scripts/plugins/storage/sqlite/downgrade/69.sql new file mode 100644 index 0000000000..feab9f1d6e --- /dev/null +++ b/scripts/plugins/storage/sqlite/downgrade/69.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('BUCAD', 'BUCCH', 'BUCDL'); diff --git a/scripts/plugins/storage/sqlite/upgrade/70.sql b/scripts/plugins/storage/sqlite/upgrade/70.sql new file mode 100644 index 0000000000..fdec1bec8f --- /dev/null +++ b/scripts/plugins/storage/sqlite/upgrade/70.sql @@ -0,0 +1,2 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES ( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' ); diff --git a/scripts/plugins/storage/sqlitelb/downgrade/69.sql b/scripts/plugins/storage/sqlitelb/downgrade/69.sql new file mode 100644 index 0000000000..feab9f1d6e --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/downgrade/69.sql @@ -0,0 +1 @@ +DELETE FROM fledge.log_codes where code IN ('BUCAD', 'BUCCH', 'BUCDL'); diff --git a/scripts/plugins/storage/sqlitelb/upgrade/70.sql b/scripts/plugins/storage/sqlitelb/upgrade/70.sql new file mode 100644 index 0000000000..fdec1bec8f --- /dev/null +++ b/scripts/plugins/storage/sqlitelb/upgrade/70.sql @@ -0,0 +1,2 @@ +INSERT INTO fledge.log_codes ( code, description ) + VALUES ( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' ); From 3a674c000ec1c77845aa0280b9cb01783415972f Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Mon, 18 Mar 2024 16:23:42 +0530 Subject: [PATCH 115/146] Audit system 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 b8a4b1d328..265d47d01b 100644 --- a/tests/system/python/api/test_audit.py +++ b/tests/system/python/api/test_audit.py @@ -33,7 +33,8 @@ def test_get_log_codes(self, fledge_url, reset_and_start_fledge): 'ACLAD', 'ACLCH', 'ACLDL', 'CTSAD', 'CTSCH', 'CTSDL', 'CTPAD', 'CTPCH', 'CTPDL', - 'CTEAD', 'CTECH', 'CTEDL' + 'CTEAD', 'CTECH', 'CTEDL', + 'BUCAD', 'BUCCH', 'BUCDL' ] conn = http.client.HTTPConnection(fledge_url) conn.request("GET", '/fledge/audit/logcode') From 9ec6b135796b488669d6368274ce8efebf0f743e Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Wed, 20 Mar 2024 15:10:08 +0530 Subject: [PATCH 116/146] Updated Code to monitor memory growth properly Signed-off-by: Mohit04tomar --- tests/system/memory_leak/config.sh | 3 ++- tests/system/memory_leak/test_memcheck.sh | 32 +++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/tests/system/memory_leak/config.sh b/tests/system/memory_leak/config.sh index 05ff72a619..9d7865c66e 100644 --- a/tests/system/memory_leak/config.sh +++ b/tests/system/memory_leak/config.sh @@ -6,4 +6,5 @@ PI_PASSWORD="password" READINGSRATE="100" # It is the readings rate per second per service PURGE_INTERVAL_SECONDS="180" STORAGE='sqlite' # postgres, sqlite-in-memory, sqlitelb -READING_PLUGIN_DB='Use main plugin' \ No newline at end of file +READING_PLUGIN_DB='Use main plugin' +MEMORY_THRESHOLD=20 # Memory Threshold in percentage \ 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 4d082d384f..543a9e2737 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -207,10 +207,37 @@ setup_north_pi_egress () { echo 'North setup done' } -# This Function keep the fledge and its plugin running state for the "TEST_RUN_TIME" seconds then stop the fledge, So that data required for mem check be collected. +monitor_memory() { + local duration=$1 + local threshold=$2 + local interval=5 # Check memory every 5 seconds + + echo "Monitoring system memory for ${duration} seconds..." + + # Calculate threshold memory value + local total_mem=$(free | awk '/^Mem:/{print $2}') + local threshold_mem=$((total_mem * threshold / 100)) + + local remaining=$duration + + while [ $remaining -gt 0 ]; do + # Check available memory + local avail_mem=$(free | awk '/^Mem:/{print $7}') + + if [ $avail_mem -lt $threshold_mem ]; then + echo "Available memory is below threshold. Stopping monitoring." + break + fi + + # Sleep for interval seconds + sleep $interval + remaining=$((remaining - interval)) + echo "${remaining} seconds remaining" + done +} + collect_data() { echo "Collecting Data and Generating reports" - sleep "${TEST_RUN_TIME}" set +e echo "===================== COLLECTING SUPPORT BUNDLE / SYSLOG ============================" @@ -256,6 +283,7 @@ if [ "${USE_FILTER}" = "True" ]; then fi enable_services setup_north_pi_egress +monitor_memory ${TEST_RUN_TIME} ${MEMORY_THRESHOLD} collect_data generate_valgrind_logs From 7cf854b2077b2df59d3e5f08e68bcc79d8d73613 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 21 Mar 2024 12:29:39 +0530 Subject: [PATCH 117/146] make async func added in common utility Signed-off-by: ashish-jabble --- python/fledge/common/utils.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/python/fledge/common/utils.py b/python/fledge/common/utils.py index 09bf56a1a8..f11cb409da 100644 --- a/python/fledge/common/utils.py +++ b/python/fledge/common/utils.py @@ -6,6 +6,7 @@ """Common utilities""" +import functools import datetime __author__ = "Amarendra K Sinha" @@ -132,3 +133,18 @@ def get_open_ssl_version(version_string=True): """ import ssl return ssl.OPENSSL_VERSION if version_string else ssl.OPENSSL_VERSION_INFO + + +def make_async(fn): + """ turns a sync function to async function using threads """ + from concurrent.futures import ThreadPoolExecutor + import asyncio + pool = ThreadPoolExecutor() + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + future = pool.submit(fn, *args, **kwargs) + return asyncio.wrap_future(future) # make it awaitable + + return wrapper + From 3ad416caa4db5f0385bd3af30550b982049f7111 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 21 Mar 2024 12:30:20 +0530 Subject: [PATCH 118/146] multipart request for proxy converted to async Signed-off-by: ashish-jabble --- python/fledge/services/core/proxy.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/python/fledge/services/core/proxy.py b/python/fledge/services/core/proxy.py index ae2e9e8f6b..2416e92cf9 100644 --- a/python/fledge/services/core/proxy.py +++ b/python/fledge/services/core/proxy.py @@ -10,6 +10,7 @@ from aiohttp import web from fledge.common.logger import FLCoreLogger +from fledge.common.utils import make_async 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 @@ -163,6 +164,20 @@ async def _get_service_record_info_along_with_bearer_token(svc_name): return service[0], token +@make_async +def _post_multipart(url, headers, payload): + import requests + from requests_toolbelt.multipart.encoder import MultipartEncoder + from aiohttp.web_request import FileField + multipart_payload = {} + for k, v in payload.items(): + multipart_payload[k] = (v.filename, v.file.read(), 'text/plain') if isinstance(v, FileField) else v + m = MultipartEncoder(fields=multipart_payload) + headers['Content-Type'] = m.content_type + result = requests.post(url, data=m, headers=headers) + return result + + async def _call_microservice_service_api( request: web.Request, protocol: str, address: str, port: int, uri: str, token: str): # Custom Request header @@ -182,19 +197,8 @@ async def _call_microservice_service_api( elif request.method == 'POST': payload = await request.post() if 'multipart/form-data' in request.headers['Content-Type']: - import requests - from requests_toolbelt.multipart.encoder import MultipartEncoder - from aiohttp.web_request import FileField - multipart_payload = {} - for k, v in payload.items(): - multipart_payload[k] = (v.filename, v.file.read(), 'text/plain') if isinstance(v, FileField) else v - m = MultipartEncoder(fields=multipart_payload) - headers['Content-Type'] = m.content_type - r = requests.post(url, data=m, headers=headers) + r = await _post_multipart(url, headers, payload) response = (r.status_code, r.text) - if r.status_code not in range(200, 209): - _logger.error("POST Request Error: Http status code: {}, reason: {}, response: {}".format( - r.status_code, r.reason, r.text)) else: payload = await request.json() async with aiohttp.ClientSession() as session: From 8df28e7750d2c9e2748ca2ae8001669bfce59e11 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 21 Mar 2024 14:57:00 +0530 Subject: [PATCH 119/146] log error statement added for multipart form-data proxy request Signed-off-by: ashish-jabble --- python/fledge/services/core/proxy.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/fledge/services/core/proxy.py b/python/fledge/services/core/proxy.py index 2416e92cf9..6ea6308ac4 100644 --- a/python/fledge/services/core/proxy.py +++ b/python/fledge/services/core/proxy.py @@ -199,6 +199,9 @@ async def _call_microservice_service_api( if 'multipart/form-data' in request.headers['Content-Type']: r = await _post_multipart(url, headers, payload) response = (r.status_code, r.text) + if r.status_code not in range(200, 209): + _logger.error("POST Request Error: Http status code: {}, reason: {}, response: {}".format( + r.status_code, r.reason, r.text)) else: payload = await request.json() async with aiohttp.ClientSession() as session: From d8dfa6dce148763edfa98e936f4da1839e4c22fd Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 26 Mar 2024 11:15:52 +0530 Subject: [PATCH 120/146] listSize optional config param fixes Signed-off-by: ashish-jabble --- python/fledge/common/configuration_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index b8c961f0e1..ce2f22711a 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -425,7 +425,7 @@ def get_entry_val(k): "value for item name {}".format( category_name, item_name)) if list_size >= 0: - if len(eval_default_val) != list_size: + if len(eval_default_val) > list_size: raise ArithmeticError("For {} category, default value {} list size limit to " "{} for item name {}".format(category_name, msg, list_size, item_name)) @@ -1908,7 +1908,7 @@ def _validate_min_max(_type, val): if 'listSize' in storage_value_entry: list_size = int(storage_value_entry['listSize']) if list_size >= 0: - if len(eval_new_val) != list_size: + if len(eval_new_val) > list_size: raise TypeError("For config item {} value {} list size limit to {}".format( item_name, msg, list_size)) From 5865cd9442386917eafa6ea5f497bb540eba7598 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 26 Mar 2024 11:16:29 +0530 Subject: [PATCH 121/146] configuration tests updated for listSize Signed-off-by: ashish-jabble --- .../common/test_configuration_manager.py | 68 +++++++++++-------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index ab52a1c2bf..8f64f018d2 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -612,13 +612,13 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re ({ITEM_NAME: {"description": "test", "type": "list", "default": "[]", "items": "float", "listSize": ""}}, ValueError, "For {} category, listSize value must be an integer value for item name {}".format( CAT_NAME, ITEM_NAME)), - ({ITEM_NAME: {"description": "test", "type": "list", "default": "[]", "items": "float", "listSize": "1"}}, - ValueError, "For {} category, default value array list size limit to 1 for item name {}".format( - CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"10.12\", \"0.9\"]", "items": "float", + "listSize": "1"}}, ValueError, "For {} category, default value array list size limit to 1 for " + "item name {}".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"1\"]", "items": "integer", "listSize": "0"}}, ValueError, "For {} category, default value array list size limit to 0 " "for item name {}".format(CAT_NAME, ITEM_NAME)), - ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"6e7777\", \"1.79e+308\"]", + ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"6e7777\", \"1.79e+308\", \"1.0\", \"0.9\"]", "items": "float", "listSize": "3"}}, ValueError, "For {} category, default value array list size limit to 3 for item name {}".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "test", "type": "list", "default": "[\"1\", \"2\", \"1\"]", "items": "integer", @@ -817,6 +817,8 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas "default": "[\".5\", \"1.79e+308\"]", "listSize": "2"}}, {"include": {"description": "A list of variables to include", "type": "list", "items": "string", "default": "[\"var1\", \"var2\"]", "listSize": "2"}}, + {"include": {"description": "A list of variables to include", "type": "list", "items": "string", + "default": "[]", "listSize": "1"}}, {"include": {"description": "A list of variables to include", "type": "list", "items": "integer", "default": "[\"10\", \"100\", \"200\", \"300\"]", "listSize": "4"}}, {"include": {"description": "A list of variables to include", "type": "list", "items": "object", @@ -839,6 +841,8 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas "default": "{\"key\": \"13\"}", "order": "1", "displayName": "labels", "listSize": "1"}}, {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "float", "default": "{\"key\": \"13.13\"}", "order": "1", "displayName": "labels", "listSize": "1"}}, + {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "float", + "default": "{}", "order": "1", "displayName": "labels", "listSize": "3"}}, {"include": {"description": "A list of expressions and values", "type": "kvlist", "items": "object", "default": "{\"register\": {\"width\": \"2\"}}", "order": "1", "displayName": "labels", "properties": {"width": {"description": "Number of registers to read", "displayName": "Width", @@ -3890,8 +3894,8 @@ async def async_mock(return_value): ("", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"1\"]', 'order': '2', 'items': 'integer', 'listSize': '2', 'value': '[\"1\", \"2\"]'}, "For config item {} value should be passed array list in string format", TypeError), - ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"1\"]', 'order': '2', - 'items': 'integer', 'listSize': '2', 'value': '[\"1\", \"2\"]'}, + ("[\"5\", \"7\", \"9\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"3\"]', + 'order': '2', 'items': 'integer', 'listSize': '2', 'value': '[\"5\", \"7\"]'}, "For config item {} value array list size limit to 2", TypeError), ("", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, @@ -3899,11 +3903,12 @@ async def async_mock(return_value): ("", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, "For config item {} value should be passed array list in string format", TypeError), - ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', - 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, + ("[\"foo\", \"bar\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"foo\"]', 'order': '2', + 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, "For config item {} value array list size limit to 1", TypeError), - ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.4\", \".03\", \"50.67\"]', 'order': '2', - 'items': 'float', 'listSize': '3', 'value': '[\"1.4\", \".03\", \"50.67\"]'}, + ("[\"1.4\", \".03\", \"50.67\", \"13.13\"]", + {'description': 'Simple list', 'type': 'list', 'default': '[\"1.4\", \".03\", \"50.67\"]', 'order': '2', + 'items': 'float', 'listSize': '3', 'value': '[\"1.4\", \".03\", \"50.67\"]'}, "For config item {} value array list size limit to 3", TypeError), ("[\"10\", \"10\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"2\"]', 'order': '2', 'items': 'integer', 'value': '[\"3\", \"4\"]'}, "For config item {} elements are not unique", ValueError), @@ -3958,8 +3963,9 @@ async def async_mock(return_value): ("", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', 'items': 'integer', 'listSize': '1', 'value': '{\"key\": \"val\"}'}, "For config item {} value should be passed KV pair list in string format", TypeError), - ("[]", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', - 'items': 'integer', 'listSize': '1', 'value': '{\"key\": \"val\"}'}, + ("{\"key\": \"1\", \"key2\": \"2\"}", + {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"1\"}', 'order': '2', + 'items': 'integer', 'listSize': '1', 'value': '{\"key\": \"2\"}'}, "For config item {} value KV pair list size limit to 1", TypeError), ("", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', 'items': 'string', 'listSize': '1', 'value': '{\"key\": \"val\"}'}, @@ -3967,12 +3973,14 @@ async def async_mock(return_value): ("", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', 'items': 'string', 'listSize': '1', 'value': '[\"bar\"]'}, "For config item {} value should be passed KV pair list in string format", TypeError), - ("{}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', - 'items': 'string', 'listSize': '1', 'value': '{\"key\": \"val\"}'}, + ("{\"key\": \"val\", \"key2\": \"val2\"}", + {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', + 'items': 'string', 'listSize': '1', 'value': '{\"key\": \"val\"}'}, "For config item {} value KV pair list size limit to 1", TypeError), - ("{}", {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"val\"}', 'order': '2', - 'items': 'float', 'listSize': '3', 'value': '{\"key\": \"val\"}'}, - "For config item {} value KV pair list size limit to 3", TypeError), + ("{\"key\": \"1.2\", \"key2\": \"0.9\", \"key3\": \"444.12\"}", + {'description': 'expression', 'type': 'kvlist', 'default': '{\"key\": \"1.2\", \"key2\": \"0.9\"}', + 'order': '2', 'items': 'float', 'listSize': '2', 'value': '{\"key\": \"1.2\", \"key2\": \"0.9\"}'}, + "For config item {} value KV pair list size limit to 2", TypeError), ("{\"key\": \"1.2\", \"key\": \"1.23\"}", {'description': 'Simple list', 'type': 'kvlist', 'default': '{\"key\": \"11.12\"}', 'order': '2', 'items': 'float', 'value': '{\"key\": \"1.4\"}'}, "For config item {} duplicate KV pair found", TypeError), @@ -4045,6 +4053,8 @@ def test_bad__validate_value_per_optional_attribute(self, new_value_entry, stora 'value': '15', 'type': 'integer', 'description': 'Test value'}), ("15", {'order': '4', 'default': '10', 'minimum': '10', 'maximum': '19', 'displayName': 'Range Test', 'value': '15', 'type': 'integer', 'description': 'Test value'}), + ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"2\"]', 'order': '2', + 'items': 'integer', 'value': '[\"3\", \"4\"]'}), ("[\"10\", \"20\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1\", \"2\"]', 'order': '2', 'items': 'integer', 'value': '[\"3\", \"4\"]'}), ("[\"foo\", \"bar\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a\", \"c\"]', 'order': '2', @@ -4063,6 +4073,8 @@ def test_bad__validate_value_per_optional_attribute(self, new_value_entry, stora 'items': 'string', 'listSize': "0", 'value': '[\"abc\", \"def\"]'}), ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"1.2\", \"1.4\"]', 'order': '2', 'items': 'float', 'listSize': "0", 'value': '[\"5.67\", \"12.0\"]'}), + ("[]", {'description': 'Simple list', 'type': 'list', 'default': '[\"a\", \"c\"]', 'order': '2', + 'items': 'string', 'listSize': "1", 'value': '[\"abc\", \"def\"]'}), ("[\"100\", \"20\"]", {'description': 'SL', 'type': 'list', 'default': '[\"34\", \"48\"]', 'order': '2', 'items': 'integer', 'listSize': '2', 'value': '[\"34\", \"48\"]', 'minimum': '20'}), ("[\"50\", \"49\", \"0\"]", {'description': 'Simple list', 'type': 'list', 'default': '[\"34\", \"48\"]', @@ -4113,7 +4125,8 @@ def test_bad__validate_value_per_optional_attribute(self, new_value_entry, stora 'items': 'string', 'listSize': "0", 'value': '{\"abc\": \"def\"}'}), ("{}", {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"1.4\"}', 'order': '2', 'items': 'float', 'listSize': "0", 'value': '{\"key\": \"12.0\"}'}), - + ("{}", {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"1\": \"2\"}', + 'order': '2', 'items': 'integer', 'listSize': "1", 'value': '{\"3\": \"4\"}'}), ("{\"key\": \"100\", \"key2\": \"20\"}", {'description': 'SL', 'type': 'kvlist', 'default': '{\"key\": \"100\", \"key2\": \"48\"}', 'order': '2', 'items': 'integer', 'listSize': '2', 'value': '{\"key\": \"34\", \"key2\": \"20\"}', 'minimum': '20'}), @@ -4142,17 +4155,14 @@ def test_bad__validate_value_per_optional_attribute(self, new_value_entry, stora 'listSize': '2'}), ("{\"key\": \"2.4\", \"key2\": \"1.002\"}", {'description': 'A list of expressions and values', 'type': 'kvlist', - 'default': '{\"key\": \"2.2\", \"key2\": \"2.5\"}', - 'order': '2', 'items': 'float', 'value': '{\"key\": \"1.67\", \"key2\": \"2.5\"}', 'maximum': '2.5', - 'listSize': '2'}), - ("{\"key\": \"2.0\"}", - {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"2.2\"}', - 'order': '2', - 'items': 'float', 'value': '{\"2.5\"}', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}), - ("{\"key\": \"2.5\"}", - {'description': 'A list of expressions and values', 'type': 'kvlist', 'default': '{\"key\": \"2.2\"}', - 'order': '2', - 'items': 'float', 'value': '{\"2.5\"}', 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}) + 'default': '{\"key\": \"2.2\", \"key2\": \"2.5\"}', 'order': '2', 'items': 'float', + 'value': '{\"key\": \"1.67\", \"key2\": \"2.5\"}', 'maximum': '2.5', 'listSize': '2'}), + ("{\"key\": \"2.0\"}", {'description': 'A list of expressions and values', 'type': 'kvlist', + 'default': '{\"key\": \"2.2\"}', 'order': '2', 'items': 'float', 'value': '{\"2.5\"}', + 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}), + ("{\"key\": \"2.5\"}", {'description': 'A list of expressions and values', 'type': 'kvlist', + 'default': '{\"key\": \"2.2\"}', 'order': '2', 'items': 'float', 'value': '{\"2.5\"}', + 'listSize': '1', 'minimum': '2', 'maximum': '2.5'}) ]) def test_good__validate_value_per_optional_attribute(self, new_value_entry, storage_value_entry): storage_client_mock = MagicMock(spec=StorageClientAsync) From 97b33a0adf4a8cdb0569febe92f0b6e9d902f46a Mon Sep 17 00:00:00 2001 From: Mohit04tomar Date: Tue, 26 Mar 2024 12:39:33 +0530 Subject: [PATCH 122/146] Feedback changes and refactor code Signed-off-by: Mohit04tomar --- tests/system/memory_leak/config.sh | 4 +- tests/system/memory_leak/scripts/setup | 210 ++++++++++++---------- tests/system/memory_leak/test_memcheck.sh | 6 +- 3 files changed, 125 insertions(+), 95 deletions(-) diff --git a/tests/system/memory_leak/config.sh b/tests/system/memory_leak/config.sh index 9d7865c66e..bb4875c924 100644 --- a/tests/system/memory_leak/config.sh +++ b/tests/system/memory_leak/config.sh @@ -3,8 +3,8 @@ TEST_RUN_TIME=3600 PI_IP="localhost" PI_USER="Administrator" PI_PASSWORD="password" -READINGSRATE="100" # It is the readings rate per second per service +READINGS_RATE="100" # It is the readings rate per second per service PURGE_INTERVAL_SECONDS="180" STORAGE='sqlite' # postgres, sqlite-in-memory, sqlitelb READING_PLUGIN_DB='Use main plugin' -MEMORY_THRESHOLD=20 # Memory Threshold in percentage \ No newline at end of file +MEMORY_THRESHOLD=20 # If system memory falls below the specified memory threshold percentage, Fledge halts and generates a support bundle with a Valgrind report. \ No newline at end of file diff --git a/tests/system/memory_leak/scripts/setup b/tests/system/memory_leak/scripts/setup index f95fefbade..7f6b4d6a72 100755 --- a/tests/system/memory_leak/scripts/setup +++ b/tests/system/memory_leak/scripts/setup @@ -2,102 +2,132 @@ set -e +FLEDGE_PLUGINS_LIST=${1} BRANCH=${2:-develop} # here Branch means branch of fledge repository that is needed to be scanned through valgrind, default is develop COLLECT_FILES=${3} +PROJECT_ROOT=$(pwd) + +# Function to fetch OS information +fetch_os_info() { + OS_NAME=$(grep -o '^NAME=.*' /etc/os-release | cut -f2 -d\" | sed 's/"//g') + ID=$(awk -F= '/^ID=/{print $2}' /etc/os-release | tr -d '"') + UNAME=$(uname -m) + VERSION_ID=$(awk -F= '/^VERSION_ID=/{print $2}' /etc/os-release | tr -d '"') + echo "OS Name is ${OS_NAME}" + echo "VERSION ID is ${VERSION_ID}" + echo "ID is ${ID}" + echo "UNAME is ${UNAME}" +} + +clone_fledge(){ + # installing pre requisite package - git, for cloning fledge non package + 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 ; + +} + +# Function to modify scripts for Valgrind +modify_scripts_for_valgrind() { + echo 'fledge root path is set to ${FLEDGE_ROOT}' + 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} + 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 -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` -VERSION_ID=$(cat /etc/os-release | grep -w VERSION_ID | cut -f2 -d"=" | tr -d '"') -echo "OS Name is "${OS_NAME} -echo "VERSION ID is "${VERSION_ID} -echo "ID is "${ID} -echo "UNAME is "${UNAME} + pnorth_C=${FLEDGE_ROOT}/scripts/services/north_C + echo $pnorth_C + sudo sed -i 's#/usr/local/fledge#'"$FLEDGE_ROOT"'#g' ${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 -# installing pre requisite package - git, for cloning fledge non package -sudo apt -y install git + pstorage=${FLEDGE_ROOT}/scripts/services/storage + echo $pstorage + sudo sed -i 's#/usr/local/fledge#'"$FLEDGE_ROOT"'#g' ${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 +} + +# Function to install C based plugin +install_c_plugin() { + local plugin="${1}" + echo "Installing C based plugin: ${plugin}" + sed -i 's|c++11 -O3|c++11 -O0 -ggdb|g' "${plugin}/CMakeLists.txt" + cd "${plugin}" && mkdir -p build && cd build && \ + cmake -DFLEDGE_INSTALL=${FLEDGE_ROOT} -DFLEDGE_ROOT=${FLEDGE_ROOT} .. && make && make install && cd "${PROJECT_ROOT}" + echo "Done installation of C Based Plugin" +} + +# Function to install Python based plugin +install_python_plugin() { + local plugin_dir="${1}" + # Install dependencies if requirements.txt exists + [[ -f ${plugin_dir}/requirements.txt ]] && python3 -m pip install -r "${plugin_dir}/requirements.txt" + # Copy plugin + echo 'Copying Plugin' + sudo cp -r "${plugin_dir}/python" "${FLEDGE_ROOT}/" + echo 'Copied.' +} + +# Function to install plugins +install_plugins() { + local plugin_dir="${1}" + echo "Installing Plugin: ${plugin_dir}" + + # Install dependencies if requirements.sh exists + [[ -f ${plugin_dir}/requirements.sh ]] && ${plugin_dir}/requirements.sh + + # Install plugin based on type + if [[ -f ${plugin_dir}/CMakeLists.txt ]]; then + install_c_plugin "${plugin_dir}" + else + install_python_plugin "${plugin_dir}" + fi +} + +# Main + +# Fetch OS information +fetch_os_info + +# Clone Fledge +cd "${PROJECT_ROOT}" +clone_fledge + +# Change CMakelists to build with debug options +echo 'Changing CMakelists' +sed -i 's|c++11 -O3|c++11 -O0 -ggdb|g' CMakeLists.txt && make -# 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' -sed -i 's|c++11 -O3|c++11 -O0 -ggdb|g' CMakeLists.txt && make - -echo '----------------------------------' -echo -cat CMakeLists.txt -echo -echo '----------------------------------' -echo 'CMakeLists.txt changed' - -# exporting fledge path and changing directory to location where plugin repositories will be cloned and removed once the test is finished -export FLEDGE_ROOT=`pwd` && cd ..; - -# modifying script -echo 'fledge root path is set to ${FLEDGE_ROOT}' -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} -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} -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} -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 - -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 - if [[ -f ${plugin_dir}/requirements.sh ]] - then - ./${plugin_dir}/requirements.sh - fi +# Export fledge path and change directory to the location where plugin repositories will be cloned +export FLEDGE_ROOT=$(pwd) +cd "${PROJECT_ROOT}" - # checking CMakeLists.txt exists or not, to confirm whther it is a C based plugin or python based plugin - if [[ -f ${plugin_dir}/CMakeLists.txt ]] - then - 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 && cd .. - else - # Checking requirements.txt file exists or not, to install plugins dependencies (if any) - if [[ -f ${plugin_dir}/requirements.txt ]] - then - python3 -m pip install -r ${plugin_dir}/requirements.txt - fi - # Copying Plugin - echo 'Copying Plugin' - sudo cp -r $plugin_dir/python $FLEDGE_ROOT/ - echo 'Copied.' - fi - cd ../ +# Install Fledge Based Plugins +IFS=' ' read -ra fledge_plugin_list <<< "${FLEDGE_PLUGINS_LIST}" +for i in "${fledge_plugin_list[@]}"; do + echo "Plugin: ${i}" + # tar -xzf sources.tar.gz --wildcards "*/${i}" --strip-components=1 + git clone https://github.com/fledge-iot/${i}.git + install_plugins "${PROJECT_ROOT}/${i}" done -echo 'Current location - '; pwd; + +# Modify scripts for Valgrind +modify_scripts_for_valgrind + +echo "Current location - $(pwd)" 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 543a9e2737..0096b35b6f 100755 --- a/tests/system/memory_leak/test_memcheck.sh +++ b/tests/system/memory_leak/test_memcheck.sh @@ -61,7 +61,7 @@ reset_fledge(){ configure_purge(){ # This function is for updating purge configuration and schedule of python based purge. echo -e "Updating Purge Configuration \n" - row_count="$(printf "%.0f" "$(echo "${READINGSRATE} * 2 * ${PURGE_INTERVAL_SECONDS}"| bc)")" + row_count="$(printf "%.0f" "$(echo "${READINGS_RATE} * 2 * ${PURGE_INTERVAL_SECONDS}"| bc)")" curl -X PUT "$FLEDGE_URL/category/PURGE_READ" -d "{\"size\":\"${row_count}\"}" echo echo -e "Updated Purge Configuration \n" @@ -92,7 +92,7 @@ add_sinusoid(){ sleep 60 - curl -sX PUT "$FLEDGE_URL/category/SineAdvanced" -d '{ "readingsPerSec": "'${READINGSRATE}'"}' + curl -sX PUT "$FLEDGE_URL/category/SineAdvanced" -d '{ "readingsPerSec": "'${READINGS_RATE}'"}' echo } @@ -134,7 +134,7 @@ add_random(){ sleep 60 - curl -sX PUT "$FLEDGE_URL/category/RandomAdvanced" -d '{ "readingsPerSec": "'${READINGSRATE}'"}' + curl -sX PUT "$FLEDGE_URL/category/RandomAdvanced" -d '{ "readingsPerSec": "'${READINGS_RATE}'"}' echo } From 5b71091312273f297724f8135881834b2499cd4b Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 27 Mar 2024 11:09:02 +0530 Subject: [PATCH 123/146] filtered out manage based packages list in GET update API Signed-off-by: ashish-jabble --- python/fledge/services/core/api/update.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fledge/services/core/api/update.py b/python/fledge/services/core/api/update.py index e49e7cb75e..008c91af56 100644 --- a/python/fledge/services/core/api/update.py +++ b/python/fledge/services/core/api/update.py @@ -126,10 +126,10 @@ async def get_updates(request: web.Request) -> web.Response: curl -sX GET http://localhost:8081/fledge/update |jq """ update_cmd = "sudo apt update" - upgradable_pkgs_check_cmd = "apt list --upgradable | grep \^fledge" + upgradable_pkgs_check_cmd = "apt list --upgradable | grep \^fledge | grep -v \^fledge-manage" if utils.is_redhat_based(): update_cmd = "sudo yum check-update" - upgradable_pkgs_check_cmd = "yum list updates | grep \^fledge" + upgradable_pkgs_check_cmd = "yum list updates | grep \^fledge | grep -v \^fledge-manage" update_process = await asyncio.create_subprocess_shell(update_cmd, stdout=asyncio.subprocess.PIPE, From 0b667fde8795765de7d6fcd41aa526b82f70b679 Mon Sep 17 00:00:00 2001 From: Geoffroy Jamgotchian Date: Thu, 28 Mar 2024 12:14:25 +0100 Subject: [PATCH 124/146] Fix JSON unescaping performance issue (#1321) * Fix JSON unescaping performance issue Signed-off-by: Geoffroy Jamgotchian * Clean Signed-off-by: Geoffroy Jamgotchian --------- Signed-off-by: Geoffroy Jamgotchian --- C/common/json_utils.cpp | 61 +++++++++++++------------ tests/unit/C/common/test_json_utils.cpp | 18 ++++++++ 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/C/common/json_utils.cpp b/C/common/json_utils.cpp index a8fb57fb27..705f26eb9c 100644 --- a/C/common/json_utils.cpp +++ b/C/common/json_utils.cpp @@ -83,44 +83,47 @@ string escaped = subject; } return escaped; } + /** * Return unescaped version of a JSON string * * Routine removes \" inside the string * and leading and trailing " * - * @param subject Input string + * @param input Input string * @return Unescaped string */ -std::string JSONunescape(const std::string& subject) +std::string JSONunescape(const std::string& input) { - size_t pos = 0; - string replace(""); - string json = subject; + std::string output; + output.reserve(input.size()); - // Replace '\"' with '"' - while ((pos = json.find("\\\"", pos)) != std::string::npos) - { - json.replace(pos, 1, ""); - } - // Remove leading '"' - if (json[0] == '\"') - { - json.erase(0, 1); - } - // Remove trailing '"' - if (json[json.length() - 1] == '\"') - { - json.erase(json.length() - 1, 1); - } + for (size_t i = 0; i < input.size(); ++i) + { + // skip leading or trailing " + if ((i == 0 || i == input.size() -1) && input[i] == '"') + { + continue; + } - // Where we had escaped " characters we now have \\" - // replace this with \" - pos = 0; - while ((pos = json.find("\\\\\"", pos)) != std::string::npos) - { - json.replace(pos, 3, "\\\""); - } - return json; -} + // \\" -> \" + if (input[i] == '\\' && i + 2 < input.size() && input[i + 1] == '\\' && input[i + 2] == '"') + { + output.push_back('\\'); + output.push_back('"'); + i += 2; + } + // \" -> " + else if (input[i] == '\\' && i + 1 < input.size() && input[i + 1] == '"') + { + output.push_back('"'); + ++i; + } + else + { + output.push_back(input[i]); + } + } + return output; +} diff --git a/tests/unit/C/common/test_json_utils.cpp b/tests/unit/C/common/test_json_utils.cpp index 96f2bffed1..6243fe9b1a 100644 --- a/tests/unit/C/common/test_json_utils.cpp +++ b/tests/unit/C/common/test_json_utils.cpp @@ -73,3 +73,21 @@ TEST(JsonToVectorString, JSONbad) ASSERT_EQ(result, false); } + +TEST(JsonStringUnescape, LeadingAndTrailingDoubleQuote) +{ + string json = R"("value")"; + ASSERT_EQ("value", JSONunescape(json)); +} + +TEST(JsonStringUnescape, UnescapedDoubleQuote) +{ + string json = R"({\"key\":\"value\"})"; + ASSERT_EQ(R"({"key":"value"})", JSONunescape(json)); +} + +TEST(JsonStringUnescape, TwoTimesUnescapedDoubleQuote) +{ + string json = R"({\\"key\\":\\"value\\"})"; + ASSERT_EQ(R"({\"key\":\"value\"})", JSONunescape(json)); +} From abbb76b3c75e887d84d503331d125a19a0dd7d04 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 28 Mar 2024 16:16:16 +0000 Subject: [PATCH 125/146] FOGL-8618 Add values and default when converting values and defaults to (#1324) JSON Signed-off-by: Mark Riddoch --- C/common/config_category.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/C/common/config_category.cpp b/C/common/config_category.cpp index cde31d36c7..5ba085f664 100755 --- a/C/common/config_category.cpp +++ b/C/common/config_category.cpp @@ -1640,7 +1640,10 @@ ostringstream convert; if (m_itemType == StringItem || m_itemType == BoolItem || - m_itemType == EnumerationItem) + m_itemType == EnumerationItem || + m_itemType == BucketItem || + m_itemType == ListItem || + m_itemType == KVListItem) { convert << "\"value\" : \"" << JSONescape(m_value) << "\", "; convert << "\"default\" : \"" << JSONescape(m_default) << "\""; @@ -1654,6 +1657,10 @@ ostringstream convert; convert << "\"value\" : " << m_value << ", "; convert << "\"default\" : " << m_default; } + else + { + Logger::getLogger()->error("Unknown item type in configuration category"); + } if (full) { @@ -1810,7 +1817,10 @@ ostringstream convert; if (m_itemType == StringItem || m_itemType == EnumerationItem || - m_itemType == BoolItem) + m_itemType == BoolItem || + m_itemType == BucketItem || + m_itemType == ListItem || + m_itemType == KVListItem) { convert << ", \"default\" : \"" << JSONescape(m_default) << "\" }"; } From dacb52150b2bc78b6ec7be444fb73618608dc649 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 29 Mar 2024 12:04:37 +0000 Subject: [PATCH 126/146] FOGL-8600 Update instructions in line with current version and file (#1318) * FOGL-8600 Update instructions in line with current version and file nameing Signed-off-by: Mark Riddoch * Address review comments Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- docs/quick_start/installing.rst | 39 +++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/quick_start/installing.rst b/docs/quick_start/installing.rst index ceaabc6128..1ebced7a42 100644 --- a/docs/quick_start/installing.rst +++ b/docs/quick_start/installing.rst @@ -15,9 +15,9 @@ Installing Fledge ================== -Fledge is extremely lightweight and can run on inexpensive edge devices, sensors and actuator boards. For the purposes of this manual, we assume that all services are running on a Raspberry Pi running the Raspbian operating system. Be sure your system has plenty of storage available for data readings. +Fledge is extremely lightweight and can run on inexpensive edge devices, sensors and actuator boards. For the purposes of this manual, we assume that all services are running on a Raspberry Pi running the Bullseye operating system. Be sure your system has plenty of storage available for data readings. -If your system does not have Raspbian pre-installed, you can find instructions on downloading and installing it at https://www.raspberrypi.org/downloads/raspbian/. After installing Raspbian, ensure you have the latest updates by executing the following commands on your Fledge server:: +If your system does not have a supported version of the Raspberry Pi Operating System pre-installed, you can find instructions on downloading and installing it at https://www.raspberrypi.org/downloads/operating-systems/. After installing a supported operating system, ensure you have the latest updates by executing the following commands on your Fledge server:: sudo apt-get update sudo apt-get upgrade @@ -58,12 +58,12 @@ Once complete you can add the repository itself into the apt configuration file .. code-block:: console - deb http://archives.fledge-iot.org/latest/buster/armv7l/ / + deb http://archives.fledge-iot.org/latest/bullseye/armv7l/ / to the end of the file. .. note:: - Replace `buster` with `stretch` or `bullseye` based on the OS image used. + Replace `bullseye` with the name of the version of the Raspberry Operating System you have installed. - Users with an Intel or AMD system with Ubuntu 18.04 should run @@ -77,9 +77,6 @@ Once complete you can add the repository itself into the apt configuration file sudo add-apt-repository "deb http://archives.fledge-iot.org/latest/ubuntu2004/x86_64/ / " - .. note:: - We do not support the `aarch64` architecture with Ubuntu 20.04 yet. - - Users with an Arm system with Ubuntu 18.04, such as the Odroid board, should run .. code-block:: console @@ -114,18 +111,32 @@ You may also install multiple packages in a single command. To install the base Installing Fledge downloaded packages ###################################### -Assuming you have downloaded the packages from the download link given above. Use SSH to login to the system that will host Fledge services. For each Fledge package that you choose to install, type the following command:: +Assuming you have downloaded the packages from the download link given above. Use SSH to login to the system that will host Fledge services. For each Fledge package that you choose to install, type the following command + +.. code-block:: console - sudo apt -y install PackageName + sudo apt -y install -The key packages to install are the Fledge core and the Fledge User Interface:: +.. note:: - sudo DEBIAN_FRONTEND=noninteractive apt -y install ./fledge-1.8.0-armv7l.deb - sudo apt -y install ./fledge-gui-1.8.0.deb + The downloaded files are named using the package name and the current version of the software. Therefore these names will change over time as new versions are released. At the time of writing the version of the Fledge package is 2.3.0, therefore the package filename is fledge_2.3.0_x86_64.deb on the X86 64bit platform. As a result the filenames shown in the following examples may differ from the names of the files you have downloaded. + +The key packages to install are the Fledge core and the Fledge Graphical User Interface + +.. code-block:: console -You will need to install one of more South plugins to acquire data. You can either do this now or when you are adding the data source. For example, to install the plugin for the Sense HAT sensor board, type:: + sudo DEBIAN_FRONTEND=noninteractive apt -y install ./fledge_2.3.0_x86_64.deb + sudo apt -y install ./fledge-gui_2.3.0.deb + +You will need to install one of more South plugins to acquire data. You can either do this now or when you are adding the data source. For example, to install the plugin for the Sense HAT sensor board, type + +.. code-block:: console + + sudo apt -y install ./fledge-south-sensehat_2.3.0_armv7l.deb + +.. note:: - sudo apt -y install ./fledge-south-sensehat-1.8.0-armv7l.deb + In this case we are showing the name for a package on the Raspberry Pi platform. The sensehat plugin is not supported on all platforms as it requires Raspberry Pi specific hardware connections. You may also need to install one or more North plugins to transmit data. Support for OSIsoft PI and OCS are included with the Fledge core package, so you don't need to install anything more if you are sending data to only these systems. From 3d269ad0bc4e2345fdbcb63adb99ee9505d1c5dd Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Fri, 29 Mar 2024 12:44:41 +0000 Subject: [PATCH 127/146] FOGL-8453 Prevent crash is bas64 encoded buffer has bad contents (#1323) * FOGL-8453 Catch bad length base 64 encoded data and report meaningful errors Signed-off-by: Mark Riddoch * CHeck data point creation Signed-off-by: Mark Riddoch * Prevent free of buffer in base64decode failed to create it Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/base64databuffer.cpp | 1 + C/common/reading_set.cpp | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/C/common/base64databuffer.cpp b/C/common/base64databuffer.cpp index 5e91d8b170..a785da66ac 100644 --- a/C/common/base64databuffer.cpp +++ b/C/common/base64databuffer.cpp @@ -16,6 +16,7 @@ using namespace std; */ Base64DataBuffer::Base64DataBuffer(const string& encoded) { + m_data = NULL; m_itemSize = encoded[0] - '0'; size_t in_len = encoded.size() - 1; if (in_len % 4 != 0) diff --git a/C/common/reading_set.cpp b/C/common/reading_set.cpp index 349dbf6dac..9033aeb742 100755 --- a/C/common/reading_set.cpp +++ b/C/common/reading_set.cpp @@ -468,7 +468,11 @@ JSONReading::JSONReading(const Value& json) // Add 'reading' values for (auto &m : json["reading"].GetObject()) { - addDatapoint(datapoint(m.name.GetString(), m.value)); + Datapoint *dp = datapoint(m.name.GetString(), m.value); + if (dp) + { + addDatapoint(dp); + } } } else From e6be02189c7a437cab25b6005b05e1e3e86eb024 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Tue, 2 Apr 2024 12:25:49 +0530 Subject: [PATCH 128/146] last_object reset in streams fixes when readings stored in memory on restart Signed-off-by: ashish-jabble --- python/fledge/services/core/server.py | 66 ++++++++++++--------------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 9922642f87..c1e636cc5d 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -736,44 +736,38 @@ 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, " - "force reset of 'fledge.streams' last_objects") - - configuration = loop.run_until_complete(cls._storage_client_async.query_tbl('configuration')) - rows = configuration['rows'] - if len(rows) > 0: - streams_id = [] - # Identifies the sending process handling the readings table - for _item in rows: - try: - if _item['value']['source']['value'] is not None: - if _item['value']['source']['value'] == "readings": - # Sending process in C++ - try: - streams_id.append(_item['value']['streamId']['value']) - except KeyError: - # Sending process in Python - try: - streams_id.append(_item['value']['stream_id']['value']) - except KeyError: - pass - except KeyError: - pass + "force reset of last_object column in 'fledge.streams'") - # Reset identified rows of the streams table - if len(streams_id) >= 0: - for _stream_id in streams_id: - - # Checks if there is the row in the Stream table to avoid an error during the update - where = 'id={0}'.format(_stream_id) - streams = loop.run_until_complete(cls._readings_client_async.query_tbl('streams', where)) - rows = streams['rows'] - - if len(rows) > 0: - payload = payload_builder.PayloadBuilder().SET(last_object=0, ts='now()')\ - .WHERE(['id', '=', _stream_id]).payload() - loop.run_until_complete(cls._storage_client_async.update_tbl("streams", payload)) + def _reset_last_object_in_streams(_stream_id): + payload = payload_builder.PayloadBuilder().SET(last_object=0, ts='now()').WHERE( + ['id', '=', _stream_id]).payload() + loop.run_until_complete(cls._storage_client_async.update_tbl("streams", payload)) + try: + # Find the child categories of parent North + query_payload = payload_builder.PayloadBuilder().SELECT("child").WHERE(["parent", "=", "North"]).payload() + north_children = loop.run_until_complete(cls._storage_client_async.query_tbl_with_payload( + 'category_children', query_payload)) + rows = north_children['rows'] + if len(rows) > 0: + configuration = loop.run_until_complete(cls._storage_client_async.query_tbl('configuration')) + for nc in rows: + for cat in configuration['rows']: + if nc['child'] == cat['key']: + cat_value = cat['value'] + stream_id = cat_value['streamId']['value'] + # reset last_object in streams table as per streamId with following scenarios + # a) if source KV pair is present and having value 'readings' + # b) if source KV pair is not present + if 'source' in cat_value: + source_val = cat_value['source']['value'] + if source_val == 'readings': + _reset_last_object_in_streams(stream_id) + else: + _reset_last_object_in_streams(stream_id) + break + except Exception as ex: + _logger.error(ex, "last_object of 'fledge.streams' reset is failed.") @classmethod def _check_readings_table(cls, loop): From 1fdf61ea767a874e2044f32418bda855f8cdc050 Mon Sep 17 00:00:00 2001 From: Aman <40791522+AmandeepArora@users.noreply.github.com> Date: Tue, 2 Apr 2024 17:24:29 +0530 Subject: [PATCH 129/146] FOGL-8625: Fix for properly incrementing stream ID in north service when a filter creates new reading(s) (#1326) * FOGL-8502: Fix for properly incrementing stream ID when a filter on north service adds new readings which have 0 id and that causes last send ID logic to fail Signed-off-by: Amandeep Singh Arora * Further fix for more use-cases Signed-off-by: Amandeep Singh Arora * Minor updates Signed-off-by: Amandeep Singh Arora --------- Signed-off-by: Amandeep Singh Arora --- C/services/north/data_load.cpp | 22 +++++++++++++++++++++- C/services/north/data_send.cpp | 23 +++++++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/C/services/north/data_load.cpp b/C/services/north/data_load.cpp index 8d721395ef..c0a119f255 100755 --- a/C/services/north/data_load.cpp +++ b/C/services/north/data_load.cpp @@ -550,8 +550,28 @@ void DataLoad::pipelineEnd(OUTPUT_HANDLE *outHandle, { DataLoad *load = (DataLoad *)outHandle; - if (readingSet->getCount() == 0) // Special case when all filtered out + std::vector* vecPtr = readingSet->getAllReadingsPtr(); + unsigned long lastReadingId = 0; + + for(auto rdngPtrItr = vecPtr->crbegin(); rdngPtrItr != vecPtr->crend(); rdngPtrItr++) + { + if((*rdngPtrItr)->hasId()) // only consider valid reading IDs + { + lastReadingId = (*rdngPtrItr)->getId(); + break; + } + } + + Logger::getLogger()->debug("DataLoad::pipelineEnd(): readingSet->getCount()=%d, lastReadingId=%d, " + "load->m_lastFetched=%d", + readingSet->getCount(), lastReadingId, load->m_lastFetched); + + // Special case when all readings are filtered out + // or new readings are appended by filter with id 0 + if ((readingSet->getCount() == 0) || (lastReadingId == 0)) { + Logger::getLogger()->debug("DataLoad::pipelineEnd(): updating with load->updateLastSentId(%d)", + load->m_lastFetched); load->updateLastSentId(load->m_lastFetched); } diff --git a/C/services/north/data_send.cpp b/C/services/north/data_send.cpp index b6ad804052..9d0e148641 100755 --- a/C/services/north/data_send.cpp +++ b/C/services/north/data_send.cpp @@ -126,16 +126,32 @@ unsigned long DataSender::send(ReadingSet *readings) uint32_t to_send = readings->getCount(); uint32_t sent = m_plugin->send(readings->getAllReadings()); releasePause(); - unsigned long lastSent = readings->getReadingId(sent); + + // last few readings in the reading set may have 0 reading ID, + // if they have been generated by filters on north service itself + const std::vector& readingsVec = readings->getAllReadings(); + unsigned long lastSent = 0; + for(auto rdngPtrItr = readingsVec.crbegin(); rdngPtrItr != readingsVec.crend(); rdngPtrItr++) + { + if((*rdngPtrItr)->hasId()) // only consider readings with valid reading IDs + { + lastSent = (*rdngPtrItr)->getId(); + break; + } + } + + // 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); } + Logger::getLogger()->debug("DataSender::send(): to_send=%d, sent=%d, lastSent=%d", to_send, sent, lastSent); + if (sent > 0) { - lastSent = readings->getLastId(); + // lastSent = readings->getLastId(); // Update asset tracker table/cache, if required vector *vec = readings->getAllReadingsPtr(); @@ -144,9 +160,8 @@ unsigned long DataSender::send(ReadingSet *readings) { Reading *reading = *it; - if (reading->getId() <= lastSent) + if (!reading->hasId() || reading->getId() <= lastSent) { - AssetTrackingTuple tuple(m_service->getName(), m_service->getPluginName(), reading->getAssetName(), "Egress"); if (!AssetTracker::getAssetTracker()->checkAssetTrackingCache(tuple)) { From 403d9a81453ed97758fe35f1205121fde2fe5e98 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Tue, 2 Apr 2024 14:34:06 +0100 Subject: [PATCH 130/146] FOGL-8616 Add notes on firewall setup (#1322) * FOGL-8616 Add notes on firewall setup Signed-off-by: Mark Riddoch * Address review comments Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- docs/quick_start/installing.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/quick_start/installing.rst b/docs/quick_start/installing.rst index 1ebced7a42..4329b24d83 100644 --- a/docs/quick_start/installing.rst +++ b/docs/quick_start/installing.rst @@ -140,6 +140,29 @@ You will need to install one of more South plugins to acquire data. You can eit You may also need to install one or more North plugins to transmit data. Support for OSIsoft PI and OCS are included with the Fledge core package, so you don't need to install anything more if you are sending data to only these systems. +Firewall Configuration +###################### + +If you are installing packages within a fire walled environment you will need to open a number of locations for outgoing connections. This will vary depending upon how you install the packages. + +If you are downloading or installing packages on the fire walled machine, that machine will need to access *archives.fledge-iot.org* to be able to pull the Fledge packages. This will use the standard HTTP port, port 80. + +It is also recommended that you allow the machine to access the source of packages for your Linux installation. This allows you to keep the machine updated with important patches and also for the installation of any Linux packages that are required by Fledge or the plugins that you load. + +As part of the installation of the Python components of Fledge a number of Python packages are installed using the *pip* utility. In order to allow this you need to open access to a set of locations that pip will pull packages from. The set of locations required is + + - python.org + + - pypi.org + + - pythonhosted.org + +In all cases the standard HTTPS port, 443, is used for communication and is the only port that needs to be opened. + +.. note:: + + If you download packages on a different machine and copy them to your machine behind the fire wall you must still open the access for pip to the Python package locations. + Checking package installation ############################# From 025e39878f16e91bc1d7dc4c55c769370518e180 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 3 Apr 2024 11:57:03 +0530 Subject: [PATCH 131/146] default datasource set to readings for C sending task as same as C North service Signed-off-by: ashish-jabble --- C/tasks/north/sending_process/sending.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C/tasks/north/sending_process/sending.cpp b/C/tasks/north/sending_process/sending.cpp index e17effed82..cf61b6633b 100755 --- a/C/tasks/north/sending_process/sending.cpp +++ b/C/tasks/north/sending_process/sending.cpp @@ -903,7 +903,7 @@ ConfigCategory SendingProcess::fetchConfiguration(const std::string& defaultConf m_data_source_t = configuration.getValue("source"); } catch (...) { - m_data_source_t = ""; + m_data_source_t = "readings"; } // Sets the m_memory_buffer_size = 1 in case of an invalid value From 51f9eb3abb7ed8577e0a350ed7918774d58fd98a Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Wed, 3 Apr 2024 12:07:23 +0530 Subject: [PATCH 132/146] requests-toolbelt pip dependency version bumped to 1.0.0 Signed-off-by: ashish-jabble --- python/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/requirements.txt b/python/requirements.txt index 75e8a090ce..54634351be 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -7,7 +7,7 @@ yarl==1.7.2 pyjwt==2.4.0 # only required for Public Proxy multipart payload -requests-toolbelt==0.9.1 +requests-toolbelt==1.0.0 # Transformation of data, Apply JqFilter # Install pyjq based on python version From dbc001f9f1913f47b97dd9fde9ce5c19520d66bd Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 3 Apr 2024 09:35:12 +0100 Subject: [PATCH 133/146] Add note on startup priority (#1330) Signed-off-by: Mark Riddoch --- docs/tuning_fledge.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index ce59ed2bcf..e32f74dfcc 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -400,6 +400,17 @@ The Fledge core contains a scheduler that is used for running periodic tasks, th Individual tasks have a setting that they may use to stop multiple instances of the same task running in parallel. This also helps protect the system from runaway tasks. +Startup Ordering +---------------- + +The Fledge scheduler also provides for ordering the startup sequence of the various services within a Fledge instance. This ensures that the support services are started before any south or north services are started, with the south services started before the north services. + +There is no ordering within the south or north services, with all south services being started in a single block and all north services started in a single block. + +The order in which a service is started is controlled by assigning a priority to the service. This priority is a numeric value and services are started based on this value. The lower the value the earlier in the sequence the service is started. + +Priorities are stored in the database table, scheduled_processes. There is currently no user interface to modify the priority of scheduled processes, but it may be changed by direct access to the database. Future versions of Fledge may add an interface to allow for the tuning of process startup priorities. + Storage ======= From 0a1d6f3e3ea04293ac781cbbc7643c6067b20123 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Wed, 3 Apr 2024 11:34:43 +0100 Subject: [PATCH 134/146] FOGL-8626 Add timeout configuration option. Also resolved FOGL-8203 (#1331) * FOGL-8626 Add timeout configuration option. Also resolved FOGL-8203 issue with update of storage log level. Signed-off-by: Mark Riddoch * Add comment Signed-off-by: Mark Riddoch * Fix adding new values from default storage configuration Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/services/storage/configuration.cpp | 101 +++++++++++++++---- C/services/storage/include/storage_api.h | 7 ++ C/services/storage/include/storage_service.h | 6 ++ C/services/storage/storage.cpp | 31 +++++- C/services/storage/storage_api.cpp | 1 + docs/images/storage_config.png | Bin 67148 -> 86777 bytes docs/tuning_fledge.rst | 2 + 7 files changed, 128 insertions(+), 20 deletions(-) diff --git a/C/services/storage/configuration.cpp b/C/services/storage/configuration.cpp index a3253ff684..527ac349e2 100644 --- a/C/services/storage/configuration.cpp +++ b/C/services/storage/configuration.cpp @@ -77,6 +77,16 @@ static const char *defaultConfiguration = QUOTE({ "displayName" : "Log Level", "options" : [ "error", "warning", "info", "debug" ], "order" : "7" + }, + "timeout" : { + "value" : "60", + "default" : "60", + "description" : "Server request timeout, expressed in seconds", + "type" : "integer", + "displayName" : "Timeout", + "order" : "8", + "minimum" : "5", + "maximum" : "3600" } }); @@ -95,6 +105,10 @@ StorageConfiguration::StorageConfiguration() document = new Document(); readCache(); checkCache(); + if (hasValue("logLevel")) + { + logger->setMinLevel(getValue("logLevel")); + } } /** @@ -312,7 +326,52 @@ DefaultConfigCategory *StorageConfiguration::getDefaultCategory() void StorageConfiguration::checkCache() { bool forceUpdate = false; +bool writeCacheRequired = false; + /* + * If the cached version of the configuration that has been read in + * does not contain an item in the default configuration, then copy + * that item from the default configuration. + * + * This allows new tiems to be added to the configuration and populated + * in the cache on first restart. + */ + Document *newdoc = new Document(); + newdoc->Parse(defaultConfiguration); + if (newdoc->HasParseError()) + { + logger->error("Default configuration failed to parse. %s at %d", + GetParseError_En(document->GetParseError()), + newdoc->GetErrorOffset()); + } + else + { + for (Value::ConstMemberIterator itr = newdoc->MemberBegin(); + itr != newdoc->MemberEnd(); ++itr) + { + const char *name = itr->name.GetString(); + Value &newval = (*newdoc)[name]; + if (!hasValue(name)) + { + logger->warn("Adding storage configuration item %s from defaults", name); + Document::AllocatorType& a = document->GetAllocator(); + Value copy(name, a); + copy.CopyFrom(newval, a); + Value n(name, a); + document->AddMember(n, copy, a); + writeCacheRequired = true; + } + } + } + delete newdoc; + + if (writeCacheRequired) + { + // We added a new member + writeCache(); + } + + // Upgrade step to add eumeration for plugin if (document->HasMember("plugin")) { Value& item = (*document)["plugin"]; @@ -327,8 +386,11 @@ bool forceUpdate = false; } } + // Cache is from before we used an enumeration for the plugin, force upgrade + // steps if (forceUpdate == false && document->HasMember("plugin")) { + logger->info("Adding database plugin enumerations"); Value& item = (*document)["plugin"]; if (item.HasMember("type")) { @@ -349,7 +411,7 @@ bool forceUpdate = false; } logger->info("Storage configuration cache is not up to date"); - Document *newdoc = new Document(); + newdoc = new Document(); newdoc->Parse(defaultConfiguration); if (newdoc->HasParseError()) { @@ -357,28 +419,31 @@ bool forceUpdate = false; GetParseError_En(document->GetParseError()), newdoc->GetErrorOffset()); } - for (Value::ConstMemberIterator itr = newdoc->MemberBegin(); - itr != newdoc->MemberEnd(); ++itr) + else { - const char *name = itr->name.GetString(); - Value &newval = (*newdoc)[name]; - if (hasValue(name)) + for (Value::ConstMemberIterator itr = newdoc->MemberBegin(); + itr != newdoc->MemberEnd(); ++itr) { - const char *val = getValue(name); - newval["value"].SetString(strdup(val), strlen(val)); - if (strcmp(name, "plugin") == 0) + const char *name = itr->name.GetString(); + Value &newval = (*newdoc)[name]; + if (hasValue(name)) { - newval["default"].SetString(strdup(val), strlen(val)); - logger->warn("Set default of %s to %s", name, val); - } - if (strcmp(name, "readingPlugin") == 0) - { - if (strlen(val) == 0) + const char *val = getValue(name); + newval["value"].SetString(strdup(val), strlen(val)); + if (strcmp(name, "plugin") == 0) + { + newval["default"].SetString(strdup(val), strlen(val)); + logger->warn("Set default of %s to %s", name, val); + } + if (strcmp(name, "readingPlugin") == 0) { - val = "Use main plugin"; + 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); } - newval["default"].SetString(strdup(val), strlen(val)); - logger->warn("Set default of %s to %s", name, val); } } } diff --git a/C/services/storage/include/storage_api.h b/C/services/storage/include/storage_api.h index 1b2d152ba4..e83521b0db 100644 --- a/C/services/storage/include/storage_api.h +++ b/C/services/storage/include/storage_api.h @@ -100,6 +100,13 @@ class StorageApi { void printList(); bool createSchema(const std::string& schema); + void setTimeout(long timeout) + { + if (m_server) + { + m_server->config.timeout_request = timeout; + } + }; public: std::atomic m_workers_count; diff --git a/C/services/storage/include/storage_service.h b/C/services/storage/include/storage_service.h index 6309208716..6e97e0bd10 100644 --- a/C/services/storage/include/storage_service.h +++ b/C/services/storage/include/storage_service.h @@ -40,6 +40,10 @@ class StorageService : public ServiceHandler { string getPluginName(); string getPluginManagedStatus(); string getReadingPluginName(); + void setLogLevel(std::string level) + { + m_logLevel = level; + }; private: const string& m_name; bool loadPlugin(); @@ -50,5 +54,7 @@ class StorageService : public ServiceHandler { StoragePlugin *readingPlugin; bool m_shutdown; bool m_requestRestart; + std::string m_logLevel; + long m_timeout; }; #endif diff --git a/C/services/storage/storage.cpp b/C/services/storage/storage.cpp index 8999e21e41..68c67a0d8b 100644 --- a/C/services/storage/storage.cpp +++ b/C/services/storage/storage.cpp @@ -141,6 +141,7 @@ string logLevel = "warning"; } StorageService service(myName); + service.setLogLevel(logLevel); Logger::getLogger()->setMinLevel(logLevel); if (returnPlugin) { @@ -228,15 +229,26 @@ unsigned short servicePort; } if (config->hasValue("logLevel")) { - logger->setMinLevel(config->getValue("logLevel")); + m_logLevel = config->getValue("logLevel"); } else { - logger->setMinLevel("warning"); + m_logLevel = "warning"; + } + logger->setMinLevel(m_logLevel); + + if (config->hasValue("timeout")) + { + m_timeout = strtol(config->getValue("timeout"), NULL, 10); + } + else + { + m_timeout = 5; } api = new StorageApi(servicePort, threads); + api->setTimeout(m_timeout); } /** @@ -543,6 +555,21 @@ void StorageService::configChange(const string& categoryName, const string& cate if (!categoryName.compare(STORAGE_CATEGORY)) { config->updateCategory(category); + + if (m_logLevel.compare(config->getValue("logLevel"))) + { + m_logLevel = config->getValue("logLevel"); + logger->setMinLevel(m_logLevel); + } + if (config->hasValue("timeout")) + { + long timeout = strtol(config->getValue("timeout"), NULL, 10); + if (timeout != m_timeout) + { + api->setTimeout(timeout); + m_timeout = timeout; + } + } return; } if (!categoryName.compare(getPluginName())) diff --git a/C/services/storage/storage_api.cpp b/C/services/storage/storage_api.cpp index 146df5c82c..575e2129ce 100644 --- a/C/services/storage/storage_api.cpp +++ b/C/services/storage/storage_api.cpp @@ -393,6 +393,7 @@ StorageApi::StorageApi(const unsigned short port, const unsigned int threads) : m_server = new HttpServer(); m_server->config.port = port; m_server->config.thread_pool_size = threads; + m_server->config.timeout_request = 60; StorageApi::m_instance = this; } diff --git a/docs/images/storage_config.png b/docs/images/storage_config.png index f764829e2ed6ff2b9bb5379238ef0d7146bfae1d..09c860c87f09fc30bdcaa27f59674d5e9b990b05 100644 GIT binary patch literal 86777 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

RXEX`KdAV6_n2f-dbDVeLAV&F z{%Mt?Aq^M6f5|~^Z2E5er&P8KwnW8?hulu=R49F~jC&O&ScY^dYJ43FN`$5%yXa%E zsa4Y)j3r%k$&dOfo%YDrJ!G)|khjL(Hm~Qw)>D&kB z7tKqtp*&9>){Qa}bd$~r4 zPp7uJ3C>V@4OM65S(^|vK8`ZghZgnu6%xv8GUQR5;vaTUYR{Itg41i0F z5wCm(`4S)3`SHg=3M^bd!+~?#uM1?&xTXVJpZz>juzzf43ZLY$FUfA?%(3=QDc)^X z1&1zORT|mtxz0u%GeqKf*vGZn%SMJ^0DKW=y!l{>>BTP1Nh+&^Tyv;p)x7GkS+V+T zsg2dz7SfRXE<#DA$tm*rusv>{nr$mT|ovqs%!6?{8=XdXU6j}mA`Gbdc=-_f;N^Rwf+i|qk@(T1X0 zT(8@YjrQduUISs@wP*4Rs=jm^SZt#-&q?^lI%%Kz=nw?VY;GPAyoV?mGmi2{t z>v4{x+%71ON=J8m?^`NTjB8ASTFPI+l+Q~6n>%L0WFZl2h7G4Y_{ryNDvO8M9i~u8 z^59l$WYwuh^+~5XMzESuyW!kkha6V5SYid1g(wX3xL#0Ll;z6?c7+hBkSN(s$?=`3 zGi;Ml7%?rSwFPBO(X|1_PZ!U4c_Pydioy!D$Z9=sc3oJiOf!GramsVSTeOvNl=X}9 zYwCMfGyJ5Lf2MrdCx1NS@J5oQqofwU8+Xo>swsd&tE}Ul{6*SxE|msIgO6H?`4VS1 z%LU}3m4;pdPFL@gt3cHQ+{1$k3Z@&4jxG7CnqO!ki(0rNOjUVSj3L4D?es36Hw;&J zDzpNFo=-pcCbhZGg)G0(TCwciaT=cjuF}%lw)3UJJ!G!4Qn-VkPbvZoBLrJEg+0qt0{-_Dtp?6crlIX_!d%B&FShvo zJTjh>?Bsha$<;Rb<%9IcRcHn8$-Qb>8L1oK+bjICjByQVJtW0rCcn=4xqk>qAuPqojbTFWhnTH$TsbBNl_q3O{kqT_Jj zK`}`zg=TjN>XE%KFotDt^au~xoDL{X5S(R*5WXil)v-5cOoq&AE03sh{ZmobPwja$WEuz87(P|M;|Bo-jBUTc_4A>;IE~L^}%2Awvwbu zWhtFb5KZuYlAhRjd|K}-Fk*su)6@iLpJ(H6=-DvNyehmpMp_&#v-SpU{E$wuG zL}(um80AGz9QV()_uz{;%FZ6+o+Pq!i*$YOH;GEe(>TSWE+^X@W&taH%1r*Ql2SHm zDn~*Zl{IM;3M}l}MQK*v$)3WijgF+-cyg^6j?;oOl!Q@A!%LfZM3(a&_mznTL2T50 z+V$gJX0NV2B-xc#NEX|~kHG~5sfY2xoI@zVzyO~`25kTomlCOreIcmXwn`RlQjPZztLHXe}vC&J;Dxu@MFN8#+8#0 z@O|2^|C~q?cV+$p6`$h8`M1bG`6{3*bI^Ic%alG*+6)Ng96$sT&;9rRS>)A9?`SEt zZ?K@nU_J09cRMu@kuFJ&7V;=r24d5xkpj zE)1>lQ9jEXsY2+vy`gAzV$-GDeyC#@0{iB154#9HF&qUE?$QkgW6s^K#`q7Ft3&xP z3J945mAAk*#`?H3D*Y}}aaMiPW4_-IBpYju7*K)l;N4TI&-RVnAFiHi^XO zwlEmWFAp(?j?G^8VK~`x6xhTKgd-*&vrK=Jefp$!MGq8ID#+~@xy#Kju@QfTsP*2P zVOqBU2zz885xooWKnc7X${0n-ReqmkBDVq3=X3z7&aTFu*88=l@I~~eAp8b;0x^m3 z_Ll%v87_qe-Dcit+7c^d3th{@31wv0(;DyEu#1xxMLY}|Vn0?$E~3=ce&t8$f@=tf zSpDz=s+Ow&21R$rcHKmtO(3tX$>P$f8sOj#L+UUaqyacAjVBQPjcv1H2#wycE}UBi z1``!y#|Q|BrP(!0%mdjsOgu{_nq&HDf~YlGtBc+16xGQl8sVURwiZ?~8Y}=qV|Q(o zN@fxzgN&hhISu>?77;Tp=8|HK)=dzZuwNJav z#%(J=0X^8kWL^xC>qwOFddalE=-nLnNG)j9uixLf3)z<1l+rk^%o-wN+L;*;IOi4* z-EAuD^alu7V5iOuYoXE)y8(-bF}8h%wy0xh=?2ZJOp5Nda@6<&yr;&?s;3pU@AS&? zc)fj~n0BA*$*X~`bL_+-nhQv(*7S# zbbF>P$!80u4YErb!Op}0mW&NS@%%U_7)tL2?%GRQ&=|sAzG&_lCau|?2jj1A2gCZB zL)gQR1Mb{W?mNK^7gRP{1QrCT=d<;(4L9M!3Cl3nTl?P+`<2g^v73^qInjF#pK`~pi7z3=s}FwQ@=>^5Lk23Al?vH5d5)TMoJuP{~~ z6V>JbC3}SzQDY*Q7s8D&GMmn1KeX(z^Amr=@SwAI}Y-Q3)y z<7Laq74YXUmiPd5t_p~NY0=Uc5+Zi&bYBPL^kcPlC#^ePjtO@_j_iyk0CrM~xHdAHUNI}How1ZoG7&V}n&95A`t=6a#Ky|2n~ApA zX0TbjgG?LBW7ii;8Qdo?m1pNU0|#{8dGlK(5_R~_o=p@I6wBK6(@~u8`Up( z37*I|a0!#miBEgvRx$E`jBZ{VrPw&yi%cg5MPK&i!aaL94TH#~5A}qM*~L*ucHXlw z>BiDM5+2X=Pa`@bX~Z>37KC2zV1{KwZ(CJayOSw0?!1XQOQK4havoH+DyLN$5(Fp4 z4xEFD6g88sxE3q{pYMI|3`q2N2fm!6Y_!tmYJ?f{2OOd$Tf4T{7s_BmYISHzCs+no z-@{@_cixU9k8%t_^3GiIazDCkV&7rF{+ENlnuO#lj?8}U0TaV1xgb3AaubUGMCeel zKi&4Se~i1&8+Jf>nqr#~b0@T4h!{&ouwW0MPO@Ze*cZz5+_z!(%62TNLIzN#LbKoi zF(@bax%PT~Ygxto7leA4uQT1{RW&(jR!r3w&j=0pLyC)M$J{{aay)Ss+PM?k zd-l3|Fyj_@$8UEyjgu5hkcXuvJ8I}=1JY)`Di8Ux$c;%ZwTa~vc7rlB`;>;~V52ka zv;oYPc0Kam;9LE+O5!(Z?@wV#RQ zD`Al0IJduHoVdHatV8CPYWPVPm~snx7){+g?v#G&D~Mm(xfP~Th*Rp%syNVw>}{rY zYG8SQG(=yg+w0IM>Wi(FD0YT~iXg+&MAl$gWSy`;Xk|#>Q2O&el5@Z|eOvN9>94!E z!*w4$aEClsp+!de%##LuRz)s-kop=MDC zu5sC^7kmzKXbxSnJLT?d4ZeAv(d=Zg-X#)=%NGsWEG3Ejx5bmVe8OY4IG$RO2%FQ{ zyIjSY;yp%|n(L>`9#VwRExYZ&D4rmn)+yXBq!(LZ14|s>yG#!^NgfSoC4Xv|z6s5N zoo+VUx4`$wqx;!#$6a;003uM!gCnu|u+#L74SBQJ2JJ_?eqLih8V1_pn~ks1D;i#f z?HK{8;@iu6+2e~_&PQALh0OgGBMHVzThzE-^@EUh27X2t#Mg@#iqz>1KiHWhezK7;DFls}&i)(_aL>dGXU zdJ2Uk5fx$*9wKY(%oj!Bu)U;d)c1Npeottdkv;Fk{`iF^oT}x{DLYRL**%RG;#he^Uh3vFi{ynZ2Byk6I zoBfrvt@(3+9(1PuSw;WnX9tX6^s>#WEIwXBu+&^Fg?s_%j~E^H1M>nWZq%pvVw3(` z2GJG|D84D0$m}Al#p4q)v3+jHU(0dq$z>TYH>EQ%Da-#tj{!l1EOa?f33Cf5$S;4c z-M+zjH$PM>qbK$(fRzhaelns%-e78BTxL>SgWeOn~6Tr zVA@_}H7Up33TG(0BE^a*u7)s`&4>}|_HfDI_Tq3j2kp{ntsA~lq?xV!!|S=u%6)*M zwir&3D25x0@jeTKlsum){^N=`lbRPGGYV0Dk^?_Os!_0OnnQXWIH6ylUN5u%tcOa? z7|0Su1r#&i0vz)NpFh_1qmkw)?aI5oj}9D1s^YF>*`wXI;I*~_ma^fY>z5SjAo^1e z-WItw>Z83;cza$W^Ycrg?F%a(-o7v(P`}^y?%z) z$M=2}CbTa4uUk{*ZMB}FoTbifw^iUgb3#|HbPnt^&EOvu0jtvku~%c5LQu$+8Et-P zmp>2T%*f<36U>!Xz*@ZA#3}bSRDeeLkn#n}l1+PHAZ)dju%FIVf!F_ugQ^AZMlp&T zG0Oy6aB?eBPASeX`{-Y{|7Y#tZV_OE{REfl!&^RLe!om@ayZ%10T8ZCisyegHTFam zNURUp;oaj7%nQZr*(KG;-OsM7wouSviT9Pzwyc?l z)kYy>KLGT$9oFm2(HSOsWGPSwP3yVgrB4?oeR+m%L)c>+DkW@zVx_~-M>>=8d(r&! z<$j@|KUXb|6s1!_Nq7fpD>=(vG>OvdXP^%!31{95@sjuk{dr(%i=cl{M{&q=W7j4< z`3gg*wScV^O97y)?jhJ`0;HKo;**FhH3wOUv>B3AYx7WR;`gZp8QL*R@ zqrZ%G1u;BkLjmMHkXIeX^VY`#s~w!dd7Ic(rBE$m$roAeb4FXh4wUu)zh^^NXof;T zu37@^V`@7Xj_ArwGIz@-R`u@vYk%GDO54P6&m#}q^k0f@XV*sLsFwYm#d{&R9F5{^ zFfM%sGhO3Lb*wpqP(&izMXS+BYI)Pr@q^UqeL$y%Nz27r0LN^JH7iFAAqpErBf%hW ziy#)Qcbta;R6a0+lXp*7KJ!k`XHpeF2bu*ENlbNaWF50=6b%oYzE`cT#8CV1HVb>; z?=KewbIF<@2rvfQD^@>U2xz-94)Q*kN;lx+Wy5lRK)r9rHxB)={!Dicj~1{=T<28W zza~dp0BUF(>j;#s^{9`xvP9;x?L>q@A4p1~TYq{`4Y5%2D2B+nkT%Uq<6ORPCP!Po zT2$SN6<>B{k5-ukjPnngefy8UiieJcxT{<%!1vHlS{|8whIb`^xI`WS(5bnXf7Bi> zFjNX;Agem=*(voXP3J|`@J+UZ33_BuS`|Ti=&@pSks;3eVt~>~qEL_i)|@ZdTIGR+ z)@ibeFDTY4eTx;LkCE1*6?ZOHJpRrCG$phIPrj>cIH^C#*>HkJ?X->zYwV0A>0edD zBG*nnXT+>c?`b35l)hZkT@#U%%w0RnmqNl4eeNvBRVQwHEh={fKR!=(HcC~=A252V zMBUW~(G}N~inAH8S4SyYxhm7w=7%ab;>o)u^3_<_X|0~rBQ?)|-U-*oCxBH7d++XD z^n--6h0AN#5|wQJ+LK`!n;Lz6a%Bf$Hnh(tuab`G-jz0 zru)`N8cv7BgLsML|9wEfe!^O+uE_lpugU45dV=Ozc|da?0iWM}GL8Lm0}IRanfw#o zTY+*ls()MXhESe|G*DbuBJZc25 zv{zdA!GT|UE1U+1iyyEI3UA_r^WqwL3+zvc0l@eL@|14|E(E)?D(xMVJBDokZ4b z>8U?xuC60Brby@)%s;OKRKdoQcrtb6+?G%?8uyvDcyIEb|F zPin5*8$QJS>+f;WXdgkH6-RGgFsD|Z?QD;3@t|##7Vz;iNbWPXQ2{(7YnUGIBj`f!%je?0}x2S@<1AAF7dct;6~b0CTv;an&vr`UKf-{vR1d7NP@qM)M?QE6_n^chP$5~&!Mv~m60=CkG{p!M4?;22 z1q!>#wd6gJ>XS!G!{38o;LQBy=WuI=!PWm3@?;fw0b}j9Bc9ZKELO}fumE|quA^~&qWo&POX?qSP z*BWpzYt=11>J(xdk)uro19y8CmuHwtD3JLUx}hVwhGay}bp^hU^0%M3m+1YROzcOe z8wa<)pYg>Hx!}NjyW+`8+7l1H2#hPNf(P15;Yz0b4iyb(yfO6q!=kOzFZ`jMBp2D~ zkc-IZ6&R>Rej72}8J0Ya2#}gAU56u*H>dljAfTYkeXldiLZT&Kqjwud=rP%YDHCa0qdqbGB-b1u5~r0dJ17$*2wL%uFwpsG}Urj zlj6DM2G)g`^&mhDbxaR`ubIpv8E!d`vE+9J2(PB%Va7b4LuDh0T=G+PVlplm5bqB) z(1i2-Lc(+-$r>66&Jp-e=Fw)GIY}AAb#`<8jOs05>te*m%3$9>8%i))F_| z42CJxoGG8ums%m)M-3T(T%%(DAvK2K_uCqCUZM!jW z_1S~SMPg-*PMgslzH!XufMEcX$iIc*0h-nn=A*K4fB;=%z|ALW8fHMy1WFRc1|?L|p!j;dDaG@7KhVWv6BHcq>n=;crZu!W9fv!mG9S?mseCyHiD} z5-dF~RKG;vogV%mgUWaClF~Qyl6vag{{k>uPhq1oLU}Zp7@2 zfvlP;4EF`i8OBqkvlJ7Ty5Iwi;t?K_hf%u zs+Vk5Ryo52Rqu3-(n*Gkmfd1RQgw3AAv_Nx6fNY-R-lCl*2W=M!gvJ*7OeRP8BsiQI57naM&^hB;ow{ zL&;{w#%a2eD%T*53Yj&15Fd^Y3+$*Q(9PnT*}7Fk4)!7IafeNv z<3dM3t;X=6@PXBJ*uNzNTyNK?f4v5-c2&uXi_r9{L)59v8ZFi6@%G7eK&uB#nrc+^ zVugpBI=%xvWx1Q-OK}>2^j+YBVfXkKCK^yl2-Mbh-7HX2ph;_lm#WcolmKZ2=SwS%Hc)g0AO?Mqg&sM&cI|UKTU`Up?FUjltPB6W_n-=t zoCn5sgtkD zp@7l*o|9_!v4*0zAxdu8d-v*w+2^2V+`wqJi!6fl^JIs317_T271&+_X$#JC2Y$n6 zKR4ijc#LA>OzJ(~rAX;!MgGtMzS-^ig^?eLS*qQxbE>wk8>gW;e;e%mZb`Us((VH$RY^5DUzMcDn37Og5LdSas(V3 z*by^ONONAk1bC=1m?~vwZXlI12-Zr`h-w0TqwEg=sdJ&_e(!t5;heIo)MUfv*ReK{ zTW=3V3D{+`GjsXB0D`XMJbVS4kPMVNQ2dVUcYpQg?S*4eoPpe`u*=+^*UR{z`zM?QkwtnOL zUF-oQ2eCAmI{Fe)f=W#0+RHpdwz1FO1?_e#wu6B60N`$%nk-75D&yfnlhJ~sPq~S- zfxOE2IzQO&(3<9tPop)YXtL8N>U`aKs*TUXU8m{Kfy+$xwegcXF^Rr+x?}cKAA~X@8VkLBPxXOySqbQkowd2~N;* z7lU>ELt96)idNFU-k3k07Wr|Mp28MF8>=yw`pi$thLEi+{+=aZGbAeZAqsYoD$z6g z+UF-NcwMDu(Y$xjL`$J`;a)bs37`;iK8zQcJ5CIAWTQBHJHXCnK`lUNHo6{Ya2=uv z$@t%p{*U!sD~*6R04_}6=OnYUj#&ddb0g4c#!4vF?;|TUUo8L>Ow1!=dm~9(z6=fLatI(l^M35pe{?-FSlDjcw)F^OT*ablmmP9>b=P}Wa_)SW%*l&%{J^|HAJUMgxmqNN{~Cf>~m z&S~GBP6{a2S}bFG6MxKdX)H>_r3y*r)r(C{AQ6d(TZ!cpbRb!<6Cug3Ba}8KP-CIe zyL$qqiRfR6f-J!yhYlyOr9LqtpHP%`4Vr>E*aq#EQ&loTk*8Rp5-;f=3(EC^6#fb$ zgx?gv)I1#wnE*%u6-7NQw8CqIwX>&3GesK1U-J=Nhek@y;uf}oa!+KSdePxJJ%Ixs!Wa6mY4+8Qgi$ZpUd5Q}q?1cDyp4A35gkA>dG( zrkGhVF{T1OydoLOX@N#3cAA6Z#Chu}h2r3_Sd5s8>;Bb65{Sx+;xRKld+Tj35;?Ht z0pzs$@amtG2~HZTAbRevQJNakG20F1E@@;{0hu)svxZAjNOg^@@EeLg@1=ZG25OK= z`V_bQ#0I`M(0EgpZUtUtvkz%3p<|XoaVhqDp#tKOH zbyhb(PEV27dPOyyLjO+`25-v{PibU%B*>KNo*}7of6l`ecD}5pV(E12waT=UW?)vP ztS;_yl|lVnr`C8LwQmw*$=b5byN*7|8(x>g+qtVfMP0Q1)(Zc-eGy_oT}@(Zp4v@C z?D+O{T}iYxtM5f^2Pnv8(unH*o{O}n()Rh0*}I5?mS-fVz{Fe zi@F)FSid<5d#KcHmg!)-jxfIY)rW2W(^2MtC%RdsqjU!i{(MM(p%8}jz_%W}8x5m; zzd!JgJ!XgNIf#ps-X$pa_l(&;Ulp$o%vq8r?d1RX0Nj26CfauREB)@d|Ge99X95mF z$7q;P_lvgt^OJvlABkN+bDewnQTe}qyPbrIo&ch#Pg(qpIsN;4+VD8}BhBM~|8txu z;k{Gi@<%`S#4`~p6_|VSl^j|;dPQ>U|r%8V0={sg#o^63x9{eOz*G7Z!KX>roP|NVu3KQY%@26$Rm7nt*5xbMIGhWblr+Vg$XYlZ)QyRklSxAZFG-u_o+ z5RW?w&{)$Yvx67@`_q;<2@lKbtMi$oe?E<4Sb`+!v3#-JR&;?zR#$%g72ftl0J8vD zY~JsN;@2a282ZwyP`WRkw$OMZdrNifKJRMXr=6HhKgkZ)3)0ZNznx#W*1yL$(kyJ?drXW=J5SN=we#h9`h^z+ zPGxWJuW(=5I;~7$??VvkC2pre4H!mAHGwg>EPxACi%qqVF&PUrY`Rx%w+wRV~ zYeDNtCLgvO5u72rV<)2?;c;2fEjmm}RK3dZ3oPjwZamcEZ^i zzW(&|U&|t(6k~gg@Ma9ZevI!Y7q`mwk%jYflV3TbZ3dGEO>vWPwjL(^dEI?Oo4%c? ziXE}D8pFw(C0)raz1O?7J5Dh+$JkbnG=DVx@!ciUZt_zok6HGvSo>j(7=7>FfT)?C zBs*s_jiR}C0}U426gU3FfkztA{V-yNT%#<<*LlvYfO%&g%x^`lP_ru5k_P!Zve`mq z)(4h*hwpTPQza@5jiGk~k&cQHfN4{QKr#2n`o?!1Z(Z|(n@SEH6%Rnipd51#D5d)=v+My)Va!>lX!aV3vol98kBM@zZz z{*s&iPB!>&00~+YW0eW5r(YeCJdp{1i$^Ft zf}x<=HBctd;(d#-H6Z9`fzFd-Xnq$f)DJWCLde7wwGeV0MqutJumw@Gc~H@me8y~` ziANBhw6DY1fvpR0=POVR2((n7X(Z0FDF$k(qk_yONZHp57MORChb09)9EZ1|Z!Cft z>jCcHGj_!g%+q_+tU?!}y0Q>T4)MmOVOqA=a@nQl`k>7%M4>Q1ab4bkihDLaBl|hf z2{=hQ<3G1}T*iT(LdO@C-(Fd+rr#5%FMnpVzkKygUzgM3z6|xe-2QCUwA{JDRsRr? ziu)uQoyUZ?Yzt$Y@xdBJmgSv-xVEo+xKC69))5Q%2tERa`qTA$IQS&&|nI zoRQp0^byMOMm~AWRSvq4NoNxZ1CwC4o2q5mz=W)KnLAJ{uf=IpkD!r_WQP6E;j-Q# z*Mr(BkQji^O2_0EMtdL__ z)jULzZNTpx{Jcnq;7br^^i_M3#I)LeeoZZ~Q{f{L)Ei|dHPfBB@n|MTEk|2!7J=lT z`#j%q^IW&82xur57&};3X}9$OKNlqEck$KN@*n(gw#c&J6bU#!pT-jwUd!9-lAU8X z+^lYvWxDxBosQ*T!p9-u4O3s%=)x;8iYtRVbsfG=rxh0t(A#lc$%M2_Yf(A?%)=nidtXu(AM#)F)^TtpPBjhvJ-v!#ITRng)#!yFj z@Do{KE0rHT1$ht3A4g;_KxI}5R&MM|0|*3Ieq+?0?2CGCBEQCrRR9e!QR)yg8+|n` z4_SGc8e{vcx2|Suff4ScS2qHjX~M@C{$5=^2YMV{8qI%?`gFDO$m3;(-*Wf&f(?n`QvofUWN_d z@+2NBdbPJ=@s2VlmLEaF=sd(fJpecAr*57}$une+?ApZG+`)66w$NknjBOnh4DGuM zN*GNFFO4%qCS}PL(~N%80NY>gh1b{^lpW1R$SzqDnS~Nx2%(M>T}c60$aXnpdwL5z*6dApYjcHDaKYW862`yb^C%+mms{_$$v z5GBwkUdaC;$lXF)`O*I94{?g9b7c`R_g+%WznVN)_Fi~-<;+p*3mkTtT0X=@H(tMZ za@v_Kj)r{v4z1SPz1R~Y&F0{YbeWX~gVKahp<`sc%3M43W&t1FAzK`!lC^)A4D zDbKK@zGbk<1mk=XRJIQ98ji{(BodQ^bjM;YI4m|W1~lB8=PN$&Ef4$*Uv|C7Si&NH zimXouC`flqB?{j)OgVq%>j;T{qP2-d@LE#;hixqRSHbdp=S~%!t)-Mg)dU&Qep?)) z&2ra|oOJ%_4es-64+~XKxtLOC9}$k}X3joU>6W~lbNE}3z~zMO4O08T-!fS76XZd> z%;bP1TE9NP*a`!$PMqButskZN`jlDppi5~!%Ry_;wyscSZ3rMo$4=^RTcOPIw9J{T z#^(kqzA@$SIa`=%{VjbTbfGEj}>vDpjn6T$tyjS_acYu>J+*-7d- z`o?OGF(&VC)(hfKJ>XwRV{}p#=*y>``XEp9 zvs9_6TIEG(K8Mz2(?YGDLGh+C)y;1p&(*x*Bh6LW@eb6jIyO#4cRJZRid8{>b9VBH z9pu^eZmun(lyC4eF=AI78?G(P(-x4N{A&;SUG{=@>!fSRYrX?lO!<<({HNvT#c1MP$(a948x+?jX(v?_X3eNfuRP$x1Ix z9*@LM!i1_WafI(1n8g|wf4OMHHmg!S2y*|4rB*{@U_EWCbu72u+?^X+*LCS)z{$%H zy?y^(?q=5evqykE$e?)iC8uwp9T`|Cy*jRw$db|Wi3c3G%wd_Ej&Kp)* zQF`RB%KhJ8AqFj-7|XAk>i_rX_z5huY??!w|2=?U2LU~_Z1j}BYux|1>v)_M_<-Hy zG|T?8%K76{T*;vyB`5x05G3>$;8zsJqxQex+DFiD67K%Hod377uh)bx7ts`z@tYU@ z_o{z>QJgs3Sr51E1OMNDx7{4v;Dj&tcKluE_Ek;GAu?BbRd{;aPHFqsNyOmq-aB|X zS~}(|$A8{{?Ufopk7+yEJU3L?|HhBT2SttUraxb(4hH#V>Aazd_7^I|b zEbeF8C-s3OvZ!Wv4N@?Gv=c%3uabXLanH}W+G~w}6ylPuxo-Z)vLolkU`Dr$Y2GHEDjk_pcXC79W>?b~N7>GB5xvj#;#wP73RTdK4u)SXt$O1O(-UQ%kK z8ixin-3Egk_DD?9&*hp6A`KLifOIx!j9>-+Uq1hkl;#d1;N4=DCIEZr0|N&m#I1y| zD!n0lg>nzNC!q$&1brY16sM+zg4~MvRQd=MqqER5TS$LR(+65S4`NCluK$jY2zY|2 z_T)2}k(E&CfLIMG zz$chJR zE&=5*DEu`gSKUh&x&%vy*@CRCK4vl@RA~o}C2Qp0@&(a^+6wR>CDC?h_GB3upKXoL zL3+aTSMGbu6?$MQN&|8v28z5<%t~)z&>_}Wa(d7DY(XnI3$7Js|j0h?;6 z7rU;YgB^eg@=XhnXXFC>-U9;2iLTB|+V`+6j1^ltj&`)8Dqr7NZ04PcIAU_#_({Q; z%Tf)NtWusbR_n}JCy&29&n*?u$jVYE(vik;=z^P@xO!Ur>w~Nu2_j<8#nm4WwHH_z zDMd;}GNd>=H|H4eTUFZKs>*jTs9GRt7i!XKb{f3y*Ai{z|0$~fOICSfW8<4EyN^sK z8?pDC&~!QqzExLNAk6suB}r~iwU)^!g2&!Zo1=KRG!1GW9Y_!Yo1lEUYTBI`a{LZg zQmWR4RNkr3(C&qEzN*6@B>i?g=(~N_kl%%e!WilcJAVEc`VTIJ;FA%1*RyNU>KdZE zUIsc%hc0C~6}Y)sMXsd9hw!iVox^g_j@yDC6B>vRiy^pr{IZ|%8AT4|&5bzF#_-9g zbp|8Us`b2a4>^HFS*+^)PVHx71<3KL2pSESmQ5FD;8J|oRv3Z7ihlDlI{VqnJ`%19 z@~V`Sv@F!k1H+WwKyrx3RPzwW)N1E#p-OVuycif`3SUp3c}{h*>#~V-a%H7+)#O$Wul$jJu4;#?APJ+p!5t&b3B$P{!*OnV8IjM- zO`9ct_vYc!h~FY#cSJM8NbA05Js_Ubg+ayVFoM)P1Q9g3dKvsbj$BDmxx&uIcBVfg z^%;TI`g@%fpgj%b1wVVBJl~OW^Yz%6^MhQaFqFh~ts{98TFv$`!7O}+?2mx=1uBKo zB$~BR+K=VuR7x+Aa$e1Sc`k4f(q{=_Vv z(e1ZS$(nS!5flH!u40|n4H2=;des&j)cfMyeYjW@QW9KRh3c<2{EULPYL^-`}GBBIO2zsh_ly*a^gZ)`9+47t6JDr@2>p&P^(8bi<2Rg`zJd3N61qt9w&z_osXP&u{u5CVLOfKFpo=NB7s)*8^65q{?)+ z`4hO940cq36q4y)xtvL>h&uGsLt~Zzt&*l(VH=a z-?^3D0YhpNG?*NpdB^k2^SsaVJHNm0(|eq6t$les^uJppd~R+o2g_Vi2+(DiFsBnn{^X(E?GJpv8kXGdOIu?9@?*srWyOSbtpV z@|mtwmXcBovHf|gvv9e_vt4Z$c8G-oeG@A3ws~-2+ItU@UG!#s^u z$Br=@MIM@C)WWkVPF3x96hCB%JTLCpc&X(2b-7+Tl$0Mwwd&Db>T|1UP_tM~v#Isj z=<%(IpL3?FZ@r~W_SnVA_Wt3nGW(!U04u%wA_8CYxLhC*tTS}&?M0aDIp!m5e6i}; z1lI|_FsUmVdN+bPP5EZ>QDc~iHxmx1XLeen?GgJ2ha)xf-mG(rI_^+1Np9n^>wK>6 zv5*i5>U!sw8r$_~N706;oxYEU1s-a>ppO_S{82?-jhZlV*sPcQ6ZxRM_Mk~j1Z0xS zz*Aj!W{>s@GO0cD-H4TwP!`2w_ngsI!qQ`;i3>_A3=E4m7*F29|3mDJZnzj_?3;o} zsoAN*?m_Qd$g>)Y#$@NQQ}~~!JXAG>BsZVX7@AQeK7bsy{#xGdD_(HGR4J5AbwW}4 z3Y#4Y>Xs%&&btmUmVI8__q`HmD6(9>^7-#FYkWcl?ixo|a(1`x)c=1`@!FZ2pW}~wWm)lZ{ zrjP4QZEM-`o4 z4!upTeAPt_5X+J=)}g%XKz;lv+*pVg|(IGI+t@jg8B!Go%bd+0xu({xI*$mBpg z#^=8$4$uCX8$IW9aM#`9GasiWMbWGkY37DP4Nw z0jEoKRQ#tGW?JfoHn_pjLidB)LKqwEfQ}nkQ&^sCE730~)^M~U)l%K!I5{I;Vy(r? zvC0m23HtH=%kEUBp3I-?E^1v)k2pqiCt%lM)}a zm$AHg_)&c&jpLm`nRy@Ui@JX`p)L8E@LWU)PU4YOQL$ZVFB{90X~NwE#_W2)Cd;%~ zs>zImy^iyfpW9N^p2|Cg`bYO3MDa+MO(ti>v{t5xIy9}yLKLSf!(Y0&$5sL#1358+CF&21-U}O4vLQKF*iM1f^21}I@n1nI7 z+#XefeFqj>jmOR)0bN`U<>cf*gwqmn*AT?8q3Iz&g#gx`+1l^#?;m*gq#PWw*4Fxg zbr%s$VBohD=t|hab0frRrAf=C(3}pu1n^$@#6(zOQBmN819DawlF>p4UIYC6ZUBv? z>2;m?W&t+-w491bm_5Zzc92o=^bDHD_bDhWL`o<;S^Eiut4Q5zsu95-kYR@(qpGGe ztmVb?FTSEYU81XNjwEb40G<>y@$A9YVZi;DLtOE+Ua@>F2CA#FrT{F)1Q)a;yb0sG zoY<~JtZGbKm_5Cq0r7mWocO{s;oE&n?d( zSv<$$D{Pv)ib*j98Q80u2Mnjcbs7N@%u=L!6%eZG+^v&<&+7ydT_u}lq{4L-7CD)7 zOAhWM(0yo=`x#I&8UbYU0Mh$(2vFwCPe9sj3T0%i0WX2}0%3l+VO|qd-hBu#+cyzJ zuYpI_g&6Q0?Jy}2Dnn%R7r(0P==8oWNDBsn=aUo!sl>>g)8LCE{&*(@r`4syk5vOT zdJvQgWMaH;gUz)LXX=*K=g!f~&&ASmn}BfF%vo<)el_i76r5LgKi({NX)E8)0>^%g zV5AE3^Vb!R6qJ?D%1E(|Fmn}RE(gl*Q-V!?_?qM8!YJn&Q&Ng&i$L@MvXfJ znf$3$j~_S&{d(+u&YQ8`E$7lj)!hI|RpEiPy6aE~5==^>Sg4cl$f|XO)~TzxP<}8! zirOSQ-?kwbI5>nbSgclrMNVH|KcJ-%@LK6E{`&wYj%4st_A{_%mA;8lq)Z8Zx*<+6vfXnU((R`l!|~nhTU4&GD}fNCprGKn zYSH5Q80ZWqxX5XOz}6@naYZy})7>OH!m|=J)^1ob-;AMF8c`4*cC94@vu`AvSoHk0 zLOJZ;M}X^Xsr3a~nj}5*av*c8Wxiblyg*)Hq`yTtb6^1?qy^;#1qIE?a3ra|s0Cl` z1=YR?621fKH@%<&NRd(rWOlxp+hpgFtWwg19~_!lE*tH76A=dtNpQiIxOSsHj%zUq zo0rO6pQn{>laGNy2A5xcX<5}2ig01T(PASIi;%ywHHpqo;5>Y2=R{~j&h0#93UMi& zqSXk-_OHpFXsW@I3nZg~d53+Z3|n%7zj>NE=~mqxG1e0V_{|MC^=`;Ln>+QMLg?6# z$kkO|M90P0lA)bvILB3fiwmE?le;2>BYJJubI+ zzF}-%+dN1t-adH0e|2+_6dz9=$XX&^NdI1Kkoj$-zVh5q&a$s=AuG5Gg{Cz;Dx|NP zuY;Pk*bqiXk9m`X5ilq6&aYSy7hV~))I?^UhUp!kHWWb_N)70+Sm{yvy!;e za~e;gcNHq=O|R&FE90-HF%}z)=;Oh?&FC+s9Wod%WzffghiOCe>f5vWurBmwP z(<4{b|DMXl?Kii}!L_GXqgrf6uCiLS94H!*Hg1~K$;7Ac3_q`<<&As2o)%q|cJ z)yKcL_l~;bI_5igM(DZxS-`zFpGQ^l?_OJ@xQr?PI>!J8vAglY>z#H44{d+Tyu+r~ zBZs_Hvzbem!z&Z@A42VH>@dBuwn=i59|Q~4KW^6%X5#YpT7CI>{;97Z`=$if!11j| ztB)#Gi&@A_SJvzs7ZF1$*D21KgkGg55fsBetTou)AaAx0%Y`u9=odOxa(dNe9@TFp zz@ytBnq&U1VovYFypCR$<)VVG=0%$Pty%laWB2m$Ftx7dz)Zut{5(IZ@b>etW9u$; z#=-TNkdN7I$)1Uv)1@5BZhDf#!{RNmC62*3~!Mdz=k(L3r6O zA50F27zk@xay%2NDZ%~R5xrYUD9uzjE7|Wg;<#q|#nZoMlEan(F5U5PkPb|;v8nEL zGYt(iF0c(n!$2oNy9Bn-!GE+6OEk#OZ8S7?@EZ;7($ioxZ19@|{AlH1{PQWqI_J_q z+vxqMFUsFhynY@0zGLEKYHH_fVeeue`y&|*4MWCKN7qGHLtVne-j)ac*xuNb2Vv`g z+67GtAptgRO}3y8b$y6zX6ST9yb?n+HmkwqRDl&}4)} z1f+hR=D$AqJXeOvzC`OgpkbH|^j)ct)*ettpzKhOEcM}O>! zx&{eNCrfZTIBF7Qz&`)=-rwJs;ziByA2a^f?fm&JxSuk(QoO%xLk2fs%H_7vU_AaD|IpA-N^oFYkGb6@-yb^C2Mc-&dOMC=TlKUU8HB$62;r*|TFRh8uiDCcs zDnwwASaY2}D#ZKm&%ir{nh@5};ev}7#mfKV*h)DIIBG>NkNk3Z{@1CMa%6}(ZGK=6hB~y7-AB^7jpriG zKjtd`uYs*y4bU+rplJMnMT7k8J}cdu!p{=PAS;7UFD0h$ceXzd&Vh5^!1b=hr~K@>(s_Ze|~8WHZg)199AoIt{Eo1JzVd!*M!4M!x6C=Ykt zR&(y1f?M`1)guxkeV~ij`heY%?RQQk;r5NP)^(j6I^=Y+l5Stby;-+=E5#&Qyjfzs zi1c9A@8Urvm86?Z^?Wc*bK&j0Ud7NITA#g-@P^}+@)mvX?Kb!aOo|GhAN%VRNsoMw z9l$*aetD-Lc*?#}8lPIIQ!3hR(z3w6kc~9JiQV5lLmgt3E|;egWp$Duk^Qe}(BTfC z4inZFp1*wwn^0)}*@JR3_L&UdA79}ebnf`z>dlXi7GpHHnMw#eEJ|nUz0$upHREuM z`$8oPGTqW}#411Z%4*7vKrZ zc)D{F0p>}au@+3S?Qw>>9;b(_@#b)$(zq`?&sfRx^)1oCTPATBpnebr_4h~U5B7>Ec)y=)u%+=eY#%K zXx3doMa(ITj>G$$y=ajlLe6z##EBg7%2~S2_~qiCc90M)S2HYc%{|Kf{@YuXFo#0n zG5sM(tYE)bOP!@|+3`wxNJ-=Qq|-Fx=;>Aq5xImb@CYIMqk01e299lHE$=rQXtr`v zo%=7yURn;lSgFS-?9Y@8$@cwm1)oabF?pF8FyZ;iDLcH~0og`6>=;TUMQh%*%5mm7 z%*ClWsz6(Cmvg-iH`pf`*_lRZ-NxZ=gahwe5+Ln}^m#cOvLx&q zj?J3ZvFz#aiJk=#$&9b;t3qxl%SE+n$Bj7lM9F!VB)8lY#PR2a&QZyGfyuXeez2?Gzg(wDB6MFSy%q*dBZO@6t~ zGh9k{>}bpf<)^(W(wwDV2tUTXS_C71Koxo$N(#ySV~f$C)Fz zjaOB2K1gE2!vvUywR}ZY8h1)LllUdb#}*U_dc@k<(`}lI)FI(2{tOXEcPcKe%Q0)o zLl(>#eFKtykSy#b{m_?a_`_1=JctW>YK@|#t!2*k?&Mvkr@;RZ_%V0J_vHH)nAr3= zlKVwn;?DCy6q9P&NO@xVcOO+~9?(-YXH7$2h7;s2fk}*}2y**A*|(F)8^Kd+p+d<_ z(Px%!D>5bW?eGIO!?;to+n9lS&}x=fKQe7X=+RGepIHwgk&tn$jZz`J)!6Wj)%$+Ea~1F953b%d^JWeS2fslxw&SKEvZ+@W4BIz zMc>qXVzYNU-B+iKpVI6~TXLJwsrXWpP&xZHzpW-OetEydu<}!4HLMH1>6lW>m*E(=XIhNhou}^hKfU ztjz3i-aPqvoWR)lQ8g*`Peu$(xVcCZ2Va|-`{Xb)CQDh6JNR5}yL8hgFu(!Gj*` z#jfxnL(Z;1-uK@GXk5d$UhCP_uvOSh2-wtqec=X$Q1-`E5v@Keo2s&ZM2Y}QU2-aT z$BB*=kA0P3OxburL8UnDdD26!44;FEdEi4tV=_3hy#+wPYZxqk$cym+XUPi9X||U0 z>l2M)+j4qcJms8boTDCCZg7S%bg!4=O)YQlk;jh5i!2uz4|<%7+fY;(8 zI7#vhN@k}-q(s5s(Iuk3jU7W@=i<5d1+FJ}Dzbo+d!Le?H&Vj2g+MeVN5Nw}n5jw{ zKimXX+2*h)guEUbdE_$Vu{y!;be2mpP{UKd_epMp?!a{EwcY312mM+4CzdtP$1HP1 z9!_+JWDHsHvgb!EH4kT%bL3%j$8x1hEnZK|F|Gz1#ebhLNzKlaM;lcR|071{Gt1G% z)fyKCl$unUz9dXB&r=a3l3*G5VO7Fh!haN*C#i&fI)BM1ONCF}P_p+s}?UFQhkdg?mKOs#rCa z0ppN)D>ia1Q{F2~?u@zVJ?1>tK(Eacg8S*-0cI?*-Up|W1N57hD9+2jzc&370TRTZ z#VJ&r)Dxx^IkPv8A+@Xq(q3h;(rZhOHCR@5y|b^QMQ2ZjE>4j-nQJ=Z%ieQ(n3&($ z<+EQ8C*5j?KgH;=bf9r89M=+EepmwIY!^Y)I-f{ z84n9!0pToj1FAbUFE@fNuZxXHHy)7wxmfWr-rcoN(7i)-7e0PA?ySRo z)L56;VX}hTb(_Sz5vxPRhu?+B{@4|U4sCVqM$CMDthciM=@#2)KHKx}N;yi91#Iy# z@332MTEgX&NjvxJT6(4+n=kB6CJ(e>W_pwt-HS6Ha*@3X)jyTgc2&EpqInsb^@Xr zPrRKT=HFT8W7gV)vSyQA^ar)?PBd^NA0KpGndtfEb7XdRywcmD_=ZNYwdtSS6 zML`o}(f!=K)3_armu*(pz0;=e&;AR6GhT06kCxFWUiE5lov4gSzFrh&X<-Ptp~iTe z!`^uE(x*n#r{)E)?{bnY^VWj$;>@#UN_&cdj+0}j?Dlmb-aoRR!BQLq-N%CK)HO*Q zRX2AHj5VllOsarmH8EIZJH0|QAPYuwl|hQqIuJf^Qo%(*u}L~`f0=Uf-oGrFG;}?D z+qTWO;2!)cmLzP;2<^^rq)s-7Ov+jz2Ah6Az!D-$aL+u9pD=*G_hxev^Gqe+KcU=C%JPRK|3MEG?&QoJT zThqKzfip@%Vg2W7BCT&vWTuBJ9nFd%IjGW+^GnGmEc z&x2Hitw)QBBN7fz+dSh|Byq&E?cRT{+yb+nj!iNpVm~8wx+;5t1LCcz*;q-#4=z-a z=<;oxzg%4~2xqR(^;=m7D(gbJjv0)(AwVE#E+3Gg5t(=#uf=m{RC!@9o>b1~Xv_4E z)y77kx%L=)PN@}jDL?E{&Pl=IW5>Hql5Ixi$*Nqc33R z?bMd`tD5nA?cqb~`6UpANUpmj z@$Q2xL?w!v%opSe4t>I7$fRp}li?em--k#aPM6ytsc{p(U+6_IpN15z{PG5$V1?Ju z$_KZ}u zlx<%G7Jq;PKlN8|m%R~Z`(v*?#cZ<~w*bgYWBj5(@S5snr2dU%yF~3Mxg6!R>P@HG1ZCN1RG3ab&T%9ad}Z_Wcdsaj6fce+j~<6Z3|3(}>Qr#E@1Zb@w` z>7fudM9MLR2jtQ4OxIByug&@@g7ki@B}*zH3$1bMlKN)9i<4QoXRDH%bwkLvAq>7^ zbpY}vafGb;RDE{Cqp>Lspi7pGTpFpepWW)0+=QqcO#=9@(Nqs0d-J<7!}N)b79v@E zP3irF#4?|g1Nd&AXf_-2YB^kpbTcsa?fH-Okh6m+UcPVdY2b1t^#>1+zLgI&JM~y4m$8!U#FmoOszlHzDsn@_Bnvx?cBe39@*tYZu~@b6a$a@XMVNyB`*eRXTPX= zAv|FHkwtFMH}8?Nmhy1-F;wND!vG#c_Ra4t0Opju8)cV>q)KZ;#(aOFle?^2nr~i) zOIkUDT8Rs!jz86I-LLZ1hVKO}(+O?MwUg56iK;fBPwh^iZ>77YL<)qc?Qc(9uQ-u2p(Jpztgfe9qV5gB6tby z>-Up9&KdLT4rjdB#bC`VD)gCP6;CxVKV)=)Iqj{zF8RS;uF(-qN7FB{&P|%ldFZ+R z#$2J}?tO#Ai6mMXkhNuowL>`~xZkQ7Nk5CES(a08&I?>u;QKn;*oebo6WG}-96v{G zC;6>3@OwB^S-@Ph6&pgT!Uf)FX})pw&Eqk`M@Mj3Fz6$Y7W+`7JMn5^HTpT1saODO z+-=srdCe4K=%Mu+(|Ma1shvBOTs;eUWa)7q^M!xHNo6v(F}i(E&IiJ(f&Y%9;R}Ym$N6L~nyl zNaqv2fEv~ApVf<$rgSXgOCMe+h7eW~WL@LPaCp*pk`0`0o0Or%dD@KD3qPo>-5STFOf>p)<;)Ax`UIHC>xNEk$!5}eYNRLHm*!mMB3zaj?BkQ zR6)^-fl*qqPQui8H^jAyO^cSC9KmvM+#E?_%YM0ZD70MIFEbGxG479$H_3{73wzuz zwX43n)pV0N+3|cp_F|g)-0@O|12cj|Vo7CDg4{~?RyC;tL6pr-?VP0o%WMc?2Th0r z9fj36b{VoXXMBtLgJkF#Cocn%lKRNE2;s#m+8mB2T%_70=WWc&-E0fP7-tuO&~&3R z7;|zE`qolC%xhLA(peRE?nRGOGN}y;s%c6mHl$HvU?*Bk@4Ymd$Le=@6;z}eLtn0w@N!N& zwQZ#4W-?EZ8*jHKSADg&v!SP|ef2z61bnQtMEQfBJ(0qtu#@^1D!OP)q==qDqt}+j z$h0KYlVnkJ-`AGY_X`~LXYMBzyC7r+Q&w99k3fBT%3%hfI>#2@2h-hJQ1kr2G!2tc zUYxm>K1Mx%6JZ5AP*S9wM-Fm`Ml*0M?=L$V8&<*@nqMBVMme))pXo10ADYVIGhG#F zyf;c39>}Ij+M@WH%aDHB9V)Wz>NwpVMPrV{X()JSQ$(C%6vaW{dih-S?wigLWY}2S zt7f(aL#a2YDsm(R`8wrJ#t%bpx~nC1PIc)kztHpz^_&~V1pJ?&Y?NI;DyN-#NGR+N zT81RmxTO{#WRYvs1nSWN>%7c(W3H#$9eSE1^}#`rhT_3kZC^fmf~xwp%Jds$yu2=v z#%~HlpXU2#UF7I?fEFDCI@hjYSxMIj0j=0zbFc;4JbNQR7G{JU?oL))UXH!|9gmaZ z8w0Syco(Ty{U9~MFu%^@rY~Ybj1O+f;3maKJgK8#l`=&#?dTP~->7xd_bk269PWS| z!5&eJDmm{iiImC;eKL+Ah!!i3Lt`1czQNQ(KsRFLo8k1j!Uy`C9+|>L&%KXV88s{NSTgOpO z_Vf}S)5g3kkAe$H^&V~q*6|=D;$T$rmvRH$of_(7wmnixG}ybGS@hw^WH{86l@Ami zGQUecW6FKTlQW@k_N%$VDIUNTBwr7p!@XRiR;VY|)d>gmj%n8kXRFrY<>TTQDg0{l zUHBd@iRc1uUni|AoWyGFiRW@=e7hQd9Sw|w*!q}nQ$;amnhKP_Gxl5KD@aO&d*dU2 zR0>@tzGuNHwcScOn!@VA^`wW&JOiE^CM2=V$e4{SeFBZo%1>}zy2OOt8Lug0nPn!# z_23pIW=GDpQeKz|pEhB;IH#bYYqEh`>YGCW%OA2BLKVb48B{kJytm{}{5rz)hISUR zZi*m>HuSoth_PCr5|K*@LP{jQRR%D{%K>rD3pGYMoXxSuEcw`*1OxX;OLjf_BUHOU z$H8tPz$>q`WQyx`k@0};H~yAg8rcB^glTUsv*lt?Hk1-+!KB0%IQS+v*&(omHq>YIC3y9+Nq zCE_a^Q{D*1d+w;jE2bFvLn}k*nH_-5>$cCvGGX+dU8%Li&OV@_^pJ?C{*I0>CFT=c zTKG+!YCccAmtvm+WAvOFc7K#DULn)sO4K3jLYEtmGUPwzYT7=ZSPAQD{9ZZrO)P;x zu`bs}XBy^KC^b~=lINtpDXSR&>Es5p0(FL?z^yt0dcRXgnr(D@=Ftw7ptmS6*B(hD zPHR)3Z+VJ3`n6kF2<~9#-lJU3 zL;vRZy;94MD;RBn@z6&ja@BXd6wa>osk~qC=A|sd`@t}krU{F@sBf1MuN`HLrvsd)>84_}{r1IeS5i>043;N<` z@6gZDwRXzThPcygjvVSH8oSkr?-`ImTLUAszTMN*DM&O`gQo_9YICp zF1MLrmU5P8e^}@?sF|jUbqjuT4W@IS{^&HmH4{k8Lj4(B>p^rMaq0=gh4yKa+ zf@mCE51I>fCyG+6yyh}vLs^*5lrdP|Uj|EozRUS^*FX`$gf$yPU7WZ`O0LW+Tt`U; zaSZMjO*G_++j@LM7ixSt3r8rDkIS1?R+%x7Z1s!aos`_TH7wFr{>^12qg;7)UUo8j zuH#VYDA`ZzjzV`HzOwT=&!D_gcu`L`33L@L|W0aFzt1Bq&zMW!O6?~w>YVj zgn~^>j!YsAeURFRXiGEutP5QN>@ma)CAs~(C$|U*&kIhYjaW;SIkf`cSh-_1&Yl_? zdVYOQH2?t^57w$G3c>R&@xUJ3T0Nw8M7m#UoP|;?*~@=ZEgj&nV0^YS)#pLXF3yli z4}DX{M9$R2(y&vvoy9iYR_`-TKA(8vf4C2f0={lBJOp2W2W|7Gs#b`t73Ogd#}cA< zcY8=dp4uX2jS(y;y^-XiNTt?$qFF?Bwul3@;P2wR z2>K%p`BM4VFlj>X*`b66kydiE)pkR%cw}7#r>>QMPuA-N)Tjmw{dj6}}#gDA~DA-8g{= zhW%(cNtj(f^6A@#@mhqw525MEiD$cF#B8@U|6t{K=}srYZ)aXA)UWFhb8O%wn1~L4 zqE5K$YNAkZ^U7koaNAg$Q!z&aJA|T@Q_A(&2-=FkY#-}>PmNAsHocxe%rtq8!dfC4 z7Fd1Hcj(k7>vGo9i`mEGLwvbBu|mk4>Wr>^%Z5etAVg4=00vFtXBRHVKB(`xUgnA} zs8s%4ddU%kXh#UsV=R-Y2T!-+1!GCiP*L)_d6sWCDR6>+da;X%RFQRsCK zr=Q}t^>|xB*6$A78+F$B?PZ5)#`SZGy-k}M7bVySsJ;9ZJBkDg4%Y7 z=oGz#YQyuj1Lx5dFJYIl+G#lvu$s4THE-WwU%(}UbL~Ne5j=xKYDbsJ3ag*o!mQRB z$FeafoY&Xsw9Fa08LTVlxo2VR@LsD$L`Te0X-KyTW?Q(_5*|*iZt)nAYRhu=k`rTk z7O6Cdcon@}=d8*6)-w>bpooCLt$H7RrxTZ;4Yr%-FiJVG?{3lvt|zSOQ!TyT_#;5^g7DqBXLF z_M8%(U1a+7oZhTieHyiY>NVW>PG8pJIxf}bxJ^;rrM$uOuKgN_i0`GN*&V_;>D~)! zA{q%e)K|&yXIGMvA)K)xyadqXQl_*>D z7G9=kjr5oTTVjf*=foHO){zV+{)brgwp~oh@f}Ui)6e+0s)Q(C&Ur$IQ}|76=nNqf z_`W)w+iUPWT9Rk%1VorA?~Rt%EeC7mR(hTeEjbh| zrn!*pk2t)JY8KX~$+)>m4rn_Au!Rre2$iRY zT+PuOhpyV88KI3f5r>9pD%TG867L&Yh9Nz&s*8tiN%nOIi-o~%YKk9TGi7BZT^AA5 z#1?-BOSr;9Q2kgyi#PcN({B1bQu0%KtA)P1@y^z>-yjHvxvqA}(7~(JtKZGt*yp0i z-QPZ*eg8IzS!G*NE{jnj5>~pU!X#8w+ZdFn%;;xH{N7}7CRU4-^kd!M1>a!;6pKe% zixRG;^TjxCXVI8|fVobt^MT?9j+9R4fIu)RjQj+X^Oi|2ff~l!R_MGpforJ(Er8hB~@mIPO zQU}}(M>(Rhgu3NTK1DeXUDf<_hntXT$JR-MICpD<2`4{9iuhT8C~?Q`y3vhl>?hE6 z*F|OjLt-WqebLD2qg!uBmtcjFMr6sKt4g{Pv|vhCl>|kRRY#{nrAN1PFE8h1Ad?&j zESq&stJ9K*&^P3>=pe(|)EH{teBxL1rKYZbVC(Y*WU@xx!mt?CW6px23P|->^=kFu z<6oHUuZ^$J%a^TTH=uz#r-a*Zs(GBv@cU+jCt-0X0S;JCkK-~3`Jc4Q%-<3U-+Cqg zg{g}<(S17ZdjI+{oRWvMEX0Ae|EB&isO?%y`^o$ub{rAvRm12lB!uFCc#C=S6kcd| zplHK9&!a%n!5rDrSJKr;k@nQ@lBjj?S?ip_Q=PhCD4FxXWfFW(W;q4vExG2aAs_i* zZJo=+G&AT9#!#JLRrW)yymFm)OtcK~$2B2;h7 zKm;Ll#RU8}?xjMi-M8rD!{lsXbB3?KZr-&`xU>2%@?L-wvlbsdE;{-EF@?@T^U$3e zoWyV(r1-~DRiyYv^K$5y4f6AvYACm9)=&rn*jMz(Z{?f>X&5FR~rNL_rrSwo0uVedPiFRB<4An8@w zj6&~Bk5WBNgd12JpUT1p)Yc+bN#Xp+T-c5hC$35}ye^887>gKZ0J}J%PFX*qNC0Q< zfr{Og4f|#5!rlw`^Mtfn5~UfN+?+%w0oRuKLC0+z*)RH@-7E=$>YJ zTx{&hET8iva<&VqaT=s4H9TK}^Z^8Cl=Irjv7tZU>gm3#o3+^v;l?%cDO$xtxG9q98z6{@F4r~hWEh3RqA&e z7{w1Ig5a(cy(FS|X`PaZHm|wn&ui!z*D6Bh^Sg^RyRiuU*YtHqKZn(&@0=i%5Hc( zP5fv+qyJIQ7k|X?aIjV$U*YA8%eKt_^wvN!rX_?y#(;HKh(sb58Y2OpZiYOX=0pB#=l9wvrR zaaTIhyp`gSH$2MLNjy#xcm4$riQxU*qsfXSoY+lhK=4Tr>-SKfX~ni1{)Rotc|-qh zicS#6>fUj?AR$wOtOcV{b#I5yDJs(P;shU=J*I8Wf6P1wfjTo^l3}LLlO_{9uE|!s z^RF4U=mZR0p{0w4Vgra1tSMb3S4(`(qU>Kr4hV$_DgG*fT>M4=7yRnz{kicUN7aaX=?sMB;)yqGJrj*kPE-`_!l0)Nw^Z}2(bFLJ&)Fm0@|>r2 z?j;69SsPcS5{@4t&(m%cT_Jn$H#s?H0m}NQQy-1jJWQDEutX=|_eJEiL+b20FP8;- z=JLMY{(wgd<``Y>;kBQWG{^_tT2M~drYJqkQY*Biyzn!4{`D-O+)d8@86Y zM>XcK4ePWvY6kso{|7uH-TJ|Zec+Kze)jkyI@_ocpU^+|ug?2yQOIgB=-cWioj(jF zM+%2^J@h&E^S{TG6AWR$l|gw=-?5FFUip25+!+g>(^BW)Gv$bIr&jAm~`Jt*XM?Ou4}_vOYxe)fR_4lxZuJpaE}zKBFEyUDTeQPbLH^P{#o!X**rOy2e=j;0x;8KRMsv zlrr3?JWW-pOV@x#f163h&g+BfcT2d5+d86TE<7e~iCQl73KCM{k zmoa{LGZMvG!+as|hl%|`tgui+VSW@DcHFWcvl)=?Xvp;)J`5HcymvU74*~rP3FLi4 zkLOI=pWgk8@APYV$z8{}j33Ah35Vqekw3Jke3Icl<1t?~>zi)s0m$(!pp{Mwx<~P) z&A3;Q*Q%xkZ0h$%EBgS;RekB}M7cR2eG3bmNFU7w!jHD*=TSXkBI!LX#NJ0$Te=xx z=K*Q=YS4=lm?8=ONf`$DOE91Tq=i=W@VtLKYW*wy{5L_KD^*T@AWeKVV@S#Zb{nQ+ ztjKK%=pkkw{n0ORm#Ci{e80qW2gP5l{_-rM8F23(Po07WD}1`zaenNPU*ETuUKC+O zbk^IXr}IuZ=yy*^4P7D>W7NW`dR<)Gjm&Eyl;p0k`z)dFvp+KbrfOzqZgHRWF&{f?qj>a_G32r_ z?LkiH&2jqmbxA{-sfTOhoX29YRt4x^lYJle)z|oc7CTWDDC z`G6-KkEgn+TgL61ZaT@2%QRh~{Erw)UiW*KoAsGZ*aBiU9`gRbNnO8gX^sqxlXDdC zmwYN|1(71(OIrvKS4m8vs?lIdz#sR z^m~D#0wfbLIN}^}Am1)v+V{0%#s$N!G{dX(Nto6f!4jmB)MX=$b`KEE0!zhV$-BfwKF$LPuY^JK$Gl|W-14i%$^9?OZ> zLxk@Q9)}UO4Ne-=x^zVnwsn(gO7WuzD7m1TUwI8DbbA`KhTOkN z`oY{#Dhp8Y-*y<3$HokX01DUd2=IFZa*B+D7G|77krfo(v6GQMkXnihMhumbQ|Bm3 zfUZ#h=G!-KO;KJiibj+Wv9wf)+cs4KU6Wqv{6z!eYfgg3T{0m4k2vfS`8ci1%gW?|}D**Cq&-OKi@e z#wRl<1otSoY47XMC2_xYz{v2uI6Eu_yzL;Z>R?Bw@EcyRA<#}+qUX6`T65SdoXud04#mA{b`hW zFbB%mGQ2{X-_19=rkXYmhjHkt{3A)uC0hX?u>0R2Lg-+wSR-UpQdgF zi#68}&atE*E`(r&&^oDonJ6y^;s$&z{c?PRj2EX+0Tk;;!yT}B<_v|_i{BSVt%#sP zDWC@i167EG6!88lcU#)ee(d9+C}AD6&XKlZfYnMb16rnhjti}WwXW7zfusBO zlq&oTEMWYzlS3lVDhvp+vMv391@B@@#z*OjP}Hn%V!nO$b=xWkVEO7utFhutI%_a& z0_R?)?~_!PT>p;7T2&}j*)_jR1IP`PIl#~$jFI_aAy+X0mYYUJDhR6r7}s-2S&|rc z7?Y$fPIplfIMgC8`|JjkJiQxG)>*)NS&_`W1NfQcVWI%pK|$#BqLgqce8b_5`!RTc zNcI#4$TYHg=ir)xfqV{ADH))6e#Wf|+o{n}1K?KXhcsmg0EyEawZxi1FT^stE1n|+ z@R$ZGzXCQr0uZ=t8*b;Ruy4S^+xTnDsz5{W$~5TJwEJ6A37V-N0ZdcG4IoOR{fn3X zxw40^0MP3i{)mGj;1kIau&frKgJYlv{&w(+u;HURmGx%lxUp`Q1OKLo8vFw!|z?wtHcM0ssZN~(5d(}39bO@nvf=~(-PxM zwH6q|2@5@-b=>vUO=o75-fFtUK0LP%dQZ$BYgV&p486V!ORm1{;gRf-`SE9_^>cB$ z%FFp)xXK%>UJiK3>Yu|8kf8(Uc!cD|$VkjN#*ZFMT9^1!04}4g4Mi&fG7f}p_POom zFIvanB6u50N9ZJ6_;Q1J!L?N`H^FDd1Y?LXMxNhHF5POzOXnM|^KkBoAFB6qCwGs)w zP_;pLnPg`iNSkCY-VKw(BxcMHofhzBS5GlwQVhf*5P%12u}f)Fo?S_v-+vNk&Mwlx z^7hE{QHWZ61t43FIL*LB68pTx4P_I?qDtS4FF4QRBvc@LoH85!Gi#EwB>`DqdW9CM#1I49le3)mnZTDu`htcPmNK?|2A! zb~a>8YGrZuSiAe%wTP^(t2-ZKCC$NNE6xA#%?pG&Gm+hC5u|tABXdAHt#s{rdt3u8 zk=P%;BX}Yh*coUZt^S{mwj1SQosM`5@tL)lF%YFXq96F^uLAyDG!9k0*I^~3?J_Cx zD)1feNCfk%XqchKIJw$(K)firNmJ(l1T{~Lc0NVYNsfxZePme5_}V=xXFRRbJBHXr zNN`xx95+y^lts)v;Ql(&gO2n=WjERs3F%Z)nI91xJN~(Ok1 zs^d>YZI$nRx~a!WCT;>##q~Jm>lHl!>nsGA{EU=Mr!XlEy&y{8R}I*GEhy3=gOsV- z6Di=BdtZbKvho1cC;)A=oV*yHLWx;=X*j;x#!uY-FVC;lgBq@YL*^}rRRkIf&&aUn z{+cOV4Urv5FFi>Z0(%mw_-R&*g22O;G6-jY>goQ|>M9^+6v4fmCpym-n9{5@8A(3! z&iX#-qn$)zerQY@NDtan{1hDEq2I;u!vXnq?J~?w=5$N(rl&fINu4RodO=DgBR;M) zn1F`d(C^GuQuullxQfkQt@-2bJlFpWPFge&>ONX3Qx)3`wN+R^Evvsd7LIvWqxMwN zZ$to>UNUEzRX3OdAygw2zE=PgY0Gp!rb;--uXD;S^a1Q{lTff!t1#BlfYM7ExfJH} z*Hz*1{*+v$gY_p}2dFzD6-T#K=@O@}!B&DC^7Vzwb6Q^wm3U+@i=)%W)^ z&+-yzSUr6|`&vDoAxm~g2!&C(lF{<8o(@DFhRALvdI>MzcJjRm{DW1dutu8Dl^BJW zmr<9c6#=1i38GCk9E&Wt+_Ih_v;T~(@npyQj>qUy-p?ynL5stSV~<+?!Ngpm?u2|H z)L}w%(8oSpO1PQ%ArO1Y;_lMR2WF|J>blH(;ZhRmwv|-reP$!qdTg*iVf;+#0^YLt zYk|0tGWbG+#>;P;K5irfEtA8A^+V-ZZ=ExVja9#n>d!c>57}GkR#xc&n4Rkh+5!wG zme{mVh{Q^nEi*$1#a7g~x}c!*S1lso8F5n{R&H?XHi=bu@Q7~n?=8m$Mowb;#oCQA<%?$c-G9zX@jeL*%2xRY%XRMMqiD4t*Xl>-C8 zZ#c|)#^>;}oT(egXkY6Na#jBwI$3eN4?nvnLDO5u%i@R@p5XC7Y3!=uX4_W)+w_HI zHa$w3T-wd``f<|zAHATZPmIb5CU`D=TpCm)A^^)g=1sbUtCjy)jO@ACRArlPQ>RFV zGmwraYTd!lkqG`LM9W2?@+_b;BR&V}Vuzkf1vhHYyov0G?0 z^JJsn#TM`-H;P_f(f|HeeM;*d1ZD)Yx~B{0QhImepJxFCm4cGQWT@Fs6oe8Iqsj|F z6e3R37px!)#>PHs^KRtc{*9ur3nsuWudd&m^;o6bt*imj?x%zV1#J-JPkR?XDp4lv zWF$aJ2!G)wEoF?Ct<=%tzNbeH0AgVw)3qn-4`-}H0RGv%=|N4W_qV@aOK1X^|58>S zWpkw0N1O6K5tLRNABahCP1+;NXt|JQb9Vz#(&710J$nFH9RhL0{mzfg8I`^OgCxxM z-U$mLLE*bx>#<_0`FRwu;B7p0(gnJB4axa!32VFTY)Jvg9RYh7u{PMxe65NoTy?$>N@ZY+fd2FY3CLL z_s?XoPpD@Jpi;^ppckBwII~1Rst-FID~7diVm2Saj*)K?JGypU)8u>K^|@^nIuo(e)NWh8F4yV7SX?k9Etz{KOJ!PqmP@zjV_ zfJiJx3JOd;PnB1aFUsZ5=!2z-$p#xOx4V{S?3#q$1XTbRc1ONtkV`JU;c0OLMfP{! zfnvmbiij9*F~!%ryI-oV3?xTCb*5CofNVyM9{{j^dzRkDRfuesNfrl0w{8J?H{tKJ zR!##apPnGxP>9RSag(0o5Pss_Ex;wV_v54UyD7W(K>%uZaM={Kd^yfjT?44R^06;U zjC;3T_W*>oiI8IupD+9*PcI&E6}6VQfBn?Pm7{a-AGM=a7-Z|=1i-sRUl88k049Z_ z5)6>&c+UB(W(gre=k`BUJDN<958P?str%1o)ne5NcSW)^0xD+e(tA{K45SBNxGgBZ zLStx<$tZ-d(}+@yZ_(598Obqzt41a8L|I}9@TJZ;s@#>POGH9Mm+OqS?}ZUKhDcx- zLpywdJfj&5-426fw{G^eu|8hqLip9midsJa?BS$VMlOJfPxZdMU!rG?(K041bRYOd ztYR8@{N0`B(!rIlvmu4aZkscoc?j*r>PG*8MOyeM+tQcP6q`r#*-eYlL@~{F@z(EY zzuvQb+r`da{4=Wr*+!+$M9d6dv?8~JbXYajov%`J{+F78iouRy1@Ox~DUYmHswUeshA_6`OWfY7{eW;6R3Po)le!fxu~x{3dd5QHy@yx6 zfk;(3eKDsTxQdf0>N3e_Hdx zRWLT7z73^83e1x|(TeTa(rN1yB&C@pzT#b9qGue&<}D()OuJOaICJF=cm1p zgSQXaxMLPC4jV7}5DTm^clBnEZRKd&KoyH4oq5>dA!b@%P`40i!}&5si60)L}W*t(h2#RC@yX5^KI`e)rA;++%koJ2hYmj zt+~G9#ZB8rueL$8M5FDiRI00PZ1DE9q5cIy2-;yE3gOJ~xyUUXFQ`y5&IpA%d#j!H z38wdK$*uY>H?T60sfe-E&~JOUY_Y0VZ(_kPd&?R-0Hx%R>>HP1Y8 z-T(OA2>L%3$hzhmFQtxVhmZPQ>5x7UnV6-IJ3)4Vxr&TyiQ3`=3BOmw;>fI^FBViE zYO>+e4=NP!a=&*W*P$eg;I)jlD~a2oRQu2hDk&$=*b#a6!`y=_+ zWaR{NiQo59Ii{W9brLpNY!&8~%o7qlNWc>yAx8DW-zU7rnZT-w2h)v21t@%_XkhP= z#H4aLb;b65>R}iSwZpQi0 zj8zBS6z|`4L+G7+>s_sQ(|e@Unr2&06TN*rs-F-w^yln77%7`9<%m4J`Ze8Nk>Cl2 zh+mAE{1qa}EF^Dxl=tK_VC@GBc_Iqn_<)16|AdL-6g|tA2Q%K|DCmqIDv+j*Haj6G z9h)w5%*@8fru(tQ7(-odpI+sx8Gjj6sOB3aV(m-6e74Y zR;2E#O}oiF*-t2tG`*6Cvwf z%Z|_+C(e<4EKWVG^w0`bvKBCEjXbxL*QspUpxU;x_(A9QNYd#J_mz``gIp`C|1`O| zKXP@SfJsf{neMlIxK+F;#SyFtzXL2PmfiLQfSwC)8N(@KRLe@eJo{lLQFXD5@~e^$UkhyTXuc{hF*i9_z)&BK?v>OZA^r} z!c9U&O=&@PK6x=Hqn#^bDY(Pc5z^in7`>Ikw6pIN4^6}2P({{r1BN*cBwo(!=ea|-j_yJHC8pL!0Gr9eK}G-bLT`8 zq&hXCBKI50OGXD(EyF?};cG5d%pbBl}kvHIZ4~RJlNY<|Puzc|~#&ODWJRUj?Cr!Q6$* zpBmQEmXGsey)o&12J2q&RxBmM3oWx<#*P}V2Rg#$+=HQQv1{Zy{dx+USQgRL7PO4g zru<mKtu592mKO)ITb9152c)fr>%$jnfeL;1apea<#fCr$2?c&@z@;A zw%u0h{TLiVVWIt!w{jIc>}oV%PF+5mmvg5w@LlK&wOEN=QL_gLUxDgj-Ir{TNWBL* zQP)jP;ml2Nl-_Z~rgyQ0TUWb4&qaDz9>e(G1rv{0g6JFD65(nn3lHAB5EEWs$ZfXbMOA$IV%E|tFa;cCu+q8pXebo0r5i`+mi@jwH+GSzTsq%vscQ1CBf2Gy^pJGHx@Fo;-1(~CR<6I0 znsDQ(fQQF&igrKtqK_|II)^e5d+S;lXB4*rMR&XkoxPq6PLoW(np_tNk?g`Gez0@A zu|K_;q<2CN=3RxX!QQ|dlI9d9OwlqHS8BE2l(+Drn3cR4+da%rT*`e-{`vqm%U_B{z#OCQcdV}Z2eR2J;70NS3jnKZj&SnGkMRk~wm_U|9AW!+UNL`g!}(9-7-gCvwQ0Jbw;7cE-@hSkiHn|%!J z$)V)4Ap%!Xpa2`fX9F>hvK%kpdk&VCxFLjKtvSMVg+=EZC3yrcpE*|eJ-4S(wYsdw za;^imVTK4(Msb#C>o8We4*d7?YC}!sK31k`V8tiV;y72(UUD8_tQ& zYZQu1A=tf4eTfGv0Sc6qgA_be3E~N9sT?a{=0yy~DT-=me6tQKNs68DhWd%#K0fH9 z$3>95Sf?E5BAmd=!^(q<(M7GU_Fxf72AC|!u`3uH6xKK$b(89(eOR5ZmeCZ$Ig_hy zM7nnCZ2%p4tlDwqp3y4J9wXyu_W&A^~_%?SS~*vAP4YYxp3A)3RyptvLUwO z_Nik@C7({tSwwQ#tUx+(O?e5vYxI-khlGAx9thkdV1y1WXkfXJ>hx}HGC8pck5LE6 zuJ($#6Ea1VD%9$sJF-N%61$B|r0Pmx&`zR)6}UH~-eOCir|$I~F_w1_80Yb^d7EWl zjBuwmQmQLFahloiA=d5g^n=wtTVdOGl;jQu{rlDMNwoQv?tD11Ox$w?z1FY9->@g> zv1Rqb$8b|wG!;QrlJ{TJNT2nZ_muv|l~&PhfEM&8hb#*ELfOz~zAIQ&8vec^?+xXu zhI>Xey6&u|j1=J31R;6gB~VTwpo|!XjQxC>UP~9imWonSG$32#^G)deeWo03?t@_O z<{*h>Ojuq|;Ch71cq=6L7ImI3#dd4O0HTMxSa(XW_nB6<_~*`wmG1|GsEgk+PaX^R z{?Fw0ckYG#6<|dUUcD+Li2w@Pa=2WWCc0p6#U}Y=l0v={EZ(IfRUjxuz|hDH#)}uh zsiM_s*3eFKQn;$sk?t}^jMGWp+kev`z?_;JxH$wQ<@h64;7`3n^1S@@V?dmSr&A9$ z58k)C$gcpwCxi<9e(*#sBQ>6R_@Gn{awn}yXZKO|-r7k(0lkDY&X9vg*xKpBAB(R5 zcNYJ&5ed#?<-60h4=tLWP=_8oToEcA{zFdQ#UjIbuzLkd*1>X+&3(2#C4A!}m@lbs z%3Qco2eZH3#Lj^Mr70jBdF?I$* zf>%g4?Ze+&=6Dbdh=EvuKy9Kh6e0PfR$kHUMPgS+dXm z?3!v-)Ob!pYk-#bs6>IINO*xWzCLY+8O1E4p=#x(i;d@Ldr#zXAN96OH%jUCuH;#udsin8ugO6!@3kHq3Eu zN;8Yv%s475P)WjF(Zr3$gI;PbH8-?-Cqwc(LH7)89JV3#?SO}gw<9F340twk$h66vJM(91al@qs1fY;QSvdU6%~yVGU!HOH z05sppD1;orB{KG$f(v~c55UpzAHC82(of+4GyYa6#$EFp#uR_e-0CW_=DJ0e*rBq!( z(b!Nsuzb1{X1cpf%vmmWP5k9ng+Eu+U!PILG;X&sq~TIEdGI-Y2!R2UTDxS(Ds137 z3+q`*KiQMs(<3=ru`#&`<3zW{j<%rCxl;$cEL62(as17%0R%AId=&&Hhy8MJcN zXa8!y)*NXMv4Uu-#fP8gdmnAoaq(<44P6>U`3F~r`uAN@ykc(wj*Km5o9(59p4r}5 zwvPAj6%)mVyx;$mL-B_Q+F`;!+Lr$r^)UD&>hVTG_z#{IVHQRXKRlz5&wcz;2n>0P zM1^9brJ+LC@Qq%K37xcEL#uO{2A3t%K&_(7>kmx9RYyh2UM@17D;Lzc+!18kR0?biZpouz0aQ3!PW3uK z!x-B5r4UA51gCZ>5J`~%{wuvdvA`rKQ=Uc%b1*b5J5|MlyoYdXMLTz5;(Wpcy z25-6x=g--*5RMcNt#h`;uxqUco!#YYe=64(L9*?c|u(M@3JJQCf?4Z>`) zs|E&0Fi1cN1IJw&1%x*Oa00}7kD+62XfYfe?W}jcZDqX=kbi!-X|z>dE`edgE?s{@zc#@$O0fTV&tK<#b= z@Y(*P22U_JZD!p^?zWw-pb^m3maZ6x2$mFs&X{0-1tnqynhfCKbpf-+V*dOPyyLS} z(G3&BGR*WFz7Rn)4T?h8DJ@()Oy2i30LO*1=x;!^K(gCz1^9JYmh!`Bw$QD0w7u^H z<`i?lh$V#Y>2fG)%E3GP2RuMz3+_ z7MTC)eFcN;fS6L<+dDm&;>s&tyqmd}LDs;|HQ~@UT(?YklBBy;cuyv;0-U982IyGL zPv8vOS@S%|iFCJ2w94Q6=Ps74!%%O~RmAx258@kyS}@`?NASfMIF#Ow%YV268Cc!$ z+T|K|0RBiP$6I8f*sh&LgP;QYL4Ya6gPJMx}4E7-Q>gv(0i-o^k7=6-YMZ4AHN? z6xHCWPYO4IvyAIS7}SB=Q^>2!ZL{YD-SiF7UF}Z6;1E(mUGqY4)|IK9trqWu)x)!i z^C^6)ljYnVU=f!ZV4|sItwCGtxpmixNETB)_4i}S@K-NGQXR!ME|*X2!1(nZ_L5SK z#MBcDwlE6r4=jBTMxE6C4#WfgeqzhMg_hnQrBYYqp=ZR7ZWR47pfR+ii_QgrAH5EL z6IY%3tmhrz@^xB#1*E!d!<#<9B{L$T#>aa!XC6wcnJcGiuFs)JCusu`{UwISLqCJJ z3wO}Qj&iBFF=r%1=HMABGsEt^!Wq*J8Mv_st3~Rrldo^JcTbu|uxArY$;&cnDfjV~F zbPfQo7LCWjyb_DfT+}IEBFlmC-G#P(4NGwLq(rb_$`7n)O)K6nxJOoHSie|s>q{r+ z%Il?|8B#O)_+iS)upXzO;c=fM&YIUa_FHPAx17tcG6>*RKpj zdT~u7(wP5JGtk5E8&vnzf##-w9z+H)?tQ9TP8zSaU0$ zC&W8A)^d{{kphgOA*vMV3$IFN53Un;wy~g4!s&18Pp;PLiFINPgkJvgl(;~nTJrSZ zY`XT$eK%W+5m8Nj+Li8hO%VNH48kHHMrJgs>v%lanM~`^cnelp&k4-> zl9ni_;t^cEeKvv;oy-TlLpH&qS~s~QqxPSp3HCb%VU;GK0K~0@VV-27gZOPBNdyr? zx@)o$Aily3})$&B5FDNzLC zG%cai9Q(^bL>Z-l%ZDJFV0g-tElTn1`r*=(i(ND~=Q-C-WD`Y~@WoSCDFkyYkaiZQ zvl3lb-i3s6W-}sfzY?Gf)Rg8xOUtMeLiX>IS}F zNLY|Z>u?SV!VgND0FWQk8dN{|@D|XhKalRI8<%>KNz7O4-L)>x#>hlSokQ+sqhSCcw0DPpp6d5(8mw6V z8H>Tl(KNR}9i(_n5qJZbKFoMO&rkMT%m>26xD#As6EU(b9DzDq?F=#O-XE76UY>Q! z3mLucKKKRPJsTDP_1X5~^kAb^A&q~LnPPpNa#N&t=wpqg3Vx&?P}|;qWRw;h)3MWx$5$Xy+Ct$0=Tbp&GYGyEGbY2~{>)XEO;*ibnr9&y6`f?33m?WXAXXecJC1>D^@K}<$(0f+}iGia8yGLl(Q< z7>2zb-7eZ%cSo8oPajaa4W3NsW8ENLmfmb*jg9chUuhj|2@jziqf{=<+l)J~WK7!E zX1q&oo&q&Kd~YT2Y7yRG4{5gc**j}qBXiMve`=^`710%q<^pFLG6^58U)lZ|{_#r; z+(N&f9ouFt-S&HZgN3IgF(j6H&qkho)$#C|eaQn3pGwR7)6X-hF%3X#djVW`lS3T%Fugy-ZUUnDG zaTXV;`%Jt=Fc}uLezBxC3*PQ;86RYX>K&50RfO_IK145yo4ooem+Pilh0R0BY4u~U zWg^qx6|fhGaHQ})^U1ToXD3|?<-1GOe;AF|1;YY$2a4`G_2H+yT#!n4E?t-~mS@N1 zxw+7ns7&%Cwe_Q-hVvR%~^;3i8;%($7JLE*J*U*KACm$~{xF(|lj~zQqD85a1u^klqN0C#MvZgG=}4cj@7)n6 zv4gD0ttzLHr4Z0-hX|e@KgjOQC(0%cD^K(J(cO2%%OUQuFA2LF;aJ%Ap+)?7H2TDn zE|y8s@$@mM>N`#v>`ZK%shLZGt}I}Ez*vj;0yGMY1f<0vAX&s z1};SDhfO0Mm%Eo6fze(YjcmqE$%gCbB3OVsEKa>*K>6_gS zg&8HM#wSO4hsx_jJTxyvED9!vai<@>7(*_!5%O5PqCDp>cH?2+ED<3VZY;dBsidiX zRK)P%rMevkChN&dBOTt|dpVhK1MeI{&hjv7NGgOYN0%lRqsrvrrnsG=`&~(|gpDz( zc`HZ#$^EF;KF2!8R&4Eko2nkWaG#M#*)j-k0wX3aU0PHU0v`T|YK%)Bhu-BAOgEt= z#D_jF;3hOmx!B8qSxQaHDfNV+$q}Ao&x?xJBPfs?+QXo`f&6&DzC6p+%`Ch)o#UBG z!vn8iybn`6XbJRsEL{`Qe9c13s9-dJI6-TbDi&7~+fu{$7*0R(Ncvh%)v0M$OQdZ$ zjeS_L-=1qx1(OPiav%Lq4RLz!r+7yr&vo2+d}5U5-8Np&Y)4tc)0<&a`v(g;lABS= z5k*oZ^yM_gbe+K%A!kXp(#HJz?(B7ge@Hy*@(jradS5yZF^5?{b@!D=JW@xRHJr@| zNi^k{Mhi-<2rA&#&^2kTF+W0>L z`!8Y9AxCc4GD=bxKD9q;`Fduiw}+wA4X<1l;~9@XeE!>LB5#^D4RTi%D<;03>E_6a zRD{aib(LJDLR9+t`(UKVELH1gE~0Z{_fGaG!K{HVN(B-(eGV;4I9goF?JpUYvu#a7 z6=~#hQFKKy>J&-S)=g%Q?c^3Ltz@T;Ob!!I z7OM?C8x{OHzgFXLdjg433_h%!})CMb>t@W;F8Bkd&PQl)UMYglb zXgc_zY_?l3<LE#aaS^NqI=6dI~nbh2R*fT>%r zd`s#IBAa89T&;ZQ?dsIY2{e$WFw&a-K_PdVLHRMM=sI9`b%kJ$K5Mlf&2}{7A`h@2 z%}rfAl7QJUXfxK1^D?V(jr9Tb&#IGkpq?_1px_#!bk&q8j9&7p2KJ3XbsSn45Xyjn zCqC4Vj!kx?Ly^VO$?evt+epv;iOJ;R4M^^x^K2zFL3c?n%)6(4WpQl1ylF+J=#us zUIfLcie(a1owGGeTTu1OK3VRBy2VqfOlJNoAh7Kq;TumN%7W4_*F;|Y{2I^vG^h4S zn3hRsXe61qh?vm+?cB+oJhSHvmt~}wa6ME&fNtMQAmGOV0ss6XfFnu_UAD-0Nw1&X znZ6P*mt3g&vHww4lPU-E;(6SK;A`TIevikRRaB!YU8(&1_cMKvDA*@k8wf9UB89a*RNAM1mq^J0l_o}3n-YPLJAFoq%ov!5n4HGs9xvA zlG-Y$$azv~Pj$jQ#al}uPq(5fnUQKHwZfEreRfd=AqSgbo_1?zQfF+7i=xj9^V|$I&`s^nv$W!Bepg$vPL_~OS;0JVv0S}%^O^rb$JR2Yf@%0S* zZ4=>A@K_ke!H4aT1vkXyy?VhfSNd&ssz0q2-|&$FLG91BOoEndju6ZF*~c9LW>BFc zkSj}vBtbC`D>z^}NyQwPN{S4mIs~0`1AVvOp6csQ63*5$TI% z&;OC?{?`X*+MwLq#`GZcfBpZz{u<{4Ol$WsZLfbSDB!#P&mY+~QsANNseb-96GZkN z2?IT$b)7ZKYm6~V3;Q1l?VNA_Xw*!iKRYMh=zqQeK(8=h>YEtVTB+e>)D&k#V2l-T z+7;y&pkcH{>7aBsd9mZ*+>;=?@?Iglug85dBMJQ_;q7|ieJpky8E-(qhZ|4OZv4Z> z*lU+aa-Nh>EpE!&Kb_&9_Ad@34}Ay!I6M`^rMgqMD}Mg_Wh6bteDFrU2EDsOAU6I* z*Kg1U(Q76*x$!7;JVu2OT#zMyEPDs3z@=pUopSmGel~`Su=^3~y+w@R-}?3+NR%1g zw=*@hRv}Omo=UmHq|4X+B5!jIO5$Eacc1=vvjCfAcvGv6y9X2tHtx0a;6v zJ^k{8`q*P3{OO;c`PY8~EYxMi93#Gx7T70iVNMWb?>u^=Kk%@^;GVF#-Dk$QK#X?M zi>J)L+Y0R}KmO+}(_I0O84fp{bXF51JVhCJt#XVTD#bw0=(XG1JMXbhvzNV?X7@Co zo_~7z->=I;f1UPN$Oa=OF;G1B_o_B0i!_*Q>%lV=>c`f(2X^NC&wFmq1boGJQUyjj z;f!%xl+bL+X-@spC%}8D2V@Nu9(yya!MoS|$p83}U-op;gA0TIUGYqUW2*C-th|=z z%4LQwD_Fl{)MxFhz#qG|dk!iLkE_c}nrc6nKjN7uDq{%<-C)NQgH`*UxnI#fi9GP> z2wEyo>|mXBd*1FNmL59y?>96|)*OusGAI;^fmq2h_>M>UHLfYx_e9)s1{IKo7e5^` zg&b2(6f^gi6@TPx`FYOhZx%Se;`#x8%$lE_wze$-R z-k&DBf;)M*K6H-zP+XPFKA%zZ&1DqsLj_`XX3U9c_==yXO=ctgXz)?6;A6u7n!v%z z43;$;Xx$ZbUuMuvUT}gP;tAVNc8^M(n9KuM0=`uz+Asp=u@UI994%adqRV%%nWHMl z6z>#Bg3!{3`a(cUw#*r9<<1!Mj_E;p78X0FrQ0w zd-jh)mJ0ZzKM_*dp_ zaO$+3WWVVGIlGqk!*3pxqb2SdV8zY=mWvapj+Z?Hl@=ETq&Mh2y#=-MvSyCzBc+bQ z54ZJt#W${7ecEO@5M2(-A)*)WdO5LLO}}fI$-by~9?|fz4MHXqU{$r`$~`NbWx2S+ zM#pBX^G>Ug$50~se?(*SN@_)!fvLu(KModRI|58yC$LRsL{*+yyWjEV>sQTodQb~3 z-(2~HS%>ns6~OhAX3QtEm;lugYd|o0j|PM^0Fm)meLmFT260dbrxm7eDS^C(ep>i0 z`C*b^7KDsFKoJ()(b;4*-|aDRJaIW50532KR~v%sL-7|=ghq!e>6{Q31`UjyhKN&q{k0>}i%0RGcFJp`OWC*U7ck7SCB zp@X^SK)IK!QUPtUXuh(nMDlXe*5=0^BSO!CqcsOyA=IMK$6~FzYu9la;Y#cj#~|Y} z1AIYe;Jvq{6vT)Rf@YFS`|YVnuSrlDLo+xIs^0)7dj_;kHk?lb_J0Do`4`px4vYYw z72T0-&}{Sq#F1m5v%gq+7pk_8wIVL;v7t$S1$L8qmr}FOZ_p_?b8&X`9c^5VNuLh` zKnV)vT+PZ%fWIw@j-2F$($!+)-)@|~*Jw6>!SrG8A=G6{XeZS9O?>CeiS2<=r7A}= ztQ(maRRfBVIr!(Z&ZJc=$0guF9Z;*a3=FRQlVax0zgQzlewcQITf`kr`GOUqZwgKDskV38CdtN0qD;= zW0%&?*i6Cs`C3^rFcEJp-EsmP5e0GH=L z3isoTAKnU94&WCutxg&6IrAUb)34rgo6%_~dI@zPd7oFc)9@pGINw9<`QuGR1|F>Y zt-A?7DV;N&WU?%&zm!yUuWmK*KC~l^sYeewU$FKeoA_Po$((2MKSgpqWtNP~?rVj@PUEn`zOaW78F;5FPbWc0 zm<9^^AFf3aM!xQawg6|?k z4-XkQQ7pIK_=3qL3r(yz*nGW7SjC3)v%*&K7fEg+wsse1YV{uB;H&3h2hR?R+DljN z&kea7MSh@Qtl6AI6K$QI_eH@iPq__IPTuao~k z`x*&M7(##|6gmNJP$f5=E8#T^reRiy5X~wqXqyD4`}K_sONiwJ4@y$5G*8BTkU`az zONQWpsakc1=pc-jxw9H-;@ZxL=k^JHz%Y&#L=*^Of6EVREP5K_x1?>(J}W5W_ur0nL~0hal*c)Rt%jvo8*BpIwwABRv_FS-~vr=v&X;_=a2IFK#Ded2 zp?f2LNG>e#vPLyF1<}IpFmr&g+x;2;g^`@wWQ$G!rQl~bMD2)F+G3(;dXgWK$q5rp z@4k(jI^`;JNwSz|H+=noO(tr;5?YxwvjjGA+s%;z{%n4zUAzDePd=K!^&1tqAQm1- zexyJF`%xwrgmXNhLO&}oCTR(FnXU&{)_fCNOfsA%jg?==3Ac`2B!{%k&4MNhsYqdN z6@I&S*>{DCWd56|)U_F0H`aGCIT{W0qcuGw*cs!o4UD=Zc!=p|p1Tts1ILNktG~C- z#?5LyP#F#X9o%T}3U4_=b)ioCm8v#tk$7|hfNtX!aP0s#AxcgHWiuVx=DTDhW1-qS zwA4hJK<}!~We%TCg~TzsPsOo?((ofhPzNOP1*)EaUY~IHBa!AZzDUB|#QwCn0`SaT z=;7+VC7)3%uu{X9a2c{+wli1Ktekes+Z!^X{%$LawCbzq6=XkvKTLGsxC?i*RG0M2 zSbKUY8!@{HCaOML)ZvG(_6h0v=ij$dd2{0SQybQ1y*j z5hh|;_&I#$7>A!F5Br%=yx&vCS=|qLQvFoCtLpw&T{89DSRSM+Jx1L(jDLm?N`r% zs84fgaoo1sfU$}y?^RG_!9jb{*@5cuRjcTzp~pX4qqB=++Dkd6QLWdspYKXknR)^f z-J9d7qyyBol*03V1*aY)wj&Y2se-^nKl_Bec1>D9P)Ew_Msk6>#ab5Fal8UOe{es6 zHY5&1#5yyB9aInp(7B*61RMV6cy|kgR=f^w6$KXAph2Qlx>es=hnkJPV}6Cmwz4Ej ziy}uA(769-b#{a9M51;JLdSUkiYV zXCjY(B$5_Hkji&}fyyPRvl!vU&jdU85ODqRLavFZizi2(l-+nPO{g4k1cL7oQj487 zW=ru^Sp_&Ay94Mwt((1wOhxryeghNkMGAPDQh$fugN(f~Yj11EBMJ{V=>Ph;JQd)` zJlbqJ-$O!gtdA99-5yEI$vi}0Kc!7ZAMKuum0|W_=Yy4=W8nUu!7Nh$c74GTTAeAty@u%3 zRZ8A~>oY;Nig0!LuZJD`#iTAoot}pl28_vl z552({u>+3Y3DG=qBI;pfM1zti-gMo0b$kTg31hysmy0LT>Ku`C@YakSm0h8wCei1Z zvRTx)u{5wd%{yCfwRvBPP0T*?4N*3Ezy)huV*9;;IaUesjWjsdffoaf%KIG^dibLa z79xW*jb>+%s{c~A0g_NdD@t9dj6t|W$MbvO0S6~}iF^P;&Dtl=*Y~6rvzY7HN~m77 z93ZmAJc`-QH(y=z9@(Piq5dti2&Q@NH3m%Bkj`Sdw=2HQ-Xie2xtH*EJJGCeVJ0~ta zW{v7>oU_sP*M+xv-l@Dg5ikok`g5UdCGS7B0>53tc_T@UKK&gGGsF{yT+;M)eFdkd zYRDVwUUijjaUL~!{F^!}E?ct#Ogia;8uP4rc_A?n5l+rkLb)GW4z=F<%q%|Fspc4+ zinfCq%F@aa3K6;H&6}jKRS94-DP4a41?~8_6xHIYbbPa?|1oNwD-R$GB zT_ZPR+vi8H*>eD*m2|?A z@A7VdW5cjuQRBFsSoy=Cb9Q+pl-Gx*HjO3vrQw@7p?4(R&8E&KhiOk#^ZnBF9c_wX z=>Bzvi4L0bb!UPKgO72gPLAZP7~gFqM54eMc-E4N={&w^V_+Wv9P^IM>b=M3V7I|!3909#~sAZ=!vDm(ioU5$rhj|a#yGY8e zAuK*d2Z$m>bnOv7s*zd252Q3(DRb(;L7G7qwLG$5;|z1IJm?y=3q4j08ru~k^BYc{ z0|K|})BR#Wxt?IKJJ!F%h!!gStTCKZ{bdQI_v$+ zI|5@9MA2;f>qcf`!`EM&+|zi}o$}Av-AfNXK$#&yjI%Pz03{uw!nSyw;snJ)D(Sj^ zSCs(u1t3CJuiirip0Mu^(ib?EQAj`7#f^z=GzRtYFX0NITLpKcpT+HA!7mvp4(AF>73?o0686SF{i&$R z=AjiWyHcSzh?Oz}(r{QY49_|7XV$>w!0tyeVs=f;+13z&bQRORF{`M2ha)hzosM^O zg_u~Q7n-3eu*$VhAvbBic^mHgso5g1mEHoGj(ds${sdtf?@yPL4}*JRwTr*dm5P{+x) zQoJh<8?iO42G2aB)Q`3$Tsa20#&qB@r?1PdGL_Dp?414-z!iavOcK6jfE9rku+J*> z-i5B~vd2m+BLuQ`Ct&jTFXet@2%Jrb@IGVV( zijtdrtQfw`k^q-M!S3O+l;9P0l7?b^fNwA$<8*KXuy|5RQRacNh(@^_DoI;V|E3^y z40{M?9^f%I>45v;OYk|VvbTbaY>SF!8ou#{Le6W=DLSnjg7?c(q!h~MT7B$@CZ)ZJ zf0_ldvkcX8vj)WvIWSpLG3ZJ1V-s3PMoh5Wbr^n0eDJdu>c&WzyWfGQLnWUFCE`uk zwLrjV-TPt6O)d#H*^D3p5v(hOCyW=+DMis+mPP!BGgw5#2_#IlPxQ?g*$CwcR~asJ zIXDe1Uda2DE+7o#v@<}Mda#^lf++7kFbH{j^J{MrxzEHci&BNGMgKl0)~ePKHSnX% zGX^VB3lfhG>J3n5IoU+SflDf8OJRn*H-mUV&2-o=zsNFa#GLYCT- zIY*w?^xltE(;^{3{y~i-oze0#`sR$+cyn0cVR6%}2x&f*C{8WrJ(hGnyZ~a0#!wo* z0r`T)P$XXxw8Wik6$Jctg+b$;dwrna|8*Pg3b+xMxrnLeuw#|)CoFY7?C zwpoR6b3da)VGRW6BbE{yJ-UfiX3$H;TS;l8rjlzzWrlUdKIaTE%X|t&Z@{2FhMgM+ z75niFM21^b75uURKj>3eJ>)#E^FB~xTPXv_Z#^Q3|Jm)x4K=%`gWZ>c7G+27qL@G=Gty9zUm^20iDr!|AAeleRwsBkJ^>DUg# zNKto;dN>}xM!i`1)p>?|`c))pLHqNgVn-dePvftOqwbF=Z60Ya1FF%e58F4nzmD5s z{D-SoUs7VT-`x58l={Qg`5((BU;!9Qy9+KJUM0IE0VJ}ya?v-Qe}qL>cyy8Q8fxA9 z>l^*^PJjH%kv`8=^!v9*t z{PWY-S?PMgMbniDBgb{m=hjznU{CDcm9FpZg2?gE+hZU~|j^ z^}fQW-XUG^5%r(9e-dr~_OpxcJX)+Z#e!0qMv?!1xk5nHwi(ND1`Y}-;h&$+FN={E z(tcSq0A4_`t)An*ao5!VyEaZQ|M#x&EXn@vQ%Rp}wT}Q9@Sj8T&j;+uSne!S@0@i9 zzUkk|f>%HN?r5~sx;-mA^2VUa-DdN7rLD1GnMuX#s9!>Z^T9sT@0)|WOAWfl*Z%#U z{qwQABCum@3dC=HKT9;8SqS*P)Au`bE&;;<6GFmzLT#T%s&(EOmY+M)C-6-=;fqa2g#f>Egg6+ zy|JlS>X|wZ7CUGYY5S#;+3x9l(OBwbG+kw-p#7!)dE*)RxqGkr>(TK8jkoKc^_+g? z3n@uSMgN~$Na%J#teB^ulR>s40YsYbs$WLm!K;{~xae8bE+&MTYZPC`AOvC8JbV;p*zb^7;4DB`4G2k3~wF!*~t0 z^WXSf5^pyw_l|sz)G(SC!WDjZgRd&I!LoS8gt2@_DM&i+iamV2{4Brycds?D5=O%v|O3twC|YDhrcf#7z^geOVm9+U_2YW0XJ8q*iV`=7x{o3t4Bz#Vq{mNdh4hnzU zN%4F7>sjXPGHC|&ON%gr3R24<(a)UD%VN)FTpm;J8z*DPlH|EcG&73z@UP+m% zVoBzo^gb->#F_HiGV!^bm-&t~xWDYi>h+TSk^Y;}96;Yg|h_dRnw`aYp^do~(Z zL>NIU8Bl##gZ(b@i(Y~IRFQCnA&j;7qW#0C%6lx-Gp#ifwpSt>^D{He!cik%KSmlE z_ZV=mOY@A?o)3JRsEI0=LqW4wB$P%cXWxBk6%O$ z#Bi0*jqF`K^=fS&KMq^34%%@%ua-yQNc&E#l*+ptTd zed2c-AToRvGV-(O?$x%7OuO&TrRPI=(S$IY&YcH*u#&9D^csk$QZo0cZi!Quk}=bt zZ|`Z+%sDLU>;`v#MJ1-pb>Eqzq7!=Ge$w(@vu~|^_uaqmWB**}>^)eu&6&)G?52z} zgF;skx70Gabx^@|k0UvcR~~7s$P<#AMdFc$(ayW*vn(FRCU4h zr&g>?@p(QmwI`Tr_k8`N-D#of4*z#Xjh1fSDtr7Z_gKn}n7CRs%|`hwAmj5tDDIV9 zy0SMkDMd-U&-v0Ad-w?qcxpSa{kMGlhltLoD~VV5fJSv+!%pX%^}$XnePXD5a8W#z z;@;|J+7l8X_j%3#hpzXIr}}^Y$4k^fa;&V2}gBXFZ>f>$>jOeS|-@{Ivb!O#%bkixAOgeS5~1 zefu#CWy&RyH+MUYK6~<6dm7&AihkK=)lj0S^Su2*k)Ra8>g=2iL4Lmq^{rM>66=`O zOSf$UJYLkezU@9wm_DNOqgLR-871!c$ie?m4-RMu!0Rz5BfyS5W8^~+>=8rxO?&0k zw>wj;oVnE(85b~v^iEx4GNYEF1^fd+m-(1T0`neQ1JB$`;F@Z zO#NM#^{59va(2Dsul?~}#8}Q}Z|PgUL`+yIkI^>|4IUQC5Bc2JbEy7R2>*FOlksAM zT+A|JIRfXW^xoxusr`_Up0PPpn^x^HD6`}#N%1~Y@R`-J@{dnCex*9ijWKsVv{$B* zRP#UIb=z^Wym7YO&8<$#Ka1g6-^XI@WJ+QxS?g!o?57TuKoiv#9!arpFhzkSofrj zUw;1MmQdN#Tbxa8nuK>Oz2jE0J-!AD^%aF~<}ZJUkx)sbmgOvrgWAA$v3lbxare4I zKaui+Z^}dJ2N`ScfzO<_Nd2s9WAk?YLg*};@q-87JDJwo{IUX?31v`yS9z`s7M-@N z=UCrSCPqs%&}zx$tF2LLc_TxwQ-tfz= z7RJ0BdzZYp;wMX_piwL9lNJTv{$9@kx~@*xHmuECfU!ZxqQUjxD_Y&ovI&;H?co~ZnC z<-cEu$)ne>`_9wIQnSlh+3}ra!-Q zWEJ`z*~smK(BJ%+`AEgf(~vsd2lm?~)*lXr9=uSoh&KuK3EGUGJ*X9LRn1##TEF%t zSln!&a>p85eR1XElIz~n#_IS(TEDwgI))WMbBEyV=fjG&56mhu9bulbwSuwa`?)U2 zLr5u6HWv^CL3<*Xq0ik{HoZCbfeF+FEXB}wgxO_4Kg86vn!$Ywu61VFd=ld=8UxmXHhd zu&(?k-2wzr)+h?w>aSNG&S!lw0E&tW1S||C@Lp?pw)N@mU1~0adzbTfY#Ey1Z=XiN z&UZSKurKS&$L=4_=yw7TQCA5sjc3jS@xh0EJxfNZ1^w?M-(MP&@ovPQmoogIM=`m7 z$f#pwyrExb2Z$xQIoFmYST3%@%Iza`-(&N_5UsQbOM;i+jNOYv*_aE;bN-s^64wQ` zz_qBk+x(YY=;v1fn*-wIYnoIkr1|nc$Dz~(A~)dhEN;A4jbzGh5W44>dtCJ%TvsC^ zsJY(U&$N<2Eh%EsoK6Nbp=CwtBVhy7Cod4)4`wZ4p<#nA zb3C7Q!q#nM@6HgXJK;I(^j^6Xcn!OkCfX4K5!h+_TfZ6)pb4q3nCr`*?F-m@$q!BO!cL6BXEiQh;Kh=KmW&a)br>Dknq#oh!8*hYzF??&6 z;Pk2PqJ}DndvPUCLO5iIk-+8W`XfWqcd^f#fS8Em72-pCrQE?}2x3n5hz5(Bz-gk) z?U1F!h3l{E(rz$*Y5@)Q%r1(vV;;T$-Dz>BwZzml5apiTF8BpykjJ76;IZt>8bOe9N+&#cf$f^ zsJGw2US3TKn{UKP^SPH4MK_GW)<8Vu2(j}5ovDuP6+i58`C>RthQMZGfrino)8K_^ zch@^Fh@&V41MfH0*O^tr&Nl%8X=WEL>RQmT-X6N-xuXrXvmzhuFWd|cEvXwiYB^Yv zt8REqo!PBx%o!HF&GoyG`N5&Tiq~^g^;CUqh3oRtgkte-y&yIvHba%YTvOOpB2Nd- zfHL*omE-k_K^B_yP8>ICs>pbKXZC>m_~!^mt5L-2A3GRJj-y~d-@ryU{?cdc-mwK>?;}pyHtE$ztx+jlP_=s-V(D9#}zy;cbpE;5hhm>K%9F9zMe~0FGO>bMSMJJ z&wPhtp$r0Js$J0@=&LO+C7s!&7^l5<_-2fd{?of;nM6ohrVfLnxny^bGleV72hWnUiq zGs)K&L(bbecQzNyfv&`d9S@Gze2SGyC%9*y0rI_CfYt?Ll4GJOq&HgBNy4JC%)F@j ze2*z|pWW(!)oi|DLjXtm7*3-vcAcN#KI|-$W0(f-mZaESntYDiUvK}4ri;lY^oVbx z;OtLRsb9dr_aAJ`MQCpgxkG7F*^*Qb>K~ZP!v~oaerQ}`(z34Imn#)+=@IEijn2_A zN7Ig;e**SWr2uME$vZk4!+p!Il5@(RpYZa77JL%oodA1zzm6kkZ`*=<)LxOr2i969 z`m-k%8#W6nmx>W@X*Mm|LF}{Q+yP8VD-*feMd>MLDIHO}=^>;tqm*lF27Vg%IgjD@ z?_*jcCthP(OPmxAaH!NUlAM+9YMqDn*w?+TVIW#?qPS2gA8@KvKN8FWOKhHw9Mbqn^OA8ve|1vG1d4igmxPW4InT zq9C4i)DvwpJsEn!@+Ya@ROgYb9!vX@>U&~MSUBiB7=2iKB%w$B9yvFkc6Bphs)~pZ zoT8YA{rB_}+MYGAV4qADjb&!)R{?9%3Hb*Z>tRde$By=VGZS6LUYVo|zVMtys@qBC z(P{Z-2Q7hG5{mg!rho`_!Y@=wES|9t{L;%`i`5Tno#9@+KzYF|N>r{uv2_WlRyo^J zxywn~*`6q=}Z#yG2HC8=M@kRvZY3XP^mO|7iW?TFmLwdcV1|-bPA? zh%VN$SJt$M`^Nn=ct0)VvcWwv`JqnEA&I%y?$UMtkk3lT@B7O#K2(Cav(0|GFXV{t zaF^cJpV=yw1JOh1bw9Zh;{%%tje9-jM9fF9eU2$>TL?thWa#rQ#F2I<6lVL3PP|!z z=D~g7J>;gn>~24@i%|Mk}N)CY9yi zpZN*@y6j$**5|uD@8;n|JL!R7Qj_-=2wY%VR$wTz{|Pd~MN1cTU)vEFvmb%aZH_347QscX)ZvQ)uu=OL!1xAE zxTn{^Mi$?~Ywqg7L3Pl@|N2bYNWH`AQ!hcD-~4EGCDJQE4`p%=0AFXztfaBvE2MMNmMWNjCo%BJM*2N1E_5Zp(2`r*VCUAFFc`6 z+{Ha+`8M?@x7Y>3Fo&QSUjQC?$GgZAf3N3%zfBB3@)q9C0_EHWnr`RAN9_^v^pY|{ z-qR^}_~M}}Md5cM`fDiL(ZtODhXjzID9eyyT+_C=n$8!fx=J* zt$Ck+0*oO2gJrhM*1!S2x}{rZIUOZ=;=JG4wW+t+kOVrK4I+u;l}B9p!`B&&@51b~ zr0i#)`O@ODX%A!=k$XD`6)gQn4Dbht_Z9!)wNN_sDC_zNNDBNUb5{#`e?nYa zBbwl*fgZc3jq|e+Je2N;EgMoIj}p=>9OnZ@wA-sIk8V`hJL&!JyHbNlj;$|Gr>sll z7Fx^aa-Mb%5+l2nO?lqPZy#=qKCn@%tum;?f~M8nEaCIHa)TMRYbPkG9Js*qIG7u* zDtBRaw&NXp84uk~Rl^5R((E3T+3H3T4ldXTZGn!Y-0eTWmb4;5(YGU9To~OVTRhHN z@$(}Q^CC_iAiQ|Ei!|%ayAWQtj;O{hnAW?AYp@SVQAuR3M9|ve8?}LiW9haD7G~`O zy3`4Y+SB}KgKLsbOe*h}VW`sK%XE(`QbzGKoq#=#X9**nKx(!{?v1UkP zB+7~S#T-X&;8YZ9OuZq_#iNJm*WCfUe6*F_DHiL|SYWKWUAHa&a*~|6p!WNnj0BP< z(bq_(BAd{+-~qx$GKX%=06JPL|2nS_SmjkkYnsK_z%6c42`U*6;WkIjuerc5(?1{b z_N*0<1UDT%0Xp%>eZfu@4#Kd^Mn6LwNj32eO29T!(l3&Y>)i1Cdf6=6B(Zg%!>}t! zFxlXdhX_haD_uhFKUI#D5Ua|bkapvZu}p&h@4M!vN~*4s|4zRxv#&eaqUF7h8hE!Z z=qiwN>MwA}eS9g;Pv1t7%%Y`^1e>aB6R8{!Pk|J_9f42o{TG%UL`!Kua!OWN(nV&P2hCKbB)7OZs z9Oq!8EtNWh8hd%supYZRw-iKmTSo_A^NuefK8i5N;&dH?BNc1Pd^Y4#S6+jRY{Z?< zX%o#&+L98LNMatyI;JL?Af{&wOBLaB zL`sLAq^v*Y0(6fdNH{h`0#`j!=0boTS9R4qlc04=RdnCU1^Iklkg;7Efbq~8G>`mM z>OT#`H-Snr2ykW|%%i19S%eJgssS;xb?E8`p^PlTbDMwGXGgNVua7^03q^nN{6>RF z$2W)sFUItBfPhQH9_^TO;Ujn`3PH?~6?9-V60r+aO@1i(uWpIj8AIXBb4Q z{@HgDQNe*5<(3-55|YWY2sx$>>e*mqRtoj0+r7aP84{J_x!+W#-jzCujb3|)gt2x#NTsb5_mVV$<7!>c6jR{)UU(zW+YW+e}K42OF zaSB|~^Aq8|JQ=>@5w4aN!b$*BD#GIZ3z}*Ah4u{ zykqxhzrs{dlTt?J&^mQI7UEaLVUl`~GcM@+1eZn-WKDKxIzKdt!xyQc7u_C(5I(!Z zw}+v0D4fv+hr&I(i#45%l7|UX4g4q4p;ggWy1&|PTtc*&(BW;=-J|%QcfGxW z#7^L@49aYWQZ@JV>ue-!{yqy*xp0|Hl?~tZY5%?@R?Zx*GHki#2u8oRzbzd)N@V)6 z*u-m~Ku_qCP0^c^HPVG?gT#>tTSqdT`D^%COjnDZTIJDBbGXro5|`<1eJtxuMXQC<%3Nj6b~>Y7ajK4t{%7M=+jFkrX9iGUWX3zgE8h0U5d6P8a*~@?CsMz8d)LJ z3V8b0tD4AEuJR?{&DTImd592lYi^m!t^e-AUShZOOMEkz@M^g#8Lj6~gqcvI5}LHA zYnVd(U!V*O)<6O+HTTV+ac@UNg#F;Ee*F0qGj2nPRJ5H2rDM7(fMsLO_YdEL$e4D8t3T`Qr^df3wyWuwOPx7ZM0luo$$n09I`KT5tJ;pUd9tIgNB4;*UuQ<%zRm@gZ0 zw=W7i-x$T+gObldm5oo-$TJT|FpnRV*AEq;#S2@n+jful`u;j242e^T8E&-saV_~! zxkGPBv(p)OJ(#d*QrBtz*!}f!ftdXIRPMA)zJrQz54+{ryHe|+Fqzkq^nO-1og^>u zTENC`Fc}?%*M@r57OK`zMc+%>`$E%USDFGb>ebDoCDdxM*K)X;8UG9Qi#-WAl0rLA zuJga(%w&jIiHB>!#kb18pDd~ytl$|_yq^jk^eKO))1MDo@S#xOPJ811`)2*8{X_ad zObV`CUaM*9+)v~4Kb_&92Wt}G0-`@N3y$Xh{k^~b+F}YXa*Z;l;{TYBs}Lu3@;ep( z2YRy>FILc)0C~<=5#lvb!CjcVh z1o-=US`iy#d*6jWuk_ys4q~Yi+17V=7x~=*d9Vf_%u=%+y4rkDySpG($aK4Hux#ph8*BhXpElB zGcGy{nI+$IFQ$ib6ob1xMSVY5f}4T4axs!hid6Ic8xU+;BRz2c3#WIk=93-E zoRWR4X-2>L6chuAQh^%0#euB=rEK9-(Wr}1hE`1XWu^=f{a>IWE$?yfhn@D z_U>IF@HAh5B}EauwO14!TAF_^XYY67_xR^=_5-S&qTaFox{N~Dz^Wnv7IMSY=IIgy z_!7Jq5syZggk8@cW57=ouurcBl^o6Hi&n-&ZBQfu*kR)pe#UJUl5yLAp0BW)9KT&J z;)gr#?ZdD5>`K1wTQ|U#tO1U7fl#?}a=hIESeq|~^gVVRH3wiev}`rB=uD4bFSIR^ z*%YR}1mBh4OLm>E(y7^*V{?refyMPAxHc5Qx^+NAAK8DV;7gDiUV)I8BDjeARwix( zu&8@8EfT^%dsGC*v9GSy5A;BbiL44Uo`_`PbEX4g=9U6wGl3_QXnq+iQ4xZKS2qBh z|%AGPs~)KsRamequQA3g_6n_qvGmcdy2 z7!KDX2Kb5(WKEE`Prq2sqa~{;gY{r6tb*27Ab-OF!xF!PS^g`#MT9kp2zQ=6`CBd{$7= zhIo}2>LFU%Kxf((%*pW`l}{c1f<>R8nXDzZN39+`9I_4z>x`{8o~?O+r*W{3fPrj4 zoHDFApm91KnU8PGbz(Ox^n4$DYKG^G%N;phjbWuw&j+nZq3KQ$&_09wYI_&K$Cgeu zgJ7)n_rf`N%uIzSbc0$YHpJLl8X*%#pWJ1|qi+2uC)%h2j+MPqzIy_ z2|--BjZoM2-`)YMg2Q0*dr9UIA?`gQRc&J!Ej`VmPkm z)b)m~K5eZ2)OmsPO)$%UZZ;H#$$0f{l%|C+isK-?ew@ zdfM&(|KBP6gjoZ5vp&sopv~t=U`|tVd(RHSpN2atJ7TdIc{>ztdPZZ^)3A%(sYJirEW@aX>kjRP@cK*Rr^U6JB*)Pj5OepOPCyc6>P#(`(03=18z_{y%piY8MbJ zIjV)+-gkvjSiP7h`iDkB0-mfearnv;G>3ngvN(mKc8y0luVo+m(2^*mQ>2`w)$EAp z&E{zfF{X_RJF~Zyap&t?Ps7(u79og02;B9>!_lgtJ97eyI9agu_>{JhbaWwKL16Jj zu+eq>sRS#Ii~pM`DS!>oqu*)Tfv91=`0;V?tGKiZa7w$_o>aKK{IXwvp0`^HeQi1W zR5Iq+aS3#?NbUjvlBEzT+H?ZamAkEdDe6UnY1o6eG>t0lKR0dOWIu10?r-<3aow#6 zb5!~b=~I#pwn2^e$7&Etm;qQPXkg>v zZNhiv{Pa9m-;iIc5KDzJr&HA{@J-iD*2wUw3T-AmzOm6IwyOnUTfP|S8IsGX^@9V^ z0&b&Kw^v8z+4mv(eaz}w;+`>>Ta!@l&@wM^+a{Vo^KytCy%f5M5*p*HnF$xnqnXx}Pepx; zTA{d)*AvkAdh#=Ef5-Of=f_7(Uam%qrz+g4YY4)-26A^AhaNTB&~vyUzUOIhBOsKX z=d4!9sS9pDqPV{KYQJQ{rj16&FqfNlCv!yjyOWfa$6Femx%d|_J2*&?+sgyXydm`w zF%1vgy(=LqvBTN17i0vwe|+B(1qMyxne_~)ar*Yb^c9HC$2|bamEh>h#3RGwF9X6^ zh0Efc1mQ&E5kg@!#}_k2iBFId(c+(ptfi#s8bc8jf0ve$^R`h7`8uqx+-XiLcy28Y z@wW>Esp%rA_luCnQ3hM5@)^koIWUu!-;=C2YNUFq$iPFPUh4ifRb0m0ju>MM{WOv^ z*!AqX|E`=*7)mPWB*QCrpw7q#mq!>#3XqN~gJY0g)kUpW{Pv+`XspXBQwals{{YDU zw4!(^V&#wAR-WI98Xvg8#KK9#`tgfWnoyP8Q>|R$t%_DV6#0d|e0AIw%z+nxPF()9 zT(;*qy^3BDEp!rbxGpz%ylq;jq+z=lQAe~2#mta3?yY#G5o@_@$?Tr3C6xCb573Tj zTiFpGP#`U%AY5g7@-VS{$|-DOG`XL>1NSbHvKo8tAIZ~z%>|{GRz&YD{_UM%TE7#d z#cXycm~ilzHO5NvOmOqKhs)5ZP~MNu*_L!_y3kRx6m*A8F<{}SVuH90God?@@u%8k z__<3KYo&ipt~*INiO=>6T@jmQdL6eWAU)YsPRrzkrSYwr(OOl@PqO!Eq*96 z0n|6Fd*#eymP$w!W4;OMp-eOa*ArXfee=&8|Mz6TOBO5MyNe_GROWXX@ZO`1QlDMg zqg1H=cOd!qaVGe`F`lN?`=7Tg+FOD?l`BWWFF)<~^I#MZSrs4g1P~(LK!iNHn)K6; z->k}HW@n)AM5gqsjH9<~q21K7d3z@g_( zxWBtq21{p##YH3=2M#IEYD=xenUTseq)nRFYhMgLAW_~uy#cpP*~rYLgOdTSEz?i&@*PAKv|bmJ{AkE=E;E_cz?k z2uzp-G5LU>Z()#6x zD}B#zh+6ifOIOW1dG^?WQxyljQX*gHt}yI~7ort8Rxcr@R6_ujd;WkJx?-dwgxnnb zhTWTir(IT@5xDKS?6mX@c{9uvrN|_;a)AnA=vD1O6}q&HWY_gi^EZ1n!6r-9s63RSLY zdH(&ANOw*9A4o$4j5KJN_)QP-SWR`rFBL)0u`5uy-T8OyV&*|4Z}CsAr12hn zVC`t*mT=!=-LNKD08U^jtexLfc4R(Bc%B6i-Di8FKv!@JOM*WAZdgqbVzLH#K%WI& z9+CUt_`HWAvIzX&moRiaNg_8!w(mHMeymd=10@LiVWsZa`tV(+5j%g?LTHs6rHk!2 zMotpp6$K>mQ3zT?R2DmVV_O{Ovf*W9Yxmr6?6o&X%i9ynl#P#odPLW?41>$YbkrrT zWFDy^e|++tA}>;5ZwAGO+VFn%U7w7}zCQ>!jY_^(pZbnR1ON9$%&dk5Ltpk*qyZ88 zLZIci@DMN_u7h!gULmd493Vxjgz-UqKsoj-$;CfXHsX@#p69p6ngYY8@CK9K4HT33 zKwae||(s%zl_eEa~C>TbE z!!vDuALst=7`9YP@AeVQVg76X2#qo0Px+(XpFWtc4dA1}2-0xq6AAbG0UVc*%98J9RDsJ5M|y50Sg)%h9_ zP>M8O6fZO$ii3D|mqPrD+qR|T#ZzQTcam+v>-3N#B zv|pmZxUN8#`H$JY(>tB558uA{gMu%KkW9%gB^n^@Jz{x7%cm&gK{GBNbKdOSF2 zweNK((j+C{+kz>x^BqTwuhI@2>X)n7u*n@>8U#<^3tIRPy(1wr)+U^g(a%fW?Y?P) zv|G2p5dg>E;Zt6u<77?nRQ$Lg-5G+Dk84xjM$$3eYd%S1u1@3##$ju3E9p{_7=KN_ zYE!t($Del8Vq^d6XHo^w7;H1xR&xpK$+c91y`O2W59ySg3YFBM#-tk0-9aF0TEL!( zAkML*Gn4aD3(SdCw-I1z(vtQ#n+3hZlsD~l08*OHOcstOM}-O8QB~PouLS)UTT@`< z9l|%Nj0;g$5faPlF|tx0HIfANaC~ZB3oT)KYY3HF-QQds3e09HGgP5ZJYi_fLOm~$ zj2m@`y8B6v{VB5H^!`8OUVnKgCI0d{#=nVRVtm*rcRK7Nia_;rJ8Gz5*Y z{AHw&G?8-ULbIHsBCa%v5z9b#Ws%VWNRc;H%Pztv? zjAktT>8Z}Daa_?@OWB)O5eXJsr5E!jzI5mduc^eU?&Djq*N!^<4+h>56e9CxWCaUz z-v+I?1{8&zJFxqzrr!cM(~34Ja87X2Zu|(Rp?dJ$8>`x{-!ml;(sQmWR`J7!Kgldg z5g<4rFD1W$K6$ch`ex!M$F|mith94qCRR@=dgw705RBiSo`;M=C(dV3{NASEdfajAI+?jDt$64P-e{p5mX`W~y&?r=Ti1IbEX zCb8-wg?vPeCnn;Gaocl2ncddz@9Ua5h7k|gu6D(!NuK#8!n6~cuR4a{T);r!e*Jdq<-~xNBhr2gZu;|;vFVz35lC% z)mZY13{q2QTdkqF;3Y2l@&u8L(725q&Y(9_9{|VTFybtFMS62Oc}Dq*vKns|te}L< zU#Xa;k1#PSD%-&g&Afw(t6g>UkEZhr4#pu_oD zcBRRT+*z`&U0(oHsTi120|;I$iK30Cdw>N5#fLq_s=}*M^Ka99%d+;)nkaIDxf}4F zhpYg_YsraJq9XY1$E&*VNZ$SBzj*fViy0;gN^EXCUx_Frzt8$ve(72>tjDY&63#bH z;yWm^6q^)ylD+`H;U>bT#OEFMk?lNlch4~PLT6(RrVwiE_L0a;WRKil2mT(F@xuiN zJC*;wL6amgnPlEp3UyHrpr-M*ojQsbL@z=Q;rg7q$`FzRkmJSucFQlKEs2LW90TlHx{V`EBHArXODBK47L z;p;KPEXv{Y0`OX0>T#L08v+OjdokK~~01VMPerzWwr=t}yKBzgKaYO0f>+vP$f+ z^hEsLX8t>sk>Vj&VRYwBkWg{pA$Z(sdk`0V3nkOVvtUHa3l`}(F-Sb_Xn45rRY zL>mxDItUw(Q`Oehp4dcE*$75kZG_At-Pe2t9#HSX9s09J&nH=`g&SP&CU7A_iTJ*L zjuhQ-LvalNlsk9Z5y>O0giQ}}YSc&@n1i5ujgp-XK zaCw%jeK0=bU+M$*k)sDFi?6q}R;_4j@v#p5r1-0l35x)c)RvwQ5$7yG&XSRtZ{X@G}@b_EM)wa(iLhx^bj zNkyjIo}&CL$S6waLvhqMl(S}ForqSy_3sImnu%rx8PHwIH||V-5Q=+8uRAe8z&hGbl1I6w5AzyIY01@-9PPnsaLfSqBH+G`8_L?6>GvOz zbDowWaA=uqTG0)rsz*=Y8<9-Nrmtehydqak1+Mc5`n*ge&7uSzt&E=*EHhk!%S;5D z5(6zXD8^OtmccSJDA1?wkncD=+0^bHrUQ?V`uKUA@qqH;qxBgp#jRbh;tKOIy>A~D zl}Fk=&A}OGPLdR>Hx7&{MfZX4Fi)-0_QS>mF@-;T?%iO(R%Kq;X!qpiqUK}#35{0~81M6uH;oANxxgX%CF1Z*Q~wgSMs6t5dOXWU zZBv&%2yK4Vd|c23b~BTLhV>#SY3<@+LA$0CqI-6Pvl6Ey(2{uGFB-#HNXz;rUr6(H0XQw$38UC^k*EkpPHeHtHcB6=T zi>jnPWxXjfTBvS@&a>z<)S)l>aG|@h1}D_lfiXCE@9+`fFeVg%c;0JE28-Zk-QE4I zPoHU(P6dXjyoMN&A+ z(RRs=jg#59GG=7obPH;BEC@6345f9O+i~z|B^N%w0j}FAmlxXUZr=UJ1Dch%jk{>& z?O&;QCBhs1#HgCQFe(59yyJ22Y;xC10Nthv4wt_WMth6syA&C-ux`Ea(r|h5PypYG zxEWm-?!(Cl+A8yUKU`fG$+x()ib<&bi8X58zK)j*VblIA>`nOVhKMF$2&ls^Dlhl{ zz2E+68eks|igkT9`a%LiVYQVF4DC7Eiyi}Ta)FiwF%i5S#`WPb01Sh#4G^&Vv)gr$kmlt+_{iuGP zC2Y|-hPh;g&FcmHR38KDuI7>-bDMLF&@DI&BM8G$7hD#!w=5(|ysaTdHE9vTgg@(! zg)o0$Vwh+Q5HNbAFJbMgwW1;k{qDi7dYk)GROX|``XE-+ zxjMNGA-5HSN;m-Z7R1@pu?1)^x!sF8z73y7S;MV%$~^LSXpe&i^h zByR5#Iy#O`#OgMHURFYpfXo3uSvp_04ValJl#PY<= zfHwFru7mL^!BNpR_lB6prj@L@J`g8QjdGTVOBp5Sg|Cf1zUaKRtIgv>vQKA4+bczK z4!m}!v3gi7xH2qA_s5C+QL+)~X7v+hyV0eQf#lus^%V+(JWsxbh>ha@3W&{^i2Bs%K9Zfu(OIYxUbcJ zl`>?$NModuzTN1FGE6(}TH;yZ38}-m4vw}JHKA)2jS1zm|+NS?uS^eL|v;FB;*pVZUU3iIcaC4B9xAgGe3FA&d&D4Gt-jL zcR@R=QsqZ5Xdqs3mKiBR-JF#;Fz^0PzYDTj#Q#XjvHfcy>I5)fUHPNX(Mfur1Da`prVPHPa`u5$w+AIv3xq~Z5WA++} zn8p9xG-9wA!U1}Y-V8IZ`Om96c=D_S_-Bp%4Ga7K`OiQ4M?GK5Ue5A7br*|Bg45@M zIhGGTAGXO=C-pk%x{!jl0*;gHW2^-}Ie9n_$n@~@@bk2<=#k>!hlHT8Hhsj^#4_y+c?( z|6ouwfXy+&Z)tC7u`||qkoSyj@0q;3d=VZQD}olDJ9jRBg|LMPEq3TIb~-kyPs+aV z;6I}c=7C7RU_6if4)BYEB6ePjj9Eye!Wz9y<)WZ zIrEwiSTEy!V{YxKI`u_f99|V#F7HrXLgO9B3)nwjCH&znadM#2m6MVe=Vsn<>f0JW ze@@IMR>)X1dWr!43-WIM`6CGVCk{etqMtu!7WUrQaM}eSq4l@5FaEAk0_Qkk#eMoN z9QgJY3^wnfp)^A%2O_X83l?IQl{olq63p;>{8aIt_#vNf)F~{)YZC%4ocRpv{piZa zV2f9~v@IEMoF)2mGmR5K3rO}%Q*)5NA?5EE6s-d9`klxZU9LZG`}cQ+^1FuN?OD|0S+yde6G&JHqOjX- zzwzA&iAB$}@iIl|CDh;&K1*@&7DSWg!=Ca=^&Mwd8`vTjL*e-d{h2ij3roQ_gby_8 zxuu6Rs|bzaVSKzf!rBKWy=ke_tcDP{V=s2_beD+kjT3$oIX!n)jUA_VQO)=3+;WW1 z&-Wo_gN1GBKw>2H>+MN1bKTi6LwbQ>N-dx~!`LVEo`MG)0}Ah-mAww1zxDUMZ!Qf4 z`~yI!)2)Vez4r2GHDcvl3QRymROWS~4klob8u#kUheHa)GYh+i^m*0jB_24MwPN|bAz3RI|x>k z#1p~Qdk7G+=~5P78Sey`@CU&d_~-bosUr9W=Zb^b#qMWdKh2?;DibdZ%czI5N2ZIK zY38wH@CGl~3=bgfUw?A(Ev*ZiVOVmkR9xD-%seyDoVmmcK+%6naQb5J$cb&>RoII& zT#Y}2iZ$@Id|urLbgwpI4}aaCH;$M%9y*UjPI>UxMVfjDhcxPArR#GRYUwj;z5eOY z$p3T>&&bh<4dK47)jI+PmA9^>N#ImM&ok_0)8O@epcuG3*GpO!^~!AY;&9htHzrka zgJoXkjjPF8T>_$MeC!IFwtUDtn3|v6dq9~PMrAUT1N8T^!~^zh+umY6*rZk)hgJ6< z>j6!46M)-_jCtF+>_i)fbqIg?3H2Zny}sFHi7(J4l|gdMPw7ML{-W*3a!JKbE`BN& zPa7-3=hGt>`_O-Hl(TBZ=a$};>ndMM^y)fbWBn24|K$pjCEF<3ACkS{+@%}y{oN)t zZwgF*-dq@Axbo`Pb?q2`oqtle+Y6*P3ZyeDUts2Uelb{JBURxWt$?@6)TdFUT_VsW=1kEgm7YeUTuqgarsPvMjv7A z2q%v;h6t+-g&ZIwX&XYXKuyD+9eriS^_sVg$peBjmxggn5G7gDO^vtG(nGQ5U4XKX zP&#Yt1T3)4cZ!_XK-8hjFa7fQSXLYG%B|p|wYj8U4f!f>;IC7gcM-yV@*sACJNP_9Aghv^S!{Rvj%@a?ghy?;Z2ATgV#q+n2b?+=@N()TIKw%Inv!Cs4l{ zN=f`)GYZ3Kt(kHnA4nf7q#yDkK`7EqHX5EdROS&C@uFbCh9Aq0O73ezDQ zlCf!T>rhvJ%_y)4xD=w9%>N8D6et7&I9~e+97)s7*M1#_Q6*Tw%5>^4u$|lwJZDm1 zcASE&R?oO4hdDLf_O0_4qo;d8Ug8lJymWY*OJkXY*>{*T#6C~tD8_JJ3YF1QB&F9_ zX)--zjy57QImTR=!RI7<%H-5c1+XvY5)DJd>ahV$`0Pdg;e)E{Ma?aV{rtt@^7lA2 zo?Mc5uObQ~{Q6{U~6c)hoM-SHk=CA;Gi;L@vcpx&)FJ%^}*D!}p!AjEE7i0ODaMVoS*mkDi${gR< zC!+hUBX@LNV>HMKOmNIk<9Mf|-+qeqBIkWnRzXAqDM3rFuYjYsCDK|mv`3%CRtWY3a>oO2+__=vm zXvv2I6-8%?urAch?2$&jduhgw4xde1QU&=GlJQ{8acFX$TN-m83z27y09eQ&m+_hP zh_vff&CTGDribcdGL(ipLOthZ@hif|5GwT$GGCHd5D9-(s(k z=CPDyy#HO*<=Mx4&#a;@aD-t|-2q3`HaLe0aUX)-sq(z#MtGHv04rd{JSKxV+qN?! z5@UhICL1mJd#jOmFNpA24UG9H!2Yboh5WAR;{`s8GoGlvWBLS>cUouz7aYr2agvx( z^crF#UpIE$I(hqFHPE;Y+m`}a7HI)=zK<+>nF5MvA8&+rk#%eppexcj7S^F@;N{sJ z9z;Z3z(uft|00`46VvpwC#q+PissE39355(?MN8*8=!GH{8jd9++k6QTLay3>yn4O zeMo{l)~?8MG#Ig7q)-+qyhTw^rf~d2I0*YGS5Hh)oUeun?K|^f5H;lrQqE7S8seam4@RT z>5-4q0H?QF_eR3Eh2}9W*|p`ez1}k-E+3yq@}Dd0pz<8z2eXcTQ~lY#J1^X3v*IcU zTfSpon5fywvuyOtQhO%)ec|H^YiPcfl(XkpMmBEz*omX|ztKJ?2aWR&b>?eO5i=mTqz!Z z@y^-YI?|V_&_|y&&9XiEQf2muPN|js;Z!6-?T|_)G0(4}g+Vi9QJ9uoU9-~1hr@A5Fp$3Z*W;Ggt>!z{!5-9~!G z8Z;5tfxtOZHOFlC*_Rh@W`C#~4}gydu!Dzo#vFU3^)U$zq>2h#7}w4OPY z-I>5b=~vgS?~N7gHFG6tt{}5Oxdu7m;2ij3^FG)4X@>ZaN2<*HMyK~tm-Wv_nawil zhM@KRQ7$AoQ<|ZGvj~{))8C)*pWluge8ik;Pca2*858}_uG*$#mvluwss$i1XVucf z#5r_LdL-q-PPK-}2ZFi07hMjnxhvM&ca(m0?f>~b(X48iI;s&bO}$4(p?&6wAvR1E zU`WM(X0OO?bz&@ielzoM0594eT1h<_6)3k#pC{D*zR`n6-q9TJdlCR$`G~02gj|=x z+{_Od5^&{Nvm*S0veQZDjb$>sDwk{Kn{rm!_-scP9)38dAfAjAKM>uGFmK=NcVo!@ z{pCU9zKR{2l`q7ienM9);<%m1SVRfZ%}-v%bQ|~OP``&{g_o+kc=gOV+{h6s><0O^ zvEOqohbc$M<{%otZWlIVf;M-ZZ7skZX>p*aft!aXAKVh|=TYKVI)(N?buD+fkXnd! z5sdoEpj}vUw7T+V-a}!~Y^gfd^AG#sN(&unbhv~w`KU!FZhCzqCjwqathRS-9{a=U za=ph(10{Cj^;XYVQeoXl)Qd_*m;Ewsx=>4fe@%*C(5l-o26|g%OU! zdmv7*_EeI^rR}$IxK!9FFfF{%6r&uS^LkFLuLT6L>L={US2dhW^Eq#j<$QyM>#*HX zb8~ZPSMF0tdxr{s4JViGo+M>p5#i(XZmA8;yMsmTUY=Fv$v(WRR6!%z3)@4j=|;$n z6Dq#mRGlPwtJjr?m}{^AqUZbpCmNkA&AFPZG21--ySGhx5tkG<(PX!~5kZHr#!2OF z7BY5^q^>DEcrWTGJ$>xNsTm($ye_Mu(oXnlZow`7|H`}ac&NAc&s`RH#yoKMF=xo zGM@|Psnv2WZ-_bcL+Zd9%TU9MK%Fdz8}c$fy);OpA9~WA;<{$609=V#$!9@p_=4F} zGzSVE%dE~!T&Gd9_~Of}I8ocALStTdaC|?lpq$DE+~-jPHSi9UhYswi4ejw>Cl3U3H7)NO`5B;ieGq zMt}5j|6!x~){3)&ru(Z6R#wmVPxxpQ)f^34i;Q;pAxoc7Xa1bBdMSwayTW$<{OYNCU_X4j_pe?G z5x?)Ci!XB*nhyFGE@A}BNXTL*__FcN(W1A096;l0Ok)6z=@vLAW?kZ7&R&-MdDz+P zaOvvCJXiIuV}@UuD%;SD;iEaPSrSft65hPr@6_KpzEqgWVibx^s^7J>n{Qmj>$(j+ z@PY-5>*W+-qZKFw7mV?rPRwf%pBGSI=1Ox49AJ)~5Jf+Q^M#Qq7c%)}`FEE)qjwX4 zmkTCdxS#v~fBhEac$fmDz#1wxI{&X}T^gXv_R1hA)cq5DdOQRA9!5Xd%?_(ntrD)O9*0X^!y^7vPqRYD`H{n;^L-99 z=jQx_DO}IAP^a}g*(+uBKH9QLZ&@CU>ODK3kW8b#_&iyNv#y&RwhpUmEIDgNDx0OZ zg)X`PY?KRVc`2)`KDaz`Ym)F6Wz(r<=`Z6dNl9v@75)uhDk^K|HRi?2sCLhLokUOW zf7RY&w$}MXSY!4$W}o^Is^hw3mJ42&nB~~}s^zFbx<$gv-BXWuPLf*pTXpCLtJNFr zqXu+HC$W|yifovhYZ`l#H!Yhj{tXB16glv`)ALP9-dgsvgzg-(l9M^(9i{DN^TGUC z143~n&57psr=p~3efw!0(#o`Aa=q2_N;SQ=siZ7X}hj@as*jeAPWt6sg8Jqrm8a+3XEb~sAIPpq8%KU_LUlBaz?V$m1V9c$d0wOYSk~%XVD(G%qzOM zzqT$3-FtT0`EA_UanrxZ5ze#y2dv|Zx^<2pS31e^{Gub|b?ld<`M(tXKQN@?%KZz* zduZK?XZ!3gt^4|lkK^u=sbAZ|PS+gLy+U8H+nq;oaxMXh=KhM8y`5fa`4F^K~=7JRf_em9&$uU?dq>a zC@Q*yyw+*0>&O^q5DTB$_9h=w8!Kc~`r0>QVP61lBEC2%wg3!~e})97a-Rb(MndYt+$~uYq}_q;%E_2*#i^~F zDt!LzWLv9b@-wjrgB!%ZrHyY8EBfhI>(%pb z@L}TlTu2#sZKEOGsmr_1@`i#>#bFQdQ_lg5-Oy5|YGLlfIM6V>dvApD2Y~Ep7s#iC z<&q!~4$0%QU+Nq|gb~ zqknpuOs7Mjd2`jKwg+;xkqJs?ZA@=LnSySd!td@t@7bfS-mB{SUf8lgO=mYjEc4AX zS|=!)86C5T`aT`F^`7jL83iU=TiYBD2AA`HY>xCluORuyu#|rf>(ur|7lp}rVOxnK zGZdTQW-OSti-tI^z+*oPlz(y(!skO3fDk}`_ugbn(C0f9ASr0VK=M^^r~M9pxG$50 zO2XltJK-vENK8rP*tp*TA_4-d0c?7RAZqz3o~3OAEs@mEocsK2q$GE)^-cZtFx_omlWNH{bilCy*7`$#k1)h!@+iO&S$W{+TbGP7m zR?7B)o-7I~jlxG_RMF;B_Z00*NJVA#{s(jM?U_(2s_53EjKp8Y0OXDo>Y13`tG#Rt zYvQYi#&Z%xA>u(NQ+O`^kl8Bz9=_`!luPfnyEKmR9qV|#YJ=G|F&oROpo%lXLcy1^ z{m65Q5wU5w-_i%yh+7{bz6ETW=qVj8smVs!$N9LjerBlOl*PPcvl_GylQO5WfAD{V zF(c(^7L+&KMe8^}KL|@XAKT$jrSVFhFF|3#==V%lI^%|Dc`RbfnmMG5b2v|JJ%Rmc zCG60{@d23p)=K5qpW1Ni&x3z>CpcVL^Cy$l+XYR3efvSJIPconHr%gVbf_L7ltZVe!VhiN}zuutIPAH%dUbZiMsxil> z+fDnqhS(PR#1j-QW*c<>H1r{O4G2I6d9RNAOVbGyR~;sHEv^#rO&D*A#HJ&>Vt;<0 zxC?7GLQAaRbZeTisaPeE@ zjXHX6ea#WJC+!hRt+Wo^|Hyx>WRkHeTN3RIA=gbTJO#viGpNSMazV9D<3QC>aD^*_|QZROS>%6ZdlPs35$S2wV`GVzl|SLK@JG| zdR_p$79C9w63Kx#P|5g}AM250UhsYd<+Iy>9;#=&vzWzRPqip|1oSvj{^T=&NV&X2 zwQQjE)&xfpnN6xh63tyTh3l<$%WAVwmb21lOs;B>igHTxgnf5yw&>_eEY5YLx+<7k zbnAoXjV^*}AL8dKnM3^#`)rJxjRD#+;j;05`+^C1R5RoD&8JjtSOYj`H@K@Gi zp&3aj`5*}$a0SmISr9luI+0we;1!*8I1Aj4-$Y3oQU9^MlGZfpZhyz>5O#D!H~1Y% zxdeufE6RM6fHgJ+iRqig?g337Q9T=wyW0v+XsN-4v{X18gG%PWb$OqRE&w+$FepJq z5pEA&uaX3sCKAN7@gG+(H}6z9RItz(Ja3PjH$Wu2=OJBSfpQ#EaH@8J|L&AH63{{% zg$yHkU@R+??gZ($2E>d*GGgfZPE>9_ii#c24Mykl%-sZXGLpGKsr|A!Bo6{$72I+n zfEIKD$EQaE0X&>GjK1N_uHk7ZQ`^xQcb4sL1hfx%@|}?u8)StG;4olr7Xx}o%vE#2Cr$2 z0VS^t2=EP}!CyU;R@I|R1)R0CDdDSl1RtEkx`P(~Kf@b^bB>o68{SfFHSH_tyMN{e zlIP8j3=;QC8`zEaf||k(xLox2dtd~0$3Pd>gN($95s*s+4V4V9M2$c?Qm}h()eS;@BmY^1RdD|76N$ado1v-w0=8uxD)lc=s zZVkrS%XovAKLy15@?VpY`h%-k+htkHm?BRI4H$biIK*cS#+Z0QMFu0J23-D|_9EC* zF%fAYQN@;~Nct#VT@LPj&@0qQPz|bpj?~z z64D;@+lvLuEqf+dorYmHOkCu`evzK{)9doDD5VgECPqqDoaz2*?8&|`Z2a8A4CXlz zh0LLT+AsSVwpI`&>12}8Me6w)BWpK~g%8SD`CfY2n}m`N3eT^u8avjW61oSN>OQ{N zs&k*3(-JFyRKC4#B)=uyfWM5250~}gOE!604+WDXfUU2#G@Q}d!sbmJsT>MsH=PFY zDsRdbSJuGi(>=HBs&#u)S{%ke>&JsBh<(`N>H=CW`MGhjQKz&J=5pNl zeP+gwP_>1y>=9AkNTyEvGdCQcVx7B{BVwzRsTelOQr%K(7eUtokhih-sP#aZFoZ)U z?P(JyT4Kd%EkI?Fw=2Z#5|n>{3y(~{OY@RA&sym5YV1V%o67Pktye%j)_2U~)4frv zxpP?&9WKqAmATBuUsZIA+yylP_+c8O&1}fR>MP8gPUidQX)9!qO7+3 zS_9IAuTMCSpN2UJ06~Df14QC z+iyA=rkT|LkY=(3yWrWJU-$E|v9UMkAJjBu=XfM_jZnSwfd*q{tnJ;Qu+h0#r?Tf~ z>qh18GX;qu7;eEM!%)-^P8TJweu-z+#mhw1`sp7z66L+|x$mp==`sFN z7SvpHfM$zOCvl+lt{5(xAir>i4c#s@F(=oNyTcri;?RRfq@m>La`|8hk*}cf| z+k018|1#@*cr(wguYfyrq-ur*D7b*~<>>8Ca2hf8+Q>gQ47#$4tFk|odkloU-PzZd zssQEL*H=8x+~nF2A^^Y->=Eeo@du)g$tCAxg*An;K}aSZ((1BO&o*H*S3$ypE=7iO zmz@2wH_uzh9UJ!13NiKH$E4HvNH6`AxI|}I4K8Xe2q8UmH^hb%Xt&ER5YAhy%I0Lw z=Pp$pLw45xqN7e{w%Z2$lO diff --git a/docs/tuning_fledge.rst b/docs/tuning_fledge.rst index e32f74dfcc..7a3ac21cee 100644 --- a/docs/tuning_fledge.rst +++ b/docs/tuning_fledge.rst @@ -474,6 +474,8 @@ The storage plugins to use can be selected in the *Advanced* section of the *Con - **Log Level**: This control the level at which the storage plugin will output logs. +- **Timeout**: Sets the timeout value in seconds for each request to the storage layer. This causes a timeout error to be returned to a client if a storage call takes longer than the specified value. + 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 3b385f676d828c9ea588a81fe0b154422d541072 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 4 Apr 2024 11:48:25 +0530 Subject: [PATCH 135/146] Doc Plugin cross referencing as per shared keywords Signed-off-by: ashish-jabble --- docs/conf.py | 2 +- docs/scripts/fledge_plugin_list | 4 +- docs/scripts/plugin_and_service_documentation | 86 ++++++++++++++++++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4069ee0648..87c0c1f652 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='FOGL-8581'"], shell=True, check=True) diff --git a/docs/scripts/fledge_plugin_list b/docs/scripts/fledge_plugin_list index d06e0a3d85..0b9319882e 100755 --- a/docs/scripts/fledge_plugin_list +++ b/docs/scripts/fledge_plugin_list @@ -22,10 +22,12 @@ print("\n".join(fRepos)); fledge_wip_repos=$(curl -sX GET -H "$header" -H "Accept: application/vnd.github.mercy-preview+json" https://api.github.com/search/repositories?q=topic:wip+org:fledge-iot) fledge_poc_repos=$(curl -sX GET -H "$header" -H "Accept: application/vnd.github.mercy-preview+json" https://api.github.com/search/repositories?q=topic:poc+org:fledge-iot) fledge_internal_repos=$(curl -sX GET -H "$header" -H "Accept: application/vnd.github.mercy-preview+json" https://api.github.com/search/repositories?q=topic:internal+org:fledge-iot) +fledge_obsolete_repos=$(curl -sX GET -H "$header" -H "Accept: application/vnd.github.mercy-preview+json" https://api.github.com/search/repositories?q=topic:obsolete+org:fledge-iot) fledge_wip_repos_name=$(echo ${fledge_wip_repos} | python3 -c "$fetchTopicReposPyScript" | sort -f) fledge_poc_repos_name=$(echo ${fledge_poc_repos} | python3 -c "$fetchTopicReposPyScript" | sort -f) fledge_internal_repos_name=$(echo ${fledge_internal_repos} | python3 -c "$fetchTopicReposPyScript" | sort -f) -export EXCLUDE_FLEDGE_TOPIC_REPOSITORIES=$(echo ${fledge_wip_repos_name} ${fledge_poc_repos_name} ${fledge_internal_repos_name} | sort -f) +fledge_obsolete_repos_name=$(echo ${fledge_obsolete_repos} | python3 -c "$fetchTopicReposPyScript" | sort -f) +export EXCLUDE_FLEDGE_TOPIC_REPOSITORIES=$(echo ${fledge_wip_repos_name} ${fledge_poc_repos_name} ${fledge_internal_repos_name} ${fledge_obsolete_repos_name} | sort -f) echo "EXCLUDED FLEDGE TOPIC REPOS LIST: $EXCLUDE_FLEDGE_TOPIC_REPOSITORIES" fetchFledgeReposPyScript=' import os,json,sys;\ diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index 7fa8e39c04..b47aef98d6 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -72,10 +72,12 @@ print("\n".join(fRepos)); fledge_wip_repos=$(curl -sX GET -H "$header" -H "Accept: application/vnd.github.mercy-preview+json" https://api.github.com/search/repositories?q=topic:wip+org:fledge-iot) fledge_poc_repos=$(curl -sX GET -H "$header" -H "Accept: application/vnd.github.mercy-preview+json" https://api.github.com/search/repositories?q=topic:poc+org:fledge-iot) fledge_internal_repos=$(curl -sX GET -H "$header" -H "Accept: application/vnd.github.mercy-preview+json" https://api.github.com/search/repositories?q=topic:internal+org:fledge-iot) +fledge_obsolete_repos=$(curl -sX GET -H "$header" -H "Accept: application/vnd.github.mercy-preview+json" https://api.github.com/search/repositories?q=topic:obsolete+org:fledge-iot) fledge_wip_repos_name=$(echo ${fledge_wip_repos} | python3 -c "$fetchTopicReposPyScript" | sort -f) fledge_poc_repos_name=$(echo ${fledge_poc_repos} | python3 -c "$fetchTopicReposPyScript" | sort -f) fledge_internal_repos_name=$(echo ${fledge_internal_repos} | python3 -c "$fetchTopicReposPyScript" | sort -f) -export EXCLUDE_FLEDGE_TOPIC_REPOSITORIES=$(echo ${fledge_wip_repos_name} ${fledge_poc_repos_name} ${fledge_internal_repos_name} | sort -f) +fledge_obsolete_repos_name=$(echo ${fledge_obsolete_repos} | python3 -c "$fetchTopicReposPyScript" | sort -f) +export EXCLUDE_FLEDGE_TOPIC_REPOSITORIES=$(echo ${fledge_wip_repos_name} ${fledge_poc_repos_name} ${fledge_internal_repos_name} ${fledge_obsolete_repos_name} | sort -f) echo "EXCLUDED FLEDGE TOPIC REPOS LIST: $EXCLUDE_FLEDGE_TOPIC_REPOSITORIES" fetchFledgeReposPyScript=' import os,json,sys;\ @@ -86,10 +88,52 @@ fRepos = list(set(all_repos) - set(exclude_topic_packages.split()));\ print("\n".join(fRepos)); ' REPOS=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) +REPOS="fledge-south-sinusoid fledge-south-lathesim fledge-filter-expression fledge-filter-python35 fledge-south-expression fledge-south-randomwalk" 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} +#KEYWORDS="Mathematical Compression Structure Augmentation Labeling Simulation Cleansing PLC Signal Processing Image Textual Model Execution Electrical Vibration Scripted" + +MATHEMATICAL_KEYWORD="" +AUGMENTATION_KEYWORD="" +SCRIPTED_KEYWORD="" +SIMULATION_KEYWORD="" + +function get_keywords() { + repo_path="$1" + repo_name="$2" + keywords=$(cat $repo_path/keywords | sed -e "s/,/ /g") + ret_val=$(echo $keywords | grep -w 'Augmentation') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + AUGMENTATION_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Mathematical') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + MATHEMATICAL_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Scripted') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + SCRIPTED_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Simulation') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + SIMULATION_KEYWORD+="$repo_name " + fi + + echo "Augmentation List: "$AUGMENTATION_KEYWORD + echo "Scripted List: "$SCRIPTED_KEYWORD + echo "Simulation List: "$SIMULATION_KEYWORD + echo "Mathematical List: "$MATHEMATICAL_KEYWORD +} + function plugin_and_service_doc { repo_name=$1 dest=$2 @@ -125,6 +169,10 @@ function plugin_and_service_doc { else echo "*** WARNING: index.rst file is missing for ${repo_name}." fi + # prepare shared list of doc keywords + if [[ ${type} != "service" && -f ${dir_type}/${repo_name}/keywords ]]; then + get_keywords "${dir_type}/${repo_name}" "$repo_name" + fi else echo "*** WARNING: ${repo_name} docs directory is missing." fi @@ -182,3 +230,39 @@ do dest=services/index.rst plugin_and_service_doc $repo $dest "services" done + +# Cross Referencing list of plugins +plugins_path=$(pwd)/plugins + +for dir in $plugins_path/* +do + dir_name=$(echo $dir | sed 's/^.*fledge-/fledge-/') + if [[ $dir_name == *fledge-* ]]; then + echo "Cross Referencing repo: "$plugins_path/$dir_name/keywords + if [ -f $plugins_path/$dir_name/keywords ]; then + cat >> $plugins_path/$dir_name/index.rst << EOFPLUGINS + +******** +See Also +******** +EOFPLUGINS + + result="" + ret_val=$(echo $SIMULATION_KEYWORD | grep -w "$dir_name") + result+="$ret_val" + ret_val=$(echo $SCRIPTED_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + ret_val=$(echo $AUGMENTATION_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + ret_val=$(echo $MATHEMATICAL_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + result=$(echo $result | sed -e "s/$dir_name//g" | xargs -n 1 | sort -u | xargs) + echo "For $dir_name:$result" + for r in $result + do + echo " \`$r <../$r/index.html>\`_" >> $plugins_path/$dir_name/index.rst + echo -e "\n" >> $plugins_path/$dir_name/index.rst + done + fi + fi +done From 00b4257ec9a55cd1ab90544ce2c370734c328e7a Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 4 Apr 2024 09:17:49 +0100 Subject: [PATCH 136/146] FOGL-8618 Add missing attributes when converting defaults to JSON (#1329) * FOGL-8618 Add values and default when converting values and defaults to JSON Signed-off-by: Mark Riddoch * Add mssng attributes when converting defauilts to JSON Signed-off-by: Mark Riddoch --------- Signed-off-by: Mark Riddoch --- C/common/config_category.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/C/common/config_category.cpp b/C/common/config_category.cpp index 5ba085f664..2356636b4b 100755 --- a/C/common/config_category.cpp +++ b/C/common/config_category.cpp @@ -1814,6 +1814,14 @@ ostringstream convert; } convert << "]"; } + if (!m_listSize.empty()) + { + convert << ", \"listSize\" : \"" << m_listSize << "\""; + } + if (!m_listItemType.empty()) + { + convert << ", \"items\" : \"" << m_listItemType << "\""; + } if (m_itemType == StringItem || m_itemType == EnumerationItem || From c2ef7af00a0dc58a3f4992895d46f1220be4be23 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Thu, 4 Apr 2024 15:01:32 +0530 Subject: [PATCH 137/146] description added for each plugin cross referencing Signed-off-by: ashish-jabble --- docs/scripts/plugin_and_service_documentation | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index b47aef98d6..3ed64b75a9 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -88,7 +88,7 @@ fRepos = list(set(all_repos) - set(exclude_topic_packages.split()));\ print("\n".join(fRepos)); ' REPOS=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) -REPOS="fledge-south-sinusoid fledge-south-lathesim fledge-filter-expression fledge-filter-python35 fledge-south-expression fledge-south-randomwalk" +REPOS="fledge-south-sinusoid fledge-south-lathesim fledge-filter-expression fledge-filter-python35 fledge-south-expression fledge-south-random" 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} @@ -233,7 +233,6 @@ done # Cross Referencing list of plugins plugins_path=$(pwd)/plugins - for dir in $plugins_path/* do dir_name=$(echo $dir | sed 's/^.*fledge-/fledge-/') @@ -260,7 +259,8 @@ EOFPLUGINS echo "For $dir_name:$result" for r in $result do - echo " \`$r <../$r/index.html>\`_" >> $plugins_path/$dir_name/index.rst + description=$(echo "$fledgeRepos" | python3 -c 'import json,sys;repos=json.load(sys.stdin);fRepo = [r for r in repos if r["name"] == "'"${r,,}"'" ];print(fRepo[0]["description"])') + echo " \`$r <../$r/index.html>\`_ - $description" >> $plugins_path/$dir_name/index.rst echo -e "\n" >> $plugins_path/$dir_name/index.rst done fi From bd7f5f23cc1877fc3af671e5ad354e6f130ce42c Mon Sep 17 00:00:00 2001 From: gnandan <111729765+gnandan@users.noreply.github.com> Date: Thu, 4 Apr 2024 16:03:27 +0530 Subject: [PATCH 138/146] FOGL-8579: Changed logic to raise single alert for all the updates (#1325) * FOGL-8579: Changed logic to raise single alert for all the updates Signed-off-by: nandan --- C/tasks/check_updates/check_updates.cpp | 40 +++++-------------- C/tasks/check_updates/include/check_updates.h | 2 - 2 files changed, 10 insertions(+), 32 deletions(-) diff --git a/C/tasks/check_updates/check_updates.cpp b/C/tasks/check_updates/check_updates.cpp index d42dba460e..1c06c01aea 100644 --- a/C/tasks/check_updates/check_updates.cpp +++ b/C/tasks/check_updates/check_updates.cpp @@ -75,16 +75,17 @@ void CheckUpdates::raiseAlerts() m_logger->debug("raiseAlerts running"); try { - for (auto item: getUpgradablePackageList()) + int availableUpdates = getUpgradablePackageList().size(); + + if (availableUpdates > 0) { - std::string key = ""; - std::string version = ""; - std::istringstream iss(item); - iss >> key; - iss >> version; - removeSubstring(key,"/", " "); - - std::string message = "A newer version " + version + " of " + key + " is available for upgrade"; + std::string key = "package_updates"; + std::string message = ""; + if (availableUpdates == 1) + message = "There is " + std::to_string(availableUpdates) + " update available to be installed"; + else + message = "There are " + std::to_string(availableUpdates) + " updates available to be installed"; + std::string urgency = "normal"; if (!m_mgtClient->raiseAlert(key,message,urgency)) { @@ -194,24 +195,3 @@ std::vector CheckUpdates::getUpgradablePackageList() return packageList; } - -/** - * Remove substring - */ -void CheckUpdates::removeSubstring(std::string& str, const std::string& startDelimiter, const std::string& endDelimiter) -{ - size_t pos = str.find(startDelimiter); - while (pos != std::string::npos) - { - size_t end_pos = str.find(endDelimiter, pos + 1); - if (end_pos != std::string::npos) - { - str.erase(pos, end_pos - pos + 1); - } - else - { - str.erase(pos); // Remove until the end if space not found - } - pos = str.find(startDelimiter); - } -} diff --git a/C/tasks/check_updates/include/check_updates.h b/C/tasks/check_updates/include/check_updates.h index b93c789bb5..1a8f852e5a 100644 --- a/C/tasks/check_updates/include/check_updates.h +++ b/C/tasks/check_updates/include/check_updates.h @@ -34,7 +34,5 @@ class CheckUpdates : public FledgeProcess std::string getPackageManager(); std::vector getUpgradablePackageList(); void processEnd(); - void removeSubstring(std::string& str, const std::string& startDelimiter, const std::string& endDelimiter); - }; #endif From bb03460bf1713b75fe8861a52d5e9718ccdec007 Mon Sep 17 00:00:00 2001 From: Mark Riddoch Date: Thu, 4 Apr 2024 14:48:20 +0100 Subject: [PATCH 139/146] Fix plugin_shutdown return type (#1333) Signed-off-by: Mark Riddoch --- docs/plugin_developers_guide/04_north_plugins.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin_developers_guide/04_north_plugins.rst b/docs/plugin_developers_guide/04_north_plugins.rst index 669659faa7..4fd607dc72 100644 --- a/docs/plugin_developers_guide/04_north_plugins.rst +++ b/docs/plugin_developers_guide/04_north_plugins.rst @@ -386,7 +386,7 @@ The *plugin_shutdown* entry point is called when the plugin is no longer require .. code-block:: C - uint32_t plugin_shutdown(PLUGIN_HANDLE handle) + void plugin_shutdown(PLUGIN_HANDLE handle) { myNorthPlugin *plugin = (myNorthPlugin *)handle; delete plugin; From 57d82eaa9238d75db9cf6434e8227609251001b3 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 5 Apr 2024 12:40:08 +0530 Subject: [PATCH 140/146] with FOGL-8636 debug list Signed-off-by: ashish-jabble --- docs/conf.py | 2 +- docs/scripts/fledge_plugin_list | 2 + docs/scripts/plugin_and_service_documentation | 184 +++++++++++++++--- 3 files changed, 160 insertions(+), 28 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 87c0c1f652..6678ed1a4e 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='FOGL-8581'"], shell=True, check=True) +subprocess.run(["make generated DOCBRANCH='FOGL-8636'"], shell=True, check=True) diff --git a/docs/scripts/fledge_plugin_list b/docs/scripts/fledge_plugin_list index 0b9319882e..50ab44abf0 100755 --- a/docs/scripts/fledge_plugin_list +++ b/docs/scripts/fledge_plugin_list @@ -38,6 +38,8 @@ fRepos = list(set(all_repos) - set(exclude_topic_packages.split()));\ print("\n".join(fRepos)); ' REPOS=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) +REPOS="fledge-north-azure fledge-filter-python35 fledge-filter-replace fledge-filter-rate fledge-filter-change fledge-filter-delta fledge-filter-asset fledge-filter-omfhint fledge-filter-fft fledge-filter-rms fledge-filter-metadata fledge-filter-scale-set fledge-south-dnp3 fledge-south-s2opcua fledge-south-opcua fledge-south-modbus-c" +#REPOS="fledge-south-sinusoid fledge-south-lathesim fledge-filter-expression fledge-filter-python35 fledge-south-expression fledge-south-random" 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} diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index 3ed64b75a9..6ec68d77d0 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -88,17 +88,30 @@ fRepos = list(set(all_repos) - set(exclude_topic_packages.split()));\ print("\n".join(fRepos)); ' REPOS=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) -REPOS="fledge-south-sinusoid fledge-south-lathesim fledge-filter-expression fledge-filter-python35 fledge-south-expression fledge-south-random" +#REPOS="fledge-south-sinusoid fledge-south-lathesim fledge-filter-expression fledge-filter-python35 fledge-south-expression fledge-south-random" +REPOS="fledge-north-azure fledge-filter-python35 fledge-filter-replace fledge-filter-rate fledge-filter-change fledge-filter-delta fledge-filter-asset fledge-filter-omfhint fledge-filter-fft fledge-filter-rms fledge-filter-metadata fledge-filter-scale-set fledge-south-dnp3 fledge-south-s2opcua fledge-south-opcua fledge-south-modbus-c" 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} -#KEYWORDS="Mathematical Compression Structure Augmentation Labeling Simulation Cleansing PLC Signal Processing Image Textual Model Execution Electrical Vibration Scripted" - -MATHEMATICAL_KEYWORD="" +# Keywords AUGMENTATION_KEYWORD="" +CLEANSING_KEYWORD="" +CLOUD_KEYWORD="" +COMPRESSION_KEYWORD="" +ELECTRICAL_KEYWORD="" +IMAGE_KEYWORD="" +LABELLING_KEYWORD="" +MATHEMATICAL_KEYWORD="" +ML_MODEL_KEYWORD="" +OMF_KEYWORD="" +PLC_KEYWORD="" SCRIPTED_KEYWORD="" +SIGNAL_PROCESSING_KEYWORD="" SIMULATION_KEYWORD="" +STRUCTURE_KEYWORD="" +TEXTUAL_KEYWORD="" +VIBRATION_KEYWORD="" function get_keywords() { repo_path="$1" @@ -110,28 +123,101 @@ function get_keywords() { AUGMENTATION_KEYWORD+="$repo_name " fi + ret_val=$(echo $keywords | grep -w 'Cleansing') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + CLEANSING_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Cloud') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + CLOUD_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Compression') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + COMPRESSION_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Electrical') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + ELECTRICAL_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Image') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + IMAGE_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Labelling') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + LABELLING_KEYWORD+="$repo_name " + fi + ret_val=$(echo $keywords | grep -w 'Mathematical') ret_val=$(echo $?) if [[ $ret_val -eq "0" ]]; then MATHEMATICAL_KEYWORD+="$repo_name " fi + ret_val=$(echo $keywords | grep -w 'Model Execution') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + ML_MODEL_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'OMF') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + OMF_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'PLC') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + PLC_KEYWORD+="$repo_name " + fi + ret_val=$(echo $keywords | grep -w 'Scripted') ret_val=$(echo $?) if [[ $ret_val -eq "0" ]]; then SCRIPTED_KEYWORD+="$repo_name " fi + ret_val=$(echo $keywords | grep -w 'Signal Processing') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + SIGNAL_PROCESSING_KEYWORD+="$repo_name " + fi + ret_val=$(echo $keywords | grep -w 'Simulation') ret_val=$(echo $?) if [[ $ret_val -eq "0" ]]; then SIMULATION_KEYWORD+="$repo_name " fi - echo "Augmentation List: "$AUGMENTATION_KEYWORD - echo "Scripted List: "$SCRIPTED_KEYWORD - echo "Simulation List: "$SIMULATION_KEYWORD - echo "Mathematical List: "$MATHEMATICAL_KEYWORD + ret_val=$(echo $keywords | grep -w 'Structure') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + STRUCTURE_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Textual') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + TEXTUAL_KEYWORD+="$repo_name " + fi + + ret_val=$(echo $keywords | grep -w 'Vibration') + ret_val=$(echo $?) + if [[ $ret_val -eq "0" ]]; then + VIBRATION_KEYWORD+="$repo_name " + fi } function plugin_and_service_doc { @@ -233,36 +319,80 @@ done # Cross Referencing list of plugins plugins_path=$(pwd)/plugins + +echo "Augmentation List: "$AUGMENTATION_KEYWORD +echo "Cleansing List: "$CLEANSING_KEYWORD +echo "Cloud List: "$CLOUD_KEYWORD +echo "Compression List: "$COMPRESSION_KEYWORD +echo "Electrical List: "$ELECTRICAL_KEYWORD +echo "Image List: "$IMAGE_KEYWORD +echo "Labelling List: "$LABELLING_KEYWORD +echo "Mathematical List: "$MATHEMATICAL_KEYWORD +echo "Model Execution List: "$ML_MODEL_KEYWORD +echo "OMF List: "$OMF_KEYWORD +echo "PLC List: "$PLC_KEYWORD +echo "Scripted List: "$SCRIPTED_KEYWORD +echo "Signal Processing List: "$SIGNAL_PROCESSING_KEYWORD +echo "Simulation List: "$SIMULATION_KEYWORD +echo "Structure List: "$STRUCTURE_KEYWORD +echo "Textual List: "$TEXTUAL_KEYWORD +echo "Vibration List: "$VIBRATION_KEYWORD + for dir in $plugins_path/* do dir_name=$(echo $dir | sed 's/^.*fledge-/fledge-/') if [[ $dir_name == *fledge-* ]]; then - echo "Cross Referencing repo: "$plugins_path/$dir_name/keywords if [ -f $plugins_path/$dir_name/keywords ]; then - cat >> $plugins_path/$dir_name/index.rst << EOFPLUGINS - -******** -See Also -******** -EOFPLUGINS - result="" - ret_val=$(echo $SIMULATION_KEYWORD | grep -w "$dir_name") - result+="$ret_val" - ret_val=$(echo $SCRIPTED_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" ret_val=$(echo $AUGMENTATION_KEYWORD | grep -w "$dir_name") result+=" $ret_val" + ret_val=$(echo $CLEANSING_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + ret_val=$(echo $CLOUD_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + ret_val=$(echo $COMPRESSION_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + ret_val=$(echo $ELECTRICAL_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + ret_val=$(echo $IMAGE_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + ret_val=$(echo $LABELLING_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" ret_val=$(echo $MATHEMATICAL_KEYWORD | grep -w "$dir_name") result+=" $ret_val" + ret_val=$(echo $ML_MODEL_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + ret_val=$(echo $OMF_KEYWORD | grep -w "$dir_name") + result+="$ret_val" + ret_val=$(echo $PLC_KEYWORD | grep -w "$dir_name") + result+="$ret_val" + ret_val=$(echo $SCRIPTED_KEYWORD | grep -w "$dir_name") + result+=" $ret_val" + ret_val=$(echo $SIGNAL_PROCESSING_KEYWORD | grep -w "$dir_name") + result+="$ret_val" + ret_val=$(echo $SIMULATION_KEYWORD | grep -w "$dir_name") + result+="$ret_val" + ret_val=$(echo $STRUCTURE_KEYWORD | grep -w "$dir_name") + result+="$ret_val" + ret_val=$(echo $TEXTUAL_KEYWORD | grep -w "$dir_name") + result+="$ret_val" + ret_val=$(echo $VIBRATION_KEYWORD | grep -w "$dir_name") + result+="$ret_val" result=$(echo $result | sed -e "s/$dir_name//g" | xargs -n 1 | sort -u | xargs) - echo "For $dir_name:$result" - for r in $result - do - description=$(echo "$fledgeRepos" | python3 -c 'import json,sys;repos=json.load(sys.stdin);fRepo = [r for r in repos if r["name"] == "'"${r,,}"'" ];print(fRepo[0]["description"])') - echo " \`$r <../$r/index.html>\`_ - $description" >> $plugins_path/$dir_name/index.rst - echo -e "\n" >> $plugins_path/$dir_name/index.rst - done + echo "For $dir_name: $result" + if [[ -n "$result" ]]; then + cat >> $plugins_path/$dir_name/index.rst << EOFPLUGINS + +See Also +-------- +EOFPLUGINS + for r in $result + do + description=$(cat $(pwd)/fledge_plugins.rst | grep -A1 -w "plugins/$r/index.html" | grep -v "$r") + echo " \`$r <../$r/index.html>\`_ $description" >> $plugins_path/$dir_name/index.rst + echo -e "\n" >> $plugins_path/$dir_name/index.rst + done + fi fi fi done From 1df8e231b76dfdf093d5f663c909b48174239336 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 5 Apr 2024 13:00:02 +0530 Subject: [PATCH 141/146] debug related code removed Signed-off-by: ashish-jabble --- docs/conf.py | 2 +- docs/scripts/fledge_plugin_list | 2 -- docs/scripts/plugin_and_service_documentation | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 6678ed1a4e..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='FOGL-8636'"], shell=True, check=True) +subprocess.run(["make generated DOCBRANCH='develop'"], shell=True, check=True) diff --git a/docs/scripts/fledge_plugin_list b/docs/scripts/fledge_plugin_list index 50ab44abf0..0b9319882e 100755 --- a/docs/scripts/fledge_plugin_list +++ b/docs/scripts/fledge_plugin_list @@ -38,8 +38,6 @@ fRepos = list(set(all_repos) - set(exclude_topic_packages.split()));\ print("\n".join(fRepos)); ' REPOS=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) -REPOS="fledge-north-azure fledge-filter-python35 fledge-filter-replace fledge-filter-rate fledge-filter-change fledge-filter-delta fledge-filter-asset fledge-filter-omfhint fledge-filter-fft fledge-filter-rms fledge-filter-metadata fledge-filter-scale-set fledge-south-dnp3 fledge-south-s2opcua fledge-south-opcua fledge-south-modbus-c" -#REPOS="fledge-south-sinusoid fledge-south-lathesim fledge-filter-expression fledge-filter-python35 fledge-south-expression fledge-south-random" 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} diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index 6ec68d77d0..f44b422ed6 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -88,8 +88,6 @@ fRepos = list(set(all_repos) - set(exclude_topic_packages.split()));\ print("\n".join(fRepos)); ' REPOS=$(echo ${fledgeRepos} | python3 -c "$fetchFledgeReposPyScript" | sort -f) -#REPOS="fledge-south-sinusoid fledge-south-lathesim fledge-filter-expression fledge-filter-python35 fledge-south-expression fledge-south-random" -REPOS="fledge-north-azure fledge-filter-python35 fledge-filter-replace fledge-filter-rate fledge-filter-change fledge-filter-delta fledge-filter-asset fledge-filter-omfhint fledge-filter-fft fledge-filter-rms fledge-filter-metadata fledge-filter-scale-set fledge-south-dnp3 fledge-south-s2opcua fledge-south-opcua fledge-south-modbus-c" 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} From 164d6a1399f9454290143c3fa718ae4c70b9e4c3 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 5 Apr 2024 17:17:15 +0530 Subject: [PATCH 142/146] hard code approach removed for keywords and associated repos info Signed-off-by: ashish-jabble --- docs/scripts/plugin_and_service_documentation | 215 +++--------------- 1 file changed, 32 insertions(+), 183 deletions(-) diff --git a/docs/scripts/plugin_and_service_documentation b/docs/scripts/plugin_and_service_documentation index f44b422ed6..344ffe5b1f 100644 --- a/docs/scripts/plugin_and_service_documentation +++ b/docs/scripts/plugin_and_service_documentation @@ -92,132 +92,6 @@ INBUILT_PLUGINS="fledge-north-OMF fledge-rule-Threshold fledge-rule-DataAvailabi REPOSITORIES=$(echo ${REPOS} ${INBUILT_PLUGINS} | xargs -n1 | sort -f | xargs) echo "REPOSITORIES LIST: "${REPOSITORIES} -# Keywords -AUGMENTATION_KEYWORD="" -CLEANSING_KEYWORD="" -CLOUD_KEYWORD="" -COMPRESSION_KEYWORD="" -ELECTRICAL_KEYWORD="" -IMAGE_KEYWORD="" -LABELLING_KEYWORD="" -MATHEMATICAL_KEYWORD="" -ML_MODEL_KEYWORD="" -OMF_KEYWORD="" -PLC_KEYWORD="" -SCRIPTED_KEYWORD="" -SIGNAL_PROCESSING_KEYWORD="" -SIMULATION_KEYWORD="" -STRUCTURE_KEYWORD="" -TEXTUAL_KEYWORD="" -VIBRATION_KEYWORD="" - -function get_keywords() { - repo_path="$1" - repo_name="$2" - keywords=$(cat $repo_path/keywords | sed -e "s/,/ /g") - ret_val=$(echo $keywords | grep -w 'Augmentation') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - AUGMENTATION_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Cleansing') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - CLEANSING_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Cloud') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - CLOUD_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Compression') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - COMPRESSION_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Electrical') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - ELECTRICAL_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Image') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - IMAGE_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Labelling') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - LABELLING_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Mathematical') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - MATHEMATICAL_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Model Execution') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - ML_MODEL_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'OMF') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - OMF_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'PLC') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - PLC_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Scripted') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - SCRIPTED_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Signal Processing') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - SIGNAL_PROCESSING_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Simulation') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - SIMULATION_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Structure') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - STRUCTURE_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Textual') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - TEXTUAL_KEYWORD+="$repo_name " - fi - - ret_val=$(echo $keywords | grep -w 'Vibration') - ret_val=$(echo $?) - if [[ $ret_val -eq "0" ]]; then - VIBRATION_KEYWORD+="$repo_name " - fi -} - function plugin_and_service_doc { repo_name=$1 dest=$2 @@ -253,10 +127,6 @@ function plugin_and_service_doc { else echo "*** WARNING: index.rst file is missing for ${repo_name}." fi - # prepare shared list of doc keywords - if [[ ${type} != "service" && -f ${dir_type}/${repo_name}/keywords ]]; then - get_keywords "${dir_type}/${repo_name}" "$repo_name" - fi else echo "*** WARNING: ${repo_name} docs directory is missing." fi @@ -318,65 +188,43 @@ done # Cross Referencing list of plugins plugins_path=$(pwd)/plugins -echo "Augmentation List: "$AUGMENTATION_KEYWORD -echo "Cleansing List: "$CLEANSING_KEYWORD -echo "Cloud List: "$CLOUD_KEYWORD -echo "Compression List: "$COMPRESSION_KEYWORD -echo "Electrical List: "$ELECTRICAL_KEYWORD -echo "Image List: "$IMAGE_KEYWORD -echo "Labelling List: "$LABELLING_KEYWORD -echo "Mathematical List: "$MATHEMATICAL_KEYWORD -echo "Model Execution List: "$ML_MODEL_KEYWORD -echo "OMF List: "$OMF_KEYWORD -echo "PLC List: "$PLC_KEYWORD -echo "Scripted List: "$SCRIPTED_KEYWORD -echo "Signal Processing List: "$SIGNAL_PROCESSING_KEYWORD -echo "Simulation List: "$SIMULATION_KEYWORD -echo "Structure List: "$STRUCTURE_KEYWORD -echo "Textual List: "$TEXTUAL_KEYWORD -echo "Vibration List: "$VIBRATION_KEYWORD +# HashMap used for storing keywords and repos +declare -A KEYWORDS +for dir in $plugins_path/* +do + dir_name=$(echo $dir | sed 's/^.*fledge-/fledge-/') + if [[ $dir_name == *fledge-* ]]; then + if [ -f $plugins_path/$dir_name/keywords ]; then + keywords=$(cat $plugins_path/$dir_name/keywords | sed -e "s/,/ /g") + for k in $keywords + do + KEYWORDS+=(["$k"]+="$dir_name ") + done + fi + fi +done + +function get_repos_list_by_keywords() { + DIR_NAME="$1" + REPOSITORIES_LIST="" + for i in "${!KEYWORDS[@]}" + do + repos_val=$(echo ${KEYWORDS[$i]} | grep -w "$DIR_NAME") + if [[ $repos_val != "" ]]; then + repos_result+=$(echo "$repos_val ") + fi + done + REPOSITORIES_LIST=$(echo $repos_result | sed -e "s/$DIR_NAME//g" | xargs -n 1 | sort -u | xargs) + echo "$REPOSITORIES_LIST" +} +# See Also section added as per installed plugins directory path for dir in $plugins_path/* do dir_name=$(echo $dir | sed 's/^.*fledge-/fledge-/') if [[ $dir_name == *fledge-* ]]; then if [ -f $plugins_path/$dir_name/keywords ]; then - result="" - ret_val=$(echo $AUGMENTATION_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $CLEANSING_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $CLOUD_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $COMPRESSION_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $ELECTRICAL_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $IMAGE_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $LABELLING_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $MATHEMATICAL_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $ML_MODEL_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $OMF_KEYWORD | grep -w "$dir_name") - result+="$ret_val" - ret_val=$(echo $PLC_KEYWORD | grep -w "$dir_name") - result+="$ret_val" - ret_val=$(echo $SCRIPTED_KEYWORD | grep -w "$dir_name") - result+=" $ret_val" - ret_val=$(echo $SIGNAL_PROCESSING_KEYWORD | grep -w "$dir_name") - result+="$ret_val" - ret_val=$(echo $SIMULATION_KEYWORD | grep -w "$dir_name") - result+="$ret_val" - ret_val=$(echo $STRUCTURE_KEYWORD | grep -w "$dir_name") - result+="$ret_val" - ret_val=$(echo $TEXTUAL_KEYWORD | grep -w "$dir_name") - result+="$ret_val" - ret_val=$(echo $VIBRATION_KEYWORD | grep -w "$dir_name") - result+="$ret_val" - result=$(echo $result | sed -e "s/$dir_name//g" | xargs -n 1 | sort -u | xargs) + result=$(get_repos_list_by_keywords "$dir_name") echo "For $dir_name: $result" if [[ -n "$result" ]]; then cat >> $plugins_path/$dir_name/index.rst << EOFPLUGINS @@ -386,6 +234,7 @@ See Also EOFPLUGINS for r in $result do + # Add link and description to the plugin description=$(cat $(pwd)/fledge_plugins.rst | grep -A1 -w "plugins/$r/index.html" | grep -v "$r") echo " \`$r <../$r/index.html>\`_ $description" >> $plugins_path/$dir_name/index.rst echo -e "\n" >> $plugins_path/$dir_name/index.rst From 3f84fcfbd130a7cd77cec40526188ed01f2868a1 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 5 Apr 2024 17:53:58 +0530 Subject: [PATCH 143/146] cpid passed in an integer to storage payload while updating control filter pipelines Signed-off-by: ashish-jabble --- python/fledge/services/core/api/control_service/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fledge/services/core/api/control_service/pipeline.py b/python/fledge/services/core/api/control_service/pipeline.py index 9b4b44593d..11accff213 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -234,7 +234,7 @@ async def update(request: web.Request) -> web.Response: 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) + await _update_filters(storage, pipeline['id'], pipeline['name'], filters, db_filters) else: raise ValueError('Filters do not exist as per the given list {}'.format(filters)) except ValueError as err: From da889507c0f8abfd4c02bb4924411a3ed5a015c7 Mon Sep 17 00:00:00 2001 From: ashish-jabble Date: Fri, 5 Apr 2024 19:03:22 +0530 Subject: [PATCH 144/146] cpid integer value fixes in other areas Signed-off-by: ashish-jabble --- python/fledge/services/core/api/control_service/pipeline.py | 6 +++--- 1 file changed, 3 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 11accff213..2dcf06ee5f 100644 --- a/python/fledge/services/core/api/control_service/pipeline.py +++ b/python/fledge/services/core/api/control_service/pipeline.py @@ -218,13 +218,13 @@ async def update(request: web.Request) -> web.Response: columns = await _check_parameters(data, request) storage = connect.get_storage_async() if columns: - payload = PayloadBuilder().SET(**columns).WHERE(['cpid', '=', cpid]).payload() + payload = PayloadBuilder().SET(**columns).WHERE(['cpid', '=', pipeline['id']]).payload() await storage.update_tbl("control_pipelines", payload) filters = data.get('filters', None) if filters is not None: # Case: When filters payload is empty then remove all filters if not filters: - await _remove_filters(storage, pipeline['filters'], cpid, pipeline['name']) + await _remove_filters(storage, pipeline['filters'], pipeline['id'], pipeline['name']) else: go_ahead = await _check_filters(storage, filters) if filters else True if go_ahead: @@ -268,7 +268,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, pipeline['name']) + await _remove_filters(storage, pipeline['filters'], pipeline['id'], pipeline['name']) # Delete entry from control_pipelines payload = PayloadBuilder().WHERE(['cpid', '=', pipeline['id']]).payload() await storage.delete_from_tbl("control_pipelines", payload) From 494f4e1f81b1fe319e16b173f3bb52f21f8cb3f5 Mon Sep 17 00:00:00 2001 From: dianomicbot Date: Wed, 10 Apr 2024 11:13:51 +0000 Subject: [PATCH 145/146] VERSION changed Signed-off-by: dianomicbot --- VERSION | 2 +- docs/91_version_history.rst | 40 +++++++++++++++++++++++++++++++++++++ docs/conf.py | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 9cb99d227c..c612d5c498 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ -fledge_version=2.3.0 +fledge_version=2.4.0 fledge_schema=70 diff --git a/docs/91_version_history.rst b/docs/91_version_history.rst index 327879bcd0..5f0c274c92 100644 --- a/docs/91_version_history.rst +++ b/docs/91_version_history.rst @@ -25,6 +25,46 @@ Version History Fledge v2 ========== +v2.4.0 +------- + +Release Date: 2024-04-10 + +- **Fledge Core** + + - New Features: + + - A new storage configuration option has been added that allows the server request timeout value to be modified has been added. + + + - Bug Fix: + + - The return type of plugin_shutdown was incorrectly documented in the plugin developers guide for north plugins. This has now been resolved. + + +- **GUI** + + - New Features: + + - + + + - Bug Fix: + + + +- **Plugins** + + - New Features: + + - A new notification delivery plugin has been added the will create an alert in the user interface. + + + - Bug Fix: + + - An issue with alarm data in the SKF Observer plugin has been resolved. + + v2.3.0 ------- diff --git a/docs/conf.py b/docs/conf.py index 4069ee0648..42b9647e39 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.4.0RC'"], shell=True, check=True) From 508d264dc757c6be828cef89aa94e375423a9331 Mon Sep 17 00:00:00 2001 From: Mohit04tomar <43023917+Mohit04tomar@users.noreply.github.com> Date: Wed, 17 Apr 2024 18:14:13 +0530 Subject: [PATCH 146/146] Updated 91_version_history file for 2.4.0 release (#1341) --- docs/91_version_history.rst | 71 +++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/docs/91_version_history.rst b/docs/91_version_history.rst index 5f0c274c92..5e3a1caef4 100644 --- a/docs/91_version_history.rst +++ b/docs/91_version_history.rst @@ -14,7 +14,6 @@ check here - .. ============================================= @@ -34,11 +33,37 @@ Release Date: 2024-04-10 - New Features: - - A new storage configuration option has been added that allows the server request timeout value to be modified has been added. + - A new feature has been added that allows for internal alerts to be raised. These are used to inform users of any issue internally that may require attention, they are not related to specific data that is flowing through the Fledge data pipelines. Examples of alerts that may be raised are that updates to the software are available, a service is repeatedly failing or an exceptional issue has occurred. + - A new task (update checker) has been added that will run periodically and raise an alert if there are software updates available. + - The internal service monitor has been updated to use the new alerts mechanism to alert a user if services are failing. + - A new storage configuration option has been added that allows the server request timeout value to be modified. + - The ability to tune the cache flushing frequency of the asset tracker has been added to the advanced configuration options of the south and north services. + - The reporting of south service send latency has been updated to give more detail regarding continue send latency issues. + - A new tuning parameter has been added to the purge process to control the number of readings purged within single blocks. This can be used to tune the intention between the purge process and the ingestion of new data in highly loaded systems. + - A new list type has been added to the types supported in the configuration category. This allows for improved configuration interactions. + - Support has been added in the C++ configuration manager code to allow for the new list, key/value list and object list types in configuration categories. Also some convenience functions have been added for use by plugins that wish to traverse the lists. + - In the hierarchy map, forward-slash-separated string tokens in the meta-data are now parsed and used to construct an object hierarchy in the OPC UA Server's Address Space. + - The scheduler has been enhanced to provide the capability to order the startup of services when Fledge is started. + - A performance improvement, courtesy of a community member, for the JSON escaping code has been added. This improves performance of the PostgreSQL storage plugin and other areas of the system. + - A new section has been added to the documentation that describes how storage plugins are built. + - The plugin developers guide has been updated with information and examples of the new list handling facilities added to configuration items within Fledge. + - The tuning section of the documentation has been updated to include details of the service startup ordering enhancement. + - The plugin documentation has been updated to include cross referencing between plugins. A new See Also section will be included that will link the set to other plugins that might be useful or relate to the plugin that is being viewed. + - The plugin developers guide has been updated to add some additional guidance to the developer as to how to decide if features should be added to a plugin or not and also to document common problems that cause problems with plugins. + - Documentation that describes what firewall settings are needed to install Fledge has been added to the quick start guide. - Bug Fix: + - An issue that prevented configuration categories items called messages has been resolved. + - An issue that could cause data to be repeated in a north service when using a pipeline in the north that adds new readings to the pipeline has been resolved. + - An issue that could cause the order of filters in a control pipeline API to be modified has been fixed. + - An issue that could result in series that are already installed being shown in the list of services available to be installed has been resolved. + - An issue that could cause some north plugins to fail following a restart when using the SQLite in-memory storage plugin has been fixed. + - An issue that could prevent a plugin being updated in some circumstances has been resolved. + - An issue requiring a restart before the change in log level for the storage service took effect has been resolved. + - An issue causing the database to potentially not initialize correctly when switching the readings plugin from SQLite to PostgreSQL has been resolved. + - An issue in the control pipeline API related to the type of one of the parameters of the pipeline has been resolved. This issue could manifest itself as an inability to edit a control pipeline. - The return type of plugin_shutdown was incorrectly documented in the plugin developers guide for north plugins. This has now been resolved. @@ -46,23 +71,55 @@ Release Date: 2024-04-10 - New Features: - - + - A new page has been added for managing additional services within an instance. + - Support for entering simple lists for configuration items has been added. + - Support has been added for manipulating key/value lists using the new available list configuration type that is available. + - Navigation buttons have been added to the tabs in the south and north services to facilitate easier navigation between tabs. + - A preview of the new flow editor for the north side has been added. This may be enabled via the GUI settings page. + - The GUI now shows the internal alerts via an icon in the navigation bar at the top of the screen. - Bug Fix: + - An issue with creating an operation in a control script with no parameters in the GUI has been resolved. + - An issue with the Next button not being enabled when changing the name of a service in the service creation wizard has been resolved. + - An issue that could result in a filter not being added to a control pipeline when the user does not click on the see button has been addressed by adding a check before navigating off the page. + - An issue that could result in the JSON code editor being incorrectly displayed for non-JSON code has been resolved. + - An issue with the visibility of the last item on the side menu when scrolling in a small window has been resolved. -- **Plugins** +- **Services & Plugins** - New Features: - - - A new notification delivery plugin has been added the will create an alert in the user interface. + + - Improvements have been made to the buffering strategy of the OMF north plugin to reduce the overhead in creating outgoing OMF messages. + - The control pipelines mechanism has been enhanced to allow pipelines to change the name of the operation that is performed as well as the parameters. + - The documentation of the expression filter has been updated to document the restriction on asset and datapoint names. - Bug Fix: - - An issue with alarm data in the SKF Observer plugin has been resolved. + - An issue with the dynamic reconfiguration of filters in control pipelines has been resolved. + - An issue that could cause the control dispatcher service to fail when changing the destination of a control pipeline has been resolved. + - An issue with the control dispatcher that prevents operations with no parameters from being correctly passed via control pipelines has been resolved. + - An issue in the control dispatcher that could cause a crash if a control pipeline completely removed the request has now been resolved. + - An issue that could cause an error to be logged when installing the control dispatcher has been resolved. The error did not prevent the dispatcher from executing. + - An issue when using the PostgreSQL storage plugin and data containing double quotes within JSON data has been resolved. + - An issue that could cause an error in the south plugin written in Python that supports control operations has been resolved. + - A memory consumption issue in the fledge-filter-asset when using the flatten option has been resolved. + - The fledge-filter-asset issue causing deadlock in pipelines with two instances has been resolved. + - An issue that limited the number of variables the fledge-south-s2opcua plugin could subscribed to has been resolved. + - An issue that could result in the sent count being incorrectly incremented when using the fledge-north-kafka (C based) plugin has been resolved. + - An issue that could cause excessive messages regarding connection loss and regain to be raised in the OMF north plugin has been resolved. + - An issue that caused the fledge-north-kafka (C based) plugin to fetch data when it was disabled has been resolved. + - If you set the User Authentication Policy to username, you must select a Security Policy other than None to communicate with the OPC UA Server. Allowing username authentication with None would mean that usernames and passwords would be passed from the plugin to the server as clear text which is a serious security risk. This is explained in the `OPC UA Specification `_. In addition, OPC UA defines a Security Policy for a "UserIdentityToken". When configuring the fledge-south-s2opcua plugin, the Security Policy selected in your configuration must match a supported "UserIdentityToken" Security Policy. To help troubleshoot configuration problems, log messages for the endpoint search have been improved. The documentation includes a new section called "Username Authentication". + - If a datapoint or asset name contains a reserved mathematical symbol then the fledge-filter-expression plugin was previously unable to use this as a variable in an expression. A mechanism has been added to allow these names. + - The Notification service would create Rule and Delivery support objects even if the notification was disabled. When the notification was later enabled, the original objects would remain. This has been fixed. + - If the OMF North plugin gets an exception when POSTing data to the PI Web API, the plugin would declare the connection to PI broken when it wasn't. This would result in endless connection loss and reconnection messages. This has been fixed. The plugin will now ping the PI Web API every 60 seconds and will determine that connection has been lost only if this ping fails. The OMFHint LegacyType has been deprecated because a Container cannot be changed after it is created in the PI System. This means there is no way to process the LegacyType hint when readings are processed. If the LegacyType hint appears in any reading, a warning message will be written saying that this hint type has been deprecated. + - This fix applies when configuring OMF North to create an Asset Framework (AF) structure. The first time an AF Element holding an AF Attribute pointing to a PI Point (i.e. the Container) is created, it will appear in Asset Framework as a normal AF Element. If the path is then changed using an "AFLocation hint", a reference to the AF Element should appear in the hint's location. The original AF Element's location should remain unchanged. This feature was not working correctly but has been fixed. Before this fix, the hint's path would be created as expected but no reference to the original data location was created. + - The storage service with the SQLite in-memory plugin does consume large amounts of memory while running at higher data rates. Analysis has determined this is not caused by a memory leak but rather by legitimately storing large amounts of data in memory while operating. The reason for the high load on the storage service appears to be database purging but this is a subject for further study. + - An issue in the control pipeline documentation that stated that services could only be the source of control pipelines has been fixed to now show that they may be the source or the destination. + - It is not possible to change the numeric data type of OMF Container (which maps to a PI Point) after it has been created. This means it is not possible to enable or disable an integer OMFHint or change the numeric data type in the Fledge GUI after the Container has been created. It is possible to manually correct the problem if it is necessary. OMF North plugin documentation has been updated with the procedure. v2.3.0