@sonde/packs 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (377) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +53 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/CHANGELOG.md +19 -0
  5. package/dist/index.d.ts +16 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +40 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/integrations/citrix.d.ts +13 -0
  10. package/dist/integrations/citrix.d.ts.map +1 -0
  11. package/dist/integrations/citrix.js +415 -0
  12. package/dist/integrations/citrix.js.map +1 -0
  13. package/dist/integrations/citrix.test.d.ts +2 -0
  14. package/dist/integrations/citrix.test.d.ts.map +1 -0
  15. package/dist/integrations/citrix.test.js +464 -0
  16. package/dist/integrations/citrix.test.js.map +1 -0
  17. package/dist/integrations/graph.d.ts +9 -0
  18. package/dist/integrations/graph.d.ts.map +1 -0
  19. package/dist/integrations/graph.js +285 -0
  20. package/dist/integrations/graph.js.map +1 -0
  21. package/dist/integrations/graph.test.d.ts +2 -0
  22. package/dist/integrations/graph.test.d.ts.map +1 -0
  23. package/dist/integrations/graph.test.js +356 -0
  24. package/dist/integrations/graph.test.js.map +1 -0
  25. package/dist/integrations/httpbin.d.ts +3 -0
  26. package/dist/integrations/httpbin.d.ts.map +1 -0
  27. package/dist/integrations/httpbin.js +65 -0
  28. package/dist/integrations/httpbin.js.map +1 -0
  29. package/dist/integrations/nutanix.d.ts +18 -0
  30. package/dist/integrations/nutanix.d.ts.map +1 -0
  31. package/dist/integrations/nutanix.js +1116 -0
  32. package/dist/integrations/nutanix.js.map +1 -0
  33. package/dist/integrations/nutanix.test.d.ts +2 -0
  34. package/dist/integrations/nutanix.test.d.ts.map +1 -0
  35. package/dist/integrations/nutanix.test.js +978 -0
  36. package/dist/integrations/nutanix.test.js.map +1 -0
  37. package/dist/integrations/proxmox.d.ts +12 -0
  38. package/dist/integrations/proxmox.d.ts.map +1 -0
  39. package/dist/integrations/proxmox.js +728 -0
  40. package/dist/integrations/proxmox.js.map +1 -0
  41. package/dist/integrations/proxmox.test.d.ts +2 -0
  42. package/dist/integrations/proxmox.test.d.ts.map +1 -0
  43. package/dist/integrations/proxmox.test.js +697 -0
  44. package/dist/integrations/proxmox.test.js.map +1 -0
  45. package/dist/integrations/servicenow.d.ts +3 -0
  46. package/dist/integrations/servicenow.d.ts.map +1 -0
  47. package/dist/integrations/servicenow.js +251 -0
  48. package/dist/integrations/servicenow.js.map +1 -0
  49. package/dist/integrations/servicenow.test.d.ts +2 -0
  50. package/dist/integrations/servicenow.test.d.ts.map +1 -0
  51. package/dist/integrations/servicenow.test.js +217 -0
  52. package/dist/integrations/servicenow.test.js.map +1 -0
  53. package/dist/integrations/splunk.d.ts +9 -0
  54. package/dist/integrations/splunk.d.ts.map +1 -0
  55. package/dist/integrations/splunk.js +237 -0
  56. package/dist/integrations/splunk.js.map +1 -0
  57. package/dist/integrations/splunk.test.d.ts +2 -0
  58. package/dist/integrations/splunk.test.d.ts.map +1 -0
  59. package/dist/integrations/splunk.test.js +323 -0
  60. package/dist/integrations/splunk.test.js.map +1 -0
  61. package/dist/mysql/index.d.ts +3 -0
  62. package/dist/mysql/index.d.ts.map +1 -0
  63. package/dist/mysql/index.js +13 -0
  64. package/dist/mysql/index.js.map +1 -0
  65. package/dist/mysql/manifest.d.ts +3 -0
  66. package/dist/mysql/manifest.d.ts.map +1 -0
  67. package/dist/mysql/manifest.js +69 -0
  68. package/dist/mysql/manifest.js.map +1 -0
  69. package/dist/mysql/probes/databases-list.d.ts +13 -0
  70. package/dist/mysql/probes/databases-list.d.ts.map +1 -0
  71. package/dist/mysql/probes/databases-list.js +31 -0
  72. package/dist/mysql/probes/databases-list.js.map +1 -0
  73. package/dist/mysql/probes/databases-list.test.d.ts +2 -0
  74. package/dist/mysql/probes/databases-list.test.d.ts.map +1 -0
  75. package/dist/mysql/probes/databases-list.test.js +54 -0
  76. package/dist/mysql/probes/databases-list.test.js.map +1 -0
  77. package/dist/mysql/probes/processlist.d.ts +18 -0
  78. package/dist/mysql/probes/processlist.d.ts.map +1 -0
  79. package/dist/mysql/probes/processlist.js +36 -0
  80. package/dist/mysql/probes/processlist.js.map +1 -0
  81. package/dist/mysql/probes/processlist.test.d.ts +2 -0
  82. package/dist/mysql/probes/processlist.test.d.ts.map +1 -0
  83. package/dist/mysql/probes/processlist.test.js +41 -0
  84. package/dist/mysql/probes/processlist.test.js.map +1 -0
  85. package/dist/mysql/probes/status.d.ts +14 -0
  86. package/dist/mysql/probes/status.d.ts.map +1 -0
  87. package/dist/mysql/probes/status.js +40 -0
  88. package/dist/mysql/probes/status.js.map +1 -0
  89. package/dist/mysql/probes/status.test.d.ts +2 -0
  90. package/dist/mysql/probes/status.test.d.ts.map +1 -0
  91. package/dist/mysql/probes/status.test.js +43 -0
  92. package/dist/mysql/probes/status.test.js.map +1 -0
  93. package/dist/nginx/index.d.ts +3 -0
  94. package/dist/nginx/index.d.ts.map +1 -0
  95. package/dist/nginx/index.js +13 -0
  96. package/dist/nginx/index.js.map +1 -0
  97. package/dist/nginx/manifest.d.ts +3 -0
  98. package/dist/nginx/manifest.d.ts.map +1 -0
  99. package/dist/nginx/manifest.js +68 -0
  100. package/dist/nginx/manifest.js.map +1 -0
  101. package/dist/nginx/probes/access-log-tail.d.ts +9 -0
  102. package/dist/nginx/probes/access-log-tail.d.ts.map +1 -0
  103. package/dist/nginx/probes/access-log-tail.js +14 -0
  104. package/dist/nginx/probes/access-log-tail.js.map +1 -0
  105. package/dist/nginx/probes/access-log-tail.test.d.ts +2 -0
  106. package/dist/nginx/probes/access-log-tail.test.d.ts.map +1 -0
  107. package/dist/nginx/probes/access-log-tail.test.js +40 -0
  108. package/dist/nginx/probes/access-log-tail.test.js.map +1 -0
  109. package/dist/nginx/probes/config-test.d.ts +8 -0
  110. package/dist/nginx/probes/config-test.d.ts.map +1 -0
  111. package/dist/nginx/probes/config-test.js +18 -0
  112. package/dist/nginx/probes/config-test.js.map +1 -0
  113. package/dist/nginx/probes/config-test.test.d.ts +2 -0
  114. package/dist/nginx/probes/config-test.test.d.ts.map +1 -0
  115. package/dist/nginx/probes/config-test.test.js +35 -0
  116. package/dist/nginx/probes/config-test.test.js.map +1 -0
  117. package/dist/nginx/probes/error-log-tail.d.ts +9 -0
  118. package/dist/nginx/probes/error-log-tail.d.ts.map +1 -0
  119. package/dist/nginx/probes/error-log-tail.js +14 -0
  120. package/dist/nginx/probes/error-log-tail.js.map +1 -0
  121. package/dist/nginx/probes/error-log-tail.test.d.ts +2 -0
  122. package/dist/nginx/probes/error-log-tail.test.d.ts.map +1 -0
  123. package/dist/nginx/probes/error-log-tail.test.js +34 -0
  124. package/dist/nginx/probes/error-log-tail.test.js.map +1 -0
  125. package/dist/postgres/index.d.ts +3 -0
  126. package/dist/postgres/index.d.ts.map +1 -0
  127. package/dist/postgres/index.js +13 -0
  128. package/dist/postgres/index.js.map +1 -0
  129. package/dist/postgres/manifest.d.ts +3 -0
  130. package/dist/postgres/manifest.d.ts.map +1 -0
  131. package/dist/postgres/manifest.js +90 -0
  132. package/dist/postgres/manifest.js.map +1 -0
  133. package/dist/postgres/probes/connections-active.d.ts +17 -0
  134. package/dist/postgres/probes/connections-active.d.ts.map +1 -0
  135. package/dist/postgres/probes/connections-active.js +37 -0
  136. package/dist/postgres/probes/connections-active.js.map +1 -0
  137. package/dist/postgres/probes/connections-active.test.d.ts +2 -0
  138. package/dist/postgres/probes/connections-active.test.d.ts.map +1 -0
  139. package/dist/postgres/probes/connections-active.test.js +36 -0
  140. package/dist/postgres/probes/connections-active.test.js.map +1 -0
  141. package/dist/postgres/probes/databases-list.d.ts +14 -0
  142. package/dist/postgres/probes/databases-list.d.ts.map +1 -0
  143. package/dist/postgres/probes/databases-list.js +34 -0
  144. package/dist/postgres/probes/databases-list.js.map +1 -0
  145. package/dist/postgres/probes/databases-list.test.d.ts +2 -0
  146. package/dist/postgres/probes/databases-list.test.d.ts.map +1 -0
  147. package/dist/postgres/probes/databases-list.test.js +49 -0
  148. package/dist/postgres/probes/databases-list.test.js.map +1 -0
  149. package/dist/postgres/probes/query-slow.d.ts +17 -0
  150. package/dist/postgres/probes/query-slow.d.ts.map +1 -0
  151. package/dist/postgres/probes/query-slow.js +37 -0
  152. package/dist/postgres/probes/query-slow.js.map +1 -0
  153. package/dist/postgres/probes/query-slow.test.d.ts +2 -0
  154. package/dist/postgres/probes/query-slow.test.d.ts.map +1 -0
  155. package/dist/postgres/probes/query-slow.test.js +30 -0
  156. package/dist/postgres/probes/query-slow.test.js.map +1 -0
  157. package/dist/proxmox/index.d.ts +3 -0
  158. package/dist/proxmox/index.d.ts.map +1 -0
  159. package/dist/proxmox/index.js +23 -0
  160. package/dist/proxmox/index.js.map +1 -0
  161. package/dist/proxmox/manifest.d.ts +3 -0
  162. package/dist/proxmox/manifest.d.ts.map +1 -0
  163. package/dist/proxmox/manifest.js +75 -0
  164. package/dist/proxmox/manifest.js.map +1 -0
  165. package/dist/proxmox/probes/ceph-status.d.ts +36 -0
  166. package/dist/proxmox/probes/ceph-status.d.ts.map +1 -0
  167. package/dist/proxmox/probes/ceph-status.js +71 -0
  168. package/dist/proxmox/probes/ceph-status.js.map +1 -0
  169. package/dist/proxmox/probes/ceph-status.test.d.ts +2 -0
  170. package/dist/proxmox/probes/ceph-status.test.d.ts.map +1 -0
  171. package/dist/proxmox/probes/ceph-status.test.js +115 -0
  172. package/dist/proxmox/probes/ceph-status.test.js.map +1 -0
  173. package/dist/proxmox/probes/cluster-config.d.ts +31 -0
  174. package/dist/proxmox/probes/cluster-config.d.ts.map +1 -0
  175. package/dist/proxmox/probes/cluster-config.js +72 -0
  176. package/dist/proxmox/probes/cluster-config.js.map +1 -0
  177. package/dist/proxmox/probes/cluster-config.test.d.ts +2 -0
  178. package/dist/proxmox/probes/cluster-config.test.d.ts.map +1 -0
  179. package/dist/proxmox/probes/cluster-config.test.js +107 -0
  180. package/dist/proxmox/probes/cluster-config.test.js.map +1 -0
  181. package/dist/proxmox/probes/ha-status.d.ts +18 -0
  182. package/dist/proxmox/probes/ha-status.d.ts.map +1 -0
  183. package/dist/proxmox/probes/ha-status.js +38 -0
  184. package/dist/proxmox/probes/ha-status.js.map +1 -0
  185. package/dist/proxmox/probes/ha-status.test.d.ts +2 -0
  186. package/dist/proxmox/probes/ha-status.test.d.ts.map +1 -0
  187. package/dist/proxmox/probes/ha-status.test.js +66 -0
  188. package/dist/proxmox/probes/ha-status.test.js.map +1 -0
  189. package/dist/proxmox/probes/lvm.d.ts +35 -0
  190. package/dist/proxmox/probes/lvm.d.ts.map +1 -0
  191. package/dist/proxmox/probes/lvm.js +75 -0
  192. package/dist/proxmox/probes/lvm.js.map +1 -0
  193. package/dist/proxmox/probes/lvm.test.d.ts +2 -0
  194. package/dist/proxmox/probes/lvm.test.d.ts.map +1 -0
  195. package/dist/proxmox/probes/lvm.test.js +128 -0
  196. package/dist/proxmox/probes/lvm.test.js.map +1 -0
  197. package/dist/proxmox/probes/lxc-config.d.ts +29 -0
  198. package/dist/proxmox/probes/lxc-config.d.ts.map +1 -0
  199. package/dist/proxmox/probes/lxc-config.js +67 -0
  200. package/dist/proxmox/probes/lxc-config.js.map +1 -0
  201. package/dist/proxmox/probes/lxc-config.test.d.ts +2 -0
  202. package/dist/proxmox/probes/lxc-config.test.d.ts.map +1 -0
  203. package/dist/proxmox/probes/lxc-config.test.js +77 -0
  204. package/dist/proxmox/probes/lxc-config.test.js.map +1 -0
  205. package/dist/proxmox/probes/lxc-list.d.ts +20 -0
  206. package/dist/proxmox/probes/lxc-list.d.ts.map +1 -0
  207. package/dist/proxmox/probes/lxc-list.js +49 -0
  208. package/dist/proxmox/probes/lxc-list.js.map +1 -0
  209. package/dist/proxmox/probes/lxc-list.test.d.ts +2 -0
  210. package/dist/proxmox/probes/lxc-list.test.d.ts.map +1 -0
  211. package/dist/proxmox/probes/lxc-list.test.js +51 -0
  212. package/dist/proxmox/probes/lxc-list.test.js.map +1 -0
  213. package/dist/proxmox/probes/vm-config.d.ts +21 -0
  214. package/dist/proxmox/probes/vm-config.d.ts.map +1 -0
  215. package/dist/proxmox/probes/vm-config.js +58 -0
  216. package/dist/proxmox/probes/vm-config.js.map +1 -0
  217. package/dist/proxmox/probes/vm-config.test.d.ts +2 -0
  218. package/dist/proxmox/probes/vm-config.test.d.ts.map +1 -0
  219. package/dist/proxmox/probes/vm-config.test.js +80 -0
  220. package/dist/proxmox/probes/vm-config.test.js.map +1 -0
  221. package/dist/proxmox/probes/vm-locks.d.ts +16 -0
  222. package/dist/proxmox/probes/vm-locks.d.ts.map +1 -0
  223. package/dist/proxmox/probes/vm-locks.js +35 -0
  224. package/dist/proxmox/probes/vm-locks.js.map +1 -0
  225. package/dist/proxmox/probes/vm-locks.test.d.ts +2 -0
  226. package/dist/proxmox/probes/vm-locks.test.d.ts.map +1 -0
  227. package/dist/proxmox/probes/vm-locks.test.js +54 -0
  228. package/dist/proxmox/probes/vm-locks.test.js.map +1 -0
  229. package/dist/redis/index.d.ts +3 -0
  230. package/dist/redis/index.d.ts.map +1 -0
  231. package/dist/redis/index.js +13 -0
  232. package/dist/redis/index.js.map +1 -0
  233. package/dist/redis/manifest.d.ts +3 -0
  234. package/dist/redis/manifest.d.ts.map +1 -0
  235. package/dist/redis/manifest.js +51 -0
  236. package/dist/redis/manifest.js.map +1 -0
  237. package/dist/redis/probes/info.d.ts +15 -0
  238. package/dist/redis/probes/info.d.ts.map +1 -0
  239. package/dist/redis/probes/info.js +32 -0
  240. package/dist/redis/probes/info.js.map +1 -0
  241. package/dist/redis/probes/info.test.d.ts +2 -0
  242. package/dist/redis/probes/info.test.d.ts.map +1 -0
  243. package/dist/redis/probes/info.test.js +64 -0
  244. package/dist/redis/probes/info.test.js.map +1 -0
  245. package/dist/redis/probes/keys-count.d.ts +13 -0
  246. package/dist/redis/probes/keys-count.d.ts.map +1 -0
  247. package/dist/redis/probes/keys-count.js +24 -0
  248. package/dist/redis/probes/keys-count.js.map +1 -0
  249. package/dist/redis/probes/keys-count.test.d.ts +2 -0
  250. package/dist/redis/probes/keys-count.test.d.ts.map +1 -0
  251. package/dist/redis/probes/keys-count.test.js +37 -0
  252. package/dist/redis/probes/keys-count.test.js.map +1 -0
  253. package/dist/redis/probes/memory-usage.d.ts +16 -0
  254. package/dist/redis/probes/memory-usage.d.ts.map +1 -0
  255. package/dist/redis/probes/memory-usage.js +31 -0
  256. package/dist/redis/probes/memory-usage.js.map +1 -0
  257. package/dist/redis/probes/memory-usage.test.d.ts +2 -0
  258. package/dist/redis/probes/memory-usage.test.d.ts.map +1 -0
  259. package/dist/redis/probes/memory-usage.test.js +48 -0
  260. package/dist/redis/probes/memory-usage.test.js.map +1 -0
  261. package/dist/runbooks/nutanix.d.ts +3 -0
  262. package/dist/runbooks/nutanix.d.ts.map +1 -0
  263. package/dist/runbooks/nutanix.js +619 -0
  264. package/dist/runbooks/nutanix.js.map +1 -0
  265. package/dist/runbooks/nutanix.test.d.ts +2 -0
  266. package/dist/runbooks/nutanix.test.d.ts.map +1 -0
  267. package/dist/runbooks/nutanix.test.js +971 -0
  268. package/dist/runbooks/nutanix.test.js.map +1 -0
  269. package/dist/runbooks/proxmox.d.ts +3 -0
  270. package/dist/runbooks/proxmox.d.ts.map +1 -0
  271. package/dist/runbooks/proxmox.js +451 -0
  272. package/dist/runbooks/proxmox.js.map +1 -0
  273. package/dist/runbooks/proxmox.test.d.ts +2 -0
  274. package/dist/runbooks/proxmox.test.d.ts.map +1 -0
  275. package/dist/runbooks/proxmox.test.js +700 -0
  276. package/dist/runbooks/proxmox.test.js.map +1 -0
  277. package/dist/signatures.d.ts +2 -0
  278. package/dist/signatures.d.ts.map +1 -0
  279. package/dist/signatures.js +2 -0
  280. package/dist/signatures.js.map +1 -0
  281. package/dist/system/index.d.ts.map +1 -1
  282. package/dist/system/index.js +2 -0
  283. package/dist/system/index.js.map +1 -1
  284. package/dist/system/manifest.d.ts.map +1 -1
  285. package/dist/system/manifest.js +19 -1
  286. package/dist/system/manifest.js.map +1 -1
  287. package/dist/system/probes/ping.d.ts +20 -0
  288. package/dist/system/probes/ping.d.ts.map +1 -0
  289. package/dist/system/probes/ping.js +54 -0
  290. package/dist/system/probes/ping.js.map +1 -0
  291. package/dist/system/probes/ping.test.d.ts +2 -0
  292. package/dist/system/probes/ping.test.d.ts.map +1 -0
  293. package/dist/system/probes/ping.test.js +127 -0
  294. package/dist/system/probes/ping.test.js.map +1 -0
  295. package/dist/types.d.ts +53 -0
  296. package/dist/types.d.ts.map +1 -1
  297. package/dist/validation.d.ts +6 -1
  298. package/dist/validation.d.ts.map +1 -1
  299. package/dist/validation.js +10 -1
  300. package/dist/validation.js.map +1 -1
  301. package/package.json +1 -1
  302. package/src/index.ts +60 -6
  303. package/src/integrations/citrix.test.ts +592 -0
  304. package/src/integrations/citrix.ts +553 -0
  305. package/src/integrations/graph.test.ts +478 -0
  306. package/src/integrations/graph.ts +409 -0
  307. package/src/integrations/httpbin.ts +68 -0
  308. package/src/integrations/nutanix.test.ts +1508 -0
  309. package/src/integrations/nutanix.ts +1456 -0
  310. package/src/integrations/proxmox.test.ts +1020 -0
  311. package/src/integrations/proxmox.ts +985 -0
  312. package/src/integrations/servicenow.test.ts +314 -0
  313. package/src/integrations/servicenow.ts +280 -0
  314. package/src/integrations/splunk.test.ts +440 -0
  315. package/src/integrations/splunk.ts +352 -0
  316. package/src/mysql/index.ts +14 -0
  317. package/src/mysql/manifest.ts +70 -0
  318. package/src/mysql/probes/databases-list.test.ts +62 -0
  319. package/src/mysql/probes/databases-list.ts +45 -0
  320. package/src/mysql/probes/processlist.test.ts +47 -0
  321. package/src/mysql/probes/processlist.ts +55 -0
  322. package/src/mysql/probes/status.test.ts +50 -0
  323. package/src/mysql/probes/status.ts +56 -0
  324. package/src/nginx/index.ts +14 -0
  325. package/src/nginx/manifest.ts +69 -0
  326. package/src/nginx/probes/access-log-tail.test.ts +51 -0
  327. package/src/nginx/probes/access-log-tail.ts +23 -0
  328. package/src/nginx/probes/config-test.test.ts +47 -0
  329. package/src/nginx/probes/config-test.ts +24 -0
  330. package/src/nginx/probes/error-log-tail.test.ts +44 -0
  331. package/src/nginx/probes/error-log-tail.ts +23 -0
  332. package/src/postgres/index.ts +14 -0
  333. package/src/postgres/manifest.ts +91 -0
  334. package/src/postgres/probes/connections-active.test.ts +42 -0
  335. package/src/postgres/probes/connections-active.ts +55 -0
  336. package/src/postgres/probes/databases-list.test.ts +57 -0
  337. package/src/postgres/probes/databases-list.ts +49 -0
  338. package/src/postgres/probes/query-slow.test.ts +37 -0
  339. package/src/postgres/probes/query-slow.ts +55 -0
  340. package/src/proxmox/index.ts +24 -0
  341. package/src/proxmox/manifest.ts +76 -0
  342. package/src/proxmox/probes/ceph-status.test.ts +126 -0
  343. package/src/proxmox/probes/ceph-status.ts +116 -0
  344. package/src/proxmox/probes/cluster-config.test.ts +118 -0
  345. package/src/proxmox/probes/cluster-config.ts +97 -0
  346. package/src/proxmox/probes/ha-status.test.ts +76 -0
  347. package/src/proxmox/probes/ha-status.ts +56 -0
  348. package/src/proxmox/probes/lvm.test.ts +140 -0
  349. package/src/proxmox/probes/lvm.ts +121 -0
  350. package/src/proxmox/probes/lxc-config.test.ts +89 -0
  351. package/src/proxmox/probes/lxc-config.ts +90 -0
  352. package/src/proxmox/probes/lxc-list.test.ts +60 -0
  353. package/src/proxmox/probes/lxc-list.ts +67 -0
  354. package/src/proxmox/probes/vm-config.test.ts +93 -0
  355. package/src/proxmox/probes/vm-config.ts +77 -0
  356. package/src/proxmox/probes/vm-locks.test.ts +63 -0
  357. package/src/proxmox/probes/vm-locks.ts +49 -0
  358. package/src/redis/index.ts +14 -0
  359. package/src/redis/manifest.ts +52 -0
  360. package/src/redis/probes/info.test.ts +73 -0
  361. package/src/redis/probes/info.ts +46 -0
  362. package/src/redis/probes/keys-count.test.ts +44 -0
  363. package/src/redis/probes/keys-count.ts +38 -0
  364. package/src/redis/probes/memory-usage.test.ts +54 -0
  365. package/src/redis/probes/memory-usage.ts +46 -0
  366. package/src/runbooks/nutanix.test.ts +1138 -0
  367. package/src/runbooks/nutanix.ts +941 -0
  368. package/src/runbooks/proxmox.test.ts +838 -0
  369. package/src/runbooks/proxmox.ts +626 -0
  370. package/src/signatures.ts +1 -0
  371. package/src/system/index.ts +2 -0
  372. package/src/system/manifest.ts +21 -1
  373. package/src/system/probes/ping.test.ts +163 -0
  374. package/src/system/probes/ping.ts +89 -0
  375. package/src/types.ts +62 -0
  376. package/src/validation.ts +21 -1
  377. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,1508 @@
1
+ import type { IntegrationConfig, IntegrationCredentials } from '@sonde/shared';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { buildAuthHeaders, nutanixPack, nutanixUrl, ppmToPercent, usecsToMs } from './nutanix.js';
4
+
5
+ const ntnxConfig: IntegrationConfig = {
6
+ endpoint: 'https://prism.local:9440',
7
+ };
8
+
9
+ const basicCreds: IntegrationCredentials = {
10
+ packName: 'nutanix',
11
+ authMethod: 'api_key',
12
+ credentials: { username: 'admin', password: 'secret123' },
13
+ };
14
+
15
+ const apiKeyCreds: IntegrationCredentials = {
16
+ packName: 'nutanix',
17
+ authMethod: 'bearer_token',
18
+ credentials: { nutanixApiKey: 'ntnx-api-key-abc123' },
19
+ };
20
+
21
+ const handler = (name: string) => {
22
+ const h = nutanixPack.handlers[name];
23
+ if (!h) throw new Error(`Handler ${name} not found`);
24
+ return h;
25
+ };
26
+
27
+ function callArgs(fn: ReturnType<typeof vi.fn>, index: number): unknown[] {
28
+ const args = fn.mock.calls[index];
29
+ if (!args) throw new Error(`No call at index ${index}`);
30
+ return args;
31
+ }
32
+
33
+ function mockNtnxResponse(body: unknown, status = 200) {
34
+ return vi.fn().mockResolvedValue(
35
+ new Response(JSON.stringify(body), {
36
+ status,
37
+ headers: { 'Content-Type': 'application/json' },
38
+ }),
39
+ );
40
+ }
41
+
42
+ function mockFetchError(status: number) {
43
+ return vi.fn().mockResolvedValue(new Response('Error', { status, statusText: 'Error' }));
44
+ }
45
+
46
+ /** Decode URL fully (handles %24 → $ and + → space) */
47
+ function decodeUrl(url: string): string {
48
+ return decodeURIComponent(url).replace(/\+/g, ' ');
49
+ }
50
+
51
+ /** Get a specific query param from a URL */
52
+ function getParam(url: string, key: string): string | null {
53
+ return new URL(url).searchParams.get(key);
54
+ }
55
+
56
+ /** Wrap data in Nutanix v4 response envelope */
57
+ function v4Envelope(data: unknown, totalAvailableResults?: number) {
58
+ return {
59
+ data,
60
+ $reserved: {},
61
+ $objectType: 'base.v1.r0.a3.Response',
62
+ metadata: totalAvailableResults != null ? { totalAvailableResults } : undefined,
63
+ };
64
+ }
65
+
66
+ describe('nutanix pack', () => {
67
+ describe('auth helpers', () => {
68
+ it('builds Basic auth header for api_key method', () => {
69
+ const headers = buildAuthHeaders(basicCreds);
70
+ const expected = `Basic ${Buffer.from('admin:secret123').toString('base64')}`;
71
+ expect(headers.Authorization).toBe(expected);
72
+ });
73
+
74
+ it('builds X-Ntnx-Api-Key header for bearer_token method', () => {
75
+ const headers = buildAuthHeaders(apiKeyCreds);
76
+ expect(headers['X-Ntnx-Api-Key']).toBe('ntnx-api-key-abc123');
77
+ expect(headers.Authorization).toBeUndefined();
78
+ });
79
+
80
+ it('returns empty headers for missing credentials', () => {
81
+ const emptyCreds: IntegrationCredentials = {
82
+ packName: 'nutanix',
83
+ authMethod: 'api_key',
84
+ credentials: {},
85
+ };
86
+ const headers = buildAuthHeaders(emptyCreds);
87
+ expect(Object.keys(headers)).toHaveLength(0);
88
+ });
89
+ });
90
+
91
+ describe('nutanixUrl', () => {
92
+ it('builds correct namespaced v4 URL', () => {
93
+ const url = nutanixUrl('https://prism.local:9440', 'clustermgmt', 'config/clusters');
94
+ expect(url).toBe('https://prism.local:9440/api/clustermgmt/v4.0/config/clusters');
95
+ });
96
+
97
+ it('includes query params', () => {
98
+ const url = nutanixUrl('https://prism.local:9440', 'vmm', 'ahv/config/vms', {
99
+ $limit: '50',
100
+ $filter: "name eq 'test'",
101
+ });
102
+ expect(getParam(url, '$limit')).toBe('50');
103
+ expect(getParam(url, '$filter')).toBe("name eq 'test'");
104
+ });
105
+
106
+ it('strips trailing slash from endpoint', () => {
107
+ const url = nutanixUrl('https://prism.local:9440/', 'prism', 'config/tasks');
108
+ expect(url).toBe('https://prism.local:9440/api/prism/v4.0/config/tasks');
109
+ });
110
+ });
111
+
112
+ describe('unit conversions', () => {
113
+ it('ppmToPercent converts correctly', () => {
114
+ expect(ppmToPercent(250000)).toBe(25);
115
+ expect(ppmToPercent(999999)).toBe(100);
116
+ expect(ppmToPercent(0)).toBe(0);
117
+ expect(ppmToPercent(123456)).toBe(12.35);
118
+ });
119
+
120
+ it('usecsToMs converts correctly', () => {
121
+ expect(usecsToMs(1000)).toBe(1);
122
+ expect(usecsToMs(1500)).toBe(1.5);
123
+ expect(usecsToMs(0)).toBe(0);
124
+ expect(usecsToMs(12345)).toBe(12.35);
125
+ });
126
+ });
127
+
128
+ describe('envelope unwrap', () => {
129
+ it('extracts data from v4 response wrapper', async () => {
130
+ const fetchFn = mockNtnxResponse(v4Envelope([{ name: 'cluster-1' }], 42));
131
+ const result = (await handler('clusters.list')({}, ntnxConfig, basicCreds, fetchFn)) as {
132
+ clusters: unknown[];
133
+ totalCount: number;
134
+ };
135
+ expect(result.clusters).toHaveLength(1);
136
+ expect(result.totalCount).toBe(42);
137
+ });
138
+ });
139
+
140
+ describe('clusters.list', () => {
141
+ it('returns cluster list', async () => {
142
+ const fetchFn = mockNtnxResponse(
143
+ v4Envelope(
144
+ [
145
+ {
146
+ name: 'prod-cluster',
147
+ extId: 'abc-123',
148
+ hypervisorType: 'AHV',
149
+ aosVersion: '6.5.1',
150
+ numNodes: 4,
151
+ redundancyFactor: 2,
152
+ operationMode: 'NORMAL',
153
+ },
154
+ ],
155
+ 1,
156
+ ),
157
+ );
158
+
159
+ const result = (await handler('clusters.list')({}, ntnxConfig, basicCreds, fetchFn)) as {
160
+ clusters: Array<{ name: string; isDegraded: boolean }>;
161
+ totalCount: number;
162
+ };
163
+
164
+ expect(result.clusters).toHaveLength(1);
165
+ expect(result.clusters[0]?.name).toBe('prod-cluster');
166
+ expect(result.clusters[0]?.isDegraded).toBe(false);
167
+ expect(result.totalCount).toBe(1);
168
+ });
169
+
170
+ it('applies name filter via OData', async () => {
171
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
172
+ await handler('clusters.list')({ name: 'prod' }, ntnxConfig, basicCreds, fetchFn);
173
+
174
+ const [url] = callArgs(fetchFn, 0) as [string];
175
+ expect(getParam(url, '$filter')).toBe("name eq 'prod'");
176
+ });
177
+
178
+ it('flags degraded clusters', async () => {
179
+ const fetchFn = mockNtnxResponse(
180
+ v4Envelope([{ name: 'degraded-cluster', operationMode: 'STANDBY' }]),
181
+ );
182
+
183
+ const result = (await handler('clusters.list')({}, ntnxConfig, basicCreds, fetchFn)) as {
184
+ clusters: Array<{ isDegraded: boolean }>;
185
+ };
186
+
187
+ expect(result.clusters[0]?.isDegraded).toBe(true);
188
+ });
189
+ });
190
+
191
+ describe('hosts.list', () => {
192
+ it('returns hosts', async () => {
193
+ const fetchFn = mockNtnxResponse(
194
+ v4Envelope(
195
+ [
196
+ {
197
+ hostName: 'node-01',
198
+ extId: 'h-123',
199
+ serialNumber: 'SN001',
200
+ blockModel: 'NX-1065',
201
+ numCpuSockets: 2,
202
+ numCpuCores: 16,
203
+ memoryCapacityBytes: 137438953472,
204
+ maintenanceMode: false,
205
+ },
206
+ ],
207
+ 1,
208
+ ),
209
+ );
210
+
211
+ const result = (await handler('hosts.list')({}, ntnxConfig, basicCreds, fetchFn)) as {
212
+ hosts: Array<{ name: string; maintenanceMode: boolean }>;
213
+ totalCount: number;
214
+ };
215
+
216
+ expect(result.hosts).toHaveLength(1);
217
+ expect(result.hosts[0]?.name).toBe('node-01');
218
+ expect(result.hosts[0]?.maintenanceMode).toBe(false);
219
+ });
220
+
221
+ it('applies cluster_id filter', async () => {
222
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
223
+ await handler('hosts.list')({ cluster_id: 'c-123' }, ntnxConfig, basicCreds, fetchFn);
224
+
225
+ const [url] = callArgs(fetchFn, 0) as [string];
226
+ expect(getParam(url, '$filter')).toBe("clusterExtId eq 'c-123'");
227
+ });
228
+ });
229
+
230
+ describe('vms.list', () => {
231
+ it('returns VMs', async () => {
232
+ const fetchFn = mockNtnxResponse(
233
+ v4Envelope(
234
+ [
235
+ {
236
+ name: 'web-01',
237
+ extId: 'vm-123',
238
+ powerState: 'ON',
239
+ numSockets: 2,
240
+ numCoresPerSocket: 4,
241
+ memorySizeBytes: 4294967296,
242
+ },
243
+ ],
244
+ 1,
245
+ ),
246
+ );
247
+
248
+ const result = (await handler('vms.list')({}, ntnxConfig, basicCreds, fetchFn)) as {
249
+ vms: Array<{ name: string; memorySizeMb: number }>;
250
+ };
251
+
252
+ expect(result.vms).toHaveLength(1);
253
+ expect(result.vms[0]?.name).toBe('web-01');
254
+ expect(result.vms[0]?.memorySizeMb).toBe(4096);
255
+ });
256
+
257
+ it('combines multiple OData filters', async () => {
258
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
259
+ await handler('vms.list')(
260
+ { name: 'web', power_state: 'ON', cluster_id: 'c-1' },
261
+ ntnxConfig,
262
+ basicCreds,
263
+ fetchFn,
264
+ );
265
+
266
+ const [url] = callArgs(fetchFn, 0) as [string];
267
+ const filter = getParam(url, '$filter') ?? '';
268
+ expect(filter).toContain("name eq 'web'");
269
+ expect(filter).toContain("powerState eq 'ON'");
270
+ expect(filter).toContain("clusterExtId eq 'c-1'");
271
+ expect(filter).toContain(' and ');
272
+ });
273
+
274
+ it('uses default limit of 50', async () => {
275
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
276
+ await handler('vms.list')({}, ntnxConfig, basicCreds, fetchFn);
277
+
278
+ const [url] = callArgs(fetchFn, 0) as [string];
279
+ expect(getParam(url, '$limit')).toBe('50');
280
+ });
281
+ });
282
+
283
+ describe('vm.detail', () => {
284
+ it('parses full VM config with disks and NICs', async () => {
285
+ const fetchFn = mockNtnxResponse(
286
+ v4Envelope({
287
+ name: 'db-01',
288
+ extId: 'vm-456',
289
+ powerState: 'ON',
290
+ numSockets: 4,
291
+ numCoresPerSocket: 2,
292
+ memorySizeBytes: 8589934592,
293
+ disks: [
294
+ {
295
+ backingInfo: {
296
+ deviceType: 'DISK',
297
+ storageContainerId: 'sc-1',
298
+ diskSizeBytes: 107374182400,
299
+ },
300
+ },
301
+ ],
302
+ nics: [
303
+ {
304
+ networkInfo: {
305
+ macAddress: 'AA:BB:CC:DD:EE:FF',
306
+ subnet: { extId: 'subnet-1' },
307
+ nicType: 'NORMAL_NIC',
308
+ isConnected: true,
309
+ },
310
+ },
311
+ ],
312
+ bootConfig: { bootType: 'UEFI' },
313
+ categories: [{ key: 'Environment', value: 'Production' }],
314
+ guestTools: { isInstalled: true },
315
+ createTime: '2024-01-15T10:00:00Z',
316
+ }),
317
+ );
318
+
319
+ const result = (await handler('vm.detail')(
320
+ { vm_id: 'vm-456' },
321
+ ntnxConfig,
322
+ basicCreds,
323
+ fetchFn,
324
+ )) as {
325
+ name: string;
326
+ disks: Array<{ deviceType: string; sizeBytes: number }>;
327
+ nics: Array<{ macAddress: string; subnetExtId: string }>;
328
+ totalStorageBytes: number;
329
+ bootConfig: unknown;
330
+ guestTools: unknown;
331
+ };
332
+
333
+ expect(result.name).toBe('db-01');
334
+ expect(result.disks).toHaveLength(1);
335
+ expect(result.disks[0]?.deviceType).toBe('DISK');
336
+ expect(result.disks[0]?.sizeBytes).toBe(107374182400);
337
+ expect(result.nics).toHaveLength(1);
338
+ expect(result.nics[0]?.macAddress).toBe('AA:BB:CC:DD:EE:FF');
339
+ expect(result.nics[0]?.subnetExtId).toBe('subnet-1');
340
+ expect(result.totalStorageBytes).toBe(107374182400);
341
+ expect(result.bootConfig).toEqual({ bootType: 'UEFI' });
342
+ });
343
+
344
+ it('requires vm_id parameter', async () => {
345
+ const fetchFn = mockNtnxResponse({});
346
+ await expect(handler('vm.detail')({}, ntnxConfig, basicCreds, fetchFn)).rejects.toThrow(
347
+ 'vm_id parameter is required',
348
+ );
349
+ });
350
+ });
351
+
352
+ describe('vm.stats', () => {
353
+ it('converts ppm to percent and usecs to ms', async () => {
354
+ const fetchFn = mockNtnxResponse(
355
+ v4Envelope([
356
+ { metricType: 'CPU_USAGE_PPM', value: 250000 },
357
+ { metricType: 'MEMORY_USAGE_PPM', value: 750000 },
358
+ { metricType: 'IOPS', value: 500 },
359
+ { metricType: 'IO_BANDWIDTH_KBPS', value: 102400 },
360
+ { metricType: 'AVG_IO_LATENCY_USECS', value: 5000 },
361
+ { metricType: 'NETWORK_RX_BYTES', value: 1048576 },
362
+ { metricType: 'NETWORK_TX_BYTES', value: 524288 },
363
+ ]),
364
+ );
365
+
366
+ const result = (await handler('vm.stats')(
367
+ { vm_id: 'vm-123' },
368
+ ntnxConfig,
369
+ basicCreds,
370
+ fetchFn,
371
+ )) as {
372
+ cpuUsagePct: number;
373
+ memoryUsagePct: number;
374
+ iops: number;
375
+ ioBandwidthKbps: number;
376
+ avgIoLatencyMs: number;
377
+ networkRxBytes: number;
378
+ networkTxBytes: number;
379
+ };
380
+
381
+ expect(result.cpuUsagePct).toBe(25);
382
+ expect(result.memoryUsagePct).toBe(75);
383
+ expect(result.iops).toBe(500);
384
+ expect(result.ioBandwidthKbps).toBe(102400);
385
+ expect(result.avgIoLatencyMs).toBe(5);
386
+ expect(result.networkRxBytes).toBe(1048576);
387
+ expect(result.networkTxBytes).toBe(524288);
388
+ });
389
+
390
+ it('requires vm_id parameter', async () => {
391
+ const fetchFn = mockNtnxResponse({});
392
+ await expect(handler('vm.stats')({}, ntnxConfig, basicCreds, fetchFn)).rejects.toThrow(
393
+ 'vm_id parameter is required',
394
+ );
395
+ });
396
+ });
397
+
398
+ describe('alerts.list', () => {
399
+ it('returns alerts', async () => {
400
+ const fetchFn = mockNtnxResponse(
401
+ v4Envelope(
402
+ [
403
+ {
404
+ title: 'Disk failure',
405
+ severity: 'CRITICAL',
406
+ sourceEntity: { type: 'disk', name: 'sda', extId: 'd-1' },
407
+ creationTime: '2024-01-15T10:00:00Z',
408
+ resolvedStatus: 'UNRESOLVED',
409
+ },
410
+ ],
411
+ 1,
412
+ ),
413
+ );
414
+
415
+ const result = (await handler('alerts.list')({}, ntnxConfig, basicCreds, fetchFn)) as {
416
+ alerts: Array<{ title: string; severity: string }>;
417
+ totalCount: number;
418
+ };
419
+
420
+ expect(result.alerts).toHaveLength(1);
421
+ expect(result.alerts[0]?.title).toBe('Disk failure');
422
+ expect(result.alerts[0]?.severity).toBe('CRITICAL');
423
+ });
424
+
425
+ it('applies severity filter', async () => {
426
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
427
+ await handler('alerts.list')({ severity: 'CRITICAL' }, ntnxConfig, basicCreds, fetchFn);
428
+
429
+ const [url] = callArgs(fetchFn, 0) as [string];
430
+ const filter = getParam(url, '$filter') ?? '';
431
+ expect(filter).toContain("severity eq 'CRITICAL'");
432
+ });
433
+
434
+ it('applies time range filter', async () => {
435
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
436
+ await handler('alerts.list')({ hours: 4 }, ntnxConfig, basicCreds, fetchFn);
437
+
438
+ const [url] = callArgs(fetchFn, 0) as [string];
439
+ const filter = getParam(url, '$filter') ?? '';
440
+ expect(filter).toContain('creationTime ge');
441
+ });
442
+
443
+ it('applies resolved filter', async () => {
444
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
445
+ await handler('alerts.list')({ resolved: false }, ntnxConfig, basicCreds, fetchFn);
446
+
447
+ const [url] = callArgs(fetchFn, 0) as [string];
448
+ const filter = getParam(url, '$filter') ?? '';
449
+ expect(filter).toContain("resolvedStatus eq 'UNRESOLVED'");
450
+ });
451
+ });
452
+
453
+ describe('alerts.summary', () => {
454
+ it('aggregates alerts by severity and entity type', async () => {
455
+ const fetchFn = mockNtnxResponse(
456
+ v4Envelope([
457
+ {
458
+ severity: 'CRITICAL',
459
+ resolvedStatus: 'UNRESOLVED',
460
+ title: 'Disk fail',
461
+ sourceEntity: { type: 'disk', name: 'sda' },
462
+ creationTime: '2024-01-15T10:00:00Z',
463
+ },
464
+ {
465
+ severity: 'WARNING',
466
+ resolvedStatus: 'RESOLVED',
467
+ title: 'High CPU',
468
+ sourceEntity: { type: 'vm' },
469
+ },
470
+ {
471
+ severity: 'CRITICAL',
472
+ resolvedStatus: 'RESOLVED',
473
+ title: 'Memory',
474
+ sourceEntity: { type: 'host' },
475
+ },
476
+ {
477
+ severity: 'INFO',
478
+ resolvedStatus: 'UNRESOLVED',
479
+ title: 'Info alert',
480
+ sourceEntity: { type: 'vm' },
481
+ },
482
+ ]),
483
+ );
484
+
485
+ const result = (await handler('alerts.summary')({}, ntnxConfig, basicCreds, fetchFn)) as {
486
+ bySeverity: Record<string, number>;
487
+ byEntityType: Record<string, number>;
488
+ unresolvedCritical: unknown[];
489
+ totalCount: number;
490
+ };
491
+
492
+ expect(result.bySeverity.CRITICAL).toBe(2);
493
+ expect(result.bySeverity.WARNING).toBe(1);
494
+ expect(result.bySeverity.INFO).toBe(1);
495
+ expect(result.byEntityType.disk).toBe(1);
496
+ expect(result.byEntityType.vm).toBe(2);
497
+ expect(result.byEntityType.host).toBe(1);
498
+ expect(result.unresolvedCritical).toHaveLength(1);
499
+ expect(result.totalCount).toBe(4);
500
+ });
501
+ });
502
+
503
+ describe('storage.containers', () => {
504
+ it('returns containers with usage', async () => {
505
+ const fetchFn = mockNtnxResponse(
506
+ v4Envelope(
507
+ [
508
+ {
509
+ name: 'default-container',
510
+ extId: 'sc-1',
511
+ maxCapacityBytes: 1099511627776,
512
+ usedBytes: 549755813888,
513
+ replicationFactor: 2,
514
+ compressionEnabled: true,
515
+ },
516
+ ],
517
+ 1,
518
+ ),
519
+ );
520
+
521
+ const result = (await handler('storage.containers')({}, ntnxConfig, basicCreds, fetchFn)) as {
522
+ containers: Array<{ name: string; usedPct: number; highUsage: boolean }>;
523
+ };
524
+
525
+ expect(result.containers).toHaveLength(1);
526
+ expect(result.containers[0]?.name).toBe('default-container');
527
+ expect(result.containers[0]?.usedPct).toBe(50);
528
+ expect(result.containers[0]?.highUsage).toBe(false);
529
+ });
530
+
531
+ it('flags containers >85% used', async () => {
532
+ const fetchFn = mockNtnxResponse(
533
+ v4Envelope([
534
+ {
535
+ name: 'full-container',
536
+ maxCapacityBytes: 1000,
537
+ usedBytes: 900,
538
+ },
539
+ ]),
540
+ );
541
+
542
+ const result = (await handler('storage.containers')({}, ntnxConfig, basicCreds, fetchFn)) as {
543
+ containers: Array<{ highUsage: boolean; usedPct: number }>;
544
+ };
545
+
546
+ expect(result.containers[0]?.highUsage).toBe(true);
547
+ expect(result.containers[0]?.usedPct).toBe(90);
548
+ });
549
+
550
+ it('applies cluster_id filter', async () => {
551
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
552
+ await handler('storage.containers')({ cluster_id: 'c-1' }, ntnxConfig, basicCreds, fetchFn);
553
+
554
+ const [url] = callArgs(fetchFn, 0) as [string];
555
+ expect(getParam(url, '$filter')).toBe("clusterExtId eq 'c-1'");
556
+ });
557
+ });
558
+
559
+ describe('categories.list', () => {
560
+ it('returns categories', async () => {
561
+ const fetchFn = mockNtnxResponse(
562
+ v4Envelope(
563
+ [
564
+ {
565
+ key: 'Environment',
566
+ value: 'Production',
567
+ type: 'USER',
568
+ description: 'Environment tag',
569
+ },
570
+ { key: 'Environment', value: 'Staging', type: 'USER' },
571
+ ],
572
+ 2,
573
+ ),
574
+ );
575
+
576
+ const result = (await handler('categories.list')({}, ntnxConfig, basicCreds, fetchFn)) as {
577
+ categories: Array<{ key: string; value: string }>;
578
+ totalCount: number;
579
+ };
580
+
581
+ expect(result.categories).toHaveLength(2);
582
+ expect(result.totalCount).toBe(2);
583
+ });
584
+
585
+ it('applies key filter', async () => {
586
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
587
+ await handler('categories.list')({ key: 'Environment' }, ntnxConfig, basicCreds, fetchFn);
588
+
589
+ const [url] = callArgs(fetchFn, 0) as [string];
590
+ expect(getParam(url, '$filter')).toBe("key eq 'Environment'");
591
+ });
592
+ });
593
+
594
+ describe('categories.entities', () => {
595
+ it('posts v3 category query and returns entities', async () => {
596
+ const fetchFn = mockNtnxResponse({
597
+ results: [
598
+ {
599
+ kind: 'vm',
600
+ kind_reference_list: [
601
+ { kind: 'vm', uuid: 'vm-1', name: 'web-01' },
602
+ { kind: 'vm', uuid: 'vm-2', name: 'web-02' },
603
+ ],
604
+ },
605
+ ],
606
+ });
607
+
608
+ const result = (await handler('categories.entities')(
609
+ { key: 'Environment', value: 'Production' },
610
+ ntnxConfig,
611
+ basicCreds,
612
+ fetchFn,
613
+ )) as {
614
+ entities: Array<{ entityType: string; entityId: string; entityName: string }>;
615
+ totalCount: number;
616
+ };
617
+
618
+ expect(result.entities).toHaveLength(2);
619
+ expect(result.entities[0]?.entityType).toBe('vm');
620
+ expect(result.entities[0]?.entityId).toBe('vm-1');
621
+ expect(result.totalCount).toBe(2);
622
+
623
+ // Verify it POSTed to v3 URL
624
+ const [url, opts] = callArgs(fetchFn, 0) as [string, { method: string; body: string }];
625
+ expect(url).toContain('/api/nutanix/v3/category/query');
626
+ expect(opts.method).toBe('POST');
627
+ const body = JSON.parse(opts.body);
628
+ expect(body.category_filter.params.Environment).toEqual(['Production']);
629
+ });
630
+
631
+ it('requires key and value parameters', async () => {
632
+ const fetchFn = mockNtnxResponse({});
633
+ await expect(
634
+ handler('categories.entities')({}, ntnxConfig, basicCreds, fetchFn),
635
+ ).rejects.toThrow('key parameter is required');
636
+
637
+ await expect(
638
+ handler('categories.entities')({ key: 'Env' }, ntnxConfig, basicCreds, fetchFn),
639
+ ).rejects.toThrow('value parameter is required');
640
+ });
641
+ });
642
+
643
+ describe('networks.list', () => {
644
+ it('returns subnets', async () => {
645
+ const fetchFn = mockNtnxResponse(
646
+ v4Envelope(
647
+ [
648
+ {
649
+ name: 'vlan-100',
650
+ subnetType: 'VLAN',
651
+ vlanId: 100,
652
+ clusterExtId: 'c-1',
653
+ },
654
+ ],
655
+ 1,
656
+ ),
657
+ );
658
+
659
+ const result = (await handler('networks.list')({}, ntnxConfig, basicCreds, fetchFn)) as {
660
+ subnets: Array<{ name: string; type: string; vlanId: number }>;
661
+ };
662
+
663
+ expect(result.subnets).toHaveLength(1);
664
+ expect(result.subnets[0]?.name).toBe('vlan-100');
665
+ expect(result.subnets[0]?.type).toBe('VLAN');
666
+ expect(result.subnets[0]?.vlanId).toBe(100);
667
+ });
668
+
669
+ it('applies cluster_id filter', async () => {
670
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
671
+ await handler('networks.list')({ cluster_id: 'c-1' }, ntnxConfig, basicCreds, fetchFn);
672
+
673
+ const [url] = callArgs(fetchFn, 0) as [string];
674
+ expect(getParam(url, '$filter')).toBe("clusterExtId eq 'c-1'");
675
+ });
676
+ });
677
+
678
+ describe('tasks.recent', () => {
679
+ it('returns tasks with defaults', async () => {
680
+ const fetchFn = mockNtnxResponse(
681
+ v4Envelope(
682
+ [
683
+ {
684
+ operationType: 'kVmCreate',
685
+ status: 'SUCCEEDED',
686
+ startTime: '2024-01-15T10:00:00Z',
687
+ completedTime: '2024-01-15T10:01:00Z',
688
+ progressPercentage: 100,
689
+ },
690
+ ],
691
+ 1,
692
+ ),
693
+ );
694
+
695
+ const result = (await handler('tasks.recent')({}, ntnxConfig, basicCreds, fetchFn)) as {
696
+ tasks: Array<{ type: string; status: string; isFailed: boolean; isLongRunning: boolean }>;
697
+ };
698
+
699
+ expect(result.tasks).toHaveLength(1);
700
+ expect(result.tasks[0]?.type).toBe('kVmCreate');
701
+ expect(result.tasks[0]?.isFailed).toBe(false);
702
+ expect(result.tasks[0]?.isLongRunning).toBe(false);
703
+ });
704
+
705
+ it('detects failed tasks', async () => {
706
+ const fetchFn = mockNtnxResponse(
707
+ v4Envelope([
708
+ {
709
+ operationType: 'kVmUpdate',
710
+ status: 'FAILED',
711
+ startTime: '2024-01-15T10:00:00Z',
712
+ errorMessages: [{ message: 'Out of memory' }],
713
+ },
714
+ ]),
715
+ );
716
+
717
+ const result = (await handler('tasks.recent')({}, ntnxConfig, basicCreds, fetchFn)) as {
718
+ tasks: Array<{ isFailed: boolean; errorMessage: string }>;
719
+ };
720
+
721
+ expect(result.tasks[0]?.isFailed).toBe(true);
722
+ expect(result.tasks[0]?.errorMessage).toBe('Out of memory');
723
+ });
724
+
725
+ it('detects long-running tasks (>1hr without end time)', async () => {
726
+ const twoHoursAgo = new Date(Date.now() - 7200000).toISOString();
727
+ const fetchFn = mockNtnxResponse(
728
+ v4Envelope([
729
+ {
730
+ operationType: 'kMigrate',
731
+ status: 'RUNNING',
732
+ startTime: twoHoursAgo,
733
+ },
734
+ ]),
735
+ );
736
+
737
+ const result = (await handler('tasks.recent')({}, ntnxConfig, basicCreds, fetchFn)) as {
738
+ tasks: Array<{ isLongRunning: boolean }>;
739
+ };
740
+
741
+ expect(result.tasks[0]?.isLongRunning).toBe(true);
742
+ });
743
+
744
+ it('applies status filter', async () => {
745
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
746
+ await handler('tasks.recent')({ status: 'FAILED' }, ntnxConfig, basicCreds, fetchFn);
747
+
748
+ const [url] = callArgs(fetchFn, 0) as [string];
749
+ const filter = getParam(url, '$filter') ?? '';
750
+ expect(filter).toContain("status eq 'FAILED'");
751
+ });
752
+ });
753
+
754
+ describe('cluster.health', () => {
755
+ it('returns composite health assessment', async () => {
756
+ let callCount = 0;
757
+ const fetchFn = vi.fn().mockImplementation(() => {
758
+ callCount++;
759
+ if (callCount === 1) {
760
+ // clusters list
761
+ return Promise.resolve(
762
+ new Response(
763
+ JSON.stringify(v4Envelope([{ name: 'prod', extId: 'c-1', operationMode: 'NORMAL' }])),
764
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
765
+ ),
766
+ );
767
+ }
768
+ if (callCount === 2) {
769
+ // hosts
770
+ return Promise.resolve(
771
+ new Response(
772
+ JSON.stringify(
773
+ v4Envelope([
774
+ { hostName: 'n1', extId: 'h-1', maintenanceMode: false },
775
+ { hostName: 'n2', extId: 'h-2', maintenanceMode: false },
776
+ ]),
777
+ ),
778
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
779
+ ),
780
+ );
781
+ }
782
+ if (callCount === 3) {
783
+ // critical alerts
784
+ return Promise.resolve(
785
+ new Response(JSON.stringify(v4Envelope([])), {
786
+ status: 200,
787
+ headers: { 'Content-Type': 'application/json' },
788
+ }),
789
+ );
790
+ }
791
+ // storage
792
+ return Promise.resolve(
793
+ new Response(
794
+ JSON.stringify(
795
+ v4Envelope([{ name: 'default', maxCapacityBytes: 1000, usedBytes: 400 }]),
796
+ ),
797
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
798
+ ),
799
+ );
800
+ });
801
+
802
+ const result = (await handler('cluster.health')({}, ntnxConfig, basicCreds, fetchFn)) as {
803
+ cluster: { name: string };
804
+ nodeCount: number;
805
+ degradedNodes: unknown[];
806
+ criticalAlerts: unknown[];
807
+ healthAssessment: string;
808
+ issues: string[];
809
+ };
810
+
811
+ expect(result.cluster.name).toBe('prod');
812
+ expect(result.nodeCount).toBe(2);
813
+ expect(result.degradedNodes).toHaveLength(0);
814
+ expect(result.criticalAlerts).toHaveLength(0);
815
+ expect(result.healthAssessment).toBe('HEALTHY');
816
+ expect(result.issues).toHaveLength(0);
817
+ });
818
+
819
+ it('returns CRITICAL when alerts or degraded nodes exist', async () => {
820
+ let callCount = 0;
821
+ const fetchFn = vi.fn().mockImplementation(() => {
822
+ callCount++;
823
+ if (callCount === 1) {
824
+ return Promise.resolve(
825
+ new Response(JSON.stringify(v4Envelope([{ name: 'prod', extId: 'c-1' }])), {
826
+ status: 200,
827
+ headers: { 'Content-Type': 'application/json' },
828
+ }),
829
+ );
830
+ }
831
+ if (callCount === 2) {
832
+ return Promise.resolve(
833
+ new Response(
834
+ JSON.stringify(v4Envelope([{ hostName: 'n1', extId: 'h-1', maintenanceMode: true }])),
835
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
836
+ ),
837
+ );
838
+ }
839
+ if (callCount === 3) {
840
+ return Promise.resolve(
841
+ new Response(
842
+ JSON.stringify(v4Envelope([{ title: 'Critical!', severity: 'CRITICAL' }])),
843
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
844
+ ),
845
+ );
846
+ }
847
+ return Promise.resolve(
848
+ new Response(JSON.stringify(v4Envelope([])), {
849
+ status: 200,
850
+ headers: { 'Content-Type': 'application/json' },
851
+ }),
852
+ );
853
+ });
854
+
855
+ const result = (await handler('cluster.health')({}, ntnxConfig, basicCreds, fetchFn)) as {
856
+ healthAssessment: string;
857
+ issues: string[];
858
+ };
859
+
860
+ expect(result.healthAssessment).toBe('CRITICAL');
861
+ expect(result.issues.length).toBeGreaterThan(0);
862
+ });
863
+
864
+ it('returns WARNING when storage >85%', async () => {
865
+ let callCount = 0;
866
+ const fetchFn = vi.fn().mockImplementation(() => {
867
+ callCount++;
868
+ if (callCount === 1) {
869
+ return Promise.resolve(
870
+ new Response(JSON.stringify(v4Envelope([{ name: 'prod', extId: 'c-1' }])), {
871
+ status: 200,
872
+ headers: { 'Content-Type': 'application/json' },
873
+ }),
874
+ );
875
+ }
876
+ if (callCount === 2) {
877
+ return Promise.resolve(
878
+ new Response(JSON.stringify(v4Envelope([{ hostName: 'n1', maintenanceMode: false }])), {
879
+ status: 200,
880
+ headers: { 'Content-Type': 'application/json' },
881
+ }),
882
+ );
883
+ }
884
+ if (callCount === 3) {
885
+ return Promise.resolve(
886
+ new Response(JSON.stringify(v4Envelope([])), {
887
+ status: 200,
888
+ headers: { 'Content-Type': 'application/json' },
889
+ }),
890
+ );
891
+ }
892
+ return Promise.resolve(
893
+ new Response(
894
+ JSON.stringify(
895
+ v4Envelope([{ name: 'full-sc', maxCapacityBytes: 1000, usedBytes: 900 }]),
896
+ ),
897
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
898
+ ),
899
+ );
900
+ });
901
+
902
+ const result = (await handler('cluster.health')({}, ntnxConfig, basicCreds, fetchFn)) as {
903
+ healthAssessment: string;
904
+ };
905
+
906
+ expect(result.healthAssessment).toBe('WARNING');
907
+ });
908
+ });
909
+
910
+ describe('testConnection', () => {
911
+ it('returns true on successful cluster query', async () => {
912
+ const fetchFn = mockNtnxResponse(v4Envelope([{ name: 'cluster-1' }]));
913
+ const result = await nutanixPack.testConnection(ntnxConfig, basicCreds, fetchFn);
914
+ expect(result).toBe(true);
915
+
916
+ const [url] = callArgs(fetchFn, 0) as [string];
917
+ expect(url).toContain('/api/clustermgmt/v4.0/config/clusters');
918
+ expect(getParam(url, '$limit')).toBe('1');
919
+ });
920
+
921
+ it('returns false on 401', async () => {
922
+ const fetchFn = mockFetchError(401);
923
+ const result = await nutanixPack.testConnection(ntnxConfig, basicCreds, fetchFn);
924
+ expect(result).toBe(false);
925
+ });
926
+
927
+ it('throws on network error', async () => {
928
+ const fetchFn = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
929
+ await expect(nutanixPack.testConnection(ntnxConfig, basicCreds, fetchFn))
930
+ .rejects.toThrow('ECONNREFUSED');
931
+ });
932
+
933
+ it('returns false when data is null', async () => {
934
+ const fetchFn = mockNtnxResponse({ data: null });
935
+ const result = await nutanixPack.testConnection(ntnxConfig, basicCreds, fetchFn);
936
+ expect(result).toBe(false);
937
+ });
938
+ });
939
+
940
+ describe('manifest', () => {
941
+ it('has correct name and 20 probes', () => {
942
+ expect(nutanixPack.manifest.name).toBe('nutanix');
943
+ expect(nutanixPack.manifest.probes).toHaveLength(20);
944
+ });
945
+
946
+ it('all handlers match manifest probes', () => {
947
+ const probeNames = nutanixPack.manifest.probes.map((p) => p.name);
948
+ const handlerNames = Object.keys(nutanixPack.handlers);
949
+ expect(handlerNames.sort()).toEqual(probeNames.sort());
950
+ });
951
+
952
+ it('all probes have observe capability', () => {
953
+ for (const probe of nutanixPack.manifest.probes) {
954
+ expect(probe.capability).toBe('observe');
955
+ }
956
+ });
957
+
958
+ it('has correct timeouts (30s for vm.stats and cluster.health, 15s for others)', () => {
959
+ const probeMap = new Map(nutanixPack.manifest.probes.map((p) => [p.name, p.timeout]));
960
+ expect(probeMap.get('vm.stats')).toBe(30000);
961
+ expect(probeMap.get('cluster.health')).toBe(30000);
962
+ for (const [name, timeout] of probeMap) {
963
+ if (name !== 'vm.stats' && name !== 'cluster.health') {
964
+ expect(timeout).toBe(15000);
965
+ }
966
+ }
967
+ });
968
+
969
+ it('has hyperconverged runbook', () => {
970
+ expect(nutanixPack.manifest.runbook).toEqual({
971
+ category: 'hyperconverged',
972
+ probes: ['clusters.list', 'alerts.summary', 'storage.containers'],
973
+ parallel: true,
974
+ });
975
+ });
976
+ });
977
+
978
+ describe('vm.snapshots', () => {
979
+ it('returns snapshots via v4 API', async () => {
980
+ const now = new Date().toISOString();
981
+ const fetchFn = mockNtnxResponse(
982
+ v4Envelope([
983
+ {
984
+ name: 'daily-snap',
985
+ extId: 'rp-1',
986
+ creationTime: now,
987
+ expirationTime: new Date(Date.now() + 86400000).toISOString(),
988
+ recoveryPointType: 'CRASH_CONSISTENT',
989
+ sizeBytes: 1073741824,
990
+ },
991
+ ]),
992
+ );
993
+
994
+ const result = (await handler('vm.snapshots')(
995
+ { vm_id: 'vm-1' },
996
+ ntnxConfig,
997
+ basicCreds,
998
+ fetchFn,
999
+ )) as {
1000
+ snapshots: Array<{
1001
+ name: string;
1002
+ isOld: boolean;
1003
+ isExpired: boolean;
1004
+ consistencyType: string;
1005
+ }>;
1006
+ totalCount: number;
1007
+ usedV3: boolean;
1008
+ warnings: string[];
1009
+ };
1010
+
1011
+ expect(result.snapshots).toHaveLength(1);
1012
+ expect(result.snapshots[0]?.name).toBe('daily-snap');
1013
+ expect(result.snapshots[0]?.isOld).toBe(false);
1014
+ expect(result.snapshots[0]?.isExpired).toBe(false);
1015
+ expect(result.snapshots[0]?.consistencyType).toBe('CRASH_CONSISTENT');
1016
+ expect(result.usedV3).toBe(false);
1017
+ expect(result.warnings).toHaveLength(0);
1018
+ });
1019
+
1020
+ it('flags old snapshots (>7 days)', async () => {
1021
+ const eightDaysAgo = new Date(Date.now() - 8 * 86400000).toISOString();
1022
+ const fetchFn = mockNtnxResponse(
1023
+ v4Envelope([
1024
+ { name: 'old-snap', creationTime: eightDaysAgo, recoveryPointType: 'APP_CONSISTENT' },
1025
+ ]),
1026
+ );
1027
+
1028
+ const result = (await handler('vm.snapshots')(
1029
+ { vm_id: 'vm-1' },
1030
+ ntnxConfig,
1031
+ basicCreds,
1032
+ fetchFn,
1033
+ )) as {
1034
+ snapshots: Array<{ isOld: boolean; ageDays: number }>;
1035
+ warnings: string[];
1036
+ };
1037
+
1038
+ expect(result.snapshots[0]?.isOld).toBe(true);
1039
+ expect(result.snapshots[0]?.ageDays).toBeGreaterThan(7);
1040
+ expect(result.warnings.some((w: string) => w.includes('older than 7 days'))).toBe(true);
1041
+ });
1042
+
1043
+ it('flags expired snapshots not cleaned up', async () => {
1044
+ const pastExpiration = new Date(Date.now() - 86400000).toISOString();
1045
+ const fetchFn = mockNtnxResponse(
1046
+ v4Envelope([
1047
+ {
1048
+ name: 'expired-snap',
1049
+ creationTime: new Date(Date.now() - 2 * 86400000).toISOString(),
1050
+ expirationTime: pastExpiration,
1051
+ },
1052
+ ]),
1053
+ );
1054
+
1055
+ const result = (await handler('vm.snapshots')(
1056
+ { vm_id: 'vm-1' },
1057
+ ntnxConfig,
1058
+ basicCreds,
1059
+ fetchFn,
1060
+ )) as {
1061
+ snapshots: Array<{ isExpired: boolean }>;
1062
+ warnings: string[];
1063
+ };
1064
+
1065
+ expect(result.snapshots[0]?.isExpired).toBe(true);
1066
+ expect(result.warnings.some((w: string) => w.includes('expired'))).toBe(true);
1067
+ });
1068
+
1069
+ it('falls back to v3 API when v4 fails', async () => {
1070
+ let callCount = 0;
1071
+ const fetchFn = vi.fn().mockImplementation((url: string) => {
1072
+ callCount++;
1073
+ if (callCount === 1) {
1074
+ // v4 fails
1075
+ return Promise.resolve(
1076
+ new Response('Not Found', { status: 404, statusText: 'Not Found' }),
1077
+ );
1078
+ }
1079
+ // v3 succeeds
1080
+ return Promise.resolve(
1081
+ new Response(
1082
+ JSON.stringify({
1083
+ entities: [
1084
+ {
1085
+ status: {
1086
+ name: 'v3-snap',
1087
+ creation_time: new Date().toISOString(),
1088
+ recovery_point_type: 'CRASH_CONSISTENT',
1089
+ },
1090
+ extId: 'rp-v3',
1091
+ },
1092
+ ],
1093
+ }),
1094
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
1095
+ ),
1096
+ );
1097
+ });
1098
+
1099
+ const result = (await handler('vm.snapshots')(
1100
+ { vm_id: 'vm-1' },
1101
+ ntnxConfig,
1102
+ basicCreds,
1103
+ fetchFn,
1104
+ )) as { snapshots: unknown[]; usedV3: boolean };
1105
+
1106
+ expect(result.usedV3).toBe(true);
1107
+ expect(result.snapshots).toHaveLength(1);
1108
+ });
1109
+
1110
+ it('requires vm_id parameter', async () => {
1111
+ const fetchFn = mockNtnxResponse({});
1112
+ await expect(handler('vm.snapshots')({}, ntnxConfig, basicCreds, fetchFn)).rejects.toThrow(
1113
+ 'vm_id parameter is required',
1114
+ );
1115
+ });
1116
+ });
1117
+
1118
+ describe('protection.policies', () => {
1119
+ it('returns all policies', async () => {
1120
+ const fetchFn = mockNtnxResponse(
1121
+ v4Envelope(
1122
+ [
1123
+ {
1124
+ name: 'daily-backup',
1125
+ extId: 'pp-1',
1126
+ schedules: [
1127
+ { recoveryPointObjective: 60, rpoUnit: 'MINUTES', localRetentionCount: 7 },
1128
+ ],
1129
+ protectedEntities: [{ extId: 'vm-1' }, { extId: 'vm-2' }],
1130
+ lastSuccessfulReplicationTime: '2024-01-15T10:00:00Z',
1131
+ },
1132
+ ],
1133
+ 1,
1134
+ ),
1135
+ );
1136
+
1137
+ const result = (await handler('protection.policies')(
1138
+ {},
1139
+ ntnxConfig,
1140
+ basicCreds,
1141
+ fetchFn,
1142
+ )) as {
1143
+ policies: Array<{ name: string; protectedEntityCount: number; rpo: { value: number } }>;
1144
+ totalCount: number;
1145
+ };
1146
+
1147
+ expect(result.policies).toHaveLength(1);
1148
+ expect(result.policies[0]?.name).toBe('daily-backup');
1149
+ expect(result.policies[0]?.protectedEntityCount).toBe(2);
1150
+ expect(result.policies[0]?.rpo?.value).toBe(60);
1151
+ });
1152
+
1153
+ it('filters by vm_id and reports coverage', async () => {
1154
+ const fetchFn = mockNtnxResponse(
1155
+ v4Envelope([
1156
+ {
1157
+ name: 'covers-vm1',
1158
+ extId: 'pp-1',
1159
+ protectedEntities: [{ extId: 'vm-1' }],
1160
+ schedules: [],
1161
+ },
1162
+ {
1163
+ name: 'other-policy',
1164
+ extId: 'pp-2',
1165
+ protectedEntities: [{ extId: 'vm-99' }],
1166
+ schedules: [],
1167
+ },
1168
+ ]),
1169
+ );
1170
+
1171
+ const result = (await handler('protection.policies')(
1172
+ { vm_id: 'vm-1' },
1173
+ ntnxConfig,
1174
+ basicCreds,
1175
+ fetchFn,
1176
+ )) as {
1177
+ policies: Array<{ name: string }>;
1178
+ vmCovered: boolean;
1179
+ allPoliciesCount: number;
1180
+ };
1181
+
1182
+ expect(result.policies).toHaveLength(1);
1183
+ expect(result.policies[0]?.name).toBe('covers-vm1');
1184
+ expect(result.vmCovered).toBe(true);
1185
+ expect(result.allPoliciesCount).toBe(2);
1186
+ });
1187
+
1188
+ it('reports uncovered VM', async () => {
1189
+ const fetchFn = mockNtnxResponse(
1190
+ v4Envelope([
1191
+ {
1192
+ name: 'policy-1',
1193
+ extId: 'pp-1',
1194
+ protectedEntities: [{ extId: 'vm-99' }],
1195
+ schedules: [],
1196
+ },
1197
+ ]),
1198
+ );
1199
+
1200
+ const result = (await handler('protection.policies')(
1201
+ { vm_id: 'vm-orphan' },
1202
+ ntnxConfig,
1203
+ basicCreds,
1204
+ fetchFn,
1205
+ )) as { vmCovered: boolean; totalCount: number };
1206
+
1207
+ expect(result.vmCovered).toBe(false);
1208
+ expect(result.totalCount).toBe(0);
1209
+ });
1210
+ });
1211
+
1212
+ describe('lifecycle.status', () => {
1213
+ it('returns LCM entities with update detection', async () => {
1214
+ const fetchFn = mockNtnxResponse(
1215
+ v4Envelope([
1216
+ {
1217
+ entityType: 'AOS',
1218
+ name: 'AOS',
1219
+ extId: 'lcm-1',
1220
+ installedVersion: { version: '6.5.1' },
1221
+ availableVersion: { version: '6.5.2' },
1222
+ },
1223
+ {
1224
+ entityType: 'NCC',
1225
+ name: 'NCC',
1226
+ extId: 'lcm-2',
1227
+ installedVersion: { version: '4.6.0' },
1228
+ availableVersion: { version: '4.6.0' },
1229
+ },
1230
+ ]),
1231
+ );
1232
+
1233
+ const result = (await handler('lifecycle.status')({}, ntnxConfig, basicCreds, fetchFn)) as {
1234
+ entities: Array<{
1235
+ entityType: string;
1236
+ hasUpdate: boolean;
1237
+ currentVersion: string;
1238
+ availableVersion: string;
1239
+ }>;
1240
+ updatableCount: number;
1241
+ warnings: string[];
1242
+ };
1243
+
1244
+ expect(result.entities).toHaveLength(2);
1245
+ expect(result.entities[0]?.hasUpdate).toBe(true);
1246
+ expect(result.entities[0]?.currentVersion).toBe('6.5.1');
1247
+ expect(result.entities[0]?.availableVersion).toBe('6.5.2');
1248
+ expect(result.entities[1]?.hasUpdate).toBe(false);
1249
+ expect(result.updatableCount).toBe(1);
1250
+ expect(result.warnings.some((w: string) => w.includes('1 component(s)'))).toBe(true);
1251
+ expect(result.warnings.some((w: string) => w.includes('6.5.1'))).toBe(true);
1252
+ });
1253
+
1254
+ it('returns no warnings when everything is current', async () => {
1255
+ const fetchFn = mockNtnxResponse(
1256
+ v4Envelope([
1257
+ {
1258
+ entityType: 'AOS',
1259
+ installedVersion: { version: '6.5.2' },
1260
+ availableVersion: { version: '6.5.2' },
1261
+ },
1262
+ ]),
1263
+ );
1264
+
1265
+ const result = (await handler('lifecycle.status')({}, ntnxConfig, basicCreds, fetchFn)) as {
1266
+ updatableCount: number;
1267
+ warnings: string[];
1268
+ };
1269
+
1270
+ expect(result.updatableCount).toBe(0);
1271
+ expect(result.warnings).toHaveLength(0);
1272
+ });
1273
+ });
1274
+
1275
+ describe('host.stats', () => {
1276
+ it('returns host metrics with ppm conversion', async () => {
1277
+ const fetchFn = mockNtnxResponse(
1278
+ v4Envelope([
1279
+ { metricType: 'HYPERVISOR_CPU_USAGE_PPM', value: 500000 },
1280
+ { metricType: 'HYPERVISOR_MEMORY_USAGE_PPM', value: 700000 },
1281
+ { metricType: 'IOPS', value: 1200 },
1282
+ { metricType: 'IO_BANDWIDTH_KBPS', value: 204800 },
1283
+ { metricType: 'NETWORK_RX_BYTES', value: 2097152 },
1284
+ { metricType: 'NETWORK_TX_BYTES', value: 1048576 },
1285
+ { metricType: 'HYPERVISOR_UPTIME_USECS', value: 86400000000 },
1286
+ ]),
1287
+ );
1288
+
1289
+ const result = (await handler('host.stats')(
1290
+ { host_id: 'h-1' },
1291
+ ntnxConfig,
1292
+ basicCreds,
1293
+ fetchFn,
1294
+ )) as {
1295
+ cpuUsagePct: number;
1296
+ memoryUsagePct: number;
1297
+ iops: number;
1298
+ warnings: string[];
1299
+ };
1300
+
1301
+ expect(result.cpuUsagePct).toBe(50);
1302
+ expect(result.memoryUsagePct).toBe(70);
1303
+ expect(result.iops).toBe(1200);
1304
+ expect(result.warnings).toHaveLength(0);
1305
+ });
1306
+
1307
+ it('flags high CPU (>85%)', async () => {
1308
+ const fetchFn = mockNtnxResponse(
1309
+ v4Envelope([
1310
+ { metricType: 'HYPERVISOR_CPU_USAGE_PPM', value: 900000 },
1311
+ { metricType: 'HYPERVISOR_MEMORY_USAGE_PPM', value: 500000 },
1312
+ ]),
1313
+ );
1314
+
1315
+ const result = (await handler('host.stats')(
1316
+ { host_id: 'h-1' },
1317
+ ntnxConfig,
1318
+ basicCreds,
1319
+ fetchFn,
1320
+ )) as { cpuUsagePct: number; warnings: string[] };
1321
+
1322
+ expect(result.cpuUsagePct).toBe(90);
1323
+ expect(result.warnings.some((w: string) => w.includes('CPU at 90%'))).toBe(true);
1324
+ });
1325
+
1326
+ it('flags high memory (>90%)', async () => {
1327
+ const fetchFn = mockNtnxResponse(
1328
+ v4Envelope([
1329
+ { metricType: 'HYPERVISOR_CPU_USAGE_PPM', value: 200000 },
1330
+ { metricType: 'HYPERVISOR_MEMORY_USAGE_PPM', value: 950000 },
1331
+ ]),
1332
+ );
1333
+
1334
+ const result = (await handler('host.stats')(
1335
+ { host_id: 'h-1' },
1336
+ ntnxConfig,
1337
+ basicCreds,
1338
+ fetchFn,
1339
+ )) as { memoryUsagePct: number; warnings: string[] };
1340
+
1341
+ expect(result.memoryUsagePct).toBe(95);
1342
+ expect(result.warnings.some((w: string) => w.includes('memory at 95%'))).toBe(true);
1343
+ });
1344
+
1345
+ it('requires host_id parameter', async () => {
1346
+ const fetchFn = mockNtnxResponse({});
1347
+ await expect(handler('host.stats')({}, ntnxConfig, basicCreds, fetchFn)).rejects.toThrow(
1348
+ 'host_id parameter is required',
1349
+ );
1350
+ });
1351
+ });
1352
+
1353
+ describe('cluster.stats', () => {
1354
+ it('returns aggregate cluster metrics with utilization percentages', async () => {
1355
+ const fetchFn = mockNtnxResponse(
1356
+ v4Envelope([
1357
+ { metricType: 'CPU_CAPACITY_HZ', value: 100000000000 },
1358
+ { metricType: 'CPU_USAGE_HZ', value: 40000000000 },
1359
+ { metricType: 'MEMORY_CAPACITY_BYTES', value: 274877906944 },
1360
+ { metricType: 'MEMORY_USAGE_BYTES', value: 137438953472 },
1361
+ { metricType: 'STORAGE_CAPACITY_BYTES', value: 10995116277760 },
1362
+ { metricType: 'STORAGE_USAGE_BYTES', value: 5497558138880 },
1363
+ { metricType: 'IOPS', value: 5000 },
1364
+ { metricType: 'AVG_IO_LATENCY_USECS', value: 2000 },
1365
+ ]),
1366
+ );
1367
+
1368
+ const result = (await handler('cluster.stats')(
1369
+ { cluster_id: 'c-1' },
1370
+ ntnxConfig,
1371
+ basicCreds,
1372
+ fetchFn,
1373
+ )) as {
1374
+ cpuUsagePct: number;
1375
+ memoryUsagePct: number;
1376
+ storageUsagePct: number;
1377
+ iops: number;
1378
+ avgIoLatencyMs: number;
1379
+ };
1380
+
1381
+ expect(result.cpuUsagePct).toBe(40);
1382
+ expect(result.memoryUsagePct).toBe(50);
1383
+ expect(result.storageUsagePct).toBe(50);
1384
+ expect(result.iops).toBe(5000);
1385
+ expect(result.avgIoLatencyMs).toBe(2);
1386
+ });
1387
+
1388
+ it('requires cluster_id parameter', async () => {
1389
+ const fetchFn = mockNtnxResponse({});
1390
+ await expect(handler('cluster.stats')({}, ntnxConfig, basicCreds, fetchFn)).rejects.toThrow(
1391
+ 'cluster_id parameter is required',
1392
+ );
1393
+ });
1394
+ });
1395
+
1396
+ describe('images.list', () => {
1397
+ it('returns images', async () => {
1398
+ const fetchFn = mockNtnxResponse(
1399
+ v4Envelope(
1400
+ [
1401
+ {
1402
+ name: 'ubuntu-22.04',
1403
+ extId: 'img-1',
1404
+ type: 'DISK_IMAGE',
1405
+ sizeBytes: 2147483648,
1406
+ description: 'Ubuntu 22.04 LTS',
1407
+ createTime: '2024-01-10T08:00:00Z',
1408
+ },
1409
+ {
1410
+ name: 'windows-2022.iso',
1411
+ extId: 'img-2',
1412
+ type: 'ISO',
1413
+ sizeBytes: 5368709120,
1414
+ },
1415
+ ],
1416
+ 2,
1417
+ ),
1418
+ );
1419
+
1420
+ const result = (await handler('images.list')({}, ntnxConfig, basicCreds, fetchFn)) as {
1421
+ images: Array<{ name: string; type: string; sizeBytes: number }>;
1422
+ totalCount: number;
1423
+ };
1424
+
1425
+ expect(result.images).toHaveLength(2);
1426
+ expect(result.images[0]?.name).toBe('ubuntu-22.04');
1427
+ expect(result.images[0]?.type).toBe('DISK_IMAGE');
1428
+ expect(result.images[1]?.type).toBe('ISO');
1429
+ expect(result.totalCount).toBe(2);
1430
+ });
1431
+
1432
+ it('applies name filter', async () => {
1433
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
1434
+ await handler('images.list')({ name: 'ubuntu' }, ntnxConfig, basicCreds, fetchFn);
1435
+
1436
+ const [url] = callArgs(fetchFn, 0) as [string];
1437
+ expect(getParam(url, '$filter')).toBe("name eq 'ubuntu'");
1438
+ });
1439
+ });
1440
+
1441
+ describe('vms.by_host', () => {
1442
+ it('returns VMs on a specific host', async () => {
1443
+ const fetchFn = mockNtnxResponse(
1444
+ v4Envelope(
1445
+ [
1446
+ {
1447
+ name: 'web-01',
1448
+ extId: 'vm-1',
1449
+ powerState: 'ON',
1450
+ numSockets: 2,
1451
+ memorySizeBytes: 4294967296,
1452
+ },
1453
+ {
1454
+ name: 'db-01',
1455
+ extId: 'vm-2',
1456
+ powerState: 'ON',
1457
+ numSockets: 4,
1458
+ memorySizeBytes: 8589934592,
1459
+ },
1460
+ ],
1461
+ 2,
1462
+ ),
1463
+ );
1464
+
1465
+ const result = (await handler('vms.by_host')(
1466
+ { host_id: 'h-1' },
1467
+ ntnxConfig,
1468
+ basicCreds,
1469
+ fetchFn,
1470
+ )) as {
1471
+ vms: Array<{ name: string; memorySizeMb: number }>;
1472
+ totalCount: number;
1473
+ hostId: string;
1474
+ };
1475
+
1476
+ expect(result.vms).toHaveLength(2);
1477
+ expect(result.vms[0]?.name).toBe('web-01');
1478
+ expect(result.vms[0]?.memorySizeMb).toBe(4096);
1479
+ expect(result.vms[1]?.memorySizeMb).toBe(8192);
1480
+ expect(result.hostId).toBe('h-1');
1481
+ expect(result.totalCount).toBe(2);
1482
+ });
1483
+
1484
+ it('applies hostExtId filter in URL', async () => {
1485
+ const fetchFn = mockNtnxResponse(v4Envelope([]));
1486
+ await handler('vms.by_host')({ host_id: 'h-42' }, ntnxConfig, basicCreds, fetchFn);
1487
+
1488
+ const [url] = callArgs(fetchFn, 0) as [string];
1489
+ expect(getParam(url, '$filter')).toBe("hostExtId eq 'h-42'");
1490
+ });
1491
+
1492
+ it('requires host_id parameter', async () => {
1493
+ const fetchFn = mockNtnxResponse({});
1494
+ await expect(handler('vms.by_host')({}, ntnxConfig, basicCreds, fetchFn)).rejects.toThrow(
1495
+ 'host_id parameter is required',
1496
+ );
1497
+ });
1498
+ });
1499
+
1500
+ describe('error handling', () => {
1501
+ it('throws on non-200 API response for probes', async () => {
1502
+ const fetchFn = mockFetchError(403);
1503
+ await expect(handler('clusters.list')({}, ntnxConfig, basicCreds, fetchFn)).rejects.toThrow(
1504
+ 'Nutanix API returned 403',
1505
+ );
1506
+ });
1507
+ });
1508
+ });