@oneuptime/common 14.0.2 → 14.0.5

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 (765) hide show
  1. package/Models/DatabaseModels/Alert.ts +55 -0
  2. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  3. package/Models/DatabaseModels/DatabaseServer.ts +1650 -0
  4. package/Models/DatabaseModels/DatabaseServerEndpoint.ts +540 -0
  5. package/Models/DatabaseModels/DatabaseServerFeed.ts +625 -0
  6. package/Models/DatabaseModels/DatabaseServerLabelRule.ts +514 -0
  7. package/Models/DatabaseModels/DatabaseServerOwnerRule.ts +596 -0
  8. package/Models/DatabaseModels/DatabaseServerOwnerTeam.ts +491 -0
  9. package/Models/DatabaseModels/DatabaseServerOwnerUser.ts +490 -0
  10. package/Models/DatabaseModels/Incident.ts +55 -0
  11. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  12. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  13. package/Models/DatabaseModels/Index.ts +18 -0
  14. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +26 -0
  15. package/Models/DatabaseModels/Project.ts +60 -0
  16. package/Models/DatabaseModels/RumApplication.ts +52 -1
  17. package/Models/DatabaseModels/ScheduledMaintenance.ts +55 -0
  18. package/Models/DatabaseModels/TelemetryException.ts +2 -0
  19. package/Models/DatabaseModels/UserProjectSsoConsent.ts +146 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.ts +673 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.ts +51 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  25. package/Server/Middleware/MultipartFormData.ts +1 -1
  26. package/Server/Middleware/TelemetryIngest.ts +15 -8
  27. package/Server/Services/AlertReminderRuleService.ts +100 -24
  28. package/Server/Services/DatabaseServerEndpointService.ts +895 -0
  29. package/Server/Services/DatabaseServerFeedService.ts +128 -0
  30. package/Server/Services/DatabaseServerLabelRuleEngineService.ts +340 -0
  31. package/Server/Services/DatabaseServerLabelRuleService.ts +14 -0
  32. package/Server/Services/DatabaseServerOwnerRuleEngineService.ts +425 -0
  33. package/Server/Services/DatabaseServerOwnerRuleService.ts +14 -0
  34. package/Server/Services/DatabaseServerOwnerTeamService.ts +180 -0
  35. package/Server/Services/DatabaseServerOwnerUserService.ts +173 -0
  36. package/Server/Services/DatabaseServerService.ts +4546 -0
  37. package/Server/Services/DatabaseService.ts +14 -8
  38. package/Server/Services/DockerResourceService.ts +43 -2
  39. package/Server/Services/IncidentAlertService.ts +1811 -0
  40. package/Server/Services/IncidentReminderRuleService.ts +103 -23
  41. package/Server/Services/IncidentService.ts +210 -10
  42. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  43. package/Server/Services/Index.ts +18 -0
  44. package/Server/Services/InventoryItemService.ts +113 -5
  45. package/Server/Services/IoTDeviceService.ts +145 -37
  46. package/Server/Services/OpenTelemetryIngestService.ts +19 -0
  47. package/Server/Services/PodmanResourceService.ts +43 -2
  48. package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +107 -23
  49. package/Server/Services/ServiceService.ts +1 -1
  50. package/Server/Services/TelemetryUsageBillingService.ts +29 -2
  51. package/Server/Services/UserProjectSsoConsentService.ts +74 -0
  52. package/Server/Services/UserService.ts +14 -1
  53. package/Server/Types/Database/Permissions/OwnerTableRegistry.ts +18 -0
  54. package/Server/Types/Database/QueryHelper.ts +30 -1
  55. package/Server/Utils/AI/Toolbox/ResourceTools.ts +69 -1
  56. package/Server/Utils/Database/AffectedResourceRelations.ts +6 -0
  57. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +188 -6
  58. package/Server/Utils/Monitor/MonitorAlert.ts +28 -1
  59. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +2262 -755
  60. package/Server/Utils/Monitor/MonitorIncident.ts +28 -1
  61. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +268 -6
  62. package/Server/Utils/Monitor/MonitorResource.ts +47 -11
  63. package/Server/Utils/Monitor/MonitorResourceContext.ts +1 -0
  64. package/Server/Utils/Monitor/MonitorStepResourceIdentity.ts +239 -21
  65. package/Server/Utils/Monitor/PerSeriesResolutionRootCause.ts +43 -0
  66. package/Server/Utils/Monitor/SeriesResourceLabels.ts +291 -0
  67. package/Server/Utils/Monitor/SeriesResourceLinker.ts +111 -12
  68. package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +25 -0
  69. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  70. package/Server/Utils/StartServer.ts +26 -6
  71. package/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.ts +2 -0
  72. package/Server/Utils/Telemetry/DatabaseEndpointDiscovery.ts +688 -0
  73. package/Server/Utils/Telemetry/EntityRegistry.ts +5 -1
  74. package/Server/Utils/Telemetry/LogExceptionExtractor.ts +18 -1
  75. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +175 -12
  76. package/Server/Utils/Telemetry/ResourceFacetResolver.ts +12 -0
  77. package/Server/Utils/Telemetry/ServiceDependencyDiscovery.ts +606 -36
  78. package/Server/Utils/Telemetry/Telemetry.ts +16 -0
  79. package/Server/Utils/Telemetry/TelemetryEntity.ts +22 -22
  80. package/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.ts +16 -10
  81. package/Tests/App/Accounts/SignedInRedirect.test.tsx +241 -0
  82. package/Tests/App/AdminDashboard/AdminHeaderSmallScreens.test.tsx +221 -4
  83. package/Tests/App/Dashboard/AIInsightsResourcePageContext.test.ts +42 -0
  84. package/Tests/App/Dashboard/AffectedResourcesDisplay.test.tsx +100 -0
  85. package/Tests/App/Dashboard/AffectedResourcesPicker.test.tsx +86 -0
  86. package/Tests/App/Dashboard/AffectedResourcesPickerHiddenTypes.test.tsx +2 -0
  87. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +19 -0
  88. package/Tests/App/Dashboard/BreadcrumbCoverage.test.tsx +14 -0
  89. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  90. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  91. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  92. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  93. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  94. package/Tests/App/Dashboard/CorrelateFilterBuilder.test.tsx +26 -1
  95. package/Tests/App/Dashboard/DashboardAppSsoError.test.tsx +501 -0
  96. package/Tests/App/Dashboard/DashboardHeaderRequests.test.tsx +64 -1
  97. package/Tests/App/Dashboard/DashboardHeaderSmallScreens.test.tsx +178 -18
  98. package/Tests/App/Dashboard/DashboardMasterPageSsoRedirect.test.tsx +444 -0
  99. package/Tests/App/Dashboard/DashboardNavBarForeignHiddenRule.test.tsx +573 -0
  100. package/Tests/App/Dashboard/DatabaseChartAxes.test.tsx +431 -0
  101. package/Tests/App/Dashboard/DatabaseContainerDetailBadge.test.tsx +249 -0
  102. package/Tests/App/Dashboard/DatabaseDashboardE2EFixes.test.ts +681 -0
  103. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +1801 -0
  104. package/Tests/App/Dashboard/DatabaseFleetSummaryRefresh.test.tsx +280 -0
  105. package/Tests/App/Dashboard/DatabaseFormHelpers.test.tsx +321 -0
  106. package/Tests/App/Dashboard/DatabaseMetricMonitorLink.test.tsx +795 -0
  107. package/Tests/App/Dashboard/DatabaseOverviewHeader.test.tsx +580 -0
  108. package/Tests/App/Dashboard/DatabaseRecommendationsEngineMetrics.test.ts +270 -0
  109. package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +1486 -0
  110. package/Tests/App/Dashboard/DatabaseServerEngineMetricsFilter.test.ts +223 -0
  111. package/Tests/App/Dashboard/DatabaseServerPresentation.test.ts +435 -0
  112. package/Tests/App/Dashboard/DatabaseServerSummary.test.ts +186 -0
  113. package/Tests/App/Dashboard/DatabaseServerTelemetryQueries.test.ts +1553 -0
  114. package/Tests/App/Dashboard/DatabaseTableTruncation.test.tsx +326 -0
  115. package/Tests/App/Dashboard/DatabaseTelemetryScope.test.ts +512 -0
  116. package/Tests/App/Dashboard/DatabaseTelemetryTabs.test.tsx +774 -0
  117. package/Tests/App/Dashboard/DatabaseViewLayout.test.tsx +233 -0
  118. package/Tests/App/Dashboard/DatabaseWorkloadBadge.test.tsx +972 -0
  119. package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +5 -0
  120. package/Tests/App/Dashboard/DiscoveryReviewUnnamedHostHint.test.tsx +1061 -0
  121. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  122. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  123. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  124. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  125. package/Tests/App/Dashboard/EventOverlayScope.test.ts +147 -0
  126. package/Tests/App/Dashboard/EventOverlayScopeHook.test.tsx +1 -0
  127. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  128. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  129. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  130. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  131. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  132. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  133. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  134. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  135. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  136. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  137. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  138. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  139. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  140. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  141. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  142. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  143. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  144. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  145. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  146. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  147. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  148. package/Tests/App/Dashboard/LogsResourceFacetChips.test.tsx +1 -0
  149. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  150. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  151. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  152. package/Tests/App/Dashboard/MetricsViewerDrillDown.test.tsx +310 -0
  153. package/Tests/App/Dashboard/MetricsViewerEntityScopedRows.test.tsx +495 -0
  154. package/Tests/App/Dashboard/MonitorCreateFromMonitorBackedDevice.test.tsx +132 -1
  155. package/Tests/App/Dashboard/NavigationSearchKeywords.test.tsx +5 -0
  156. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  157. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  158. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  159. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  160. package/Tests/App/Dashboard/ProductOrientation.test.tsx +4 -3
  161. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  162. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  163. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  164. package/Tests/App/Dashboard/RecommendationsLoadedContext.test.tsx +349 -0
  165. package/Tests/App/Dashboard/ResourceArchiveSettings.test.tsx +62 -0
  166. package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +8 -1
  167. package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +14 -0
  168. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  169. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  170. package/Tests/App/Dashboard/ScheduledMaintenanceOverviewPage.test.tsx +2 -0
  171. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  172. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +1085 -0
  173. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  174. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  175. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  176. package/Tests/App/Dashboard/TelemetryResourceRetentionSettings.test.tsx +10 -0
  177. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  178. package/Tests/App/Dashboard/TracesResourceFacets.test.tsx +2 -1
  179. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  180. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  181. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  182. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  183. package/Tests/App/StatusPage/StatusPageHeaderForeignHiddenRule.test.tsx +284 -0
  184. package/Tests/ClassStrings.ts +178 -0
  185. package/Tests/Fixtures/MobilePasskeyUser.json +1 -2
  186. package/Tests/ForeignHiddenRuleGuard.ts +2152 -0
  187. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +2250 -0
  188. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  189. package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +24 -5
  190. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  191. package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
  192. package/Tests/RenderedMarkup.ts +518 -0
  193. package/Tests/ResponsiveVisibility.test.ts +490 -4
  194. package/Tests/ResponsiveVisibility.ts +505 -52
  195. package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
  196. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +541 -0
  197. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  198. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  199. package/Tests/Server/Infrastructure/Postgres/AddUserProjectSsoConsentMigration.test.ts +234 -0
  200. package/Tests/Server/Services/ContainerSnapshotUpsertPostgres.test.ts +319 -0
  201. package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +13 -0
  202. package/Tests/Server/Services/DatabaseServerEndpointService.test.ts +2033 -0
  203. package/Tests/Server/Services/DatabaseServerFeedAndOwnerServices.test.ts +529 -0
  204. package/Tests/Server/Services/DatabaseServerLifecycle.test.ts +3302 -0
  205. package/Tests/Server/Services/DatabaseServerRegistries.test.ts +603 -0
  206. package/Tests/Server/Services/DatabaseServerRuleEngines.test.ts +754 -0
  207. package/Tests/Server/Services/DatabaseServerService.test.ts +3558 -0
  208. package/Tests/Server/Services/DatabaseServerSqlPostgres.test.ts +2045 -0
  209. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedPostgres.test.ts +362 -0
  210. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedReply.test.ts +210 -0
  211. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +18 -0
  212. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  213. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  214. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  215. package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +2 -0
  216. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  217. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  218. package/Tests/Server/Services/IncomingCallPolicyEscalationRuleTarget.test.ts +225 -0
  219. package/Tests/Server/Services/InventoryItemDisplayName.test.ts +280 -2
  220. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +8 -2
  221. package/Tests/Server/Services/IoTDeviceLatestMetricsPostgres.test.ts +689 -0
  222. package/Tests/Server/Services/IoTDeviceService.test.ts +379 -0
  223. package/Tests/Server/Services/LatestMetricsValuesShape.test.ts +360 -0
  224. package/Tests/Server/Services/LlmModelPriceService.test.ts +402 -0
  225. package/Tests/Server/Services/OpenTelemetryResourceRetentionMemo.test.ts +7 -1
  226. package/Tests/Server/Services/OwnerRowUniqueness.test.ts +61 -9
  227. package/Tests/Server/Services/ReminderRuleLookupPostgres.test.ts +2250 -0
  228. package/Tests/Server/Services/ReminderRuleRefreshOpenSubjectsPaging.test.ts +653 -0
  229. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +68 -20
  230. package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +3 -3
  231. package/Tests/Server/Services/TelemetryResourceFacetCounting.test.ts +9 -0
  232. package/Tests/Server/Services/UserProjectSsoConsentPostgres.test.ts +153 -0
  233. package/Tests/Server/Services/UserProjectSsoConsentService.test.ts +192 -0
  234. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +71 -0
  235. package/Tests/Server/Services/UserSignupIdTakeoverPostgres.test.ts +238 -0
  236. package/Tests/Server/Services/WorkspaceNotificationSummaryHelpers.test.ts +592 -0
  237. package/Tests/Server/Types/Billing/PayAsYouGoMeteredAuthorization.test.ts +3 -0
  238. package/Tests/Server/Types/Database/EventOverlayScopePostgres.test.ts +1 -0
  239. package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +11 -0
  240. package/Tests/Server/Types/Database/Permissions/InfrastructureOwnershipScope.test.ts +8 -0
  241. package/Tests/Server/Types/Database/QueryHelperBooleanForCriteriaBackedRule.test.ts +1094 -0
  242. package/Tests/Server/Utils/AI/ResourceTools.test.ts +134 -0
  243. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +41 -0
  244. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +279 -9
  245. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaDisplayContext.test.ts +933 -0
  246. package/Tests/Server/Utils/Monitor/DatabaseMaintenanceSuppression.test.ts +582 -0
  247. package/Tests/Server/Utils/Monitor/DatabaseServerMonitorLinking.test.ts +753 -0
  248. package/Tests/Server/Utils/Monitor/MonitorActionTimestamps.test.ts +1 -0
  249. package/Tests/Server/Utils/Monitor/MonitorAlertCriteriaReferences.test.ts +1 -0
  250. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +119 -0
  251. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +60 -25
  252. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +1719 -0
  253. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesAffectedResources.test.ts +1447 -0
  254. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesRatioEmail.test.ts +352 -0
  255. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformMetricUnits.test.ts +717 -0
  256. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformRawScan.test.ts +2125 -0
  257. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformSeriesAffectedResources.test.ts +1288 -0
  258. package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +1 -0
  259. package/Tests/Server/Utils/Monitor/MonitorIncidentCriteriaReferences.test.ts +1 -0
  260. package/Tests/Server/Utils/Monitor/MonitorIncidentMemberRoleMembership.test.ts +1 -0
  261. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +88 -0
  262. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +205 -0
  263. package/Tests/Server/Utils/Monitor/MonitorResourceContext.test.ts +62 -0
  264. package/Tests/Server/Utils/Monitor/MonitorResourceDatabaseMaintenance.test.ts +803 -0
  265. package/Tests/Server/Utils/Monitor/MonitorStepResourceIdentity.test.ts +133 -3
  266. package/Tests/Server/Utils/Monitor/MonitorSummaryPersistence.test.ts +1 -0
  267. package/Tests/Server/Utils/Monitor/PerSeriesRecoveryResolution.test.ts +472 -1
  268. package/Tests/Server/Utils/Monitor/PerSeriesSecondEntityAlerting.test.ts +1 -0
  269. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +236 -2
  270. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +70 -0
  271. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  272. package/Tests/Server/Utils/StartServerProtobufContentType.test.ts +54 -0
  273. package/Tests/Server/Utils/Telemetry/DatabaseEndpointDiscovery.test.ts +1895 -0
  274. package/Tests/Server/Utils/Telemetry/EntityRegistry.test.ts +7 -0
  275. package/Tests/Server/Utils/Telemetry/LogExceptionExtractorSeverityGate.test.ts +90 -0
  276. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +570 -0
  277. package/Tests/Server/Utils/Telemetry/ResourceFacetResolver.test.ts +97 -2
  278. package/Tests/Server/Utils/Telemetry/ServiceDependencyDiscovery.test.ts +1337 -0
  279. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +295 -0
  280. package/Tests/Types/AI/AIResourceContext.test.ts +30 -0
  281. package/Tests/Types/DatabaseServer/DatabaseContainerClassifier.test.ts +3644 -0
  282. package/Tests/Types/DatabaseServer/DatabaseContainerCommand.test.ts +340 -0
  283. package/Tests/Types/DatabaseServer/DatabaseContainerCommandCorpus.ts +826 -0
  284. package/Tests/Types/DatabaseServer/DatabaseContainerCommandPostgres.test.ts +150 -0
  285. package/Tests/Types/DatabaseServer/DatabaseEndpoint.test.ts +2657 -0
  286. package/Tests/Types/DatabaseServer/DatabaseServerDiscoverySource.test.ts +64 -0
  287. package/Tests/Types/DatabaseServer/DatabaseServerEntityKeys.test.ts +952 -0
  288. package/Tests/Types/DatabaseServer/DatabaseServerMetricCatalog.test.ts +1161 -0
  289. package/Tests/Types/DatabaseServer/DatabaseSystem.test.ts +1514 -0
  290. package/Tests/Types/DatabaseServer/DatabaseTelemetryResolver.test.ts +1558 -0
  291. package/Tests/Types/Monitor/CephAlertTemplates.test.ts +255 -6
  292. package/Tests/Types/Monitor/DatabaseAlertTemplates.test.ts +2254 -0
  293. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +298 -0
  294. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +271 -0
  295. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +253 -9
  296. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCoverage.test.ts +13 -5
  297. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +4 -2
  298. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +111 -2
  299. package/Tests/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.test.ts +61 -0
  300. package/Tests/Types/Monitor/SeriesContext/SeriesLabelDisplay.test.ts +114 -0
  301. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +181 -0
  302. package/Tests/Types/NetworkDevice/DiscoveredHostNamingStatus.test.ts +401 -0
  303. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  304. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +4 -1
  305. package/Tests/Types/Telemetry/ResourceFacetCatalog.test.ts +24 -2
  306. package/Tests/UI/Components/Card.test.tsx +93 -2
  307. package/Tests/UI/Components/FeedOptions.test.ts +6 -0
  308. package/Tests/UI/Components/FeedOptionsButton.test.tsx +8 -1
  309. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  310. package/Tests/UI/Components/ForeignHiddenRuleResilience.test.tsx +1147 -0
  311. package/Tests/UI/Components/HeaderRightRail.test.tsx +41 -1
  312. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  313. package/Tests/UI/Components/LogsEntityNames.test.ts +16 -1
  314. package/Tests/UI/Components/LogsFacetSidebarHiddenFacets.test.tsx +7 -3
  315. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  316. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  317. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  318. package/Tests/UI/Components/ResourceFacetConfigs.test.ts +2 -1
  319. package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
  320. package/Tests/UI/Components/SrOnlyForeignRule.test.tsx +994 -0
  321. package/Tests/UI/Components/StatusPage/ResourceListSelection.test.tsx +34 -1
  322. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  323. package/Tests/UI/Components/TelemetryFacetSidebarHiddenFacets.test.tsx +4 -1
  324. package/Tests/UI/ForeignHiddenRuleGuard.test.ts +1951 -0
  325. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  326. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  327. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  328. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  329. package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +19 -1
  330. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  331. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  332. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  333. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  334. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  335. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  336. package/Tests/UI/Rum/ReplayTabSwitcher.test.tsx +19 -1
  337. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  338. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  339. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  340. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  341. package/Tests/UI/TailwindPlayCdnForeignHiddenRule.test.ts +1005 -0
  342. package/Tests/UI/ThemeGroupHoverReveal.test.ts +847 -0
  343. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +80 -0
  344. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +23 -0
  345. package/Tests/UI/Utils/ModelAPISsoRedirect.test.ts +127 -0
  346. package/Tests/UI/Utils/Navigation.test.ts +102 -0
  347. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  348. package/Tests/UI/Utils/Telemetry/TelemetryEntityNames.test.ts +23 -0
  349. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  350. package/Tests/Utils/LabelRuleImportExport.test.ts +5 -0
  351. package/Tests/Utils/MetricUnitUtil.test.ts +227 -2
  352. package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +288 -2
  353. package/Tests/Utils/Monitor/PlatformResourceIdentityAndUnits.test.ts +1559 -0
  354. package/Tests/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.test.ts +2546 -0
  355. package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +315 -0
  356. package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +336 -1
  357. package/Tests/Utils/NetworkDiscovery/NetbiosNameUtil.test.ts +244 -0
  358. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  359. package/Tests/Utils/ValueFormatter.test.ts +50 -0
  360. package/Tests/tsconfig.json +16 -0
  361. package/Types/AI/AIResourceContext.ts +7 -0
  362. package/Types/Dashboard/DashboardTemplates.ts +2 -1
  363. package/Types/DatabaseServer/DatabaseConnectionSpan.ts +77 -0
  364. package/Types/DatabaseServer/DatabaseContainerClassifier.ts +2068 -0
  365. package/Types/DatabaseServer/DatabaseContainerCommand.ts +801 -0
  366. package/Types/DatabaseServer/DatabaseEndpoint.ts +1943 -0
  367. package/Types/DatabaseServer/DatabaseServerDiscoverySource.ts +68 -0
  368. package/Types/DatabaseServer/DatabaseServerMetricCatalog.ts +966 -0
  369. package/Types/DatabaseServer/DatabaseSystem.ts +2124 -0
  370. package/Types/DatabaseServer/DatabaseTelemetryResolver.ts +555 -0
  371. package/Types/Email/EmailTemplateType.ts +1 -0
  372. package/Types/Incident/IncidentAlertLink.ts +38 -0
  373. package/Types/Monitor/CephAlertTemplates.ts +18 -0
  374. package/Types/Monitor/DatabaseAlertTemplates.ts +2020 -0
  375. package/Types/Monitor/KubernetesAlertTemplates.ts +64 -24
  376. package/Types/Monitor/KubernetesMetricCatalog.ts +118 -19
  377. package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +14 -0
  378. package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +93 -10
  379. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +109 -8
  380. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +36 -7
  381. package/Types/Monitor/Recommendation/MonitorRecommendationUtil.ts +43 -2
  382. package/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.ts +41 -6
  383. package/Types/Monitor/SeriesContext/SeriesLabelDisplay.ts +26 -0
  384. package/Types/NetworkDevice/DiscoveredHostNamingStatus.ts +147 -0
  385. package/Types/Permission.ts +315 -0
  386. package/Types/Probe/ProbeApiIngestResponse.ts +8 -0
  387. package/Types/Rules/RuleCriteriaFieldRegistry.ts +10 -0
  388. package/Types/Rules/RuleRun.ts +12 -0
  389. package/Types/Rum/SessionReplay.ts +25 -6
  390. package/Types/Rum/WebVitals.ts +52 -0
  391. package/Types/Telemetry/EntityType.ts +23 -0
  392. package/Types/Telemetry/ResourceFacetCatalog.ts +14 -0
  393. package/Types/Telemetry/ServiceType.ts +8 -0
  394. package/UI/Components/Banner/Banner.tsx +1 -1
  395. package/UI/Components/Card/Card.tsx +1 -1
  396. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +2 -2
  397. package/UI/Components/Charts/Line/LineChart.tsx +7 -0
  398. package/UI/Components/CommandPalette/CommandPalette.tsx +1 -1
  399. package/UI/Components/Date/StartAndEndDate.tsx +5 -5
  400. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  401. package/UI/Components/Diagram/HorizontalStepChain.tsx +1 -1
  402. package/UI/Components/Feed/FeedOptionsButton.tsx +1 -1
  403. package/UI/Components/Forms/BasicForm.tsx +1 -1
  404. package/UI/Components/Header/Header.tsx +1 -1
  405. package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -1
  406. package/UI/Components/HeaderAlert/HeaderAlert.tsx +1 -1
  407. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  408. package/UI/Components/LogsViewer/components/FacetValueRow.tsx +1 -1
  409. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +1 -1
  410. package/UI/Components/Modal/ModalFooter.tsx +1 -1
  411. package/UI/Components/ModelDetail/CardModelDetail.tsx +2 -2
  412. package/UI/Components/ModelTable/BaseModelTable.tsx +99 -21
  413. package/UI/Components/ModelTable/Column.ts +5 -0
  414. package/UI/Components/Navbar/NavBar.tsx +9 -2
  415. package/UI/Components/Navbar/NavBarMenuModal.tsx +2 -2
  416. package/UI/Components/Page/Page.tsx +1 -1
  417. package/UI/Components/Pagination/Pagination.tsx +3 -3
  418. package/UI/Components/ProgressButtons/ProgressButtonItem.tsx +1 -1
  419. package/UI/Components/SideMenu/SideMenu.tsx +4 -1
  420. package/UI/Components/StatusPage/ResourceGroupNavigator.tsx +4 -2
  421. package/UI/Components/StatusPage/ResourceList.tsx +6 -6
  422. package/UI/Components/Table/TableHeader.tsx +47 -2
  423. package/UI/Components/Table/Types/Column.ts +7 -0
  424. package/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.tsx +1 -1
  425. package/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.tsx +1 -1
  426. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  427. package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +1 -1
  428. package/UI/Components/Workflow/ComponentsModal.tsx +2 -2
  429. package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
  430. package/UI/Styles/Theme.css +8 -1
  431. package/UI/Utils/Navigation.ts +89 -42
  432. package/UI/Utils/PermissionGate.ts +11 -2
  433. package/UI/Utils/Telemetry/TelemetryEntityNames.ts +7 -0
  434. package/UI/Utils/TelemetryService.ts +4 -3
  435. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  436. package/Utils/LabelRuleImportExport.ts +2 -0
  437. package/Utils/MetricUnitUtil.ts +103 -11
  438. package/Utils/Monitor/MetricValueFormatter.ts +66 -13
  439. package/Utils/Monitor/PlatformMetricUnitUtil.ts +215 -0
  440. package/Utils/Monitor/PlatformResourceIdentity.ts +514 -0
  441. package/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.ts +425 -0
  442. package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +32 -0
  443. package/Utils/NetworkDiscovery/NetbiosNameUtil.ts +73 -0
  444. package/Utils/Rum/SessionTraceState.ts +212 -0
  445. package/Utils/Telemetry/DatabaseServerEntityKeys.ts +275 -0
  446. package/Utils/Telemetry/EntityKey.ts +176 -0
  447. package/Utils/ValueFormatter.ts +16 -0
  448. package/build/dist/Models/DatabaseModels/Alert.js +54 -0
  449. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  450. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  451. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  452. package/build/dist/Models/DatabaseModels/DatabaseServer.js +1683 -0
  453. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -0
  454. package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js +559 -0
  455. package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js.map +1 -0
  456. package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js +648 -0
  457. package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js.map +1 -0
  458. package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js +522 -0
  459. package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js.map +1 -0
  460. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js +603 -0
  461. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js.map +1 -0
  462. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js +506 -0
  463. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js.map +1 -0
  464. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js +505 -0
  465. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js.map +1 -0
  466. package/build/dist/Models/DatabaseModels/Incident.js +54 -0
  467. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  468. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  469. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  470. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  471. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  472. package/build/dist/Models/DatabaseModels/Index.js +18 -0
  473. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  474. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  475. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  476. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  477. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  478. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  479. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +54 -0
  480. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  481. package/build/dist/Models/DatabaseModels/TelemetryException.js +2 -0
  482. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  483. package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js +162 -0
  484. package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js.map +1 -0
  485. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  486. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  487. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  488. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  489. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js +240 -0
  490. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js.map +1 -0
  491. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js +26 -0
  492. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js.map +1 -0
  493. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  494. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  495. package/build/dist/Server/Middleware/MultipartFormData.js +1 -1
  496. package/build/dist/Server/Middleware/TelemetryIngest.js +15 -8
  497. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  498. package/build/dist/Server/Services/AlertReminderRuleService.js +72 -23
  499. package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
  500. package/build/dist/Server/Services/DatabaseServerEndpointService.js +694 -0
  501. package/build/dist/Server/Services/DatabaseServerEndpointService.js.map +1 -0
  502. package/build/dist/Server/Services/DatabaseServerFeedService.js +110 -0
  503. package/build/dist/Server/Services/DatabaseServerFeedService.js.map +1 -0
  504. package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js +272 -0
  505. package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js.map +1 -0
  506. package/build/dist/Server/Services/DatabaseServerLabelRuleService.js +13 -0
  507. package/build/dist/Server/Services/DatabaseServerLabelRuleService.js.map +1 -0
  508. package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js +340 -0
  509. package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js.map +1 -0
  510. package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js +13 -0
  511. package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js.map +1 -0
  512. package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js +160 -0
  513. package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js.map +1 -0
  514. package/build/dist/Server/Services/DatabaseServerOwnerUserService.js +153 -0
  515. package/build/dist/Server/Services/DatabaseServerOwnerUserService.js.map +1 -0
  516. package/build/dist/Server/Services/DatabaseServerService.js +3272 -0
  517. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -0
  518. package/build/dist/Server/Services/DatabaseService.js +8 -4
  519. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  520. package/build/dist/Server/Services/DockerResourceService.js +30 -3
  521. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  522. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  523. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  524. package/build/dist/Server/Services/IncidentReminderRuleService.js +73 -23
  525. package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
  526. package/build/dist/Server/Services/IncidentService.js +164 -12
  527. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  528. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  529. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  530. package/build/dist/Server/Services/Index.js +18 -0
  531. package/build/dist/Server/Services/Index.js.map +1 -1
  532. package/build/dist/Server/Services/InventoryItemService.js +88 -5
  533. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  534. package/build/dist/Server/Services/IoTDeviceService.js +111 -24
  535. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  536. package/build/dist/Server/Services/OpenTelemetryIngestService.js +24 -9
  537. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  538. package/build/dist/Server/Services/PodmanResourceService.js +30 -3
  539. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  540. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +75 -23
  541. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
  542. package/build/dist/Server/Services/ServiceService.js +1 -1
  543. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  544. package/build/dist/Server/Services/TelemetryUsageBillingService.js +23 -2
  545. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  546. package/build/dist/Server/Services/UserProjectSsoConsentService.js +79 -0
  547. package/build/dist/Server/Services/UserProjectSsoConsentService.js.map +1 -0
  548. package/build/dist/Server/Services/UserService.js +13 -1
  549. package/build/dist/Server/Services/UserService.js.map +1 -1
  550. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js +18 -0
  551. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js.map +1 -1
  552. package/build/dist/Server/Types/Database/QueryHelper.js +26 -1
  553. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  554. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +46 -0
  555. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  556. package/build/dist/Server/Utils/Database/AffectedResourceRelations.js +6 -0
  557. package/build/dist/Server/Utils/Database/AffectedResourceRelations.js.map +1 -1
  558. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +134 -13
  559. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  560. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -6
  561. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  562. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1489 -524
  563. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  564. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +22 -6
  565. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  566. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +196 -6
  567. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  568. package/build/dist/Server/Utils/Monitor/MonitorResource.js +36 -9
  569. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  570. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js +1 -0
  571. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js.map +1 -1
  572. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js +175 -17
  573. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js.map +1 -1
  574. package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js +29 -0
  575. package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js.map +1 -0
  576. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +211 -0
  577. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  578. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +82 -10
  579. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -1
  580. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +25 -0
  581. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
  582. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  583. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  584. package/build/dist/Server/Utils/StartServer.js +20 -4
  585. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  586. package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js +2 -0
  587. package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js.map +1 -1
  588. package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js +442 -0
  589. package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js.map +1 -0
  590. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +5 -1
  591. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  592. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +15 -1
  593. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
  594. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +121 -5
  595. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -1
  596. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js +12 -0
  597. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js.map +1 -1
  598. package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js +438 -36
  599. package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js.map +1 -1
  600. package/build/dist/Server/Utils/Telemetry/Telemetry.js +12 -0
  601. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  602. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +17 -18
  603. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  604. package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js +16 -10
  605. package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js.map +1 -1
  606. package/build/dist/Types/AI/AIResourceContext.js +7 -0
  607. package/build/dist/Types/AI/AIResourceContext.js.map +1 -1
  608. package/build/dist/Types/Dashboard/DashboardTemplates.js +2 -1
  609. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  610. package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js +71 -0
  611. package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js.map +1 -0
  612. package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js +1462 -0
  613. package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js.map +1 -0
  614. package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js +691 -0
  615. package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js.map +1 -0
  616. package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js +1407 -0
  617. package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js.map +1 -0
  618. package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js +60 -0
  619. package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js.map +1 -0
  620. package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js +804 -0
  621. package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js.map +1 -0
  622. package/build/dist/Types/DatabaseServer/DatabaseSystem.js +1860 -0
  623. package/build/dist/Types/DatabaseServer/DatabaseSystem.js.map +1 -0
  624. package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js +406 -0
  625. package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js.map +1 -0
  626. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  627. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  628. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  629. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  630. package/build/dist/Types/Monitor/CephAlertTemplates.js +18 -0
  631. package/build/dist/Types/Monitor/CephAlertTemplates.js.map +1 -1
  632. package/build/dist/Types/Monitor/DatabaseAlertTemplates.js +1518 -0
  633. package/build/dist/Types/Monitor/DatabaseAlertTemplates.js.map +1 -0
  634. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js +57 -24
  635. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js.map +1 -1
  636. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js +104 -18
  637. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js.map +1 -1
  638. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +80 -3
  639. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  640. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +10 -3
  641. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  642. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js +29 -0
  643. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js.map +1 -1
  644. package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js +21 -6
  645. package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js.map +1 -1
  646. package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js +25 -0
  647. package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js.map +1 -1
  648. package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js +136 -0
  649. package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js.map +1 -0
  650. package/build/dist/Types/Permission.js +276 -0
  651. package/build/dist/Types/Permission.js.map +1 -1
  652. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +10 -0
  653. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
  654. package/build/dist/Types/Rules/RuleRun.js +4 -0
  655. package/build/dist/Types/Rules/RuleRun.js.map +1 -1
  656. package/build/dist/Types/Rum/WebVitals.js +25 -0
  657. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  658. package/build/dist/Types/Telemetry/EntityType.js +23 -0
  659. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  660. package/build/dist/Types/Telemetry/ResourceFacetCatalog.js +14 -0
  661. package/build/dist/Types/Telemetry/ResourceFacetCatalog.js.map +1 -1
  662. package/build/dist/Types/Telemetry/ServiceType.js +8 -0
  663. package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
  664. package/build/dist/UI/Components/Banner/Banner.js +1 -1
  665. package/build/dist/UI/Components/Banner/Banner.js.map +1 -1
  666. package/build/dist/UI/Components/Card/Card.js +1 -1
  667. package/build/dist/UI/Components/Card/Card.js.map +1 -1
  668. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +2 -2
  669. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  670. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  671. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  672. package/build/dist/UI/Components/CommandPalette/CommandPalette.js +1 -1
  673. package/build/dist/UI/Components/CommandPalette/CommandPalette.js.map +1 -1
  674. package/build/dist/UI/Components/Date/StartAndEndDate.js +5 -5
  675. package/build/dist/UI/Components/Date/StartAndEndDate.js.map +1 -1
  676. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  677. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  678. package/build/dist/UI/Components/Diagram/HorizontalStepChain.js +1 -1
  679. package/build/dist/UI/Components/Diagram/HorizontalStepChain.js.map +1 -1
  680. package/build/dist/UI/Components/Feed/FeedOptionsButton.js +1 -1
  681. package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -1
  682. package/build/dist/UI/Components/Forms/BasicForm.js +1 -1
  683. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  684. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -1
  685. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  686. package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js +1 -1
  687. package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js.map +1 -1
  688. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  689. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  690. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js +1 -1
  691. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js.map +1 -1
  692. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +1 -1
  693. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  694. package/build/dist/UI/Components/Modal/ModalFooter.js +1 -1
  695. package/build/dist/UI/Components/Modal/ModalFooter.js.map +1 -1
  696. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +2 -2
  697. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
  698. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +70 -18
  699. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  700. package/build/dist/UI/Components/Navbar/NavBar.js +9 -2
  701. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  702. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
  703. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  704. package/build/dist/UI/Components/Page/Page.js +1 -1
  705. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  706. package/build/dist/UI/Components/Pagination/Pagination.js +3 -3
  707. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  708. package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js +1 -1
  709. package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js.map +1 -1
  710. package/build/dist/UI/Components/SideMenu/SideMenu.js +4 -1
  711. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  712. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js +3 -1
  713. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js.map +1 -1
  714. package/build/dist/UI/Components/StatusPage/ResourceList.js +6 -6
  715. package/build/dist/UI/Components/StatusPage/ResourceList.js.map +1 -1
  716. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  717. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  718. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js +1 -1
  719. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js.map +1 -1
  720. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js +1 -1
  721. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js.map +1 -1
  722. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  723. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  724. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +1 -1
  725. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -1
  726. package/build/dist/UI/Components/Workflow/ComponentsModal.js +2 -2
  727. package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
  728. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
  729. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
  730. package/build/dist/UI/Utils/Navigation.js +66 -33
  731. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  732. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  733. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  734. package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js +7 -0
  735. package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js.map +1 -1
  736. package/build/dist/UI/Utils/TelemetryService.js +3 -2
  737. package/build/dist/UI/Utils/TelemetryService.js.map +1 -1
  738. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  739. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  740. package/build/dist/Utils/LabelRuleImportExport.js +2 -0
  741. package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
  742. package/build/dist/Utils/MetricUnitUtil.js +77 -13
  743. package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
  744. package/build/dist/Utils/Monitor/MetricValueFormatter.js +58 -13
  745. package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -1
  746. package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js +161 -0
  747. package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js.map +1 -0
  748. package/build/dist/Utils/Monitor/PlatformResourceIdentity.js +323 -0
  749. package/build/dist/Utils/Monitor/PlatformResourceIdentity.js.map +1 -0
  750. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js +270 -0
  751. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js.map +1 -0
  752. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +23 -0
  753. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
  754. package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js +61 -0
  755. package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js.map +1 -1
  756. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  757. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  758. package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js +178 -0
  759. package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js.map +1 -0
  760. package/build/dist/Utils/Telemetry/EntityKey.js +133 -0
  761. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  762. package/build/dist/Utils/ValueFormatter.js +15 -0
  763. package/build/dist/Utils/ValueFormatter.js.map +1 -1
  764. package/package.json +1 -1
  765. package/tsconfig.tests.json +7 -5
@@ -0,0 +1,3644 @@
1
+ import {
2
+ attachPoolerServices,
3
+ classifyContainer,
4
+ classifyImage,
5
+ classifyKubernetesPod,
6
+ classifyKubernetesPoolerPod,
7
+ ContainerDatabaseClassification,
8
+ ContainerWorkloadKind,
9
+ DATABASE_OPERATOR_LABEL_KEYS,
10
+ DATABASE_WORKLOAD_NAME_LABEL_VALUES,
11
+ groupKubernetesDatabaseCandidates,
12
+ hasDatabaseWorkloadLabels,
13
+ ImageClassification,
14
+ KubernetesDatabaseCandidate,
15
+ KubernetesDatabaseGroup,
16
+ KubernetesPodLike,
17
+ KubernetesPoolerService,
18
+ normalizeImageRepository,
19
+ parseImageVersion,
20
+ pickMostSpecificDatabaseSystem,
21
+ } from "../../../Types/DatabaseServer/DatabaseContainerClassifier";
22
+ import {
23
+ ContainerCommandRole,
24
+ classifyContainerCommand,
25
+ isNonServerCommand,
26
+ } from "../../../Types/DatabaseServer/DatabaseContainerCommand";
27
+ import { buildWorkloadDatabaseServerIdentifier } from "../../../Types/DatabaseServer/DatabaseEndpoint";
28
+ import { describe, expect, test } from "@jest/globals";
29
+
30
+ type ContainerFixture = {
31
+ name: string;
32
+ image: string;
33
+ ports?: Array<{ containerPort?: number }>;
34
+ command?: Array<string | null>;
35
+ args?: Array<string>;
36
+ };
37
+
38
+ function pod(data: {
39
+ name: string;
40
+ namespace?: string;
41
+ phase?: string | null;
42
+ labels?: Record<string, unknown>;
43
+ owner?: { kind: string; name: string } | null;
44
+ containers: Array<ContainerFixture>;
45
+ }): KubernetesPodLike {
46
+ return {
47
+ namespaceKey: data.namespace ?? "data",
48
+ name: data.name,
49
+ phase: data.phase === undefined ? "Running" : data.phase,
50
+ labels: data.labels ?? {},
51
+ ownerReferences: data.owner ? { items: [data.owner] } : { items: [] },
52
+ spec: { containers: data.containers },
53
+ };
54
+ }
55
+
56
+ function port(containerPort: number): { containerPort: number } {
57
+ return { containerPort };
58
+ }
59
+
60
+ describe("normalizeImageRepository", () => {
61
+ test.each([
62
+ ["postgres", "postgres"],
63
+ ["postgres:16", "postgres"],
64
+ ["docker.io/library/postgres:16.2-alpine", "postgres"],
65
+ ["library/postgres", "postgres"],
66
+ [
67
+ "ghcr.io/cloudnative-pg/postgresql:16.2@sha256:0123abcd",
68
+ "cloudnative-pg/postgresql",
69
+ ],
70
+ [
71
+ "registry.example.com:5000/mirror/bitnami/postgresql:16",
72
+ "mirror/bitnami/postgresql",
73
+ ],
74
+ ["registry:5000/postgres", "postgres"],
75
+ ["localhost/redis:7", "redis"],
76
+ ["localhost:5000/redis", "redis"],
77
+ [" MCR.Microsoft.com/MSSQL/Server:2022-latest ", "mssql/server"],
78
+ ["postgres@sha256:abcdef0123", "postgres"],
79
+ ])("%s → %s", (image: string, repository: string) => {
80
+ expect(normalizeImageRepository(image)).toBe(repository);
81
+ });
82
+
83
+ test("bare image ids and empty values have no repository", () => {
84
+ expect(normalizeImageRepository(`sha256:${"a".repeat(64)}`)).toBeNull();
85
+ expect(normalizeImageRepository("0123456789ab")).toBeNull();
86
+ expect(normalizeImageRepository("")).toBeNull();
87
+ expect(normalizeImageRepository(" ")).toBeNull();
88
+ expect(normalizeImageRepository(null)).toBeNull();
89
+ expect(normalizeImageRepository(42)).toBeNull();
90
+ expect(normalizeImageRepository("bad image name!")).toBeNull();
91
+ });
92
+ });
93
+
94
+ describe("parseImageVersion", () => {
95
+ test.each([
96
+ ["postgres:16.2-alpine", "16.2"],
97
+ ["bitnami/postgresql:16.2.0-debian-12-r5", "16.2.0"],
98
+ ["ghcr.io/cloudnative-pg/postgresql:16.2-12", "16.2"],
99
+ ["mysql:v8.0.36", "8.0.36"],
100
+ ["redis:7.2.4-alpine3.19", "7.2.4"],
101
+ ["mongo:7.0.5-jammy", "7.0.5"],
102
+ ["mcr.microsoft.com/mssql/server:2022-latest", "2022"],
103
+ ["registry:5000/postgres:16", "16"],
104
+ ["ghcr.io/zalando/spilo-16:3.2-p2", "16"],
105
+ ["percona/percona-postgresql-operator:2.3.1-ppg16-postgres", "16"],
106
+ ["percona/percona-postgresql-operator:2.4.0-ppg16.3-postgres", "16.3"],
107
+ ])("%s → %s", (image: string, version: string) => {
108
+ expect(parseImageVersion(image)).toBe(version);
109
+ });
110
+
111
+ test.each([
112
+ ["postgres:latest"],
113
+ ["postgres"],
114
+ ["postgres@sha256:abcdef0123"],
115
+ ["postgres:bookworm"],
116
+ [""],
117
+ ])("%s → null", (image: string) => {
118
+ expect(parseImageVersion(image)).toBeNull();
119
+ });
120
+
121
+ test("non-strings are null", () => {
122
+ expect(parseImageVersion(null)).toBeNull();
123
+ expect(parseImageVersion(undefined)).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe("classifyImage — databases (exact repository or basename)", () => {
128
+ test.each([
129
+ ["postgres:16", "postgresql"],
130
+ ["docker.io/library/postgres:16.2-alpine", "postgresql"],
131
+ ["postgis/postgis:16-3.4", "postgresql"],
132
+ ["timescale/timescaledb-ha:pg16", "postgresql"],
133
+ ["pgvector/pgvector:pg16", "postgresql"],
134
+ ["bitnami/postgresql:16", "postgresql"],
135
+ ["registry.example.com/mirror/bitnami/postgresql:16", "postgresql"],
136
+ ["ghcr.io/cloudnative-pg/postgresql:16.2", "postgresql"],
137
+ [
138
+ "registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.2-0",
139
+ "postgresql",
140
+ ],
141
+ ["ghcr.io/zalando/spilo-16:3.2-p2", "postgresql"],
142
+ ["registry.opensource.zalan.do/acid/spilo-15:3.0-p1", "postgresql"],
143
+ ["mysql:8.0", "mysql"],
144
+ ["mysql/mysql-server:8.0", "mysql"],
145
+ ["mariadb:11", "mariadb"],
146
+ ["bitnami/mariadb-galera:11", "mariadb"],
147
+ ["percona/percona-xtradb-cluster:8.0", "mysql"],
148
+ ["redis:7", "redis"],
149
+ ["redis/redis-stack-server:7.2.0-v10", "redis"],
150
+ ["valkey/valkey:8", "valkey"],
151
+ ["docker.dragonflydb.io/dragonflydb/dragonfly:v1.16", "dragonfly"],
152
+ ["eqalpha/keydb", "keydb"],
153
+ ["mongo:7", "mongodb"],
154
+ ["mongodb/mongodb-community-server:7.0-ubi8", "mongodb"],
155
+ ["percona/percona-server-mongodb:7.0", "mongodb"],
156
+ ["mcr.microsoft.com/mssql/server:2022-latest", "microsoft.sql_server"],
157
+ ["mcr.microsoft.com/mssql/rhel/server:2022-latest", "microsoft.sql_server"],
158
+ ["gvenzl/oracle-free:23-slim", "oracle.db"],
159
+ ["container-registry.oracle.com/database/free:23.4", "oracle.db"],
160
+ ["container-registry.oracle.com/database/enterprise:21.3.0.0", "oracle.db"],
161
+ ["docker.elastic.co/elasticsearch/elasticsearch:8.13.0", "elasticsearch"],
162
+ ["opensearchproject/opensearch:2", "opensearch"],
163
+ ["memcached:1.6", "memcached"],
164
+ ["couchdb:3", "couchdb"],
165
+ ["cassandra:5", "cassandra"],
166
+ ["scylladb/scylla:5.4", "scylladb"],
167
+ ["k8ssandra/cass-management-api:4.1", "cassandra"],
168
+ ["clickhouse/clickhouse-server:24.3", "clickhouse"],
169
+ ["altinity/clickhouse-server:23.8", "clickhouse"],
170
+ ["cockroachdb/cockroach:v23.2", "cockroachdb"],
171
+ ["neo4j:5", "neo4j"],
172
+ ["influxdb:2.7", "influxdb"],
173
+ ["couchbase/server:7.6", "couchbase"],
174
+ ])("%s is %s", (image: string, system: string) => {
175
+ expect(classifyImage(image)).toEqual({ kind: "database", system });
176
+ });
177
+
178
+ test("never a substring match", () => {
179
+ for (const image of [
180
+ "acme/redis-cache-warmer:1",
181
+ "acme/postgres-migrations:2",
182
+ "acme/mongoose-api",
183
+ "enterprise:1",
184
+ "free:1",
185
+ "cockroach:1",
186
+ "mypostgres",
187
+ "postgresql-client",
188
+ ]) {
189
+ expect(classifyImage(image)).toEqual({ kind: "unknown" });
190
+ }
191
+ });
192
+
193
+ test("untagged / id-only / empty images are unknown", () => {
194
+ expect(classifyImage(`sha256:${"b".repeat(64)}`)).toEqual({
195
+ kind: "unknown",
196
+ });
197
+ expect(classifyImage("")).toEqual({ kind: "unknown" });
198
+ expect(classifyImage(null)).toEqual({ kind: "unknown" });
199
+ });
200
+ });
201
+
202
+ describe("classifyImage — exclusions", () => {
203
+ test.each([
204
+ ["prometheuscommunity/postgres-exporter:v0.15.0", "exporter"],
205
+ ["quay.io/prometheuscommunity/postgres-exporter", "exporter"],
206
+ ["bitnami/postgres-exporter:0.15", "exporter"],
207
+ ["wrouesnel/postgres_exporter", "exporter"],
208
+ ["oliver006/redis_exporter:v1.58", "exporter"],
209
+ ["bitnami/redis-exporter", "exporter"],
210
+ ["prom/mysqld-exporter", "exporter"],
211
+ ["percona/mongodb_exporter:0.40", "exporter"],
212
+ ["acme/anything-exporter", "exporter"],
213
+ ["ghcr.io/cloudnative-pg/cloudnative-pg:1.22", "operator"],
214
+ ["registry.opensource.zalan.do/acid/postgres-operator:v1.10", "operator"],
215
+ [
216
+ "registry.developers.crunchydata.com/crunchydata/postgres-operator:ubi8-5.5",
217
+ "operator",
218
+ ],
219
+ ["percona/percona-xtradb-cluster-operator:1.14.0", "operator"],
220
+ ["percona/percona-server-mongodb-operator:1.16", "operator"],
221
+ ["docker.elastic.co/eck/eck-operator:2.12", "operator"],
222
+ ["altinity/clickhouse-operator:0.23", "operator"],
223
+ ["k8ssandra/cass-operator:v1.19", "operator"],
224
+ ["quay.io/opstree/redis-operator", "operator"],
225
+ ["dpage/pgadmin4:8", "admin-ui"],
226
+ ["phpmyadmin:5", "admin-ui"],
227
+ ["adminer", "admin-ui"],
228
+ ["mongo-express", "admin-ui"],
229
+ ["redis/redisinsight:2", "admin-ui"],
230
+ ["rediscommander/redis-commander", "admin-ui"],
231
+ ["docker.elastic.co/kibana/kibana:8.13.0", "admin-ui"],
232
+ ["bitnami/pgbouncer:1.22", "pooler"],
233
+ ["edoburu/pgbouncer", "pooler"],
234
+ ["ghcr.io/cloudnative-pg/pgbouncer:1.22", "pooler"],
235
+ [
236
+ "registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi8-1.21",
237
+ "pooler",
238
+ ],
239
+ ["bitnami/pgpool:4", "pooler"],
240
+ ["proxysql/proxysql:2.6", "pooler"],
241
+ ["percona/haproxy:2.8", "pooler"],
242
+ ["haproxy:2.9", "pooler"],
243
+ ["mysql/mysql-router:8.0", "pooler"],
244
+ ["gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.8", "pooler"],
245
+ ["gcr.io/cloudsql-docker/gce-proxy:1.33", "pooler"],
246
+ ["pgbackrest/pgbackrest", "backup"],
247
+ [
248
+ "registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi8-2.49",
249
+ "backup",
250
+ ],
251
+ ["bitnami/kubectl:1.29", "backup"],
252
+ ["wal-g/wal-g", "backup"],
253
+ ["percona/percona-xtrabackup:8.0", "backup"],
254
+ ["bitnami/redis-sentinel:7", "companion"],
255
+ ["quay.io/mongodb/mongodb-agent-ubi:107.0", "companion"],
256
+ ["k8ssandra/system-logger:v1.19", "companion"],
257
+ ])("%s is excluded (%s)", (image: string, reason: string) => {
258
+ expect(classifyImage(image)).toEqual({ kind: "excluded", reason });
259
+ });
260
+ });
261
+
262
+ describe("classifyImage — infrastructure sidecars and unknowns", () => {
263
+ test.each([
264
+ ["docker.io/istio/proxyv2:1.20.0"],
265
+ ["cr.l5d.io/linkerd/proxy:stable-2.14"],
266
+ ["envoyproxy/envoy:v1.29"],
267
+ ["hashicorp/vault:1.15"],
268
+ ["fluent/fluent-bit:3.0"],
269
+ ["fluentd:v1.16"],
270
+ ["docker.elastic.co/beats/filebeat:8.13"],
271
+ ["grafana/promtail:2.9"],
272
+ ["otel/opentelemetry-collector-contrib:0.100.0"],
273
+ ["busybox:1.36"],
274
+ ["gcr.io/cadvisor/cadvisor:v0.49"],
275
+ ["registry.k8s.io/pause:3.9"],
276
+ ])("%s is an infrastructure sidecar", (image: string) => {
277
+ expect(classifyImage(image)).toEqual({ kind: "infrastructure-sidecar" });
278
+ });
279
+
280
+ test.each([["acme/checkout:1.2.3"], ["nginx:1.25"], ["node:20"]])(
281
+ "%s is unknown",
282
+ (image: string) => {
283
+ expect(classifyImage(image)).toEqual({ kind: "unknown" });
284
+ },
285
+ );
286
+ });
287
+
288
+ describe("classifyKubernetesPod — operators and charts", () => {
289
+ test("CloudNativePG instance: the cluster, its role and its Services", () => {
290
+ expect(
291
+ classifyKubernetesPod(
292
+ pod({
293
+ name: "pg-main-1",
294
+ labels: {
295
+ "cnpg.io/cluster": "pg-main",
296
+ "cnpg.io/podRole": "instance",
297
+ "cnpg.io/instanceRole": "primary",
298
+ },
299
+ owner: { kind: "Cluster", name: "pg-main" },
300
+ containers: [
301
+ {
302
+ name: "postgres",
303
+ image: "ghcr.io/cloudnative-pg/postgresql:16.2",
304
+ ports: [port(5432), port(9187), port(8000)],
305
+ },
306
+ ],
307
+ }),
308
+ ),
309
+ ).toEqual({
310
+ system: "postgresql",
311
+ namespace: "data",
312
+ workloadKind: "Cluster",
313
+ workloadName: "pg-main",
314
+ ownerKind: "Cluster",
315
+ ownerName: "pg-main",
316
+ podName: "pg-main-1",
317
+ containerName: "postgres",
318
+ image: "ghcr.io/cloudnative-pg/postgresql:16.2",
319
+ version: "16.2",
320
+ ports: [5432, 9187, 8000],
321
+ operator: "cloudnative-pg",
322
+ role: "primary",
323
+ serviceNames: ["pg-main", "pg-main-rw", "pg-main-ro", "pg-main-r"],
324
+ headlessServiceName: null,
325
+ evidence: [
326
+ "label:cnpg.io/cluster=pg-main",
327
+ "image:ghcr.io/cloudnative-pg/postgresql:16.2",
328
+ ],
329
+ });
330
+ });
331
+
332
+ test("CloudNativePG: the legacy `role` label and a missing podRole", () => {
333
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
334
+ pod({
335
+ name: "pg-main-2",
336
+ labels: { "cnpg.io/cluster": "pg-main", role: "replica" },
337
+ containers: [
338
+ { name: "postgres", image: "ghcr.io/cloudnative-pg/postgresql:16" },
339
+ ],
340
+ }),
341
+ );
342
+ expect(candidate?.role).toBe("replica");
343
+ expect(candidate?.workloadName).toBe("pg-main");
344
+ });
345
+
346
+ test("CloudNativePG pooler pods are not members", () => {
347
+ expect(
348
+ classifyKubernetesPod(
349
+ pod({
350
+ name: "pg-main-pooler-rw-6d9f",
351
+ labels: {
352
+ "cnpg.io/cluster": "pg-main",
353
+ "cnpg.io/poolerName": "pg-main-pooler-rw",
354
+ },
355
+ containers: [
356
+ {
357
+ name: "pgbouncer",
358
+ image: "ghcr.io/cloudnative-pg/pgbouncer:1.22",
359
+ },
360
+ ],
361
+ }),
362
+ ),
363
+ ).toBeNull();
364
+ expect(
365
+ classifyKubernetesPod(
366
+ pod({
367
+ name: "pg-main-pooler-x",
368
+ labels: { "cnpg.io/cluster": "pg-main", "cnpg.io/podRole": "pooler" },
369
+ containers: [
370
+ {
371
+ name: "pgbouncer",
372
+ image: "ghcr.io/cloudnative-pg/pgbouncer:1.22",
373
+ },
374
+ ],
375
+ }),
376
+ ),
377
+ ).toBeNull();
378
+ });
379
+
380
+ test("Zalando (Spilo): cluster-name, spilo-role, the -repl Service", () => {
381
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
382
+ pod({
383
+ name: "acid-main-0",
384
+ namespace: "pg",
385
+ labels: {
386
+ application: "spilo",
387
+ "cluster-name": "acid-main",
388
+ "spilo-role": "master",
389
+ },
390
+ owner: { kind: "StatefulSet", name: "acid-main" },
391
+ containers: [
392
+ {
393
+ name: "postgres",
394
+ image: "ghcr.io/zalando/spilo-16:3.2-p2",
395
+ ports: [port(8008), port(5432), port(8080)],
396
+ },
397
+ ],
398
+ }),
399
+ { statefulSetServiceNames: { "acid-main": "acid-main-config" } },
400
+ );
401
+ expect(candidate).toMatchObject({
402
+ system: "postgresql",
403
+ workloadKind: "Cluster",
404
+ workloadName: "acid-main",
405
+ operator: "zalando",
406
+ role: "primary",
407
+ version: "16",
408
+ // The poolers clients connect through, the StatefulSet and its headless Service.
409
+ serviceNames: [
410
+ "acid-main",
411
+ "acid-main-repl",
412
+ "acid-main-pooler",
413
+ "acid-main-pooler-repl",
414
+ "acid-main-config",
415
+ ],
416
+ headlessServiceName: "acid-main-config",
417
+ });
418
+ });
419
+
420
+ test("Zalando: application=spilo without a cluster-name falls back to the image", () => {
421
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
422
+ pod({
423
+ name: "spilo-0",
424
+ labels: { application: "spilo" },
425
+ owner: { kind: "StatefulSet", name: "spilo" },
426
+ containers: [
427
+ { name: "postgres", image: "ghcr.io/zalando/spilo-16:3.2" },
428
+ ],
429
+ }),
430
+ );
431
+ expect(candidate).toMatchObject({
432
+ workloadKind: "StatefulSet",
433
+ workloadName: "spilo",
434
+ operator: null,
435
+ });
436
+ });
437
+
438
+ test("Crunchy PGO: the cluster (not the per-instance-set StatefulSet)", () => {
439
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
440
+ pod({
441
+ name: "hippo-instance1-abcd-0",
442
+ namespace: "postgres-operator",
443
+ labels: {
444
+ "postgres-operator.crunchydata.com/cluster": "hippo",
445
+ "postgres-operator.crunchydata.com/instance-set": "instance1",
446
+ "postgres-operator.crunchydata.com/instance": "hippo-instance1-abcd",
447
+ "postgres-operator.crunchydata.com/role": "master",
448
+ "postgres-operator.crunchydata.com/data": "postgres",
449
+ },
450
+ owner: { kind: "StatefulSet", name: "hippo-instance1-abcd" },
451
+ containers: [
452
+ {
453
+ name: "database",
454
+ image:
455
+ "registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.2-0",
456
+ ports: [port(5432)],
457
+ },
458
+ {
459
+ name: "replication-cert-copy",
460
+ image:
461
+ "registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.2-0",
462
+ },
463
+ {
464
+ name: "pgbackrest",
465
+ image:
466
+ "registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi8-2.49",
467
+ },
468
+ ],
469
+ }),
470
+ { statefulSetServiceNames: { "hippo-instance1-abcd": "hippo-pods" } },
471
+ );
472
+ expect(candidate).toMatchObject({
473
+ system: "postgresql",
474
+ workloadKind: "Cluster",
475
+ workloadName: "hippo",
476
+ operator: "crunchy",
477
+ role: "primary",
478
+ containerName: "database",
479
+ ports: [5432],
480
+ serviceNames: [
481
+ "hippo",
482
+ "hippo-primary",
483
+ "hippo-replicas",
484
+ "hippo-ha",
485
+ "hippo-pgbouncer",
486
+ "hippo-instance1-abcd",
487
+ "hippo-pods",
488
+ ],
489
+ headlessServiceName: "hippo-pods",
490
+ });
491
+ });
492
+
493
+ test("Crunchy pgBouncer, repo host and pgAdmin pods are not members", () => {
494
+ for (const labels of [
495
+ {
496
+ "postgres-operator.crunchydata.com/cluster": "hippo",
497
+ "postgres-operator.crunchydata.com/role": "pgbouncer",
498
+ },
499
+ {
500
+ "postgres-operator.crunchydata.com/cluster": "hippo",
501
+ "postgres-operator.crunchydata.com/pgbackrest-dedicated": "",
502
+ },
503
+ {
504
+ "postgres-operator.crunchydata.com/cluster": "hippo",
505
+ "postgres-operator.crunchydata.com/pgadmin": "",
506
+ },
507
+ ]) {
508
+ expect(
509
+ classifyKubernetesPod(
510
+ pod({
511
+ name: "hippo-other-0",
512
+ labels,
513
+ containers: [
514
+ {
515
+ name: "database",
516
+ image:
517
+ "registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.2-0",
518
+ },
519
+ ],
520
+ }),
521
+ ),
522
+ ).toBeNull();
523
+ }
524
+ });
525
+
526
+ test("Percona PostgreSQL v2 (a Crunchy fork) keeps its operand despite the -operator image", () => {
527
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
528
+ pod({
529
+ name: "cluster1-instance1-x9z-0",
530
+ labels: {
531
+ "postgres-operator.crunchydata.com/cluster": "cluster1",
532
+ "postgres-operator.crunchydata.com/role": "replica",
533
+ },
534
+ owner: { kind: "StatefulSet", name: "cluster1-instance1-x9z" },
535
+ containers: [
536
+ {
537
+ name: "database",
538
+ image: "percona/percona-postgresql-operator:2.3.1-ppg16-postgres",
539
+ ports: [port(5432)],
540
+ },
541
+ {
542
+ name: "pgbackrest",
543
+ image: "percona/percona-postgresql-operator:2.3.1-ppg16-pgbackrest",
544
+ },
545
+ ],
546
+ }),
547
+ );
548
+ expect(candidate).toMatchObject({
549
+ workloadName: "cluster1",
550
+ containerName: "database",
551
+ version: "16",
552
+ role: "replica",
553
+ });
554
+ });
555
+
556
+ test("Percona XtraDB Cluster: instance label, -pxc Service; haproxy is not a member", () => {
557
+ const labels: Record<string, string> = {
558
+ "app.kubernetes.io/managed-by": "percona-xtradb-cluster-operator",
559
+ "app.kubernetes.io/name": "percona-xtradb-cluster",
560
+ "app.kubernetes.io/instance": "cluster1",
561
+ };
562
+ expect(
563
+ classifyKubernetesPod(
564
+ pod({
565
+ name: "cluster1-pxc-0",
566
+ labels: { ...labels, "app.kubernetes.io/component": "pxc" },
567
+ owner: { kind: "StatefulSet", name: "cluster1-pxc" },
568
+ containers: [
569
+ {
570
+ name: "pxc",
571
+ image: "percona/percona-xtradb-cluster:8.0.35-27.1",
572
+ ports: [port(3306), port(4444)],
573
+ },
574
+ {
575
+ name: "logs",
576
+ image:
577
+ "percona/percona-xtradb-cluster-operator:1.14.0-logcollector",
578
+ },
579
+ ],
580
+ }),
581
+ ),
582
+ ).toMatchObject({
583
+ system: "mysql",
584
+ workloadKind: "Cluster",
585
+ workloadName: "cluster1",
586
+ operator: "percona",
587
+ // HAProxy / ProxySQL are where applications connect.
588
+ serviceNames: [
589
+ "cluster1",
590
+ "cluster1-pxc",
591
+ "cluster1-haproxy",
592
+ "cluster1-haproxy-replicas",
593
+ "cluster1-proxysql",
594
+ ],
595
+ version: "8.0.35",
596
+ });
597
+
598
+ for (const component of ["haproxy", "proxysql"]) {
599
+ expect(
600
+ classifyKubernetesPod(
601
+ pod({
602
+ name: `cluster1-${component}-0`,
603
+ labels: { ...labels, "app.kubernetes.io/component": component },
604
+ containers: [{ name: component, image: "percona/haproxy:2.8" }],
605
+ }),
606
+ ),
607
+ ).toBeNull();
608
+ }
609
+ });
610
+
611
+ test("Percona Server for MongoDB: the replset Service; mongos is not a member", () => {
612
+ const labels: Record<string, string> = {
613
+ "app.kubernetes.io/managed-by": "percona-server-mongodb-operator",
614
+ "app.kubernetes.io/name": "percona-server-mongodb",
615
+ "app.kubernetes.io/instance": "my-cluster-name",
616
+ };
617
+ expect(
618
+ classifyKubernetesPod(
619
+ pod({
620
+ name: "my-cluster-name-rs0-0",
621
+ labels: {
622
+ ...labels,
623
+ "app.kubernetes.io/component": "mongod",
624
+ "app.kubernetes.io/replset": "rs0",
625
+ },
626
+ containers: [
627
+ {
628
+ name: "mongod",
629
+ image: "percona/percona-server-mongodb:7.0.8-5",
630
+ },
631
+ ],
632
+ }),
633
+ ),
634
+ ).toMatchObject({
635
+ system: "mongodb",
636
+ workloadName: "my-cluster-name",
637
+ serviceNames: [
638
+ "my-cluster-name",
639
+ "my-cluster-name-rs0",
640
+ "my-cluster-name-mongos",
641
+ ],
642
+ });
643
+ expect(
644
+ classifyKubernetesPod(
645
+ pod({
646
+ name: "my-cluster-name-mongos-0",
647
+ labels: { ...labels, "app.kubernetes.io/component": "mongos" },
648
+ containers: [
649
+ { name: "mongos", image: "percona/percona-server-mongodb:7.0.8-5" },
650
+ ],
651
+ }),
652
+ ),
653
+ ).toBeNull();
654
+ });
655
+
656
+ test("a non-Percona managed-by is not the Percona rule", () => {
657
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
658
+ pod({
659
+ name: "x-0",
660
+ labels: {
661
+ "app.kubernetes.io/managed-by": "Helm",
662
+ "app.kubernetes.io/name": "percona-xtradb-cluster",
663
+ "app.kubernetes.io/instance": "x",
664
+ },
665
+ owner: { kind: "StatefulSet", name: "x" },
666
+ containers: [
667
+ { name: "pxc", image: "percona/percona-xtradb-cluster:8.0" },
668
+ ],
669
+ }),
670
+ );
671
+ expect(candidate?.operator).toBeNull();
672
+ expect(candidate?.workloadKind).toBe("StatefulSet");
673
+ });
674
+
675
+ test("Oracle MySQL operator: cluster role; router pods are not members", () => {
676
+ expect(
677
+ classifyKubernetesPod(
678
+ pod({
679
+ name: "mycluster-0",
680
+ labels: {
681
+ "mysql.oracle.com/cluster": "mycluster",
682
+ "mysql.oracle.com/cluster-role": "PRIMARY",
683
+ },
684
+ owner: { kind: "StatefulSet", name: "mycluster" },
685
+ containers: [
686
+ {
687
+ name: "mysql",
688
+ image:
689
+ "container-registry.oracle.com/mysql/community-server:8.3.0",
690
+ ports: [port(3306), port(33060)],
691
+ },
692
+ {
693
+ name: "sidecar",
694
+ image:
695
+ "container-registry.oracle.com/mysql/community-operator:8.3.0-2.1.2",
696
+ },
697
+ ],
698
+ }),
699
+ ),
700
+ ).toMatchObject({
701
+ system: "mysql",
702
+ workloadKind: "Cluster",
703
+ workloadName: "mycluster",
704
+ operator: "mysql-operator",
705
+ role: "primary",
706
+ serviceNames: ["mycluster", "mycluster-instances"],
707
+ containerName: "mysql",
708
+ });
709
+
710
+ // Router Deployment pods, whether or not they carry a component label.
711
+ expect(
712
+ classifyKubernetesPod(
713
+ pod({
714
+ name: "mycluster-router-5c6d",
715
+ labels: {
716
+ "mysql.oracle.com/cluster": "mycluster",
717
+ component: "mysqlrouter",
718
+ },
719
+ containers: [
720
+ {
721
+ name: "router",
722
+ image: "container-registry.oracle.com/mysql/community-router:8.3",
723
+ },
724
+ ],
725
+ }),
726
+ ),
727
+ ).toBeNull();
728
+ expect(
729
+ classifyKubernetesPod(
730
+ pod({
731
+ name: "mycluster-router-5c6e",
732
+ labels: { "mysql.oracle.com/cluster": "mycluster" },
733
+ containers: [{ name: "router", image: "mysql/mysql-router:8.0" }],
734
+ }),
735
+ ),
736
+ ).toBeNull();
737
+ });
738
+
739
+ test("ECK: the Elasticsearch cluster and its -es-http Service", () => {
740
+ expect(
741
+ classifyKubernetesPod(
742
+ pod({
743
+ name: "quickstart-es-default-0",
744
+ namespace: "elastic",
745
+ labels: {
746
+ "common.k8s.elastic.co/type": "elasticsearch",
747
+ "elasticsearch.k8s.elastic.co/cluster-name": "quickstart",
748
+ "elasticsearch.k8s.elastic.co/node-master": "true",
749
+ },
750
+ owner: { kind: "StatefulSet", name: "quickstart-es-default" },
751
+ containers: [
752
+ {
753
+ name: "elasticsearch",
754
+ image: "docker.elastic.co/elasticsearch/elasticsearch:8.13.0",
755
+ ports: [port(9200), port(9300)],
756
+ },
757
+ ],
758
+ }),
759
+ {
760
+ statefulSetServiceNames: {
761
+ "quickstart-es-default": "quickstart-es-default",
762
+ },
763
+ },
764
+ ),
765
+ ).toMatchObject({
766
+ system: "elasticsearch",
767
+ workloadName: "quickstart",
768
+ operator: "eck",
769
+ serviceNames: [
770
+ "quickstart",
771
+ "quickstart-es-http",
772
+ "quickstart-es-default",
773
+ ],
774
+ headlessServiceName: "quickstart-es-default",
775
+ version: "8.13.0",
776
+ });
777
+ });
778
+
779
+ test("ECK Kibana pods are not Elasticsearch members", () => {
780
+ expect(
781
+ classifyKubernetesPod(
782
+ pod({
783
+ name: "quickstart-kb-6d9f",
784
+ labels: {
785
+ "common.k8s.elastic.co/type": "kibana",
786
+ "kibana.k8s.elastic.co/name": "quickstart",
787
+ },
788
+ containers: [
789
+ {
790
+ name: "kibana",
791
+ image: "docker.elastic.co/kibana/kibana:8.13.0",
792
+ },
793
+ ],
794
+ }),
795
+ ),
796
+ ).toBeNull();
797
+ });
798
+
799
+ test("Altinity ClickHouse: the installation and its clickhouse-<chi> Service", () => {
800
+ expect(
801
+ classifyKubernetesPod(
802
+ pod({
803
+ name: "chi-demo-cluster-0-0-0",
804
+ labels: { "clickhouse.altinity.com/chi": "demo" },
805
+ owner: { kind: "StatefulSet", name: "chi-demo-cluster-0-0" },
806
+ containers: [
807
+ {
808
+ name: "clickhouse",
809
+ image: "altinity/clickhouse-server:23.8.8.21.altinitystable",
810
+ },
811
+ ],
812
+ }),
813
+ ),
814
+ ).toMatchObject({
815
+ system: "clickhouse",
816
+ workloadName: "demo",
817
+ operator: "altinity",
818
+ // Altinity names each replica's Service after its StatefulSet.
819
+ serviceNames: ["demo", "clickhouse-demo", "chi-demo-cluster-0-0"],
820
+ });
821
+ });
822
+
823
+ test("cass-operator: the cluster and its <cluster>-<dc>-service", () => {
824
+ expect(
825
+ classifyKubernetesPod(
826
+ pod({
827
+ name: "demo-dc1-default-sts-0",
828
+ labels: {
829
+ "cassandra.datastax.com/cluster": "demo",
830
+ "cassandra.datastax.com/datacenter": "dc1",
831
+ },
832
+ owner: { kind: "StatefulSet", name: "demo-dc1-default-sts" },
833
+ containers: [
834
+ {
835
+ name: "cassandra",
836
+ image: "k8ssandra/cass-management-api:4.1.4",
837
+ ports: [port(9042)],
838
+ },
839
+ {
840
+ name: "server-system-logger",
841
+ image: "k8ssandra/system-logger:v1.19.0",
842
+ },
843
+ ],
844
+ }),
845
+ ),
846
+ ).toMatchObject({
847
+ system: "cassandra",
848
+ workloadName: "demo",
849
+ operator: "cass-operator",
850
+ serviceNames: [
851
+ "demo",
852
+ "demo-dc1-service",
853
+ "demo-dc1-all-pods-service",
854
+ "demo-dc1-default-sts",
855
+ ],
856
+ });
857
+ });
858
+
859
+ test("Bitnami chart: release fullname, headless and component Services", () => {
860
+ expect(
861
+ classifyKubernetesPod(
862
+ pod({
863
+ name: "my-pg-postgresql-primary-0",
864
+ labels: {
865
+ "app.kubernetes.io/name": "postgresql",
866
+ "app.kubernetes.io/instance": "my-pg",
867
+ "app.kubernetes.io/component": "primary",
868
+ "app.kubernetes.io/managed-by": "Helm",
869
+ },
870
+ owner: { kind: "StatefulSet", name: "my-pg-postgresql-primary" },
871
+ containers: [
872
+ {
873
+ name: "postgresql",
874
+ image: "docker.io/bitnami/postgresql:16.2.0-debian-12-r5",
875
+ ports: [port(5432)],
876
+ },
877
+ {
878
+ name: "metrics",
879
+ image: "docker.io/bitnami/postgres-exporter:0.15.0",
880
+ ports: [port(9187)],
881
+ },
882
+ ],
883
+ }),
884
+ {
885
+ statefulSetServiceNames: {
886
+ "my-pg-postgresql-primary": "my-pg-postgresql-primary-hl",
887
+ },
888
+ },
889
+ ),
890
+ ).toEqual({
891
+ system: "postgresql",
892
+ namespace: "data",
893
+ workloadKind: "Cluster",
894
+ workloadName: "my-pg-postgresql",
895
+ ownerKind: "StatefulSet",
896
+ ownerName: "my-pg-postgresql-primary",
897
+ podName: "my-pg-postgresql-primary-0",
898
+ containerName: "postgresql",
899
+ image: "docker.io/bitnami/postgresql:16.2.0-debian-12-r5",
900
+ version: "16.2.0",
901
+ ports: [5432],
902
+ operator: "helm",
903
+ role: "primary",
904
+ serviceNames: [
905
+ "my-pg-postgresql",
906
+ "my-pg-postgresql-headless",
907
+ "my-pg-postgresql-hl",
908
+ "my-pg-postgresql-primary",
909
+ "my-pg-postgresql-primary-hl",
910
+ ],
911
+ headlessServiceName: "my-pg-postgresql-primary-hl",
912
+ evidence: [
913
+ "label:app.kubernetes.io/name=postgresql",
914
+ "image:docker.io/bitnami/postgresql:16.2.0-debian-12-r5",
915
+ ],
916
+ });
917
+ });
918
+
919
+ test("Bitnami chart: a release named after the chart is the fullname itself", () => {
920
+ expect(
921
+ classifyKubernetesPod(
922
+ pod({
923
+ name: "redis-replicas-0",
924
+ labels: {
925
+ "app.kubernetes.io/name": "redis",
926
+ "app.kubernetes.io/instance": "redis",
927
+ "app.kubernetes.io/component": "replica",
928
+ },
929
+ containers: [{ name: "redis", image: "bitnami/redis:7.2" }],
930
+ }),
931
+ ),
932
+ ).toMatchObject({
933
+ workloadName: "redis",
934
+ role: "replica",
935
+ operator: null,
936
+ serviceNames: [
937
+ "redis",
938
+ "redis-headless",
939
+ "redis-hl",
940
+ "redis-replica",
941
+ "redis-replicas",
942
+ ],
943
+ });
944
+ });
945
+
946
+ test("Bitnami Redis with a Sentinel container in the pod is still Redis", () => {
947
+ expect(
948
+ classifyKubernetesPod(
949
+ pod({
950
+ name: "cache-redis-node-0",
951
+ labels: {
952
+ "app.kubernetes.io/name": "redis",
953
+ "app.kubernetes.io/instance": "cache",
954
+ "app.kubernetes.io/component": "node",
955
+ },
956
+ containers: [
957
+ { name: "redis", image: "bitnami/redis:7.2" },
958
+ { name: "sentinel", image: "bitnami/redis-sentinel:7.2" },
959
+ ],
960
+ }),
961
+ )?.workloadName,
962
+ ).toBe("cache-redis");
963
+ });
964
+
965
+ test.each(["metrics", "exporter", "pgpool", "pgbouncer", "mongos"])(
966
+ "Bitnami component %s is not a member",
967
+ (component: string) => {
968
+ expect(
969
+ classifyKubernetesPod(
970
+ pod({
971
+ name: `x-${component}-0`,
972
+ labels: {
973
+ "app.kubernetes.io/name": "postgresql-ha",
974
+ "app.kubernetes.io/instance": "x",
975
+ "app.kubernetes.io/component": component,
976
+ },
977
+ containers: [{ name: component, image: "bitnami/postgresql:16" }],
978
+ }),
979
+ ),
980
+ ).toBeNull();
981
+ },
982
+ );
983
+
984
+ test("a chart label without an instance names the workload after the owner", () => {
985
+ expect(
986
+ classifyKubernetesPod(
987
+ pod({
988
+ name: "mongo-0",
989
+ labels: { "app.kubernetes.io/name": "mongodb" },
990
+ owner: { kind: "StatefulSet", name: "mongo" },
991
+ containers: [{ name: "mongodb", image: "bitnami/mongodb:7.0" }],
992
+ }),
993
+ ),
994
+ ).toMatchObject({
995
+ system: "mongodb",
996
+ workloadKind: "StatefulSet",
997
+ workloadName: "mongo",
998
+ serviceNames: ["mongo"],
999
+ });
1000
+ });
1001
+
1002
+ test("the operator's engine wins over an unrecognised image", () => {
1003
+ expect(
1004
+ classifyKubernetesPod(
1005
+ pod({
1006
+ name: "pg-main-1",
1007
+ labels: { "cnpg.io/cluster": "pg-main" },
1008
+ containers: [
1009
+ { name: "postgres", image: "registry.corp/custom/pg-build:16" },
1010
+ ],
1011
+ }),
1012
+ ),
1013
+ ).toMatchObject({
1014
+ system: "postgresql",
1015
+ containerName: "postgres",
1016
+ image: "registry.corp/custom/pg-build:16",
1017
+ });
1018
+ });
1019
+ });
1020
+
1021
+ describe("classifyKubernetesPod — the Vitess operator", () => {
1022
+ /*
1023
+ * What the operator (planetscale/vitess-operator) stamps on every pod of a
1024
+ * VitessCluster: `planetscale.com/cluster` and `planetscale.com/component`.
1025
+ * Every component runs `vitess/lite`; the vttablets add a mysqld.
1026
+ */
1027
+ function vitessPod(data: {
1028
+ name: string;
1029
+ component: string | null;
1030
+ owner?: { kind: string; name: string } | null;
1031
+ containers: Array<ContainerFixture>;
1032
+ extraLabels?: Record<string, string>;
1033
+ }): KubernetesPodLike {
1034
+ const labels: Record<string, string> = {
1035
+ "planetscale.com/cluster": "example",
1036
+ ...(data.extraLabels || {}),
1037
+ };
1038
+ if (data.component !== null) {
1039
+ labels["planetscale.com/component"] = data.component;
1040
+ }
1041
+ return pod({
1042
+ name: data.name,
1043
+ namespace: "vitess",
1044
+ labels,
1045
+ owner: data.owner === undefined ? null : data.owner,
1046
+ containers: data.containers,
1047
+ });
1048
+ }
1049
+
1050
+ test("vtgate is the database: the VitessCluster, on vitess/lite", () => {
1051
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
1052
+ vitessPod({
1053
+ name: "example-zone1-vtgate-bc6cde92-6bd99c6888-vwcj5",
1054
+ component: "vtgate",
1055
+ extraLabels: {
1056
+ "planetscale.com/cell": "zone1",
1057
+ "pod-template-hash": "6bd99c6888",
1058
+ },
1059
+ owner: {
1060
+ kind: "ReplicaSet",
1061
+ name: "example-zone1-vtgate-bc6cde92-6bd99c6888",
1062
+ },
1063
+ containers: [
1064
+ {
1065
+ name: "vtgate",
1066
+ image: "vitess/lite:v19.0.4",
1067
+ command: ["/vt/bin/vtgate"],
1068
+ args: ["--cell=zone1", "--mysql_server_port=3306"],
1069
+ ports: [port(15000), port(15999), port(3306)],
1070
+ },
1071
+ ],
1072
+ }),
1073
+ );
1074
+
1075
+ expect(candidate).toMatchObject({
1076
+ system: "vitess",
1077
+ namespace: "vitess",
1078
+ workloadKind: "Cluster",
1079
+ workloadName: "example",
1080
+ ownerKind: "Deployment",
1081
+ ownerName: "example-zone1-vtgate-bc6cde92",
1082
+ operator: "vitess-operator",
1083
+ role: null,
1084
+ containerName: "vtgate",
1085
+ image: "vitess/lite:v19.0.4",
1086
+ version: "19.0.4",
1087
+ // The cell's vtgate Deployment - the operator hashes Service names.
1088
+ serviceNames: ["example", "example-zone1-vtgate-bc6cde92"],
1089
+ evidence: [
1090
+ "label:planetscale.com/cluster=example",
1091
+ "image:vitess/lite:v19.0.4",
1092
+ ],
1093
+ });
1094
+ // Without the labels, vitess/lite is no database at all.
1095
+ expect(classifyImage("vitess/lite:v19.0.4").kind).not.toBe("database");
1096
+ });
1097
+
1098
+ test("a vtgate on the dedicated vitess/vtgate image is the same database", () => {
1099
+ expect(
1100
+ classifyKubernetesPod(
1101
+ vitessPod({
1102
+ name: "example-zone2-vtgate-0a1b2c3d-7f8e9d-abcde",
1103
+ component: "vtgate",
1104
+ owner: { kind: "Deployment", name: "example-zone2-vtgate-0a1b2c3d" },
1105
+ containers: [
1106
+ {
1107
+ name: "vtgate",
1108
+ image: "vitess/vtgate:v19.0.4",
1109
+ ports: [port(3306)],
1110
+ },
1111
+ ],
1112
+ }),
1113
+ ),
1114
+ ).toMatchObject({
1115
+ system: "vitess",
1116
+ workloadKind: "Cluster",
1117
+ workloadName: "example",
1118
+ });
1119
+ });
1120
+
1121
+ test.each(["vttablet", "vtctld", "vtorc", "vtbackup", "vtadmin", "etcd"])(
1122
+ "%s pods belong to the VitessCluster but are not members",
1123
+ (component: string) => {
1124
+ expect(
1125
+ classifyKubernetesPod(
1126
+ vitessPod({
1127
+ name: `example-${component}-zone1-2469782763-bfadd780`,
1128
+ component,
1129
+ owner: { kind: "VitessShard", name: "example-commerce-x-x" },
1130
+ containers: [
1131
+ {
1132
+ name: component,
1133
+ image: "vitess/lite:v19.0.4",
1134
+ ports: [port(15000), port(15999)],
1135
+ },
1136
+ ],
1137
+ }),
1138
+ ),
1139
+ ).toBeNull();
1140
+ },
1141
+ );
1142
+
1143
+ test("a vttablet's mysqld container never makes it a MySQL database of its own", () => {
1144
+ const tablet: KubernetesPodLike = vitessPod({
1145
+ name: "example-vttablet-zone1-2548885007-46a852d0",
1146
+ component: "vttablet",
1147
+ extraLabels: {
1148
+ "planetscale.com/keyspace": "commerce",
1149
+ "planetscale.com/shard": "x-x",
1150
+ "planetscale.com/tablet-type": "replica",
1151
+ },
1152
+ owner: { kind: "VitessShard", name: "example-commerce-x-x-2b3c4d5e" },
1153
+ containers: [
1154
+ {
1155
+ name: "vttablet",
1156
+ image: "vitess/lite:v19.0.4",
1157
+ ports: [port(15000), port(15999)],
1158
+ },
1159
+ { name: "mysqld", image: "mysql:8.0.30", ports: [port(3306)] },
1160
+ { name: "mysqld-exporter", image: "prom/mysqld-exporter:v0.14.0" },
1161
+ ],
1162
+ });
1163
+ expect(classifyKubernetesPod(tablet)).toBeNull();
1164
+
1165
+ /*
1166
+ * The labels decide, not the neighbours: a tablet pod whose only
1167
+ * server is its mysqld is not a member either, though the image alone
1168
+ * would make it MySQL.
1169
+ */
1170
+ const mysqldOnly: KubernetesPodLike = {
1171
+ ...tablet,
1172
+ spec: {
1173
+ containers: [
1174
+ { name: "mysqld", image: "mysql:8.0.30", ports: [port(3306)] },
1175
+ { name: "mysqld-exporter", image: "prom/mysqld-exporter:v0.14.0" },
1176
+ ],
1177
+ },
1178
+ };
1179
+ expect(classifyKubernetesPod(mysqldOnly)).toBeNull();
1180
+ expect(classifyKubernetesPod({ ...mysqldOnly, labels: {} })).toMatchObject({
1181
+ system: "mysql",
1182
+ });
1183
+ });
1184
+
1185
+ test("a VitessCluster pod without a component is not guessed a member", () => {
1186
+ expect(
1187
+ classifyKubernetesPod(
1188
+ vitessPod({
1189
+ name: "example-x",
1190
+ component: null,
1191
+ owner: { kind: "Deployment", name: "example-x" },
1192
+ containers: [{ name: "x", image: "vitess/lite:v19.0.4" }],
1193
+ }),
1194
+ ),
1195
+ ).toBeNull();
1196
+ });
1197
+
1198
+ test("the cells' vtgates fold into ONE database, keyed in the mysql family", () => {
1199
+ const vtgate: (
1200
+ cell: string,
1201
+ suffix: string,
1202
+ ) => KubernetesDatabaseCandidate = (
1203
+ cell: string,
1204
+ suffix: string,
1205
+ ): KubernetesDatabaseCandidate => {
1206
+ return classifyKubernetesPod(
1207
+ vitessPod({
1208
+ name: `example-${cell}-vtgate-${suffix}-5d6f7-abcde`,
1209
+ component: "vtgate",
1210
+ owner: {
1211
+ kind: "Deployment",
1212
+ name: `example-${cell}-vtgate-${suffix}`,
1213
+ },
1214
+ containers: [
1215
+ {
1216
+ name: "vtgate",
1217
+ image: "vitess/lite:v19.0.4",
1218
+ ports: [port(3306)],
1219
+ },
1220
+ ],
1221
+ }),
1222
+ )!;
1223
+ };
1224
+
1225
+ const groups: Array<KubernetesDatabaseGroup> =
1226
+ groupKubernetesDatabaseCandidates([
1227
+ vtgate("zone2", "0a1b2c3d"),
1228
+ vtgate("zone1", "bc6cde92"),
1229
+ ]);
1230
+
1231
+ expect(groups).toHaveLength(1);
1232
+ expect(groups[0]).toMatchObject({
1233
+ system: "vitess",
1234
+ workloadKind: "Cluster",
1235
+ workloadName: "example",
1236
+ operator: "vitess-operator",
1237
+ ownerWorkloads: [
1238
+ { kind: "Deployment", name: "example-zone1-vtgate-bc6cde92" },
1239
+ { kind: "Deployment", name: "example-zone2-vtgate-0a1b2c3d" },
1240
+ ],
1241
+ });
1242
+ expect(
1243
+ buildWorkloadDatabaseServerIdentifier({
1244
+ system: groups[0]!.system,
1245
+ platform: "kubernetes",
1246
+ parentName: "prod",
1247
+ namespace: groups[0]!.namespace,
1248
+ workloadKind: groups[0]!.workloadKind,
1249
+ workloadName: groups[0]!.workloadName,
1250
+ }),
1251
+ ).toBe("mysql|kubernetes:prod/vitess/cluster/example");
1252
+ });
1253
+ });
1254
+
1255
+ describe("classifyKubernetesPod — images", () => {
1256
+ test("a plain StatefulSet running postgres", () => {
1257
+ expect(
1258
+ classifyKubernetesPod(
1259
+ pod({
1260
+ name: "pg-0",
1261
+ namespace: "shop",
1262
+ owner: { kind: "StatefulSet", name: "pg" },
1263
+ containers: [
1264
+ {
1265
+ name: "postgres",
1266
+ image: "postgres:16.2-alpine",
1267
+ ports: [port(5432), port(5432), { containerPort: 0 }, {}],
1268
+ },
1269
+ ],
1270
+ }),
1271
+ { statefulSetServiceNames: { pg: "pg-hl", other: "x" } },
1272
+ ),
1273
+ ).toEqual({
1274
+ system: "postgresql",
1275
+ namespace: "shop",
1276
+ workloadKind: "StatefulSet",
1277
+ workloadName: "pg",
1278
+ ownerKind: "StatefulSet",
1279
+ ownerName: "pg",
1280
+ podName: "pg-0",
1281
+ containerName: "postgres",
1282
+ image: "postgres:16.2-alpine",
1283
+ version: "16.2",
1284
+ ports: [5432],
1285
+ operator: null,
1286
+ role: null,
1287
+ // The headless Service is a name clients reach the database by too.
1288
+ serviceNames: ["pg", "pg-hl"],
1289
+ headlessServiceName: "pg-hl",
1290
+ evidence: ["image:postgres:16.2-alpine"],
1291
+ });
1292
+ });
1293
+
1294
+ test("a Deployment is named by stripping the pod-template-hash", () => {
1295
+ expect(
1296
+ classifyKubernetesPod(
1297
+ pod({
1298
+ name: "cache-7d9f8b6c5d-x2x4z",
1299
+ labels: { "pod-template-hash": "7d9f8b6c5d" },
1300
+ owner: { kind: "ReplicaSet", name: "cache-7d9f8b6c5d" },
1301
+ containers: [{ name: "redis", image: "redis:7" }],
1302
+ }),
1303
+ ),
1304
+ ).toMatchObject({ workloadKind: "Deployment", workloadName: "cache" });
1305
+ });
1306
+
1307
+ test("a ReplicaSet without a matching hash label stays a ReplicaSet (no guessing)", () => {
1308
+ expect(
1309
+ classifyKubernetesPod(
1310
+ pod({
1311
+ name: "cache-7d9f8b6c5d-x2x4z",
1312
+ owner: { kind: "ReplicaSet", name: "cache-7d9f8b6c5d" },
1313
+ containers: [{ name: "redis", image: "redis:7" }],
1314
+ }),
1315
+ ),
1316
+ ).toMatchObject({
1317
+ workloadKind: "ReplicaSet",
1318
+ workloadName: "cache-7d9f8b6c5d",
1319
+ });
1320
+ expect(
1321
+ classifyKubernetesPod(
1322
+ pod({
1323
+ name: "cache-abc-x",
1324
+ labels: { "pod-template-hash": "zzz" },
1325
+ owner: { kind: "ReplicaSet", name: "cache-abc" },
1326
+ containers: [{ name: "redis", image: "redis:7" }],
1327
+ }),
1328
+ )?.workloadKind,
1329
+ ).toBe("ReplicaSet");
1330
+ });
1331
+
1332
+ test("a DaemonSet, a bare pod and another owner kind", () => {
1333
+ expect(
1334
+ classifyKubernetesPod(
1335
+ pod({
1336
+ name: "memcached-node-1",
1337
+ owner: { kind: "DaemonSet", name: "memcached" },
1338
+ containers: [{ name: "memcached", image: "memcached:1.6" }],
1339
+ }),
1340
+ ),
1341
+ ).toMatchObject({ workloadKind: "DaemonSet", workloadName: "memcached" });
1342
+ // An owner-less pod counts once it declares a port (a server run).
1343
+ expect(
1344
+ classifyKubernetesPod(
1345
+ pod({
1346
+ name: "scratch-db",
1347
+ containers: [
1348
+ { name: "mysql", image: "mysql:8", ports: [port(3306)] },
1349
+ ],
1350
+ }),
1351
+ ),
1352
+ ).toMatchObject({
1353
+ workloadKind: "Pod",
1354
+ workloadName: "scratch-db",
1355
+ ownerKind: null,
1356
+ ownerName: null,
1357
+ serviceNames: ["scratch-db"],
1358
+ });
1359
+ expect(
1360
+ classifyKubernetesPod(
1361
+ pod({
1362
+ name: "x-1",
1363
+ owner: { kind: "MongoDBCommunity", name: "mdb" },
1364
+ containers: [{ name: "mongod", image: "mongo:7" }],
1365
+ }),
1366
+ ),
1367
+ ).toMatchObject({ workloadKind: "MongoDBCommunity", workloadName: "mdb" });
1368
+ });
1369
+
1370
+ test("a StatefulSet owner without a known serviceName has no headless Service", () => {
1371
+ expect(
1372
+ classifyKubernetesPod(
1373
+ pod({
1374
+ name: "pg-0",
1375
+ owner: { kind: "StatefulSet", name: "pg" },
1376
+ containers: [{ name: "postgres", image: "postgres:16" }],
1377
+ }),
1378
+ )?.headlessServiceName,
1379
+ ).toBeNull();
1380
+ // Object.prototype keys are not StatefulSet names.
1381
+ expect(
1382
+ classifyKubernetesPod(
1383
+ pod({
1384
+ name: "constructor-0",
1385
+ owner: { kind: "StatefulSet", name: "constructor" },
1386
+ containers: [{ name: "postgres", image: "postgres:16" }],
1387
+ }),
1388
+ { statefulSetServiceNames: {} },
1389
+ )?.headlessServiceName,
1390
+ ).toBeNull();
1391
+ });
1392
+
1393
+ test("exporters, mesh proxies and log shippers next to the DB are fine", () => {
1394
+ expect(
1395
+ classifyKubernetesPod(
1396
+ pod({
1397
+ name: "pg-0",
1398
+ owner: { kind: "StatefulSet", name: "pg" },
1399
+ containers: [
1400
+ { name: "istio-proxy", image: "docker.io/istio/proxyv2:1.20.0" },
1401
+ { name: "postgres", image: "postgres:16" },
1402
+ {
1403
+ name: "exporter",
1404
+ image: "quay.io/prometheuscommunity/postgres-exporter:v0.15.0",
1405
+ },
1406
+ { name: "fluent-bit", image: "fluent/fluent-bit:3.0" },
1407
+ { name: "backup", image: "bitnami/kubectl:1.29" },
1408
+ ],
1409
+ }),
1410
+ ),
1411
+ ).toMatchObject({ system: "postgresql", containerName: "postgres" });
1412
+ });
1413
+
1414
+ test("an app pod with a database SIDECAR is not a database", () => {
1415
+ expect(
1416
+ classifyKubernetesPod(
1417
+ pod({
1418
+ name: "api-6c9f-x",
1419
+ labels: { "pod-template-hash": "6c9f" },
1420
+ owner: { kind: "ReplicaSet", name: "api-6c9f" },
1421
+ containers: [
1422
+ { name: "api", image: "acme/api:1.4.2" },
1423
+ { name: "redis", image: "redis:7" },
1424
+ ],
1425
+ }),
1426
+ ),
1427
+ ).toBeNull();
1428
+ // Even when the DB container is listed first.
1429
+ expect(
1430
+ classifyKubernetesPod(
1431
+ pod({
1432
+ name: "api-x",
1433
+ containers: [
1434
+ { name: "redis", image: "redis:7" },
1435
+ { name: "api", image: "acme/api:1.4.2" },
1436
+ ],
1437
+ }),
1438
+ ),
1439
+ ).toBeNull();
1440
+ // A container without an image is unknown, so it rejects too.
1441
+ expect(
1442
+ classifyKubernetesPod(
1443
+ pod({
1444
+ name: "api-y",
1445
+ containers: [
1446
+ { name: "redis", image: "redis:7" },
1447
+ { name: "api", image: "" },
1448
+ ],
1449
+ }),
1450
+ ),
1451
+ ).toBeNull();
1452
+ });
1453
+
1454
+ test.each([
1455
+ ["an exporter", "prometheuscommunity/postgres-exporter:v0.15.0"],
1456
+ ["an operator", "ghcr.io/cloudnative-pg/cloudnative-pg:1.22"],
1457
+ ["an admin UI", "dpage/pgadmin4:8"],
1458
+ ["a pooler", "bitnami/pgbouncer:1.22"],
1459
+ ["a backup tool", "pgbackrest/pgbackrest"],
1460
+ ["a mesh proxy", "istio/proxyv2:1.20"],
1461
+ ["an application", "acme/api:1"],
1462
+ ])(
1463
+ "a pod running only %s is not a database",
1464
+ (_label: string, image: string) => {
1465
+ expect(
1466
+ classifyKubernetesPod(
1467
+ pod({
1468
+ name: "x-0",
1469
+ owner: { kind: "Deployment", name: "x" },
1470
+ containers: [{ name: "c", image }],
1471
+ }),
1472
+ ),
1473
+ ).toBeNull();
1474
+ },
1475
+ );
1476
+
1477
+ test("backup Jobs and CronJobs running a DB image are skipped", () => {
1478
+ for (const owner of [
1479
+ { kind: "Job", name: "pg-dump-28491230" },
1480
+ { kind: "CronJob", name: "pg-dump" },
1481
+ ]) {
1482
+ expect(
1483
+ classifyKubernetesPod(
1484
+ pod({
1485
+ name: "pg-dump-28491230-abcde",
1486
+ owner,
1487
+ containers: [{ name: "dump", image: "postgres:16" }],
1488
+ }),
1489
+ ),
1490
+ ).toBeNull();
1491
+ }
1492
+ });
1493
+
1494
+ test("a Job owner anywhere in the owner list skips the pod, labels or not", () => {
1495
+ const skipped: KubernetesPodLike = {
1496
+ ...pod({
1497
+ name: "backup-x",
1498
+ labels: { "cnpg.io/cluster": "pg-main" },
1499
+ containers: [{ name: "postgres", image: "postgres:16" }],
1500
+ }),
1501
+ ownerReferences: {
1502
+ items: [
1503
+ { kind: "Cluster", name: "pg-main" },
1504
+ { kind: "Job", name: "pg-main-backup" },
1505
+ ],
1506
+ },
1507
+ };
1508
+ expect(classifyKubernetesPod(skipped)).toBeNull();
1509
+ });
1510
+
1511
+ test.each(["Succeeded", "Failed", "succeeded"])(
1512
+ "a %s pod is skipped",
1513
+ (phase: string) => {
1514
+ expect(
1515
+ classifyKubernetesPod(
1516
+ pod({
1517
+ name: "pg-0",
1518
+ phase,
1519
+ containers: [{ name: "postgres", image: "postgres:16" }],
1520
+ }),
1521
+ ),
1522
+ ).toBeNull();
1523
+ },
1524
+ );
1525
+
1526
+ test("Pending and phase-less pods are still classified", () => {
1527
+ for (const phase of ["Pending", null]) {
1528
+ expect(
1529
+ classifyKubernetesPod(
1530
+ pod({
1531
+ name: "pg-0",
1532
+ phase,
1533
+ owner: { kind: "StatefulSet", name: "pg" },
1534
+ containers: [{ name: "postgres", image: "postgres:16" }],
1535
+ }),
1536
+ )?.system,
1537
+ ).toBe("postgresql");
1538
+ }
1539
+ });
1540
+
1541
+ test("malformed input never throws", () => {
1542
+ expect(
1543
+ classifyKubernetesPod(null as unknown as KubernetesPodLike),
1544
+ ).toBeNull();
1545
+ expect(classifyKubernetesPod({ namespaceKey: "x", name: "" })).toBeNull();
1546
+ expect(
1547
+ classifyKubernetesPod({
1548
+ namespaceKey: "x",
1549
+ name: "p",
1550
+ spec: { containers: "nope" } as unknown as KubernetesPodLike["spec"],
1551
+ ownerReferences: {
1552
+ items: [null, { kind: "" }, { name: "x" }],
1553
+ } as unknown as KubernetesPodLike["ownerReferences"],
1554
+ labels: null,
1555
+ }),
1556
+ ).toBeNull();
1557
+ expect(
1558
+ classifyKubernetesPod({
1559
+ namespaceKey: "x",
1560
+ name: "p",
1561
+ ownerReferences: { items: [{ kind: "StatefulSet", name: "p" }] },
1562
+ spec: {
1563
+ containers: [
1564
+ null,
1565
+ {
1566
+ name: 5,
1567
+ image: "postgres:16",
1568
+ ports: "5432",
1569
+ command: "not-a-list",
1570
+ args: [7, "x"],
1571
+ },
1572
+ ] as unknown as Array<{ name?: string; image?: string }>,
1573
+ },
1574
+ }),
1575
+ ).toMatchObject({ system: "postgresql", containerName: "", ports: [] });
1576
+ });
1577
+ });
1578
+
1579
+ describe("classifyKubernetesPod — never reads environment variables", () => {
1580
+ const SECRET: string = "hunter2-do-not-copy";
1581
+
1582
+ test("env values are not read, even when present", () => {
1583
+ let envReads: number = 0;
1584
+ const container: Record<string, unknown> = {
1585
+ name: "postgres",
1586
+ image: "postgres:16",
1587
+ ports: [{ containerPort: 5432 }],
1588
+ };
1589
+ Object.defineProperty(container, "env", {
1590
+ enumerable: true,
1591
+ get: (): Array<{ name: string; value: string }> => {
1592
+ envReads++;
1593
+ return [{ name: "POSTGRES_PASSWORD", value: SECRET }];
1594
+ },
1595
+ });
1596
+
1597
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
1598
+ {
1599
+ namespaceKey: "data",
1600
+ name: "pg-0",
1601
+ spec: {
1602
+ containers: [container] as unknown as Array<{
1603
+ name?: string;
1604
+ image?: string;
1605
+ }>,
1606
+ },
1607
+ },
1608
+ );
1609
+
1610
+ expect(candidate?.system).toBe("postgresql");
1611
+ expect(envReads).toBe(0);
1612
+ expect(JSON.stringify(candidate)).not.toContain(SECRET);
1613
+ });
1614
+
1615
+ test("a secret in env never reaches the candidate on the label path either", () => {
1616
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
1617
+ {
1618
+ namespaceKey: "data",
1619
+ name: "pg-main-1",
1620
+ labels: { "cnpg.io/cluster": "pg-main" },
1621
+ spec: {
1622
+ containers: [
1623
+ {
1624
+ name: "postgres",
1625
+ image: "ghcr.io/cloudnative-pg/postgresql:16",
1626
+ env: [{ name: "PGPASSWORD", value: SECRET }],
1627
+ },
1628
+ ] as unknown as Array<{ name?: string; image?: string }>,
1629
+ },
1630
+ },
1631
+ );
1632
+ expect(candidate).not.toBeNull();
1633
+ expect(JSON.stringify(candidate)).not.toContain(SECRET);
1634
+ });
1635
+ });
1636
+
1637
+ describe("groupKubernetesDatabaseCandidates", () => {
1638
+ function cnpgMember(
1639
+ name: string,
1640
+ role: "primary" | "replica",
1641
+ image: string,
1642
+ ): KubernetesDatabaseCandidate {
1643
+ return classifyKubernetesPod(
1644
+ pod({
1645
+ name,
1646
+ labels: {
1647
+ "cnpg.io/cluster": "pg-main",
1648
+ "cnpg.io/instanceRole": role,
1649
+ },
1650
+ owner: { kind: "Cluster", name: "pg-main" },
1651
+ containers: [{ name: "postgres", image, ports: [port(5432)] }],
1652
+ }),
1653
+ )!;
1654
+ }
1655
+
1656
+ test("one group per operator cluster; image/version from the primary", () => {
1657
+ const groups: Array<KubernetesDatabaseGroup> =
1658
+ groupKubernetesDatabaseCandidates([
1659
+ cnpgMember(
1660
+ "pg-main-3",
1661
+ "replica",
1662
+ "ghcr.io/cloudnative-pg/postgresql:16.3",
1663
+ ),
1664
+ cnpgMember(
1665
+ "pg-main-1",
1666
+ "replica",
1667
+ "ghcr.io/cloudnative-pg/postgresql:16.3",
1668
+ ),
1669
+ cnpgMember(
1670
+ "pg-main-2",
1671
+ "primary",
1672
+ "ghcr.io/cloudnative-pg/postgresql:16.2",
1673
+ ),
1674
+ ]);
1675
+ expect(groups).toEqual([
1676
+ {
1677
+ system: "postgresql",
1678
+ namespace: "data",
1679
+ workloadKind: "Cluster",
1680
+ workloadName: "pg-main",
1681
+ podNames: ["pg-main-1", "pg-main-2", "pg-main-3"],
1682
+ image: "ghcr.io/cloudnative-pg/postgresql:16.2",
1683
+ version: "16.2",
1684
+ ports: [5432],
1685
+ operator: "cloudnative-pg",
1686
+ serviceNames: ["pg-main", "pg-main-rw", "pg-main-ro", "pg-main-r"],
1687
+ headlessServiceName: null,
1688
+ podServiceNames: [],
1689
+ ownerWorkloads: [{ kind: "Cluster", name: "pg-main" }],
1690
+ },
1691
+ ]);
1692
+ });
1693
+
1694
+ test("StatefulSet members carry their headless Service; ports are unioned", () => {
1695
+ const members: Array<KubernetesDatabaseCandidate> = [
1696
+ "mongo-1",
1697
+ "mongo-0",
1698
+ ].map((name: string, index: number): KubernetesDatabaseCandidate => {
1699
+ return classifyKubernetesPod(
1700
+ pod({
1701
+ name,
1702
+ owner: { kind: "StatefulSet", name: "mongo" },
1703
+ containers: [
1704
+ {
1705
+ name: "mongod",
1706
+ image: "mongo:7.0.5",
1707
+ ports: index === 0 ? [port(27017), port(27018)] : [port(27017)],
1708
+ },
1709
+ ],
1710
+ }),
1711
+ { statefulSetServiceNames: { mongo: "mongo-headless" } },
1712
+ )!;
1713
+ });
1714
+
1715
+ const groups: Array<KubernetesDatabaseGroup> =
1716
+ groupKubernetesDatabaseCandidates(members);
1717
+ expect(groups).toHaveLength(1);
1718
+ expect(groups[0]).toMatchObject({
1719
+ podNames: ["mongo-0", "mongo-1"],
1720
+ ports: [27017, 27018],
1721
+ headlessServiceName: "mongo-headless",
1722
+ podServiceNames: [
1723
+ { podName: "mongo-0", serviceName: "mongo-headless" },
1724
+ { podName: "mongo-1", serviceName: "mongo-headless" },
1725
+ ],
1726
+ image: "mongo:7.0.5",
1727
+ operator: null,
1728
+ });
1729
+ });
1730
+
1731
+ test("different namespaces, kinds, names or engines never merge; output is sorted", () => {
1732
+ const base: KubernetesDatabaseCandidate = classifyKubernetesPod(
1733
+ pod({
1734
+ name: "pg-0",
1735
+ namespace: "b",
1736
+ owner: { kind: "StatefulSet", name: "pg" },
1737
+ containers: [{ name: "postgres", image: "postgres:16" }],
1738
+ }),
1739
+ )!;
1740
+ const groups: Array<KubernetesDatabaseGroup> =
1741
+ groupKubernetesDatabaseCandidates([
1742
+ base,
1743
+ { ...base, namespace: "a", podName: "pg-0" },
1744
+ { ...base, workloadKind: "Deployment", podName: "pg-x" },
1745
+ { ...base, system: "redis", podName: "pg-1" },
1746
+ { ...base, podName: "pg-1" },
1747
+ ]);
1748
+ expect(
1749
+ groups.map((group: KubernetesDatabaseGroup): string => {
1750
+ return `${group.namespace}/${group.workloadKind}/${group.workloadName}/${group.system}:${group.podNames.join(",")}`;
1751
+ }),
1752
+ ).toEqual([
1753
+ "a/StatefulSet/pg/postgresql:pg-0",
1754
+ "b/Deployment/pg/postgresql:pg-x",
1755
+ "b/StatefulSet/pg/postgresql:pg-0,pg-1",
1756
+ "b/StatefulSet/pg/redis:pg-1",
1757
+ ]);
1758
+ });
1759
+
1760
+ describe("a fork and its family engine on one workload are ONE database", () => {
1761
+ function cacheMember(
1762
+ name: string,
1763
+ image: string,
1764
+ ): KubernetesDatabaseCandidate {
1765
+ return classifyKubernetesPod(
1766
+ pod({
1767
+ name,
1768
+ namespace: "cache",
1769
+ owner: { kind: "StatefulSet", name: "cache" },
1770
+ containers: [{ name: "server", image, ports: [port(6379)] }],
1771
+ }),
1772
+ { statefulSetServiceNames: { cache: "cache-headless" } },
1773
+ )!;
1774
+ }
1775
+
1776
+ function identifierOf(group: KubernetesDatabaseGroup): string {
1777
+ return buildWorkloadDatabaseServerIdentifier({
1778
+ system: group.system,
1779
+ platform: "kubernetes",
1780
+ parentName: "prod",
1781
+ namespace: group.namespace,
1782
+ workloadKind: group.workloadKind,
1783
+ workloadName: group.workloadName,
1784
+ });
1785
+ }
1786
+
1787
+ test("mid-rollout from redis to valkey: one group, the fork's engine, every pod", () => {
1788
+ const groups: Array<KubernetesDatabaseGroup> =
1789
+ groupKubernetesDatabaseCandidates([
1790
+ cacheMember("cache-0", "redis:7.2.4"),
1791
+ cacheMember("cache-1", "redis:7.2.4"),
1792
+ cacheMember("cache-2", "valkey/valkey:8.0.1"),
1793
+ ]);
1794
+
1795
+ expect(groups).toHaveLength(1);
1796
+ expect(groups[0]).toMatchObject({
1797
+ system: "valkey",
1798
+ workloadKind: "StatefulSet",
1799
+ workloadName: "cache",
1800
+ podNames: ["cache-0", "cache-1", "cache-2"],
1801
+ // Image and version describe the engine shown, not the first pod.
1802
+ image: "valkey/valkey:8.0.1",
1803
+ version: "8.0.1",
1804
+ podServiceNames: [
1805
+ { podName: "cache-0", serviceName: "cache-headless" },
1806
+ { podName: "cache-1", serviceName: "cache-headless" },
1807
+ { podName: "cache-2", serviceName: "cache-headless" },
1808
+ ],
1809
+ });
1810
+ expect(identifierOf(groups[0]!)).toBe(
1811
+ "redis|kubernetes:prod/cache/statefulset/cache",
1812
+ );
1813
+ });
1814
+
1815
+ test("a fork is never undone by more pods of the family engine, and order never matters", () => {
1816
+ const members: Array<KubernetesDatabaseCandidate> = [
1817
+ cacheMember("cache-2", "redis:7.2.4"),
1818
+ cacheMember("cache-0", "valkey/valkey:8.0.1"),
1819
+ cacheMember("cache-1", "redis:7.2.4"),
1820
+ ];
1821
+ const groups: Array<KubernetesDatabaseGroup> =
1822
+ groupKubernetesDatabaseCandidates(members);
1823
+
1824
+ expect(
1825
+ groups.map((group: KubernetesDatabaseGroup) => {
1826
+ return group.system;
1827
+ }),
1828
+ ).toEqual(["valkey"]);
1829
+ expect(groups).toEqual(
1830
+ groupKubernetesDatabaseCandidates([...members].reverse()),
1831
+ );
1832
+ });
1833
+
1834
+ test("MySQL and MariaDB pods of one StatefulSet are one MariaDB", () => {
1835
+ const member: (
1836
+ name: string,
1837
+ image: string,
1838
+ ) => KubernetesDatabaseCandidate = (
1839
+ name: string,
1840
+ image: string,
1841
+ ): KubernetesDatabaseCandidate => {
1842
+ return classifyKubernetesPod(
1843
+ pod({
1844
+ name,
1845
+ owner: { kind: "StatefulSet", name: "orders" },
1846
+ containers: [{ name: "db", image, ports: [port(3306)] }],
1847
+ }),
1848
+ )!;
1849
+ };
1850
+ const groups: Array<KubernetesDatabaseGroup> =
1851
+ groupKubernetesDatabaseCandidates([
1852
+ member("orders-0", "mariadb:11.4.2"),
1853
+ member("orders-1", "mysql:8.0.36"),
1854
+ ]);
1855
+ expect(groups).toHaveLength(1);
1856
+ expect(groups[0]).toMatchObject({
1857
+ system: "mariadb",
1858
+ podNames: ["orders-0", "orders-1"],
1859
+ version: "11.4.2",
1860
+ });
1861
+ });
1862
+
1863
+ test("engines of different families on one workload stay two databases", () => {
1864
+ const base: KubernetesDatabaseCandidate = cacheMember(
1865
+ "cache-0",
1866
+ "redis:7.2.4",
1867
+ );
1868
+ const groups: Array<KubernetesDatabaseGroup> =
1869
+ groupKubernetesDatabaseCandidates([
1870
+ base,
1871
+ { ...base, system: "memcached", podName: "cache-1" },
1872
+ ]);
1873
+ expect(
1874
+ groups.map((group: KubernetesDatabaseGroup): string => {
1875
+ return `${group.system}:${group.podNames.join(",")}`;
1876
+ }),
1877
+ ).toEqual(["memcached:cache-1", "redis:cache-0"]);
1878
+ });
1879
+
1880
+ test("a pooler still reaches its cluster whatever engine of the family the cluster shows", () => {
1881
+ const cluster: KubernetesDatabaseGroup = {
1882
+ ...groupKubernetesDatabaseCandidates([
1883
+ cnpgMember(
1884
+ "pg-main-1",
1885
+ "primary",
1886
+ "ghcr.io/cloudnative-pg/postgresql:16",
1887
+ ),
1888
+ ])[0]!,
1889
+ system: "cockroachdb",
1890
+ };
1891
+ expect(
1892
+ attachPoolerServices(
1893
+ [cluster],
1894
+ [
1895
+ {
1896
+ system: "postgresql",
1897
+ namespace: "data",
1898
+ clusterName: "pg-main",
1899
+ serviceName: "pg-main-pooler-rw",
1900
+ },
1901
+ {
1902
+ system: "mysql",
1903
+ namespace: "data",
1904
+ clusterName: "pg-main",
1905
+ serviceName: "not-this-one",
1906
+ },
1907
+ ],
1908
+ )[0]!.serviceNames,
1909
+ ).toEqual([
1910
+ "pg-main",
1911
+ "pg-main-rw",
1912
+ "pg-main-ro",
1913
+ "pg-main-r",
1914
+ "pg-main-pooler-rw",
1915
+ ]);
1916
+ });
1917
+ });
1918
+
1919
+ test("input order does not change the result", () => {
1920
+ const members: Array<KubernetesDatabaseCandidate> = [
1921
+ cnpgMember(
1922
+ "pg-main-1",
1923
+ "replica",
1924
+ "ghcr.io/cloudnative-pg/postgresql:16",
1925
+ ),
1926
+ cnpgMember(
1927
+ "pg-main-2",
1928
+ "replica",
1929
+ "ghcr.io/cloudnative-pg/postgresql:16",
1930
+ ),
1931
+ ];
1932
+ expect(groupKubernetesDatabaseCandidates(members)).toEqual(
1933
+ groupKubernetesDatabaseCandidates([...members].reverse()),
1934
+ );
1935
+ });
1936
+
1937
+ test("without a primary, image/version come from the first pod by name", () => {
1938
+ const groups: Array<KubernetesDatabaseGroup> =
1939
+ groupKubernetesDatabaseCandidates([
1940
+ cnpgMember(
1941
+ "pg-main-2",
1942
+ "replica",
1943
+ "ghcr.io/cloudnative-pg/postgresql:16.3",
1944
+ ),
1945
+ cnpgMember(
1946
+ "pg-main-1",
1947
+ "replica",
1948
+ "ghcr.io/cloudnative-pg/postgresql:16.2",
1949
+ ),
1950
+ ]);
1951
+ expect(groups[0]!.version).toBe("16.2");
1952
+ });
1953
+
1954
+ test("malformed candidates are dropped; non-arrays give []", () => {
1955
+ expect(
1956
+ groupKubernetesDatabaseCandidates([
1957
+ null as unknown as KubernetesDatabaseCandidate,
1958
+ { system: "" } as unknown as KubernetesDatabaseCandidate,
1959
+ ]),
1960
+ ).toEqual([]);
1961
+ expect(
1962
+ groupKubernetesDatabaseCandidates(
1963
+ undefined as unknown as Array<KubernetesDatabaseCandidate>,
1964
+ ),
1965
+ ).toEqual([]);
1966
+ });
1967
+ });
1968
+
1969
+ describe("pickMostSpecificDatabaseSystem", () => {
1970
+ test.each([
1971
+ [["redis"], "redis"],
1972
+ [["redis", "redis", "valkey"], "valkey"],
1973
+ [["valkey", "valkey", "redis"], "valkey"],
1974
+ [["mysql", "mariadb"], "mariadb"],
1975
+ [["mariadb", "mysql", "mysql", "mysql"], "mariadb"],
1976
+ [["postgresql", "cockroachdb"], "cockroachdb"],
1977
+ // Two forks that do not refine each other: the more reported one.
1978
+ [["keydb", "valkey", "valkey"], "valkey"],
1979
+ [["keydb", "keydb", "valkey"], "keydb"],
1980
+ // ...the first by name on a tie, whatever the order.
1981
+ [["valkey", "keydb"], "keydb"],
1982
+ [["keydb", "valkey"], "keydb"],
1983
+ [["redis", "valkey", "keydb"], "keydb"],
1984
+ ])("%j -> %s", (systems: Array<string>, expected: string) => {
1985
+ expect(pickMostSpecificDatabaseSystem(systems)).toBe(expected);
1986
+ expect(pickMostSpecificDatabaseSystem([...systems].reverse())).toBe(
1987
+ expected,
1988
+ );
1989
+ });
1990
+
1991
+ test("nothing to pick is null; junk entries are ignored", () => {
1992
+ expect(pickMostSpecificDatabaseSystem([])).toBeNull();
1993
+ expect(
1994
+ pickMostSpecificDatabaseSystem(undefined as unknown as Array<string>),
1995
+ ).toBeNull();
1996
+ expect(
1997
+ pickMostSpecificDatabaseSystem(["", null as unknown as string, "valkey"]),
1998
+ ).toBe("valkey");
1999
+ });
2000
+ });
2001
+
2002
+ describe("classifyContainer (Docker / Podman)", () => {
2003
+ test("Compose replicas are grouped by the Compose project + service labels", () => {
2004
+ expect(
2005
+ classifyContainer({
2006
+ name: "/shop-postgres-1",
2007
+ imageName: "postgres:16",
2008
+ labels: {
2009
+ "com.docker.compose.project": "shop",
2010
+ "com.docker.compose.service": "postgres",
2011
+ "com.docker.compose.container-number": "1",
2012
+ },
2013
+ }),
2014
+ ).toEqual({
2015
+ system: "postgresql",
2016
+ workloadKind: ContainerWorkloadKind.ComposeService,
2017
+ workloadName: "shop-postgres",
2018
+ containerName: "shop-postgres-1",
2019
+ version: "16",
2020
+ });
2021
+ // Compose v1 naming groups the same way.
2022
+ expect(
2023
+ classifyContainer({
2024
+ name: "shop_redis_2",
2025
+ imageName: "redis:7.2.4",
2026
+ labels: {
2027
+ "com.docker.compose.project": "shop",
2028
+ "com.docker.compose.service": "redis",
2029
+ },
2030
+ }),
2031
+ ).toEqual({
2032
+ system: "redis",
2033
+ workloadKind: ContainerWorkloadKind.ComposeService,
2034
+ workloadName: "shop-redis",
2035
+ containerName: "shop_redis_2",
2036
+ version: "7.2.4",
2037
+ });
2038
+ });
2039
+
2040
+ test("a custom container_name in a Compose file still groups by the service", () => {
2041
+ expect(
2042
+ classifyContainer({
2043
+ name: "legacy-db",
2044
+ imageName: "mysql:8",
2045
+ labels: {
2046
+ "com.docker.compose.project": "shop",
2047
+ "com.docker.compose.service": "database",
2048
+ },
2049
+ })?.workloadName,
2050
+ ).toBe("shop-database");
2051
+ });
2052
+
2053
+ test("podman-compose labels group replicas too", () => {
2054
+ expect(
2055
+ classifyContainer({
2056
+ name: "shop_db_1",
2057
+ imageName: "docker.io/library/postgres:16",
2058
+ labels: {
2059
+ "io.podman.compose.project": "shop",
2060
+ "io.podman.compose.service": "db",
2061
+ },
2062
+ })?.workloadName,
2063
+ ).toBe("shop-db");
2064
+ expect(
2065
+ classifyContainer({
2066
+ name: "shop_db_2",
2067
+ imageName: "postgres:16",
2068
+ labels: {
2069
+ "io.podman.compose.project": "shop",
2070
+ "com.docker.compose.service": "db",
2071
+ },
2072
+ })?.workloadName,
2073
+ ).toBe("shop-db");
2074
+ });
2075
+
2076
+ test("half a Compose label pair is not an identity: the container name is", () => {
2077
+ expect(
2078
+ classifyContainer({
2079
+ name: "shop-db-1",
2080
+ imageName: "mysql:8",
2081
+ labels: { "com.docker.compose.project": "shop" },
2082
+ })?.workloadName,
2083
+ ).toBe("shop-db-1");
2084
+ });
2085
+
2086
+ test("a plain name is its own workload", () => {
2087
+ expect(
2088
+ classifyContainer({ name: "postgres", imageName: "postgres:16" })
2089
+ ?.workloadName,
2090
+ ).toBe("postgres");
2091
+ });
2092
+
2093
+ test.each([
2094
+ ["redis-6379", "redis-6380", "redis:7"],
2095
+ ["pg-14", "pg-16", "postgres:16"],
2096
+ ["orders-db-2024", "orders-db-2025", "postgres:16"],
2097
+ ["mysql_1", "mysql_2", "mysql:8"],
2098
+ ["shop-postgres-1", "shop-postgres-2", "postgres:16"],
2099
+ ])(
2100
+ "without Compose labels %s and %s stay two servers (no suffix is guessed away)",
2101
+ (first: string, second: string, imageName: string) => {
2102
+ const a: ContainerDatabaseClassification | null = classifyContainer({
2103
+ name: first,
2104
+ imageName,
2105
+ });
2106
+ const b: ContainerDatabaseClassification | null = classifyContainer({
2107
+ name: second,
2108
+ imageName,
2109
+ });
2110
+ expect(a?.workloadName).toBe(first);
2111
+ expect(b?.workloadName).toBe(second);
2112
+ expect(a?.workloadName).not.toBe(b?.workloadName);
2113
+ },
2114
+ );
2115
+
2116
+ test("a Swarm task is grouped by its service label, whatever its task name", () => {
2117
+ expect(
2118
+ classifyContainer({
2119
+ name: "mystack_db.1.x7y8z9abcdefghijklmnopqrs",
2120
+ imageName: "postgres:16",
2121
+ labels: {
2122
+ "com.docker.swarm.service.name": "mystack_db",
2123
+ "com.docker.swarm.task.id": "x7y8z9abcdefghijklmnopqrs",
2124
+ },
2125
+ })?.workloadName,
2126
+ ).toBe("mystack_db");
2127
+ });
2128
+
2129
+ test("a Swarm task name without labels still names its service, never the task", () => {
2130
+ expect(
2131
+ classifyContainer({
2132
+ name: "mystack_db.1.x7y8z9abcdefghijklmnopqrs",
2133
+ imageName: "postgres:16",
2134
+ })?.workloadName,
2135
+ ).toBe("mystack_db");
2136
+ // A global service's task: <service>.<node id>.<task id>.
2137
+ expect(
2138
+ classifyContainer({
2139
+ name: "mon_redis.abcdefghijklmnopqrstuvwxy.x7y8z9abcdefghijklmnopqrs",
2140
+ imageName: "redis:7",
2141
+ })?.workloadName,
2142
+ ).toBe("mon_redis");
2143
+ // Dots alone are not a task name.
2144
+ expect(
2145
+ classifyContainer({ name: "db.internal.local", imageName: "redis:7" })
2146
+ ?.workloadName,
2147
+ ).toBe("db.internal.local");
2148
+ expect(
2149
+ classifyContainer({ name: "db.1.shortid", imageName: "redis:7" })
2150
+ ?.workloadName,
2151
+ ).toBe("db.1.shortid");
2152
+ });
2153
+
2154
+ test.each([
2155
+ [{ "org.testcontainers": "true" }],
2156
+ [{ "org.testcontainers.sessionId": "0f1e2d3c" }],
2157
+ [{ "com.docker.compose.oneoff": "True" }],
2158
+ [{ "io.kubernetes.pod.name": "orders-db-0" }],
2159
+ [{ "io.kubernetes.container.name": "postgres" }],
2160
+ ])(
2161
+ "an ephemeral or kubelet-managed container (%j) is not a database",
2162
+ (labels: Record<string, string>) => {
2163
+ expect(
2164
+ classifyContainer({
2165
+ name: "eager_turing",
2166
+ imageName: "postgres:16",
2167
+ labels,
2168
+ }),
2169
+ ).toBeNull();
2170
+ },
2171
+ );
2172
+
2173
+ test("a Compose service that is not a one-off is a database", () => {
2174
+ expect(
2175
+ classifyContainer({
2176
+ name: "shop-db-1",
2177
+ imageName: "postgres:16",
2178
+ labels: {
2179
+ "com.docker.compose.project": "shop",
2180
+ "com.docker.compose.service": "db",
2181
+ "com.docker.compose.oneoff": "False",
2182
+ },
2183
+ })?.workloadName,
2184
+ ).toBe("shop-db");
2185
+ });
2186
+
2187
+ test("labels that are not an object are ignored", () => {
2188
+ for (const labels of [null, undefined, ["a"], "x"]) {
2189
+ expect(
2190
+ classifyContainer({
2191
+ name: "shop-db-1",
2192
+ imageName: "postgres:16",
2193
+ labels: labels as unknown as Record<string, unknown>,
2194
+ })?.workloadName,
2195
+ ).toBe("shop-db-1");
2196
+ }
2197
+ });
2198
+
2199
+ /*
2200
+ * Regression (live re-verification): every Docker / Podman database was
2201
+ * stored with workloadKind "Container", so a two-replica Compose service
2202
+ * read "detected from container e2e-docker-spans-compose-postgres" (no
2203
+ * container has that name) and "workload: Container/…" in its header.
2204
+ */
2205
+ test("the workload kind says what grouped the container: a Swarm service, a Compose service, or itself", () => {
2206
+ const kindOf: (
2207
+ name: string,
2208
+ labels?: Record<string, string>,
2209
+ ) => ContainerWorkloadKind | undefined = (
2210
+ name: string,
2211
+ labels?: Record<string, string>,
2212
+ ): ContainerWorkloadKind | undefined => {
2213
+ return classifyContainer({ name, imageName: "postgres:16", labels })
2214
+ ?.workloadKind;
2215
+ };
2216
+
2217
+ expect(
2218
+ kindOf("shop-db-1", {
2219
+ "com.docker.compose.project": "shop",
2220
+ "com.docker.compose.service": "db",
2221
+ }),
2222
+ ).toBe("Compose service");
2223
+ expect(
2224
+ kindOf("shop_db_1", {
2225
+ "io.podman.compose.project": "shop",
2226
+ "io.podman.compose.service": "db",
2227
+ }),
2228
+ ).toBe("Compose service");
2229
+ expect(
2230
+ kindOf("mystack_db.1.x7y8z9abcdefghijklmnopqrs", {
2231
+ "com.docker.swarm.service.name": "mystack_db",
2232
+ // A stack's task carries Compose-style labels as well.
2233
+ "com.docker.compose.project": "mystack",
2234
+ "com.docker.compose.service": "db",
2235
+ }),
2236
+ ).toBe("Swarm service");
2237
+ // A task name alone still names its Swarm service.
2238
+ expect(kindOf("mystack_db.1.x7y8z9abcdefghijklmnopqrs")).toBe(
2239
+ "Swarm service",
2240
+ );
2241
+ // Anything else is a container of its own, half a Compose pair included.
2242
+ expect(kindOf("postgres")).toBe("Container");
2243
+ expect(kindOf("shop-db-1", { "com.docker.compose.project": "shop" })).toBe(
2244
+ "Container",
2245
+ );
2246
+ });
2247
+
2248
+ test("Docker's comma-joined Names keep the first", () => {
2249
+ expect(
2250
+ classifyContainer({ name: "/web-db,/web-db-alias", imageName: "mongo:7" })
2251
+ ?.containerName,
2252
+ ).toBe("web-db");
2253
+ });
2254
+
2255
+ test("non-database images are null", () => {
2256
+ for (const imageName of [
2257
+ "acme/api:1",
2258
+ "oliver006/redis_exporter",
2259
+ "dpage/pgadmin4",
2260
+ "bitnami/pgbouncer",
2261
+ "busybox",
2262
+ "",
2263
+ null,
2264
+ undefined,
2265
+ ]) {
2266
+ expect(classifyContainer({ name: "x", imageName })).toBeNull();
2267
+ }
2268
+ });
2269
+
2270
+ test("a blank name is null; the container id is never needed", () => {
2271
+ expect(classifyContainer({ name: " ", imageName: "redis:7" })).toBeNull();
2272
+ expect(classifyContainer({ name: "/", imageName: "redis:7" })).toBeNull();
2273
+ expect(
2274
+ classifyContainer({
2275
+ name: "cache",
2276
+ imageName: "redis:7",
2277
+ containerId: null,
2278
+ })?.system,
2279
+ ).toBe("redis");
2280
+ expect(classifyContainer(null as unknown as { name: string })).toBeNull();
2281
+ });
2282
+
2283
+ test("the classification is exact-match, like for pods", () => {
2284
+ const classification: ImageClassification = classifyImage(
2285
+ "acme/redis-cache-warmer",
2286
+ );
2287
+ expect(classification.kind).toBe("unknown");
2288
+ expect(
2289
+ classifyContainer({
2290
+ name: "warmer",
2291
+ imageName: "acme/redis-cache-warmer",
2292
+ }),
2293
+ ).toBeNull();
2294
+ });
2295
+ });
2296
+
2297
+ describe("normalizeImageRepository — republished and Red Hat images", () => {
2298
+ test.each([
2299
+ ["bitnamilegacy/postgresql:16.4.0", "bitnami/postgresql"],
2300
+ ["docker.io/bitnamilegacy/redis-cluster:7.2", "bitnami/redis-cluster"],
2301
+ ["bitnamisecure/mongodb:7.0", "bitnami/mongodb"],
2302
+ ["public.ecr.aws/bitnami/mysql:8.4", "bitnami/mysql"],
2303
+ ["docker.io/bitnami/postgresql:16", "bitnami/postgresql"],
2304
+ [
2305
+ "harbor.corp/dockerhub-proxy/bitnamilegacy/mongodb-sharded:7",
2306
+ "dockerhub-proxy/bitnami/mongodb-sharded",
2307
+ ],
2308
+ ["registry.redhat.io/rhel9/postgresql-16:1-54", "rhel9/postgresql"],
2309
+ ["registry.redhat.io/rhel8/mysql-80", "rhel8/mysql"],
2310
+ ["registry.redhat.io/rhel9/mariadb-1011", "rhel9/mariadb"],
2311
+ ["registry.redhat.io/rhel9/redis-7:latest", "rhel9/redis"],
2312
+ ["quay.io/sclorg/postgresql-15-c9s", "sclorg/postgresql"],
2313
+ ["quay.io/sclorg/mysql-80-c9s:c9s", "sclorg/mysql"],
2314
+ ["centos/postgresql-96-centos7", "centos/postgresql"],
2315
+ [
2316
+ "registry.access.redhat.com/rhscl/postgresql-10-rhel7",
2317
+ "rhscl/postgresql",
2318
+ ],
2319
+ ["quay.io/fedora/postgresql-15", "fedora/postgresql"],
2320
+ [
2321
+ "quay.io/mongodb/mongodb-enterprise-database-ubi:2.0.2",
2322
+ "mongodb/mongodb-enterprise-database",
2323
+ ],
2324
+ ["quay.io/mongodb/mongodb-agent-ubi:107.0", "mongodb/mongodb-agent"],
2325
+ ])("%s → %s", (image: string, repository: string) => {
2326
+ expect(normalizeImageRepository(image)).toBe(repository);
2327
+ });
2328
+
2329
+ test("a versioned basename outside the Software Collections namespaces keeps its name", () => {
2330
+ expect(normalizeImageRepository("bitnami/postgresql-repmgr:16")).toBe(
2331
+ "bitnami/postgresql-repmgr",
2332
+ );
2333
+ expect(normalizeImageRepository("acme/api-2:1")).toBe("acme/api-2");
2334
+ expect(normalizeImageRepository("postgresql-16")).toBe("postgresql-16");
2335
+ expect(normalizeImageRepository("rhel9/nodejs-20-minimal")).toBe(
2336
+ "rhel9/nodejs-20-minimal",
2337
+ );
2338
+ // An organisation alias never renames the image itself.
2339
+ expect(normalizeImageRepository("acme/bitnamilegacy")).toBe(
2340
+ "acme/bitnamilegacy",
2341
+ );
2342
+ });
2343
+ });
2344
+
2345
+ describe("parseImageVersion — Red Hat Software Collections", () => {
2346
+ test.each([
2347
+ ["registry.redhat.io/rhel9/postgresql-16:1-54", "16"],
2348
+ ["centos/postgresql-96-centos7", "9.6"],
2349
+ ["registry.access.redhat.com/rhscl/postgresql-10-rhel7", "10"],
2350
+ ["registry.redhat.io/rhel8/mysql-80:1", "8.0"],
2351
+ ["quay.io/sclorg/mysql-84-c10s", "8.4"],
2352
+ ["registry.redhat.io/rhel9/mariadb-1011", "10.11"],
2353
+ ["registry.redhat.io/rhel8/mariadb-105", "10.5"],
2354
+ ["registry.redhat.io/rhel9/redis-7:1-12", "7"],
2355
+ ["centos/mongodb-36-centos7", "3.6"],
2356
+ ])(
2357
+ "%s → %s (the name, never the image build in the tag)",
2358
+ (image: string, version: string) => {
2359
+ expect(parseImageVersion(image)).toBe(version);
2360
+ },
2361
+ );
2362
+
2363
+ test("republished images keep their tag version", () => {
2364
+ expect(parseImageVersion("bitnamilegacy/postgresql:16.4.0-debian-12")).toBe(
2365
+ "16.4.0",
2366
+ );
2367
+ });
2368
+ });
2369
+
2370
+ describe("classifyImage — registries, republishers and sidecars", () => {
2371
+ test.each([
2372
+ ["bitnamilegacy/postgresql:16", "postgresql"],
2373
+ ["bitnamilegacy/mongodb:7.0", "mongodb"],
2374
+ ["bitnamilegacy/redis-cluster:7.2", "redis"],
2375
+ ["bitnamilegacy/postgresql-repmgr:16", "postgresql"],
2376
+ ["bitnamisecure/postgresql:17", "postgresql"],
2377
+ ["public.ecr.aws/bitnami/mongodb-sharded:7", "mongodb"],
2378
+ ["registry.redhat.io/rhel9/postgresql-16", "postgresql"],
2379
+ ["quay.io/sclorg/postgresql-15-c9s", "postgresql"],
2380
+ ["registry.redhat.io/rhel8/mysql-80", "mysql"],
2381
+ ["registry.redhat.io/rhel9/redis-7", "redis"],
2382
+ ["quay.io/mongodb/mongodb-enterprise-database-ubi:2.0", "mongodb"],
2383
+ ])("%s is %s", (image: string, system: string) => {
2384
+ expect(classifyImage(image)).toEqual({ kind: "database", system });
2385
+ });
2386
+
2387
+ test.each([
2388
+ ["timberio/vector:0.34.0-debian"],
2389
+ ["docker.io/vectordotdev/vector:0.39.0-distroless-libc"],
2390
+ ["public.ecr.aws/appmesh/aws-appmesh-envoy:v1.29.5.0-prod"],
2391
+ ["tailscale/tailscale:v1.70"],
2392
+ ["cloudflare/cloudflared:2024.6.1"],
2393
+ ["percona/pmm-client:2.41.0"],
2394
+ ["gcr.io/datadoghq/agent:7"],
2395
+ ["jaegertracing/jaeger-agent:1.57"],
2396
+ ["docker.io/bitnamilegacy/os-shell:12"],
2397
+ ["quay.io/signalfx/splunk-otel-collector:0.100.0"],
2398
+ ])("%s is an infrastructure sidecar", (image: string) => {
2399
+ expect(classifyImage(image)).toEqual({ kind: "infrastructure-sidecar" });
2400
+ });
2401
+
2402
+ test.each([
2403
+ ["prodrigestivill/postgres-backup-local:16"],
2404
+ ["schickling/postgres-backup-s3"],
2405
+ ["databack/mysql-backup:1.0"],
2406
+ ["tiredofit/db-backup:4"],
2407
+ ])("%s is a backup tool", (image: string) => {
2408
+ expect(classifyImage(image)).toEqual({
2409
+ kind: "excluded",
2410
+ reason: "backup",
2411
+ });
2412
+ });
2413
+ });
2414
+
2415
+ describe("classifyContainerCommand / isNonServerCommand", () => {
2416
+ test.each<
2417
+ [
2418
+ {
2419
+ command?: Array<string | null> | undefined;
2420
+ args?: Array<string | null> | undefined;
2421
+ },
2422
+ ContainerCommandRole,
2423
+ ]
2424
+ >([
2425
+ [{}, "server"],
2426
+ [{ command: [], args: [] }, "server"],
2427
+ [{ args: ["postgres", "-c", "max_connections=200"] }, "server"],
2428
+ [{ args: ["-c", "config_file=/etc/pg.conf"] }, "server"],
2429
+ [{ command: ["docker-entrypoint.sh"], args: ["postgres"] }, "server"],
2430
+ [{ command: ["/usr/bin/psql", "-h", "db"] }, "client"],
2431
+ [{ args: ["psql", "-h", "prod-db"] }, "client"],
2432
+ [{ command: ["redis-cli"], args: ["-h", "cache"] }, "client"],
2433
+ [{ command: ["sh", "-c", "psql -h db -f /x.sql"] }, "client"],
2434
+ [
2435
+ { command: ["/bin/bash"], args: ["-ec", "exec redis-server /conf"] },
2436
+ "server",
2437
+ ],
2438
+ [
2439
+ { command: ["/bin/bash", "-c"], args: ["/opt/bitnami/scripts/start.sh"] },
2440
+ "server",
2441
+ ],
2442
+ [{ command: ["bash"] }, "keep-alive"],
2443
+ [{ command: ["sh", "-x"] }, "keep-alive"],
2444
+ [{ args: ["sleep", "infinity"] }, "keep-alive"],
2445
+ [{ command: ["SH", "-c", " SLEEP 3600"] }, "keep-alive"],
2446
+ [{ command: ["sh", "-c", "exec"] }, "server"],
2447
+ // Launchers are looked through to the program they start.
2448
+ [{ command: ["tini", "--", "psql"] }, "client"],
2449
+ [{ command: ["env", "PGPASSWORD=x", "psql"] }, "client"],
2450
+ [{ command: ["tini", "--", "docker-entrypoint.sh", "postgres"] }, "server"],
2451
+ [{ command: ["timeout", "30", "psql"] }, "server"],
2452
+ [{ command: [null, "x"] }, "server"],
2453
+ [{ command: ["redis-sentinel", "/conf"] }, "companion"],
2454
+ ])(
2455
+ "%j → %s",
2456
+ (
2457
+ container: {
2458
+ command?: Array<string | null> | undefined;
2459
+ args?: Array<string | null> | undefined;
2460
+ },
2461
+ role: ContainerCommandRole,
2462
+ ) => {
2463
+ expect(classifyContainerCommand(container)).toBe(role);
2464
+ },
2465
+ );
2466
+
2467
+ test("clients, dump tools and keep-alives are not servers; the default entrypoint is", () => {
2468
+ for (const container of [
2469
+ { args: ["psql", "-h", "db"] },
2470
+ { command: ["pg_dump", "-Fc"] },
2471
+ { command: ["mongosh"] },
2472
+ { command: ["mysql"], args: ["-h", "db"] },
2473
+ { command: ["redis-cli", "monitor"] },
2474
+ { command: ["sleep", "infinity"] },
2475
+ { command: ["tail", "-f", "/dev/null"] },
2476
+ { command: ["bash"] },
2477
+ { command: ["sh", "-c", "exec valkey-cli -h cache"] },
2478
+ ]) {
2479
+ expect(isNonServerCommand(container)).toBe(true);
2480
+ }
2481
+ for (const container of [
2482
+ {},
2483
+ { args: ["postgres"] },
2484
+ { args: ["--requirepass", "x"] },
2485
+ { command: ["docker-entrypoint.sh", "mysqld"] },
2486
+ { command: ["/bin/bash"], args: ["-ec", "/opt/bitnami/scripts/run.sh"] },
2487
+ { command: ["mongod", "--replSet", "rs0"] },
2488
+ ]) {
2489
+ expect(isNonServerCommand(container)).toBe(false);
2490
+ }
2491
+ });
2492
+ });
2493
+
2494
+ describe("classifyKubernetesPod — client runs and one-off pods are not databases", () => {
2495
+ test("`kubectl run --rm -it psql --image=postgres -- psql -h prod-db` (no owner, no port)", () => {
2496
+ expect(
2497
+ classifyKubernetesPod(
2498
+ pod({
2499
+ name: "psql",
2500
+ containers: [
2501
+ {
2502
+ name: "psql",
2503
+ image: "postgres:16",
2504
+ args: ["psql", "-h", "prod-db"],
2505
+ },
2506
+ ],
2507
+ }),
2508
+ ),
2509
+ ).toBeNull();
2510
+ });
2511
+
2512
+ test("init containers are never read: a migration step in a database image is not a database", () => {
2513
+ const withInit: KubernetesPodLike = {
2514
+ ...pod({
2515
+ name: "api-5f6d7-x",
2516
+ labels: { "pod-template-hash": "5f6d7" },
2517
+ owner: { kind: "ReplicaSet", name: "api-5f6d7" },
2518
+ containers: [{ name: "api", image: "acme/api:1" }],
2519
+ }),
2520
+ };
2521
+ (withInit.spec as Record<string, unknown>)["initContainers"] = [
2522
+ {
2523
+ name: "wait-for-db",
2524
+ image: "postgres:16",
2525
+ command: ["sh", "-c", "until pg_isready -h db; do sleep 1; done"],
2526
+ },
2527
+ ];
2528
+ expect(classifyKubernetesPod(withInit)).toBeNull();
2529
+ });
2530
+
2531
+ test("an owner-less pod running the server but declaring no port is still a one-off", () => {
2532
+ expect(
2533
+ classifyKubernetesPod(
2534
+ pod({
2535
+ name: "tmp-db",
2536
+ containers: [{ name: "postgres", image: "postgres:16" }],
2537
+ }),
2538
+ ),
2539
+ ).toBeNull();
2540
+ });
2541
+
2542
+ test("a client run is rejected even when it declares a port or has an owner", () => {
2543
+ expect(
2544
+ classifyKubernetesPod(
2545
+ pod({
2546
+ name: "psql",
2547
+ containers: [
2548
+ {
2549
+ name: "psql",
2550
+ image: "postgres:16",
2551
+ ports: [port(5432)],
2552
+ command: ["psql"],
2553
+ },
2554
+ ],
2555
+ }),
2556
+ ),
2557
+ ).toBeNull();
2558
+ expect(
2559
+ classifyKubernetesPod(
2560
+ pod({
2561
+ name: "redis-monitor-6d9f-x",
2562
+ labels: { "pod-template-hash": "6d9f" },
2563
+ owner: { kind: "ReplicaSet", name: "redis-monitor-6d9f" },
2564
+ containers: [
2565
+ {
2566
+ name: "cli",
2567
+ image: "redis:7",
2568
+ command: ["redis-cli", "-h", "cache", "monitor"],
2569
+ },
2570
+ ],
2571
+ }),
2572
+ ),
2573
+ ).toBeNull();
2574
+ });
2575
+
2576
+ test.each([
2577
+ // The end-to-end kind cluster's pg-client-envprefix and pg-client-loop.
2578
+ [
2579
+ [
2580
+ "sh",
2581
+ "-c",
2582
+ "PGPASSWORD=e2e-pg-pass psql -h postgres -U postgres -c 'select pg_sleep(100000)'",
2583
+ ],
2584
+ ],
2585
+ [
2586
+ [
2587
+ "sh",
2588
+ "-c",
2589
+ "while true; do psql -h postgres -U postgres -c 'select 1' >/dev/null 2>&1; sleep 15; done",
2590
+ ],
2591
+ ],
2592
+ // The same client behind the launchers images and charts use.
2593
+ [["env", "PGPASSWORD=e2e-pg-pass", "psql", "-h", "postgres"]],
2594
+ [["/usr/bin/env", "-i", "PGHOST=postgres", "psql", "-c", "select 1"]],
2595
+ [["nohup", "psql", "-h", "postgres"]],
2596
+ [["/sbin/tini", "--", "psql", "-h", "postgres"]],
2597
+ [["dumb-init", "--", "sh", "-c", "exec env PGPASSWORD=x psql -h db"]],
2598
+ [["docker-entrypoint.sh", "sleep", "infinity"]],
2599
+ ])(
2600
+ "regression: a client Deployment of a database image (%j) is not a database",
2601
+ (command: Array<string>) => {
2602
+ expect(
2603
+ classifyKubernetesPod(
2604
+ pod({
2605
+ name: "pg-client-loop-58fdcb4994-4v59m",
2606
+ namespace: "data",
2607
+ labels: {
2608
+ app: "pg-client-loop",
2609
+ "pod-template-hash": "58fdcb4994",
2610
+ },
2611
+ owner: { kind: "ReplicaSet", name: "pg-client-loop-58fdcb4994" },
2612
+ containers: [{ name: "client", image: "postgres:16", command }],
2613
+ }),
2614
+ ),
2615
+ ).toBeNull();
2616
+ },
2617
+ );
2618
+
2619
+ test("a server behind a launcher is still a server", () => {
2620
+ expect(
2621
+ classifyKubernetesPod(
2622
+ pod({
2623
+ name: "orders-db-0",
2624
+ owner: { kind: "StatefulSet", name: "orders-db" },
2625
+ containers: [
2626
+ {
2627
+ name: "postgres",
2628
+ image: "postgres:16",
2629
+ ports: [port(5432)],
2630
+ command: ["/sbin/tini", "--", "docker-entrypoint.sh"],
2631
+ args: ["-c", "max_connections=200"],
2632
+ },
2633
+ ],
2634
+ }),
2635
+ )?.system,
2636
+ ).toBe("postgresql");
2637
+ });
2638
+
2639
+ test.each([
2640
+ [["sleep", "infinity"]],
2641
+ [["tail", "-f", "/dev/null"]],
2642
+ [["bash"]],
2643
+ [["sh", "-c", "sleep 3600"]],
2644
+ ])(
2645
+ "a debug pod in a database image (%j) is not a database",
2646
+ (command: Array<string>) => {
2647
+ expect(
2648
+ classifyKubernetesPod(
2649
+ pod({
2650
+ name: "debug-0",
2651
+ owner: { kind: "StatefulSet", name: "debug" },
2652
+ containers: [
2653
+ {
2654
+ name: "pg",
2655
+ image: "postgres:16",
2656
+ ports: [port(5432)],
2657
+ command,
2658
+ },
2659
+ ],
2660
+ }),
2661
+ ),
2662
+ ).toBeNull();
2663
+ },
2664
+ );
2665
+
2666
+ test("a server started through a shell script is still a server", () => {
2667
+ expect(
2668
+ classifyKubernetesPod(
2669
+ pod({
2670
+ name: "cache-0",
2671
+ owner: { kind: "StatefulSet", name: "cache" },
2672
+ containers: [
2673
+ {
2674
+ name: "redis",
2675
+ image: "bitnami/redis:7.2",
2676
+ command: ["/bin/bash"],
2677
+ args: [
2678
+ "-ec",
2679
+ "exec /opt/bitnami/scripts/start-scripts/start-master.sh",
2680
+ ],
2681
+ },
2682
+ ],
2683
+ }),
2684
+ )?.system,
2685
+ ).toBe("redis");
2686
+ });
2687
+
2688
+ test("the server container is picked over a client sidecar in the same image", () => {
2689
+ expect(
2690
+ classifyKubernetesPod(
2691
+ pod({
2692
+ name: "pg-0",
2693
+ owner: { kind: "StatefulSet", name: "pg" },
2694
+ containers: [
2695
+ {
2696
+ name: "wait-and-migrate",
2697
+ image: "postgres:15",
2698
+ command: ["sh", "-c", "psql -f /migrations.sql"],
2699
+ },
2700
+ { name: "postgres", image: "postgres:16.3", ports: [port(5432)] },
2701
+ ],
2702
+ }),
2703
+ ),
2704
+ ).toMatchObject({ containerName: "postgres", version: "16.3" });
2705
+ });
2706
+
2707
+ test("a chart's test hook pod (chart labels, a client command) is not a member", () => {
2708
+ expect(
2709
+ classifyKubernetesPod(
2710
+ pod({
2711
+ name: "cache-redis-test-connection",
2712
+ labels: {
2713
+ "app.kubernetes.io/name": "redis",
2714
+ "app.kubernetes.io/instance": "cache",
2715
+ },
2716
+ containers: [
2717
+ {
2718
+ name: "test",
2719
+ image: "bitnami/redis:7.2",
2720
+ command: ["redis-cli", "-h", "cache-redis-master", "ping"],
2721
+ },
2722
+ ],
2723
+ }),
2724
+ ),
2725
+ ).toBeNull();
2726
+ });
2727
+
2728
+ test("a labelled member with a client sidecar keeps the server container", () => {
2729
+ expect(
2730
+ classifyKubernetesPod(
2731
+ pod({
2732
+ name: "pg-main-1",
2733
+ labels: { "cnpg.io/cluster": "pg-main" },
2734
+ owner: { kind: "Cluster", name: "pg-main" },
2735
+ containers: [
2736
+ { name: "probe", image: "postgres:16", command: ["pg_isready"] },
2737
+ {
2738
+ name: "postgres",
2739
+ image: "ghcr.io/cloudnative-pg/postgresql:16.3",
2740
+ },
2741
+ ],
2742
+ }),
2743
+ ),
2744
+ ).toMatchObject({ containerName: "postgres", version: "16.3" });
2745
+ });
2746
+ });
2747
+
2748
+ describe("classifyKubernetesPod — servers started through shells and scripts are databases", () => {
2749
+ const MONGOD_SCRIPT: string =
2750
+ '\nif [ -e "/hooks/version-upgrade" ]; then\n\t#run post-start hook to handle version changes (if exists)\n /hooks/version-upgrade\nfi\n\n# wait for config and keyfile to be created by the agent\n while ! [ -f /data/automation-mongod.conf -a -f /var/lib/mongodb-mms-automation/authentication/keyfile ]; do sleep 3 ; done ; sleep 2 ;\n\n# start mongod with this configuration\nexec mongod -f /data/automation-mongod.conf;\n\n';
2751
+
2752
+ function mongoCommunityPod(command: Array<string | null>): KubernetesPodLike {
2753
+ return pod({
2754
+ name: "orders-mongo-0",
2755
+ labels: { app: "orders-mongo-svc" },
2756
+ owner: { kind: "StatefulSet", name: "orders-mongo" },
2757
+ containers: [
2758
+ {
2759
+ name: "mongod",
2760
+ image: "quay.io/mongodb/mongodb-community-server:7.0.12-ubi8",
2761
+ command,
2762
+ },
2763
+ {
2764
+ name: "mongodb-agent",
2765
+ image: "quay.io/mongodb/mongodb-agent-ubi:107.0.12.8669-1",
2766
+ command: ["/bin/bash", "-c", "agent/mongodb-agent -noDaemonize"],
2767
+ },
2768
+ ],
2769
+ });
2770
+ }
2771
+
2772
+ test("regression: a MongoDB Community Operator member (newline-led sh -c script) is MongoDB", () => {
2773
+ expect(
2774
+ classifyKubernetesPod(
2775
+ mongoCommunityPod(["/bin/sh", "-c", MONGOD_SCRIPT]),
2776
+ ),
2777
+ ).toMatchObject({
2778
+ system: "mongodb",
2779
+ workloadKind: "StatefulSet",
2780
+ workloadName: "orders-mongo",
2781
+ containerName: "mongod",
2782
+ version: "7.0.12",
2783
+ });
2784
+ // A script the projection could not read is still a possible server.
2785
+ expect(
2786
+ classifyKubernetesPod(mongoCommunityPod(["/bin/sh", "-c", null])),
2787
+ ).toMatchObject({ system: "mongodb", containerName: "mongod" });
2788
+ });
2789
+
2790
+ test("regression: CockroachDB's StatefulSet (bash -ecx 'exec cockroach start') is CockroachDB, plain or chart-labelled", () => {
2791
+ for (const labels of [
2792
+ { app: "cockroachdb" },
2793
+ {
2794
+ "app.kubernetes.io/name": "cockroachdb",
2795
+ "app.kubernetes.io/instance": "crdb",
2796
+ },
2797
+ ]) {
2798
+ expect(
2799
+ classifyKubernetesPod(
2800
+ pod({
2801
+ name: "cockroachdb-0",
2802
+ labels,
2803
+ owner: { kind: "StatefulSet", name: "cockroachdb" },
2804
+ containers: [
2805
+ {
2806
+ name: "cockroachdb",
2807
+ image: "cockroachdb/cockroach:v24.1.0",
2808
+ ports: [port(26257), port(8080)],
2809
+ command: [
2810
+ "/bin/bash",
2811
+ "-ecx",
2812
+ "exec /cockroach/cockroach start --logtostderr --insecure --join cockroachdb-0.cockroachdb",
2813
+ ],
2814
+ },
2815
+ ],
2816
+ }),
2817
+ ),
2818
+ ).toMatchObject({ system: "cockroachdb", containerName: "cockroachdb" });
2819
+ }
2820
+ });
2821
+
2822
+ test.each([
2823
+ [["/bin/bash", "/scripts/start-redis.sh"]],
2824
+ [["sh", "/docker-entrypoint-initdb.d/run.sh"]],
2825
+ [["bash", "-cex", "exec redis-server /conf/redis.conf"]],
2826
+ [["bash", "-c", "#!/bin/bash\nset -euo pipefail\nexec redis-server"]],
2827
+ [["sh", "-c", "sleep 5 && exec redis-server /conf/redis.conf"]],
2828
+ [
2829
+ [
2830
+ "sh",
2831
+ "-c",
2832
+ "cat /tmpl > /etc/redis.conf; exec redis-server /etc/redis.conf",
2833
+ ],
2834
+ ],
2835
+ [
2836
+ [
2837
+ "sh",
2838
+ "-c",
2839
+ "until nslookup redis-0.redis; do sleep 2; done\nexec redis-server",
2840
+ ],
2841
+ ],
2842
+ [["sh", "-c", "\texec redis-server"]],
2843
+ ])(
2844
+ "regression: a StatefulSet started with %j is Redis",
2845
+ (command: Array<string>) => {
2846
+ expect(
2847
+ classifyKubernetesPod(
2848
+ pod({
2849
+ name: "cache-0",
2850
+ owner: { kind: "StatefulSet", name: "cache" },
2851
+ containers: [
2852
+ {
2853
+ name: "redis",
2854
+ image: "redis:7.2",
2855
+ ports: [port(6379)],
2856
+ command,
2857
+ },
2858
+ ],
2859
+ }),
2860
+ ),
2861
+ ).toMatchObject({ system: "redis", containerName: "redis" });
2862
+ },
2863
+ );
2864
+
2865
+ test("a Sentinel in the database image is not the database: the server container is picked", () => {
2866
+ expect(
2867
+ classifyKubernetesPod(
2868
+ pod({
2869
+ name: "cache-ha-server-0",
2870
+ owner: { kind: "StatefulSet", name: "cache-ha-server" },
2871
+ containers: [
2872
+ {
2873
+ name: "sentinel",
2874
+ image: "redis:7.2",
2875
+ ports: [port(26379)],
2876
+ command: ["redis-sentinel", "/data/conf/sentinel.conf"],
2877
+ },
2878
+ {
2879
+ name: "redis",
2880
+ image: "redis:7.2",
2881
+ ports: [port(6379)],
2882
+ command: ["redis-server", "/data/conf/redis.conf"],
2883
+ },
2884
+ ],
2885
+ }),
2886
+ ),
2887
+ ).toMatchObject({ system: "redis", containerName: "redis", ports: [6379] });
2888
+ });
2889
+
2890
+ test("a Sentinel-only pod in the database image is not a database", () => {
2891
+ expect(
2892
+ classifyKubernetesPod(
2893
+ pod({
2894
+ name: "rfs-cache-5d8f-x",
2895
+ labels: { "pod-template-hash": "5d8f" },
2896
+ owner: { kind: "ReplicaSet", name: "rfs-cache-5d8f" },
2897
+ containers: [
2898
+ {
2899
+ name: "sentinel",
2900
+ image: "redis:7.2",
2901
+ ports: [port(26379)],
2902
+ command: ["redis-server", "/redis/sentinel.conf", "--sentinel"],
2903
+ },
2904
+ ],
2905
+ }),
2906
+ ),
2907
+ ).toBeNull();
2908
+ });
2909
+ });
2910
+
2911
+ describe("classifyKubernetesPod — a labelled pod whose database container does not run", () => {
2912
+ function labelledPod(sidecar: ContainerFixture): KubernetesPodLike {
2913
+ return pod({
2914
+ name: "orders-postgresql-0",
2915
+ labels: {
2916
+ "app.kubernetes.io/name": "postgresql",
2917
+ "app.kubernetes.io/instance": "orders",
2918
+ },
2919
+ owner: { kind: "StatefulSet", name: "orders-postgresql" },
2920
+ containers: [
2921
+ {
2922
+ name: "postgresql",
2923
+ image: "docker.io/bitnami/postgresql:16.2.0",
2924
+ ports: [port(5432)],
2925
+ command: ["sleep"],
2926
+ args: ["infinity"],
2927
+ },
2928
+ sidecar,
2929
+ ],
2930
+ });
2931
+ }
2932
+
2933
+ test.each([
2934
+ [
2935
+ {
2936
+ name: "metrics",
2937
+ image: "docker.io/bitnami/postgres-exporter:0.15.0-debian-12-r14",
2938
+ ports: [port(9187)],
2939
+ },
2940
+ ],
2941
+ [
2942
+ {
2943
+ name: "pgbouncer",
2944
+ image: "docker.io/bitnami/pgbouncer:1.22.0",
2945
+ ports: [port(6432)],
2946
+ },
2947
+ ],
2948
+ [{ name: "istio-proxy", image: "docker.io/istio/proxyv2:1.22.0" }],
2949
+ [
2950
+ {
2951
+ name: "sentinel",
2952
+ image: "docker.io/bitnami/redis-sentinel:7.2",
2953
+ ports: [port(26379)],
2954
+ },
2955
+ ],
2956
+ ])(
2957
+ "regression: only %j still runs → not a member (never the sidecar's version and ports)",
2958
+ (sidecar: ContainerFixture) => {
2959
+ expect(classifyKubernetesPod(labelledPod(sidecar))).toBeNull();
2960
+ },
2961
+ );
2962
+
2963
+ test("an unrecognised running container is still a possible database", () => {
2964
+ expect(
2965
+ classifyKubernetesPod(
2966
+ labelledPod({ name: "custom", image: "acme/pg-custom:3" }),
2967
+ ),
2968
+ ).toMatchObject({ system: "postgresql", containerName: "custom" });
2969
+ });
2970
+ });
2971
+
2972
+ describe("classifyKubernetesPod — an uncatalogued neighbour of a database StatefulSet", () => {
2973
+ function statefulSetPod(data: {
2974
+ owner?: { kind: string; name: string } | null;
2975
+ databasePorts?: Array<number>;
2976
+ neighbours: Array<ContainerFixture>;
2977
+ }): KubernetesPodLike {
2978
+ return pod({
2979
+ name: "pg-0",
2980
+ owner:
2981
+ data.owner === undefined
2982
+ ? { kind: "StatefulSet", name: "pg" }
2983
+ : data.owner,
2984
+ containers: [
2985
+ {
2986
+ name: "postgres",
2987
+ image: "postgres:16",
2988
+ ports: (data.databasePorts ?? [5432]).map(port),
2989
+ },
2990
+ ...data.neighbours,
2991
+ ],
2992
+ });
2993
+ }
2994
+
2995
+ test("one unknown sidecar is tolerated when the database declares its default port", () => {
2996
+ expect(
2997
+ classifyKubernetesPod(
2998
+ statefulSetPod({
2999
+ neighbours: [{ name: "shipper", image: "acme/log-shipper:3" }],
3000
+ }),
3001
+ ),
3002
+ ).toMatchObject({ system: "postgresql", workloadName: "pg" });
3003
+ });
3004
+
3005
+ test("…but not without the default port, in a Deployment, or with two unknown containers", () => {
3006
+ expect(
3007
+ classifyKubernetesPod(
3008
+ statefulSetPod({
3009
+ databasePorts: [15432],
3010
+ neighbours: [{ name: "shipper", image: "acme/log-shipper:3" }],
3011
+ }),
3012
+ ),
3013
+ ).toBeNull();
3014
+ expect(
3015
+ classifyKubernetesPod(
3016
+ statefulSetPod({
3017
+ owner: { kind: "Deployment", name: "pg" },
3018
+ neighbours: [{ name: "api", image: "acme/api:1" }],
3019
+ }),
3020
+ ),
3021
+ ).toBeNull();
3022
+ expect(
3023
+ classifyKubernetesPod(
3024
+ statefulSetPod({
3025
+ neighbours: [
3026
+ { name: "api", image: "acme/api:1" },
3027
+ { name: "worker", image: "acme/worker:1" },
3028
+ ],
3029
+ }),
3030
+ ),
3031
+ ).toBeNull();
3032
+ });
3033
+
3034
+ test("catalogued sidecars need no exception at all", () => {
3035
+ expect(
3036
+ classifyKubernetesPod(
3037
+ statefulSetPod({
3038
+ databasePorts: [],
3039
+ neighbours: [
3040
+ { name: "vector", image: "vectordotdev/vector:0.39" },
3041
+ { name: "backup", image: "prodrigestivill/postgres-backup-local" },
3042
+ {
3043
+ name: "mesh",
3044
+ image: "public.ecr.aws/appmesh/aws-appmesh-envoy:v1",
3045
+ },
3046
+ { name: "tailscale", image: "tailscale/tailscale:v1.70" },
3047
+ ],
3048
+ }),
3049
+ )?.system,
3050
+ ).toBe("postgresql");
3051
+ });
3052
+ });
3053
+
3054
+ describe("classifyKubernetesPod — the Service names clients use", () => {
3055
+ test("a chart with fullnameOverride: the owner StatefulSet and its headless Service are aliases", () => {
3056
+ const candidate: KubernetesDatabaseCandidate | null = classifyKubernetesPod(
3057
+ pod({
3058
+ name: "postgres-0",
3059
+ labels: {
3060
+ "app.kubernetes.io/name": "postgresql",
3061
+ "app.kubernetes.io/instance": "rel",
3062
+ "app.kubernetes.io/component": "primary",
3063
+ },
3064
+ owner: { kind: "StatefulSet", name: "postgres" },
3065
+ containers: [
3066
+ {
3067
+ name: "postgresql",
3068
+ image: "bitnami/postgresql:16",
3069
+ ports: [port(5432)],
3070
+ },
3071
+ ],
3072
+ }),
3073
+ { statefulSetServiceNames: { postgres: "postgres-hl" } },
3074
+ );
3075
+ expect(candidate?.workloadName).toBe("rel-postgresql");
3076
+ expect(candidate?.serviceNames).toEqual(
3077
+ expect.arrayContaining(["postgres", "postgres-hl"]),
3078
+ );
3079
+ });
3080
+
3081
+ test("a chart-labelled Deployment keeps its Deployment as the owner workload", () => {
3082
+ expect(
3083
+ classifyKubernetesPod(
3084
+ pod({
3085
+ name: "cache-memcached-7d9f8-x2x4z",
3086
+ labels: {
3087
+ "app.kubernetes.io/name": "memcached",
3088
+ "app.kubernetes.io/instance": "cache",
3089
+ "pod-template-hash": "7d9f8",
3090
+ },
3091
+ owner: { kind: "ReplicaSet", name: "cache-memcached-7d9f8" },
3092
+ containers: [{ name: "memcached", image: "bitnami/memcached:1.6" }],
3093
+ }),
3094
+ ),
3095
+ ).toMatchObject({
3096
+ workloadKind: "Cluster",
3097
+ workloadName: "cache-memcached",
3098
+ ownerKind: "Deployment",
3099
+ ownerName: "cache-memcached",
3100
+ });
3101
+ });
3102
+
3103
+ test("a ReplicaSet without its hash is not a Service name", () => {
3104
+ expect(
3105
+ classifyKubernetesPod(
3106
+ pod({
3107
+ name: "x-abc-1",
3108
+ labels: {
3109
+ "app.kubernetes.io/name": "redis",
3110
+ "app.kubernetes.io/instance": "x",
3111
+ },
3112
+ owner: { kind: "ReplicaSet", name: "x-abc" },
3113
+ containers: [{ name: "redis", image: "redis:7" }],
3114
+ }),
3115
+ )?.serviceNames,
3116
+ ).not.toContain("x-abc");
3117
+ });
3118
+
3119
+ test("Percona Server for MySQL: the primary, router and HAProxy Services", () => {
3120
+ expect(
3121
+ classifyKubernetesPod(
3122
+ pod({
3123
+ name: "ps1-mysql-0",
3124
+ labels: {
3125
+ "app.kubernetes.io/managed-by": "percona-server-mysql-operator",
3126
+ "app.kubernetes.io/name": "percona-server",
3127
+ "app.kubernetes.io/instance": "ps1",
3128
+ "app.kubernetes.io/component": "mysql",
3129
+ },
3130
+ owner: { kind: "StatefulSet", name: "ps1-mysql" },
3131
+ containers: [{ name: "mysql", image: "percona/percona-server:8.0" }],
3132
+ }),
3133
+ )?.serviceNames,
3134
+ ).toEqual([
3135
+ "ps1",
3136
+ "ps1-mysql",
3137
+ "ps1-mysql-primary",
3138
+ "ps1-haproxy",
3139
+ "ps1-router",
3140
+ ]);
3141
+ });
3142
+ });
3143
+
3144
+ describe("CloudNativePG poolers", () => {
3145
+ const poolerPod: KubernetesPodLike = pod({
3146
+ name: "pg-main-pooler-rw-6d9f-x",
3147
+ namespace: "db",
3148
+ labels: {
3149
+ "cnpg.io/cluster": "pg-main",
3150
+ "cnpg.io/poolerName": "pg-main-pooler-rw",
3151
+ },
3152
+ owner: { kind: "ReplicaSet", name: "pg-main-pooler-rw-6d9f" },
3153
+ containers: [
3154
+ { name: "pgbouncer", image: "ghcr.io/cloudnative-pg/pgbouncer:1.22" },
3155
+ ],
3156
+ });
3157
+
3158
+ test("a pooler pod is not a member but names the Service it backs", () => {
3159
+ expect(classifyKubernetesPod(poolerPod)).toBeNull();
3160
+ expect(classifyKubernetesPoolerPod(poolerPod)).toEqual({
3161
+ system: "postgresql",
3162
+ namespace: "db",
3163
+ clusterName: "pg-main",
3164
+ serviceName: "pg-main-pooler-rw",
3165
+ });
3166
+ });
3167
+
3168
+ test("only CloudNativePG pooler pods, and never finished ones, name a pooler", () => {
3169
+ expect(
3170
+ classifyKubernetesPoolerPod({ ...poolerPod, phase: "Succeeded" }),
3171
+ ).toBeNull();
3172
+ expect(
3173
+ classifyKubernetesPoolerPod(
3174
+ pod({
3175
+ name: "pg-main-1",
3176
+ labels: { "cnpg.io/cluster": "pg-main" },
3177
+ containers: [{ name: "postgres", image: "postgres:16" }],
3178
+ }),
3179
+ ),
3180
+ ).toBeNull();
3181
+ expect(
3182
+ classifyKubernetesPoolerPod(null as unknown as KubernetesPodLike),
3183
+ ).toBeNull();
3184
+ });
3185
+
3186
+ test("attachPoolerServices adds the pooler Service to its cluster only", () => {
3187
+ const member: KubernetesDatabaseCandidate = classifyKubernetesPod(
3188
+ pod({
3189
+ name: "pg-main-1",
3190
+ namespace: "db",
3191
+ labels: { "cnpg.io/cluster": "pg-main" },
3192
+ owner: { kind: "Cluster", name: "pg-main" },
3193
+ containers: [
3194
+ { name: "postgres", image: "ghcr.io/cloudnative-pg/postgresql:16" },
3195
+ ],
3196
+ }),
3197
+ )!;
3198
+ const other: KubernetesDatabaseCandidate = {
3199
+ ...member,
3200
+ namespace: "elsewhere",
3201
+ };
3202
+ const pooler: KubernetesPoolerService =
3203
+ classifyKubernetesPoolerPod(poolerPod)!;
3204
+
3205
+ const groups: Array<KubernetesDatabaseGroup> = attachPoolerServices(
3206
+ groupKubernetesDatabaseCandidates([member, other]),
3207
+ [pooler, { ...pooler, clusterName: "another" }],
3208
+ );
3209
+
3210
+ expect(
3211
+ groups.find((group: KubernetesDatabaseGroup): boolean => {
3212
+ return group.namespace === "db";
3213
+ })?.serviceNames,
3214
+ ).toEqual([
3215
+ "pg-main",
3216
+ "pg-main-rw",
3217
+ "pg-main-ro",
3218
+ "pg-main-r",
3219
+ "pg-main-pooler-rw",
3220
+ ]);
3221
+ expect(
3222
+ groups.find((group: KubernetesDatabaseGroup): boolean => {
3223
+ return group.namespace === "elsewhere";
3224
+ })?.serviceNames,
3225
+ ).not.toContain("pg-main-pooler-rw");
3226
+ expect(attachPoolerServices(groups, [])).toEqual(groups);
3227
+ expect(
3228
+ attachPoolerServices(
3229
+ undefined as unknown as Array<KubernetesDatabaseGroup>,
3230
+ [],
3231
+ ),
3232
+ ).toEqual([]);
3233
+ });
3234
+ });
3235
+
3236
+ describe("groupKubernetesDatabaseCandidates — owner workloads", () => {
3237
+ test("the Deployments / StatefulSets behind a cluster's members are unioned and sorted", () => {
3238
+ const labels: Record<string, string> = {
3239
+ "app.kubernetes.io/name": "redis",
3240
+ "app.kubernetes.io/instance": "cache",
3241
+ };
3242
+ const members: Array<KubernetesDatabaseCandidate> = [
3243
+ classifyKubernetesPod(
3244
+ pod({
3245
+ name: "cache-redis-replicas-0",
3246
+ labels,
3247
+ owner: { kind: "StatefulSet", name: "cache-redis-replicas" },
3248
+ containers: [{ name: "redis", image: "redis:7" }],
3249
+ }),
3250
+ )!,
3251
+ classifyKubernetesPod(
3252
+ pod({
3253
+ name: "cache-redis-master-0",
3254
+ labels,
3255
+ owner: { kind: "StatefulSet", name: "cache-redis-master" },
3256
+ containers: [{ name: "redis", image: "redis:7" }],
3257
+ }),
3258
+ )!,
3259
+ classifyKubernetesPod(
3260
+ pod({
3261
+ name: "cache-redis-master-1",
3262
+ labels,
3263
+ owner: { kind: "StatefulSet", name: "cache-redis-master" },
3264
+ containers: [{ name: "redis", image: "redis:7" }],
3265
+ }),
3266
+ )!,
3267
+ ];
3268
+ expect(
3269
+ groupKubernetesDatabaseCandidates(members)[0]!.ownerWorkloads,
3270
+ ).toEqual([
3271
+ { kind: "StatefulSet", name: "cache-redis-master" },
3272
+ { kind: "StatefulSet", name: "cache-redis-replicas" },
3273
+ ]);
3274
+ });
3275
+
3276
+ test("owner-less members add no owner workload", () => {
3277
+ const member: KubernetesDatabaseCandidate = classifyKubernetesPod(
3278
+ pod({
3279
+ name: "scratch",
3280
+ containers: [{ name: "redis", image: "redis:7", ports: [port(6379)] }],
3281
+ }),
3282
+ )!;
3283
+ expect(
3284
+ groupKubernetesDatabaseCandidates([member])[0]!.ownerWorkloads,
3285
+ ).toEqual([]);
3286
+ });
3287
+ });
3288
+
3289
+ describe("hasDatabaseWorkloadLabels — the store-side pre-filter's twin", () => {
3290
+ /*
3291
+ * Every label set an operator / chart rule matches (or skips) must pass
3292
+ * the pre-filter, or the job's SQL would never hand that pod to the
3293
+ * classifier.
3294
+ */
3295
+ const RULE_TRIGGERS: Array<Record<string, string>> = [
3296
+ { "cnpg.io/cluster": "pg-main" },
3297
+ { "cnpg.io/cluster": "pg-main", "cnpg.io/poolerName": "rw" },
3298
+ { application: "spilo", "cluster-name": "acid-main" },
3299
+ { "postgres-operator.crunchydata.com/cluster": "hippo" },
3300
+ {
3301
+ "app.kubernetes.io/managed-by": "percona-xtradb-cluster-operator",
3302
+ "app.kubernetes.io/name": "percona-xtradb-cluster",
3303
+ "app.kubernetes.io/instance": "cluster1",
3304
+ },
3305
+ {
3306
+ "app.kubernetes.io/managed-by": "percona-server-mongodb-operator",
3307
+ "app.kubernetes.io/name": "percona-server-mongodb",
3308
+ "app.kubernetes.io/instance": "x",
3309
+ },
3310
+ {
3311
+ "app.kubernetes.io/managed-by": "percona-server-mysql-operator",
3312
+ "app.kubernetes.io/name": "percona-server",
3313
+ "app.kubernetes.io/instance": "x",
3314
+ },
3315
+ { "mysql.oracle.com/cluster": "mycluster" },
3316
+ {
3317
+ "common.k8s.elastic.co/type": "elasticsearch",
3318
+ "elasticsearch.k8s.elastic.co/cluster-name": "quickstart",
3319
+ },
3320
+ { "clickhouse.altinity.com/chi": "demo" },
3321
+ { "cassandra.datastax.com/cluster": "demo" },
3322
+ {
3323
+ "planetscale.com/cluster": "example",
3324
+ "planetscale.com/component": "vtgate",
3325
+ },
3326
+ {
3327
+ "planetscale.com/cluster": "example",
3328
+ "planetscale.com/component": "vttablet",
3329
+ },
3330
+ { "app.kubernetes.io/name": "postgresql" },
3331
+ { "app.kubernetes.io/name": " Redis " },
3332
+ { "app.kubernetes.io/name": "mongodb-sharded" },
3333
+ ];
3334
+
3335
+ test.each(RULE_TRIGGERS)("%j passes", (labels: Record<string, string>) => {
3336
+ expect(hasDatabaseWorkloadLabels(labels)).toBe(true);
3337
+ });
3338
+
3339
+ test("every chart name any catalog engine declares passes", () => {
3340
+ for (const name of DATABASE_WORKLOAD_NAME_LABEL_VALUES) {
3341
+ expect(name).toBe(name.toLowerCase());
3342
+ expect(
3343
+ hasDatabaseWorkloadLabels({ "app.kubernetes.io/name": name }),
3344
+ ).toBe(true);
3345
+ }
3346
+ for (const key of DATABASE_OPERATOR_LABEL_KEYS) {
3347
+ expect(hasDatabaseWorkloadLabels({ [key]: "" })).toBe(true);
3348
+ }
3349
+ });
3350
+
3351
+ test("anything a rule would not look at is filtered out", () => {
3352
+ for (const labels of [
3353
+ {},
3354
+ { app: "postgres" },
3355
+ { "app.kubernetes.io/name": "api" },
3356
+ { "app.kubernetes.io/instance": "postgresql" },
3357
+ null,
3358
+ ["cnpg.io/cluster"],
3359
+ "cnpg.io/cluster",
3360
+ ]) {
3361
+ expect(hasDatabaseWorkloadLabels(labels)).toBe(false);
3362
+ }
3363
+ });
3364
+
3365
+ test("a pod the rules classify by labels alone always passes the pre-filter", () => {
3366
+ for (const labels of RULE_TRIGGERS) {
3367
+ const candidate: KubernetesDatabaseCandidate | null =
3368
+ classifyKubernetesPod(
3369
+ pod({
3370
+ name: "x-0",
3371
+ labels,
3372
+ owner: { kind: "StatefulSet", name: "x" },
3373
+ containers: [{ name: "db", image: "registry.corp/custom:1" }],
3374
+ }),
3375
+ );
3376
+ if (candidate && candidate.evidence[0]!.startsWith("label:")) {
3377
+ expect(hasDatabaseWorkloadLabels(labels)).toBe(true);
3378
+ }
3379
+ }
3380
+ });
3381
+ });
3382
+
3383
+ /*
3384
+ * The dashboard's workload badge (DatabaseWorkloadLookup) runs this
3385
+ * classifier on a StatefulSet or Deployment page, and on a pod listed
3386
+ * without its spec, from the object's LABELS alone: `spec.containers` is
3387
+ * empty, and a StatefulSet / Deployment stands in as the owner of a pod of
3388
+ * its own name. That is how those pages ask for the name discovery gave the
3389
+ * database (Bitnami's `shop-redis-master` for `shop-redis`, Percona's
3390
+ * `cluster1-pxc` for `cluster1`).
3391
+ *
3392
+ * So an empty container list must mean "not known", never "none that could
3393
+ * be the server": the two container checks inside the label rules only run
3394
+ * when there are containers, while the label-only rejections (a chart's
3395
+ * non-member component, an operator's pooler / backup pods) still apply.
3396
+ */
3397
+ describe("classifyKubernetesPod — label rules for an object whose containers are not known", () => {
3398
+ function labelsOnly(data: {
3399
+ name: string;
3400
+ labels: Record<string, unknown>;
3401
+ owner: { kind: string; name: string };
3402
+ }): KubernetesPodLike {
3403
+ return {
3404
+ namespaceKey: "data",
3405
+ name: data.name,
3406
+ phase: null,
3407
+ labels: data.labels,
3408
+ ownerReferences: { items: [data.owner] },
3409
+ spec: { containers: [] },
3410
+ };
3411
+ }
3412
+
3413
+ type MemberCase = {
3414
+ labels: Record<string, unknown>;
3415
+ owner: { kind: string; name: string };
3416
+ // The server container a real member pod runs.
3417
+ image: string;
3418
+ system: string;
3419
+ workloadName: string;
3420
+ };
3421
+
3422
+ const MEMBERS: Array<[string, MemberCase]> = [
3423
+ [
3424
+ "a CloudNativePG instance",
3425
+ {
3426
+ labels: {
3427
+ "cnpg.io/cluster": "pg-main",
3428
+ "cnpg.io/podRole": "instance",
3429
+ "cnpg.io/instanceRole": "primary",
3430
+ },
3431
+ owner: { kind: "Cluster", name: "pg-main" },
3432
+ image: "ghcr.io/cloudnative-pg/postgresql:16.2",
3433
+ system: "postgresql",
3434
+ workloadName: "pg-main",
3435
+ },
3436
+ ],
3437
+ [
3438
+ "a Zalando (Spilo) StatefulSet",
3439
+ {
3440
+ labels: {
3441
+ application: "spilo",
3442
+ "cluster-name": "acid-main",
3443
+ "spilo-role": "master",
3444
+ },
3445
+ owner: { kind: "StatefulSet", name: "acid-main" },
3446
+ image: "ghcr.io/zalando/spilo-16:3.2-p2",
3447
+ system: "postgresql",
3448
+ workloadName: "acid-main",
3449
+ },
3450
+ ],
3451
+ [
3452
+ "a Percona XtraDB StatefulSet",
3453
+ {
3454
+ labels: {
3455
+ "app.kubernetes.io/managed-by": "percona-xtradb-cluster-operator",
3456
+ "app.kubernetes.io/name": "percona-xtradb-cluster",
3457
+ "app.kubernetes.io/instance": "cluster1",
3458
+ "app.kubernetes.io/component": "pxc",
3459
+ },
3460
+ owner: { kind: "StatefulSet", name: "cluster1-pxc" },
3461
+ image: "percona/percona-xtradb-cluster:8.0.36",
3462
+ system: "mysql",
3463
+ workloadName: "cluster1",
3464
+ },
3465
+ ],
3466
+ [
3467
+ "a Bitnami Redis replication StatefulSet",
3468
+ {
3469
+ labels: {
3470
+ "app.kubernetes.io/name": "redis",
3471
+ "app.kubernetes.io/instance": "shop",
3472
+ "app.kubernetes.io/component": "master",
3473
+ "app.kubernetes.io/managed-by": "Helm",
3474
+ },
3475
+ owner: { kind: "StatefulSet", name: "shop-redis-master" },
3476
+ image: "bitnami/redis:7.2.4",
3477
+ system: "redis",
3478
+ workloadName: "shop-redis",
3479
+ },
3480
+ ],
3481
+ ];
3482
+
3483
+ test.each(MEMBERS)(
3484
+ "%s is named from its labels exactly as its member pods are",
3485
+ (_label: string, member: MemberCase) => {
3486
+ const fromLabels: KubernetesDatabaseCandidate | null =
3487
+ classifyKubernetesPod(
3488
+ labelsOnly({
3489
+ name: member.owner.name,
3490
+ labels: member.labels,
3491
+ owner: member.owner,
3492
+ }),
3493
+ );
3494
+
3495
+ const fromMemberPod: KubernetesDatabaseCandidate | null =
3496
+ classifyKubernetesPod(
3497
+ pod({
3498
+ name: `${member.owner.name}-0`,
3499
+ labels: member.labels,
3500
+ owner: member.owner,
3501
+ containers: [{ name: "server", image: member.image }],
3502
+ }),
3503
+ );
3504
+
3505
+ expect(fromLabels).not.toBeNull();
3506
+ expect(fromLabels).toMatchObject({
3507
+ system: member.system,
3508
+ workloadName: member.workloadName,
3509
+ // No container stands in for the server's image, version or ports.
3510
+ containerName: "",
3511
+ image: null,
3512
+ version: null,
3513
+ ports: [],
3514
+ });
3515
+
3516
+ // Discovery's name for the database, whichever of the two asks.
3517
+ expect(fromMemberPod).not.toBeNull();
3518
+ expect({
3519
+ system: fromLabels!.system,
3520
+ workloadKind: fromLabels!.workloadKind,
3521
+ workloadName: fromLabels!.workloadName,
3522
+ operator: fromLabels!.operator,
3523
+ role: fromLabels!.role,
3524
+ }).toEqual({
3525
+ system: fromMemberPod!.system,
3526
+ workloadKind: fromMemberPod!.workloadKind,
3527
+ workloadName: fromMemberPod!.workloadName,
3528
+ operator: fromMemberPod!.operator,
3529
+ role: fromMemberPod!.role,
3530
+ });
3531
+ },
3532
+ );
3533
+
3534
+ test("an object with no spec at all is treated like an empty container list", () => {
3535
+ const withoutSpec: KubernetesPodLike = {
3536
+ namespaceKey: "data",
3537
+ name: "pg-main-1",
3538
+ phase: null,
3539
+ labels: { "cnpg.io/cluster": "pg-main" },
3540
+ ownerReferences: { items: [{ kind: "Cluster", name: "pg-main" }] },
3541
+ };
3542
+
3543
+ expect(classifyKubernetesPod(withoutSpec)?.workloadName).toBe("pg-main");
3544
+ });
3545
+
3546
+ test.each([
3547
+ [
3548
+ "a chart's pooler component (Bitnami postgresql-ha's pgpool)",
3549
+ {
3550
+ "app.kubernetes.io/name": "postgresql-ha",
3551
+ "app.kubernetes.io/instance": "shop",
3552
+ "app.kubernetes.io/component": "pgpool",
3553
+ },
3554
+ { kind: "Deployment", name: "shop-postgresql-ha-pgpool" },
3555
+ ],
3556
+ [
3557
+ "a chart's metrics component",
3558
+ {
3559
+ "app.kubernetes.io/name": "redis",
3560
+ "app.kubernetes.io/instance": "shop",
3561
+ "app.kubernetes.io/component": "metrics",
3562
+ },
3563
+ { kind: "Deployment", name: "shop-redis-metrics" },
3564
+ ],
3565
+ [
3566
+ "a CloudNativePG pooler",
3567
+ {
3568
+ "cnpg.io/cluster": "pg-main",
3569
+ "cnpg.io/poolerName": "pg-main-pooler-rw",
3570
+ },
3571
+ { kind: "Deployment", name: "pg-main-pooler-rw" },
3572
+ ],
3573
+ [
3574
+ "a Crunchy pgbouncer Deployment",
3575
+ {
3576
+ "postgres-operator.crunchydata.com/cluster": "hippo",
3577
+ "postgres-operator.crunchydata.com/role": "pgbouncer",
3578
+ },
3579
+ { kind: "Deployment", name: "hippo-pgbouncer" },
3580
+ ],
3581
+ [
3582
+ "a Crunchy backup repo host",
3583
+ {
3584
+ "postgres-operator.crunchydata.com/cluster": "hippo",
3585
+ "postgres-operator.crunchydata.com/pgbackrest-dedicated": "",
3586
+ },
3587
+ { kind: "StatefulSet", name: "hippo-repo-host" },
3588
+ ],
3589
+ [
3590
+ "a Zalando connection pooler",
3591
+ {
3592
+ application: "db-connection-pooler",
3593
+ "cluster-name": "acid-main",
3594
+ },
3595
+ { kind: "Deployment", name: "acid-main-pooler" },
3596
+ ],
3597
+ ])(
3598
+ "%s is still not a member without its containers",
3599
+ (
3600
+ _label: string,
3601
+ labels: Record<string, unknown>,
3602
+ owner: { kind: string; name: string },
3603
+ ) => {
3604
+ expect(
3605
+ classifyKubernetesPod(
3606
+ labelsOnly({ name: owner.name, labels: labels, owner: owner }),
3607
+ ),
3608
+ ).toBeNull();
3609
+ },
3610
+ );
3611
+
3612
+ test("known containers still decide: the same labels on a pod running only an exporter are not a member", () => {
3613
+ const labels: Record<string, unknown> = {
3614
+ "cnpg.io/cluster": "pg-main",
3615
+ "cnpg.io/podRole": "instance",
3616
+ };
3617
+ const owner: { kind: string; name: string } = {
3618
+ kind: "Cluster",
3619
+ name: "pg-main",
3620
+ };
3621
+
3622
+ expect(
3623
+ classifyKubernetesPod(
3624
+ labelsOnly({ name: "pg-main-1", labels: labels, owner: owner }),
3625
+ )?.workloadName,
3626
+ ).toBe("pg-main");
3627
+
3628
+ expect(
3629
+ classifyKubernetesPod(
3630
+ pod({
3631
+ name: "pg-main-1",
3632
+ labels: labels,
3633
+ owner: owner,
3634
+ containers: [
3635
+ {
3636
+ name: "exporter",
3637
+ image: "quay.io/prometheuscommunity/postgres-exporter:v0.15.0",
3638
+ },
3639
+ ],
3640
+ }),
3641
+ ),
3642
+ ).toBeNull();
3643
+ });
3644
+ });