From 43081ed366eb52108604c7f92af9e1bf6d656d55 Mon Sep 17 00:00:00 2001 From: Alessandro <37877991+aledefra@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:05:49 +0200 Subject: [PATCH 1/7] feat: add alias management in tunnels manager (#272) --- .../business/tunnels/tunnels_manager.py | 83 ++++++++++++++++++- ver.py | 2 +- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/extensions/business/tunnels/tunnels_manager.py b/extensions/business/tunnels/tunnels_manager.py index 53daa21a..c6c5e6ac 100644 --- a/extensions/business/tunnels/tunnels_manager.py +++ b/extensions/business/tunnels/tunnels_manager.py @@ -1,6 +1,6 @@ from naeural_core.business.default.web_app.supervisor_fast_api_web_app import SupervisorFastApiWebApp as BasePlugin -__VER__ = '0.0.1' +__VER__ = '0.0.2' MESSAGE_PREFIX = "Please sign this message to manage your tunnels: " @@ -216,10 +216,12 @@ def delete_tunnel(self, tunnel_id: str, cloudflare_account_id: str, cloudflare_z } @BasePlugin.endpoint(method="post") - def add_custom_hostname(self, tunnel_id: str, hostname: str, cloudflare_account_id: str, cloudflare_zone_id: str, cloudflare_api_key: str): + def add_custom_hostname(self, tunnel_id: str, hostname: str, cloudflare_account_id: str, cloudflare_zone_id: str, cloudflare_api_key: str, cloudflare_domain: str): """ Add a new custom hostname for a specific tunnel based on its ID. """ + if hostname.endswith(cloudflare_domain): + raise Exception(f"Hostname cannot be a subdomain of the main domain {cloudflare_domain}. Use add_alias instead.") value = self.get_tunnel(tunnel_id, cloudflare_account_id, cloudflare_api_key) if value is None: raise Exception(f"Tunnel {tunnel_id} not found.") @@ -290,6 +292,83 @@ def delete_custom_hostname(self, tunnel_id: str, hostname_id: str, cloudflare_ac "success": True, } + @BasePlugin.endpoint(method="post") + def add_alias(self, tunnel_id: str, alias: str, cloudflare_account_id: str, cloudflare_zone_id: str, cloudflare_api_key: str, cloudflare_domain: str): + """ + Add a new alias (CNAME) on the same domain for a specific tunnel based on its ID. + """ + if not alias.endswith(cloudflare_domain): + raise Exception(f"Alias must be a subdomain of the main domain {cloudflare_domain}. Use add_custom_hostname instead.") + value = self.get_tunnel(tunnel_id, cloudflare_account_id, cloudflare_api_key) + if value is None: + raise Exception(f"Tunnel {tunnel_id} not found.") + + url = f"{self.cfg_base_cloudflare_url}/client/v4/zones/{cloudflare_zone_id}/dns_records" + headers = { + "Authorization": f"Bearer {cloudflare_api_key}" + } + data = { + "type": "CNAME", + "proxied": True, + "name": alias, + "content": f"{value['id']}.cfargotunnel.com", + } + dns_record = self.requests.post(url, headers=headers, json=data).json() + if dns_record["success"] is False: + raise Exception("Error creating alias: " + str(dns_record['errors'])) + + if 'aliases' not in value['metadata']: + value['metadata']['aliases'] = [] + value['metadata']['aliases'].append({ + "id": dns_record['result']['id'], + "name": alias + }) + self._cloudflare_update_metadata( + tunnel_id=tunnel_id, + metadata=value['metadata'], + cloudflare_account_id=cloudflare_account_id, + cloudflare_api_key=cloudflare_api_key + ) + return { + "success": True, + } + + @BasePlugin.endpoint(method="delete") + def delete_alias(self, tunnel_id: str, alias_id: str, cloudflare_account_id: str, cloudflare_zone_id: str, cloudflare_api_key: str): + """ + Remove an alias (CNAME) from a specific tunnel based on its ID. + """ + value = self.get_tunnel(tunnel_id, cloudflare_account_id, cloudflare_api_key) + if value is None: + raise Exception(f"Tunnel {tunnel_id} not found.") + + if 'aliases' not in value['metadata']: + raise Exception(f"No aliases found for tunnel {tunnel_id}.") + + alias = next((a for a in value['metadata']['aliases'] if a['id'] == alias_id), None) + if alias is None: + raise Exception(f"Alias {alias_id} not found for tunnel {tunnel_id}.") + + url = f"{self.cfg_base_cloudflare_url}/client/v4/zones/{cloudflare_zone_id}/dns_records/{alias_id}" + headers = { + "Authorization": f"Bearer {cloudflare_api_key}" + } + response = self.requests.delete(url, headers=headers).json() + if response["success"] is False: + raise Exception("Error deleting alias: " + str(response['errors'])) + + value['metadata']['aliases'].remove(alias) + self._cloudflare_update_metadata( + tunnel_id=tunnel_id, + metadata=value['metadata'], + cloudflare_account_id=cloudflare_account_id, + cloudflare_api_key=cloudflare_api_key + ) + + return { + "success": True, + } + @BasePlugin.endpoint(method="post") def rename_tunnel(self, tunnel_id: str, new_alias: str, cloudflare_account_id: str, cloudflare_api_key: str): """ diff --git a/ver.py b/ver.py index c879c128..9df9244a 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.9.740' +__VER__ = '2.9.741' From 8cdc922e1442a5064dcd55bf1d0a4a4b44ecbf59 Mon Sep 17 00:00:00 2001 From: Vitalii <87299468+vitalii-t12@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:52:56 +0300 Subject: [PATCH 2/7] Fix aiho sensibo data retrieval & add logging (#274) * fix: revert maybe_reconnect * wip * wip2 * fix: update log * chore: inc version --- .../eth2025/sensibo_maintenance_sensor.py | 171 ++++++++++++++---- ver.py | 2 +- 2 files changed, 135 insertions(+), 38 deletions(-) diff --git a/plugins/data/eth2025/sensibo_maintenance_sensor.py b/plugins/data/eth2025/sensibo_maintenance_sensor.py index 70689908..9f8089ec 100644 --- a/plugins/data/eth2025/sensibo_maintenance_sensor.py +++ b/plugins/data/eth2025/sensibo_maintenance_sensor.py @@ -8,6 +8,7 @@ "SENSIBO_API_KEY": "", "SENSIBO_DEVICE_NAME": "R1 Sensibo", "SENSIBO_POD_UID": "", # If provided, will use this instead of looking up by device name + "VERBOSITY": 1, # 0=minimal, 1=normal, 2=verbose, 3=debug 'VALIDATION_RULES': { **DataCaptureThread.CONFIG['VALIDATION_RULES'], @@ -20,31 +21,52 @@ class SensiboMaintenanceSensorDataCapture(DataCaptureThread): CONFIG = _CONFIG - def __init__(self, **kwargs): - super(SensiboMaintenanceSensorDataCapture, self).__init__(**kwargs) - self._last_acquisition_time = None - return - def _init(self): - self._maybe_reconnect() + def _log(self, message, verbosity_level=1, color=None): + """ + Conditional logging based on verbosity level. + + Args: + message: The message to log + verbosity_level: Minimum verbosity level required to show this message + color: Optional color for the message + """ + if self.cfg_verbosity >= verbosity_level: + self.P(message, color=color) + + def on_init(self): + super().on_init() + self._log("Initializing SensiboMaintenanceSensorDataCapture", verbosity_level=1) + self._api_key = None + self._uid = None + self._device_name = None + self._last_acquisition_time = None return def __get_data(self, path, **params): + self._log(f"Making API request to: {_SERVER + path}", verbosity_level=3) params['apiKey'] = self._api_key response = self.requests.get(_SERVER + path, params=params) response.raise_for_status() + self._log(f"API request successful, status: {response.status_code}", verbosity_level=3) return response.json() def __list_devices(self): + self._log("Fetching list of Sensibo devices", verbosity_level=2) result = self.__get_data('/users/me/pods', fields='id,room') - self.P("Sensibo device info:\n{}".format(self.json.dumps(result, indent=4))) - return {x['room']['name']: x['id'] for x in result['result']} + self._log("Sensibo device info:\n{}".format(self.json.dumps(result, indent=4)), verbosity_level=2) + devices = {x['room']['name']: x['id'] for x in result['result']} + self._log(f"Found {len(devices)} devices: {list(devices.keys())}", verbosity_level=1) + return devices def __get_measurement(self, pod_uid=None): if pod_uid is None: pod_uid = self._uid + self._log(f"Fetching measurements for pod UID: {pod_uid}", verbosity_level=2) results = self.__get_data('/pods/{}/measurements'.format(pod_uid)) results = results['result'] + self._log(f"Received {len(results)} measurement records", verbosity_level=2) + for res in results: if 'time' in res: str_dt = res['time']['time'] @@ -53,47 +75,122 @@ def __get_measurement(self, pod_uid=None): res['read_time'] = dt.isoformat() res['read_time_str'] = str_dt res['read_delay'] = delay - self.P("Measurement data:\n{}".format(self.json.dumps(results, indent=4))) + self._log(f"Processed timestamp: {str_dt} (delay: {delay}s)", verbosity_level=3) + + self._log("Measurement data:\n{}".format(self.json.dumps(results, indent=4)), verbosity_level=2) return results - def _maybe_reconnect(self): # MANDATORY - if self.has_connection: - return - self.has_connection = True - self._api_key = self.cfg_sensibo_api_key - - # Use the pod UID directly if provided - if hasattr(self, 'cfg_sensibo_pod_uid') and self.cfg_sensibo_pod_uid: - self._uid = self.cfg_sensibo_pod_uid - return - - # Otherwise look up the device by name - self._device_name = self.cfg_sensibo_device_name - devices = self.__list_devices() + def connect(self): # MANDATORY + if self.has_connection and self._uid: + self._log("Connection already established, skipping new connect", verbosity_level=2) + return True + + self._log("Establishing connection to Sensibo API", verbosity_level=0) + self._api_key = (self.cfg_sensibo_api_key or '').strip() + if not self._api_key: + self._log("SENSIBO_API_KEY is missing", verbosity_level=0, color='r') + self.has_connection = False + return False + + self._log( + f"Using API key prefix: {self._api_key[:8]}...", + verbosity_level=2, + ) + + pod_uid = (self.cfg_sensibo_pod_uid or '').strip() + if pod_uid: + self._uid = pod_uid + self.has_connection = True + self._log(f"Using provided pod UID: {self._uid}", verbosity_level=0) + return True + + self._device_name = (self.cfg_sensibo_device_name or '').strip() + if not self._device_name: + self._log("SENSIBO_DEVICE_NAME is missing", verbosity_level=0, color='r') + self.has_connection = False + return False + + self._log(f"Looking up device by name: {self._device_name}", verbosity_level=1) + try: + devices = self.__list_devices() + except Exception as exc: + self._log(f"Failed to list Sensibo devices: {exc}", verbosity_level=0, color='r') + self.has_connection = False + return False + + if self._device_name not in devices: + available = ', '.join(devices.keys()) if devices else 'none' + self._log( + f"Device '{self._device_name}' not found. Available devices: {available}", + verbosity_level=0, + color='r', + ) + self.has_connection = False + return False + self._uid = devices[self._device_name] + self.has_connection = True + self._log( + f"Successfully connected to device '{self._device_name}' with UID: {self._uid}", + verbosity_level=0, + ) + return True + + def _release(self): # MANDATORY + self._log("Releasing Sensibo connection", verbosity_level=2) + self._uid = None + self._api_key = None + self._device_name = None + self.has_connection = False return def __get_data_from_sensibo(self): + self._log("Fetching latest data from Sensibo", verbosity_level=2) res = self.__get_measurement(self._uid) - return res[-1] + latest_data = res[-1] if res else None + if latest_data: + self._log(f"Retrieved latest measurement: temp={latest_data.get('temperature', 'N/A')}°C, humidity={latest_data.get('humidity', 'N/A')}%", verbosity_level=1) + else: + self._log("No measurement data available", verbosity_level=0, color='r') + return latest_data + + def data_step(self): # MANDATORY + self._maybe_reconnect() + if not self.has_connection: + self._log("Unable to establish connection to Sensibo API", verbosity_level=0, color='r') + return - def _run_data_acquisition_step(self): # MANDATORY current_time = self.datetime.now() - + self._log(f"Starting data acquisition step at {current_time.isoformat()}", verbosity_level=3) + # Check if 30 seconds have elapsed since the last acquisition if self._last_acquisition_time is not None: elapsed_seconds = (current_time - self._last_acquisition_time).total_seconds() + self._log(f"Time since last acquisition: {elapsed_seconds:.1f} seconds", verbosity_level=3) if elapsed_seconds < 30: - self.P(f"Skipping data acquisition, only {elapsed_seconds:.1f} seconds elapsed (< 30s required)") + self._log(f"Skipping data acquisition, only {elapsed_seconds:.1f} seconds elapsed (< 30s required)", verbosity_level=2) return - - _obs = self.__get_data_from_sensibo() - self._last_acquisition_time = current_time - - self._add_inputs( - [ - self._new_input(img=None, struct_data=_obs, metadata=self._metadata.__dict__.copy()), - ] - ) + else: + self._log("First data acquisition, no previous time recorded", verbosity_level=2) + + try: + _obs = self.__get_data_from_sensibo() + self._last_acquisition_time = current_time + + if _obs: + self._log(f"Successfully acquired data: {self.json.dumps(_obs, indent=2)}", verbosity_level=2) + self._add_inputs( + [ + self._new_input(img=None, struct_data=_obs, metadata=self._metadata.__dict__.copy()), + ] + ) + self._log("Data added to input queue", verbosity_level=3) + else: + self._log("No data acquired from Sensibo", verbosity_level=0, color='r') + + except Exception as e: + self._log(f"Error during data acquisition: {str(e)}", verbosity_level=0, color='r') + raise + + self._log("Data step completed", verbosity_level=3) return - diff --git a/ver.py b/ver.py index 9df9244a..65c35058 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.9.741' +__VER__ = '2.9.742' From 0d8269a11d1e0cf532ec0ead9d09db988d44b316 Mon Sep 17 00:00:00 2001 From: Vitalii <87299468+vitalii-t12@users.noreply.github.com> Date: Tue, 14 Oct 2025 17:08:25 +0300 Subject: [PATCH 3/7] fix: deeploy job discovery & resources checks (#275) * fix: job discovery * fix: add explicit errors for failing job resources validation * fix: add explicit errors for failing job resources validation * chore: inc version --- extensions/business/deeploy/deeploy_const.py | 3 +++ extensions/business/deeploy/deeploy_mixin.py | 16 ++++++++++------ ver.py | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/extensions/business/deeploy/deeploy_const.py b/extensions/business/deeploy/deeploy_const.py index d031e17e..6a696baa 100644 --- a/extensions/business/deeploy/deeploy_const.py +++ b/extensions/business/deeploy/deeploy_const.py @@ -89,6 +89,9 @@ class DEEPLOY_ERRORS: NODETAGS1 = "ERR19_NODETAGS1" NODETAGS2 = "ERR20_NODETAGS2" NODETAGS3 = "ERR21_NODETAGS3" + JOB_RESOURCES1 = "ERR22_JOB_RESOURCES1" + JOB_RESOURCES2 = "ERR23_JOB_RESOURCES2" + JOB_RESOURCES3 = "ERR24_JOB_RESOURCES3" class DEEPLOY_RESOURCES: # Result dictionary keys diff --git a/extensions/business/deeploy/deeploy_mixin.py b/extensions/business/deeploy/deeploy_mixin.py index 7264d056..896a6b18 100644 --- a/extensions/business/deeploy/deeploy_mixin.py +++ b/extensions/business/deeploy/deeploy_mixin.py @@ -513,12 +513,14 @@ def deeploy_check_payment_and_job_owner(self, inputs, sender, is_create, debug=F job_type = job.get('jobType') if job_type is None: self.P(f"Job type missing or invalid for job {job_id}. Cannot validate resources.") - return False + msg = (f"{DEEPLOY_ERRORS.JOB_RESOURCES1}: Job type missing or invalid for job {job_id}.") + raise ValueError(msg) #endif expected_resources = JOB_TYPE_RESOURCE_SPECS.get(job_type) if expected_resources is None: self.P(f"No resource specs configured for job type {job_type}. Cannot validate resources.") - return False + msg = (f"{DEEPLOY_ERRORS.JOB_RESOURCES2}: No resource specs configured for job type {job_type}.") + raise ValueError(msg) #endif if expected_resources: required_resources = inputs.app_params.get(DEEPLOY_RESOURCES.CONTAINER_RESOURCES, {}) @@ -538,9 +540,11 @@ def deeploy_check_payment_and_job_owner(self, inputs, sender, is_create, debug=F f"Requested resources {required_resources} do not match paid resources " f"{expected_resources} for job type {job_type}." ) - is_valid = False - #endif resources match - #endif expected resources + msg = (f"{DEEPLOY_ERRORS.JOB_RESOURCES3}: Requested resources {required_resources} " + + f"do not match paid resources {expected_resources} for job type {job_type}.") + raise ValueError(msg) + # endif resources match + # endif expected resources # endif is valid else: # job not found self.P(f"Job {job_id} not found.") @@ -1191,7 +1195,7 @@ def _get_online_apps(self, owner=None, target_nodes=None, job_id=None): filtered_result = self.defaultdict(dict) for node, apps in result.items(): for app_name, app_data in apps.items(): - if app_data.get(ct.CONFIG_STREAM.DEEPLOY_SPECS, {}).get(DEEPLOY_KEYS.JOB_ID, None) != job_id: + if app_data.get(NetMonCt.DEEPLOY_SPECS, {}).get(DEEPLOY_KEYS.JOB_ID, None) != job_id: continue filtered_result[node][app_name] = app_data result = filtered_result diff --git a/ver.py b/ver.py index 65c35058..cbc2939c 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.9.742' +__VER__ = '2.9.743' From 99deeda4ddbd5969c88e59d2a1e89b2bb398c15a Mon Sep 17 00:00:00 2001 From: Vitalii <87299468+vitalii-t12@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:12:58 +0300 Subject: [PATCH 4/7] Feat multiple plugins support for native apps (#276) * feat: add job_app_type to deeploy_specs * feat: add the possibility of deploying apps with pipelines * chore: inc version * fix: docs --- extensions/business/deeploy/deeploy_const.py | 98 +++- .../business/deeploy/deeploy_manager_api.py | 221 ++++++-- extensions/business/deeploy/deeploy_mixin.py | 531 ++++++++++++++++-- ver.py | 2 +- 4 files changed, 729 insertions(+), 123 deletions(-) diff --git a/extensions/business/deeploy/deeploy_const.py b/extensions/business/deeploy/deeploy_const.py index 6a696baa..1c482106 100644 --- a/extensions/business/deeploy/deeploy_const.py +++ b/extensions/business/deeploy/deeploy_const.py @@ -35,6 +35,7 @@ class DEEPLOY_KEYS: PROJECT_ID = "project_id" # this is the SHA256(UUID) generated by UI PROJECT_NAME = "project_name" JOB_TAGS = "job_tags" + JOB_APP_TYPE = "job_app_type" CHAINSTORE_RESPONSE = "chainstore_response" PIPELINE_INPUT_TYPE = "pipeline_input_type" PIPELINE_INPUT_URI = "pipeline_input_uri" @@ -42,6 +43,10 @@ class DEEPLOY_KEYS: APP_PARAMS = "app_params" APP_PARAMS_IMAGE = "IMAGE" APP_PARAMS_CR = "CR" + # Multi-plugin keys + PLUGINS = "plugins" + PLUGIN_INSTANCES = "instances" + PLUGIN_INSTANCE_ID = "instance_id" # Auth result keys SENDER = "sender" SENDER_ORACLES = "sender_oracles" @@ -92,6 +97,9 @@ class DEEPLOY_ERRORS: JOB_RESOURCES1 = "ERR22_JOB_RESOURCES1" JOB_RESOURCES2 = "ERR23_JOB_RESOURCES2" JOB_RESOURCES3 = "ERR24_JOB_RESOURCES3" + PLUGINS1 = "ERR25_DEEPLOY_PLUGINS1" # Invalid plugins array structure + PLUGINS2 = "ERR26_DEEPLOY_PLUGINS2" # Plugin missing required field + PLUGINS3 = "ERR27_DEEPLOY_PLUGINS3" # Instance validation failed class DEEPLOY_RESOURCES: # Result dictionary keys @@ -188,6 +196,20 @@ class DEEPLOY_PLUGIN_DATA: CHAINSTORE_RESPONSE_KEY = "CHAINSTORE_RESPONSE_KEY" CONTAINER_APP_RUNNER_SIGNATURE = 'CONTAINER_APP_RUNNER' +WORKER_APP_RUNNER_SIGNATURE = 'WORKER_APP_RUNNER' + + +class JOB_APP_TYPES: + GENERIC = "generic" + NATIVE = "native" + SERVICE = "service" + + +JOB_APP_TYPES_ALL = ( + JOB_APP_TYPES.GENERIC, + JOB_APP_TYPES.NATIVE, + JOB_APP_TYPES.SERVICE, +) DEEPLOY_FORBIDDEN_SIGNATURES = ['ADMIN_CONTAINER_APP_RUNNER'] DEEPLOY_CREATE_REQUEST = { @@ -195,6 +217,7 @@ class DEEPLOY_PLUGIN_DATA: "project_id" : "DELIVERD_BY_UI = SHA256(UUID)", "project_name": "human-readable-project-name", "job_id" : "DELIVERED_BY_UI_FROM_SC", + "job_app_type": "generic", "plugin_signature" : "SOME_PLUGIN_01", "nonce" : hex(int(time() * 1000)), # recoverable via int(nonce, 16) "target_nodes" : [ @@ -449,7 +472,7 @@ class DEEPLOY_PLUGIN_DATA: } DEEPLOY_CREATE_REQUES_NATIVE_APPS = { - "app_alias" : "some_app_name", + "app_alias" : "some_app_name", "project_id" : "DELIVERD_BY_UI = SHA256(UUID)", "project_name": "human-readable-project-name", "job_id" : "DELIVERED_BY_UI_FROM_SC", @@ -473,12 +496,12 @@ class DEEPLOY_PLUGIN_DATA: # - if NGROK_EDGE_LABEL is not None and NGROK_ENABLED is True => normal use # - if NGROK_EDGE_LABEL is None and NGROK_ENABLED is True => create/use dynamic url # - if NGROK_EDGE_LABEL is not None and NGROK_ENABLED is False => consider NGROK_ENABLED=True - - "NGROK_USE_API": True, # use API or shell for ngrok tunnel creation - + + "NGROK_USE_API": True, # use API or shell for ngrok tunnel creation + # TODO: in v2 we need to have for each native plugin a set of default params and their values # thus the below params will be pre-completed with these default key-values - + "OTHER_PARAM1" : "value1", "OTHER_PARAM2" : "value2", "OTHER_PARAM3" : "value3", @@ -494,6 +517,71 @@ class DEEPLOY_PLUGIN_DATA: "chainstore_response" : False, } +################################################################################################################### +# Multi-plugin request format (NEW - recommended for multiple plugins) +DEEPLOY_CREATE_REQUEST_MULTI_PLUGIN = { + # Pipeline-level configuration + "app_alias" : "EdgeNodeApiTest", + "project_id" : "DELIVERD_BY_UI = SHA256(UUID)", + "project_name": "human-readable-project-name", + "job_id" : "DELIVERED_BY_UI_FROM_SC", + "job_app_type": "native", + "nonce" : hex(int(time() * 1000)), + + # Node selection + "target_nodes" : [ + "0xai_node_1", + ], + "target_nodes_count" : 0, # Use this OR target_nodes + "spare_nodes" : [], + "job_tags": ["KYB", "REG:EU"], + "node_res_req" : { + "cpu" : 4, + "memory" : "16GiB" + }, + + # Pipeline data source + "pipeline_input_type" : "void", # Pipeline TYPE + "pipeline_input_uri" : None, + "chainstore_response" : True, + + # Plugins array (NEW FORMAT - each object is a plugin instance) + "plugins": [ + { + # Plugin instance 1: EDGE_NODE_API_TEST + "signature": "EDGE_NODE_API_TEST" + }, + { + # Plugin instance 2: CONTAINER_APP_RUNNER + "signature": "CONTAINER_APP_RUNNER", + "IMAGE": "tvitalii/ratio1-drive:latest", + "CONTAINER_RESOURCES": { + "cpu": 2, + "memory": "4096m", + "ports": {} + }, + "CR": "docker.io", + "ENV": { + "INSTANCE_NAME": "R1TEST_INSTANCE" + }, + "DYNAMIC_ENV": { + "RATIO1_AGENT_ENDPOINT": [ + {"type": "static", "value": "http://"}, + {"type": "host_ip", "value": ""}, + {"type": "static", "value": ":15033/query"} + ] + }, + "VOLUMES": { + "ks_data": "/keysoft/storage" + }, + "PORT": None, + "RESTART_POLICY": "always", + "IMAGE_PULL_POLICY": "always", + "TUNNEL_ENGINE_ENABLED": False + } + ] +} + ################################################################################################################### DEEPLOY_SCALE_UP_JOB_WORKERS_REQUEST = { "job_id" : 1, # The job ID to extend the workers for diff --git a/extensions/business/deeploy/deeploy_manager_api.py b/extensions/business/deeploy/deeploy_manager_api.py index ce333fd0..0ca2060d 100644 --- a/extensions/business/deeploy/deeploy_manager_api.py +++ b/extensions/business/deeploy/deeploy_manager_api.py @@ -11,9 +11,9 @@ from .deeploy_target_nodes_mixin import _DeeployTargetNodesMixin from extensions.business.mixins.node_tags_mixin import _NodeTagsMixin from .deeploy_const import ( - DEEPLOY_CREATE_REQUEST, DEEPLOY_GET_APPS_REQUEST, DEEPLOY_DELETE_REQUEST, + DEEPLOY_CREATE_REQUEST, DEEPLOY_CREATE_REQUEST_MULTI_PLUGIN, DEEPLOY_GET_APPS_REQUEST, DEEPLOY_DELETE_REQUEST, DEEPLOY_ERRORS, DEEPLOY_KEYS, DEEPLOY_SCALE_UP_JOB_WORKERS_REQUEST, DEEPLOY_STATUS, DEEPLOY_INSTANCE_COMMAND_REQUEST, - DEEPLOY_APP_COMMAND_REQUEST, DEEPLOY_GET_ORACLE_JOB_DETAILS_REQUEST, DEEPLOY_PLUGIN_DATA, + DEEPLOY_APP_COMMAND_REQUEST, DEEPLOY_GET_ORACLE_JOB_DETAILS_REQUEST, DEEPLOY_PLUGIN_DATA, JOB_APP_TYPES, JOB_APP_TYPES_ALL, ) @@ -161,17 +161,34 @@ def _process_pipeline_request( The response dictionary """ try: - sender, inputs = self.deeploy_verify_and_get_inputs(request) + sender, inputs = self.deeploy_verify_and_get_inputs(request) + normalized_request = self._normalize_plugins_input(self.deepcopy(request)) + if DEEPLOY_KEYS.PLUGINS in normalized_request: + inputs[DEEPLOY_KEYS.PLUGINS] = normalized_request[DEEPLOY_KEYS.PLUGINS] auth_result = self.deeploy_get_auth_result(inputs) job_id = inputs.get(DEEPLOY_KEYS.JOB_ID, None) is_confirmable_job = inputs.chainstore_response - self._validate_request_input_for_signature(inputs) - # Check request mandatory fields. - self._check_plugin_signature(inputs.plugin_signature) + # Validate plugins array structure and required fields for each plugin + plugins_array = inputs.get(DEEPLOY_KEYS.PLUGINS) + if plugins_array: + self._validate_plugins_array(plugins_array) + else: + # This shouldn't happen after normalization, but handle as fallback + raise ValueError(f"{DEEPLOY_ERRORS.REQUEST3}. No plugins array found after normalization.") app_alias = inputs.app_alias app_type = inputs.pipeline_input_type + job_app_type = inputs.get(DEEPLOY_KEYS.JOB_APP_TYPE, None) + if job_app_type: + job_app_type = str(job_app_type).lower() + if job_app_type not in JOB_APP_TYPES_ALL: + raise ValueError(f"Invalid job_app_type '{job_app_type}'. Expected one of {JOB_APP_TYPES_ALL}.") + else: + job_app_type = self.deeploy_detect_job_app_type(self.deeploy_prepare_plugins(inputs)) + if job_app_type not in JOB_APP_TYPES_ALL: + job_app_type = JOB_APP_TYPES.NATIVE + self.P(f"Detected job app type: {job_app_type}") # Generate or get app_id based on operation type if is_create: @@ -209,6 +226,7 @@ def _process_pipeline_request( new_nodes=nodes, update_nodes=[], discovered_plugin_instances=discovered_plugin_instances, + job_app_type=job_app_type, ) else: dct_status, str_status = self.check_and_deploy_pipelines( @@ -220,6 +238,7 @@ def _process_pipeline_request( new_nodes=[], update_nodes=nodes, discovered_plugin_instances=discovered_plugin_instances, + job_app_type=job_app_type, ) if str_status in [DEEPLOY_STATUS.SUCCESS, DEEPLOY_STATUS.COMMAND_DELIVERED]: @@ -236,14 +255,21 @@ def _process_pipeline_request( return_request = request.get(DEEPLOY_KEYS.RETURN_REQUEST, False) if return_request: dct_request = self.deepcopy(request) + dct_request.pop(DEEPLOY_KEYS.APP_PARAMS, None) else: + # Build simplified request summary (no app_params - data is in plugins array now) dct_request = { DEEPLOY_KEYS.APP_ALIAS: app_alias, - DEEPLOY_KEYS.PLUGIN_SIGNATURE: inputs.plugin_signature, DEEPLOY_KEYS.TARGET_NODES: inputs.target_nodes, DEEPLOY_KEYS.TARGET_NODES_COUNT: inputs.target_nodes_count, + DEEPLOY_KEYS.JOB_APP_TYPE: job_app_type, } + # Include plugins count summary + plugins_array = inputs.get(DEEPLOY_KEYS.PLUGINS) + if plugins_array: + dct_request['plugins_count'] = len(plugins_array) + result = { DEEPLOY_KEYS.STATUS: str_status, DEEPLOY_KEYS.STATUS_DETAILS: dct_status, @@ -254,15 +280,6 @@ def _process_pipeline_request( if self.cfg_deeploy_verbose > 1: self.P(f"Request Result: {result}") - - # Safely add app_params if they exist and are not empty - if hasattr(inputs, DEEPLOY_KEYS.APP_PARAMS): - app_params = getattr(inputs, DEEPLOY_KEYS.APP_PARAMS, {}) - if isinstance(app_params, dict) and app_params: - if DEEPLOY_KEYS.APP_PARAMS_IMAGE in app_params: - result[DEEPLOY_KEYS.REQUEST][DEEPLOY_KEYS.APP_PARAMS_IMAGE] = app_params[DEEPLOY_KEYS.APP_PARAMS_IMAGE] - if DEEPLOY_KEYS.APP_PARAMS_CR in app_params: - result[DEEPLOY_KEYS.REQUEST][DEEPLOY_KEYS.APP_PARAMS_CR] = app_params[DEEPLOY_KEYS.APP_PARAMS_CR] except Exception as e: result = self.__handle_error(e, request) #endtry @@ -275,48 +292,111 @@ def _process_pipeline_request( @BasePlugin.endpoint(method="post") # /create_pipeline def create_pipeline( - self, + self, request: dict = DEEPLOY_CREATE_REQUEST ): """ - Create a new pipeline on a target node(s) - + Create a new pipeline on target node(s) with support for multiple plugins. + + Supports two request formats: + 1. **Plugin instances array (recommended)**: Use 'plugins' array for pipelines with multiple plugins + 2. **Legacy single-plugin format**: Provide 'plugin_signature'; legacy payloads are normalized into the plugins array Parameters ---------- - + request: dict containing next fields: + app_alias : str - The name (alias) of the app to create + The name (alias) of the app to create - plugin_signature : str - The signature of the plugin to use + pipeline_input_type : str + The pipeline type (e.g., 'void', 'JeevesApiListener') - target_nodes : list[str] - The nodes to create the app on + job_id : int + The job ID from blockchain - target_nodes_count : int - The number of nodes to create the app on + target_nodes : list[str] or target_nodes_count : int + Either specific nodes or count of nodes to deploy on + + job_tags : list + Tags for filtering target nodes + Example: ["KYB", "DC:HOSTINGER", "CT:FR|IT|RO", "REG:EU"] nonce : str The nonce used for signing the request - job_tags: list - Tags and their expected values that the target nodes must have - Example: ["KYB","DC:HOSTINGER", "CT:FR|IT|RO", "REG:EU"] + EE_ETH_SIGN : str + The signature of the request + + EE_ETH_SENDER : str + The sender wallet address + + **Plugin instances:** + plugins : list + Array of plugin instance configurations. Each object represents ONE plugin instance: + - signature : str (required) + The plugin signature (e.g., 'CONTAINER_APP_RUNNER', 'EDGE_NODE_API_TEST') + - **instance-specific parameters** (varies by plugin type) + For CONTAINER_APP_RUNNER: + - IMAGE : str (required) + - CONTAINER_RESOURCES : dict (required) + - cpu : int + - memory : str (e.g., "4096m", "4g") + - CR, PORT, ENV, VOLUMES, TUNNEL_ENGINE_ENABLED, etc. + For native plugins: + - Plugin-specific configuration parameters + + **Example request:** + { + "app_alias": "EdgeNodeApiTest", + "pipeline_input_type": "void", + "job_id": 123, + "target_nodes_count": 1, + "plugins": [ + { + "signature": "EDGE_NODE_API_TEST" + }, + { + "signature": "CONTAINER_APP_RUNNER", + "IMAGE": "tvitalii/ratio1-drive:latest", + "CONTAINER_RESOURCES": { + "cpu": 2, + "memory": "4096m" + }, + "PORT": 8080 + } + ], + "nonce": "0x...", + "EE_ETH_SIGN": "0x...", + "EE_ETH_SENDER": "0x..." + } + **Legacy single-plugin format (deprecated):** + plugin_signature : str + The signature of the single plugin to use. Configuration should remain embedded with the + plugin instance data; legacy `app_params` payloads are normalized into the plugins array + and omitted from responses. - app_params : dict - The parameters to pass to the app such as: + Returns + ------- + dict + Response containing: + - status : str + - app_id : str + - status_details : dict + - request : dict + - auth : dict + + Notes + ----- + - Multi-plugin pipelines are automatically classified as JOB_APP_TYPE.NATIVE + - Single CONTAINER_APP_RUNNER is classified as GENERIC or SERVICE + - Resource requirements are aggregated across all container plugins + - Multiple instances of the same plugin: Include multiple objects with the same signature + - Example: [{"signature": "PLUGIN_A", ...}, {"signature": "PLUGIN_A", ...}] creates 2 instances + - For multi-plugin templates, see DEEPLOY_CREATE_REQUEST_MULTI_PLUGIN in deeploy_const.py - app_params.IMAGE : str - The image to use for the app - app_params.REGISTRY : str - The registry to use for the app - app_params.USERNAME : str - The username to use for the app - app_params.PASSWORD : str - TODO: (Vitalii) - Add support to get the ngrok url if NO edge/endpoint is provided but ngrok is STILL used TODO: (Vitalii) @@ -335,42 +415,61 @@ def update_pipeline( request: dict = DEEPLOY_CREATE_REQUEST ): """ - Update a pipeline on node(s) + Update a pipeline on node(s) with support for multiple plugins. + + Supports the same formats as create_pipeline: + 1. **Plugin instances array**: Use 'plugins' array for pipelines with multiple plugins + 2. **Legacy format**: Provide 'plugin_signature'; legacy payloads are normalized into the plugins array Parameters ---------- request: dict containing next fields: - app_alias : str - The name (alias) of the app to create - plugin_signature : str - The signature of the plugin to use + app_id : str + The ID of the app to update (required for updates) - target_nodes : list[str] - The nodes to create the app on + app_alias : str + The name (alias) of the app - target_nodes_count : int - The number of nodes to create the app on + pipeline_input_type : str + The pipeline type + + job_id : int + The job ID from blockchain nonce : str The nonce used for signing the request - job_tags: list - Tags and their expected values that the target nodes must have - Example: ["KYB","DC:HOSTINGER", "CT:FR|IT|RO", "REG:EU"] + EE_ETH_SIGN : str + The signature of the request + + EE_ETH_SENDER : str + The sender wallet address + **Plugin instances:** + plugins : list + Array of plugin instance configurations. Each object represents ONE plugin instance: + - signature : str (required) + - **instance-specific parameters** - app_params : dict - The parameters to pass to the app such as: + **Legacy format:** + plugin_signature : str + The signature of the single plugin. Legacy payloads without the plugins array are + normalized internally; any deprecated `app_params` field is ignored in responses. - app_params.IMAGE : str - The image to use for the app - app_params.REGISTRY : str - The registry to use for the app - app_params.USERNAME : str - The username to use for the app - app_params.PASSWORD : str + Returns + ------- + dict + Response containing update status and details + + Notes + ----- + - Updates are applied to existing plugin instances on the same nodes + - For multi-plugin pipelines, all plugins are updated with new configurations + - Resource validation applies the same as create operations + - The simplified plugins array format is the same as create_pipeline + - See create_pipeline endpoint for detailed parameter documentation and examples """ self.P(f"Received an update_pipeline request with body: {self.json_dumps(request)}") diff --git a/extensions/business/deeploy/deeploy_mixin.py b/extensions/business/deeploy/deeploy_mixin.py index 896a6b18..6eefbc9f 100644 --- a/extensions/business/deeploy/deeploy_mixin.py +++ b/extensions/business/deeploy/deeploy_mixin.py @@ -4,7 +4,7 @@ from extensions.business.deeploy.deeploy_const import DEEPLOY_ERRORS, DEEPLOY_KEYS, \ DEEPLOY_STATUS, DEEPLOY_PLUGIN_DATA, DEEPLOY_FORBIDDEN_SIGNATURES, CONTAINER_APP_RUNNER_SIGNATURE, \ - DEEPLOY_RESOURCES, JOB_TYPE_RESOURCE_SPECS + DEEPLOY_RESOURCES, JOB_TYPE_RESOURCE_SPECS, WORKER_APP_RUNNER_SIGNATURE, JOB_APP_TYPES, JOB_APP_TYPES_ALL DEEPLOY_DEBUG = True @@ -109,7 +109,7 @@ def __check_is_oracle(self, inputs): raise ValueError("Sender {} is not an oracle".format(sender)) return True - def __create_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, sender): + def __create_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, sender, job_app_type=None): """ Create new pipelines on each node and set CSTORE `response_key` for the "callback" action """ @@ -135,6 +135,9 @@ def __create_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, DEEPLOY_KEYS.SPARE_NODES: spare_nodes, DEEPLOY_KEYS.ALLOW_REPLICATION_IN_THE_WILD: allow_replication_in_the_wild, } + detected_job_app_type = job_app_type or self.deeploy_detect_job_app_type(plugins) + if detected_job_app_type in JOB_APP_TYPES_ALL: + dct_deeploy_specs[DEEPLOY_KEYS.JOB_APP_TYPE] = detected_job_app_type for addr in nodes: # Nodes to peer with for CHAINSTORE @@ -187,7 +190,7 @@ def __create_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, # endfor each target node return response_keys - def __update_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, sender, discovered_plugin_instances, dct_deeploy_specs = None): + def __update_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, sender, discovered_plugin_instances, dct_deeploy_specs = None, job_app_type=None): """ Create new pipelines on each node and set CSTORE `response_key` for the "callback" action """ @@ -202,6 +205,11 @@ def __update_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, response_keys = self.defaultdict(list) ts = self.time() + detected_job_app_type = job_app_type + if not detected_job_app_type: + plugins_for_detection = self.deeploy_prepare_plugins(inputs) + detected_job_app_type = self.deeploy_detect_job_app_type(plugins_for_detection) + if not dct_deeploy_specs: dct_deeploy_specs = { DEEPLOY_KEYS.JOB_ID: job_id, @@ -215,6 +223,8 @@ def __update_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, DEEPLOY_KEYS.SPARE_NODES: spare_nodes, DEEPLOY_KEYS.ALLOW_REPLICATION_IN_THE_WILD: allow_replication_in_the_wild, } + if detected_job_app_type in JOB_APP_TYPES_ALL: + dct_deeploy_specs[DEEPLOY_KEYS.JOB_APP_TYPE] = detected_job_app_type nodes = [node for plugin_instance in discovered_plugin_instances if (node := plugin_instance.get("NODE")) is not None] @@ -388,7 +398,7 @@ def deeploy_get_nonce(self, hex_nonce): def deeploy_verify_and_get_inputs(self, request: dict, require_sender_is_oracle: bool = False, no_hash: bool = True): sender = request.get(BASE_CT.BCctbase.ETH_SENDER) assert self.bc.is_valid_eth_address(sender), f"Invalid sender address: {sender}" - + # Create a copy of the request with default values request_with_defaults = { DEEPLOY_KEYS.TARGET_NODES: 0, @@ -396,44 +406,78 @@ def deeploy_verify_and_get_inputs(self, request: dict, require_sender_is_oracle: DEEPLOY_KEYS.PIPELINE_INPUT_URI: None, DEEPLOY_KEYS.CHAINSTORE_RESPONSE: False, DEEPLOY_KEYS.APP_PARAMS: {}, + DEEPLOY_KEYS.JOB_APP_TYPE: None, + DEEPLOY_KEYS.PLUGINS: None, **request } - - inputs = self.NestedDotDict(request_with_defaults) + + inputs = self.NestedDotDict(request_with_defaults) self.Pd(f"Received request from {sender}{': ' + str(inputs) if DEEPLOY_DEBUG else '.'}") - + addr = self.__verify_signature(request, no_hash=no_hash) if addr.lower() != sender.lower(): - raise ValueError("Invalid signature: recovered {} != {}".format(addr, sender)) - + raise ValueError("Invalid signature: recovered {} != {}".format(addr, sender)) + # Check if the sender is allowed to create pipelines if require_sender_is_oracle: self.__check_is_oracle(inputs) else: self.__check_allowed_wallet(inputs) - + return sender, inputs - def _validate_request_input_for_signature(self, inputs): + def _validate_plugin_instance_for_signature(self, signature: str, plugin_instance: dict, index: int = None): """ - Validate the request input for the given signature. - This method checks if the input is valid for the given signature. + Validate a plugin instance configuration based on its signature. + Checks if all required fields for the signature are present. + + Args: + signature (str): Plugin signature + plugin_instance (dict): Plugin instance configuration + index (int, optional): Index in array (for error messages) + + Raises: + ValueError: If required fields are missing """ - # Check if the plugin signature is valid - if not inputs.plugin_signature or inputs.plugin_signature == "": - raise ValueError(f"{DEEPLOY_ERRORS.REQUEST3}. Plugin signature not provided.") - - if inputs.plugin_signature == CONTAINER_APP_RUNNER_SIGNATURE: - # Check that image and container resources are - app_params = inputs.get(DEEPLOY_KEYS.APP_PARAMS, None) - if not app_params: - raise ValueError(f"{DEEPLOY_ERRORS.REQUEST4}. App params not provided for plugin signature {inputs.plugin_signature}.") - if not app_params.get(DEEPLOY_KEYS.APP_PARAMS_IMAGE): - raise ValueError(f"{DEEPLOY_ERRORS.REQUEST5}. Image not provided for plugin signature {inputs.plugin_signature}.") - if not app_params.get(DEEPLOY_RESOURCES.CONTAINER_RESOURCES): - raise ValueError(f"{DEEPLOY_ERRORS.REQUEST6}. Container resources not provided for plugin signature {inputs.plugin_signature}.") - pass - return + index_str = f" at index {index}" if index is not None else "" + + # Type-specific validation + if signature == CONTAINER_APP_RUNNER_SIGNATURE: + # Check IMAGE field + if not plugin_instance.get(DEEPLOY_KEYS.APP_PARAMS_IMAGE): + raise ValueError( + f"{DEEPLOY_ERRORS.REQUEST5}. Plugin instance{index_str} with signature '{signature}': 'IMAGE' field is required." + ) + + # Check CONTAINER_RESOURCES field + if not plugin_instance.get(DEEPLOY_RESOURCES.CONTAINER_RESOURCES): + raise ValueError( + f"{DEEPLOY_ERRORS.REQUEST6}. Plugin instance{index_str} with signature '{signature}': 'CONTAINER_RESOURCES' field is required." + ) + + # Validate CONTAINER_RESOURCES structure + resources = plugin_instance.get(DEEPLOY_RESOURCES.CONTAINER_RESOURCES, {}) + if not isinstance(resources, dict): + raise ValueError( + f"{DEEPLOY_ERRORS.REQUEST6}. Plugin instance{index_str} with signature '{signature}': 'CONTAINER_RESOURCES' must be a dictionary." + ) + + # Check required resource fields + if DEEPLOY_RESOURCES.CPU not in resources: + raise ValueError( + f"{DEEPLOY_ERRORS.REQUEST6}. Plugin instance{index_str} with signature '{signature}': 'CONTAINER_RESOURCES.cpu' is required." + ) + + if DEEPLOY_RESOURCES.MEMORY not in resources: + raise ValueError( + f"{DEEPLOY_ERRORS.REQUEST6}. Plugin instance{index_str} with signature '{signature}': 'CONTAINER_RESOURCES.memory' is required." + ) + + # Add validation for other plugin types here as needed + # elif signature == "SOME_OTHER_PLUGIN": + # ... + + return True def _validate_send_app_command_request(self, inputs): """ @@ -477,6 +521,87 @@ def _validate_send_instance_command_request(self, inputs): return + def _normalize_plugins_input(self, request: dict): + """ + Normalize plugin input to always use the plugins array format. + Converts legacy single-plugin format (plugin_signature + app_params) to new multi-plugin format. + + Args: + request (dict): The request dictionary + + Returns: + dict: Request with normalized plugins array (simple format: each object is a plugin instance) + + Raises: + ValueError: If neither plugins array nor legacy format is found + """ + # Check if already using new format (plugins array) + if DEEPLOY_KEYS.PLUGINS in request and request[DEEPLOY_KEYS.PLUGINS]: + return request + + # Try to convert from legacy format + plugin_signature = request.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE) + app_params = request.get(DEEPLOY_KEYS.APP_PARAMS, {}) + + if plugin_signature: + # Convert legacy format to simplified plugins array + # Each object in array represents ONE plugin instance with its config + self.Pd(f"Converting legacy plugin format to plugins array for signature: {plugin_signature}") + request[DEEPLOY_KEYS.PLUGINS] = [ + { + DEEPLOY_KEYS.PLUGIN_SIGNATURE: plugin_signature, + **app_params + } + ] + return request + + # If neither format is present, raise error + raise ValueError( + f"{DEEPLOY_ERRORS.REQUEST3}. Neither 'plugins' array nor 'plugin_signature' provided." + ) + + def _validate_plugins_array(self, plugins: list): + """ + Validate the plugins array structure (simplified format). + Each object in the array represents a single plugin instance with signature + config. + + Args: + plugins (list): List of plugin instance configurations + + Raises: + ValueError: If plugins array structure is invalid + """ + if not isinstance(plugins, list): + raise ValueError( + f"{DEEPLOY_ERRORS.PLUGINS1}. 'plugins' must be an array, got {type(plugins).__name__}." + ) + + if len(plugins) == 0: + raise ValueError( + f"{DEEPLOY_ERRORS.PLUGINS1}. 'plugins' array cannot be empty." + ) + + for idx, plugin_instance in enumerate(plugins): + if not isinstance(plugin_instance, dict): + raise ValueError( + f"{DEEPLOY_ERRORS.PLUGINS1}. Plugin instance at index {idx} must be a dictionary, got {type(plugin_instance).__name__}." + ) + + # Check required signature field + signature = plugin_instance.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE) + if not signature: + raise ValueError( + f"{DEEPLOY_ERRORS.PLUGINS2}. Plugin instance at index {idx} missing required field 'signature'." + ) + + # Check signature validity (forbidden signatures, etc) + self._check_plugin_signature(signature) + + # Validate required fields for this specific plugin signature + self._validate_plugin_instance_for_signature(signature, plugin_instance, index=idx) + + return True + def deeploy_get_auth_result(self, inputs): sender = inputs.get(BASE_CT.BCctbase.ETH_SENDER) @@ -488,7 +613,121 @@ def deeploy_get_auth_result(self, inputs): DEEPLOY_KEYS.SENDER_TOTAL_COUNT: len(inputs.wallet_nodes) + len(inputs.wallet_oracles), } return result - + + # TODO: FIXME + def _format_memory_to_standard(self, memory_value): + """ + Convert memory value to standard format (string with unit). + Supports: "4096m", "4g", "4096", 4096 + + Args: + memory_value: Memory value as string or int + + Returns: + str: Standardized memory string (e.g., "4096m") + """ + if memory_value is None: + return None + + # If already a string with unit, return as-is + if isinstance(memory_value, str): + if memory_value.endswith(('m', 'M', 'g', 'G', 'k', 'K')): + return memory_value.lower() + # String number without unit - assume bytes, convert to MB + try: + bytes_value = int(memory_value) + return f"{bytes_value // (1024 * 1024)}m" + except ValueError: + return memory_value + + # If integer, assume bytes and convert to MB + if isinstance(memory_value, int): + return f"{memory_value // (1024 * 1024)}m" + + return str(memory_value) + + def _parse_memory_to_mb(self, memory_str): + """ + Parse memory string to megabytes. + + Args: + memory_str: Memory value like "4096m", "4g", "128m" + + Returns: + int: Memory in megabytes + """ + if memory_str is None: + return 0 + + memory_str = str(memory_str).lower().strip() + + # Extract number and unit + import re + match = re.match(r'^(\d+(?:\.\d+)?)\s*([kmg]?)$', memory_str) + if not match: + # Try to parse as plain number (assume MB) + try: + return int(float(memory_str)) + except ValueError: + return 0 + + value = float(match.group(1)) + unit = match.group(2) + + # Convert to MB + if unit == 'k': + return int(value / 1024) + elif unit == 'm' or unit == '': + return int(value) + elif unit == 'g': + return int(value * 1024) + + return 0 + + def _aggregate_container_resources(self, inputs): + """ + Aggregate container resources across all CONTAINER_APP_RUNNER plugin instances. + Sums CPU and memory requirements for all container instances. + + Args: + inputs: Request inputs + + Returns: + dict: Aggregated resources in format: + { + "cpu": , + "memory": "m" + } + """ + plugins_array = inputs.get(DEEPLOY_KEYS.PLUGINS) + + # For legacy format, use existing app_params + if not plugins_array: + app_params = inputs.get(DEEPLOY_KEYS.APP_PARAMS, {}) + return app_params.get(DEEPLOY_RESOURCES.CONTAINER_RESOURCES, {}) + + total_cpu = 0 + total_memory_mb = 0 + + # Iterate through plugins array (simplified format - each object is an instance) + for plugin_instance in plugins_array: + signature = plugin_instance.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE, "").upper() + + # Only aggregate for CONTAINER_APP_RUNNER plugins + if signature == CONTAINER_APP_RUNNER_SIGNATURE: + resources = plugin_instance.get(DEEPLOY_RESOURCES.CONTAINER_RESOURCES, {}) + cpu = resources.get(DEEPLOY_RESOURCES.CPU, 0) + memory = resources.get(DEEPLOY_RESOURCES.MEMORY, "0m") + + total_cpu += cpu + total_memory_mb += self._parse_memory_to_mb(memory) + + # Return aggregated resources in standard format + return { + DEEPLOY_RESOURCES.CPU: total_cpu, + DEEPLOY_RESOURCES.MEMORY: f"{total_memory_mb}m" + } + # TODO: END FIXME def deeploy_check_payment_and_job_owner(self, inputs, sender, is_create, debug=False): """ @@ -523,27 +762,57 @@ def deeploy_check_payment_and_job_owner(self, inputs, sender, is_create, debug=F raise ValueError(msg) #endif if expected_resources: - required_resources = inputs.app_params.get(DEEPLOY_RESOURCES.CONTAINER_RESOURCES, {}) - requested_cpu = required_resources.get(DEEPLOY_RESOURCES.CPU) - requested_memory = required_resources.get(DEEPLOY_RESOURCES.MEMORY) - expected_cpu = expected_resources.get(DEEPLOY_RESOURCES.CPU) - expected_memory = expected_resources.get(DEEPLOY_RESOURCES.MEMORY) - #TODO should also check disk and gpu as soon as they are supported and sent in the request - resources_match = ( - requested_cpu is not None and - requested_memory is not None and - requested_cpu == expected_cpu and - requested_memory == expected_memory - ) - if not resources_match: - self.P( - f"Requested resources {required_resources} do not match paid resources " - f"{expected_resources} for job type {job_type}." + job_app_type = inputs.get(DEEPLOY_KEYS.JOB_APP_TYPE) + if isinstance(job_app_type, str): + job_app_type = job_app_type.lower() + if not job_app_type: + try: + job_app_type = self.deeploy_detect_job_app_type(self.deeploy_prepare_plugins(inputs)) + except Exception: + job_app_type = None + if job_app_type == JOB_APP_TYPES.NATIVE: + # TODO: Re-enable resource validation for native apps once specs are defined. + self.Pd(f"Skipping resource validation for native job {job_id}.") + else: + # Aggregate container resources across all plugins (for multi-plugin support) + aggregated_resources = self._aggregate_container_resources(inputs) + requested_cpu = aggregated_resources.get(DEEPLOY_RESOURCES.CPU) + requested_memory = aggregated_resources.get(DEEPLOY_RESOURCES.MEMORY) + expected_cpu = expected_resources.get(DEEPLOY_RESOURCES.CPU) + expected_memory = expected_resources.get(DEEPLOY_RESOURCES.MEMORY) + #TODO should also check disk and gpu as soon as they are supported and sent in the request + # Normalize numeric values before comparison + try: + requested_cpu_val = None if requested_cpu is None else float(requested_cpu) + except (TypeError, ValueError): + requested_cpu_val = None + try: + expected_cpu_val = None if expected_cpu is None else float(expected_cpu) + except (TypeError, ValueError): + expected_cpu_val = None + requested_memory_mb = ( + None if requested_memory is None else self._parse_memory_to_mb(requested_memory) + ) + expected_memory_mb = ( + None if expected_memory is None else self._parse_memory_to_mb(expected_memory) ) - msg = (f"{DEEPLOY_ERRORS.JOB_RESOURCES3}: Requested resources {required_resources} " + - f"do not match paid resources {expected_resources} for job type {job_type}.") - raise ValueError(msg) - # endif resources match + resources_match = ( + requested_cpu_val is not None and + expected_cpu_val is not None and + requested_memory_mb is not None and + expected_memory_mb is not None and + requested_cpu_val == expected_cpu_val and + requested_memory_mb == expected_memory_mb + ) + if not resources_match: + self.P( + f"Requested resources {aggregated_resources} do not match paid resources " + f"{expected_resources} for job type {job_type}." + ) + msg = (f"{DEEPLOY_ERRORS.JOB_RESOURCES3}: Requested resources {aggregated_resources} " + + f"do not match paid resources {expected_resources} for job type {job_type}.") + raise ValueError(msg) + # endif resources match # endif expected resources # endif is valid else: # job not found @@ -556,6 +825,77 @@ def deeploy_check_payment_and_job_owner(self, inputs, sender, is_create, debug=F return is_valid + def deeploy_detect_job_app_type(self, pipeline_plugins): + """ + Detect the job application type based on the pipeline plugins configuration. + """ + def extract_instance_confs(instances): + result = [] + if not instances: + return result + for instance in instances: + if not isinstance(instance, dict): + continue + instance_conf = instance.get('instance_conf') if isinstance(instance.get('instance_conf'), dict) else instance + if instance_conf: + result.append(instance_conf) + return result + + normalized_plugins = [] + + if isinstance(pipeline_plugins, dict): + for signature, instances in pipeline_plugins.items(): + normalized_plugins.append((signature, extract_instance_confs(instances))) + elif isinstance(pipeline_plugins, list): + for plugin in pipeline_plugins: + if not isinstance(plugin, dict): + continue + signature = plugin.get(self.ct.CONFIG_PLUGIN.K_SIGNATURE) + instances = plugin.get(self.ct.CONFIG_PLUGIN.K_INSTANCES) + if signature is None: + signature = plugin.get("SIGNATURE") or plugin.get("signature") + if instances is None: + instances = plugin.get("INSTANCES") or plugin.get("instances") + if signature is None and len(plugin) == 1: + signature, instances = next(iter(plugin.items())) + normalized_plugins.append((signature, extract_instance_confs(instances))) + + normalized_plugins = [ + (signature, instances) + for signature, instances in normalized_plugins + if signature + ] + + plugin_count = len(normalized_plugins) + # if no plugins were found, we define it as native app. (normally, shouldn't happen) + if plugin_count == 0: + return JOB_APP_TYPES.NATIVE + + if plugin_count > 1: + return JOB_APP_TYPES.NATIVE + + signature, instances = normalized_plugins[0] + normalized_signature = signature.upper() if isinstance(signature, str) else '' + + if normalized_signature == CONTAINER_APP_RUNNER_SIGNATURE: + service_keywords = ('postgresql', 'postgres', 'mongo', 'mongodb', 'mysql', 'mssql') + for instance_conf in instances: + if not isinstance(instance_conf, dict): + continue + image_value = ( + instance_conf.get(DEEPLOY_KEYS.APP_PARAMS_IMAGE) + or instance_conf.get('IMAGE') + or instance_conf.get('image') + ) + if image_value and any(keyword in str(image_value).lower() for keyword in service_keywords): + return JOB_APP_TYPES.SERVICE + return JOB_APP_TYPES.GENERIC + + if normalized_signature == WORKER_APP_RUNNER_SIGNATURE: + return JOB_APP_TYPES.GENERIC + + return JOB_APP_TYPES.NATIVE + def deeploy_prepare_single_plugin_instance(self, inputs): """ Prepare the a single plugin instance for the pipeline creation. @@ -602,18 +942,84 @@ def _generate_chainstore_response_key(self, instance_id: str): response_key = instance_id + '_' + self.uuid(8) return response_key - def deeploy_prepare_plugins(self, inputs): + def deeploy_prepare_plugins(self, inputs): """ Prepare the plugins for the pipeline creation. - - OBS: This must be modified in order to support multiple - instances if needed + Converts simplified plugins array format to node-expected format with grouped instances. + + Args: + inputs: Request inputs containing plugins array (simplified format) or legacy plugin_signature + + Input Format (simplified): + plugins: [ + {"signature": "PLUGIN_A", "param1": "val1"}, + {"signature": "PLUGIN_B", "param2": "val2"}, + {"signature": "PLUGIN_A", "param1": "val3"} # another instance + ] + + Returns: + list: List of prepared plugins in node format: + [ + { + "SIGNATURE": "PLUGIN_A", + "INSTANCES": [ + {"INSTANCE_ID": "PLUGIN_A_abc123", "param1": "val1"}, + {"INSTANCE_ID": "PLUGIN_A_def456", "param1": "val3"} + ] + }, + { + "SIGNATURE": "PLUGIN_B", + "INSTANCES": [ + {"INSTANCE_ID": "PLUGIN_B_xyz789", "param2": "val2"} + ] + } + ] """ + # Check if using new plugins array format + plugins_array = inputs.get(DEEPLOY_KEYS.PLUGINS) + + if plugins_array and isinstance(plugins_array, list): + # Group plugin instances by signature + plugins_by_signature = {} + + for plugin_instance in plugins_array: + signature = plugin_instance.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE) + + # Extract instance config (everything except 'signature') + instance_config = {k: v for k, v in plugin_instance.items() + if k != DEEPLOY_KEYS.PLUGIN_SIGNATURE} + + # Generate unique instance_id + instance_id = self._generate_plugin_instance_id(signature=signature) + + # Prepare instance with INSTANCE_ID + prepared_instance = { + self.ct.CONFIG_INSTANCE.K_INSTANCE_ID: instance_id, + **instance_config + } + + # Group by signature + if signature not in plugins_by_signature: + plugins_by_signature[signature] = [] + plugins_by_signature[signature].append(prepared_instance) + + # Convert grouped dict to list format + prepared_plugins = [] + for signature, instances in plugins_by_signature.items(): + prepared_plugin = { + self.ct.CONFIG_PLUGIN.K_SIGNATURE: signature, + self.ct.CONFIG_PLUGIN.K_INSTANCES: instances + } + prepared_plugins.append(prepared_plugin) + + return prepared_plugins + + # Legacy single-plugin format - use existing method plugin = self.deeploy_prepare_single_plugin_instance(inputs) plugins = [plugin] return plugins - def check_and_deploy_pipelines(self, sender, inputs, app_id, app_alias, app_type, update_nodes, new_nodes, discovered_plugin_instances=[], dct_deeploy_specs=None): + def check_and_deploy_pipelines(self, sender, inputs, app_id, app_alias, app_type, update_nodes, new_nodes, discovered_plugin_instances=[], dct_deeploy_specs=None, job_app_type=None): """ Validate the inputs and deploy the pipeline on the target nodes. """ @@ -626,10 +1032,10 @@ def check_and_deploy_pipelines(self, sender, inputs, app_id, app_alias, app_type # Phase 2: Launch the pipeline on each node and set CSTORE `response_key`` for the "callback" action response_keys = {} if len(update_nodes) > 0: - update_response_keys = self.__update_pipeline_on_nodes(update_nodes, inputs, app_id, app_alias, app_type, sender, discovered_plugin_instances, dct_deeploy_specs) + update_response_keys = self.__update_pipeline_on_nodes(update_nodes, inputs, app_id, app_alias, app_type, sender, discovered_plugin_instances, dct_deeploy_specs, job_app_type=job_app_type) response_keys.update(update_response_keys) if len(new_nodes) > 0: - new_response_keys = self.__create_pipeline_on_nodes(new_nodes, inputs, app_id, app_alias, app_type, sender) + new_response_keys = self.__create_pipeline_on_nodes(new_nodes, inputs, app_id, app_alias, app_type, sender, job_app_type=job_app_type) response_keys.update(new_response_keys) # Phase 3: Wait until all the responses are received via CSTORE and compose status response @@ -945,6 +1351,12 @@ def get_job_base_pipeline_from_apps(self, apps): # 3. Get plugins and transform them to the expected structure plugins_data = base_pipeline.get(NetMonCt.PLUGINS, {}) + if isinstance(deeploy_specs, dict): + current_job_app_type = deeploy_specs.get(DEEPLOY_KEYS.JOB_APP_TYPE) + if not current_job_app_type: + detected_job_app_type = self.deeploy_detect_job_app_type(plugins_data) + if detected_job_app_type in JOB_APP_TYPES_ALL: + deeploy_specs[DEEPLOY_KEYS.JOB_APP_TYPE] = detected_job_app_type transformed_plugins = [] for plugin_signature, plugin_instances in plugins_data.items(): @@ -992,6 +1404,13 @@ def prepare_create_update_pipelines(self, base_pipeline, new_nodes, update_nodes chainstore_peers = list(set(new_nodes + update_nodes)) deeploy_specs = self.deepcopy(base_pipeline[NetMonCt.DEEPLOY_SPECS]) + job_app_type = None + if isinstance(deeploy_specs, dict): + job_app_type = deeploy_specs.get(DEEPLOY_KEYS.JOB_APP_TYPE) + if not job_app_type: + job_app_type = self.deeploy_detect_job_app_type(base_pipeline.get(NetMonCt.PLUGINS, [])) + if job_app_type in JOB_APP_TYPES_ALL: + deeploy_specs[DEEPLOY_KEYS.JOB_APP_TYPE] = job_app_type deeploy_specs[DEEPLOY_KEYS.CURRENT_TARGET_NODES] = chainstore_peers deeploy_specs[DEEPLOY_KEYS.DATE_UPDATED] = self.time() diff --git a/ver.py b/ver.py index cbc2939c..423fc4f2 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.9.743' +__VER__ = '2.9.744' From fb619f3c3c53ae456d61a5c536934ad42626720f Mon Sep 17 00:00:00 2001 From: Vitalii <87299468+vitalii-t12@users.noreply.github.com> Date: Thu, 16 Oct 2025 18:14:39 +0300 Subject: [PATCH 5/7] fixes: multi plugins pipelines update (#277) * fix: native jobs update with multiple plugins * fix: update deeploy_specs on pipeline update * fix: move methods to mixin * fix: use const * chore: increment version * fix: add comment --- extensions/business/deeploy/deeploy_const.py | 4 +- .../business/deeploy/deeploy_manager_api.py | 33 +- extensions/business/deeploy/deeploy_mixin.py | 486 +++++++++++++++++- ver.py | 2 +- 4 files changed, 504 insertions(+), 21 deletions(-) diff --git a/extensions/business/deeploy/deeploy_const.py b/extensions/business/deeploy/deeploy_const.py index 1c482106..b794df28 100644 --- a/extensions/business/deeploy/deeploy_const.py +++ b/extensions/business/deeploy/deeploy_const.py @@ -549,11 +549,11 @@ class JOB_APP_TYPES: "plugins": [ { # Plugin instance 1: EDGE_NODE_API_TEST - "signature": "EDGE_NODE_API_TEST" + "plugin_signature": "EDGE_NODE_API_TEST" }, { # Plugin instance 2: CONTAINER_APP_RUNNER - "signature": "CONTAINER_APP_RUNNER", + "plugin_signature": "CONTAINER_APP_RUNNER", "IMAGE": "tvitalii/ratio1-drive:latest", "CONTAINER_RESOURCES": { "cpu": 2, diff --git a/extensions/business/deeploy/deeploy_manager_api.py b/extensions/business/deeploy/deeploy_manager_api.py index 0ca2060d..71d5236c 100644 --- a/extensions/business/deeploy/deeploy_manager_api.py +++ b/extensions/business/deeploy/deeploy_manager_api.py @@ -214,6 +214,21 @@ def _process_pipeline_request( discovered_plugin_instances = self._discover_plugin_instances(app_id=app_id, job_id=job_id, owner=sender) self.P(f"Discovered plugin instances: {self.json_dumps(discovered_plugin_instances)}") + deeploy_specs_for_update = None + if job_app_type in (JOB_APP_TYPES.NATIVE, JOB_APP_TYPES.GENERIC, JOB_APP_TYPES.SERVICE): + discovered_plugin_instances = self._ensure_plugin_instance_ids( + inputs=inputs, + discovered_plugin_instances=discovered_plugin_instances, + owner=sender, + app_id=app_id, + job_id=job_id, + ) + deeploy_specs_for_update = self._prepare_updated_deeploy_specs( + owner=sender, + app_id=app_id, + job_id=job_id, + discovered_plugin_instances=discovered_plugin_instances, + ) nodes = [instance[DEEPLOY_PLUGIN_DATA.NODE] for instance in discovered_plugin_instances] if is_create: @@ -238,6 +253,7 @@ def _process_pipeline_request( new_nodes=[], update_nodes=nodes, discovered_plugin_instances=discovered_plugin_instances, + dct_deeploy_specs=deeploy_specs_for_update, job_app_type=job_app_type, ) @@ -335,7 +351,7 @@ def create_pipeline( **Plugin instances:** plugins : list Array of plugin instance configurations. Each object represents ONE plugin instance: - - signature : str (required) + - plugin_signature : str (required) The plugin signature (e.g., 'CONTAINER_APP_RUNNER', 'EDGE_NODE_API_TEST') - **instance-specific parameters** (varies by plugin type) For CONTAINER_APP_RUNNER: @@ -355,10 +371,10 @@ def create_pipeline( "target_nodes_count": 1, "plugins": [ { - "signature": "EDGE_NODE_API_TEST" + "plugin_signature": "EDGE_NODE_API_TEST" }, { - "signature": "CONTAINER_APP_RUNNER", + "plugin_signature": "CONTAINER_APP_RUNNER", "IMAGE": "tvitalii/ratio1-drive:latest", "CONTAINER_RESOURCES": { "cpu": 2, @@ -393,8 +409,8 @@ def create_pipeline( - Multi-plugin pipelines are automatically classified as JOB_APP_TYPE.NATIVE - Single CONTAINER_APP_RUNNER is classified as GENERIC or SERVICE - Resource requirements are aggregated across all container plugins - - Multiple instances of the same plugin: Include multiple objects with the same signature - - Example: [{"signature": "PLUGIN_A", ...}, {"signature": "PLUGIN_A", ...}] creates 2 instances + - Multiple instances of the same plugin: Include multiple objects with the same plugin_signature + - Example: [{"plugin_signature": "PLUGIN_A", ...}, {"plugin_signature": "PLUGIN_A", ...}] creates 2 instances - For multi-plugin templates, see DEEPLOY_CREATE_REQUEST_MULTI_PLUGIN in deeploy_const.py TODO: (Vitalii) @@ -450,8 +466,10 @@ def update_pipeline( **Plugin instances:** plugins : list Array of plugin instance configurations. Each object represents ONE plugin instance: - - signature : str (required) - - **instance-specific parameters** + - plugin_signature : str (required) + - instance_id : str (required when updating an existing plugin instance) + - **instance-specific parameters** (payload merged into the instance configuration) + - Omit instance_id to attach a brand new plugin instance; supported for native apps only **Legacy format:** plugin_signature : str @@ -469,6 +487,7 @@ def update_pipeline( - For multi-plugin pipelines, all plugins are updated with new configurations - Resource validation applies the same as create operations - The simplified plugins array format is the same as create_pipeline + - New plugin instances can be introduced by omitting `instance_id` (native job type only) - See create_pipeline endpoint for detailed parameter documentation and examples """ diff --git a/extensions/business/deeploy/deeploy_mixin.py b/extensions/business/deeploy/deeploy_mixin.py index 6eefbc9f..649e2cff 100644 --- a/extensions/business/deeploy/deeploy_mixin.py +++ b/extensions/business/deeploy/deeploy_mixin.py @@ -223,17 +223,111 @@ def __update_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, DEEPLOY_KEYS.SPARE_NODES: spare_nodes, DEEPLOY_KEYS.ALLOW_REPLICATION_IN_THE_WILD: allow_replication_in_the_wild, } + else: + dct_deeploy_specs = self.deepcopy(dct_deeploy_specs) + dct_deeploy_specs[DEEPLOY_KEYS.DATE_UPDATED] = ts + if DEEPLOY_KEYS.DATE_CREATED not in dct_deeploy_specs: + dct_deeploy_specs[DEEPLOY_KEYS.DATE_CREATED] = ts if detected_job_app_type in JOB_APP_TYPES_ALL: dct_deeploy_specs[DEEPLOY_KEYS.JOB_APP_TYPE] = detected_job_app_type - nodes = [node for plugin_instance in discovered_plugin_instances if (node := plugin_instance.get("NODE")) is not None] + requested_by_instance_id, requested_by_signature, new_plugin_configs = self._organize_requested_plugins(inputs) - pipeline_to_save = None + nodes = [] + plugins_by_node = self.defaultdict(list) for plugin in discovered_plugin_instances: - addr = plugin.get("NODE") - plugins = [self.deeploy_prepare_single_plugin_instance_update(inputs=inputs, instance_id=plugin.get("instance_id"))] + addr = plugin.get(DEEPLOY_PLUGIN_DATA.NODE) + if not addr: + continue - nodes_to_peer = nodes + if addr not in nodes: + nodes.append(addr) + + signature = plugin.get(DEEPLOY_PLUGIN_DATA.PLUGIN_SIGNATURE) + normalized_signature = signature.upper() if isinstance(signature, str) else signature + + instance_id = plugin.get(DEEPLOY_PLUGIN_DATA.INSTANCE_ID) + plugin_config = None + + if instance_id: + plugin_config = requested_by_instance_id.pop(instance_id, None) + candidate_list = requested_by_signature.get(normalized_signature, []) + if plugin_config and candidate_list: + # Safe to modify list during iteration here because we break immediately after pop + # This avoids the typical issue of modifying a list while iterating over it + for idx, candidate in enumerate(candidate_list): + if candidate is plugin_config: + candidate_list.pop(idx) + break + else: + candidate_list = requested_by_signature.get(normalized_signature, []) + for idx, candidate in enumerate(candidate_list): + candidate_instance_id = candidate.get(DEEPLOY_KEYS.PLUGIN_INSTANCE_ID) + if not candidate_instance_id: + plugin_config = candidate_list.pop(idx) + break + + if not plugin_config: + config_candidates = requested_by_signature.get(normalized_signature, []) + if config_candidates: + for idx, candidate in enumerate(config_candidates): + candidate_instance_id = candidate.get(DEEPLOY_KEYS.PLUGIN_INSTANCE_ID) + if candidate_instance_id: + plugin_config = config_candidates.pop(idx) + break + + prepared_plugin = self.deeploy_prepare_single_plugin_instance_update( + inputs=inputs, + instance_id=plugin.get(DEEPLOY_PLUGIN_DATA.INSTANCE_ID), + plugin_signature=signature, + plugin_config=plugin_config, + fallback_instance=plugin.get(DEEPLOY_PLUGIN_DATA.PLUGIN_INSTANCE), + ) + + chainstore_key = plugin.get(DEEPLOY_PLUGIN_DATA.CHAINSTORE_RESPONSE_KEY) + if chainstore_key: + prepared_plugin[DEEPLOY_PLUGIN_DATA.CHAINSTORE_RESPONSE_KEY] = chainstore_key + + plugins_by_node[addr].append(prepared_plugin) + + unique_nodes = nodes if nodes else [] + + if not unique_nodes: + target_nodes = inputs.get(DEEPLOY_KEYS.TARGET_NODES, []) if hasattr(inputs, 'get') else [] + if not target_nodes and hasattr(inputs, DEEPLOY_KEYS.TARGET_NODES): + target_nodes = getattr(inputs, DEEPLOY_KEYS.TARGET_NODES) + if isinstance(target_nodes, list): + unique_nodes = list(target_nodes) + + if requested_by_instance_id: + missing_ids = list(requested_by_instance_id.keys()) + raise ValueError( + f"{DEEPLOY_ERRORS.PLUGINS3}: Unknown plugin instance_id(s) in update request: {missing_ids}" + ) + + if new_plugin_configs: + if detected_job_app_type != JOB_APP_TYPES.NATIVE: + raise ValueError( + f"{DEEPLOY_ERRORS.PLUGINS3}. Adding new plugin instances via update is currently supported only for native apps." + ) + for addr in unique_nodes: + for plugin_config in new_plugin_configs: + plugin_signature = ( + plugin_config.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE) + or plugin_config.get("signature") + ) + prepared_plugin = self.deeploy_prepare_single_plugin_instance_update( + inputs=inputs, + instance_id=None, + plugin_signature=plugin_signature, + plugin_config=plugin_config, + fallback_instance=None, + ) + plugins_by_node[addr].append(prepared_plugin) + + pipeline_to_save = None + for addr, plugins in plugins_by_node.items(): + nodes_to_peer = unique_nodes node_plugins = self.deepcopy(plugins) # Configure chainstore peers and response keys @@ -283,6 +377,47 @@ def __update_pipeline_on_nodes(self, nodes, inputs, app_id, app_alias, app_type, self.P(f"Error saving pipeline in CSTORE: {e}", color="r") return response_keys + def _prepare_updated_deeploy_specs(self, owner, app_id, job_id, discovered_plugin_instances): + """ + Retrieve existing deeploy_specs and refresh the update timestamp. + """ + nodes = [] + for instance in discovered_plugin_instances: + node = instance.get(DEEPLOY_PLUGIN_DATA.NODE) + if node and node not in nodes: + nodes.append(node) + + try: + online_apps = self._get_online_apps( + owner=owner, + target_nodes=nodes if nodes else None, + job_id=job_id, + ) + except Exception as exc: + self.Pd(f"Unable to retrieve existing deeploy_specs for update: {exc}", color='r') + return None + + specs = None + for node, apps in online_apps.items(): + if app_id and app_id in apps: + specs = apps[app_id].get(NetMonCt.DEEPLOY_SPECS) + if specs: + break + for pipeline_name, data in apps.items(): + candidate_specs = data.get(NetMonCt.DEEPLOY_SPECS) + if candidate_specs: + specs = candidate_specs + break + if specs: + break + + if not specs or not isinstance(specs, dict): + return None + + refreshed_specs = self.deepcopy(specs) + refreshed_specs[DEEPLOY_KEYS.DATE_UPDATED] = self.time() + return refreshed_specs + def __prepare_plugins_for_update(self, inputs, discovered_plugin_instances): """ Prepare plugins for update using discovered instances instead of creating new ones @@ -727,12 +862,63 @@ def _aggregate_container_resources(self, inputs): DEEPLOY_RESOURCES.CPU: total_cpu, DEEPLOY_RESOURCES.MEMORY: f"{total_memory_mb}m" } + + def _organize_requested_plugins(self, inputs): + """ + Organize requested plugin configurations by instance_id and signature, + and separate newly requested plugin instances. + """ + plugins_by_instance_id = {} + plugins_by_signature = self.defaultdict(list) + new_plugin_configs = [] + + plugins_array = inputs.get(DEEPLOY_KEYS.PLUGINS) + if not plugins_array or not isinstance(plugins_array, list): + return plugins_by_instance_id, plugins_by_signature, new_plugin_configs + + for plugin_instance in plugins_array: + if not isinstance(plugin_instance, dict): + continue + + signature = ( + plugin_instance.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE) + or plugin_instance.get("signature") + ) + if not signature: + continue + + normalized_signature = signature.upper() if isinstance(signature, str) else signature + instance_id = ( + plugin_instance.get(DEEPLOY_KEYS.PLUGIN_INSTANCE_ID) + or plugin_instance.get("instance_id") + or plugin_instance.get(self.ct.CONFIG_INSTANCE.K_INSTANCE_ID) + ) + + plugin_copy = self.deepcopy(plugin_instance) + legacy_signature_value = plugin_copy.pop("signature", None) + if DEEPLOY_KEYS.PLUGIN_SIGNATURE not in plugin_copy and legacy_signature_value is not None: + plugin_copy[DEEPLOY_KEYS.PLUGIN_SIGNATURE] = legacy_signature_value + plugin_copy[DEEPLOY_KEYS.PLUGIN_SIGNATURE] = plugin_copy.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE, signature) + + if instance_id: + plugin_copy[DEEPLOY_KEYS.PLUGIN_INSTANCE_ID] = instance_id + plugins_by_instance_id[instance_id] = plugin_copy + else: + plugin_copy.pop(DEEPLOY_KEYS.PLUGIN_INSTANCE_ID, None) + new_plugin_configs.append(plugin_copy) + + plugins_by_signature[normalized_signature].append(plugin_copy) + + return plugins_by_instance_id, plugins_by_signature, new_plugin_configs # TODO: END FIXME def deeploy_check_payment_and_job_owner(self, inputs, sender, is_create, debug=False): """ Check if the payment is valid for the given job. """ + allow_unpaid = inputs.get("allow_unpaid_job", False) + if allow_unpaid: + return True job_id = inputs.get(DEEPLOY_KEYS.JOB_ID, None) self.Pd(f"Checking payment for job {job_id} by sender {sender}{' (debug mode)' if debug else ''}") if not job_id: @@ -912,16 +1098,79 @@ def deeploy_prepare_single_plugin_instance(self, inputs): } return plugin - def deeploy_prepare_single_plugin_instance_update(self, inputs, instance_id): + def deeploy_prepare_single_plugin_instance_update(self, inputs, instance_id, plugin_signature=None, plugin_config=None, fallback_instance=None): """ Prepare the a single plugin instance for the pipeline creation. """ + signature = plugin_signature + + if not signature and plugin_config: + signature = ( + plugin_config.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE) + or plugin_config.get("signature") + ) + + if not signature: + try: + signature = inputs.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE, None) + except Exception: + signature = None + + if not signature and hasattr(inputs, DEEPLOY_KEYS.PLUGIN_SIGNATURE): + signature = getattr(inputs, DEEPLOY_KEYS.PLUGIN_SIGNATURE) + + if not signature and fallback_instance and isinstance(fallback_instance, dict): + signature = ( + fallback_instance.get(self.ct.CONFIG_PLUGIN.K_SIGNATURE) + or fallback_instance.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE) + or fallback_instance.get("signature") + ) + + if not signature: + raise ValueError( + f"{DEEPLOY_ERRORS.REQUEST7}. 'plugin_signature' not provided for update." + ) + + if not instance_id: + instance_id = self._generate_plugin_instance_id(signature=signature) + + instance_payload = {} + + if plugin_config: + config_copy = self.deepcopy(plugin_config) + config_copy.pop(DEEPLOY_KEYS.PLUGIN_SIGNATURE, None) + config_copy.pop("signature", None) + instance_payload = config_copy + else: + app_params = None + try: + app_params = inputs.get(DEEPLOY_KEYS.APP_PARAMS, None) + except Exception: + app_params = None + + if not app_params and hasattr(inputs, DEEPLOY_KEYS.APP_PARAMS): + app_params = getattr(inputs, DEEPLOY_KEYS.APP_PARAMS) + + if app_params and isinstance(app_params, dict): + instance_payload = self.deepcopy(app_params) + elif fallback_instance and isinstance(fallback_instance, dict): + instance_conf = fallback_instance.get("instance_conf") + if instance_conf and isinstance(instance_conf, dict): + instance_payload = self.deepcopy(instance_conf) + instance_payload.pop(self.ct.CONFIG_INSTANCE.K_INSTANCE_ID, None) + instance_payload.pop(DEEPLOY_KEYS.PLUGIN_SIGNATURE, None) + instance_payload.pop("signature", None) + else: + instance_payload = {} + else: + instance_payload = {} + plugin = { - self.ct.CONFIG_PLUGIN.K_SIGNATURE : inputs.plugin_signature, - self.ct.CONFIG_PLUGIN.K_INSTANCES : [ + self.ct.CONFIG_PLUGIN.K_SIGNATURE: signature, + self.ct.CONFIG_PLUGIN.K_INSTANCES: [ { - self.ct.CONFIG_INSTANCE.K_INSTANCE_ID : instance_id, - **inputs.app_params + self.ct.CONFIG_INSTANCE.K_INSTANCE_ID: instance_id, + **instance_payload } ] } @@ -1098,7 +1347,7 @@ def _discover_plugin_instances( Returns a list of dictionaries containing infomration about plugin instances. """ apps = self._get_online_apps(owner=owner, target_nodes=target_nodes) - + self.P(f"online apps for owner {owner} and target_nodes {target_nodes}: {self.json_dumps(apps)}") discovered_plugins = [] for node, pipelines in apps.items(): iter_plugins = [] @@ -1619,3 +1868,218 @@ def _get_online_apps(self, owner=None, target_nodes=None, job_id=None): filtered_result[node][app_name] = app_data result = filtered_result return result + + # TODO: REMOVE THIS, once instance_id is coming from ui for instances that have to be updated + # Maybe add is_new_instance:bool for native apps, that want to add an extra plugin + def _ensure_plugin_instance_ids(self, inputs, discovered_plugin_instances, owner=None, app_id=None, job_id=None): + """ + Backfill missing instance_id values for plugin updates using discovered plugin instances. + """ + try: + plugins_array = inputs.get(DEEPLOY_KEYS.PLUGINS, None) if hasattr(inputs, 'get') else None + except Exception: + plugins_array = None + + if not plugins_array or not isinstance(plugins_array, list): + return discovered_plugin_instances + + if not discovered_plugin_instances and (app_id or job_id): + try: + discovered_plugin_instances = self._discover_plugin_instances(app_id=app_id, job_id=job_id, owner=owner) + except Exception as exc: + self.Pd(f"Failed to auto-discover plugin instances for update: {exc}", color='r') + discovered_plugin_instances = [] + + if not discovered_plugin_instances: + return discovered_plugin_instances + + instance_id_key = ct.BIZ_PLUGIN_DATA.INSTANCE_ID + chainstore_response_key = ct.BIZ_PLUGIN_DATA.CHAINSTORE_RESPONSE_KEY + chainstore_peers_key = ct.BIZ_PLUGIN_DATA.CHAINSTORE_PEERS + + used_instance_ids = set() + for plugin_entry in plugins_array: + existing_id = ( + plugin_entry.get(DEEPLOY_KEYS.PLUGIN_INSTANCE_ID) + or plugin_entry.get("instance_id") + or plugin_entry.get(instance_id_key) + ) + if existing_id: + used_instance_ids.add(existing_id) + + discovered_by_signature = self.defaultdict(list) + for plugin in discovered_plugin_instances: + signature = plugin.get(DEEPLOY_PLUGIN_DATA.PLUGIN_SIGNATURE) + instance_id = plugin.get(DEEPLOY_PLUGIN_DATA.INSTANCE_ID) + if not signature or not instance_id: + continue + discovered_by_signature[signature.upper()].append(plugin) + + for signature in discovered_by_signature: + discovered_by_signature[signature] = sorted( + discovered_by_signature[signature], + key=lambda item: item.get(DEEPLOY_PLUGIN_DATA.INSTANCE_ID) or "" + ) + + for plugin_entry in plugins_array: + current_id = ( + plugin_entry.get(DEEPLOY_KEYS.PLUGIN_INSTANCE_ID) + or plugin_entry.get("instance_id") + or plugin_entry.get(instance_id_key) + ) + if current_id: + continue + + signature = plugin_entry.get(DEEPLOY_KEYS.PLUGIN_SIGNATURE) or plugin_entry.get("signature") + if not signature: + continue + + normalized_signature = signature.upper() + candidates = discovered_by_signature.get(normalized_signature, []) + if not candidates: + continue + + match = self._match_native_plugin_candidate( + plugin_entry, + candidates, + used_instance_ids, + instance_id_key=instance_id_key, + chainstore_response_key=chainstore_response_key, + chainstore_peers_key=chainstore_peers_key, + ) + + if not match: + continue + + matched_instance_id = match.get(DEEPLOY_PLUGIN_DATA.INSTANCE_ID) + if not matched_instance_id: + continue + + plugin_entry[DEEPLOY_KEYS.PLUGIN_INSTANCE_ID] = matched_instance_id + plugin_entry["instance_id"] = matched_instance_id + used_instance_ids.add(matched_instance_id) + + self.Pd(f"Inferred instance_id '{matched_instance_id}' for plugin '{signature}'.", color='g') + + return discovered_plugin_instances + + def _match_native_plugin_candidate( + self, + plugin_entry, + candidates, + used_instance_ids, + instance_id_key, + chainstore_response_key, + chainstore_peers_key, + ): + """ + Match a plugin update payload without instance_id to an existing discovered instance. + """ + requested_conf = self._extract_plugin_request_conf( + plugin_entry, + instance_id_key=instance_id_key, + chainstore_response_key=chainstore_response_key, + chainstore_peers_key=chainstore_peers_key, + ) + + best_candidate = None + best_score = -1 + for candidate in candidates: + candidate_instance_id = candidate.get(DEEPLOY_PLUGIN_DATA.INSTANCE_ID) + if not candidate_instance_id or candidate_instance_id in used_instance_ids: + continue + candidate_conf = self._extract_discovered_plugin_conf( + candidate, + instance_id_key=instance_id_key, + chainstore_response_key=chainstore_response_key, + chainstore_peers_key=chainstore_peers_key, + ) + score = self._score_plugin_config_match(requested_conf, candidate_conf) + if score > best_score: + best_score = score + best_candidate = candidate + + if best_candidate is None: + for candidate in candidates: + candidate_instance_id = candidate.get(DEEPLOY_PLUGIN_DATA.INSTANCE_ID) + if candidate_instance_id and candidate_instance_id not in used_instance_ids: + best_candidate = candidate + break + + return best_candidate + + def _extract_plugin_request_conf(self, plugin_entry, instance_id_key, chainstore_response_key, chainstore_peers_key): + """ + Produce a sanitized configuration dict from the update request plugin payload. + """ + ignore_keys = { + DEEPLOY_KEYS.PLUGIN_SIGNATURE, + DEEPLOY_KEYS.PLUGIN_INSTANCE_ID, + "signature", + "instance_id", + instance_id_key, + chainstore_response_key, + chainstore_peers_key, + } + + result = {} + for key, value in plugin_entry.items(): + if key in ignore_keys: + continue + result[key] = value + + return result + + def _extract_discovered_plugin_conf(self, discovered_plugin, instance_id_key, chainstore_response_key, chainstore_peers_key): + """ + Produce a sanitized configuration dict from an already running plugin instance. + """ + plugin_instance = discovered_plugin.get(DEEPLOY_PLUGIN_DATA.PLUGIN_INSTANCE, {}) + instance_conf = {} + if isinstance(plugin_instance, dict): + instance_conf = plugin_instance.get("instance_conf", plugin_instance) + if not isinstance(instance_conf, dict): + instance_conf = {} + + ignore_keys = { + instance_id_key, + DEEPLOY_KEYS.PLUGIN_SIGNATURE, + "signature", + chainstore_response_key, + chainstore_peers_key, + } + + result = {} + for key, value in instance_conf.items(): + if key in ignore_keys: + continue + result[key] = value + + return result + + # TODO: Remove this once instance_ids are sent and make sure instance_id is mandatory. + # Update should be done strictly by instance_id. + def _score_plugin_config_match(self, requested_conf, existing_conf): + """ + Compute a similarity score between a request payload and an existing instance configuration. + """ + if not requested_conf: + return 0 + + score = 0 + for key, value in requested_conf.items(): + if key not in existing_conf: + continue + existing_value = existing_conf[key] + if isinstance(value, (dict, list)) and isinstance(existing_value, (dict, list)): + try: + if self.json_dumps(value, sort_keys=True) == self.json_dumps(existing_value, sort_keys=True): + score += 3 + except TypeError: + continue + elif value == existing_value: + score += 2 + elif str(value) == str(existing_value): + score += 1 + + return score diff --git a/ver.py b/ver.py index 423fc4f2..aef41840 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.9.744' +__VER__ = '2.9.745' From f12416017925f40bc50750cdb091dadc6cb4fb4d Mon Sep 17 00:00:00 2001 From: Alessandro <37877991+aledefra@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:08:03 +0200 Subject: [PATCH 6/7] chore: inc ver (#278) --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index aef41840..cd85b75a 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.9.745' +__VER__ = '2.9.750' From a47d35a6dac1b83f53d1f329892819562f79dd1c Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Sat, 18 Oct 2025 17:41:31 +0300 Subject: [PATCH 7/7] chore: inc ver --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index cd85b75a..94d381e5 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.9.750' +__VER__ = '2.9.760'