From 30377d7b09c4310ff5175c0ab391a2ed360eb5c3 Mon Sep 17 00:00:00 2001 From: Wisotzky Sven Date: Tue, 2 Jul 2024 14:37:42 +0200 Subject: [PATCH] rel2.5.0 --- CHANGELOG.md | 11 +- media/report.html.njk | 26 +- package.json | 2 +- src/providers/IntentManagerProvider.ts | 48 +- .../intent-type-resources/Cisco IOS-XR.ftl | 4 +- .../intent-type-resources/JunOS.ftl | 5 +- .../intent-type-resources/OpenConfig.ftl | 9 +- .../intent-type-resources/SR OS.ftl | 9 +- .../intent-type-resources/SRLinux.ftl | 4 - .../README.MD | 0 .../patch.ftl | 0 .../state.ftl | 0 .../utils.js | 0 .../utils_callouts.js | 0 .../utils_entrypoints.js | 0 .../utils_resources.js | 0 templates/common_resources_graaljs/README.MD | 50 + .../common/CalloutHandler.mjs | 284 ++++ .../common/IntentHandler.mjs | 1214 +++++++++++++++++ .../common/IntentLogic.mjs | 183 +++ .../common/ResourceAdmin.mjs | 345 +++++ .../common_resources_graaljs/common/patch.ftl | 29 + templates/common_resources_graaljs/state.ftl | 2 + .../intent-type-resources/Cisco IOS-XR.ftl | 64 + .../intent-type-resources/JunOS.ftl | 73 + .../intent-type-resources/OpenConfig.ftl | 242 ++++ .../intent-type-resources/SR OS.ftl | 213 +++ .../intent-type-resources/SRLinux.ftl | 123 ++ .../intent-type-resources/default.viewConfig | 209 +++ .../intent-type-resources/state.ftl | 21 + .../merge_common_resources_graaljs | 0 templates/ngJS iplink/meta-info.json | 33 + templates/ngJS iplink/script-content.mjs | 68 + .../yang-modules/[intent_type].yang | 84 ++ templates/templates.json | 4 + 35 files changed, 3316 insertions(+), 43 deletions(-) rename templates/{common-resources => common_resources}/README.MD (100%) rename templates/{common-resources => common_resources}/patch.ftl (100%) rename templates/{common-resources => common_resources}/state.ftl (100%) rename templates/{common-resources => common_resources}/utils.js (100%) rename templates/{common-resources => common_resources}/utils_callouts.js (100%) rename templates/{common-resources => common_resources}/utils_entrypoints.js (100%) rename templates/{common-resources => common_resources}/utils_resources.js (100%) create mode 100644 templates/common_resources_graaljs/README.MD create mode 100644 templates/common_resources_graaljs/common/CalloutHandler.mjs create mode 100644 templates/common_resources_graaljs/common/IntentHandler.mjs create mode 100644 templates/common_resources_graaljs/common/IntentLogic.mjs create mode 100644 templates/common_resources_graaljs/common/ResourceAdmin.mjs create mode 100644 templates/common_resources_graaljs/common/patch.ftl create mode 100644 templates/common_resources_graaljs/state.ftl create mode 100644 templates/ngJS iplink/intent-type-resources/Cisco IOS-XR.ftl create mode 100644 templates/ngJS iplink/intent-type-resources/JunOS.ftl create mode 100644 templates/ngJS iplink/intent-type-resources/OpenConfig.ftl create mode 100644 templates/ngJS iplink/intent-type-resources/SR OS.ftl create mode 100644 templates/ngJS iplink/intent-type-resources/SRLinux.ftl create mode 100644 templates/ngJS iplink/intent-type-resources/default.viewConfig create mode 100644 templates/ngJS iplink/intent-type-resources/state.ftl create mode 100644 templates/ngJS iplink/merge_common_resources_graaljs create mode 100644 templates/ngJS iplink/meta-info.json create mode 100644 templates/ngJS iplink/script-content.mjs create mode 100644 templates/ngJS iplink/yang-modules/[intent_type].yang diff --git a/CHANGELOG.md b/CHANGELOG.md index 88dd862..1a7a259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,4 +176,13 @@ Updates: * Audit report format: For misaligned object entries with isConfigured equals false use different color * Command `setPassword` can now be triggered from other extensions while passing the new password. * Updated badges/tooltips/colors in FileSystemProvider (Explorer View) -* Explorer view now shows if vsCode is connected to Intent Manager. Tooltips provide extra information. \ No newline at end of file +* Explorer view now shows if vsCode is connected to Intent Manager. Tooltips provide extra information. + +## [2.5.0] + +Updates: +* Workspace entry Intent Manager tooltip include details when not connected / connection errors + to replace error dialogue. Results in better user experience, especially when multiple NSP + extensions are installed. +* Show NSP version as {major}.{minor}. With this "24.4.0" is displayed now as "24.4". +* New IPL!nk template for next-gen JavaScript engine (GraalJS) diff --git a/media/report.html.njk b/media/report.html.njk index 521da48..6cbd50a 100644 --- a/media/report.html.njk +++ b/media/report.html.njk @@ -96,7 +96,13 @@ }; function clickFirstTab() { - document.getElementById("select_tab1").click(); +{% if report["misaligned-attribute"] %} + document.getElementById("select_tab1").click(); +{% elif report["misaligned-object"] %} + document.getElementById("select_tab2").click(); +{% else %} + document.getElementById("select_tab3").click(); +{% endif %} } @@ -115,11 +121,18 @@
+{% if report["misaligned-attribute"] %} +{% endif %} +{% if report["misaligned-object"] %} +{% endif %} +{% if report["undesired-object"] %} +{% endif %}
+{% if report["misaligned-attribute"] %}
@@ -128,7 +141,6 @@ -{% if report["misaligned-attribute"] %} {% for entry in report["misaligned-attribute"] %} @@ -137,17 +149,17 @@ {% endfor %} -{% endif %}
Desired Value Observed Value
{{ entry["device-name"] }}{{ entry["actual-value"] }}
+{% endif %} +{% if report["misaligned-object"] %}
-{% if report["misaligned-object"] %} {% for entry in report["misaligned-object"] %} @@ -158,17 +170,17 @@ {% endif %} {% endfor %} -{% endif %}
Site Path
{{ entry["device-name"] }}
+{% endif %} +{% if report["undesired-object"] %}
-{% if report["undesired-object"] %} {% for entry in report["undesired-object"] %} @@ -179,8 +191,8 @@ {% endif %} {% endfor %} -{% endif %}
Site Path
{{ entry["device-name"] }}
+{% endif %} \ No newline at end of file diff --git a/package.json b/package.json index d8e85f3..c3dc616 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "nokia-intent-manager", "displayName": "NOKIA_IM", "description": "NOKIA IM vsCode Developer Plugin", - "version": "2.4.0", + "version": "2.5.0", "icon": "media/NSP_Logo.png", "publisher": "Nokia", "repository": "http://github.com/nokia/vscode-intent-manager", diff --git a/src/providers/IntentManagerProvider.ts b/src/providers/IntentManagerProvider.ts index 1efa444..56fe754 100644 --- a/src/providers/IntentManagerProvider.ts +++ b/src/providers/IntentManagerProvider.ts @@ -16,9 +16,9 @@ const COLOR_CUSTOMIZATION = new vscode.ThemeColor('list.highlightForeground'); / const COLOR_WARNING = new vscode.ThemeColor('list.warningForeground'); const COLOR_ERROR = new vscode.ThemeColor('list.errorForeground'); -const DECORATION_INITIAL = { badge: '❗', tooltip: 'Disconnected', color: COLOR_ERROR }; // should become codicon $(warning) -const DECORATION_CONNECTED = { badge: '✔', tooltip: 'Connected...', color: COLOR_OK }; // should become codicon $(vm-active) -const DECORATION_SIGNED = { badge: '🔒', tooltip: 'IntentType: Signed', color: COLOR_READONLY}; +const DECORATION_DISCONNECTED = { badge: '❗', tooltip: 'Not connected!', color: COLOR_ERROR }; // should become codicon $(warning) +const DECORATION_CONNECTED = { badge: '✔', tooltip: 'Connecting...', color: COLOR_OK }; // should become codicon $(vm-active) +const DECORATION_SIGNED = { badge: '🔒', tooltip: 'IntentType: Signed', color: COLOR_READONLY}; const DECORATION_VIEWS = { tooltip: 'UI Form Customization', color: COLOR_CUSTOMIZATION }; const DECORATION_INTENTS = { tooltip: 'Intents', color: COLOR_CUSTOMIZATION }; @@ -190,7 +190,10 @@ export class IntentManagerProvider implements vscode.FileSystemProvider, vscode. setTimeout(() => this._revokeAuthToken(), 600000); // automatically revoke token after 10min } else { this.pluginLogs.warn("NSP response:", response.status, json.error); - vscode.window.showErrorMessage("NSP Authentication Error"); + + DECORATION_DISCONNECTED.tooltip = "Authentication failure (user:"+this.username+", error:"+json.error+")!"; + this._eventEmiter.fire(vscode.Uri.parse('im:/')); + this.authToken = undefined; // Reset authToken on error reject("Authentication Error!"); } @@ -200,7 +203,9 @@ export class IntentManagerProvider implements vscode.FileSystemProvider, vscode. else this.pluginLogs.error("Getting authToken failed with", error.message); - vscode.window.showErrorMessage("NSP is not reachable"); + DECORATION_DISCONNECTED.tooltip = this.nspAddr+" unreachable!"; + this._eventEmiter.fire(vscode.Uri.parse('im:/')); + this.authToken = undefined; // Reset authToken on error resolve(undefined); }); @@ -368,7 +373,9 @@ export class IntentManagerProvider implements vscode.FileSystemProvider, vscode. if (!response.ok) this._raiseRestconfError("Getting NSP release failed!", await response.json()); let json : any = await response.json(); - this.nspVersion = json.response.data.nspOSVersion.match(/\d+\.\d+\.\d+/)[0]; + this.nspVersion = json.response.data.nspOSVersion.match(/\d+\.\d+(?=\.\d+)/)[0]; + + this._eventEmiter.fire(vscode.Uri.parse('im:/')); this.pluginLogs.info("Requesting OSD version"); response = await this._callNSP("/logviewer/api/status", {method: "GET"}); @@ -382,7 +389,6 @@ export class IntentManagerProvider implements vscode.FileSystemProvider, vscode. this.osdVersion = json.version.number; vscode.window.showInformationMessage("Connected to "+this.nspAddr+", NSP version: "+this.nspVersion+", OSD version: "+this.osdVersion); - this._eventEmiter.fire(vscode.Uri.parse('im:/')); } /** @@ -1295,8 +1301,9 @@ export class IntentManagerProvider implements vscode.FileSystemProvider, vscode. this.pluginLogs.debug("provideFileDecoration(im:/)"); if (this.nspVersion) { DECORATION_CONNECTED.tooltip = "Connected to "+this.username+"@"+this.nspAddr+" (Release: "+this.nspVersion+")"; + DECORATION_DISCONNECTED.tooltip = "Not connected!"; // revert to original text return DECORATION_CONNECTED; - } else return DECORATION_INITIAL; + } else return DECORATION_DISCONNECTED; } if (parts[0]==="im:" && pattern.test(parts[1])) { @@ -1374,6 +1381,10 @@ export class IntentManagerProvider implements vscode.FileSystemProvider, vscode. this.port = port; this.nspVersion = undefined; this.osdVersion = undefined; + + DECORATION_CONNECTED.tooltip = "Connecting..."; // revert to original text + DECORATION_DISCONNECTED.tooltip = "Not connected!"; // revert to original text + this._eventEmiter.fire(vscode.Uri.parse('im:/')); } @@ -2432,7 +2443,7 @@ export class IntentManagerProvider implements vscode.FileSystemProvider, vscode. // merge common resources if (fs.existsSync(vscode.Uri.joinPath(templatePath, "merge_common_resources").fsPath)) { - const commonsPath = vscode.Uri.joinPath(this.extensionUri, 'templates', 'common-resources'); + const commonsPath = vscode.Uri.joinPath(this.extensionUri, 'templates', 'common_resources'); const j2resources = nunjucks.configure(commonsPath.fsPath); // @ts-expect-error fs.readdirSync() returns string[] for utf-8 encoding (default) @@ -2448,6 +2459,25 @@ export class IntentManagerProvider implements vscode.FileSystemProvider, vscode. meta.resource.push({name: filename, value: j2resources.render(filename, data)}); }); } + + if (fs.existsSync(vscode.Uri.joinPath(templatePath, "merge_common_resources_graaljs").fsPath)) { + const commonsPath = vscode.Uri.joinPath(this.extensionUri, 'templates', 'common_resources_graaljs'); + const j2resources = nunjucks.configure(commonsPath.fsPath); + + // @ts-expect-error fs.readdirSync() returns string[] for utf-8 encoding (default) + fs.readdirSync(commonsPath.fsPath, {recursive: true}).forEach((filename: string) => { + const fullpath = vscode.Uri.joinPath(commonsPath, filename).fsPath; + if (!fs.lstatSync(fullpath).isFile()) + this.pluginLogs.info("ignore "+filename+" (not a file)"); + else if (filename.startsWith('.') || filename.includes('/.')) + this.pluginLogs.info("ignore hidden file/folder "+filename); + else if (resourcefiles.includes(filename)) + this.pluginLogs.info(filename+" (common) skipped, overwritten in template"); + else + meta.resource.push({name: filename, value: j2resources.render(filename, data)}); + }); + } + // Intent-type "meta" may contain the parameter "intent-type" // RESTCONF API required parameter "name" instead diff --git a/templates/SRX24 iplink/intent-type-resources/Cisco IOS-XR.ftl b/templates/SRX24 iplink/intent-type-resources/Cisco IOS-XR.ftl index 0e16ca8..5c1ef48 100644 --- a/templates/SRX24 iplink/intent-type-resources/Cisco IOS-XR.ftl +++ b/templates/SRX24 iplink/intent-type-resources/Cisco IOS-XR.ftl @@ -26,7 +26,6 @@ }, "health": {} }, - "[${site.ne\-name}] ISIS INTERFACE ${site.port\-id}": { "config": { "target": "Cisco-IOS-XR-um-router-isis-cfg:/router/isis/processes/process=isis/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", @@ -47,7 +46,6 @@ }, "health": {} }, - "[${site.ne\-name}] LDP INTERFACE ${site.port\-id}": { "config": { "target": "Cisco-IOS-XR-um-mpls-ldp-cfg:/mpls/ldp/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", @@ -61,4 +59,4 @@ }, "health": {} } -} +} \ No newline at end of file diff --git a/templates/SRX24 iplink/intent-type-resources/JunOS.ftl b/templates/SRX24 iplink/intent-type-resources/JunOS.ftl index 718b284..777f308 100644 --- a/templates/SRX24 iplink/intent-type-resources/JunOS.ftl +++ b/templates/SRX24 iplink/intent-type-resources/JunOS.ftl @@ -30,7 +30,6 @@ }, "health": {} }, - "[${site.ne\-name}] LLDP INTERFACE ${site.port\-id}": { "config": { "target": "junos-conf-root:/configuration/protocols/lldp/interface=${site.port\-id?url('ISO-8859-1')}", @@ -44,7 +43,6 @@ }, "health": {} }, - "[${site.ne\-name}] ISIS INTERFACE ${site.port\-id}.1": { "config": { "target": "junos-conf-root:/configuration/protocols/isis/interface=${site.port\-id?url('ISO-8859-1')}.1", @@ -59,7 +57,6 @@ }, "health": {} }, - "[${site.ne\-name}] LDP INTERFACE ${site.port\-id}.1": { "config": { "target": "junos-conf-root:/configuration/protocols/ldp/interface=${site.port\-id?url('ISO-8859-1')}.1", @@ -73,4 +70,4 @@ }, "health": {} } -} +} \ No newline at end of file diff --git a/templates/SRX24 iplink/intent-type-resources/OpenConfig.ftl b/templates/SRX24 iplink/intent-type-resources/OpenConfig.ftl index c5d78a6..2b4ce5e 100644 --- a/templates/SRX24 iplink/intent-type-resources/OpenConfig.ftl +++ b/templates/SRX24 iplink/intent-type-resources/OpenConfig.ftl @@ -75,7 +75,6 @@ } } }, - "[${site.ne\-name}] LLDP INTERFACE ${site.port\-id}": { "config": { "target": "openconfig-lldp:/lldp/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", @@ -91,7 +90,6 @@ } } }, - "[${site.ne\-name}] NETWORK INTERFACE ${site.port\-id}": { "config": { "target": "openconfig-network-instance:/network-instances/network-instance=Base/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", @@ -109,7 +107,6 @@ } } }, - "[${site.ne\-name}] ISIS INTERFACE ${site.port\-id}": { "config": { "target": "openconfig-network-instance:/network-instances/network-instance=Base/protocols/protocol=ISIS,0/isis/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", @@ -156,7 +153,6 @@ } } }, - "health": { <#-- SR OS MDC OpenConfig adaptor does not cover levels/level/adjacencies "openconfig-network-instance:/network-instances/network-instance=Base/protocols/protocol=ISIS,0/isis/interfaces/interface=${site.port\-id?url('ISO-8859-1')}/levels/level=1/adjacencies": { @@ -172,7 +168,6 @@ --> } }, - "[${site.ne\-name}] MPLS INTERFACE ${site.port\-id}": { "config": { "target": "openconfig-network-instance:/network-instances/network-instance=Base/mpls/global/interface-attributes/interface=${site.port\-id?url('ISO-8859-1')}", @@ -193,7 +188,6 @@ } } }, - "[${site.ne\-name}] TE INTERFACE ${site.port\-id}": { "config": { "target": "openconfig-network-instance:/network-instances/network-instance=Base/mpls/te-interface-attributes/interface=${site.port\-id?url('ISO-8859-1')}", @@ -214,7 +208,6 @@ } } }, - "[${site.ne\-name}] LDP INTERFACE ${site.port\-id}": { "config": { "target": "openconfig-network-instance:/network-instances/network-instance=Base/mpls/signaling-protocols/ldp/interface-attributes/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", @@ -246,4 +239,4 @@ } } } -} +} \ No newline at end of file diff --git a/templates/SRX24 iplink/intent-type-resources/SR OS.ftl b/templates/SRX24 iplink/intent-type-resources/SR OS.ftl index 22ae4c8..a2a07ed 100644 --- a/templates/SRX24 iplink/intent-type-resources/SR OS.ftl +++ b/templates/SRX24 iplink/intent-type-resources/SR OS.ftl @@ -1,7 +1,7 @@ <#setting number_format="computer"> -<#assign ifname="${target}_${site['ne-name']}_to_${site.peer['ne-name']}"> +<#assign ifname="${site['ne-name']}_to_${site.peer['ne-name']}"> { - "[${site.ne\-name}] PORT ${site.port\-id}": { + "[${site.ne\-name}] PORT ${site.port\-id}": { "config": { "target": "nokia-conf:/configure/port=${site.port\-id?url('ISO-8859-1')}", "operation": "replace", @@ -60,7 +60,6 @@ } } }, - "[${site.ne\-name}] IP INTERFACE ${ifname}": { "config": { "target": "nokia-conf:/configure/router=Base/interface=${ifname?url('ISO-8859-1')}", @@ -86,7 +85,6 @@ } } }, - "[${site.ne\-name}] ISIS INTERFACE ${ifname}": { "config": { "target": "nokia-conf:/configure/router=Base/isis=0/interface=${ifname?url('ISO-8859-1')}", @@ -115,7 +113,6 @@ } } }, - "[${site.ne\-name}] LDP INTERFACE ${ifname}": { "config": { "target": "nokia-conf:/configure/router=Base/ldp/interface-parameters/interface=${ifname?url('ISO-8859-1')}", @@ -213,4 +210,4 @@ } } -} +} \ No newline at end of file diff --git a/templates/SRX24 iplink/intent-type-resources/SRLinux.ftl b/templates/SRX24 iplink/intent-type-resources/SRLinux.ftl index 5d33b27..18e5a10 100644 --- a/templates/SRX24 iplink/intent-type-resources/SRLinux.ftl +++ b/templates/SRX24 iplink/intent-type-resources/SRLinux.ftl @@ -45,7 +45,6 @@ } } }, - "[${site.ne\-name}] LLDP INTERFACE ${site.port\-id}": { "config": { "target": "srl_nokia-system:/system/srl_nokia-lldp:lldp/interface=${site.port\-id?url('ISO-8859-1')}", @@ -71,7 +70,6 @@ } } }, - "[${site.ne\-name}] IP INTERFACE BINDING ${site.port\-id}.1": { "config": { "target": "srl_nokia-network-instance:/network-instance=default/interface=${site.port\-id?url('ISO-8859-1')}.1", @@ -99,7 +97,6 @@ } } }, - "[${site.ne\-name}] ISIS INTERFACE ${site.port\-id}.1": { "config": { "target": "srl_nokia-network-instance:/network-instance=default/protocols/srl_nokia-isis:isis/instance=0/interface=${site.port\-id?url('ISO-8859-1')}.1", @@ -115,7 +112,6 @@ <#else> "ipv4-unicast": { - "admin-state": "enable" } } diff --git a/templates/common-resources/README.MD b/templates/common_resources/README.MD similarity index 100% rename from templates/common-resources/README.MD rename to templates/common_resources/README.MD diff --git a/templates/common-resources/patch.ftl b/templates/common_resources/patch.ftl similarity index 100% rename from templates/common-resources/patch.ftl rename to templates/common_resources/patch.ftl diff --git a/templates/common-resources/state.ftl b/templates/common_resources/state.ftl similarity index 100% rename from templates/common-resources/state.ftl rename to templates/common_resources/state.ftl diff --git a/templates/common-resources/utils.js b/templates/common_resources/utils.js similarity index 100% rename from templates/common-resources/utils.js rename to templates/common_resources/utils.js diff --git a/templates/common-resources/utils_callouts.js b/templates/common_resources/utils_callouts.js similarity index 100% rename from templates/common-resources/utils_callouts.js rename to templates/common_resources/utils_callouts.js diff --git a/templates/common-resources/utils_entrypoints.js b/templates/common_resources/utils_entrypoints.js similarity index 100% rename from templates/common-resources/utils_entrypoints.js rename to templates/common_resources/utils_entrypoints.js diff --git a/templates/common-resources/utils_resources.js b/templates/common_resources/utils_resources.js similarity index 100% rename from templates/common-resources/utils_resources.js rename to templates/common_resources/utils_resources.js diff --git a/templates/common_resources_graaljs/README.MD b/templates/common_resources_graaljs/README.MD new file mode 100644 index 0000000..9a83a74 --- /dev/null +++ b/templates/common_resources_graaljs/README.MD @@ -0,0 +1,50 @@ +# {{ intent_type }} +*created by {{author}}* + +This is an **abstract intent-type** applying IPL!nk principles. +It uses the next-generation JavaScript engine (GraalJS) planned to be added in NSP24.8. + +## DISCLAIMER +**THIS INTENT-TYPE IS FOR RESEARCH/STUDY ONLY!** + +## APPLY YOUR CHANGES +Following files are considered common framework files:
+**DON'T MODIFY THOSE FILES!!!** +``` + intent-type-resources + └── common + ├── CalloutHandler.mjs + ├── IntentHandler.mjs + ├── IntentLogic.mjs + ├── ResourceAdmin.mjs + └── patch.ftl +``` + +To adjust the intent-type to the specific needs of your use-case, consider changing the following: +``` + meta-info.json + script-content.mjs + intent-type-resources + ├── README.MD + ├── OpenConfig.ftl + ├── SR OS.ftl + ├── SRLinux.ftl + ├── default.viewConfig + └── state.ftl + yang-modules + └── *.yang +``` + +The intent-target definition is contained in the `meta-info.json`. The `script-content` contains the +business logic to drive audit and sync operations. The input-form can be customized using the +`default.viewConfig`. + +## RESTRICTIONS +* Performance constraints apply for audits +* Limited error-handling (FTL, ...) +* Requires MDC mediation (devices with NETCONF or gRPC support) + +## EXPERIMENTAL FEATURES +* operation merge and delete +* ignore-children +* intend-based assurance diff --git a/templates/common_resources_graaljs/common/CalloutHandler.mjs b/templates/common_resources_graaljs/common/CalloutHandler.mjs new file mode 100644 index 0000000..8a95fbd --- /dev/null +++ b/templates/common_resources_graaljs/common/CalloutHandler.mjs @@ -0,0 +1,284 @@ +/******************************************************************************** + * CALLOUTS FOR WEBUI PICKERS/SUGGESTS + * + * (c) 2024 by Nokia + ********************************************************************************/ + +/* global classResolver, Java */ +/* global mds, logger, restClient, resourceProvider, utilityService */ +/* eslint no-undef: "error" */ + +let HashMap = classResolver.resolveClass("java.util.HashMap"); +let Arrays = Java.type("java.util.Arrays"); + +export class CalloutHandler +{ + /** + * Executes nsp-inventory:find operation + * + * RESTCONF API operation supports the following options: + * xpath-filter {string} Object selector + * sort-by {string} Output order + * offset {number} Pagination start + * limit {number} Max number of objects + * fields {string} Output selector + * depth {number} Max sub-tree depth + * + * @param {object} options Search criteria + * @param {boolean} flatten Flatten result + * @returns success {boolean}, errmsg {string}, responses {object[]} + * + **/ + + static #nspFind(options, flatten) { + const startTS = Date.now(); + logger.debug("CalloutHandler::#nspFind({})", options["xpath-filter"]); + + var result = {}; + var managerInfo = mds.getManagerByName('NSP'); + if (managerInfo.getConnectivityState().toString() === 'CONNECTED') { + var url = "https://restconf-gateway/restconf/operations/nsp-inventory:find"; + var body = JSON.stringify({"input": options}); + + restClient.setIp(managerInfo.getIp()); + restClient.setPort(managerInfo.getPort()); + restClient.setProtocol(managerInfo.getProtocol().toString()); + + restClient.post(url, "application/json", body, "application/json", (exception, httpStatus, response) => { + const duration = Date.now()-startTS; + logger.info("POST {} {} finished within {} ms", url, options, duration|0); + + if (exception) { + logger.error("Exception {} occured.", exception); + result = { success: false, responses: [], errmsg: "Exception "+exception+" occured."}; + } + else if (httpStatus >= 400) { + // Either client error (4xx) or server error (5xx) + logger.warn("NSP response: {} {}", httpStatus, response); + result = { success: false, responses: [], errmsg: response}; + } + else { + // enable for detailed debugging: + // logger.info("NSP response: {} {}", httpStatus, response); + + const output = JSON.parse(response)["nsp-inventory:output"]; + const count = output["end-index"]-output["start-index"]+1; + const total = output["total-count"]; + if (total===0) + logger.info("NSP response: {}, no objects found", httpStatus); + else if (count===1) + logger.info("NSP response: {}, single object returned", httpStatus); + else if (total===count) + logger.info("NSP response: {}, returned {} objects", httpStatus, count); + else + logger.info("NSP response: {}, returned {} objects, total {}", httpStatus, count, total); + + if (flatten) { + function flattenRecursive(obj, flattenedObject = {}) { + for (const key in obj) { + if (key !== '@') { + if (typeof obj[key]==='object') + flattenRecursive(obj[key], flattenedObject); + else + flattenedObject[key] = obj[key]; + } + } + return flattenedObject; + } + + result = { success: true, responses: output.data.map(object => flattenRecursive(object)) }; + } else + result = { success: true, responses: output.data }; + } + }); + } else + result = { success: false, responses: [], errmsg: "NSP mediator is disconnected." }; + + const duration = Date.now()-startTS; + logger.debug("CalloutHandler::#nspFind({}) finished within {} ms", options["xpath-filter"], duration|0); + + return result; + } + +/** + * Retrieve device details from NSP model + * + * @param {} neId device + * + **/ + + static getDeviceDetails(neId) { + const options = {"depth": 3, "xpath-filter": "/nsp-equipment:network/network-element[ne-id='"+neId+"']"}; + const result = CalloutHandler.#nspFind(options, true); + + if (!result.success) + return {}; + + if (result.responses.length === 0) + return {}; + + return result.responses[0]; + } + + /** + * WebUI callout to get list of nodes from NSP inventory + * If ne-id is available, filter is applied to the given node only + * + **/ + + getNodes(context) { + const startTS = Date.now(); + logger.info("CalloutHandler::getNodes()"); + + const args = context.getInputValues()["arguments"]; + const attribute = context.getInputValues()["arguments"]["__attribute"]; + + var neId = args; + attribute.split('.').forEach( elem => neId = neId[elem] ); + + var options = {'depth': 3, 'fields': 'ne-id;ne-name;type;version;ip-address'}; + if (neId) + options['xpath-filter'] = "/nsp-equipment:network/network-element[ne-id='"+neId+"']"; + else + options['xpath-filter'] = "/nsp-equipment:network/network-element"; + + const result = CalloutHandler.#nspFind(options); + if (!result.success) return {}; + + var nodes = new HashMap(); + result.responses.forEach(node => nodes.put(node['ne-id'], node)); + + const duration = Date.now()-startTS; + logger.info("CalloutHandler::getNodes() finished within "+duration+" ms"); + + return nodes; + } + + /** + * WebUI callout to get list of all ACCESS ports from NSP inventory + * If "ne-id" is present, filter is applied to ports of the given node only + * If "ne-id" and "port-id" are present, filter is applied to the given port only + * + **/ + + getAccessPorts(context) { + const startTS = Date.now(); + logger.info("CalloutHandler::getAccessPorts()"); + + const args = context.getInputValues()["arguments"]; + const attribute = context.getInputValues()["arguments"]["__attribute"]; + + var portId = args; + attribute.split('.').forEach( elem => portId = portId[elem] ); + + var neId = args; + attribute.replace('port-id', 'ne-id').split('.').forEach( elem => neId = neId[elem] ); + + var options = {'depth': 3, 'fields': 'name;description;port-details'}; + if (neId && portId) + options['xpath-filter'] = "/nsp-equipment:network/network-element[ne-id='"+neId+"']/hardware-component/port[name='"+portId+"']"; + else if (neId) + options['xpath-filter'] = "/nsp-equipment:network/network-element[ne-id='"+neId+"']/hardware-component/port[boolean(port-details[port-type='ethernet-port'][port-mode='access'])]"; + else + options['xpath-filter'] = "/nsp-equipment:network/network-element/hardware-component/port[boolean(port-details[port-type='ethernet-port'][port-mode='access'])]"; + + const result = CalloutHandler.#nspFind(options, true); + if (!result.success) return {}; + + var ports = new HashMap(); + result.responses.forEach(port => ports.put(port.name, port)); + + const duration = Date.now()-startTS; + logger.info("CalloutHandler::getAccessPorts() finished within "+duration+" ms"); + + return ports; + } + + /** + * WebUI callout to get list of all ETHERNET ports from NSP inventory + * If "ne-id" is present, filter is applied to ports of the given node only + * If "ne-id" and "port-id" are present, filter is applied to the given port only + * + **/ + + getPorts(context) { + const startTS = Date.now(); + logger.info("CalloutHandler::getPorts()"); + + const args = context.getInputValues()["arguments"]; + const attribute = context.getInputValues()["arguments"]["__attribute"]; + + var portId = args; + attribute.split('.').forEach( elem => portId = portId[elem] ); + + var neId = args; + attribute.replace('port-id', 'ne-id').split('.').forEach( elem => neId = neId[elem] ); + + var options = {'depth': 3, 'fields': 'name;description;port-details'}; + if (neId && portId) + options['xpath-filter'] = "/nsp-equipment:network/network-element[ne-id='"+neId+"']/hardware-component/port[name='"+portId+"']"; + else if (neId) + options['xpath-filter'] = "/nsp-equipment:network/network-element[ne-id='"+neId+"']/hardware-component/port[boolean(port-details[port-type='ethernet-port'])]"; + else + options['xpath-filter'] = "/nsp-equipment:network/network-element/hardware-component/port[boolean(port-details[port-type='ethernet-port'])]"; + + const result = CalloutHandler.#nspFind(options, true); + if (!result.success) return {}; + + var ports = new HashMap(); + result.responses.forEach(port => ports.put(port.name, port)); + + const duration = Date.now()-startTS; + logger.info("CalloutHandler::getPorts() finished within "+duration+" ms"); + + return ports; + } + + /** + * Returns the list of managed devices (neId) for WebUI suggest + * @param {ValueProviderContext} context + * @returns Suggestion data in Map format + */ + + suggestTargetDevices(context) { + const startTS = Date.now(); + const searchString = context.getSearchQuery(); + + if (searchString) + logger.info("CalloutHandler::suggestTargetDevices("+searchString+")"); + else + logger.info("CalloutHandler::suggestTargetDevices()"); + + var rvalue = new HashMap(); + try { + // get connected mediators + var mediators = []; + mds.getAllManagersOfType("REST").forEach(mediator => { + if (mds.getManagerByName(mediator).getConnectivityState().toString() === 'CONNECTED') + mediators.push(mediator); + }); + + // get managed devices from mediator(s) + const devices = mds.getAllManagedDevicesFrom(Arrays.asList(mediators)); + + // filter result by searchString provided from WebUI + var filteredDevicenames = []; + devices.forEach(device => { + if (!searchString || device.getName().indexOf(searchString) !== -1) + filteredDevicenames.push(device.getName()); + }); + logger.info("Found "+filteredDevicenames.length+" devices!"); + + // convert output into HashMap required by WebUI framework + filteredDevicenames.sort().forEach(devicename => rvalue[devicename]=devicename); + } + catch (exception) { + logger.error("Exception {} occured.", exception); + } + + const duration = Date.now()-startTS; + logger.info("CalloutHandler::suggestTargetDevices() finished within "+duration+" ms"); + + return rvalue; + } +} \ No newline at end of file diff --git a/templates/common_resources_graaljs/common/IntentHandler.mjs b/templates/common_resources_graaljs/common/IntentHandler.mjs new file mode 100644 index 0000000..e65189d --- /dev/null +++ b/templates/common_resources_graaljs/common/IntentHandler.mjs @@ -0,0 +1,1214 @@ +/******************************************************************************** + * COMMON INTENT HANDLER IMPLEMENTATION + * + * (c) 2024 by Nokia + ********************************************************************************/ + +/* global classResolver, Java */ +/* global mds, logger, restClient, resourceProvider, utilityService, topologyFactory */ +/* eslint no-undef: "error" */ + +import { IntentLogic } from "common/IntentLogic.mjs"; +import { CalloutHandler } from "common/CalloutHandler.mjs"; + +let ValidateResult = classResolver.resolveClass("com.nokia.fnms.controller.ibn.intenttype.spi.ValidateResult"); +let SynchronizeResult = classResolver.resolveClass("com.nokia.fnms.controller.ibn.intenttype.spi.SynchronizeResult"); +let AuditReport = classResolver.resolveClass("com.nokia.fnms.controller.ibn.intenttype.spi.AuditReport"); +let MisAlignedObject = classResolver.resolveClass("com.nokia.fnms.controller.ibn.intenttype.spi.MisAlignedObject"); +let MisAlignedAttribute = classResolver.resolveClass("com.nokia.fnms.controller.ibn.intenttype.spi.MisAlignedAttribute"); +let RuntimeException = classResolver.resolveClass("java.lang.RuntimeException"); + +let ArrayList = classResolver.resolveClass("java.util.ArrayList"); + +export class IntentHandler extends CalloutHandler +{ + #logic; + + /** + * Create IntentHandler + * + * @param {IntentLogic} intentLogic + * + **/ + + constructor(intentLogic) + { + super(); + this.#logic = intentLogic; + } + + /** + * Retrieve device configuration/state using device model (RESTCONF GET / MDC) + * + * @param {} neId + * @param {} path + * @returns success {boolean} and respone {dict} or errmsg {string} + * + **/ + + #restconfGetDevice(neId, path) { + const startTS = Date.now(); + logger.debug("IntentHandler::#restconfGetDevice({}, {})", neId, path); + + var result = {}; + const managerInfo = mds.getAllManagersWithDevice(neId).get(0); + if (managerInfo.getConnectivityState().toString() === 'CONNECTED') { + const url = "/restconf/data/network-device-mgr:network-devices/network-device="+neId+"/root/"+path; + + restClient.setIp(managerInfo.getIp()); + restClient.setPort(managerInfo.getPort()); + restClient.setProtocol(managerInfo.getProtocol().toString()); + + restClient.get(url, "application/json", (exception, httpStatus, response) => { + const duration = Date.now()-startTS; + logger.info("GET {} finished within {} ms", url, duration|0); + + if (exception) { + logger.error("Exception {} occured.", exception); + result = { success: false, errmsg: "Couldn't connect to mediator. Exception "+exception+" occured." }; + } + else if (httpStatus === 404) { + // Treat resource not found as normal case + // This is to avoid confusing developers when reading the logs + logger.info("NSP response: {} {}", httpStatus, "Resource not found"); + result = { success: false, response: JSON.parse(response), errmsg: "Not Found" }; + } + else if (httpStatus >= 400) { + // Either client error (4xx) or server error (5xx) + logger.warn("NSP response: {} {}", httpStatus, response); + + // Error details returned in accordance to RFC8020 ch7.1 + // {"ietf-restconf:errors":{"error":[{ ___error details__ }]}} + // + // Error fields: + // error-type enumeration + // error-tag string + // error-app-tag? string + // error-path? instance-identifier + // error-message? string + // error-info? anydata + + var errmsg = "HTTP ERROR "+httpStatus; + switch (httpStatus) { + case 400: + // invalid request (should not happen) + errmsg = "Bad Request"; break; + case 401: + // access-control related (should not happen) + errmsg = "Unauthorized (access-denied)"; + break; + case 403: + // access-control related (should not happen) + errmsg = "Forbidden"; + break; + case 404: + // resource does not exist + errmsg = "Not Found"; + break; + case 405: + // operation resource (should not happen) + errmsg = "Method Not Allowed"; + break; + } + + const errorObject = JSON.parse(response); + if ('ietf-restconf:errors' in errorObject) + errorObject['ietf-restconf:errors'].error.forEach( errorDetails => errmsg += "\n\t"+errorDetails['error-message'] ); + + result = { success: false, response: JSON.parse(response), errmsg: errmsg }; + } + else { + // Should be 200 OK + if (response.length > 2048) + logger.info("NSP response: {}, received {} bytes", httpStatus, response.length); + else + logger.info("NSP response: {} {}", httpStatus, response); + result = { success: true, response: JSON.parse(response) }; + } + }); + } else { + logger.error("Mediator for {} is disconneted.", neId); + result = { success: false, errmsg: "Mediator for "+neId+" is disconnected." }; + } + + const duration = Date.now()-startTS; + logger.debug("IntentHandler::#restconfGetDevice() finished within {} ms", duration|0); + + return result; + } + + /** + * Edit device configuration using device model (RESTCONF PATCH / MDC) + * + * @param {} neId target device + * @param {} body SON string (rfc8072 YANG PATCH compliant) + * @returns success {boolean}, response {object}, errmsg {string} + * + **/ + + #restconfPatchDevice(neId, body) { + const startTS = Date.now(); + logger.debug("IntentHandler::#restconfPatchDevice({})", neId); + + var result = {}; + const managerInfo = mds.getAllManagersWithDevice(neId).get(0); + if (managerInfo.getConnectivityState().toString() === 'CONNECTED') { + const url = "/restconf/data/network-device-mgr:network-devices/network-device="+neId+"/root/"; + + restClient.setIp(managerInfo.getIp()); + restClient.setPort(managerInfo.getPort()); + restClient.setProtocol(managerInfo.getProtocol().toString()); + + restClient.patch(url, "application/yang-patch+json", body, "application/json", (exception, httpStatus, response) => { + const duration = Date.now()-startTS; + logger.info("PATCH {} {} finished within {} ms", url, body, duration|0); + + if (exception) { + logger.error("Exception {} occured.", exception); + result = { success: false, errmsg: "Couldn't connect to mediator. Exception "+exception+" occured." }; + } + else if (httpStatus >= 400) { + // Either client error (4xx) or server error (5xx) + logger.warn("NSP response: {} {}", httpStatus, response); + + // Error details returned in accordance to RFC8072 ch2.3 (2 options: global errors OR edit errors ) + // {"ietf-yang-patch:yang-patch-status":{"ietf-restconf:errors":{"error":[{ error details }]}}} + // {"ietf-yang-patch:yang-patch-status":{"edit-status":{"edit":[{"ietf-restconf:errors":{"error":[{ error details }]}}]}}} + // + // Error fields: + // error-type enumeration + // error-tag string + // error-app-tag? string + // error-path? instance-identifier + // error-message? string + // error-info? anydata + + // extract error details from response: + const errorObject = JSON.parse(response); + if ('ietf-yang-patch:yang-patch-status' in errorObject) { + const yangPatchStatus = errorObject['ietf-yang-patch:yang-patch-status']; + + if ('ietf-restconf:errors' in yangPatchStatus) { + // global errors + let errmsg = "[site:"+neId+"] rfc8072 global-errors:"; + yangPatchStatus['ietf-restconf:errors'].error.forEach( errorDetails => errmsg += "\n\t"+errorDetails['error-message'] ); + result = { success: false, response: errorObject, errmsg: errmsg }; + } + else if ('edit-status' in yangPatchStatus) { + // edit errors + let errmsg = "[site:"+neId+"] rfc8072 edit-errors:"; + yangPatchStatus['edit-status'].edit.forEach( edit => { + errmsg += "\n\t"+edit['edit-id']+":"; + edit['ietf-restconf:errors'].error.forEach( errorDetails => { + errmsg += "\n\t\t" + errorDetails['error-tag'] + " path:" + errorDetails['error-path'] + " details:" + errorDetails['error-message']; + }); + }); + result = { success: false, response: errorObject, errmsg: errmsg }; + } + else + result = { success: false, response: errorObject, errmsg: "HTTP ERROR "+httpStatus}; + } else + result = { success: false, response: errorObject, errmsg: "HTTP ERROR "+httpStatus}; + } else { + // 2xx - Success + logger.info("NSP response: {} {}", httpStatus, response); + result = { success: true, response: JSON.parse(response) }; + } + }); + } else { + logger.error("Mediator for {} is disconneted.", neId); + result = { success: false, errmsg: "Mediator for "+neId+" is disconnected." }; + } + + const duration = Date.now()-startTS; + logger.debug("IntentHandler::#restconfPatchDevice() finished within {} ms", duration|0); + + return result; + } + + /** + * Executes framework action, implemented by the mediator + * + * @param {} action mediator framework action to be called + * @param {} input dictionary of input variables (action specific) + * @returns success {boolean}, respone {object}, errmsg {string} + * + **/ + + #fwkAction(action, input) + { + const startTS = Date.now(); + logger.debug("IntentHandler::#fwkAction({})", action); + + var result = {}; + const managerInfo = mds.getManagerByName('NSP'); + if (managerInfo.getConnectivityState().toString() === 'CONNECTED') { + restClient.setIp(managerInfo.getIp()); + restClient.setPort(managerInfo.getPort()); + restClient.setProtocol(managerInfo.getProtocol().toString()); + + restClient.post(action, "application/json", JSON.stringify(input), "application/json", (exception, httpStatus, response) => { + const duration = Date.now()-startTS; + logger.info("POST {} {} finished within {} ms", action, input, duration|0); + + if (exception) { + logger.error("Exception {} occured.", exception); + result = { success: false, errmsg: "Couldn't connect to mediator. Exception "+exception+" occured." }; + } + else if (httpStatus >= 400) { + logger.warn("NSP response: {} {}", httpStatus, response); + result = { success: false, response: JSON.parse(response), errmsg: "HTTP ERROR "+httpStatus }; + + } + else { + logger.debug("NSP response: {} {}", httpStatus, response); + result = { success: true, response: JSON.parse(response) }; + } + }); + } else { + logger.error("NSP mediator is disconneted."); + result = { success: false, errmsg: "NSP mediator is disconnected." }; + } + + const duration = Date.now()-startTS; + logger.debug("IntentHandler::#fwkAction() finished within {} ms", duration|0); + + return result; + } + +/** + * Get list-keys from YANG model for MDC-managed nodes + * + * @param {} neId device + * @param {} listPath path of list to get the keys + * @returns listKeys[] + * + **/ + + #getListKeys(neId, listPath) { + const startTS = Date.now(); + logger.debug("IntentHandler::#getListKeys({}, {})", neId, listPath); + + var result = {}; + const managerInfo = mds.getManagerByName('NSP'); + if (managerInfo.getConnectivityState().toString() === 'CONNECTED') { + const url = "https://restconf-gateway/restconf/meta/api/v1/model/schema/"+neId+"/"+listPath.replace(/=[^=/]+\//g, "/"); + + restClient.setIp(managerInfo.getIp()); + restClient.setPort(managerInfo.getPort()); + restClient.setProtocol(managerInfo.getProtocol().toString()); + + restClient.get(url, "application/json", (exception, httpStatus, response) => { + if (exception) + result = { success: false, errmsg: "Couldn't connect to mediator. Exception "+exception+" occured." }; + else if (httpStatus === 200) + result = { success: true, response: JSON.parse(response) }; + else if (httpStatus === 201) + result = { success: false, errmsg: "Returned httpStatus(201): No response" }; + else + result = { success: false, errmsg: response }; + }); + } else + result = { success: false, errmsg: "NSP mediator is disconnected." }; + + var listKeys = []; + if (result["success"]) { + const attr = result["response"]["attributes"]; + for (var i = 0; i { + const misAlignedAttributeJson = { + "name": misAlignedAttribute.getName(), + "device-name": misAlignedAttribute.getDeviceName(), + "expected-value": misAlignedAttribute.getExpectedValue(), + "actual-value": misAlignedAttribute.getActualValue(), + }; + auditReportJson["misaligned-attribute"].push(misAlignedAttributeJson); + }); + } + + if (auditReport.getMisAlignedObjects()) { + auditReportJson["misaligned-object"] = []; + auditReport.getMisAlignedObjects().forEach(misAlignedObject => { + const misAlignedObjectJson = { + "object-id": misAlignedObject.getObjectId(), + "device-name": misAlignedObject.getDeviceName(), + "is-configured": misAlignedObject.isConfigured(), + "is-undesired": misAlignedObject.isUndesired() + }; + auditReportJson["misaligned-object"].push(misAlignedObjectJson); + }); + } + + // Call the mediator to resolve audit report + let resolveResponse = this.#fwkAction("/resolve-audit", auditReportJson); + if (!resolveResponse.success) + throw new RuntimeException("/resolve-audit failed with " + resolveResponse.errmsg); + + const resolvedAuditReportJson = resolveResponse.response; + logger.info("resolved audit report:\n{}", JSON.stringify(resolvedAuditReportJson, null, " ")); + + // Create a new audit report to send back + const resolvedAuditReport = new AuditReport(); + resolvedAuditReportJson["misaligned-attribute"].forEach(entry => + resolvedAuditReport.addMisAlignedAttribute(new MisAlignedAttribute(entry.name, entry["expected-value"], entry["actual-value"], entry["device-name"])) + ); + resolvedAuditReportJson["misaligned-object"].forEach(entry => + resolvedAuditReport.addMisAlignedObject(new MisAlignedObject(entry["object-id"], entry["is-configured"], entry["device-name"], entry["is-undesired"])) + ); + + const duration = Date.now()-startTS; + logger.debug("IntentHandler::#resolveAudit() finished within {} ms", duration|0); + + return resolvedAuditReport; + } + +/** + * Audit helper to compare intented vs actual config + * + * @param {} neId required for fetching model info + * @param {} basePath target root path of the object under audit + * @param {} aCfg actual config (object) + * @param {} iCfg intended config (object) + * @param {} mode operation: create, replace, merge, delete + * @param {} ignore list of children subtree to ignore + * @param {} auditReport used to report differences + * @param {} obj object reference used for report + * @param {} path used to build up relative path (recursive) + * + **/ + + #compareConfig(neId, basePath, aCfg, iCfg, mode, ignore, auditReport, obj, path) { + const startTS = Date.now(); + logger.debug("IntentHandler::#compareConfig(neId={}, basePath={}, path={})", neId, basePath, path); + + // enable for detailed debugging: + // logger.info("iCfg: "+JSON.stringify(iCfg)); + // logger.info("aCfg: "+JSON.stringify(aCfg)); + + for (const key in iCfg) { + if (key in aCfg) { + if (typeof iCfg[key] !== typeof aCfg[key]) { + // mismatch: type is different + auditReport.addMisAlignedAttribute(new MisAlignedAttribute('/'+basePath+'/'+path+key, 'type '+typeof iCfg[key], 'type '+typeof aCfg[key], obj)); + } else if (!(iCfg[key] instanceof Object)) { + if (iCfg[key] !== aCfg[key]) { + // mismatch: value is different + auditReport.addMisAlignedAttribute(new MisAlignedAttribute('/'+basePath+'/'+path+key, iCfg[key].toString(), aCfg[key].toString(), obj)); + } else { + // aligned: type/value are same + } + } else if (Array.isArray(iCfg[key])) { + if ((iCfg[key].length > 0) && (iCfg[key][0] instanceof Object) || (aCfg[key].length > 0) && (aCfg[key][0] instanceof Object)) { + // children is a yang list + // group by list-key and look one level deeper + const keys = this.#getListKeys(neId, basePath+'/'+path+key); + + const iCfgConverted = iCfg[key].reduce((rdict, entry) => { + const value = keys.map( key => encodeURIComponent(entry[key]) ).join(","); + rdict[value] = entry; + return rdict; + }, {}); + + const aCfgConverted = aCfg[key].reduce((rdict, entry) => { + const value = keys.map( key => encodeURIComponent(entry[key]) ).join(","); + rdict[value] = entry; + return rdict; + }, {}); + + this.#compareConfig(neId, basePath, aCfgConverted, iCfgConverted, mode, ignore, auditReport, obj, path+key+'='); + } else { + const iVal = JSON.stringify(iCfg[key]); + const aVal = JSON.stringify(aCfg[key]); + if (iVal !== aVal) { + auditReport.addMisAlignedAttribute(new MisAlignedAttribute('/'+basePath+'/'+path+key, iVal, aVal, obj)); + } + } + } else { + // children is a yang container + // look one level deeper + this.#compareConfig(neId, basePath, aCfg[key], iCfg[key], mode, ignore, auditReport, obj, path+key+'/'); + } + } else { + if (iCfg[key] instanceof Object) { + // mismatch: list/container is unconfigured + + const iVal = JSON.stringify(iCfg[key]); + if ((iVal === '{}') || (iVal === '[]') || (iVal === '[null]')) + auditReport.addMisAlignedAttribute(new MisAlignedAttribute('/'+basePath+'/'+path+key, iVal, null, obj)); + else + // missing object: is-configured=true, is-undesired=default(false) + auditReport.addMisAlignedObject(new MisAlignedObject('/'+basePath+'/'+path+key, true, neId)); + + // Alternative option: Report as misaligned attribute (JSON string) + // auditReport.addMisAlignedAttribute(new MisAlignedAttribute(path+key, JSON.stringify(iCfg[key]), null, obj)); + } else { + // mismatch: leaf is unconfigured + auditReport.addMisAlignedAttribute(new MisAlignedAttribute('/'+basePath+'/'+path+key, iCfg[key].toString(), null, obj)); + } + } + } + + // undesired nodal attributes (only in mode create/replace) + if (mode !== 'merge') { + for (const key in aCfg) { + if (!(key in iCfg)) { + // Possibility to ignore undesired children that match the list provided. Restrictions: + // (1) Can only ignore what is not part of the object created + // (2) Object created must contain the parent of the ignored + // (3) The ignore option is currently supported for audit only (not for deployment) + + let found = ""; + const aKey = path+key; + for (const idx in ignore) { + if (aKey.startsWith(ignore[idx])) { + found = ignore[idx]; + break; + } + } + + if (!found) { + if (aCfg[key] instanceof Object) { + // mismatch: undesired list/container + + const aVal = JSON.stringify(aCfg[key]); + if ((aVal === '{}') || (aVal === '[]') || (aVal === '[null]')) + auditReport.addMisAlignedAttribute(new MisAlignedAttribute(aKey, null, aVal, obj)); + else + // undesired object: is-configured=true, is-undesired=default(true) + auditReport.addMisAlignedObject(new MisAlignedObject('/'+basePath+'/'+aKey, true, neId, true)); + + // Alternative option: Report as misaligned attribute (JSON string) + // auditReport.addMisAlignedAttribute(new MisAlignedAttribute(aKey, null, JSON.stringify(aCfg[key]), obj)); + } else { + // mismatch: additional leaf + auditReport.addMisAlignedAttribute(new MisAlignedAttribute('/'+basePath+'/'+aKey, null, aCfg[key].toString(), obj)); + } + } + } + } + } + + const duration = Date.now()-startTS; + logger.debug("IntentHandler::#compareConfig(neId={}, basePath={}, path={}) finished within {} ms", neId, basePath, path, duration|0); + } + +/** + * Audit helper to compare intented vs actual state + * + * @param {} neId ne-id, required for fetching model info + * @param {} aState actual state (object) + * @param {} iState intended state (object) + * @param {} auditReport used to report differences + * @param {} obj object reference used for report + * + * @throws RuntimeException + * + **/ + + #compareState(neId, aState, iState, auditReport, qPath) { + const startTS = Date.now(); + logger.debug("IntentHandler::#compareState(neId={}, qPath={})", neId, qPath); + + // enable for detailed debugging: + // logger.info("iState: "+JSON.stringify(iState)); + // logger.info("aState: "+JSON.stringify(iState)); + + const siteName = neId; + for (const key in iState) { + if (iState[key] instanceof Object) { + const path = iState[key].path; + const aValue = this.#jsonPath(aState, path); + + for (const check in iState[key]) { + if (check !== 'path') { + const iValue = iState[key][check]; + if (aValue && aValue.length > 0) { + let match = true; + switch (check) { + case 'equals': + case 'matches': + match = (aValue[0] === iValue); + break; + case 'contains': + case 'includes': + match = (aValue[0].indexOf(iValue) != -1); + break; + case 'startsWith': + match = (aValue[0].startsWith(iValue)); + break; + case 'endsWith': + match = (aValue[0].endsWith(iValue)); + break; + case 'regex': + match = RegExp(iValue).test(aValue[0]); + break; + default: + throw new RuntimeException("Unsupported check '"+check+"' for object("+path+"), jsonpath("+path+"), expected value("+iValue+")"); + } + if (!match) + auditReport.addMisAlignedAttribute(new MisAlignedAttribute(qPath+'/'+key, iValue.toString(), aValue[0].toString(), siteName)); + } else { + auditReport.addMisAlignedAttribute(new MisAlignedAttribute(qPath+'/'+key, iValue.toString(), null, siteName)); + } + } + } + } else if (key in aState) { + if (iState[key] !== aState[key]) + auditReport.addMisAlignedAttribute(new MisAlignedAttribute(qPath+'/'+key, iState[key].toString(), aState[key].toString(), siteName)); + } else { + auditReport.addMisAlignedAttribute(new MisAlignedAttribute(qPath+'/'+key, iState[key].toString(), null, siteName)); + } + } + + const duration = Date.now()-startTS; + logger.debug("IntentHandler::#compareState(neId={}, qPath={}) finished within {} ms", neId, qPath, duration|0); + } + +/** + * Validation of intent config/target that is automatically called for intent + * create/edit and state-change operations. + * + * If the intent config is identified invalid, the create/edit operation will + * fail. Execution happens before synchronize() to ensure intent data is valid. + * + * In this particular case we are validating if the device is known to the + * mediator and if the corresponding freemarker template (ftl) could be loaded. + * + * @param {} input input provided by intent-engine + * @returns {ValidateResult} * + **/ + + validate(input) { + const startTS = Date.now(); + + const target = input.getTarget(); + const config = JSON.parse(input.getJsonIntentConfiguration())[0][this.#logic.INTENT_ROOT]; + + logger.info("IntentHandler::validate()"); + + var contextualErrorJsonObj = {}; + var validateResult = new ValidateResult(); + + this.#logic.getSites(target, config).forEach(neId => { + const neInfo = mds.getAllInfoFromDevices(neId); + + if (neInfo === null || neInfo.size() === 0) { + contextualErrorJsonObj["NODE "+neId] = "Node not found"; + } else { + const neFamilyTypeRelease = neInfo.get(0).getFamilyTypeRelease(); + if (neFamilyTypeRelease === null) { + contextualErrorJsonObj["NODE "+neId] = "Family/Type/Release unkown"; + } else { + const neType = neFamilyTypeRelease.split(':')[0]; + const neVersion = neFamilyTypeRelease.split(':')[1]; + const templateName = this.#logic.getTemplateName(neId, neType); + try { + resourceProvider.getResource(templateName); + } catch (e) { + contextualErrorJsonObj["NODE "+neId] = "Device type unsupported! Template '"+templateName+"' not found!"; + } + } + } + }); + + this.#logic.validate(target, config, contextualErrorJsonObj); + + const duration = Date.now()-startTS; + logger.info("IntentHandler::validate() finished within {} ms", duration|0); + + if (Object.keys(contextualErrorJsonObj).length !== 0) + utilityService.throwContextErrorException(contextualErrorJsonObj); + + return validateResult; + } + +/** + * Deployment of intents to the network, called for synchronize operations. + * Used to apply create, update, delete and reconcile to managed devices. + * + * All objects created are remembered/restored as part of topology/extra-data + * to enable update and delete operations removing network objects that are + * no longer required (house-keeping). + * + * In the deployment template (ftl) it's recommended to use operations "replace", + * "merge", or "remove". The usage of "create" must be avoided, because it fails + * if the network object already exists (use "replace" instead). The usage of + * "delete" must be avoided, because it fails if the network object does not + * exists (use "remove" instead). + * + * @param {SynchronizeInput} input information about the intent to be synchronized + * @returns {SynchronizeResult} provide information about the execution/success back to the engine + * + **/ + + async synchronize(input) { + const startTS = Date.now(); + const target = input.getTarget(); + const state = input.getNetworkState().name(); + + logger.info("IntentHandler::synchronize() in state {} ", state); + + const config = JSON.parse(input.getJsonIntentConfiguration())[0][this.#logic.INTENT_ROOT]; + + var topology = input.getCurrentTopology(); + var syncResult = new SynchronizeResult(); + + var sitesConfigs = {}; + var sitesCleanups = {}; + var deploymentErrors = []; + + const yangPatchTemplate = resourceProvider.getResource("common/patch.ftl"); + + // Recall nodal configuration elements from previous synchronize (for cleanup/housekeeping) + if (topology && topology.getXtraInfo()!==null && !topology.getXtraInfo().isEmpty()) { + topology.getXtraInfo().forEach(item => { + if (item.getKey() === 'sitesCleanups') { + sitesCleanups = JSON.parse(item.getValue()); + sitesConfigs = JSON.parse(item.getValue()); // deep-clone of sitesCleanups + logger.info("sitesCleanups restored: "+item.getValue()); + } + }); + } + + // Secure resources from Resource Admin + // Right now, we are assuming that reservation is required in any state but delete + + if (state !== 'delete') + this.#logic.obtainResources(target, config); + + if (state === "active") { + const global = this.#logic.getGlobalParameters(target, config); + + // Iterate sites to populate/update sitesConfigs per target device + this.#logic.getSiteParameters(target, config).forEach(site => { + const neId = site['ne-id']; + const neInfo = mds.getAllInfoFromDevices(neId); + const neFamilyTypeRelease = neInfo.get(0).getFamilyTypeRelease(); + const neType = neFamilyTypeRelease.split(':')[0]; + const neVersion = neFamilyTypeRelease.split(':')[1]; + + if (!(neId in sitesConfigs)) + sitesConfigs[neId] = {}; + + const siteFTL = resourceProvider.getResource(this.#logic.getTemplateName(neId, neType)); + const objects = JSON.parse(utilityService.processTemplate(siteFTL, {'target': target, 'site': site, 'global': global, 'neVersion': neVersion, 'mode': 'sync'})); + + for (const objectName in objects) { + if ("config" in objects[objectName]) { + sitesConfigs[neId][objectName] = objects[objectName].config; + + // Convert 'value' object to JSON string as required as input for PATCH.ftl + if (objects[objectName].config.value) { + let value = this.#resolveSynchronize(target, neId, '/'+objects[objectName].config.target, objects[objectName].config.value); + sitesConfigs[neId][objectName].value = JSON.stringify(value); + } + } + } + }); + } + + // Deploy changes to target devices and update topology objects and xtra-data + if (state === "active" || state === "suspend" || state === "delete") { + let topologyObjects = []; + for (const neId in sitesConfigs) { + const body = utilityService.processTemplate(yangPatchTemplate, {'patchId': target, 'patchItems': sitesConfigs[neId]}); + + let result = this.#restconfPatchDevice(neId, body); + + if (result.success) { + // RESTCONF YANG PATCH was successful + // - objects that have been added/updated are added to the new topology + // - objects that have been added/updated are added to siteCleanups (extraData) to enable housekeeping + + sitesCleanups[neId] = {}; + for (const objectName in sitesConfigs[neId]) { + if (sitesConfigs[neId][objectName]["operation"]==="replace") { + // For operation "replace" remember how to clean-up the object created (house-keeping). + // For cleanup we are using operation "remove", to avoid the operation from failing, + // if the corresponding device configuration was deleted from the network already. + + sitesCleanups[neId][objectName] = {'target': sitesConfigs[neId][objectName].target, 'operation': 'remove'}; + topologyObjects.push(topologyFactory.createTopologyObjectFrom(objectName, sitesConfigs[neId][objectName].target, "INFRASTRUCTURE", neId)); + } + + // NOTE: + // Operations "merge", and "remove" will not be reverted back! + // Operations "create", and "delete" should not be used (not reverted back either)! + } + + if (Object.keys(sitesCleanups[neId]).length === 0) + delete sitesCleanups[neId]; + + } else { + logger.error("Deployment on {} failed with error:\n{}", neId, result.errmsg); + deploymentErrors.push(result.errmsg); + + // RESTCONF YANG PATCH failed + // - Keep siteCleanups (extraData) for this site to enable housekeeping + // - Generate topology from siteCleanup (same content as it was before) + + if (neId in sitesCleanups) { + for (const objectName in sitesCleanups[neId]) { + topologyObjects.push(topologyFactory.createTopologyObjectFrom(objectName, sitesCleanups[neId][objectName].target, "INFRASTRUCTURE", neId)); + } + } + } + + if (topology === null) + topology = topologyFactory.createServiceTopology(); + + let xtrainfo = topologyFactory.createTopologyXtraInfoFrom("sitesCleanups", JSON.stringify(sitesCleanups)); + + topology.setXtraInfo([xtrainfo]); + topology.setTopologyObjects(topologyObjects); + } + } + + syncResult.setTopology(topology); + + if (deploymentErrors.length > 0) { + syncResult.setSuccess(false); + syncResult.setErrorCode("500"); + syncResult.setErrorDetail(deploymentErrors.toString()); + } else { + syncResult.setSuccess(true); + if (state === 'delete') + this.#logic.freeResources(target, config); + } + + const duration = Date.now()-startTS; + logger.info("IntentHandler::synchronize() finished within {} ms", duration|0); + + return syncResult; + } + + /** + * Function to audit intents. Renders the desired configuration (same + * as synchronize) and retrieves the actual configuration from MDC. + * Compares actual against desired configuration to produce the AuditReport. + * + * @param {} input input provided by intent-engine + * + * @throws {RuntimeException} config/state retrieval failed + * + **/ + + async onAudit(input) { + const startTS = Date.now(); + const target = input.getTarget(); + const state = input.getNetworkState().name(); + + logger.info("IntentHandler::onAudit() in state {} ", state); + + const config = JSON.parse(input.getJsonIntentConfiguration())[0][this.#logic.INTENT_ROOT]; + + var topology = input.getCurrentTopology(); + var auditReport = new AuditReport(); + + // Recall nodal configuration elements from previous synchronize + var obsoleted = {}; + if (topology && topology.getXtraInfo()!==null && !topology.getXtraInfo().isEmpty()) { + topology.getXtraInfo().forEach(item => { + if (item.getKey() === 'sitesCleanups') { + obsoleted = JSON.parse(item.getValue()); + } + }); + } + + if (state === 'active') { + // Obtain resources from Resource Admin + // Remind, this is done even if the intent was not synchronized before! + // Required for getSiteParameters() and getGlobalParameters() + this.#logic.obtainResources(target, config); + const global = this.#logic.getGlobalParameters(target, config); + + // Iterate sites to populate/update sitesConfigs per target device + this.#logic.getSiteParameters(target, config).forEach(site => { + const neId = site['ne-id']; + const neInfo = mds.getAllInfoFromDevices(neId); + const neFamilyTypeRelease = neInfo.get(0).getFamilyTypeRelease(); + const neType = neFamilyTypeRelease.split(':')[0]; + const neVersion = neFamilyTypeRelease.split(':')[1]; + + const siteFTL = resourceProvider.getResource(this.#logic.getTemplateName(neId, neType)); + const objects = JSON.parse(utilityService.processTemplate(siteFTL, {'target': target, 'site': site, 'global': global, 'neVersion': neVersion, 'mode': 'audit'})); + + // Audit device configuration + for (const objectName in objects) { + if ("config" in objects[objectName]) { + const result = this.#restconfGetDevice(neId, objects[objectName].config.target+"?content=config"); + if (result.success) { + let iCfg = objects[objectName].config.value; + for (const key in iCfg) { + iCfg = iCfg[key]; + break; + } + + let aCfg = result.response; + for (const key in aCfg) { + aCfg = aCfg[key]; + break; + } + + if (Array.isArray(aCfg)) + if (aCfg.length === 0) { + // an empty was returned + // missing object: is-configured=true, is-undesired=default(false) + auditReport.addMisAlignedObject(new MisAlignedObject('/'+objects[objectName].config.target, true, neId)); + aCfg = null; + } else { + // Due to the nature of RESTCONF GET, we've received a single entry list + // Execute the audit against this single entry + aCfg = aCfg[0]; + } + + if (aCfg) { + this.#compareConfig(neId, objects[objectName].config.target, aCfg, iCfg, objects[objectName].config.operation, objects[objectName].config.ignoreChildren, auditReport, neId, ''); + // this.#compareConfig(neId, objects[objectName].config.target, aCfg, iCfg, objects[objectName].config.operation, objects[objectName].config.ignoreChildren, auditReport, objectName, ''); + } + } + else if (result.errmsg === "Not Found") { + // get failed, because path is not configured + // missing object: is-configured=true, is-undesired=default(false) + auditReport.addMisAlignedObject(new MisAlignedObject('/'+objects[objectName].config.target, true, neId)); + } else { + logger.error("RESTCONF GET failed with error:\n" + result.errmsg); + throw new RuntimeException("RESTCONF GET failed with " + result.errmsg); + } + + // Configuration object is still present, remove from obsoleted + if (neId in obsoleted) + if (objectName in obsoleted[neId]) + delete obsoleted[neId][objectName]; + } + } + + for (const objectName in objects) { + if ("health" in objects[objectName]) { + for (const path in objects[objectName].health) { + const iState = objects[objectName].health[path]; + const result = this.#restconfGetDevice(neId, path); + if (result.success) { + let aState = result.response; + for (const key in aState) { + aState = aState[key]; + break; + } + if (Array.isArray(aState)) + aState = aState[0]; + + this.#compareState(neId, aState, iState, auditReport, '/'+path); + } + else if (result.errmsg === "Not Found") { + // get failed, because path is not available + // missing state object: is-configured=false, is-undesired=default(false) + auditReport.addMisAlignedObject(new MisAlignedObject('/'+path, false, neId)); + // this.#compareState(neId, {}, iState, auditReport, '/'+path); + } else { + logger.error("RESTCONF GET failed with error:\n" + result.errmsg); + throw new RuntimeException("RESTCONF GET failed with " + result.errmsg); + } + } + } + } + }); + } + + // Report undesired objects: is-configured=true, is-undesired=true + for (const neId in obsoleted) + for (const objectName in obsoleted[neId]) + auditReport.addMisAlignedObject(new MisAlignedObject('/'+obsoleted[neId][objectName].target, true, neId, true)); + + const resolvedAuditReport = this.#resolveAudit(target, auditReport); + + const duration = Date.now()-startTS; + logger.info("IntentHandler::onAudit() finished within {} ms", duration|0); + + return resolvedAuditReport; + } + + /** + * Function to compute/retrieve read-only state-attributes. + * + * @param {StateRetrievalInput} input input provided by intent-engine + * @return {*} State attributes report + * + **/ + + getStateAttributes(input) { + const startTS = Date.now(); + const target = input.getTarget(); + const state = input.getNetworkState().name(); + const config = JSON.parse(input.getJsonIntentConfiguration())[0][this.#logic.INTENT_ROOT]; + + if (state === "delete") + return null; + + logger.info("IntentHandler::getStateAttributes() in state {}", state); + + // Iterate sites to get indiciators + let indicators = {}; + this.#logic.getSiteParameters(target, config).forEach(site => { + const neId = site['ne-id']; + const neInfo = mds.getAllInfoFromDevices(neId); + const neFamilyTypeRelease = neInfo.get(0).getFamilyTypeRelease(); + const neType = neFamilyTypeRelease.split(':')[0]; + const neVersion = neFamilyTypeRelease.split(':')[1]; + + const global = this.#logic.getGlobalParameters(target, config); + const siteFTL = resourceProvider.getResource(this.#logic.getTemplateName(neId, neType)); + const objects = JSON.parse(utilityService.processTemplate(siteFTL, {'target': target, 'site': site, 'global': global, 'neVersion': neVersion, 'mode': 'state'})); + + for (const objectName in objects) { + if ("indicators" in objects[objectName]) { + for (const uri in objects[objectName].indicators) { + const result = this.#restconfGetDevice(neId, uri); + + if (result.success) { + let response = result.response; + for (const key in response) { + response = response[key]; + break; + } + if (Array.isArray(response)) + response = response[0]; + + for (const indicator in objects[objectName].indicators[uri]) { + const value = this.#jsonPath(response, objects[objectName].indicators[uri][indicator].path); + if (value && (value.length > 0)) { + if (!(indicator in indicators)) + indicators[indicator] = {}; + indicators[indicator][neId] = value[0]; + } + } + } + } + } + } + }); + const stateinfo = this.#logic.getState(target, config, input.getCurrentTopology()); + + if (!indicators && !stateinfo) { + logger.info('Neither indicators nor state info collected.'); + + const duration = Date.now()-startTS; + logger.info("IntentHandler::getStateAttributes() finished within {} ms", duration|0); + return null; + } + + if (indicators) + logger.info('collected indicators: '+JSON.stringify(indicators, null, " ")); + if (stateinfo) + logger.info('collected state-info: '+JSON.stringify(stateinfo, null, " ")); + + const stateFTL = resourceProvider.getResource("state.ftl"); + const stateXML = utilityService.processTemplate(stateFTL, {'state': stateinfo, 'indicators': indicators}); + + logger.info('state report:\n'+stateXML); + + const duration = Date.now()-startTS; + logger.info("IntentHandler::getStateAttributes() finished within {} ms", duration|0); + + return stateXML; + } + + /** + * Returns list of target devices + * @param {*} input + * @returns {ArrayList} + * + **/ + + getTargettedDevices(input) { + const startTS = Date.now(); + const target = input.getTarget(); + const config = JSON.parse(input.getJsonIntentConfiguration())[0][this.#logic.INTENT_ROOT]; + + // const state = input.getNetworkState().toString(); + // const version = input.getIntentTypeVersion(); + // const topology = input.getCurrentTopology(); + + logger.info("IntentHandler::getTargettedDevices()"); + + let deviceList = new ArrayList(); + this.#logic.getSites(target, config).forEach(neId => deviceList.add(neId)); + + const duration = Date.now()-startTS; + logger.info("IntentHandler::getTargettedDevices() finished within {} ms", duration|0); + + return deviceList; + } +} \ No newline at end of file diff --git a/templates/common_resources_graaljs/common/IntentLogic.mjs b/templates/common_resources_graaljs/common/IntentLogic.mjs new file mode 100644 index 0000000..7f5f553 --- /dev/null +++ b/templates/common_resources_graaljs/common/IntentLogic.mjs @@ -0,0 +1,183 @@ +/******************************************************************************** + * INTENT LOGIC MASTER + * + * (c) 2024 by Nokia + ********************************************************************************/ + +export class IntentLogic { + static INTENT_TYPE = "##"; + static INTENT_ROOT = "##:##"; + + /** + * Produces a list of sites (neId: string) to be configured. + * Default implementation assumes target holds the (single) device + * to be configured. + * + * Default implementation returns the target, assuming intents for + * a single site, typically used for any sort of golden site-level + * configuration. + * + * @param {string} target Intent target + * @param {object} config Intent configuration (dict) + * @returns {string[]} List of sites (ne-id) to be configured + * + **/ + + static getSites(target, config) { + var sites = []; + sites.push(target); + return sites; + } + + /** + * Produces a list of sites, while each site entry is an object (dict) + * of site-level parameters used to render the corresponding Freemarker + * template (ftl). + * + * Every site object must have the `ne-id` property, which is used + * as unique key to drive deployments and audits. + * + * Within the template all properties can be accessed using + * the expression ${site.parameter}. For example one can extract + * the site-id using the expression ${site.ne\-id}. + * + * For global parameters (not specific per site), please use the + * method `getGlobalParameters()`. + * + * If site-level attributes are calculated or retrieved from other sources, + * for example inventory lookups or resource administrator, here is the place + * to put the corresponding logic. For resource allocation (obtain/release) + * there is dedicated methods available. + * + * Default implementation returns the entire device config. + * + * @param {string} target Intent target + * @param {Dict} config Intent configuration + * @param {string} neId Target device + * @returns {Dict} site-level settings (site.*) + */ + + static getSiteParameters(target, config) { + return config; + } + + /** + * Produces an object (dict) of intent-level parameters (valid for all sites). + * + * Within the template all properties can be accessed using + * the expression ${global.parameter}. + * + * If intent-level attributes are calculated or retrieved from other sources, + * for example inventory lookups or resource administrator, here is the place + * to put the corresponding logic. For resource allocation (obtain/release) + * there is dedicated methods available. + * + * Default implementation returns the entire device config. + * + * @param {string} target Intent target + * @param {Dict} config Intent configuration + * @returns {Dict} intent-level settings (global.*) + */ + + static getGlobalParameters(target, config) { + return config; + } + + /** + * Intent-type specific validation. It will be executed in addition to framework-level + * validations. Default implementation does not have any extra validation rules. + * + * @param {string} target Intent target + * @param {Dict} config Intent configuration + * @param {Dict} contextualErrorJsonObj used to return list of validation errors (key/value pairs) + */ + + static validate(target, config, contextualErrorJsonObj) { + } + + /** + * Migrate from/to a different versions of this intent-type. + * Method supports upgrades to this version and downgrades from this version. + * Default implementation assumes intent-type-version 1 and therefore + * does not contain migration code. + * + * @param {object} migrationInput object that has the migration input + * + */ + + static migrate(migrationInput) { + let sourceVersion = parseInt(migrationInput.getSourceVersion()); + let targetVersion = parseInt(migrationInput.getTargetVersion()); + let intentConfigJson = JSON.parse(migrationInput.getJsonIntentConfiguration())[0][this.modelRoot]; + let intentTopology = migrationInput.getCurrentTopology(); + return ""; + } + + /** + * Produces an object with all state attributes to render `state.ftl` + * Default implementation returns Dict without entries. + * + * @param {string} target Intent target + * @param {Dict} config Intent configuration + * @param {Dict} topology Intent topology + * @returns {Dict} state attributes + */ + + static getState(target, config, topology) { + return {}; + } + + /** + * Obtain resources from resource administrator (or external). + * Called if intent moves into planned/deployed state to ensure resources are available. + * + * @param {string} target Intent target + * @param {Dict} config Intent configuration + */ + + static obtainResources(target, config) { + } + + /** + * Releases resources from resource administrator (or external). + * Called if intent moves into "not present" state to ensure resources are released. + * + * @param {string} target Intent target + * @param {Dict} config Intent configuration + */ + + static freeResources(target, config) { + } + + /** + * Returns the name of free-marker template (ftl) to be used (specific per site). + * Generic recommendation is to use a common pattern like "{neType}.ftl" + * + * NOTE: + * In some cases we need to understand the role of a node like hub-site, server-site, + * switching-site. This may require that getSites returns the sites and roles and roles + * would need to be passed into this method. + * + * @param {} neId + * @param {} familyTypeRelease + * @returns {String} name of the FTL file + */ + + static getTemplateName(neId, familyTypeRelease) { + var neType = familyTypeRelease.split(':')[0]; + + switch (neType) { + case "7220 IXR SRLinux": + case "7250 IXR SRLinux": + case "7730 SXR SRLinux": + return "SRLinux.ftl"; + case "7750 SR": + case "7450 ESS": + case "7950 XRS": + case "7250 IXR": + return "SR OS.ftl"; + default: + return "OpenConfig.ftl"; + } + } +} \ No newline at end of file diff --git a/templates/common_resources_graaljs/common/ResourceAdmin.mjs b/templates/common_resources_graaljs/common/ResourceAdmin.mjs new file mode 100644 index 0000000..7ef3806 --- /dev/null +++ b/templates/common_resources_graaljs/common/ResourceAdmin.mjs @@ -0,0 +1,345 @@ +/******************************************************************************** + * + * WRAPPER TO SIMPLIFY USAGE OF RESOURCE ADMINISTRATOR + * (c) 2024 by Nokia + * + ********************************************************************************/ + +/* global classResolver, Java */ +/* global mds, logger, restClient, resourceProvider, utilityService */ +/* eslint no-undef: "error" */ + +let RuntimeException = classResolver.resolveClass("java.lang.RuntimeException"); + +export class ResourceAdmin { + /** + * Executes nsp-inventory:find operation + * + * @param {string} xpath Search criteria (object selector) + * @returns success {boolean}, errmsg {string}, response {object} + * + **/ + + static #nspFindEntry(xpath) { + const startTS = Date.now(); + logger.debug("ResourceAdmin::#nspFindEntry({})", xpath); + + var result = {}; + var managerInfo = mds.getManagerByName('NSP'); + if (managerInfo.getConnectivityState().toString() === 'CONNECTED') { + var url = "https://restconf-gateway/restconf/operations/nsp-inventory:find"; + var body = JSON.stringify({"input": {"xpath-filter": xpath}}); + + restClient.setIp(managerInfo.getIp()); + restClient.setPort(managerInfo.getPort()); + restClient.setProtocol(managerInfo.getProtocol().toString()); + + restClient.post(url, "application/json", body, "application/json", (exception, httpStatus, response) => { + const duration = Date.now()-startTS; + logger.debug("POST {} {} finished within {} ms", url, body, duration|0); + + if (exception) { + logger.error("Exception {} occured.", exception); + result = { success: false, response: {}, errmsg: "Exception "+exception+" occured."}; + } + else if (httpStatus >= 400) { + // Either client error (4xx) or server error (5xx) + logger.warn("NSP response: {} {}", httpStatus, response); + result = { success: false, response: {}, errmsg: response}; + } + else { + logger.debug("NSP response: {} {}", httpStatus, response); + const output = JSON.parse(response)["nsp-inventory:output"]; + const total = output["total-count"]; + + if (total === 0) { + logger.debug("Resource not found"); + result = { success: true, response: {} }; + } + else if (total > 1) { + logger.warn("Query xpath={} returned multiple resources! Only returning first entry found!", xpath); + result = { success: true, response: output.data[0] }; + } else { + result = { success: true, response: output.data[0] }; + } + } + }); + } else + result = { success: false, response: {}, errmsg: "NSP mediator is disconnected." }; + + const duration = Date.now()-startTS; + logger.debug("ResourceAdmin::#nspFindEntry({}) finished within {} ms", xpath, duration|0); + + return result; + } + + /** + * Executes NSP RESTCONF action + * + * @param {} resource model-path string of the parent resource to execute the action + * @param {} input dictionary of input variables (action specific) + * @returns success {boolean} and errmsg {string} + * + **/ + + static #restconfNspAction(resource, input) { + const startTS = Date.now(); + logger.debug("ResourceAdmin::#restconfNspAction({})", resource); + + let result = {}; + const managerInfo = mds.getManagerByName('NSP'); + if (managerInfo.getConnectivityState().toString() === 'CONNECTED') { + const url = "https://restconf-gateway/restconf/data/"+resource; + const body = JSON.stringify({"input": input}); + + restClient.setIp(managerInfo.getIp()); + restClient.setPort(managerInfo.getPort()); + restClient.setProtocol(managerInfo.getProtocol().toString()); + + restClient.post(url, "application/json", body, "application/json", (exception, httpStatus, response) => { + const duration = Date.now()-startTS; + logger.debug("POST {} {} finished within {} ms", url, body, duration|0); + + if (exception) { + logger.error("Exception {} occured.", exception); + result = { success: false, response: {}, errmsg: "Exception "+exception+" occured."}; + } + else if (httpStatus >= 400) { + // Either client error (4xx) or server error (5xx) + logger.warn("NSP response: {} {}", httpStatus, response); + result = { success: false, response: {}, errmsg: response}; + } + else { + logger.debug("NSP response: {} {}", httpStatus, response); + result = { success: true, response: JSON.parse(response) }; + } + }); + } else + result = { success: false, errmsg: "NSP mediator is disconnected." }; + + const duration = Date.now()-startTS; + logger.debug("ResourceAdmin::#restconfNspAction() finished within {} ms", duration|0); + + return result; + } + + /** + * Get reserved subnet from Resource Admin + * + * @param {} pool IP pool to be used + * @param {} scope IP pool to be used + * @param {} target reference for reservation + * @returns subnet as string, for example 10.0.0.0/31 (or empty string in error cases) + * + **/ + + static getSubnet(pool, scope, target) { + const startTS = Date.now(); + logger.info("ResourceAdmin::getSubnet(pool={}, scope={}, target={})", pool, scope, target); + + const result = this.#nspFindEntry("/nsp-resource-pool:resource-pools/ip-resource-pools[name='"+pool+"' and scope='"+scope+"']/consumed-resources[reference='"+target+"']"); + + if (!result.success) + throw new RuntimeException("Find subnet failed with "+result.errmsg); + + let subnet = ""; + if ('value' in result.response) { + subnet = result.response.value; + logger.info("subnet: {}", subnet ); + } else { + logger.info("subnet: to be reserved/obtained"); + } + + const duration = Date.now()-startTS; + logger.info("ResourceAdmin::getSubnet(pool={}, scope={}, target={}) finished within {} ms", pool, scope, target, duration|0); + + return subnet; + } + + /** + * Obtain subnet from Resource Admin + * + * @param {} pool IP pool to be used + * @param {} scope IP pool to be used + * @param {} purpose Purpose tag, for example 'network-link' + * @param {} pfxlen Prefix-length for subnet + * @param {} intentTypeName reference for reservation + * @param {} target reference for reservation + * @returns subnet as string, for example 10.0.0.0/31 (or empty string in error cases) + * + * @throws {RuntimeException} obtain subnet failed + * + **/ + + static obtainSubnet(pool, scope, purpose, pfxlen, intentTypeName, target) { + const startTS = Date.now(); + logger.info("ResourceAdmin::obtainSubnet(pool={}, scope={}, target={})", pool, scope, target); + + const result = this.#nspFindEntry("/nsp-resource-pool:resource-pools/ip-resource-pools[name='"+pool+"' and scope='"+scope+"']/consumed-resources[reference='"+target+"']"); + + if (!result.success) + throw new RuntimeException("Find subnet failed with "+result.errmsg); + + let subnet = ""; + if ('value' in result.response) { + subnet = result.response.value; + logger.info("subnet: {} (existing entry)", subnet ); + } else { + const resource = "nsp-resource-pool:resource-pools/ip-resource-pools="+pool+","+scope+"/obtain-value-from-pool"; + const input = { + "owner": "restconf/data/ibn:ibn/intent=" + target + "," + intentTypeName, + "confirmed": true, + "all-or-nothing": true, + "reference": target, + "total-number-of-resources": 1, + "allocation-mask": pfxlen, + "purpose": purpose + }; + const result = this.#restconfNspAction(resource, input); + + if (!result.success) + throw new RuntimeException("Obtain subnet failed with "+result.errmsg); + + subnet = result.response["nsp-resource-pool:output"]["consumed-resources"][0][0].value; + logger.info("subnet: {} (new entry)", subnet ); + } + + const duration = Date.now()-startTS; + logger.info("ResourceAdmin::obtainSubnet(pool={}, scope={}, target={}) finished within {} ms", pool, scope, target, duration|0); + + return subnet; + } + + /** + * Release subnet from Resource Admin + * + * @param {} pool IP pool to be used + * @param {} scope IP pool to be used + * @param {} target reference for reservation + * + **/ + + static releaseSubnet(pool, scope, target) { + const startTS = Date.now(); + logger.info("ResourceAdmin::releaseSubnet(pool={}, scope={}, target={})", pool, scope, target); + + const resource = "nsp-resource-pool:resource-pools/ip-resource-pools="+pool+","+scope+"/release-by-ref"; + const input = {"reference": target}; + + const result = this.#restconfNspAction(resource, input); + + if (!result.success) + logger.error("releaseSubnet(" + target + ") failed with error:\n" + result.errmsg); + + const duration = Date.now()-startTS; + logger.info("ResourceAdmin::releaseSubnet(pool={}, scope={}, target={}) finished within {} ms", pool, scope, target, duration|0); + } + + /** + * Get reserved number from Resource Admin + * + * @param {} pool numeric pool to be used + * @param {} scope numeric pool to be used + * @param {} target reference for reservation + * @returns number as string + * + **/ + + static getId(pool, scope, target) { + const startTS = Date.now(); + logger.info("ResourceAdmin::getId(pool={}, scope={}, target={})", pool, scope, target); + + const result = this.#nspFindEntry( "/nsp-resource-pool:resource-pools/numeric-resource-pools[name='"+pool+"' and scope='"+scope+"']/num-consumed-resources[reference='"+target+"']"); + + if (!result.success) + throw new RuntimeException("Find id failed with "+result.errmsg); + + let id = ""; + if ('value' in result.response) { + id = result.response.value; + logger.info("id: {}", id ); + } else { + logger.info("id: to be reserved/obtained"); + } + + const duration = Date.now()-startTS; + logger.info("ResourceAdmin::getId(pool={}, scope={}, target={}) finished within {} ms", pool, scope, target, duration|0); + + return id; + } + + /** + * Obtain a number from Resource Admin + * + * @param {} pool numeric pool to be used + * @param {} scope numeric pool to be used + * @param {} intentTypeName reference for reservation + * @param {} target reference for reservation + * @returns number as string + * + * @throws {RuntimeException} obtain number failed + * + **/ + + static obtainId(pool, scope, intentTypeName, target) { + const startTS = Date.now(); + logger.info("ResourceAdmin::obtainId(pool={}, scope={}, target={})", pool, scope, target); + + const result = this.#nspFindEntry("/nsp-resource-pool:resource-pools/numeric-resource-pools[name='"+pool+"' and scope='"+scope+"']/num-consumed-resources[reference='"+target+"']"); + + if (!result.success) + throw new RuntimeException("Find id failed with "+result.errmsg); + + let id = ""; + if ('value' in result.response) { + id = result.response.value; + logger.info("id: {} (existing entry)", id); + } else { + const resource = "nsp-resource-pool:resource-pools/numeric-resource-pools="+pool+","+scope+"/obtain-value-from-pool"; + const input = { + "owner": "restconf/data/ibn:ibn/intent=" + target + "," + intentTypeName, + "confirmed": true, + "all-or-nothing": true, + "reference": target, + "total-number-of-resources": 1 + }; + const result = this.#restconfNspAction(resource, input); + + if (!result.success) + throw new RuntimeException("Obtain id failed with "+result.errmsg); + + id = result.response["nsp-resource-pool:output"]["num-consumed-resources"][0].value; + logger.info("id: {} (new entry)", id ); + } + + const duration = Date.now()-startTS; + logger.info("ResourceAdmin::obtainId(pool={}, scope={}, target={}) finished within {} ms", pool, scope, target, duration|0); + + return id; + } + + /** + * Release number from Resource Admin + * + * @param {} pool numeric pool to be used + * @param {} scope numeric pool to be used + * @param {} target reference for reservation + * + **/ + + static releaseId(pool, scope, target) { + const startTS = Date.now(); + logger.info("ResourceAdmin::releaseId(pool={}, scope={}, target={})", pool, scope, target); + + const resource = "nsp-resource-pool:resource-pools/numeric-resource-pools="+pool+","+scope+"/release-by-ref"; + const input = {"reference": target}; + + const result = this.#restconfNspAction(resource, input); + + if (!result.success) + logger.error("releaseId(" + target + ") failed with error:\n" + result.errmsg); + + const duration = Date.now()-startTS; + logger.info("ResourceAdmin::releaseId(pool={}, scope={}, target={}) finished within {} ms", pool, scope, target, duration|0); + } +} \ No newline at end of file diff --git a/templates/common_resources_graaljs/common/patch.ftl b/templates/common_resources_graaljs/common/patch.ftl new file mode 100644 index 0000000..c54740c --- /dev/null +++ b/templates/common_resources_graaljs/common/patch.ftl @@ -0,0 +1,29 @@ +<#setting number_format="computer"> + +<#-- This is generetic templates to render a RFC8072 compliant YANG PATCH body --> + +<#-- Input parameters: --> +<#-- patchId = string --> +<#-- patchItems = dict of config elements to patch --> +<#-- key = target path --> +<#-- value:operation = remove | replace --> +<#-- value:value = payload (string in JSON format) --> +<#-- value:name = nodal object to be created/updated/deleted --> + +{ + "ietf-yang-patch:yang-patch": { + "patch-id": "${patchId}", + "edit": [ +<#list patchItems as objectName, cfg > + { + "edit-id": "${objectName}", + "target": "${cfg.target}", +<#if cfg.value??> + "value": ${cfg.value}, + + "operation": "${cfg.operation}" + } <#sep>, + + ] + } +} diff --git a/templates/common_resources_graaljs/state.ftl b/templates/common_resources_graaljs/state.ftl new file mode 100644 index 0000000..efdca0d --- /dev/null +++ b/templates/common_resources_graaljs/state.ftl @@ -0,0 +1,2 @@ + + diff --git a/templates/ngJS iplink/intent-type-resources/Cisco IOS-XR.ftl b/templates/ngJS iplink/intent-type-resources/Cisco IOS-XR.ftl new file mode 100644 index 0000000..0e16ca8 --- /dev/null +++ b/templates/ngJS iplink/intent-type-resources/Cisco IOS-XR.ftl @@ -0,0 +1,64 @@ +<#setting number_format="computer"> +{ + "[${site.ne\-name}] INTERFACE ${site.port\-id}": { + "config": { + "target": "Cisco-IOS-XR-um-interface-cfg:/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "Cisco-IOS-XR-um-interface-cfg:interface": { + "interface-name": "${site.port\-id}", + "shutdown": {}, + "mtu": 9000, +<#if global.description??> + "description": "iplink: ${global.description}", + + "ipv4": { + "addresses": { + "address" : { + "address" : "${site.addr}", + "netmask" : "255.255.255.254" + } + } + } + } + }, + "ignoreChildren": [] + }, + "health": {} + }, + + "[${site.ne\-name}] ISIS INTERFACE ${site.port\-id}": { + "config": { + "target": "Cisco-IOS-XR-um-router-isis-cfg:/router/isis/processes/process=isis/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "Cisco-IOS-XR-um-router-isis-cfg:interface": { + "interface-name": "${site.port\-id}", + "circuit-type": "level-1", + "address-families": { + "address-family": [{ + "af-name": "ipv4", + "saf-name": "unicast" + }] + } + } + }, + "ignoreChildren": [] + }, + "health": {} + }, + + "[${site.ne\-name}] LDP INTERFACE ${site.port\-id}": { + "config": { + "target": "Cisco-IOS-XR-um-mpls-ldp-cfg:/mpls/ldp/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "Cisco-IOS-XR-um-mpls-ldp-cfg:interface": { + "interface-name": "${site.port\-id}" + } + }, + "ignoreChildren": [] + }, + "health": {} + } +} diff --git a/templates/ngJS iplink/intent-type-resources/JunOS.ftl b/templates/ngJS iplink/intent-type-resources/JunOS.ftl new file mode 100644 index 0000000..777f308 --- /dev/null +++ b/templates/ngJS iplink/intent-type-resources/JunOS.ftl @@ -0,0 +1,73 @@ +<#setting number_format="computer"> +{ + "[${site.ne\-name}] INTERFACE ${site.port\-id}": { + "config": { + "target": "junos-conf-root:/configuration/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "junos-conf-interfaces:interface": { + "name" : "${site.port\-id}", + "mtu": "9000", <#-- JunOS uses union of string/uint32, MDC returns string --> +<#if global.description??> + "description": "iplink: ${global.description}", + + "vlan-tagging": [null], + "unit" : [{ + "name" : "1", <#-- JunOS uses union of string/uint32, MDC returns string --> + "vlan-id" : "1", <#-- JunOS uses union of string/uint32, MDC returns string --> + "family" : { + "inet" : { + "address" : [{ + "name" : "${site.addr}/31" + }] + }, + "iso": {} + } + }] + } + }, + "ignoreChildren": [] + }, + "health": {} + }, + "[${site.ne\-name}] LLDP INTERFACE ${site.port\-id}": { + "config": { + "target": "junos-conf-root:/configuration/protocols/lldp/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "junos-conf-protocols:interface": { + "name" : "${site.port\-id}" + } + }, + "ignoreChildren": [] + }, + "health": {} + }, + "[${site.ne\-name}] ISIS INTERFACE ${site.port\-id}.1": { + "config": { + "target": "junos-conf-root:/configuration/protocols/isis/interface=${site.port\-id?url('ISO-8859-1')}.1", + "operation": "replace", + "value": { + "junos-conf-protocols:interface": { + "name" : "${site.port\-id}.1", + "point-to-point" : [null] + } + }, + "ignoreChildren": [] + }, + "health": {} + }, + "[${site.ne\-name}] LDP INTERFACE ${site.port\-id}.1": { + "config": { + "target": "junos-conf-root:/configuration/protocols/ldp/interface=${site.port\-id?url('ISO-8859-1')}.1", + "operation": "replace", + "value": { + "junos-conf-protocols:interface": { + "name" : "${site.port\-id}.1" + } + }, + "ignoreChildren": [] + }, + "health": {} + } +} \ No newline at end of file diff --git a/templates/ngJS iplink/intent-type-resources/OpenConfig.ftl b/templates/ngJS iplink/intent-type-resources/OpenConfig.ftl new file mode 100644 index 0000000..2b4ce5e --- /dev/null +++ b/templates/ngJS iplink/intent-type-resources/OpenConfig.ftl @@ -0,0 +1,242 @@ +<#setting number_format="computer"> +{ + "[${site.ne\-name}] INTERFACE ${site.port\-id}": { + "config": { + "target": "openconfig-interfaces:/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "openconfig-interfaces:interface": { + "name": "${site.port\-id}", + "config": { + "name": "${site.port\-id}", +<#if global.description??> + "description": "[{{ intent_type }}:${target}] ${global.description}", + + "type": "ethernetCsmacd" + }, + "subinterfaces": { + "subinterface": [ + { + "index": 0, + "config": { + "index": 0, +<#if global.description??> + "description": "[{{ intent_type }}:${target}] ${global.description}", + + "enabled": true + }, +<#-- MDC BUG | The ipv4 container uses a different namespace prefix + "openconfig-if-ip:ipv4": { +--> + "ipv4": { + "addresses": { + "address": [ + { + "ip": "${site.addr}", + "config": { + "ip": "${site.addr}", + "prefix-length": 31 + } + } + ] + }, + "config": { +<#-- MDC BUG | Adding the primary address causes a failure + "primary-address": "${site.addr}", +--> + "mtu": 8986 + } + } + } + ] + } + } + } + }, + "health": { + "openconfig-interfaces:/interfaces/interface=${site.port\-id?url('ISO-8859-1')}/state": { + "oper-status": "UP" + }, + "openconfig-lldp:lldp/interfaces/interface=${site.port\-id?url('ISO-8859-1')}/neighbors": { + "neighbor/state/system-name": { + "path": "$.neighbor[*].state.system-name", + "equals": "${site.peer.ne\-name}" + }, + "neighbor/state/port-description": { + "path": "$.neighbor[*].state.port-description", + "contains": "${site.peer.port\-id}" + } + }, + "openconfig-interfaces:/interfaces/interface=${site.port\-id?url('ISO-8859-1')}/subinterfaces/subinterface=0/state": { + "oper-status": "UP" + }, + "openconfig-interfaces:/interfaces/interface=${site.port\-id?url('ISO-8859-1')}/subinterfaces/subinterface=0/state": { + "oper-status": "UP" + } + } + }, + "[${site.ne\-name}] LLDP INTERFACE ${site.port\-id}": { + "config": { + "target": "openconfig-lldp:/lldp/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "openconfig-lldp:interface": { + "name": "${site.port\-id}", + "config": { + "name": "${site.port\-id}", + "enabled": true + } + } + } + } + }, + "[${site.ne\-name}] NETWORK INTERFACE ${site.port\-id}": { + "config": { + "target": "openconfig-network-instance:/network-instances/network-instance=Base/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "openconfig-network-instance:interface": { + "id": "${site.port\-id}", + "config": { + "id": "${site.port\-id}", + "interface": "${site.port\-id}", + "associated-address-families": ["IPV4"], + "subinterface": 0 + } + } + } + } + }, + "[${site.ne\-name}] ISIS INTERFACE ${site.port\-id}": { + "config": { + "target": "openconfig-network-instance:/network-instances/network-instance=Base/protocols/protocol=ISIS,0/isis/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "openconfig-network-instance:interface": { + "interface-id": "${site.port\-id}", + "config": { + "enabled": true, + "interface-id": "${site.port\-id}", + "passive": false, + "hello-padding": "DISABLE", + "circuit-type": "POINT_TO_POINT" + }, + "afi-safi": { + "af": [ + { + "afi-name": "IPV4", + "safi-name": "UNICAST", + "config": { + "afi-name": "IPV4", + "safi-name": "UNICAST" + } + } + ] + }, + "levels": { + "level": [ + { + "level-number": 1, + "config": { + "level-number": 1, + "enabled": true + } + } + ] + }, + "interface-ref": { + "config": { + "interface": "${site.port\-id}", + "subinterface": 0 + } + } + } + } + }, + "health": { +<#-- SR OS MDC OpenConfig adaptor does not cover levels/level/adjacencies + "openconfig-network-instance:/network-instances/network-instance=Base/protocols/protocol=ISIS,0/isis/interfaces/interface=${site.port\-id?url('ISO-8859-1')}/levels/level=1/adjacencies": { + "isis/adjacency/state": { + "path": "$.adjacency[*].adjacency-state", + "equals": "UP" + }, + "isis/adjacency/neighbor": { + "path": "$.adjacency[*].neighbor-ipv4-address", + "equals": "${site.peer.addr}" + } + } +--> + } + }, + "[${site.ne\-name}] MPLS INTERFACE ${site.port\-id}": { + "config": { + "target": "openconfig-network-instance:/network-instances/network-instance=Base/mpls/global/interface-attributes/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "openconfig-network-instance:interface": { + "interface-id": "${site.port\-id}", + "config": { + "interface-id": "${site.port\-id}" + }, + "interface-ref": { + "config": { + "interface": "${site.port\-id}", + "subinterface": 0 + } + } + } + } + } + }, + "[${site.ne\-name}] TE INTERFACE ${site.port\-id}": { + "config": { + "target": "openconfig-network-instance:/network-instances/network-instance=Base/mpls/te-interface-attributes/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "openconfig-network-instance:interface": { + "interface-id": "${site.port\-id}", + "config": { + "interface-id": "${site.port\-id}" + }, + "interface-ref": { + "config": { + "interface": "${site.port\-id}", + "subinterface": 0 + } + } + } + } + } + }, + "[${site.ne\-name}] LDP INTERFACE ${site.port\-id}": { + "config": { + "target": "openconfig-network-instance:/network-instances/network-instance=Base/mpls/signaling-protocols/ldp/interface-attributes/interfaces/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "openconfig-network-instance:interface": { + "interface-id": "${site.port\-id}", + "config": { + "interface-id": "${site.port\-id}" + }, + "interface-ref": { + "config": { + "interface": "${site.port\-id}", + "subinterface": 0 + } + }, + "address-families": { + "address-family": [ + { + "afi-name": "IPV4", + "config": { + "afi-name": "IPV4", + "enabled": true + } + } + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/templates/ngJS iplink/intent-type-resources/SR OS.ftl b/templates/ngJS iplink/intent-type-resources/SR OS.ftl new file mode 100644 index 0000000..a2a07ed --- /dev/null +++ b/templates/ngJS iplink/intent-type-resources/SR OS.ftl @@ -0,0 +1,213 @@ +<#setting number_format="computer"> +<#assign ifname="${site['ne-name']}_to_${site.peer['ne-name']}"> +{ + "[${site.ne\-name}] PORT ${site.port\-id}": { + "config": { + "target": "nokia-conf:/configure/port=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "nokia-conf:port": { + "port-id": "${site.port\-id}", + "admin-state": "${global.adminState}", +<#if global.description??> + "description": "[{{ intent_type }}:${target}] ${global.description}", + + "ethernet": { + "mode": "network", + "encap-type": "null", + "mtu": 5000, + "down-when-looped": { + "admin-state": "enable", + "keep-alive": 20 + }, + "lldp": { + "dest-mac": [ + { + "mac-type": "nearest-bridge", + "notification": false, + "receive": true, + "transmit": true, + "port-id-subtype": "tx-if-name", + "tx-tlvs": { + "port-desc": true, + "sys-name": true + }, + "tx-mgmt-address": [ + { + "mgmt-address-system-type": "system", + "admin-state": "disable" + } + ] + } + ] + } + } + } + }, + "ignoreChildren": ["ethernet/eth-cfm"] + }, + "health": { + "nokia-state:/state/port=${site.port\-id?url('ISO-8859-1')}": { + "oper-state": "up", + "ethernet/lldp/dest-mac/remote-system/system-name": { + "path": "$.ethernet.lldp.dest-mac[?(@['mac-type']=='nearest-bridge')].remote-system[*].system-name", + "equals": "${site.peer.ne\-name}" + }, + "ethernet/lldp/dest-mac/remote-system/remote-port-id": { + "path": "$.ethernet.lldp.dest-mac[?(@['mac-type']=='nearest-bridge')].remote-system[*].remote-port-id", + "equals": "${site.peer.port\-id}" + } + } + } + }, + "[${site.ne\-name}] IP INTERFACE ${ifname}": { + "config": { + "target": "nokia-conf:/configure/router=Base/interface=${ifname?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "nokia-conf:interface": + { + "interface-name": "${ifname}", + "port": "${site.port\-id}", + "ipv4": { + "primary": { + "address": "${site.addr}", + "prefix-length": 31 + } + }, + "admin-state": "enable" + } + } + }, + "health": { + "nokia-state:/state/router=Base/interface=${ifname?url('ISO-8859-1')}": { + "oper-state": "up" + } + } + }, + "[${site.ne\-name}] ISIS INTERFACE ${ifname}": { + "config": { + "target": "nokia-conf:/configure/router=Base/isis=0/interface=${ifname?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "nokia-conf:interface": { + "interface-name": "${ifname}", + "interface-type": "point-to-point", + "hello-padding": "none", + "level-capability": "2", + "admin-state": "enable" + } + } + }, + "health": { + "nokia-state:/state/router=Base/isis=0/interface=${ifname?url('ISO-8859-1')}": { + "oper-state": "up", + "adjacency/oper-state": { + "path": "$.adjacency[*].oper-state", + "equals": "up" + }, + "adjacency/neighbor/ipv4": { + "path": "$.adjacency[*].neighbor.ipv4", + "equals": "${site.peer.addr}" + } + } + } + }, + "[${site.ne\-name}] LDP INTERFACE ${ifname}": { + "config": { + "target": "nokia-conf:/configure/router=Base/ldp/interface-parameters/interface=${ifname?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "nokia-conf:interface": { + "ip-int-name": "${ifname}", + "ipv4": { + "admin-state": "enable" + } + } + }, + "ignoreChildren": [] + }, + "health": { + "nokia-state:/state/router=Base/ldp/interface-parameters/interface=${ifname?url('ISO-8859-1')}": { + "oper-state": "up" + } + } + }, + "[${site.ne\-name}] TWAMP REFLECTOR": { + "config": { + "target": "nokia-conf:/configure/router=Base/twamp-light/reflector", + "operation": "replace", + "value": { + "nokia-conf:reflector": { + "admin-state": "enable", + "udp-port": 64364, + "type": "twamp-light", + "prefix": [ + { + "ip-prefix": "0.0.0.0\/0", + "description": "Prefix subnet 0.0.0.0\/0" + } + ] + } + } + } + }, + "[${site.ne\-name}] TWAMP SESSION ${target}": { + "config": { + "target": "nokia-conf:/configure/oam-pm/session=${target?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "nokia-conf:session": { + "session-name": "${target}", + "session-type": "proactive", + "ip": { + "destination": "${site.peer.addr}", + "destination-udp-port": 64364, + "router-instance": "Base", + "source": "${site.addr}", + "twamp-light": { + "admin-state": "enable", + "test-id": "${global.testId}", + "interval": 100, + "record-stats": "delay-and-loss" + } + }, + "measurement-interval": [{ + "duration": "5-mins", + "clock-offset": 0, + "intervals-stored": 1 + }] + } + } + }, + "indicators": { + "/nokia-state:state/oam-pm/session=${target?url('ISO-8859-1')}/ip/twamp-light/statistics/delay/measurement-interval=5-mins": { + "latency": { + "path": "$.number[0].bin-type[?(@['bin-metric']=='fd')].round-trip.average" + }, + "jitter": { + "path": "$.number[0].bin-type[?(@['bin-metric']=='ifdv')].round-trip.average" + } + }, + "/nokia-state:state/oam-pm/session=${target?url('ISO-8859-1')}/ip/twamp-light/statistics/loss/measurement-interval=5-mins": { + "loss": { + "path": "$.number[0].forward.average-frame-loss-ratio" + } + }, + "nokia-state:/state/port=${site.port\-id?url('ISO-8859-1')}": { + "speed": { + "path": "$.type" + }, + "utilization": { + "path": "$.ethernet.statistics.out-utilization" + } + }, + "nokia-state:/state/router=Base/isis=0/interface=${ifname?url('ISO-8859-1')}": { + "state": { + "path": "$.oper-state" + } + } + + } + } +} \ No newline at end of file diff --git a/templates/ngJS iplink/intent-type-resources/SRLinux.ftl b/templates/ngJS iplink/intent-type-resources/SRLinux.ftl new file mode 100644 index 0000000..18e5a10 --- /dev/null +++ b/templates/ngJS iplink/intent-type-resources/SRLinux.ftl @@ -0,0 +1,123 @@ +<#setting number_format="computer"> +{ + "[${site.ne\-name}] INTERFACE ${site.port\-id}": { + "config": { + "target": "srl_nokia-interfaces:/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "srl_nokia-interfaces:interface": { + "name": "${site.port\-id}", + "admin-state": "${global.adminState}", +<#if global.description??> + "description": "{{ intent_type }}: ${global.description}", + + "mtu": 9000, + "subinterface": [{ + "index": 1, + "admin-state": "enable", + "ipv4": { + "admin-state": "enable", + "address": [{ + "ip-prefix": "${site.addr}/31" + }] + } + }] + } + }, + "ignoreChildren": [] + }, + "health": { + "srl_nokia-interfaces:/interface=${site.port\-id?url('ISO-8859-1')}": { + "oper-state": "up" + } + }, + "indicators": { + "srl_nokia-interfaces:/interface=${site.port\-id?url('ISO-8859-1')}": { + "speed": { + "path": "$.ethernet.port-speed" + }, + "utilization": { + "path": "$.traffic-rate.out-bps" + }, + "state": { + "path": "$.oper-state" + } + } + } + }, + "[${site.ne\-name}] LLDP INTERFACE ${site.port\-id}": { + "config": { + "target": "srl_nokia-system:/system/srl_nokia-lldp:lldp/interface=${site.port\-id?url('ISO-8859-1')}", + "operation": "replace", + "value": { + "srl_nokia-system:interface": { + "name": "${site.port\-id}", + "admin-state": "enable" + } + }, + "ignoreChildren": [] + }, + "health": { + "srl_nokia-system:/system/srl_nokia-lldp:lldp/interface=${site.port\-id?url('ISO-8859-1')}": { + "neighbor/nodename": { + "path": "$.srl_nokia-lldp:neighbor[*].system-name", + "equals": "${site.peer.ne\-name}" + }, + "neighbor/port-id": { + "path": "$.srl_nokia-lldp:neighbor[*].port-id", + "equals": "${site.peer.port\-id}" + } + } + } + }, + "[${site.ne\-name}] IP INTERFACE BINDING ${site.port\-id}.1": { + "config": { + "target": "srl_nokia-network-instance:/network-instance=default/interface=${site.port\-id?url('ISO-8859-1')}.1", + "operation": "replace", + "value": { + "srl_nokia-network-instance:interface": + { + "name": "${site.port\-id}.1" + } + }, + "ignoreChildren": [] + }, + "health": { + "srl_nokia-interfaces:/interface=${site.port\-id?url('ISO-8859-1')}/subinterface=1": { + "subinterface/oper-state": { + "path": "oper-state", + "equals": "up" + } + }, + "srl_nokia-network-instance:/network-instance=default/interface=${site.port\-id?url('ISO-8859-1')}.1": { + "binding/oper-state": { + "path": "oper-state", + "equals": "up" + } + } + } + }, + "[${site.ne\-name}] ISIS INTERFACE ${site.port\-id}.1": { + "config": { + "target": "srl_nokia-network-instance:/network-instance=default/protocols/srl_nokia-isis:isis/instance=0/interface=${site.port\-id?url('ISO-8859-1')}.1", + "operation": "replace", + "value": { + "interface": + { + "interface-name": "${site.port\-id}.1", + "circuit-type": "point-to-point", +<#-- ISSUE: SRL MDC adaptor namespace misalignment --> +<#if mode = 'audit'> + "srl_nokia-isis:ipv4-unicast": { +<#else> + "ipv4-unicast": { + + "admin-state": "enable" + } + } + }, + "ignoreChildren": [] + }, + "health": {} + } +} diff --git a/templates/ngJS iplink/intent-type-resources/default.viewConfig b/templates/ngJS iplink/intent-type-resources/default.viewConfig new file mode 100644 index 0000000..c5f4c7f --- /dev/null +++ b/templates/ngJS iplink/intent-type-resources/default.viewConfig @@ -0,0 +1,209 @@ +{ + "{{ intent_type }}.endpoint-a.port-id": { + "displayKey": "name", + "dependsOn": "endpoint-a.ne-id", + "componentProps": { + "isPagination": true, + "isObject": false, + "paginationProps": { + "pageLabel": "Page" + } + }, + "suggest": "getPorts", + "title": "PORT", + "type": "leafref", + "properties": [ + { + "floatingFilter": true, + "name": "name", + "description": "Port Identifier", + "title": "PORT ID", + "type": "string" + }, + { + "floatingFilter": true, + "name": "description", + "description": "Port Description", + "title": "DESCRIPTION", + "type": "string" + }, + { + "floatingFilter": true, + "name": "port-mode", + "title": "MODE", + "type": "string" + }, + { + "floatingFilter": true, + "name": "encap-type", + "title": "ENCAP", + "type": "string" + }, + { + "floatingFilter": true, + "name": "actual-rate", + "title": "RATE", + "type": "number" + }, + { + "floatingFilter": true, + "name": "mtu-value", + "title": "MTU", + "type": "number" + } + ] + }, + "{{ intent_type }}.endpoint-b.ne-id": { + "displayKey": "ne-id", + "componentProps": { + "isPagination": true, + "isObject": false, + "paginationProps": { + "pageLabel": "Page" + } + }, + "suggest": "getNodes", + "title": "NODE", + "type": "leafref", + "properties": [ + { + "floatingFilter": true, + "name": "ne-id", + "description": "Device ID", + "title": "NE ID", + "type": "string" + }, + { + "floatingFilter": true, + "name": "ne-name", + "description": "Device Name", + "title": "NE NAME", + "type": "string" + }, + { + "floatingFilter": true, + "name": "ip-address", + "description": "Management IP", + "title": "MANAGEMENT IP", + "type": "string" + }, + { + "floatingFilter": true, + "name": "type", + "title": "CHASSIS TYPE", + "type": "string" + }, + { + "floatingFilter": true, + "name": "version", + "title": "VERSION", + "type": "string" + } + ] + }, + "{{ intent_type }}.endpoint-b.port-id": { + "displayKey": "name", + "dependsOn": "endpoint-a.ne-id", + "componentProps": { + "isPagination": true, + "isObject": false, + "paginationProps": { + "pageLabel": "Page" + } + }, + "suggest": "getPorts", + "title": "PORT", + "type": "leafref", + "properties": [ + { + "floatingFilter": true, + "name": "name", + "description": "Port Identifier", + "title": "PORT ID", + "type": "string" + }, + { + "floatingFilter": true, + "name": "description", + "description": "Port Description", + "title": "DESCRIPTION", + "type": "string" + }, + { + "floatingFilter": true, + "name": "port-mode", + "title": "MODE", + "type": "string" + }, + { + "floatingFilter": true, + "name": "encap-type", + "title": "ENCAP", + "type": "string" + }, + { + "floatingFilter": true, + "name": "actual-rate", + "title": "RATE", + "type": "number" + }, + { + "floatingFilter": true, + "name": "mtu-value", + "title": "MTU", + "type": "number" + } + ] + }, + "{{ intent_type }}.adminState": { + "columnSpan": 1 + }, + "{{ intent_type }}.endpoint-a.ne-id": { + "displayKey": "ne-id", + "componentProps": { + "isPagination": true, + "isObject": false, + "paginationProps": { + "pageLabel": "Page" + } + }, + "suggest": "getNodes", + "title": "NODE", + "type": "leafref", + "properties": [ + { + "floatingFilter": true, + "name": "ne-id", + "description": "Device ID", + "title": "NE ID", + "type": "string" + }, + { + "floatingFilter": true, + "name": "ne-name", + "description": "Device Name", + "title": "NE NAME", + "type": "string" + }, + { + "floatingFilter": true, + "name": "ip-address", + "description": "Management IP", + "title": "MANAGEMENT IP", + "type": "string" + }, + { + "floatingFilter": true, + "name": "type", + "title": "CHASSIS TYPE", + "type": "string" + }, + { + "floatingFilter": true, + "name": "version", + "title": "VERSION", + "type": "string" + } + ] + } +} \ No newline at end of file diff --git a/templates/ngJS iplink/intent-type-resources/state.ftl b/templates/ngJS iplink/intent-type-resources/state.ftl new file mode 100644 index 0000000..524aaa6 --- /dev/null +++ b/templates/ngJS iplink/intent-type-resources/state.ftl @@ -0,0 +1,21 @@ + + + ${state.subnet} +<#if indicators.state??> + ${indicators.state?values[0]} + +<#if indicators.speed??> + ${indicators.speed?values[0]} + + +<#if indicators.latency??> + ${indicators.latency?values[0]?c} + ${indicators.jitter?values[0]?c} + ${indicators.loss?values[0]?c} + +<#if indicators.utilization??> + ${indicators.utilization?values[0]?c} + + + + diff --git a/templates/ngJS iplink/merge_common_resources_graaljs b/templates/ngJS iplink/merge_common_resources_graaljs new file mode 100644 index 0000000..e69de29 diff --git a/templates/ngJS iplink/meta-info.json b/templates/ngJS iplink/meta-info.json new file mode 100644 index 0000000..80b6f73 --- /dev/null +++ b/templates/ngJS iplink/meta-info.json @@ -0,0 +1,33 @@ +{ + "intent-type": "{{ intent_type }}", + "version": "1", + "author": "{{ author }}", + "mapping-engine": "js-scripted-graal", + "label": [ + "ApprovedMisalignments", + "GraalJS" + ], + "target-component": [ + { + "i18n-text": "Circuit Id", + "name": "circuitId", + "value-type": "STRING", + "order": 1 + } + ], + "supports-network-state-suspend": false, + "return-config-as-json": true, + "lifecycle-state": "released", + "priority": 50, + "build": "-", + "composite": false, + "supports-health": "never", + "notify-intent-instance-events": false, + "live-state-retrieval": true, + "targetted-device": [ + { + "function": "getTargettedDevices", + "index": 0 + } + ] +} \ No newline at end of file diff --git a/templates/ngJS iplink/script-content.mjs b/templates/ngJS iplink/script-content.mjs new file mode 100644 index 0000000..d6005ec --- /dev/null +++ b/templates/ngJS iplink/script-content.mjs @@ -0,0 +1,68 @@ +import { IntentLogic } from "common/IntentLogic.mjs"; +import { IntentHandler } from "common/IntentHandler.mjs"; +import { ResourceAdmin } from "common/ResourceAdmin.mjs"; + +class IPLink extends (IntentLogic) { + static INTENT_TYPE = "{{ intent_type }}"; + static INTENT_ROOT = "{{ intent_type }}:{{ intent_type }}"; + + static getSites(target, config) { + // Create list of sites from endpoints + var sites = []; + sites.push(config["endpoint-a"]['ne-id']); + sites.push(config["endpoint-b"]['ne-id']); + return sites; + } + + static validate(target, config, contextualErrorJsonObj) { + if (config["endpoint-a"]["ne-id"] === config["endpoint-b"]["ne-id"]) + contextualErrorJsonObj["Value inconsistency"] = "endpoint-a and endpoint-b must resite on different devices!"; + } + + static getSiteParameters(target, config) { + var sites = []; + sites.push(config["endpoint-a"]); + sites.push(config["endpoint-b"]); + + // Obtain ne-name from NSP inventory + sites[0]['ne-name'] = IntentHandler.getDeviceDetails(sites[0]['ne-id'])['ne-name']; + sites[1]['ne-name'] = IntentHandler.getDeviceDetails(sites[1]['ne-id'])['ne-name']; + + // Obtain an /31 subnet + var subnet = ResourceAdmin.getSubnet("ip-pool", "global", target); + var helper = subnet.split('/')[0].split('.'); + helper[3] = (parseInt(helper[3])+1).toString(); + sites[0]["addr"] = subnet.split('/')[0]; // Assign first address of /31 subnet to endpoint-a + sites[1]["addr"] = helper.join('.'); // Assign second address of /31 subnet to endpoint-b + + // Remote info is required to construct 7x50 interface-name and + // for audits of the operational state: + + sites[0]["peer"] = sites[1]; + sites[1]["peer"] = sites[0]; + + return sites; + } + + static getGlobalParameters(target, config) { + // add testId; Required for SROS to do OAM-PM tests + var global = config; + global['testId'] = parseInt(target.match(/\d+/)); + return global; + } + + static getState(target, config, topology) { + return {'subnet': ResourceAdmin.getSubnet("ip-pool", "global", target)}; + } + + static obtainResources(target, config) { + ResourceAdmin.obtainSubnet("ip-pool", "global", 'network-link', 31, this.INTENT_TYPE, target); + } + + static freeResources(target, config) { + ResourceAdmin.releaseSubnet("ip-pool", "global", target); + } +} + +let myIntentHandler = new IntentHandler(IPLink); +myIntentHandler; \ No newline at end of file diff --git a/templates/ngJS iplink/yang-modules/[intent_type].yang b/templates/ngJS iplink/yang-modules/[intent_type].yang new file mode 100644 index 0000000..6dbc83e --- /dev/null +++ b/templates/ngJS iplink/yang-modules/[intent_type].yang @@ -0,0 +1,84 @@ +module {{ intent_type }} { + namespace "http://www.nokia.com/management-solutions/{{ intent_type }}"; + prefix {{ intent_type }}; + + organization + "{{ author }}"; + contact + "{{ author }}"; + description + ""; + + revision "{{ date }}" { + description + "Initial revision."; + } + + container {{ intent_type }} { + leaf description { + type string; + } + + leaf adminState { + type enumeration { + enum enable; + enum disable; + } + default "enable"; + } + + container endpoint-a { + leaf ne-id { + type string; + mandatory true; + } + leaf port-id { + type string; + mandatory true; + } + } + container endpoint-b { + leaf ne-id { + type string; + mandatory true; + } + leaf port-id { + type string; + mandatory true; + } + } + } + + container {{ intent_type }}-state { + config false; + leaf oper-state { + type string; + } + leaf subnet { + type string; + } + leaf speed { + type string; + } + container performance { + leaf round-trip-delay { + type uint32; + units "microseconds"; + } + leaf round-trip-jitter { + type uint32; + units "microseconds"; + } + leaf frame-loss-ratio { + type uint32; + units "millipercent"; + } + leaf utilization { + type uint32 { + range "0..10000"; + } + units "centipercent"; + } + } + } +} \ No newline at end of file diff --git a/templates/templates.json b/templates/templates.json index c017223..98984b9 100644 --- a/templates/templates.json +++ b/templates/templates.json @@ -31,6 +31,10 @@ { "label": "SRX24 goldenConfiguration", "description": "day1 node config SReXperts 2024 | research/study, experimental" + }, + { + "label": "ngJS iplink", + "description": "abstraction/assurance for next-gen java-script engine (GraalJS) | research/study, experimental" } ] } \ No newline at end of file