@oneuptime/common 14.0.3 → 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 (617) hide show
  1. package/Models/DatabaseModels/Alert.ts +55 -0
  2. package/Models/DatabaseModels/DatabaseServer.ts +1650 -0
  3. package/Models/DatabaseModels/DatabaseServerEndpoint.ts +540 -0
  4. package/Models/DatabaseModels/DatabaseServerFeed.ts +625 -0
  5. package/Models/DatabaseModels/DatabaseServerLabelRule.ts +514 -0
  6. package/Models/DatabaseModels/DatabaseServerOwnerRule.ts +596 -0
  7. package/Models/DatabaseModels/DatabaseServerOwnerTeam.ts +491 -0
  8. package/Models/DatabaseModels/DatabaseServerOwnerUser.ts +490 -0
  9. package/Models/DatabaseModels/Incident.ts +55 -0
  10. package/Models/DatabaseModels/Index.ts +16 -0
  11. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +26 -0
  12. package/Models/DatabaseModels/ScheduledMaintenance.ts +55 -0
  13. package/Models/DatabaseModels/TelemetryException.ts +2 -0
  14. package/Models/DatabaseModels/UserProjectSsoConsent.ts +146 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.ts +673 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.ts +51 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  18. package/Server/Middleware/MultipartFormData.ts +1 -1
  19. package/Server/Middleware/TelemetryIngest.ts +15 -8
  20. package/Server/Services/AlertReminderRuleService.ts +100 -24
  21. package/Server/Services/DatabaseServerEndpointService.ts +895 -0
  22. package/Server/Services/DatabaseServerFeedService.ts +128 -0
  23. package/Server/Services/DatabaseServerLabelRuleEngineService.ts +340 -0
  24. package/Server/Services/DatabaseServerLabelRuleService.ts +14 -0
  25. package/Server/Services/DatabaseServerOwnerRuleEngineService.ts +425 -0
  26. package/Server/Services/DatabaseServerOwnerRuleService.ts +14 -0
  27. package/Server/Services/DatabaseServerOwnerTeamService.ts +180 -0
  28. package/Server/Services/DatabaseServerOwnerUserService.ts +173 -0
  29. package/Server/Services/DatabaseServerService.ts +4546 -0
  30. package/Server/Services/DatabaseService.ts +14 -8
  31. package/Server/Services/DockerResourceService.ts +43 -2
  32. package/Server/Services/IncidentReminderRuleService.ts +103 -23
  33. package/Server/Services/Index.ts +16 -0
  34. package/Server/Services/InventoryItemService.ts +113 -5
  35. package/Server/Services/IoTDeviceService.ts +145 -37
  36. package/Server/Services/OpenTelemetryIngestService.ts +19 -0
  37. package/Server/Services/PodmanResourceService.ts +43 -2
  38. package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +107 -23
  39. package/Server/Services/ServiceService.ts +1 -1
  40. package/Server/Services/TelemetryUsageBillingService.ts +29 -2
  41. package/Server/Services/UserProjectSsoConsentService.ts +74 -0
  42. package/Server/Services/UserService.ts +14 -1
  43. package/Server/Types/Database/Permissions/OwnerTableRegistry.ts +18 -0
  44. package/Server/Types/Database/QueryHelper.ts +30 -1
  45. package/Server/Utils/AI/Toolbox/ResourceTools.ts +69 -1
  46. package/Server/Utils/Database/AffectedResourceRelations.ts +6 -0
  47. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +188 -6
  48. package/Server/Utils/Monitor/MonitorAlert.ts +28 -1
  49. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +2262 -755
  50. package/Server/Utils/Monitor/MonitorIncident.ts +28 -1
  51. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +268 -6
  52. package/Server/Utils/Monitor/MonitorResource.ts +47 -11
  53. package/Server/Utils/Monitor/MonitorResourceContext.ts +1 -0
  54. package/Server/Utils/Monitor/MonitorStepResourceIdentity.ts +239 -21
  55. package/Server/Utils/Monitor/PerSeriesResolutionRootCause.ts +43 -0
  56. package/Server/Utils/Monitor/SeriesResourceLabels.ts +291 -0
  57. package/Server/Utils/Monitor/SeriesResourceLinker.ts +111 -12
  58. package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +25 -0
  59. package/Server/Utils/StartServer.ts +26 -6
  60. package/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.ts +2 -0
  61. package/Server/Utils/Telemetry/DatabaseEndpointDiscovery.ts +688 -0
  62. package/Server/Utils/Telemetry/EntityRegistry.ts +5 -1
  63. package/Server/Utils/Telemetry/LogExceptionExtractor.ts +18 -1
  64. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +175 -12
  65. package/Server/Utils/Telemetry/ResourceFacetResolver.ts +12 -0
  66. package/Server/Utils/Telemetry/ServiceDependencyDiscovery.ts +606 -36
  67. package/Server/Utils/Telemetry/Telemetry.ts +16 -0
  68. package/Server/Utils/Telemetry/TelemetryEntity.ts +22 -22
  69. package/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.ts +16 -10
  70. package/Tests/App/Accounts/SignedInRedirect.test.tsx +241 -0
  71. package/Tests/App/AdminDashboard/AdminHeaderSmallScreens.test.tsx +221 -4
  72. package/Tests/App/Dashboard/AIInsightsResourcePageContext.test.ts +42 -0
  73. package/Tests/App/Dashboard/AffectedResourcesDisplay.test.tsx +100 -0
  74. package/Tests/App/Dashboard/AffectedResourcesPicker.test.tsx +86 -0
  75. package/Tests/App/Dashboard/AffectedResourcesPickerHiddenTypes.test.tsx +2 -0
  76. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +19 -0
  77. package/Tests/App/Dashboard/BreadcrumbCoverage.test.tsx +14 -0
  78. package/Tests/App/Dashboard/CorrelateFilterBuilder.test.tsx +26 -1
  79. package/Tests/App/Dashboard/DashboardAppSsoError.test.tsx +501 -0
  80. package/Tests/App/Dashboard/DashboardHeaderRequests.test.tsx +64 -1
  81. package/Tests/App/Dashboard/DashboardHeaderSmallScreens.test.tsx +178 -18
  82. package/Tests/App/Dashboard/DashboardMasterPageSsoRedirect.test.tsx +444 -0
  83. package/Tests/App/Dashboard/DashboardNavBarForeignHiddenRule.test.tsx +573 -0
  84. package/Tests/App/Dashboard/DatabaseChartAxes.test.tsx +431 -0
  85. package/Tests/App/Dashboard/DatabaseContainerDetailBadge.test.tsx +249 -0
  86. package/Tests/App/Dashboard/DatabaseDashboardE2EFixes.test.ts +681 -0
  87. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +1801 -0
  88. package/Tests/App/Dashboard/DatabaseFleetSummaryRefresh.test.tsx +280 -0
  89. package/Tests/App/Dashboard/DatabaseFormHelpers.test.tsx +321 -0
  90. package/Tests/App/Dashboard/DatabaseMetricMonitorLink.test.tsx +795 -0
  91. package/Tests/App/Dashboard/DatabaseOverviewHeader.test.tsx +580 -0
  92. package/Tests/App/Dashboard/DatabaseRecommendationsEngineMetrics.test.ts +270 -0
  93. package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +1486 -0
  94. package/Tests/App/Dashboard/DatabaseServerEngineMetricsFilter.test.ts +223 -0
  95. package/Tests/App/Dashboard/DatabaseServerPresentation.test.ts +435 -0
  96. package/Tests/App/Dashboard/DatabaseServerSummary.test.ts +186 -0
  97. package/Tests/App/Dashboard/DatabaseServerTelemetryQueries.test.ts +1553 -0
  98. package/Tests/App/Dashboard/DatabaseTableTruncation.test.tsx +326 -0
  99. package/Tests/App/Dashboard/DatabaseTelemetryScope.test.ts +512 -0
  100. package/Tests/App/Dashboard/DatabaseTelemetryTabs.test.tsx +774 -0
  101. package/Tests/App/Dashboard/DatabaseViewLayout.test.tsx +233 -0
  102. package/Tests/App/Dashboard/DatabaseWorkloadBadge.test.tsx +972 -0
  103. package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +5 -0
  104. package/Tests/App/Dashboard/DiscoveryReviewUnnamedHostHint.test.tsx +1061 -0
  105. package/Tests/App/Dashboard/EventOverlayScope.test.ts +147 -0
  106. package/Tests/App/Dashboard/EventOverlayScopeHook.test.tsx +1 -0
  107. package/Tests/App/Dashboard/LogsResourceFacetChips.test.tsx +1 -0
  108. package/Tests/App/Dashboard/MetricsViewerDrillDown.test.tsx +310 -0
  109. package/Tests/App/Dashboard/MetricsViewerEntityScopedRows.test.tsx +495 -0
  110. package/Tests/App/Dashboard/MonitorCreateFromMonitorBackedDevice.test.tsx +132 -1
  111. package/Tests/App/Dashboard/NavigationSearchKeywords.test.tsx +5 -0
  112. package/Tests/App/Dashboard/ProductOrientation.test.tsx +4 -3
  113. package/Tests/App/Dashboard/RecommendationsLoadedContext.test.tsx +349 -0
  114. package/Tests/App/Dashboard/ResourceArchiveSettings.test.tsx +62 -0
  115. package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +8 -1
  116. package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +14 -0
  117. package/Tests/App/Dashboard/ScheduledMaintenanceOverviewPage.test.tsx +2 -0
  118. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +1085 -0
  119. package/Tests/App/Dashboard/TelemetryResourceRetentionSettings.test.tsx +10 -0
  120. package/Tests/App/Dashboard/TracesResourceFacets.test.tsx +2 -1
  121. package/Tests/App/StatusPage/StatusPageHeaderForeignHiddenRule.test.tsx +284 -0
  122. package/Tests/ClassStrings.ts +178 -0
  123. package/Tests/Fixtures/MobilePasskeyUser.json +1 -2
  124. package/Tests/ForeignHiddenRuleGuard.ts +2152 -0
  125. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +2250 -0
  126. package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +24 -5
  127. package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
  128. package/Tests/RenderedMarkup.ts +518 -0
  129. package/Tests/ResponsiveVisibility.test.ts +490 -4
  130. package/Tests/ResponsiveVisibility.ts +505 -52
  131. package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
  132. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +541 -0
  133. package/Tests/Server/Infrastructure/Postgres/AddUserProjectSsoConsentMigration.test.ts +234 -0
  134. package/Tests/Server/Services/ContainerSnapshotUpsertPostgres.test.ts +319 -0
  135. package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +13 -0
  136. package/Tests/Server/Services/DatabaseServerEndpointService.test.ts +2033 -0
  137. package/Tests/Server/Services/DatabaseServerFeedAndOwnerServices.test.ts +529 -0
  138. package/Tests/Server/Services/DatabaseServerLifecycle.test.ts +3302 -0
  139. package/Tests/Server/Services/DatabaseServerRegistries.test.ts +603 -0
  140. package/Tests/Server/Services/DatabaseServerRuleEngines.test.ts +754 -0
  141. package/Tests/Server/Services/DatabaseServerService.test.ts +3558 -0
  142. package/Tests/Server/Services/DatabaseServerSqlPostgres.test.ts +2045 -0
  143. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedPostgres.test.ts +362 -0
  144. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedReply.test.ts +210 -0
  145. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +18 -0
  146. package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +2 -0
  147. package/Tests/Server/Services/IncomingCallPolicyEscalationRuleTarget.test.ts +225 -0
  148. package/Tests/Server/Services/InventoryItemDisplayName.test.ts +280 -2
  149. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +8 -2
  150. package/Tests/Server/Services/IoTDeviceLatestMetricsPostgres.test.ts +689 -0
  151. package/Tests/Server/Services/IoTDeviceService.test.ts +379 -0
  152. package/Tests/Server/Services/LatestMetricsValuesShape.test.ts +360 -0
  153. package/Tests/Server/Services/LlmModelPriceService.test.ts +402 -0
  154. package/Tests/Server/Services/OpenTelemetryResourceRetentionMemo.test.ts +7 -1
  155. package/Tests/Server/Services/OwnerRowUniqueness.test.ts +61 -9
  156. package/Tests/Server/Services/ReminderRuleLookupPostgres.test.ts +2250 -0
  157. package/Tests/Server/Services/ReminderRuleRefreshOpenSubjectsPaging.test.ts +653 -0
  158. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +68 -20
  159. package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +3 -3
  160. package/Tests/Server/Services/TelemetryResourceFacetCounting.test.ts +9 -0
  161. package/Tests/Server/Services/UserProjectSsoConsentPostgres.test.ts +153 -0
  162. package/Tests/Server/Services/UserProjectSsoConsentService.test.ts +192 -0
  163. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +71 -0
  164. package/Tests/Server/Services/UserSignupIdTakeoverPostgres.test.ts +238 -0
  165. package/Tests/Server/Services/WorkspaceNotificationSummaryHelpers.test.ts +592 -0
  166. package/Tests/Server/Types/Billing/PayAsYouGoMeteredAuthorization.test.ts +3 -0
  167. package/Tests/Server/Types/Database/EventOverlayScopePostgres.test.ts +1 -0
  168. package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +11 -0
  169. package/Tests/Server/Types/Database/Permissions/InfrastructureOwnershipScope.test.ts +8 -0
  170. package/Tests/Server/Types/Database/QueryHelperBooleanForCriteriaBackedRule.test.ts +1094 -0
  171. package/Tests/Server/Utils/AI/ResourceTools.test.ts +134 -0
  172. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +41 -0
  173. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +279 -9
  174. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaDisplayContext.test.ts +933 -0
  175. package/Tests/Server/Utils/Monitor/DatabaseMaintenanceSuppression.test.ts +582 -0
  176. package/Tests/Server/Utils/Monitor/DatabaseServerMonitorLinking.test.ts +753 -0
  177. package/Tests/Server/Utils/Monitor/MonitorActionTimestamps.test.ts +1 -0
  178. package/Tests/Server/Utils/Monitor/MonitorAlertCriteriaReferences.test.ts +1 -0
  179. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +119 -0
  180. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +60 -25
  181. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +1719 -0
  182. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesAffectedResources.test.ts +1447 -0
  183. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesRatioEmail.test.ts +352 -0
  184. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformMetricUnits.test.ts +717 -0
  185. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformRawScan.test.ts +2125 -0
  186. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformSeriesAffectedResources.test.ts +1288 -0
  187. package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +1 -0
  188. package/Tests/Server/Utils/Monitor/MonitorIncidentCriteriaReferences.test.ts +1 -0
  189. package/Tests/Server/Utils/Monitor/MonitorIncidentMemberRoleMembership.test.ts +1 -0
  190. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +88 -0
  191. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +205 -0
  192. package/Tests/Server/Utils/Monitor/MonitorResourceContext.test.ts +62 -0
  193. package/Tests/Server/Utils/Monitor/MonitorResourceDatabaseMaintenance.test.ts +803 -0
  194. package/Tests/Server/Utils/Monitor/MonitorStepResourceIdentity.test.ts +133 -3
  195. package/Tests/Server/Utils/Monitor/MonitorSummaryPersistence.test.ts +1 -0
  196. package/Tests/Server/Utils/Monitor/PerSeriesRecoveryResolution.test.ts +472 -1
  197. package/Tests/Server/Utils/Monitor/PerSeriesSecondEntityAlerting.test.ts +1 -0
  198. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +236 -2
  199. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +70 -0
  200. package/Tests/Server/Utils/StartServerProtobufContentType.test.ts +54 -0
  201. package/Tests/Server/Utils/Telemetry/DatabaseEndpointDiscovery.test.ts +1895 -0
  202. package/Tests/Server/Utils/Telemetry/EntityRegistry.test.ts +7 -0
  203. package/Tests/Server/Utils/Telemetry/LogExceptionExtractorSeverityGate.test.ts +90 -0
  204. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +570 -0
  205. package/Tests/Server/Utils/Telemetry/ResourceFacetResolver.test.ts +97 -2
  206. package/Tests/Server/Utils/Telemetry/ServiceDependencyDiscovery.test.ts +1337 -0
  207. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +295 -0
  208. package/Tests/Types/AI/AIResourceContext.test.ts +30 -0
  209. package/Tests/Types/DatabaseServer/DatabaseContainerClassifier.test.ts +3644 -0
  210. package/Tests/Types/DatabaseServer/DatabaseContainerCommand.test.ts +340 -0
  211. package/Tests/Types/DatabaseServer/DatabaseContainerCommandCorpus.ts +826 -0
  212. package/Tests/Types/DatabaseServer/DatabaseContainerCommandPostgres.test.ts +150 -0
  213. package/Tests/Types/DatabaseServer/DatabaseEndpoint.test.ts +2657 -0
  214. package/Tests/Types/DatabaseServer/DatabaseServerDiscoverySource.test.ts +64 -0
  215. package/Tests/Types/DatabaseServer/DatabaseServerEntityKeys.test.ts +952 -0
  216. package/Tests/Types/DatabaseServer/DatabaseServerMetricCatalog.test.ts +1161 -0
  217. package/Tests/Types/DatabaseServer/DatabaseSystem.test.ts +1514 -0
  218. package/Tests/Types/DatabaseServer/DatabaseTelemetryResolver.test.ts +1558 -0
  219. package/Tests/Types/Monitor/CephAlertTemplates.test.ts +255 -6
  220. package/Tests/Types/Monitor/DatabaseAlertTemplates.test.ts +2254 -0
  221. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +298 -0
  222. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +271 -0
  223. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +253 -9
  224. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCoverage.test.ts +13 -5
  225. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +4 -2
  226. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +111 -2
  227. package/Tests/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.test.ts +61 -0
  228. package/Tests/Types/Monitor/SeriesContext/SeriesLabelDisplay.test.ts +114 -0
  229. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +181 -0
  230. package/Tests/Types/NetworkDevice/DiscoveredHostNamingStatus.test.ts +401 -0
  231. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +4 -1
  232. package/Tests/Types/Telemetry/ResourceFacetCatalog.test.ts +24 -2
  233. package/Tests/UI/Components/Card.test.tsx +93 -2
  234. package/Tests/UI/Components/FeedOptions.test.ts +6 -0
  235. package/Tests/UI/Components/FeedOptionsButton.test.tsx +8 -1
  236. package/Tests/UI/Components/ForeignHiddenRuleResilience.test.tsx +1147 -0
  237. package/Tests/UI/Components/HeaderRightRail.test.tsx +41 -1
  238. package/Tests/UI/Components/LogsEntityNames.test.ts +16 -1
  239. package/Tests/UI/Components/LogsFacetSidebarHiddenFacets.test.tsx +7 -3
  240. package/Tests/UI/Components/ResourceFacetConfigs.test.ts +2 -1
  241. package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
  242. package/Tests/UI/Components/SrOnlyForeignRule.test.tsx +994 -0
  243. package/Tests/UI/Components/StatusPage/ResourceListSelection.test.tsx +34 -1
  244. package/Tests/UI/Components/TelemetryFacetSidebarHiddenFacets.test.tsx +4 -1
  245. package/Tests/UI/ForeignHiddenRuleGuard.test.ts +1951 -0
  246. package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +19 -1
  247. package/Tests/UI/Rum/ReplayTabSwitcher.test.tsx +19 -1
  248. package/Tests/UI/TailwindPlayCdnForeignHiddenRule.test.ts +1005 -0
  249. package/Tests/UI/ThemeGroupHoverReveal.test.ts +847 -0
  250. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +70 -0
  251. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +21 -0
  252. package/Tests/UI/Utils/ModelAPISsoRedirect.test.ts +127 -0
  253. package/Tests/UI/Utils/Navigation.test.ts +102 -0
  254. package/Tests/UI/Utils/Telemetry/TelemetryEntityNames.test.ts +23 -0
  255. package/Tests/Utils/LabelRuleImportExport.test.ts +5 -0
  256. package/Tests/Utils/MetricUnitUtil.test.ts +227 -2
  257. package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +288 -2
  258. package/Tests/Utils/Monitor/PlatformResourceIdentityAndUnits.test.ts +1559 -0
  259. package/Tests/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.test.ts +2546 -0
  260. package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +315 -0
  261. package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +336 -1
  262. package/Tests/Utils/NetworkDiscovery/NetbiosNameUtil.test.ts +244 -0
  263. package/Tests/Utils/ValueFormatter.test.ts +50 -0
  264. package/Tests/tsconfig.json +16 -0
  265. package/Types/AI/AIResourceContext.ts +7 -0
  266. package/Types/Dashboard/DashboardTemplates.ts +2 -1
  267. package/Types/DatabaseServer/DatabaseConnectionSpan.ts +77 -0
  268. package/Types/DatabaseServer/DatabaseContainerClassifier.ts +2068 -0
  269. package/Types/DatabaseServer/DatabaseContainerCommand.ts +801 -0
  270. package/Types/DatabaseServer/DatabaseEndpoint.ts +1943 -0
  271. package/Types/DatabaseServer/DatabaseServerDiscoverySource.ts +68 -0
  272. package/Types/DatabaseServer/DatabaseServerMetricCatalog.ts +966 -0
  273. package/Types/DatabaseServer/DatabaseSystem.ts +2124 -0
  274. package/Types/DatabaseServer/DatabaseTelemetryResolver.ts +555 -0
  275. package/Types/Email/EmailTemplateType.ts +1 -0
  276. package/Types/Monitor/CephAlertTemplates.ts +18 -0
  277. package/Types/Monitor/DatabaseAlertTemplates.ts +2020 -0
  278. package/Types/Monitor/KubernetesAlertTemplates.ts +64 -24
  279. package/Types/Monitor/KubernetesMetricCatalog.ts +118 -19
  280. package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +14 -0
  281. package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +93 -10
  282. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +109 -8
  283. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +36 -7
  284. package/Types/Monitor/Recommendation/MonitorRecommendationUtil.ts +43 -2
  285. package/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.ts +41 -6
  286. package/Types/Monitor/SeriesContext/SeriesLabelDisplay.ts +26 -0
  287. package/Types/NetworkDevice/DiscoveredHostNamingStatus.ts +147 -0
  288. package/Types/Permission.ts +267 -0
  289. package/Types/Probe/ProbeApiIngestResponse.ts +8 -0
  290. package/Types/Rules/RuleCriteriaFieldRegistry.ts +10 -0
  291. package/Types/Rules/RuleRun.ts +12 -0
  292. package/Types/Telemetry/EntityType.ts +23 -0
  293. package/Types/Telemetry/ResourceFacetCatalog.ts +14 -0
  294. package/Types/Telemetry/ServiceType.ts +8 -0
  295. package/UI/Components/Banner/Banner.tsx +1 -1
  296. package/UI/Components/Card/Card.tsx +1 -1
  297. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +2 -2
  298. package/UI/Components/Charts/Line/LineChart.tsx +7 -0
  299. package/UI/Components/CommandPalette/CommandPalette.tsx +1 -1
  300. package/UI/Components/Date/StartAndEndDate.tsx +5 -5
  301. package/UI/Components/Diagram/HorizontalStepChain.tsx +1 -1
  302. package/UI/Components/Feed/FeedOptionsButton.tsx +1 -1
  303. package/UI/Components/Forms/BasicForm.tsx +1 -1
  304. package/UI/Components/Header/Header.tsx +1 -1
  305. package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -1
  306. package/UI/Components/HeaderAlert/HeaderAlert.tsx +1 -1
  307. package/UI/Components/LogsViewer/components/FacetValueRow.tsx +1 -1
  308. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +1 -1
  309. package/UI/Components/Modal/ModalFooter.tsx +1 -1
  310. package/UI/Components/ModelDetail/CardModelDetail.tsx +2 -2
  311. package/UI/Components/ModelTable/BaseModelTable.tsx +4 -4
  312. package/UI/Components/Navbar/NavBar.tsx +9 -2
  313. package/UI/Components/Navbar/NavBarMenuModal.tsx +2 -2
  314. package/UI/Components/Page/Page.tsx +1 -1
  315. package/UI/Components/Pagination/Pagination.tsx +3 -3
  316. package/UI/Components/ProgressButtons/ProgressButtonItem.tsx +1 -1
  317. package/UI/Components/SideMenu/SideMenu.tsx +4 -1
  318. package/UI/Components/StatusPage/ResourceGroupNavigator.tsx +4 -2
  319. package/UI/Components/StatusPage/ResourceList.tsx +6 -6
  320. package/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.tsx +1 -1
  321. package/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.tsx +1 -1
  322. package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +1 -1
  323. package/UI/Components/Workflow/ComponentsModal.tsx +2 -2
  324. package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
  325. package/UI/Styles/Theme.css +8 -1
  326. package/UI/Utils/Navigation.ts +89 -42
  327. package/UI/Utils/Telemetry/TelemetryEntityNames.ts +7 -0
  328. package/UI/Utils/TelemetryService.ts +4 -3
  329. package/Utils/LabelRuleImportExport.ts +2 -0
  330. package/Utils/MetricUnitUtil.ts +103 -11
  331. package/Utils/Monitor/MetricValueFormatter.ts +66 -13
  332. package/Utils/Monitor/PlatformMetricUnitUtil.ts +215 -0
  333. package/Utils/Monitor/PlatformResourceIdentity.ts +514 -0
  334. package/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.ts +425 -0
  335. package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +32 -0
  336. package/Utils/NetworkDiscovery/NetbiosNameUtil.ts +73 -0
  337. package/Utils/Telemetry/DatabaseServerEntityKeys.ts +275 -0
  338. package/Utils/Telemetry/EntityKey.ts +176 -0
  339. package/Utils/ValueFormatter.ts +16 -0
  340. package/build/dist/Models/DatabaseModels/Alert.js +54 -0
  341. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  342. package/build/dist/Models/DatabaseModels/DatabaseServer.js +1683 -0
  343. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -0
  344. package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js +559 -0
  345. package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js.map +1 -0
  346. package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js +648 -0
  347. package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js.map +1 -0
  348. package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js +522 -0
  349. package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js.map +1 -0
  350. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js +603 -0
  351. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js.map +1 -0
  352. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js +506 -0
  353. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js.map +1 -0
  354. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js +505 -0
  355. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js.map +1 -0
  356. package/build/dist/Models/DatabaseModels/Incident.js +54 -0
  357. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  358. package/build/dist/Models/DatabaseModels/Index.js +16 -0
  359. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  360. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  361. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +54 -0
  362. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  363. package/build/dist/Models/DatabaseModels/TelemetryException.js +2 -0
  364. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  365. package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js +162 -0
  366. package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js.map +1 -0
  367. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js +240 -0
  368. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js.map +1 -0
  369. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js +26 -0
  370. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js.map +1 -0
  371. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  372. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  373. package/build/dist/Server/Middleware/MultipartFormData.js +1 -1
  374. package/build/dist/Server/Middleware/TelemetryIngest.js +15 -8
  375. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  376. package/build/dist/Server/Services/AlertReminderRuleService.js +72 -23
  377. package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
  378. package/build/dist/Server/Services/DatabaseServerEndpointService.js +694 -0
  379. package/build/dist/Server/Services/DatabaseServerEndpointService.js.map +1 -0
  380. package/build/dist/Server/Services/DatabaseServerFeedService.js +110 -0
  381. package/build/dist/Server/Services/DatabaseServerFeedService.js.map +1 -0
  382. package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js +272 -0
  383. package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js.map +1 -0
  384. package/build/dist/Server/Services/DatabaseServerLabelRuleService.js +13 -0
  385. package/build/dist/Server/Services/DatabaseServerLabelRuleService.js.map +1 -0
  386. package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js +340 -0
  387. package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js.map +1 -0
  388. package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js +13 -0
  389. package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js.map +1 -0
  390. package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js +160 -0
  391. package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js.map +1 -0
  392. package/build/dist/Server/Services/DatabaseServerOwnerUserService.js +153 -0
  393. package/build/dist/Server/Services/DatabaseServerOwnerUserService.js.map +1 -0
  394. package/build/dist/Server/Services/DatabaseServerService.js +3272 -0
  395. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -0
  396. package/build/dist/Server/Services/DatabaseService.js +8 -4
  397. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  398. package/build/dist/Server/Services/DockerResourceService.js +30 -3
  399. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  400. package/build/dist/Server/Services/IncidentReminderRuleService.js +73 -23
  401. package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
  402. package/build/dist/Server/Services/Index.js +16 -0
  403. package/build/dist/Server/Services/Index.js.map +1 -1
  404. package/build/dist/Server/Services/InventoryItemService.js +88 -5
  405. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  406. package/build/dist/Server/Services/IoTDeviceService.js +111 -24
  407. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  408. package/build/dist/Server/Services/OpenTelemetryIngestService.js +24 -9
  409. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  410. package/build/dist/Server/Services/PodmanResourceService.js +30 -3
  411. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  412. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +75 -23
  413. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
  414. package/build/dist/Server/Services/ServiceService.js +1 -1
  415. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  416. package/build/dist/Server/Services/TelemetryUsageBillingService.js +23 -2
  417. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  418. package/build/dist/Server/Services/UserProjectSsoConsentService.js +79 -0
  419. package/build/dist/Server/Services/UserProjectSsoConsentService.js.map +1 -0
  420. package/build/dist/Server/Services/UserService.js +13 -1
  421. package/build/dist/Server/Services/UserService.js.map +1 -1
  422. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js +18 -0
  423. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js.map +1 -1
  424. package/build/dist/Server/Types/Database/QueryHelper.js +26 -1
  425. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  426. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +46 -0
  427. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  428. package/build/dist/Server/Utils/Database/AffectedResourceRelations.js +6 -0
  429. package/build/dist/Server/Utils/Database/AffectedResourceRelations.js.map +1 -1
  430. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +134 -13
  431. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  432. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -6
  433. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  434. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1489 -524
  435. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  436. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +22 -6
  437. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  438. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +196 -6
  439. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  440. package/build/dist/Server/Utils/Monitor/MonitorResource.js +36 -9
  441. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  442. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js +1 -0
  443. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js.map +1 -1
  444. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js +175 -17
  445. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js.map +1 -1
  446. package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js +29 -0
  447. package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js.map +1 -0
  448. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +211 -0
  449. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  450. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +82 -10
  451. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -1
  452. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +25 -0
  453. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
  454. package/build/dist/Server/Utils/StartServer.js +20 -4
  455. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  456. package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js +2 -0
  457. package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js.map +1 -1
  458. package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js +442 -0
  459. package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js.map +1 -0
  460. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +5 -1
  461. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  462. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +15 -1
  463. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
  464. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +121 -5
  465. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -1
  466. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js +12 -0
  467. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js.map +1 -1
  468. package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js +438 -36
  469. package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js.map +1 -1
  470. package/build/dist/Server/Utils/Telemetry/Telemetry.js +12 -0
  471. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  472. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +17 -18
  473. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  474. package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js +16 -10
  475. package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js.map +1 -1
  476. package/build/dist/Types/AI/AIResourceContext.js +7 -0
  477. package/build/dist/Types/AI/AIResourceContext.js.map +1 -1
  478. package/build/dist/Types/Dashboard/DashboardTemplates.js +2 -1
  479. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  480. package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js +71 -0
  481. package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js.map +1 -0
  482. package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js +1462 -0
  483. package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js.map +1 -0
  484. package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js +691 -0
  485. package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js.map +1 -0
  486. package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js +1407 -0
  487. package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js.map +1 -0
  488. package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js +60 -0
  489. package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js.map +1 -0
  490. package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js +804 -0
  491. package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js.map +1 -0
  492. package/build/dist/Types/DatabaseServer/DatabaseSystem.js +1860 -0
  493. package/build/dist/Types/DatabaseServer/DatabaseSystem.js.map +1 -0
  494. package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js +406 -0
  495. package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js.map +1 -0
  496. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  497. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  498. package/build/dist/Types/Monitor/CephAlertTemplates.js +18 -0
  499. package/build/dist/Types/Monitor/CephAlertTemplates.js.map +1 -1
  500. package/build/dist/Types/Monitor/DatabaseAlertTemplates.js +1518 -0
  501. package/build/dist/Types/Monitor/DatabaseAlertTemplates.js.map +1 -0
  502. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js +57 -24
  503. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js.map +1 -1
  504. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js +104 -18
  505. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js.map +1 -1
  506. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +80 -3
  507. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  508. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +10 -3
  509. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  510. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js +29 -0
  511. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js.map +1 -1
  512. package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js +21 -6
  513. package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js.map +1 -1
  514. package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js +25 -0
  515. package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js.map +1 -1
  516. package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js +136 -0
  517. package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js.map +1 -0
  518. package/build/dist/Types/Permission.js +234 -0
  519. package/build/dist/Types/Permission.js.map +1 -1
  520. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +10 -0
  521. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
  522. package/build/dist/Types/Rules/RuleRun.js +4 -0
  523. package/build/dist/Types/Rules/RuleRun.js.map +1 -1
  524. package/build/dist/Types/Telemetry/EntityType.js +23 -0
  525. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  526. package/build/dist/Types/Telemetry/ResourceFacetCatalog.js +14 -0
  527. package/build/dist/Types/Telemetry/ResourceFacetCatalog.js.map +1 -1
  528. package/build/dist/Types/Telemetry/ServiceType.js +8 -0
  529. package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
  530. package/build/dist/UI/Components/Banner/Banner.js +1 -1
  531. package/build/dist/UI/Components/Banner/Banner.js.map +1 -1
  532. package/build/dist/UI/Components/Card/Card.js +1 -1
  533. package/build/dist/UI/Components/Card/Card.js.map +1 -1
  534. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +2 -2
  535. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  536. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  537. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  538. package/build/dist/UI/Components/CommandPalette/CommandPalette.js +1 -1
  539. package/build/dist/UI/Components/CommandPalette/CommandPalette.js.map +1 -1
  540. package/build/dist/UI/Components/Date/StartAndEndDate.js +5 -5
  541. package/build/dist/UI/Components/Date/StartAndEndDate.js.map +1 -1
  542. package/build/dist/UI/Components/Diagram/HorizontalStepChain.js +1 -1
  543. package/build/dist/UI/Components/Diagram/HorizontalStepChain.js.map +1 -1
  544. package/build/dist/UI/Components/Feed/FeedOptionsButton.js +1 -1
  545. package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -1
  546. package/build/dist/UI/Components/Forms/BasicForm.js +1 -1
  547. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  548. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -1
  549. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  550. package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js +1 -1
  551. package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js.map +1 -1
  552. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js +1 -1
  553. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js.map +1 -1
  554. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +1 -1
  555. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  556. package/build/dist/UI/Components/Modal/ModalFooter.js +1 -1
  557. package/build/dist/UI/Components/Modal/ModalFooter.js.map +1 -1
  558. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +2 -2
  559. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
  560. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +4 -4
  561. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  562. package/build/dist/UI/Components/Navbar/NavBar.js +9 -2
  563. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  564. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
  565. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  566. package/build/dist/UI/Components/Page/Page.js +1 -1
  567. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  568. package/build/dist/UI/Components/Pagination/Pagination.js +3 -3
  569. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  570. package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js +1 -1
  571. package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js.map +1 -1
  572. package/build/dist/UI/Components/SideMenu/SideMenu.js +4 -1
  573. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  574. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js +3 -1
  575. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js.map +1 -1
  576. package/build/dist/UI/Components/StatusPage/ResourceList.js +6 -6
  577. package/build/dist/UI/Components/StatusPage/ResourceList.js.map +1 -1
  578. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js +1 -1
  579. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js.map +1 -1
  580. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js +1 -1
  581. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js.map +1 -1
  582. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +1 -1
  583. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -1
  584. package/build/dist/UI/Components/Workflow/ComponentsModal.js +2 -2
  585. package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
  586. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
  587. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
  588. package/build/dist/UI/Utils/Navigation.js +66 -33
  589. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  590. package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js +7 -0
  591. package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js.map +1 -1
  592. package/build/dist/UI/Utils/TelemetryService.js +3 -2
  593. package/build/dist/UI/Utils/TelemetryService.js.map +1 -1
  594. package/build/dist/Utils/LabelRuleImportExport.js +2 -0
  595. package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
  596. package/build/dist/Utils/MetricUnitUtil.js +77 -13
  597. package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
  598. package/build/dist/Utils/Monitor/MetricValueFormatter.js +58 -13
  599. package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -1
  600. package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js +161 -0
  601. package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js.map +1 -0
  602. package/build/dist/Utils/Monitor/PlatformResourceIdentity.js +323 -0
  603. package/build/dist/Utils/Monitor/PlatformResourceIdentity.js.map +1 -0
  604. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js +270 -0
  605. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js.map +1 -0
  606. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +23 -0
  607. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
  608. package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js +61 -0
  609. package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js.map +1 -1
  610. package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js +178 -0
  611. package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js.map +1 -0
  612. package/build/dist/Utils/Telemetry/EntityKey.js +133 -0
  613. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  614. package/build/dist/Utils/ValueFormatter.js +15 -0
  615. package/build/dist/Utils/ValueFormatter.js.map +1 -1
  616. package/package.json +1 -1
  617. package/tsconfig.tests.json +7 -5
@@ -0,0 +1,4546 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import DatabaseServerEndpointService, {
3
+ DatabaseServerEndpointClaimResult,
4
+ DatabaseServerEndpointOwner,
5
+ ENDPOINT_MATCH_REFRESH_SECONDS,
6
+ getOwnedByOtherDatabaseMessage,
7
+ } from "./DatabaseServerEndpointService";
8
+ import DatabaseServerFeedService from "./DatabaseServerFeedService";
9
+ import DatabaseServerLabelRuleEngineService from "./DatabaseServerLabelRuleEngineService";
10
+ import DatabaseServerOwnerRuleEngineService from "./DatabaseServerOwnerRuleEngineService";
11
+ import DockerHostService from "./DockerHostService";
12
+ import KubernetesClusterService from "./KubernetesClusterService";
13
+ import PodmanHostService from "./PodmanHostService";
14
+ import BaseModel from "../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
15
+ import Model from "../../Models/DatabaseModels/DatabaseServer";
16
+ import DatabaseServerEndpoint from "../../Models/DatabaseModels/DatabaseServerEndpoint";
17
+ import { DatabaseServerFeedEventType } from "../../Models/DatabaseModels/DatabaseServerFeed";
18
+ import DockerHost from "../../Models/DatabaseModels/DockerHost";
19
+ import KubernetesCluster from "../../Models/DatabaseModels/KubernetesCluster";
20
+ import Label from "../../Models/DatabaseModels/Label";
21
+ import PodmanHost from "../../Models/DatabaseModels/PodmanHost";
22
+ import DatabaseConfig from "../DatabaseConfig";
23
+ import GlobalCache from "../Infrastructure/GlobalCache";
24
+ import CreateBy from "../Types/Database/CreateBy";
25
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
26
+ import ModelPermission from "../Types/Database/Permissions/Index";
27
+ import QueryHelper from "../Types/Database/QueryHelper";
28
+ import Select from "../Types/Database/Select";
29
+ import RelationIdUtil from "../Utils/Database/RelationIdUtil";
30
+ import {
31
+ truncateLongText,
32
+ truncateShortText,
33
+ } from "../Utils/Database/TruncateColumnValue";
34
+ import InProcessMemo from "../Utils/InProcessMemo";
35
+ import logger, { LogAttributes } from "../Utils/Logger";
36
+ import ResourceFeedUtil from "../Utils/ResourceFeed/ResourceFeedUtil";
37
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
38
+ import ResourceHeartbeat from "../Utils/Telemetry/ResourceHeartbeat";
39
+ import URL from "../../Types/API/URL";
40
+ import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
41
+ import SortOrder from "../../Types/BaseDatabase/SortOrder";
42
+ import { Blue500, Gray500, Green500, Yellow500 } from "../../Types/BrandColors";
43
+ import LIMIT_MAX from "../../Types/Database/LimitMax";
44
+ import PartialEntity from "../../Types/Database/PartialEntity";
45
+ import DatabaseServerDiscoverySource, {
46
+ getDatabaseServerDiscoverySourceLabel,
47
+ } from "../../Types/DatabaseServer/DatabaseServerDiscoverySource";
48
+ import {
49
+ DatabaseEndpoint,
50
+ DatabaseEndpointScope,
51
+ ManualDatabaseEndpoint,
52
+ buildDatabaseServerDisplayName,
53
+ buildDatabaseServerIdentifier,
54
+ formatDatabaseEndpoint,
55
+ getDatabaseEndpointScope,
56
+ isKubernetesServiceDnsHost,
57
+ parseDatabaseEndpointString,
58
+ parseManualDatabaseEndpoint,
59
+ } from "../../Types/DatabaseServer/DatabaseEndpoint";
60
+ import {
61
+ DATABASE_SYSTEMS,
62
+ DatabaseSystemDescriptor,
63
+ getDatabaseSystemDisplayName,
64
+ getDatabaseSystemFamily,
65
+ getMoreSpecificDatabaseSystem,
66
+ normalizeDatabaseSystem,
67
+ } from "../../Types/DatabaseServer/DatabaseSystem";
68
+ import { ContainerWorkloadKind } from "../../Types/DatabaseServer/DatabaseContainerClassifier";
69
+ import OneUptimeDate from "../../Types/Date";
70
+ import BadDataException from "../../Types/Exception/BadDataException";
71
+ import { JSONObject } from "../../Types/JSON";
72
+ import ObjectID from "../../Types/ObjectID";
73
+ import { mergeDatabaseServerMemberKeys } from "../../Utils/Telemetry/DatabaseServerEntityKeys";
74
+ import crypto from "crypto";
75
+
76
+ /*
77
+ * Two heartbeats, two namespaces: a collector reporting engine metrics is a
78
+ * different fact from an application merely querying the database, and one
79
+ * must never be able to suppress the other inside a window.
80
+ */
81
+ const LAST_SEEN_CACHE_NAMESPACE: string = "database-server-last-seen";
82
+ const SIGHTING_CACHE_NAMESPACE: string = "database-server-sighting";
83
+ const LAST_SEEN_THROTTLE_SECONDS: number = 60;
84
+
85
+ const LABELS_APPLIED_CACHE_NAMESPACE: string = "database-server-labels-applied";
86
+ const LABELS_APPLIED_CACHE_TTL_SECONDS: number = 60;
87
+
88
+ /*
89
+ * How often, fleet-wide, a collector heartbeat may look at whether its row was
90
+ * auto-archived. A row is only ever auto-archived after days without being
91
+ * seen, so a heartbeat restoring it up to ten minutes late costs nothing, and
92
+ * the check stays one primary-key read per database per window.
93
+ */
94
+ const AUTO_RESTORE_CHECK_CACHE_NAMESPACE: string =
95
+ "database-server-auto-restore-check";
96
+ const AUTO_RESTORE_CHECK_WINDOW_SECONDS: number = 600;
97
+
98
+ const DEFAULT_COLLECTOR_STALE_MINUTES: number = 15;
99
+ const MIN_COLLECTOR_STALE_MINUTES: number = 10;
100
+
101
+ /*
102
+ * How far collectorLastSeenAt may trail a collector's last data while it
103
+ * reports continuously: the heartbeat runs once per OTel ingest maintenance
104
+ * fence (MAINTENANCE_FENCE_TTL_SECONDS in OtelIngestBaseService: 5 minutes,
105
+ * jittered up to 25%, so 6.25) and a pod that saw the fence held waits up
106
+ * to its 30-second refusal memo more - 6.75 minutes, rounded up.
107
+ */
108
+ const COLLECTOR_HEARTBEAT_LAG_MINUTES: number = 7;
109
+
110
+ const DEFAULT_AUTO_ARCHIVE_DAYS: number = 7;
111
+ const MIN_AUTO_ARCHIVE_DAYS: number = 1;
112
+ // Rows archived per sweep; the rest wait for the next five-minute run.
113
+ const AUTO_ARCHIVE_BATCH_SIZE: number = 500;
114
+
115
+ /*
116
+ * A person's Restore holds the sweep off this long (or the archive window,
117
+ * if that is longer) unless discovery sees the database again first.
118
+ */
119
+ const MANUAL_RESTORE_GRACE_DAYS: number = 30;
120
+
121
+ const DEFAULT_AUTO_CREATE_BUDGET: number = 500;
122
+
123
+ /*
124
+ * The auto-create budget as the ingest hot path reads it: one count per
125
+ * project per minute per process, bumped locally by every create in between.
126
+ */
127
+ const AUTO_CREATE_BUDGET_CACHE_MS: number = 60 * 1000;
128
+ // One "budget reached" warning per project per ten minutes per process.
129
+ const AUTO_CREATE_BUDGET_WARNING_MS: number = 10 * 60 * 1000;
130
+
131
+ // Endpoint aliases one workload may claim per discovery run.
132
+ const MAX_WORKLOAD_ALIASES: number = 100;
133
+
134
+ /*
135
+ * A workload discovery has not seen for this long is gone: the endpoints its
136
+ * row holds may be handed to the workload that serves them now, and seeing
137
+ * one of them in a trace no longer brings its archived row back. Discovery
138
+ * runs every five minutes, so this is a dozen missed runs.
139
+ */
140
+ const WORKLOAD_GONE_MINUTES: number = 60;
141
+
142
+ /*
143
+ * A "workload" alias its workload has stopped producing is released once it
144
+ * has not been produced for this long. Produced aliases are re-stamped at
145
+ * least hourly (ENDPOINT_MATCH_REFRESH_SECONDS), so this is comfortably
146
+ * longer than any gap a healthy workload leaves, and one partial run
147
+ * releases nothing.
148
+ */
149
+ const WORKLOAD_ALIAS_RELEASE_MINUTES: number = 120;
150
+
151
+ /*
152
+ * A workload row whose columns discovery would write back unchanged is only
153
+ * rewritten once this long has passed since it was last written: discovery
154
+ * runs every five minutes, and rewriting every row every run (the member-key
155
+ * JSON included) costs a write per database per run for nothing. Its
156
+ * lastSeenAt is then at most this plus one discovery interval old - inside
157
+ * the dashboard's 30-minute "seen recently" window
158
+ * (DATABASE_SERVER_LIVE_WINDOW_MINUTES), and far inside
159
+ * WORKLOAD_GONE_MINUTES and the auto-archive sweep's one-hour margin behind
160
+ * its parent - so a skipped write never makes a live workload look inactive
161
+ * or gone.
162
+ */
163
+ const WORKLOAD_REFRESH_MINUTES: number = 15;
164
+
165
+ // How many of the project's cluster names a manual-create refusal lists.
166
+ const MAX_LISTED_CLUSTER_NAMES: number = 10;
167
+
168
+ /*
169
+ * The columns every discovery caller gets back. Enough to key telemetry
170
+ * (id, project, engine, endpoint), name it (name), decide on archive
171
+ * handling and weigh new engine evidence - but not the member-key map,
172
+ * which only the workload path reads.
173
+ */
174
+ const DISCOVERY_SELECT: Select<Model> = {
175
+ _id: true,
176
+ projectId: true,
177
+ name: true,
178
+ dbSystem: true,
179
+ dbSystemSource: true,
180
+ databaseIdentifier: true,
181
+ workloadIdentifier: true,
182
+ workloadName: true,
183
+ workloadLastSeenAt: true,
184
+ discoverySource: true,
185
+ serverAddress: true,
186
+ serverPort: true,
187
+ kubernetesClusterId: true,
188
+ kubernetesNamespace: true,
189
+ isArchived: true,
190
+ autoArchivedAt: true,
191
+ };
192
+
193
+ /*
194
+ * The workload path also compares what it would write with what the row
195
+ * holds (see workloadColumnsChanged), so it reads every column it writes.
196
+ */
197
+ const WORKLOAD_SELECT: Select<Model> = {
198
+ ...DISCOVERY_SELECT,
199
+ memberEntityKeys: true,
200
+ dbVersion: true,
201
+ collectorLastSeenAt: true,
202
+ instanceCount: true,
203
+ workloadKind: true,
204
+ dockerHostId: true,
205
+ podmanHostId: true,
206
+ lastSeenAt: true,
207
+ };
208
+
209
+ /*
210
+ * "Nobody invested in this row", as one SQL predicate over `ds` (a
211
+ * DatabaseServer row): no retention override, and no label, owner, incident
212
+ * / alert / scheduled-maintenance link or person-added endpoint - each
213
+ * checked against the row's own project and live rows only. Labels and
214
+ * owners that rules or ingest attached on their own (automaticAssignments)
215
+ * do not count: a catch-all rule would otherwise keep every discovered row
216
+ * alive forever. Shared by the auto-archive sweep and by the workload path
217
+ * deciding whether a trace-discovered duplicate may hand over its endpoints.
218
+ */
219
+ const UNTOUCHED_DATABASE_SERVER_PREDICATE: string = `ds."retainTelemetryDataForDays" IS NULL
220
+ AND (ds."telemetryRetentionConfig" IS NULL OR ds."telemetryRetentionConfig"::text IN ('null', '{}'))
221
+ AND NOT EXISTS (
222
+ SELECT 1 FROM "DatabaseServerLabel" l
223
+ INNER JOIN "Label" lbl ON lbl."_id" = l."labelId"
224
+ WHERE l."databaseServerId" = ds."_id"
225
+ AND lbl."projectId" = ds."projectId"
226
+ AND lbl."deletedAt" IS NULL
227
+ AND NOT (COALESCE(ds."automaticAssignments" -> 'labelIds', '[]'::jsonb) @> jsonb_build_array(l."labelId"::text))
228
+ )
229
+ AND NOT EXISTS (
230
+ SELECT 1 FROM "DatabaseServerOwnerUser" ou
231
+ WHERE ou."databaseServerId" = ds."_id"
232
+ AND ou."projectId" = ds."projectId"
233
+ AND ou."deletedAt" IS NULL
234
+ AND NOT (COALESCE(ds."automaticAssignments" -> 'ownerUserIds', '[]'::jsonb) @> jsonb_build_array(ou."userId"::text))
235
+ )
236
+ AND NOT EXISTS (
237
+ SELECT 1 FROM "DatabaseServerOwnerTeam" ot
238
+ WHERE ot."databaseServerId" = ds."_id"
239
+ AND ot."projectId" = ds."projectId"
240
+ AND ot."deletedAt" IS NULL
241
+ AND NOT (COALESCE(ds."automaticAssignments" -> 'ownerTeamIds', '[]'::jsonb) @> jsonb_build_array(ot."teamId"::text))
242
+ )
243
+ AND NOT EXISTS (
244
+ SELECT 1 FROM "IncidentDatabaseServer" ids
245
+ INNER JOIN "Incident" i ON i."_id" = ids."incidentId"
246
+ WHERE ids."databaseServerId" = ds."_id"
247
+ AND i."projectId" = ds."projectId"
248
+ AND i."deletedAt" IS NULL
249
+ )
250
+ AND NOT EXISTS (
251
+ SELECT 1 FROM "AlertDatabaseServer" ads
252
+ INNER JOIN "Alert" a ON a."_id" = ads."alertId"
253
+ WHERE ads."databaseServerId" = ds."_id"
254
+ AND a."projectId" = ds."projectId"
255
+ AND a."deletedAt" IS NULL
256
+ )
257
+ AND NOT EXISTS (
258
+ SELECT 1 FROM "ScheduledMaintenanceDatabaseServer" sds
259
+ INNER JOIN "ScheduledMaintenance" sm ON sm."_id" = sds."scheduledMaintenanceId"
260
+ WHERE sds."databaseServerId" = ds."_id"
261
+ AND sm."projectId" = ds."projectId"
262
+ AND sm."deletedAt" IS NULL
263
+ )
264
+ AND NOT EXISTS (
265
+ SELECT 1 FROM "DatabaseServerEndpoint" e
266
+ WHERE e."databaseServerId" = ds."_id"
267
+ AND e."projectId" = ds."projectId"
268
+ AND e."source" = 'user'
269
+ AND e."deletedAt" IS NULL
270
+ )`;
271
+
272
+ /*
273
+ * The labels and owners rules and ingest attach on their own, recorded per
274
+ * row in DatabaseServer.automaticAssignments under these keys.
275
+ */
276
+ export type DatabaseServerAutomaticAssignmentKind =
277
+ | "labelIds"
278
+ | "ownerUserIds"
279
+ | "ownerTeamIds";
280
+
281
+ const AUTOMATIC_ASSIGNMENT_KINDS: ReadonlyArray<DatabaseServerAutomaticAssignmentKind> =
282
+ ["labelIds", "ownerUserIds", "ownerTeamIds"];
283
+
284
+ /*
285
+ * What a row's engine (dbSystem) was determined from, strongest first:
286
+ * - manual: a person chose it;
287
+ * - collector: an OTel Collector receiver or the Database Agent - the
288
+ * engine reporting on itself;
289
+ * - container: the image or Helm chart of a Kubernetes / Docker / Podman
290
+ * workload;
291
+ * - client-spans: what an application's client library called it. A
292
+ * wire-compatible client names the engine it speaks to, not the engine
293
+ * it reached (pgx says "postgresql" to CockroachDB).
294
+ */
295
+ export enum DatabaseSystemEvidence {
296
+ Manual = "manual",
297
+ Collector = "collector",
298
+ Container = "container",
299
+ ClientSpans = "client-spans",
300
+ }
301
+
302
+ const DATABASE_SYSTEM_EVIDENCE_RANK: Record<DatabaseSystemEvidence, number> = {
303
+ [DatabaseSystemEvidence.Manual]: 4,
304
+ [DatabaseSystemEvidence.Collector]: 3,
305
+ [DatabaseSystemEvidence.Container]: 2,
306
+ [DatabaseSystemEvidence.ClientSpans]: 1,
307
+ };
308
+
309
+ const DATABASE_SYSTEM_EVIDENCE_LABEL: Record<DatabaseSystemEvidence, string> = {
310
+ [DatabaseSystemEvidence.Manual]: "a person",
311
+ [DatabaseSystemEvidence.Collector]:
312
+ "its OpenTelemetry Collector receiver or Database Agent",
313
+ [DatabaseSystemEvidence.Container]: "its container image",
314
+ [DatabaseSystemEvidence.ClientSpans]: "application traces",
315
+ };
316
+
317
+ export interface DatabaseSystemDetermination {
318
+ system: string;
319
+ evidence: DatabaseSystemEvidence;
320
+ }
321
+
322
+ export interface FindOrCreateDatabaseServerByEndpointData {
323
+ projectId: ObjectID;
324
+ dbSystem: string;
325
+ endpoint: DatabaseEndpoint;
326
+ discoverySource: DatabaseServerDiscoverySource;
327
+ displayName?: string | undefined;
328
+ allowCreate: boolean;
329
+ /*
330
+ * The collector path only: what the batch that finds or creates the row
331
+ * reports. A row it creates carries the agent and engine versions from its
332
+ * first moment - the collector heartbeat that follows the create races the
333
+ * create's own feed item and rule runs for the row lock, and a skipped
334
+ * heartbeat is not retried until the ingest maintenance fence expires - and
335
+ * its Created feed item says whether the Database Agent found it.
336
+ */
337
+ collector?: DatabaseServerCollectorReport | undefined;
338
+ }
339
+
340
+ export interface DatabaseServerCollectorReport {
341
+ // `oneuptime.agent.version`.
342
+ agentVersion?: string | undefined;
343
+ // The engine's own version (`db.system.version`, the receiver's).
344
+ dbVersion?: string | undefined;
345
+ // The batch carries `oneuptime.database.agent`: the Database Agent sent it.
346
+ reportedByDatabaseAgent?: boolean | undefined;
347
+ }
348
+
349
+ /*
350
+ * What only the discovery path that creates a row knows, for its Created
351
+ * feed item: the endpoint it was found at and who reported it.
352
+ */
353
+ interface DatabaseServerCreationContext {
354
+ endpoint?: string | undefined;
355
+ collector?: DatabaseServerCollectorReport | undefined;
356
+ }
357
+
358
+ // The Created feed item of a row no person added (describeCreationOrigin).
359
+ interface DatabaseServerCreationOrigin {
360
+ emoji: string;
361
+ // Follows the database's link: "was created automatically: detected from ...".
362
+ summary: string;
363
+ // The "**Created by**" line.
364
+ createdBy: string;
365
+ explanation: string;
366
+ }
367
+
368
+ export interface UpsertWorkloadDatabaseData {
369
+ projectId: ObjectID;
370
+ // buildWorkloadDatabaseServerIdentifier.
371
+ workloadIdentifier: string;
372
+ dbSystem: string;
373
+ displayName: string;
374
+ discoverySource: DatabaseServerDiscoverySource;
375
+ // Formatted endpoints (buildKubernetesDatabaseAliases); [] for containers.
376
+ aliases: Array<string>;
377
+ // keyForKubernetesPod / keyForKubernetesDeployment / keyForContainer.
378
+ memberKeysSeenNow: Array<string>;
379
+ instanceCount: number;
380
+ dbVersion?: string | undefined;
381
+ kubernetesClusterId?: ObjectID | undefined;
382
+ kubernetesNamespace?: string | undefined;
383
+ workloadKind: string;
384
+ workloadName: string;
385
+ dockerHostId?: ObjectID | undefined;
386
+ podmanHostId?: ObjectID | undefined;
387
+ allowCreate: boolean;
388
+ }
389
+
390
+ interface WorkloadAlias {
391
+ endpoint: string;
392
+ scope: DatabaseEndpointScope;
393
+ parsed: DatabaseEndpoint;
394
+ }
395
+
396
+ // A stored DatabaseServerEndpoint row, as the workload path weighs it.
397
+ interface StoredEndpoint {
398
+ endpointId: ObjectID | null;
399
+ databaseServerId: string;
400
+ source: string;
401
+ isPrimary: boolean;
402
+ lastMatchedAt: Date | null;
403
+ }
404
+
405
+ interface HeldAlias {
406
+ alias: WorkloadAlias;
407
+ stored: StoredEndpoint;
408
+ }
409
+
410
+ interface AutoCreateCount {
411
+ count: number;
412
+ readAtMs: number;
413
+ }
414
+
415
+ /*
416
+ * The Databases product's root service. A DatabaseServer row is found and
417
+ * created by ENDPOINT (DatabaseServerEndpoint, one owner per endpoint per
418
+ * project) or by WORKLOAD (workloadIdentifier), never by name - two engines
419
+ * can share a host name and a person can rename any row.
420
+ *
421
+ * Invariants every discovery path relies on:
422
+ * - an endpoint belongs to at most one row, and an ambiguous match is never
423
+ * tie-broken. A claim never takes an endpoint away from a live row: the
424
+ * only moves are the workload path handing a DISCOVERED ("auto" /
425
+ * "workload") endpoint from a workload that is gone, or from an untouched
426
+ * trace-discovered duplicate, to the workload that serves it now. A
427
+ * person's ("user") endpoint is never moved or released by discovery;
428
+ * - a LOCAL-scope endpoint (single-label name, unqualified cluster-local
429
+ * DNS or private IP) never creates a row and never adopts one;
430
+ * - discovery only ever un-archives a row it archived itself
431
+ * (autoArchivedAt), never one a person archived;
432
+ * - an engine a person chose is never changed, and a weaker source never
433
+ * changes an engine a stronger one determined (DatabaseSystemEvidence)
434
+ * - a container image may still refine it to a fork, and undo a fork
435
+ * only client spans named (decideDatabaseSystem).
436
+ */
437
+ export class Service extends DatabaseService<Model> {
438
+ /*
439
+ * In-process front for the fleet-wide auto-restore gate: a collector
440
+ * heartbeat arrives per batch, and asking Redis per batch whether it is time
441
+ * to look again would cost more than the heartbeat's own gates.
442
+ */
443
+ private autoRestoreCheckMemo: InProcessMemo<boolean> =
444
+ new InProcessMemo<boolean>({
445
+ ttlInMs: 60 * 1000,
446
+ maxEntries: 10_000,
447
+ });
448
+
449
+ // Per project: its auto-created row count, as last read (see getAutoCreateCount).
450
+ private autoCreateCountMemo: InProcessMemo<AutoCreateCount> =
451
+ new InProcessMemo<AutoCreateCount>({
452
+ ttlInMs: AUTO_CREATE_BUDGET_CACHE_MS,
453
+ maxEntries: 10_000,
454
+ });
455
+
456
+ // Per project: a "budget reached" warning was logged recently.
457
+ private autoCreateBudgetWarningMemo: InProcessMemo<boolean> =
458
+ new InProcessMemo<boolean>({
459
+ ttlInMs: AUTO_CREATE_BUDGET_WARNING_MS,
460
+ maxEntries: 10_000,
461
+ });
462
+
463
+ public constructor() {
464
+ super(Model);
465
+ }
466
+
467
+ /*
468
+ * A person adding a database from the dashboard or the API. Root creates -
469
+ * every discovery path - pass through untouched: they compute identity and
470
+ * claim endpoints themselves.
471
+ *
472
+ * Everything set here is a user-creatable column on the model (name,
473
+ * description, dbSystem, serverAddress, serverPort, databaseIdentifier,
474
+ * discoverySource), because the column permission check runs after this
475
+ * hook. Setting a root-only column here would refuse every manual create.
476
+ *
477
+ * The create permission is checked FIRST, before any lookup: the refusals
478
+ * below name databases, and a caller who may not add a database must not
479
+ * be able to use them to learn what exists.
480
+ *
481
+ * The typed address is read by parseManualDatabaseEndpoint, whose refusals
482
+ * say what to change (`user@host`, a cluster qualifier on a public host, a
483
+ * loopback name, a port out of range), and a Kubernetes Service name typed
484
+ * without its cluster is refused in a project that has clusters (see
485
+ * refuseUnqualifiedKubernetesServiceName).
486
+ */
487
+ @CaptureSpan()
488
+ protected override async onBeforeCreate(
489
+ createBy: CreateBy<Model>,
490
+ ): Promise<OnCreate<Model>> {
491
+ if (createBy.props.isRoot) {
492
+ return { createBy: createBy, carryForward: null };
493
+ }
494
+
495
+ const data: Model = createBy.data;
496
+
497
+ ModelPermission.checkCreatePermissions(Model, data, createBy.props);
498
+
499
+ // The tenant column is stamped from props.tenantId only after this hook.
500
+ const projectId: ObjectID | undefined =
501
+ createBy.props.tenantId || data.projectId || undefined;
502
+
503
+ if (!projectId) {
504
+ throw new BadDataException("Project ID is required to add a database.");
505
+ }
506
+
507
+ const dbSystem: string | null = normalizeDatabaseSystem(data.dbSystem);
508
+
509
+ if (!dbSystem) {
510
+ throw new BadDataException(
511
+ "Database engine is required. Choose the engine this database runs, for example PostgreSQL or MySQL.",
512
+ );
513
+ }
514
+
515
+ const manual: ManualDatabaseEndpoint = parseManualEndpoint({
516
+ serverAddress: data.serverAddress,
517
+ serverPort: data.serverPort,
518
+ dbSystem: dbSystem,
519
+ });
520
+ const endpoint: DatabaseEndpoint = manual.endpoint!;
521
+
522
+ const formatted: string = formatDatabaseEndpoint(endpoint);
523
+
524
+ const owner: DatabaseServerEndpointOwner | null =
525
+ await DatabaseServerEndpointService.findOwnerByEndpoint(
526
+ projectId,
527
+ formatted,
528
+ );
529
+
530
+ if (owner) {
531
+ throw new BadDataException(
532
+ await getOwnedByOtherDatabaseMessage(formatted, owner, createBy.props),
533
+ );
534
+ }
535
+
536
+ await this.refuseUnqualifiedKubernetesServiceName({
537
+ projectId: projectId,
538
+ manual: manual,
539
+ serverAddress: data.serverAddress,
540
+ serverPort: data.serverPort,
541
+ dbSystem: dbSystem,
542
+ props: createBy.props,
543
+ });
544
+
545
+ const databaseIdentifier: string = buildDatabaseServerIdentifier(
546
+ dbSystem,
547
+ endpoint,
548
+ );
549
+
550
+ /*
551
+ * Normally unreachable - the row with this identifier would own the
552
+ * endpoint - but the unique index would otherwise answer with a raw
553
+ * constraint error.
554
+ */
555
+ const sameIdentity: Model | null = await this.findOneBy({
556
+ query: {
557
+ projectId: projectId,
558
+ databaseIdentifier: databaseIdentifier,
559
+ },
560
+ select: {
561
+ _id: true,
562
+ name: true,
563
+ },
564
+ props: {
565
+ isRoot: true,
566
+ },
567
+ });
568
+
569
+ if (sameIdentity) {
570
+ // Named only when the caller may read it.
571
+ const sameIdentityName: string = sameIdentity.id
572
+ ? await this.getDatabaseServerNameIfReadable({
573
+ databaseServerId: sameIdentity.id,
574
+ props: createBy.props,
575
+ })
576
+ : "";
577
+
578
+ throw new BadDataException(
579
+ `A ${getDatabaseSystemDisplayName(dbSystem)} database at ${formatted} already exists${
580
+ sameIdentityName ? `: "${sameIdentityName}"` : ""
581
+ }.`,
582
+ );
583
+ }
584
+
585
+ const name: string = typeof data.name === "string" ? data.name.trim() : "";
586
+
587
+ data.dbSystem = dbSystem;
588
+ data.serverAddress = endpoint.host;
589
+ if (endpoint.port !== null) {
590
+ data.serverPort = endpoint.port;
591
+ }
592
+ data.databaseIdentifier = databaseIdentifier;
593
+ // First creator wins, and a person creating it is "manual" - whatever was sent.
594
+ data.discoverySource = DatabaseServerDiscoverySource.Manual;
595
+ data.name =
596
+ name ||
597
+ buildDatabaseServerDisplayName({ system: dbSystem, endpoint: endpoint });
598
+
599
+ return {
600
+ createBy: createBy,
601
+ carryForward: {
602
+ primaryEndpoint: formatted,
603
+ },
604
+ };
605
+ }
606
+
607
+ @CaptureSpan()
608
+ protected override async onCreateSuccess(
609
+ onCreate: OnCreate<Model>,
610
+ createdItem: Model,
611
+ ): Promise<Model> {
612
+ const primaryEndpoint: string | undefined =
613
+ onCreate.carryForward?.primaryEndpoint;
614
+
615
+ if (primaryEndpoint && createdItem.projectId && createdItem.id) {
616
+ await this.claimManualPrimaryEndpoint({
617
+ projectId: createdItem.projectId,
618
+ databaseServerId: createdItem.id,
619
+ endpoint: primaryEndpoint,
620
+ props: onCreate.createBy.props,
621
+ });
622
+ }
623
+
624
+ const createdByUserId: ObjectID | undefined =
625
+ createdItem.createdByUserId ||
626
+ onCreate.createBy.props.userId ||
627
+ undefined;
628
+
629
+ this.runCreatedSideEffects(createdItem, createdByUserId);
630
+
631
+ return createdItem;
632
+ }
633
+
634
+ @CaptureSpan()
635
+ protected override async onUpdateSuccess(
636
+ onUpdate: OnUpdate<Model>,
637
+ updatedItemIds: Array<ObjectID>,
638
+ ): Promise<OnUpdate<Model>> {
639
+ const updateData: JSONObject = onUpdate.updateBy
640
+ .data as unknown as JSONObject;
641
+
642
+ /*
643
+ * A person archiving or restoring a row takes it out of discovery's
644
+ * hands: from now on only a person restores it. A person's Restore also
645
+ * holds off the auto-archive sweep (manuallyRestoredAt), which would
646
+ * otherwise re-archive a stale row within minutes. Discovery's own
647
+ * archive and restore are raw writes, so they never reach this hook.
648
+ */
649
+ if (
650
+ ResourceFeedUtil.isArchiveChange(updateData) &&
651
+ !("autoArchivedAt" in updateData)
652
+ ) {
653
+ await this.recordArchiveDecisionByPerson(
654
+ updatedItemIds,
655
+ !updateData["isArchived"],
656
+ );
657
+ }
658
+
659
+ /*
660
+ * Labels a person saves on a row are theirs, whichever rule or
661
+ * collector first attached them: they now count as investment.
662
+ */
663
+ if ("labels" in updateData) {
664
+ const labelIds: Array<string> = readRelationIds(updateData["labels"]);
665
+
666
+ for (const databaseServerId of updatedItemIds) {
667
+ await this.forgetAutomaticAssignments({
668
+ databaseServerId: databaseServerId,
669
+ kind: "labelIds",
670
+ ids: labelIds,
671
+ });
672
+ }
673
+ }
674
+
675
+ // An engine a person set is never overridden by discovery.
676
+ if ("dbSystem" in updateData) {
677
+ await this.recordDatabaseSystemSetByPerson(updatedItemIds);
678
+ }
679
+
680
+ this.writeDatabaseServerUpdatedFeed(onUpdate, updatedItemIds).catch(
681
+ (error: Error) => {
682
+ logger.error(error);
683
+ },
684
+ );
685
+
686
+ return onUpdate;
687
+ }
688
+
689
+ /**
690
+ * The database that owns this endpoint - or, when none does and creating
691
+ * is allowed, a new one that does. Returns null when nothing owns the
692
+ * endpoint and no row may be created for it (creation not allowed, a
693
+ * LOCAL-scope endpoint, which only ever joins an existing row, or - for
694
+ * the collector path, which has no budget of its own - a project that has
695
+ * reached its auto-create budget).
696
+ *
697
+ * An owner that is found is also told about the sighting: the endpoint's
698
+ * lastMatchedAt moves (throttled), the reported engine is weighed against
699
+ * the row's (a stronger source may correct or refine it, a weaker one
700
+ * never does), and a row discovery archived is restored - unless it is
701
+ * the row of a workload that is gone, which a stale alias must not bring
702
+ * back. Application traces get null for such a row (isRetiredWorkloadRow):
703
+ * a trace that cannot restore it does not sight it either, so the trace
704
+ * path records no lastSeenAt on it and claims no endpoints for it.
705
+ *
706
+ * Races. Two writers can see the endpoint unowned at once. The row insert
707
+ * is guarded by the (projectId, databaseIdentifier) unique index - the
708
+ * loser re-reads the winner's row - and the endpoint claim by the
709
+ * (projectId, endpoint) one. If our new row loses the endpoint claim to a
710
+ * DIFFERENT row (a wire-compatible engine reported for the same endpoint,
711
+ * say), our row is an orphan with no endpoint: it is deleted before anyone
712
+ * saw it (it was inserted without hooks, so no feed, rule run or
713
+ * notification ever referred to it) and the owner's row is returned.
714
+ */
715
+ @CaptureSpan()
716
+ public async findOrCreateByEndpoint(
717
+ data: FindOrCreateDatabaseServerByEndpointData,
718
+ ): Promise<Model | null> {
719
+ if (!data || !data.endpoint || !data.endpoint.host) {
720
+ return null;
721
+ }
722
+
723
+ const formatted: string = formatDatabaseEndpoint(data.endpoint);
724
+
725
+ const owner: DatabaseServerEndpointOwner | null =
726
+ await DatabaseServerEndpointService.findOwnerByEndpoint(
727
+ data.projectId,
728
+ formatted,
729
+ );
730
+
731
+ if (owner) {
732
+ const ownerRow: Model | null = await this.findByIdInProject(
733
+ data.projectId,
734
+ owner.databaseServerId,
735
+ );
736
+
737
+ if (ownerRow && this.isRetiredFor(ownerRow, data.discoverySource)) {
738
+ return null;
739
+ }
740
+
741
+ await DatabaseServerEndpointService.markEndpointMatched(owner);
742
+
743
+ if (ownerRow) {
744
+ await this.recordOwnerSighting(ownerRow, data);
745
+ }
746
+
747
+ return ownerRow;
748
+ }
749
+
750
+ if (!data.allowCreate) {
751
+ return null;
752
+ }
753
+
754
+ if (getDatabaseEndpointScope(data.endpoint) === "local") {
755
+ return null;
756
+ }
757
+
758
+ // An unknown engine is kept verbatim, so clamp it to its column.
759
+ const dbSystem: string | null = cleanShortText(
760
+ normalizeDatabaseSystem(data.dbSystem),
761
+ );
762
+
763
+ if (!dbSystem) {
764
+ return null;
765
+ }
766
+
767
+ /*
768
+ * The trace and container paths ask the budget themselves, once per run
769
+ * (AutoCreateBudget). The collector path runs on the ingest hot path and
770
+ * has no run to hang that on, so it is asked here - from a per-process
771
+ * cache, so a steady stream of known endpoints costs nothing.
772
+ */
773
+ if (
774
+ data.discoverySource === DatabaseServerDiscoverySource.Collector &&
775
+ !(await this.allowsCollectorAutoCreate(data.projectId, formatted))
776
+ ) {
777
+ return null;
778
+ }
779
+
780
+ return await this.createEndpointDatabaseServer({
781
+ projectId: data.projectId,
782
+ dbSystem: dbSystem,
783
+ endpoint: data.endpoint,
784
+ formattedEndpoint: formatted,
785
+ discoverySource: data.discoverySource,
786
+ displayName: data.displayName,
787
+ collector: data.collector,
788
+ });
789
+ }
790
+
791
+ /**
792
+ * One row by id, only inside this project (an id from a resource attribute
793
+ * is untrusted input), with the columns discovery callers need.
794
+ */
795
+ @CaptureSpan()
796
+ public async findByIdInProject(
797
+ projectId: ObjectID,
798
+ databaseServerId: ObjectID,
799
+ ): Promise<Model | null> {
800
+ if (!projectId || !databaseServerId) {
801
+ return null;
802
+ }
803
+
804
+ return await this.findOneBy({
805
+ query: {
806
+ _id: databaseServerId.toString(),
807
+ projectId: projectId,
808
+ },
809
+ select: DISCOVERY_SELECT,
810
+ props: {
811
+ isRoot: true,
812
+ },
813
+ });
814
+ }
815
+
816
+ /**
817
+ * The row of one Kubernetes / Docker / Podman database workload, found by
818
+ * workloadIdentifier, else ADOPTED from the endpoint row its aliases point
819
+ * at, else created (when allowed). Then: its aliases are claimed (an alias
820
+ * another row owns is skipped, never taken), its member keys merged, its
821
+ * workload columns refreshed and lastSeenAt stamped - never
822
+ * otelCollectorStatus, which only a collector sets.
823
+ *
824
+ * Adoption joins what application traces already found (an endpoint row
825
+ * named after the Service DNS) to the workload serving it, so the database
826
+ * does not show up twice. It happens only when the workload's GLOBAL-scope
827
+ * aliases are owned by exactly ONE row and that row is not already some
828
+ * other workload's. Two owners is ambiguous and is never tie-broken.
829
+ *
830
+ * Root, hook-free writes after the lookup; never throws (a worker run over
831
+ * many workloads must survive one bad one) - failures log and return null.
832
+ */
833
+ @CaptureSpan()
834
+ public async upsertWorkloadDatabase(
835
+ data: UpsertWorkloadDatabaseData,
836
+ ): Promise<Model | null> {
837
+ try {
838
+ return await this.upsertWorkloadDatabaseOrThrow(data);
839
+ } catch (error) {
840
+ logger.error(
841
+ `DatabaseServerService.upsertWorkloadDatabase failed for workload ${
842
+ data?.workloadIdentifier || "(none)"
843
+ }: ${error instanceof Error ? error.message : String(error)}`,
844
+ {
845
+ projectId: data?.projectId?.toString(),
846
+ } as LogAttributes,
847
+ );
848
+
849
+ return null;
850
+ }
851
+ }
852
+
853
+ /**
854
+ * Collector liveness: an OTel Collector database receiver (or the Database
855
+ * Agent) reported engine telemetry for this database. Refreshes lastSeenAt,
856
+ * collectorLastSeenAt and otelCollectorStatus "connected" once per window,
857
+ * and the agent / engine versions when they change. The engine version the
858
+ * collector reports is the one the row shows: the workload path's image
859
+ * tag never overwrites it while the collector is live.
860
+ *
861
+ * At most once per ten minutes per database, fleet-wide, the row is also
862
+ * reconciled with what the collector says: one it brings back after it was
863
+ * auto-archived is restored, and `dbSystem` (the engine the receiver or the
864
+ * agent reported) is weighed as collector evidence - it corrects an engine
865
+ * a container image or client spans guessed, and refines a family engine
866
+ * to a fork, but never overrides a person's choice or undoes a fork.
867
+ */
868
+ @CaptureSpan()
869
+ public async recordCollectorHeartbeat(
870
+ databaseServerId: ObjectID,
871
+ extra?: {
872
+ agentVersion?: string | undefined;
873
+ dbVersion?: string | undefined;
874
+ dbSystem?: string | undefined;
875
+ },
876
+ ): Promise<void> {
877
+ const agentVersion: string | null = cleanShortText(extra?.agentVersion);
878
+ const dbVersion: string | null = cleanShortText(extra?.dbVersion);
879
+
880
+ const extrasFingerprint: string = crypto
881
+ .createHash("sha256")
882
+ .update(
883
+ JSON.stringify({
884
+ agentVersion: agentVersion,
885
+ dbVersion: dbVersion,
886
+ }),
887
+ )
888
+ .digest("hex");
889
+
890
+ const now: Date = OneUptimeDate.getCurrentDate();
891
+
892
+ const liveness: PartialEntity<Model> = {
893
+ lastSeenAt: now,
894
+ collectorLastSeenAt: now,
895
+ otelCollectorStatus: "connected",
896
+ };
897
+
898
+ const metadata: PartialEntity<Model> = {};
899
+
900
+ if (agentVersion) {
901
+ metadata.agentVersion = agentVersion;
902
+ }
903
+
904
+ if (dbVersion) {
905
+ metadata.dbVersion = dbVersion;
906
+ }
907
+
908
+ /*
909
+ * One gated, non-blocking heartbeat write. The gates, the fail-open /
910
+ * fail-closed split and the liveness-only fallback all live in
911
+ * ResourceHeartbeat.
912
+ */
913
+ await ResourceHeartbeat.write({
914
+ service: this,
915
+ id: databaseServerId,
916
+ cacheNamespace: LAST_SEEN_CACHE_NAMESPACE,
917
+ throttleInSeconds: LAST_SEEN_THROTTLE_SECONDS,
918
+ liveness: liveness,
919
+ metadata: metadata,
920
+ fingerprint: extrasFingerprint,
921
+ describe: `database ${databaseServerId.toString()}`,
922
+ });
923
+
924
+ await this.reconcileAfterHeartbeat(databaseServerId, extra?.dbSystem);
925
+ }
926
+
927
+ /**
928
+ * Any-source liveness: an application called this database (a CLIENT
929
+ * span) or discovery saw it. Only lastSeenAt moves - a database that is
930
+ * merely queried says nothing about whether engine metrics are arriving,
931
+ * so otelCollectorStatus is left alone.
932
+ */
933
+ @CaptureSpan()
934
+ public async recordSighting(databaseServerId: ObjectID): Promise<void> {
935
+ const liveness: PartialEntity<Model> = {
936
+ lastSeenAt: OneUptimeDate.getCurrentDate(),
937
+ };
938
+
939
+ await ResourceHeartbeat.write({
940
+ service: this,
941
+ id: databaseServerId,
942
+ cacheNamespace: SIGHTING_CACHE_NAMESPACE,
943
+ throttleInSeconds: LAST_SEEN_THROTTLE_SECONDS,
944
+ liveness: liveness,
945
+ fingerprint: "",
946
+ describe: `database ${databaseServerId.toString()} (sighting)`,
947
+ });
948
+ }
949
+
950
+ /**
951
+ * Additively attach labels to a database. Existing labels are never
952
+ * removed - manual labels set via the UI survive ingest. The set of
953
+ * labelIds passed in is fingerprinted and cached for 60s so a collector
954
+ * pushing the same labels every batch costs one cache lookup, not a
955
+ * join-table scan. The labels this adds are recorded as automatic, so they
956
+ * never count as somebody investing in the row (a label a person already
957
+ * put on the row is not re-added, and stays theirs).
958
+ */
959
+ @CaptureSpan()
960
+ public async attachLabels(data: {
961
+ databaseServerId: ObjectID;
962
+ labelIds: Array<ObjectID>;
963
+ }): Promise<void> {
964
+ if (!data.labelIds || data.labelIds.length === 0) {
965
+ return;
966
+ }
967
+
968
+ const cacheKey: string = data.databaseServerId.toString();
969
+ const fingerprint: string = fingerprintLabelIds(data.labelIds);
970
+ const cached: string | null = await GlobalCache.getString(
971
+ LABELS_APPLIED_CACHE_NAMESPACE,
972
+ cacheKey,
973
+ );
974
+ if (cached === fingerprint) {
975
+ return;
976
+ }
977
+
978
+ try {
979
+ const databaseServerIdStr: string = data.databaseServerId.toString();
980
+ const existingLabels: Array<Label> = await this.getRepository()
981
+ .createQueryBuilder()
982
+ .relation(Model, "labels")
983
+ .of(databaseServerIdStr)
984
+ .loadMany();
985
+
986
+ const existingIds: Set<string> = new Set();
987
+ for (const lbl of existingLabels) {
988
+ const idStr: string | undefined = lbl._id?.toString();
989
+ if (idStr) {
990
+ existingIds.add(idStr);
991
+ }
992
+ }
993
+
994
+ const toAddIds: Array<string> = [];
995
+ const seen: Set<string> = new Set();
996
+ for (const id of data.labelIds) {
997
+ const idStr: string = id.toString();
998
+ if (existingIds.has(idStr) || seen.has(idStr)) {
999
+ continue;
1000
+ }
1001
+ seen.add(idStr);
1002
+ toAddIds.push(idStr);
1003
+ }
1004
+
1005
+ if (toAddIds.length > 0) {
1006
+ await this.getRepository()
1007
+ .createQueryBuilder()
1008
+ .relation(Model, "labels")
1009
+ .of(databaseServerIdStr)
1010
+ .add(toAddIds);
1011
+
1012
+ await this.recordAutomaticAssignments({
1013
+ databaseServerId: data.databaseServerId,
1014
+ kind: "labelIds",
1015
+ ids: toAddIds,
1016
+ });
1017
+ }
1018
+
1019
+ await GlobalCache.setString(
1020
+ LABELS_APPLIED_CACHE_NAMESPACE,
1021
+ cacheKey,
1022
+ fingerprint,
1023
+ { expiresInSeconds: LABELS_APPLIED_CACHE_TTL_SECONDS },
1024
+ );
1025
+ } catch (err) {
1026
+ logger.warn(
1027
+ `DatabaseServerService.attachLabels failed for database ${data.databaseServerId.toString()}: ${
1028
+ err instanceof Error ? err.message : String(err)
1029
+ }`,
1030
+ );
1031
+ }
1032
+ }
1033
+
1034
+ /*
1035
+ * Minutes of collector SILENCE before Engine metrics reads Disconnected
1036
+ * (DATABASE_SERVER_COLLECTOR_STALE_MINUTES): 15 by default; an override
1037
+ * below 10 is raised to 10 rather than allowed to flap healthy databases.
1038
+ * Anything unparseable falls back to the default. It is measured from the
1039
+ * last data, not from collectorLastSeenAt - see getCollectorSilenceCutoff.
1040
+ */
1041
+ public getCollectorStaleThresholdMinutes(): number {
1042
+ return readIntegerEnv({
1043
+ name: "DATABASE_SERVER_COLLECTOR_STALE_MINUTES",
1044
+ defaultValue: DEFAULT_COLLECTOR_STALE_MINUTES,
1045
+ min: MIN_COLLECTOR_STALE_MINUTES,
1046
+ });
1047
+ }
1048
+
1049
+ /*
1050
+ * The collectorLastSeenAt before which a collector has been silent for at
1051
+ * least getCollectorStaleThresholdMinutes(). The heartbeat that moves
1052
+ * collectorLastSeenAt runs behind the 5-minute OTel ingest maintenance
1053
+ * fence, so while data arrives continuously the column trails the last
1054
+ * data by up to COLLECTOR_HEARTBEAT_LAG_MINUTES. Comparing it with the
1055
+ * threshold alone flipped databases after as little as ~8 minutes of real
1056
+ * silence (e2e: 12m52s); adding the lag makes "15 minutes" a floor.
1057
+ */
1058
+ public getCollectorSilenceCutoff(now: Date): Date {
1059
+ return OneUptimeDate.addRemoveMinutes(
1060
+ now,
1061
+ -(
1062
+ this.getCollectorStaleThresholdMinutes() +
1063
+ COLLECTOR_HEARTBEAT_LAG_MINUTES
1064
+ ),
1065
+ );
1066
+ }
1067
+
1068
+ /**
1069
+ * Flip databases whose collector went quiet to "disconnected": silent for
1070
+ * at least getCollectorStaleThresholdMinutes() (getCollectorSilenceCutoff).
1071
+ * Keyed on collectorLastSeenAt, NOT lastSeenAt: an application still
1072
+ * querying the database keeps lastSeenAt fresh, and that must not hide a
1073
+ * dead collector. One conditional statement, so a heartbeat landing
1074
+ * mid-sweep cannot be overwritten. Returns how many rows were flipped.
1075
+ */
1076
+ @CaptureSpan()
1077
+ public async markDisconnectedDatabaseServers(): Promise<number> {
1078
+ const threshold: Date = this.getCollectorSilenceCutoff(
1079
+ OneUptimeDate.getCurrentDate(),
1080
+ );
1081
+
1082
+ const rows: Array<{ count: number | string }> =
1083
+ await this.getRepository().manager.query(
1084
+ `WITH "disconnected" AS (
1085
+ UPDATE "DatabaseServer"
1086
+ SET "otelCollectorStatus" = 'disconnected', "updatedAt" = CURRENT_TIMESTAMP
1087
+ WHERE "otelCollectorStatus" = 'connected'
1088
+ AND ("collectorLastSeenAt" IS NULL OR "collectorLastSeenAt" < $1)
1089
+ AND "deletedAt" IS NULL
1090
+ RETURNING "_id"
1091
+ )
1092
+ SELECT COUNT(*)::int AS "count" FROM "disconnected"`,
1093
+ [threshold],
1094
+ );
1095
+
1096
+ return readCount(rows);
1097
+ }
1098
+
1099
+ /*
1100
+ * Days a discovered database may go unseen before the sweep archives it.
1101
+ * Default 7, at least 1.
1102
+ */
1103
+ public getAutoArchiveDays(): number {
1104
+ return readIntegerEnv({
1105
+ name: "DATABASE_SERVER_AUTO_ARCHIVE_DAYS",
1106
+ defaultValue: DEFAULT_AUTO_ARCHIVE_DAYS,
1107
+ min: MIN_AUTO_ARCHIVE_DAYS,
1108
+ });
1109
+ }
1110
+
1111
+ /**
1112
+ * Archive discovered databases nobody has seen - and nobody has touched -
1113
+ * for getAutoArchiveDays(). Discovery creates rows on its own, so without
1114
+ * this a decommissioned database, or a one-off connection string in a
1115
+ * trace, would sit in the list forever.
1116
+ *
1117
+ * "Untouched" is what makes this safe to do unasked: a manually added
1118
+ * database is never archived, and neither is one somebody invested in -
1119
+ * labels or owners a PERSON added (not the ones rules or ingest attached
1120
+ * on their own), a link from an incident / alert / scheduled maintenance,
1121
+ * an endpoint a person added, or a retention override. Every one of those
1122
+ * checks is done in SQL, scoped to the row's project and to live rows
1123
+ * (UNTOUCHED_DATABASE_SERVER_PREDICATE).
1124
+ *
1125
+ * Two more rows are left alone:
1126
+ * - one a person restored from the archive, until discovery sees it
1127
+ * again or the grace period passes - their Restore must stick;
1128
+ * - one whose Kubernetes cluster / Docker / Podman host has gone quiet:
1129
+ * discovery only visits connected parents, so while the parent is dark
1130
+ * its databases are unseen for a reason that says nothing about them.
1131
+ * Staleness is anchored to the parent's own last contact - a row is
1132
+ * only stale if it went unseen well before its parent did - the way
1133
+ * the Kubernetes and Docker inventory cleanups keep last-known state.
1134
+ * A row whose parent was deleted ages out on its own.
1135
+ *
1136
+ * The row is marked autoArchivedAt, which is what lets discovery restore
1137
+ * it the moment it is seen again - and never restore one a person
1138
+ * archived. A workload's row is brought back only by its workload or a
1139
+ * collector, never by application traces (isRetiredWorkloadRow), and its
1140
+ * feed item says so. Bounded per run. Returns how many rows were archived.
1141
+ */
1142
+ @CaptureSpan()
1143
+ public async autoArchiveStaleDatabaseServers(): Promise<number> {
1144
+ const days: number = this.getAutoArchiveDays();
1145
+ const now: Date = OneUptimeDate.getCurrentDate();
1146
+ const cutoff: Date = OneUptimeDate.addRemoveDays(now, -days);
1147
+ const restoreGraceCutoff: Date = OneUptimeDate.addRemoveDays(
1148
+ now,
1149
+ -Math.max(MANUAL_RESTORE_GRACE_DAYS, days),
1150
+ );
1151
+
1152
+ const archived: Array<{
1153
+ _id: string;
1154
+ projectId: string;
1155
+ workloadIdentifier?: string | null;
1156
+ }> = await this.getRepository().manager.query(
1157
+ `WITH "candidates" AS (
1158
+ SELECT ds."_id"
1159
+ FROM "DatabaseServer" ds
1160
+ LEFT JOIN "KubernetesCluster" kc
1161
+ ON kc."_id" = ds."kubernetesClusterId" AND kc."deletedAt" IS NULL
1162
+ LEFT JOIN "DockerHost" dh
1163
+ ON dh."_id" = ds."dockerHostId" AND dh."deletedAt" IS NULL
1164
+ LEFT JOIN "PodmanHost" ph
1165
+ ON ph."_id" = ds."podmanHostId" AND ph."deletedAt" IS NULL
1166
+ WHERE ds."deletedAt" IS NULL
1167
+ AND ds."isArchived" = false
1168
+ AND ds."discoverySource" IS NOT NULL
1169
+ AND ds."discoverySource" <> $3
1170
+ AND COALESCE(ds."lastSeenAt", ds."createdAt") < $1
1171
+ AND COALESCE(ds."lastSeenAt", ds."createdAt") < COALESCE(kc."lastSeenAt", dh."lastSeenAt", ph."lastSeenAt", $2) - INTERVAL '1 hour'
1172
+ AND (
1173
+ ds."manuallyRestoredAt" IS NULL
1174
+ OR ds."manuallyRestoredAt" < $5
1175
+ OR COALESCE(ds."lastSeenAt", ds."createdAt") > ds."manuallyRestoredAt"
1176
+ )
1177
+ AND ${UNTOUCHED_DATABASE_SERVER_PREDICATE}
1178
+ ORDER BY COALESCE(ds."lastSeenAt", ds."createdAt") ASC
1179
+ LIMIT $4
1180
+ ),
1181
+ "archived" AS (
1182
+ UPDATE "DatabaseServer" stale
1183
+ SET "isArchived" = true,
1184
+ "archivedAt" = $2,
1185
+ "autoArchivedAt" = $2,
1186
+ "archivedByUserId" = NULL,
1187
+ "updatedAt" = CURRENT_TIMESTAMP
1188
+ FROM "candidates"
1189
+ WHERE stale."_id" = "candidates"."_id"
1190
+ AND stale."isArchived" = false
1191
+ RETURNING stale."_id" AS "_id", stale."projectId" AS "projectId", stale."workloadIdentifier" AS "workloadIdentifier"
1192
+ )
1193
+ SELECT "_id", "projectId", "workloadIdentifier" FROM "archived"`,
1194
+ [
1195
+ cutoff,
1196
+ now,
1197
+ DatabaseServerDiscoverySource.Manual,
1198
+ AUTO_ARCHIVE_BATCH_SIZE,
1199
+ restoreGraceCutoff,
1200
+ ],
1201
+ );
1202
+
1203
+ const rows: Array<{
1204
+ _id: string;
1205
+ projectId: string;
1206
+ workloadIdentifier?: string | null;
1207
+ }> = Array.isArray(archived) ? archived : [];
1208
+
1209
+ for (const row of rows) {
1210
+ if (!row || !row._id || !row.projectId) {
1211
+ continue;
1212
+ }
1213
+
1214
+ await this.writeAutoArchiveFeed({
1215
+ databaseServerId: new ObjectID(row._id.toString()),
1216
+ projectId: new ObjectID(row.projectId.toString()),
1217
+ days: days,
1218
+ isWorkload: Boolean(row.workloadIdentifier),
1219
+ });
1220
+ }
1221
+
1222
+ return rows.length;
1223
+ }
1224
+
1225
+ /*
1226
+ * How many discovered databases (collector, client spans, Kubernetes,
1227
+ * Docker, Podman) a project may accumulate before discovery stops creating
1228
+ * more on its own. Manual rows do not count and are never blocked, and a
1229
+ * database linked to its agent by DATABASE_SERVER_ID never needs a create.
1230
+ * Collector rows count: a collector keyed on pod IPs or pod host names
1231
+ * would otherwise mint rows without bound. Default 500, at least 0 (0
1232
+ * turns auto-creation off).
1233
+ */
1234
+ public getAutoCreateBudget(): number {
1235
+ return readIntegerEnv({
1236
+ name: "DATABASE_SERVER_AUTO_CREATE_BUDGET",
1237
+ defaultValue: DEFAULT_AUTO_CREATE_BUDGET,
1238
+ min: 0,
1239
+ });
1240
+ }
1241
+
1242
+ /**
1243
+ * True while the project holds fewer live, non-archived discovered
1244
+ * databases than getAutoCreateBudget(). Archived rows do not count, so
1245
+ * the auto-archive sweep frees budget. Always reads the count - the
1246
+ * workers call it once per project per run (AutoCreateBudget).
1247
+ */
1248
+ @CaptureSpan()
1249
+ public async isUnderAutoCreateBudget(projectId: ObjectID): Promise<boolean> {
1250
+ const budget: number = this.getAutoCreateBudget();
1251
+
1252
+ if (budget <= 0) {
1253
+ return false;
1254
+ }
1255
+
1256
+ return (await this.countAutoCreatedDatabaseServers(projectId)) < budget;
1257
+ }
1258
+
1259
+ /**
1260
+ * isUnderAutoCreateBudget for the ingest hot path: the count is read at
1261
+ * most once a minute per project in this process, and every row this
1262
+ * process creates in between is added to it, so a burst of new endpoints
1263
+ * stops at the budget instead of a minute's worth past it.
1264
+ */
1265
+ @CaptureSpan()
1266
+ public async isUnderAutoCreateBudgetCached(
1267
+ projectId: ObjectID,
1268
+ ): Promise<boolean> {
1269
+ const budget: number = this.getAutoCreateBudget();
1270
+
1271
+ if (budget <= 0) {
1272
+ return false;
1273
+ }
1274
+
1275
+ const key: string = projectId.toString();
1276
+ let cached: AutoCreateCount | undefined = this.autoCreateCountMemo.get(key);
1277
+
1278
+ if (!cached) {
1279
+ cached = {
1280
+ count: await this.countAutoCreatedDatabaseServers(projectId),
1281
+ readAtMs: Date.now(),
1282
+ };
1283
+ this.autoCreateCountMemo.set(key, cached);
1284
+ }
1285
+
1286
+ return cached.count < budget;
1287
+ }
1288
+
1289
+ // Live, non-archived, non-manual rows of one project.
1290
+ @CaptureSpan()
1291
+ public async countAutoCreatedDatabaseServers(
1292
+ projectId: ObjectID,
1293
+ ): Promise<number> {
1294
+ const rows: Array<{ count: number | string }> =
1295
+ await this.getRepository().manager.query(
1296
+ `SELECT COUNT(*)::int AS "count"
1297
+ FROM "DatabaseServer"
1298
+ WHERE "projectId" = $1
1299
+ AND "deletedAt" IS NULL
1300
+ AND "isArchived" = false
1301
+ AND COALESCE("discoverySource", '') <> $2`,
1302
+ [projectId.toString(), DatabaseServerDiscoverySource.Manual],
1303
+ );
1304
+
1305
+ return readCount(rows);
1306
+ }
1307
+
1308
+ /**
1309
+ * Display name for this database, or an empty string when the row is gone.
1310
+ * Feed writers call this on a best-effort basis, so a missing row must not
1311
+ * throw and take the surrounding write down with it.
1312
+ */
1313
+ @CaptureSpan()
1314
+ public async getDatabaseServerName(data: {
1315
+ databaseServerId: ObjectID;
1316
+ }): Promise<string> {
1317
+ const databaseServer: Model | null = await this.findOneById({
1318
+ id: data.databaseServerId,
1319
+ select: {
1320
+ name: true,
1321
+ },
1322
+ props: {
1323
+ isRoot: true,
1324
+ },
1325
+ });
1326
+
1327
+ return databaseServer?.name || "";
1328
+ }
1329
+
1330
+ /**
1331
+ * The database's name - but only when `props` may read that row (project,
1332
+ * label and Owned scopes applied); "" otherwise, or on any error. Refusal
1333
+ * messages use it, so a caller with scoped permissions is never told the
1334
+ * name of a database they cannot see. Without props (or as root) the name
1335
+ * is read as root.
1336
+ */
1337
+ @CaptureSpan()
1338
+ public async getDatabaseServerNameIfReadable(data: {
1339
+ databaseServerId: ObjectID;
1340
+ props?: DatabaseCommonInteractionProps | undefined;
1341
+ }): Promise<string> {
1342
+ if (!data.props || data.props.isRoot) {
1343
+ return await this.getDatabaseServerName({
1344
+ databaseServerId: data.databaseServerId,
1345
+ });
1346
+ }
1347
+
1348
+ try {
1349
+ const readable: Model | null = await this.findOneBy({
1350
+ query: {
1351
+ _id: data.databaseServerId.toString(),
1352
+ },
1353
+ select: {
1354
+ name: true,
1355
+ },
1356
+ props: data.props,
1357
+ });
1358
+
1359
+ return readable?.name || "";
1360
+ } catch {
1361
+ return "";
1362
+ }
1363
+ }
1364
+
1365
+ /**
1366
+ * Validate the database a child row (an owner, a feed note) is created
1367
+ * for by a caller: the FK column and the relation object must agree
1368
+ * (TypeORM would persist the relation object's id over the column's), and
1369
+ * the database must be in the caller's project. Leaves the validated FK
1370
+ * column as the only reference. Root creates are the caller's business.
1371
+ */
1372
+ @CaptureSpan()
1373
+ public async assertDatabaseServerReferenceInProject<TModel extends BaseModel>(
1374
+ createBy: CreateBy<TModel>,
1375
+ ): Promise<ObjectID> {
1376
+ const data: TModel = createBy.data;
1377
+
1378
+ const projectId: ObjectID | undefined =
1379
+ createBy.props.tenantId ||
1380
+ (data.getColumnValue("projectId") as ObjectID | undefined) ||
1381
+ undefined;
1382
+
1383
+ if (!projectId) {
1384
+ throw new BadDataException("Project ID is required.");
1385
+ }
1386
+
1387
+ const databaseServerId: ObjectID | null = RelationIdUtil.readConsistent(
1388
+ data as unknown as Record<string, unknown>,
1389
+ ["databaseServerId", "databaseServer"],
1390
+ "database",
1391
+ );
1392
+
1393
+ data.setColumnValue("databaseServer", undefined);
1394
+
1395
+ if (!databaseServerId) {
1396
+ throw new BadDataException("Select a database.");
1397
+ }
1398
+
1399
+ const databaseServer: Model | null = await this.findOneBy({
1400
+ query: {
1401
+ _id: databaseServerId.toString(),
1402
+ projectId: projectId,
1403
+ },
1404
+ select: {
1405
+ _id: true,
1406
+ },
1407
+ props: {
1408
+ isRoot: true,
1409
+ },
1410
+ });
1411
+
1412
+ if (!databaseServer) {
1413
+ throw new BadDataException("Database not found.");
1414
+ }
1415
+
1416
+ data.setColumnValue("databaseServerId", databaseServerId);
1417
+
1418
+ return databaseServerId;
1419
+ }
1420
+
1421
+ /**
1422
+ * Record labels or owners that a rule or ingest attached to a database on
1423
+ * its own (DatabaseServer.automaticAssignments), so the auto-archive sweep
1424
+ * does not mistake them for a person's investment. One atomic statement;
1425
+ * ids deduped. Never throws - it only annotates a write that happened.
1426
+ */
1427
+ @CaptureSpan()
1428
+ public async recordAutomaticAssignments(data: {
1429
+ databaseServerId: ObjectID;
1430
+ kind: DatabaseServerAutomaticAssignmentKind;
1431
+ ids: Array<ObjectID | string>;
1432
+ }): Promise<void> {
1433
+ const ids: Array<string> = normalizeAssignmentIds(data.ids);
1434
+
1435
+ if (ids.length === 0 || !AUTOMATIC_ASSIGNMENT_KINDS.includes(data.kind)) {
1436
+ return;
1437
+ }
1438
+
1439
+ try {
1440
+ await this.getRepository().manager.query(
1441
+ `UPDATE "DatabaseServer"
1442
+ SET "automaticAssignments" =
1443
+ (CASE WHEN jsonb_typeof("automaticAssignments") = 'object' THEN "automaticAssignments" ELSE '{}'::jsonb END)
1444
+ || jsonb_build_object(
1445
+ $2::text,
1446
+ (
1447
+ SELECT COALESCE(jsonb_agg(DISTINCT assigned.id), '[]'::jsonb)
1448
+ FROM jsonb_array_elements_text(
1449
+ (CASE WHEN jsonb_typeof("automaticAssignments" -> $2::text) = 'array' THEN "automaticAssignments" -> $2::text ELSE '[]'::jsonb END)
1450
+ || $3::jsonb
1451
+ ) AS assigned(id)
1452
+ )
1453
+ )
1454
+ WHERE "_id" = $1`,
1455
+ [data.databaseServerId.toString(), data.kind, JSON.stringify(ids)],
1456
+ );
1457
+ } catch (error) {
1458
+ logger.warn(
1459
+ `DatabaseServerService: could not record automatic ${data.kind} on database ${data.databaseServerId.toString()}: ${
1460
+ error instanceof Error ? error.message : String(error)
1461
+ }`,
1462
+ );
1463
+ }
1464
+ }
1465
+
1466
+ /**
1467
+ * The opposite of recordAutomaticAssignments: a person (re)added these
1468
+ * labels or owners, so from now on they count as investment. Never throws.
1469
+ */
1470
+ @CaptureSpan()
1471
+ public async forgetAutomaticAssignments(data: {
1472
+ databaseServerId: ObjectID;
1473
+ kind: DatabaseServerAutomaticAssignmentKind;
1474
+ ids: Array<ObjectID | string>;
1475
+ }): Promise<void> {
1476
+ const ids: Array<string> = normalizeAssignmentIds(data.ids);
1477
+
1478
+ if (ids.length === 0 || !AUTOMATIC_ASSIGNMENT_KINDS.includes(data.kind)) {
1479
+ return;
1480
+ }
1481
+
1482
+ try {
1483
+ await this.getRepository().manager.query(
1484
+ `UPDATE "DatabaseServer"
1485
+ SET "automaticAssignments" = "automaticAssignments" || jsonb_build_object(
1486
+ $2::text,
1487
+ (
1488
+ SELECT COALESCE(jsonb_agg(assigned.id), '[]'::jsonb)
1489
+ FROM jsonb_array_elements_text("automaticAssignments" -> $2::text) AS assigned(id)
1490
+ WHERE NOT (assigned.id = ANY($3::text[]))
1491
+ )
1492
+ )
1493
+ WHERE "_id" = $1
1494
+ AND jsonb_typeof("automaticAssignments") = 'object'
1495
+ AND jsonb_typeof("automaticAssignments" -> $2::text) = 'array'`,
1496
+ [data.databaseServerId.toString(), data.kind, ids],
1497
+ );
1498
+ } catch (error) {
1499
+ logger.warn(
1500
+ `DatabaseServerService: could not forget automatic ${data.kind} on database ${data.databaseServerId.toString()}: ${
1501
+ error instanceof Error ? error.message : String(error)
1502
+ }`,
1503
+ );
1504
+ }
1505
+ }
1506
+
1507
+ @CaptureSpan()
1508
+ public async getDatabaseServerLinkInDashboard(
1509
+ projectId: ObjectID,
1510
+ databaseServerId: ObjectID,
1511
+ ): Promise<URL> {
1512
+ const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
1513
+
1514
+ return URL.fromString(dashboardUrl.toString()).addRoute(
1515
+ `/${projectId.toString()}/databases/${databaseServerId.toString()}`,
1516
+ );
1517
+ }
1518
+
1519
+ /**
1520
+ * "[Database PostgreSQL orders-db:5432](https://…)" - the form every feed
1521
+ * item uses to name the resource it is about.
1522
+ */
1523
+ @CaptureSpan()
1524
+ public async getDatabaseServerMarkdownLink(
1525
+ projectId: ObjectID,
1526
+ databaseServerId: ObjectID,
1527
+ ): Promise<string> {
1528
+ const name: string = await this.getDatabaseServerName({
1529
+ databaseServerId: databaseServerId,
1530
+ });
1531
+ const link: URL = await this.getDatabaseServerLinkInDashboard(
1532
+ projectId,
1533
+ databaseServerId,
1534
+ );
1535
+
1536
+ return `[Database ${name}](${link.toString()})`;
1537
+ }
1538
+
1539
+ /**
1540
+ * For tests: forget which rows this process recently checked for an
1541
+ * auto-archive restore, the way suites clear ResourceHeartbeat's memo.
1542
+ */
1543
+ public clearAutoRestoreCheckMemo(): void {
1544
+ this.autoRestoreCheckMemo.clear();
1545
+ }
1546
+
1547
+ // For tests: forget the cached auto-create counts and budget warnings.
1548
+ public clearAutoCreateBudgetMemo(): void {
1549
+ this.autoCreateCountMemo.clear();
1550
+ this.autoCreateBudgetWarningMemo.clear();
1551
+ }
1552
+
1553
+ private async upsertWorkloadDatabaseOrThrow(
1554
+ data: UpsertWorkloadDatabaseData,
1555
+ ): Promise<Model | null> {
1556
+ if (!data || !data.projectId) {
1557
+ return null;
1558
+ }
1559
+
1560
+ const workloadIdentifier: string = truncateLongText(
1561
+ typeof data.workloadIdentifier === "string"
1562
+ ? data.workloadIdentifier.trim()
1563
+ : "",
1564
+ );
1565
+
1566
+ if (!workloadIdentifier) {
1567
+ return null;
1568
+ }
1569
+
1570
+ const dbSystem: string | null = cleanShortText(
1571
+ normalizeDatabaseSystem(data.dbSystem),
1572
+ );
1573
+
1574
+ if (!dbSystem) {
1575
+ return null;
1576
+ }
1577
+
1578
+ const projectId: ObjectID = data.projectId;
1579
+ const aliases: Array<WorkloadAlias> = canonicalizeWorkloadAliases(
1580
+ data.aliases,
1581
+ dbSystem,
1582
+ );
1583
+
1584
+ // Who owns each alias today: one query, reused by adoption and claiming.
1585
+ const ownersByEndpoint: Map<string, StoredEndpoint> =
1586
+ await this.findEndpointOwners(
1587
+ projectId,
1588
+ aliases.map((alias: WorkloadAlias): string => {
1589
+ return alias.endpoint;
1590
+ }),
1591
+ );
1592
+
1593
+ let row: Model | null = await this.findByWorkloadIdentifier(
1594
+ projectId,
1595
+ workloadIdentifier,
1596
+ );
1597
+ let createdHere: boolean = false;
1598
+
1599
+ if (!row) {
1600
+ row = await this.adoptSameWorkloadOfEngineFamily({
1601
+ projectId: projectId,
1602
+ workloadIdentifier: workloadIdentifier,
1603
+ dbSystem: dbSystem,
1604
+ });
1605
+ }
1606
+
1607
+ if (!row) {
1608
+ row = await this.adoptEndpointDatabaseServer({
1609
+ projectId: projectId,
1610
+ workloadIdentifier: workloadIdentifier,
1611
+ aliases: aliases,
1612
+ ownersByEndpoint: ownersByEndpoint,
1613
+ });
1614
+ }
1615
+
1616
+ if (!row) {
1617
+ if (!data.allowCreate) {
1618
+ return null;
1619
+ }
1620
+
1621
+ const created: { row: Model | null; createdHere: boolean } =
1622
+ await this.createWorkloadDatabaseServer({
1623
+ data: data,
1624
+ workloadIdentifier: workloadIdentifier,
1625
+ dbSystem: dbSystem,
1626
+ primaryAlias: aliases[0],
1627
+ });
1628
+
1629
+ row = created.row;
1630
+ createdHere = created.createdHere;
1631
+ }
1632
+
1633
+ if (!row || !row.id) {
1634
+ return null;
1635
+ }
1636
+
1637
+ const now: Date = OneUptimeDate.getCurrentDate();
1638
+
1639
+ await this.claimWorkloadAliases({
1640
+ projectId: projectId,
1641
+ databaseServerId: row.id,
1642
+ workloadIdentifier: workloadIdentifier,
1643
+ discoverySource: data.discoverySource,
1644
+ aliases: aliases,
1645
+ ownersByEndpoint: ownersByEndpoint,
1646
+ primaryEndpoint: createdHere ? aliases[0]?.endpoint : undefined,
1647
+ now: now,
1648
+ });
1649
+
1650
+ const memberEntityKeys: JSONObject = mergeDatabaseServerMemberKeys(
1651
+ row.memberEntityKeys,
1652
+ Array.isArray(data.memberKeysSeenNow) ? data.memberKeysSeenNow : [],
1653
+ now,
1654
+ );
1655
+
1656
+ const instanceCount: number = toInstanceCount(data.instanceCount);
1657
+
1658
+ /*
1659
+ * A plain record: PartialEntity's deep partial of the JSON member-key
1660
+ * column is too deep for the compiler to instantiate.
1661
+ */
1662
+ const update: Record<string, unknown> = {
1663
+ memberEntityKeys: memberEntityKeys,
1664
+ instanceCount: instanceCount,
1665
+ lastSeenAt: now,
1666
+ workloadLastSeenAt: now,
1667
+ };
1668
+
1669
+ const workloadKind: string | null = cleanShortText(data.workloadKind);
1670
+ if (workloadKind) {
1671
+ update["workloadKind"] = workloadKind;
1672
+ }
1673
+
1674
+ const workloadName: string | null = cleanLongText(data.workloadName);
1675
+ if (workloadName) {
1676
+ update["workloadName"] = workloadName;
1677
+ }
1678
+
1679
+ const kubernetesNamespace: string | null = cleanShortText(
1680
+ data.kubernetesNamespace,
1681
+ );
1682
+ if (kubernetesNamespace) {
1683
+ update["kubernetesNamespace"] = kubernetesNamespace;
1684
+ }
1685
+
1686
+ if (data.kubernetesClusterId) {
1687
+ update["kubernetesClusterId"] = data.kubernetesClusterId;
1688
+ }
1689
+
1690
+ if (data.dockerHostId) {
1691
+ update["dockerHostId"] = data.dockerHostId;
1692
+ }
1693
+
1694
+ if (data.podmanHostId) {
1695
+ update["podmanHostId"] = data.podmanHostId;
1696
+ }
1697
+
1698
+ /*
1699
+ * The image tag is the version of last resort: while a collector is
1700
+ * reporting the engine's own version (recordCollectorHeartbeat), that
1701
+ * one stands - otherwise the two writers would flip the column between
1702
+ * "16" and "16.4" all day.
1703
+ */
1704
+ const dbVersion: string | null = cleanShortText(data.dbVersion);
1705
+ if (dbVersion && (!this.isCollectorFresh(row, now) || !row.dbVersion)) {
1706
+ update["dbVersion"] = dbVersion;
1707
+ }
1708
+
1709
+ /*
1710
+ * The image is evidence of the engine: it corrects what client spans
1711
+ * guessed for a row this workload adopted, refines a family engine to a
1712
+ * fork (redis -> valkey) and undoes a fork only client spans named. A
1713
+ * row created here already has it.
1714
+ */
1715
+ const previousSystem: string | undefined = row.dbSystem;
1716
+ const engine: DatabaseSystemDetermination | null = createdHere
1717
+ ? null
1718
+ : decideDatabaseSystem({
1719
+ current: {
1720
+ system: row.dbSystem,
1721
+ evidence: getStoredDatabaseSystemEvidence(row),
1722
+ },
1723
+ incoming: {
1724
+ system: dbSystem,
1725
+ evidence: DatabaseSystemEvidence.Container,
1726
+ },
1727
+ });
1728
+
1729
+ if (engine) {
1730
+ update["dbSystemSource"] = engine.evidence;
1731
+
1732
+ if (engine.system !== normalizeDatabaseSystem(row.dbSystem)) {
1733
+ update["dbSystem"] = engine.system;
1734
+
1735
+ const renamed: string | null = renameForDatabaseSystem(
1736
+ row,
1737
+ engine.system,
1738
+ );
1739
+
1740
+ if (renamed) {
1741
+ update["name"] = renamed;
1742
+ }
1743
+ }
1744
+ }
1745
+
1746
+ row.workloadIdentifier = workloadIdentifier;
1747
+
1748
+ /*
1749
+ * Nothing but the liveness stamps would change, and the row was written
1750
+ * recently: skip the write (see WORKLOAD_REFRESH_MINUTES). The row is
1751
+ * returned as it was read. Aliases and the auto-archive restore above
1752
+ * and below still run on every sighting.
1753
+ */
1754
+ if (
1755
+ workloadColumnsChanged(row, update) ||
1756
+ !this.isWorkloadRowFresh(row, now)
1757
+ ) {
1758
+ await this.updateColumnsByIdWithoutHooks({
1759
+ id: row.id,
1760
+ data: update as unknown as PartialEntity<Model>,
1761
+ });
1762
+
1763
+ row.memberEntityKeys = memberEntityKeys;
1764
+ row.instanceCount = instanceCount;
1765
+ row.lastSeenAt = now;
1766
+ row.workloadLastSeenAt = now;
1767
+
1768
+ if (engine) {
1769
+ row.dbSystemSource = engine.evidence;
1770
+ if (typeof update["dbSystem"] === "string") {
1771
+ row.dbSystem = update["dbSystem"];
1772
+ }
1773
+ if (typeof update["name"] === "string") {
1774
+ row.name = update["name"];
1775
+ }
1776
+ }
1777
+
1778
+ if (typeof update["dbVersion"] === "string") {
1779
+ row.dbVersion = update["dbVersion"];
1780
+ }
1781
+ }
1782
+
1783
+ if (engine && previousSystem && row.dbSystem !== previousSystem) {
1784
+ await this.writeEngineCorrectedFeed({
1785
+ row: row,
1786
+ previousSystem: previousSystem,
1787
+ evidence: engine.evidence,
1788
+ });
1789
+ }
1790
+
1791
+ await this.restoreIfAutoArchived(row);
1792
+
1793
+ return row;
1794
+ }
1795
+
1796
+ /**
1797
+ * Weigh new evidence of a row's engine (see decideDatabaseSystem) and, if
1798
+ * it wins, write it: the engine, the evidence behind it, and the
1799
+ * auto-generated name when the row still carries the old one - a name a
1800
+ * person chose is kept. One compare-and-set on the engine AND the
1801
+ * evidence that were weighed (writeDatabaseSystemIfUnchanged): a writer
1802
+ * that loses it to a concurrent one changes nothing - not the row in
1803
+ * memory, not the feed - so two writers weighing at once never both
1804
+ * announce the change, and stronger evidence written meanwhile is never
1805
+ * overwritten with weaker. Never throws.
1806
+ */
1807
+ private async applyDatabaseSystemEvidence(
1808
+ row: Model,
1809
+ incoming: {
1810
+ system: string | null | undefined;
1811
+ evidence: DatabaseSystemEvidence;
1812
+ },
1813
+ ): Promise<void> {
1814
+ if (!row.id || !row.projectId || !row.dbSystem) {
1815
+ return;
1816
+ }
1817
+
1818
+ const decision: DatabaseSystemDetermination | null = decideDatabaseSystem({
1819
+ current: {
1820
+ system: row.dbSystem,
1821
+ evidence: getStoredDatabaseSystemEvidence(row),
1822
+ },
1823
+ incoming: incoming,
1824
+ });
1825
+
1826
+ if (!decision) {
1827
+ return;
1828
+ }
1829
+
1830
+ const previousSystem: string = row.dbSystem;
1831
+ const engineChanged: boolean =
1832
+ decision.system !== normalizeDatabaseSystem(previousSystem);
1833
+
1834
+ const renamed: string | null = engineChanged
1835
+ ? renameForDatabaseSystem(row, decision.system)
1836
+ : null;
1837
+
1838
+ let written: { name: string | null } | null = null;
1839
+
1840
+ try {
1841
+ written = await this.writeDatabaseSystemIfUnchanged({
1842
+ id: row.id,
1843
+ projectId: row.projectId,
1844
+ evidence: decision.evidence,
1845
+ system: engineChanged ? decision.system : undefined,
1846
+ rename:
1847
+ renamed && row.name ? { from: row.name, to: renamed } : undefined,
1848
+ expected: {
1849
+ dbSystem: previousSystem,
1850
+ dbSystemSource: row.dbSystemSource || null,
1851
+ },
1852
+ });
1853
+ } catch (error) {
1854
+ logger.warn(
1855
+ `DatabaseServerService: could not update the engine of database ${row.id.toString()}: ${
1856
+ error instanceof Error ? error.message : String(error)
1857
+ }`,
1858
+ );
1859
+
1860
+ return;
1861
+ }
1862
+
1863
+ /*
1864
+ * Another writer changed the engine or its evidence since this row was
1865
+ * read (or the row is gone): theirs stands, and the change is theirs to
1866
+ * announce.
1867
+ */
1868
+ if (!written) {
1869
+ logger.debug(
1870
+ `DatabaseServerService: the engine of database ${row.id.toString()} changed while ${decision.evidence} evidence was weighed; left as the other writer set it.`,
1871
+ );
1872
+
1873
+ return;
1874
+ }
1875
+
1876
+ row.dbSystemSource = decision.evidence;
1877
+
1878
+ if (!engineChanged) {
1879
+ return;
1880
+ }
1881
+
1882
+ row.dbSystem = decision.system;
1883
+
1884
+ if (written.name) {
1885
+ row.name = written.name;
1886
+ }
1887
+
1888
+ await this.writeEngineCorrectedFeed({
1889
+ row: row,
1890
+ previousSystem: previousSystem,
1891
+ evidence: decision.evidence,
1892
+ });
1893
+ }
1894
+
1895
+ /*
1896
+ * The write of applyDatabaseSystemEvidence: the evidence, and - when the
1897
+ * engine changes - the engine, updatedAt and the generated name. The name
1898
+ * only moves while it is still `rename.from`, so a person renaming the
1899
+ * row meanwhile keeps their name. Matches only while the engine and its
1900
+ * evidence are still what was read. Returns the row's name as written, or
1901
+ * null when nothing matched.
1902
+ */
1903
+ private async writeDatabaseSystemIfUnchanged(data: {
1904
+ id: ObjectID;
1905
+ projectId: ObjectID;
1906
+ evidence: DatabaseSystemEvidence;
1907
+ system?: string | undefined;
1908
+ rename?: { from: string; to: string } | undefined;
1909
+ expected: {
1910
+ dbSystem: string;
1911
+ dbSystemSource: string | null;
1912
+ };
1913
+ }): Promise<{ name: string | null } | null> {
1914
+ const params: Array<unknown> = [data.evidence];
1915
+ const sets: Array<string> = [`"dbSystemSource" = $1`];
1916
+
1917
+ if (data.system) {
1918
+ params.push(data.system);
1919
+ sets.push(`"dbSystem" = $${params.length}`);
1920
+
1921
+ if (data.rename) {
1922
+ params.push(data.rename.from, data.rename.to);
1923
+ sets.push(
1924
+ `"name" = CASE WHEN "name" IS NOT DISTINCT FROM $${
1925
+ params.length - 1
1926
+ } THEN $${params.length} ELSE "name" END`,
1927
+ );
1928
+ }
1929
+
1930
+ // Recording stronger evidence for the same engine is bookkeeping.
1931
+ sets.push(`"updatedAt" = CURRENT_TIMESTAMP`);
1932
+ }
1933
+
1934
+ params.push(
1935
+ data.id.toString(),
1936
+ data.projectId.toString(),
1937
+ data.expected.dbSystem,
1938
+ data.expected.dbSystemSource,
1939
+ );
1940
+ const at: number = params.length - 3;
1941
+
1942
+ /*
1943
+ * A CTE, so the statement is a SELECT of the row it wrote: TypeORM
1944
+ * answers a bare UPDATE with [rows, rowCount], matched or not.
1945
+ */
1946
+ const written: unknown = await this.getRepository().manager.query(
1947
+ `WITH "written" AS (
1948
+ UPDATE "DatabaseServer"
1949
+ SET ${sets.join(", ")}
1950
+ WHERE "_id" = $${at}
1951
+ AND "projectId" = $${at + 1}
1952
+ AND "deletedAt" IS NULL
1953
+ AND "dbSystem" IS NOT DISTINCT FROM $${at + 2}
1954
+ AND "dbSystemSource" IS NOT DISTINCT FROM $${at + 3}
1955
+ RETURNING "_id", "name"
1956
+ )
1957
+ SELECT "_id", "name" FROM "written"`,
1958
+ params,
1959
+ );
1960
+
1961
+ if (!Array.isArray(written) || written.length === 0) {
1962
+ return null;
1963
+ }
1964
+
1965
+ const name: unknown = (written[0] as { name?: unknown })?.name;
1966
+
1967
+ return { name: typeof name === "string" ? name : null };
1968
+ }
1969
+
1970
+ private async writeEngineCorrectedFeed(data: {
1971
+ row: Model;
1972
+ previousSystem: string;
1973
+ evidence: DatabaseSystemEvidence;
1974
+ }): Promise<void> {
1975
+ const row: Model = data.row;
1976
+
1977
+ if (!row.id || !row.projectId) {
1978
+ return;
1979
+ }
1980
+
1981
+ try {
1982
+ logger.info(
1983
+ `DatabaseServerService: database ${row.id.toString()} is now identified as ${row.dbSystem} (was ${data.previousSystem}), from ${data.evidence} evidence.`,
1984
+ { projectId: row.projectId.toString() } as LogAttributes,
1985
+ );
1986
+
1987
+ await DatabaseServerFeedService.createDatabaseServerFeedItem({
1988
+ databaseServerId: row.id,
1989
+ projectId: row.projectId,
1990
+ databaseServerFeedEventType:
1991
+ DatabaseServerFeedEventType.DatabaseServerUpdated,
1992
+ displayColor: Gray500,
1993
+ feedInfoInMarkdown: `🔎 ${await this.getDatabaseServerMarkdownLink(
1994
+ row.projectId,
1995
+ row.id,
1996
+ )} is now identified as **${getDatabaseSystemDisplayName(
1997
+ row.dbSystem,
1998
+ )}** (it was shown as ${getDatabaseSystemDisplayName(
1999
+ data.previousSystem,
2000
+ )}), from ${DATABASE_SYSTEM_EVIDENCE_LABEL[data.evidence]}.`,
2001
+ moreInformationInMarkdown: `Client libraries of wire-compatible databases report the engine they speak to, so a database first seen in application traces can be named after the wrong engine. A stronger source - a container image, a collector receiver or the Database Agent - corrects it. A fork (MariaDB, Valkey, ScyllaDB ...) refines the engine it forks when a source at least as strong names it, or its container image does; application traces alone never override an image or a collector. An engine a person chose is never changed.`,
2002
+ });
2003
+ } catch (error) {
2004
+ logger.warn(
2005
+ `DatabaseServerService: could not record the engine change of database ${row.id.toString()}: ${
2006
+ error instanceof Error ? error.message : String(error)
2007
+ }`,
2008
+ );
2009
+ }
2010
+ }
2011
+
2012
+ private isCollectorFresh(row: Model, now: Date): boolean {
2013
+ if (!row.collectorLastSeenAt) {
2014
+ return false;
2015
+ }
2016
+
2017
+ // Live exactly as long as the disconnect sweep would leave it connected.
2018
+ return (
2019
+ new Date(row.collectorLastSeenAt).getTime() >
2020
+ this.getCollectorSilenceCutoff(now).getTime()
2021
+ );
2022
+ }
2023
+
2024
+ /*
2025
+ * True when the workload path wrote this row within
2026
+ * WORKLOAD_REFRESH_MINUTES: both liveness stamps it writes are that fresh.
2027
+ */
2028
+ private isWorkloadRowFresh(row: Model, now: Date): boolean {
2029
+ const freshAfter: number = OneUptimeDate.addRemoveMinutes(
2030
+ now,
2031
+ -WORKLOAD_REFRESH_MINUTES,
2032
+ ).getTime();
2033
+
2034
+ for (const stamp of [row.lastSeenAt, row.workloadLastSeenAt]) {
2035
+ const time: number = stamp ? new Date(stamp).getTime() : NaN;
2036
+
2037
+ if (!Number.isFinite(time) || time <= freshAfter) {
2038
+ return false;
2039
+ }
2040
+ }
2041
+
2042
+ return true;
2043
+ }
2044
+
2045
+ /*
2046
+ * True for the row of a Kubernetes / Docker / Podman workload that
2047
+ * discovery has not seen for WORKLOAD_GONE_MINUTES: its endpoints may move
2048
+ * to the workload that replaced it, and a trace still naming one of them
2049
+ * does not bring the row back from the archive.
2050
+ */
2051
+ private isWorkloadGone(
2052
+ row: Model,
2053
+ now: Date = OneUptimeDate.getCurrentDate(),
2054
+ ): boolean {
2055
+ if (!row.workloadIdentifier) {
2056
+ return false;
2057
+ }
2058
+
2059
+ if (!row.workloadLastSeenAt) {
2060
+ return true;
2061
+ }
2062
+
2063
+ return (
2064
+ new Date(row.workloadLastSeenAt).getTime() <
2065
+ OneUptimeDate.addRemoveMinutes(now, -WORKLOAD_GONE_MINUTES).getTime()
2066
+ );
2067
+ }
2068
+
2069
+ /*
2070
+ * True for a row discovery archived after its Kubernetes / Docker /
2071
+ * Podman workload went away (every such row: the sweep only archives a
2072
+ * row unseen for days, far past WORKLOAD_GONE_MINUTES). Only its workload
2073
+ * or a collector restores it - application traces still naming one of
2074
+ * its endpoints do not.
2075
+ */
2076
+ private isRetiredWorkloadRow(row: Model): boolean {
2077
+ return Boolean(
2078
+ row.isArchived && row.autoArchivedAt && this.isWorkloadGone(row),
2079
+ );
2080
+ }
2081
+
2082
+ /*
2083
+ * isRetiredWorkloadRow, for a report from `source`: application traces
2084
+ * cannot bring the row back, so it is not theirs to sight either.
2085
+ */
2086
+ private isRetiredFor(
2087
+ row: Model,
2088
+ source: DatabaseServerDiscoverySource,
2089
+ ): boolean {
2090
+ return (
2091
+ source === DatabaseServerDiscoverySource.ClientSpans &&
2092
+ this.isRetiredWorkloadRow(row)
2093
+ );
2094
+ }
2095
+
2096
+ /*
2097
+ * What a report of an endpoint tells the row that owns it: the engine it
2098
+ * names is weighed as evidence (applyDatabaseSystemEvidence), and a row
2099
+ * discovery archived is restored - unless its workload is gone, which
2100
+ * only the workload path or a collector heartbeat brings back.
2101
+ */
2102
+ private async recordOwnerSighting(
2103
+ ownerRow: Model,
2104
+ data: {
2105
+ dbSystem: string;
2106
+ discoverySource: DatabaseServerDiscoverySource;
2107
+ },
2108
+ ): Promise<void> {
2109
+ const evidence: DatabaseSystemEvidence | null =
2110
+ getDatabaseSystemEvidenceForSource(data.discoverySource);
2111
+
2112
+ if (evidence) {
2113
+ await this.applyDatabaseSystemEvidence(ownerRow, {
2114
+ system: data.dbSystem,
2115
+ evidence: evidence,
2116
+ });
2117
+ }
2118
+
2119
+ if (!this.isWorkloadGone(ownerRow)) {
2120
+ await this.restoreIfAutoArchived(ownerRow);
2121
+ }
2122
+ }
2123
+
2124
+ private async findByWorkloadIdentifier(
2125
+ projectId: ObjectID,
2126
+ workloadIdentifier: string,
2127
+ ): Promise<Model | null> {
2128
+ return await this.findOneBy({
2129
+ query: {
2130
+ projectId: projectId,
2131
+ workloadIdentifier: workloadIdentifier,
2132
+ },
2133
+ select: WORKLOAD_SELECT,
2134
+ props: {
2135
+ isRoot: true,
2136
+ },
2137
+ });
2138
+ }
2139
+
2140
+ // endpoint -> the stored endpoint row, for the endpoints that have one.
2141
+ private async findEndpointOwners(
2142
+ projectId: ObjectID,
2143
+ endpoints: Array<string>,
2144
+ ): Promise<Map<string, StoredEndpoint>> {
2145
+ const owners: Map<string, StoredEndpoint> = new Map<
2146
+ string,
2147
+ StoredEndpoint
2148
+ >();
2149
+
2150
+ if (endpoints.length === 0) {
2151
+ return owners;
2152
+ }
2153
+
2154
+ const rows: Array<DatabaseServerEndpoint> =
2155
+ await DatabaseServerEndpointService.findBy({
2156
+ query: {
2157
+ projectId: projectId,
2158
+ endpoint: QueryHelper.any(endpoints),
2159
+ },
2160
+ select: {
2161
+ _id: true,
2162
+ endpoint: true,
2163
+ databaseServerId: true,
2164
+ source: true,
2165
+ isPrimary: true,
2166
+ lastMatchedAt: true,
2167
+ },
2168
+ limit: LIMIT_MAX,
2169
+ skip: 0,
2170
+ props: {
2171
+ isRoot: true,
2172
+ },
2173
+ });
2174
+
2175
+ for (const endpointRow of rows) {
2176
+ if (endpointRow.endpoint && endpointRow.databaseServerId) {
2177
+ owners.set(endpointRow.endpoint, {
2178
+ endpointId: endpointRow._id
2179
+ ? new ObjectID(endpointRow._id.toString())
2180
+ : null,
2181
+ databaseServerId: endpointRow.databaseServerId.toString(),
2182
+ source: endpointRow.source || "auto",
2183
+ isPrimary: Boolean(endpointRow.isPrimary),
2184
+ lastMatchedAt: endpointRow.lastMatchedAt
2185
+ ? new Date(endpointRow.lastMatchedAt)
2186
+ : null,
2187
+ });
2188
+ }
2189
+ }
2190
+
2191
+ return owners;
2192
+ }
2193
+
2194
+ /*
2195
+ * The row of the SAME workload stored under another identifier of its
2196
+ * engine family. Identifiers are family-keyed
2197
+ * (buildWorkloadDatabaseServerIdentifier: a Valkey workload keys as
2198
+ * `redis|...`), so a workload re-classified within its family (Redis to
2199
+ * Valkey, MySQL to MariaDB) keeps its identifier and never gets here. What
2200
+ * does get here is a row written under the old ENGINE-keyed identifiers
2201
+ * (`valkey|...`, `mariadb|...`), whatever engine the workload reports
2202
+ * now - the fork itself included. Without this adoption it would become a
2203
+ * second database: a new row with none of the endpoints, history, labels
2204
+ * or incident links of the old one. Instead the old row is re-keyed to the
2205
+ * family key (a compare-and-set, like adoption) and the engine evidence is
2206
+ * weighed as usual. Two candidates is ambiguous and adopts neither.
2207
+ */
2208
+ private async adoptSameWorkloadOfEngineFamily(data: {
2209
+ projectId: ObjectID;
2210
+ workloadIdentifier: string;
2211
+ dbSystem: string;
2212
+ }): Promise<Model | null> {
2213
+ const separator: number = data.workloadIdentifier.indexOf("|");
2214
+
2215
+ if (separator <= 0) {
2216
+ return null;
2217
+ }
2218
+
2219
+ const workload: string = data.workloadIdentifier.substring(separator + 1);
2220
+ // Every engine of the family - the reported one too - but the key itself.
2221
+ const candidates: Array<string> = getDatabaseSystemsOfFamily(data.dbSystem)
2222
+ .map((system: string): string => {
2223
+ return truncateLongText(`${system}|${workload}`.toLowerCase());
2224
+ })
2225
+ .filter((candidate: string): boolean => {
2226
+ return candidate !== data.workloadIdentifier;
2227
+ });
2228
+
2229
+ if (candidates.length === 0) {
2230
+ return null;
2231
+ }
2232
+
2233
+ const rows: Array<Model> = await this.findBy({
2234
+ query: {
2235
+ projectId: data.projectId,
2236
+ workloadIdentifier: QueryHelper.any(candidates),
2237
+ },
2238
+ select: {
2239
+ _id: true,
2240
+ workloadIdentifier: true,
2241
+ },
2242
+ limit: 2,
2243
+ skip: 0,
2244
+ props: {
2245
+ isRoot: true,
2246
+ },
2247
+ });
2248
+
2249
+ if (rows.length !== 1 || !rows[0]!.id || !rows[0]!.workloadIdentifier) {
2250
+ return null;
2251
+ }
2252
+
2253
+ const previousId: ObjectID = rows[0]!.id!;
2254
+ const previousWorkload: string = rows[0]!.workloadIdentifier!;
2255
+
2256
+ try {
2257
+ await this.updateColumnsByIdWithoutHooks({
2258
+ id: previousId,
2259
+ data: {
2260
+ workloadIdentifier: data.workloadIdentifier,
2261
+ },
2262
+ expectedData: {
2263
+ workloadIdentifier: previousWorkload,
2264
+ },
2265
+ });
2266
+
2267
+ logger.info(
2268
+ `DatabaseServerService: workload ${previousWorkload} is now ${data.workloadIdentifier} - the same workload under an engine of the same family; database ${previousId.toString()} kept.`,
2269
+ { projectId: data.projectId.toString() } as LogAttributes,
2270
+ );
2271
+ } catch (error) {
2272
+ logger.debug(
2273
+ `DatabaseServerService: re-keying database ${previousId.toString()} to workload ${data.workloadIdentifier} failed: ${
2274
+ error instanceof Error ? error.message : String(error)
2275
+ }`,
2276
+ );
2277
+ }
2278
+
2279
+ // Whatever happened, the workload index now holds the truth.
2280
+ return await this.findByWorkloadIdentifier(
2281
+ data.projectId,
2282
+ data.workloadIdentifier,
2283
+ );
2284
+ }
2285
+
2286
+ private async adoptEndpointDatabaseServer(data: {
2287
+ projectId: ObjectID;
2288
+ workloadIdentifier: string;
2289
+ aliases: Array<WorkloadAlias>;
2290
+ ownersByEndpoint: Map<string, StoredEndpoint>;
2291
+ }): Promise<Model | null> {
2292
+ /*
2293
+ * Only GLOBAL-scope aliases vote. An unqualified cluster-local name or a
2294
+ * bare service name means something different in every cluster and
2295
+ * namespace, so it is never evidence that two things are the same
2296
+ * database.
2297
+ */
2298
+ const ownerIds: Set<string> = new Set<string>();
2299
+
2300
+ for (const alias of data.aliases) {
2301
+ if (alias.scope !== "global") {
2302
+ continue;
2303
+ }
2304
+
2305
+ const ownerId: string | undefined = data.ownersByEndpoint.get(
2306
+ alias.endpoint,
2307
+ )?.databaseServerId;
2308
+
2309
+ if (ownerId) {
2310
+ ownerIds.add(ownerId);
2311
+ }
2312
+ }
2313
+
2314
+ if (ownerIds.size === 0) {
2315
+ return null;
2316
+ }
2317
+
2318
+ if (ownerIds.size > 1) {
2319
+ logger.info(
2320
+ `DatabaseServerService: not adopting an existing database for workload ${data.workloadIdentifier} - its endpoints belong to ${ownerIds.size} different databases.`,
2321
+ { projectId: data.projectId.toString() } as LogAttributes,
2322
+ );
2323
+
2324
+ return null;
2325
+ }
2326
+
2327
+ const ownerId: string = Array.from(ownerIds)[0]!;
2328
+ const candidate: Model | null = await this.findByIdInProject(
2329
+ data.projectId,
2330
+ new ObjectID(ownerId),
2331
+ );
2332
+
2333
+ if (!candidate || !candidate.id) {
2334
+ return null;
2335
+ }
2336
+
2337
+ if (candidate.workloadIdentifier) {
2338
+ // Already another workload's database: two workloads, two rows.
2339
+ logger.debug(
2340
+ `DatabaseServerService: not adopting database ${ownerId} for workload ${data.workloadIdentifier} - it already belongs to workload ${candidate.workloadIdentifier}.`,
2341
+ { projectId: data.projectId.toString() } as LogAttributes,
2342
+ );
2343
+
2344
+ return null;
2345
+ }
2346
+
2347
+ try {
2348
+ /*
2349
+ * Compare-and-set on the NULL workloadIdentifier: two workloads racing
2350
+ * to adopt the same row cannot both win.
2351
+ */
2352
+ await this.updateColumnsByIdWithoutHooks({
2353
+ id: candidate.id,
2354
+ data: {
2355
+ workloadIdentifier: data.workloadIdentifier,
2356
+ },
2357
+ expectedData: {
2358
+ workloadIdentifier: null,
2359
+ },
2360
+ });
2361
+ } catch (error) {
2362
+ // Most likely a racing run created this workload's row first.
2363
+ logger.debug(
2364
+ `DatabaseServerService: adopting database ${ownerId} for workload ${data.workloadIdentifier} failed: ${
2365
+ error instanceof Error ? error.message : String(error)
2366
+ }`,
2367
+ );
2368
+ }
2369
+
2370
+ /*
2371
+ * Whatever happened, the truth is now in the workload index: our
2372
+ * adopted row, a row a racing run created, or nothing (another workload
2373
+ * adopted the candidate first) - in which case the caller creates.
2374
+ */
2375
+ return await this.findByWorkloadIdentifier(
2376
+ data.projectId,
2377
+ data.workloadIdentifier,
2378
+ );
2379
+ }
2380
+
2381
+ private async createWorkloadDatabaseServer(data: {
2382
+ data: UpsertWorkloadDatabaseData;
2383
+ workloadIdentifier: string;
2384
+ dbSystem: string;
2385
+ primaryAlias: WorkloadAlias | undefined;
2386
+ }): Promise<{ row: Model | null; createdHere: boolean }> {
2387
+ const input: UpsertWorkloadDatabaseData = data.data;
2388
+ const now: Date = OneUptimeDate.getCurrentDate();
2389
+
2390
+ const newRow: Model = new Model();
2391
+ newRow.projectId = input.projectId;
2392
+ newRow.name = truncateShortText(
2393
+ (typeof input.displayName === "string" ? input.displayName.trim() : "") ||
2394
+ buildDatabaseServerDisplayName({
2395
+ system: data.dbSystem,
2396
+ namespace: input.kubernetesNamespace,
2397
+ workloadName: input.workloadName,
2398
+ }),
2399
+ );
2400
+ newRow.dbSystem = data.dbSystem;
2401
+ // A workload row's identity IS its workload.
2402
+ newRow.databaseIdentifier = data.workloadIdentifier;
2403
+ newRow.workloadIdentifier = data.workloadIdentifier;
2404
+ newRow.discoverySource = input.discoverySource;
2405
+ newRow.instanceCount = toInstanceCount(input.instanceCount);
2406
+ newRow.lastSeenAt = now;
2407
+ newRow.workloadLastSeenAt = now;
2408
+ newRow.memberEntityKeys = mergeDatabaseServerMemberKeys(
2409
+ undefined,
2410
+ Array.isArray(input.memberKeysSeenNow) ? input.memberKeysSeenNow : [],
2411
+ now,
2412
+ );
2413
+
2414
+ const workloadKind: string | null = cleanShortText(input.workloadKind);
2415
+ if (workloadKind) {
2416
+ newRow.workloadKind = workloadKind;
2417
+ }
2418
+
2419
+ const workloadName: string | null = cleanLongText(input.workloadName);
2420
+ if (workloadName) {
2421
+ newRow.workloadName = workloadName;
2422
+ }
2423
+
2424
+ const kubernetesNamespace: string | null = cleanShortText(
2425
+ input.kubernetesNamespace,
2426
+ );
2427
+ if (kubernetesNamespace) {
2428
+ newRow.kubernetesNamespace = kubernetesNamespace;
2429
+ }
2430
+
2431
+ if (input.kubernetesClusterId) {
2432
+ newRow.kubernetesClusterId = input.kubernetesClusterId;
2433
+ }
2434
+
2435
+ if (input.dockerHostId) {
2436
+ newRow.dockerHostId = input.dockerHostId;
2437
+ }
2438
+
2439
+ if (input.podmanHostId) {
2440
+ newRow.podmanHostId = input.podmanHostId;
2441
+ }
2442
+
2443
+ const dbVersion: string | null = cleanShortText(input.dbVersion);
2444
+ if (dbVersion) {
2445
+ newRow.dbVersion = dbVersion;
2446
+ }
2447
+
2448
+ // The address the list shows: the first (primary) alias, when there is one.
2449
+ if (data.primaryAlias) {
2450
+ newRow.serverAddress = truncateLongText(data.primaryAlias.parsed.host);
2451
+ if (data.primaryAlias.parsed.port !== null) {
2452
+ newRow.serverPort = data.primaryAlias.parsed.port;
2453
+ }
2454
+ }
2455
+
2456
+ try {
2457
+ const row: Model = await this.create({
2458
+ data: newRow,
2459
+ props: {
2460
+ isRoot: true,
2461
+ },
2462
+ });
2463
+
2464
+ return { row: row, createdHere: true };
2465
+ } catch (error) {
2466
+ // A racing run created this workload's row first.
2467
+ const existing: Model | null = await this.findByWorkloadIdentifier(
2468
+ input.projectId,
2469
+ data.workloadIdentifier,
2470
+ );
2471
+
2472
+ if (!existing) {
2473
+ throw error;
2474
+ }
2475
+
2476
+ return { row: existing, createdHere: false };
2477
+ }
2478
+ }
2479
+
2480
+ /*
2481
+ * Reconcile the workload's endpoints with the aliases this run produced:
2482
+ * 1. claim the unowned ones (source "workload");
2483
+ * 2. take over the ones a DISCOVERED endpoint of another row holds when
2484
+ * that row is a workload that is gone (a Deployment moved to a
2485
+ * StatefulSet, an engine reclassified) or an untouched duplicate that
2486
+ * application traces created (the create race) - never a person's
2487
+ * endpoint, never a live workload's, collector's or manual row's;
2488
+ * 3. re-stamp lastMatchedAt on the ones already ours (at most hourly);
2489
+ * 4. release this row's "workload" aliases that the workload has not
2490
+ * produced for WORKLOAD_ALIAS_RELEASE_MINUTES - an unqualified alias
2491
+ * once the project gained a second cluster, a Service that was
2492
+ * renamed. Never its primary endpoint, never an "auto" or "user" one.
2493
+ * Each step is best-effort: one failing never stops the next.
2494
+ */
2495
+ private async claimWorkloadAliases(data: {
2496
+ projectId: ObjectID;
2497
+ databaseServerId: ObjectID;
2498
+ workloadIdentifier: string;
2499
+ discoverySource: DatabaseServerDiscoverySource;
2500
+ aliases: Array<WorkloadAlias>;
2501
+ ownersByEndpoint: Map<string, StoredEndpoint>;
2502
+ primaryEndpoint: string | undefined;
2503
+ now: Date;
2504
+ }): Promise<void> {
2505
+ const ownId: string = data.databaseServerId.toString();
2506
+ const refreshBefore: Date = OneUptimeDate.addRemoveSeconds(
2507
+ data.now,
2508
+ -ENDPOINT_MATCH_REFRESH_SECONDS,
2509
+ );
2510
+
2511
+ const heldByOthers: Array<HeldAlias> = [];
2512
+ const racedByOthers: Array<string> = [];
2513
+ const ownedToRefresh: Array<string> = [];
2514
+ let primaryClaimed: boolean = false;
2515
+
2516
+ for (const alias of data.aliases) {
2517
+ const stored: StoredEndpoint | undefined = data.ownersByEndpoint.get(
2518
+ alias.endpoint,
2519
+ );
2520
+
2521
+ if (stored && stored.databaseServerId === ownId) {
2522
+ if (
2523
+ !stored.lastMatchedAt ||
2524
+ stored.lastMatchedAt.getTime() < refreshBefore.getTime()
2525
+ ) {
2526
+ ownedToRefresh.push(alias.endpoint);
2527
+ }
2528
+ continue;
2529
+ }
2530
+
2531
+ if (stored) {
2532
+ heldByOthers.push({ alias: alias, stored: stored });
2533
+ continue;
2534
+ }
2535
+
2536
+ try {
2537
+ const isPrimary: boolean = alias.endpoint === data.primaryEndpoint;
2538
+ const result: DatabaseServerEndpointClaimResult =
2539
+ await DatabaseServerEndpointService.claimEndpoint({
2540
+ projectId: data.projectId,
2541
+ databaseServerId: data.databaseServerId,
2542
+ endpoint: alias.endpoint,
2543
+ isPrimary: isPrimary,
2544
+ source: "workload",
2545
+ });
2546
+
2547
+ if (result === "owned-by-other") {
2548
+ racedByOthers.push(alias.endpoint);
2549
+ } else if (result === "claimed" && isPrimary) {
2550
+ primaryClaimed = true;
2551
+ }
2552
+ } catch (error) {
2553
+ logger.warn(
2554
+ `DatabaseServerService: claiming endpoint ${alias.endpoint} for workload ${data.workloadIdentifier} failed: ${
2555
+ error instanceof Error ? error.message : String(error)
2556
+ }`,
2557
+ { projectId: data.projectId.toString() } as LogAttributes,
2558
+ );
2559
+ }
2560
+ }
2561
+
2562
+ let notTaken: Array<string> = heldByOthers.map(
2563
+ (held: HeldAlias): string => {
2564
+ return held.alias.endpoint;
2565
+ },
2566
+ );
2567
+
2568
+ try {
2569
+ notTaken = await this.takeOverWorkloadAliases({
2570
+ projectId: data.projectId,
2571
+ databaseServerId: data.databaseServerId,
2572
+ workloadIdentifier: data.workloadIdentifier,
2573
+ heldByOthers: heldByOthers,
2574
+ /*
2575
+ * A row created in this run whose first alias could not be claimed
2576
+ * has no primary endpoint yet; any other row is asked (lazily).
2577
+ */
2578
+ needsPrimary: data.primaryEndpoint ? !primaryClaimed : undefined,
2579
+ now: data.now,
2580
+ });
2581
+ } catch (error) {
2582
+ logger.warn(
2583
+ `DatabaseServerService: taking over endpoints for workload ${data.workloadIdentifier} failed: ${
2584
+ error instanceof Error ? error.message : String(error)
2585
+ }`,
2586
+ { projectId: data.projectId.toString() } as LogAttributes,
2587
+ );
2588
+ }
2589
+
2590
+ if (ownedToRefresh.length > 0) {
2591
+ try {
2592
+ await DatabaseServerEndpointService.refreshMatchedEndpoints({
2593
+ projectId: data.projectId,
2594
+ databaseServerId: data.databaseServerId,
2595
+ endpoints: ownedToRefresh,
2596
+ now: data.now,
2597
+ staleBefore: refreshBefore,
2598
+ });
2599
+ } catch (error) {
2600
+ logger.warn(
2601
+ `DatabaseServerService: refreshing endpoints of workload ${data.workloadIdentifier} failed: ${
2602
+ error instanceof Error ? error.message : String(error)
2603
+ }`,
2604
+ { projectId: data.projectId.toString() } as LogAttributes,
2605
+ );
2606
+ }
2607
+ }
2608
+
2609
+ // Only Kubernetes workloads produce aliases, so only they can drop one.
2610
+ if (data.discoverySource === DatabaseServerDiscoverySource.Kubernetes) {
2611
+ try {
2612
+ const released: Array<string> =
2613
+ await DatabaseServerEndpointService.releaseUnproducedWorkloadEndpoints(
2614
+ {
2615
+ projectId: data.projectId,
2616
+ databaseServerId: data.databaseServerId,
2617
+ keepEndpoints: data.aliases.map(
2618
+ (alias: WorkloadAlias): string => {
2619
+ return alias.endpoint;
2620
+ },
2621
+ ),
2622
+ staleBefore: OneUptimeDate.addRemoveMinutes(
2623
+ data.now,
2624
+ -WORKLOAD_ALIAS_RELEASE_MINUTES,
2625
+ ),
2626
+ },
2627
+ );
2628
+
2629
+ if (released.length > 0) {
2630
+ logger.info(
2631
+ `DatabaseServerService: released ${released.length} endpoint(s) workload ${data.workloadIdentifier} no longer serves: ${released.join(", ")}`,
2632
+ { projectId: data.projectId.toString() } as LogAttributes,
2633
+ );
2634
+ }
2635
+ } catch (error) {
2636
+ logger.warn(
2637
+ `DatabaseServerService: releasing endpoints of workload ${data.workloadIdentifier} failed: ${
2638
+ error instanceof Error ? error.message : String(error)
2639
+ }`,
2640
+ { projectId: data.projectId.toString() } as LogAttributes,
2641
+ );
2642
+ }
2643
+ }
2644
+
2645
+ const ownedByOthers: Array<string> = [...notTaken, ...racedByOthers];
2646
+
2647
+ if (ownedByOthers.length > 0) {
2648
+ /*
2649
+ * Expected and stable run to run (another database already lists the
2650
+ * endpoint), so debug, not info: this runs every five minutes.
2651
+ */
2652
+ logger.debug(
2653
+ `DatabaseServerService: skipped ${ownedByOthers.length} endpoint(s) of workload ${data.workloadIdentifier} that belong to other databases: ${ownedByOthers.join(", ")}`,
2654
+ { projectId: data.projectId.toString() } as LogAttributes,
2655
+ );
2656
+ }
2657
+ }
2658
+
2659
+ /*
2660
+ * Step 2 of claimWorkloadAliases. Returns the aliases left with their
2661
+ * current owners.
2662
+ */
2663
+ private async takeOverWorkloadAliases(data: {
2664
+ projectId: ObjectID;
2665
+ databaseServerId: ObjectID;
2666
+ workloadIdentifier: string;
2667
+ heldByOthers: Array<HeldAlias>;
2668
+ needsPrimary: boolean | undefined;
2669
+ now: Date;
2670
+ }): Promise<Array<string>> {
2671
+ const notTaken: Array<string> = [];
2672
+ const candidates: Array<HeldAlias> = [];
2673
+
2674
+ for (const held of data.heldByOthers) {
2675
+ if (held.stored.source === "user" || !held.stored.endpointId) {
2676
+ notTaken.push(held.alias.endpoint);
2677
+ } else {
2678
+ candidates.push(held);
2679
+ }
2680
+ }
2681
+
2682
+ if (candidates.length === 0) {
2683
+ return notTaken;
2684
+ }
2685
+
2686
+ const ownerIds: Array<string> = Array.from(
2687
+ new Set<string>(
2688
+ candidates.map((held: HeldAlias): string => {
2689
+ return held.stored.databaseServerId;
2690
+ }),
2691
+ ),
2692
+ );
2693
+
2694
+ const owners: Array<Model> = await this.findBy({
2695
+ query: {
2696
+ projectId: data.projectId,
2697
+ _id: QueryHelper.any(ownerIds),
2698
+ },
2699
+ select: {
2700
+ _id: true,
2701
+ workloadIdentifier: true,
2702
+ workloadLastSeenAt: true,
2703
+ discoverySource: true,
2704
+ },
2705
+ limit: ownerIds.length,
2706
+ skip: 0,
2707
+ props: {
2708
+ isRoot: true,
2709
+ },
2710
+ });
2711
+
2712
+ const ownersById: Map<string, Model> = new Map<string, Model>();
2713
+
2714
+ for (const owner of owners) {
2715
+ if (owner.id) {
2716
+ ownersById.set(owner.id.toString(), owner);
2717
+ }
2718
+ }
2719
+
2720
+ const traceDuplicateIds: Set<string> = await this.findUntouchedTraceRows(
2721
+ data.projectId,
2722
+ data.now,
2723
+ owners
2724
+ .filter((owner: Model): boolean => {
2725
+ return (
2726
+ Boolean(owner.id) &&
2727
+ !owner.workloadIdentifier &&
2728
+ owner.discoverySource === DatabaseServerDiscoverySource.ClientSpans
2729
+ );
2730
+ })
2731
+ .map((owner: Model): string => {
2732
+ return owner.id!.toString();
2733
+ }),
2734
+ );
2735
+
2736
+ let needsPrimary: boolean | undefined = data.needsPrimary;
2737
+
2738
+ for (const held of candidates) {
2739
+ const owner: Model | undefined = ownersById.get(
2740
+ held.stored.databaseServerId,
2741
+ );
2742
+
2743
+ const goneWorkload: boolean = Boolean(
2744
+ owner &&
2745
+ owner.workloadIdentifier &&
2746
+ owner.workloadIdentifier !== data.workloadIdentifier &&
2747
+ this.isWorkloadGone(owner, data.now),
2748
+ );
2749
+ const traceDuplicate: boolean = traceDuplicateIds.has(
2750
+ held.stored.databaseServerId.toLowerCase(),
2751
+ );
2752
+
2753
+ if (!goneWorkload && !traceDuplicate) {
2754
+ notTaken.push(held.alias.endpoint);
2755
+ continue;
2756
+ }
2757
+
2758
+ if (needsPrimary === undefined) {
2759
+ needsPrimary = !(await DatabaseServerEndpointService.hasPrimaryEndpoint(
2760
+ {
2761
+ projectId: data.projectId,
2762
+ databaseServerId: data.databaseServerId,
2763
+ },
2764
+ ));
2765
+ }
2766
+
2767
+ const moved: boolean =
2768
+ await DatabaseServerEndpointService.transferEndpoint({
2769
+ projectId: data.projectId,
2770
+ endpointId: held.stored.endpointId!,
2771
+ fromDatabaseServerId: new ObjectID(held.stored.databaseServerId),
2772
+ toDatabaseServerId: data.databaseServerId,
2773
+ isPrimary: needsPrimary,
2774
+ now: data.now,
2775
+ });
2776
+
2777
+ if (!moved) {
2778
+ notTaken.push(held.alias.endpoint);
2779
+ continue;
2780
+ }
2781
+
2782
+ needsPrimary = false;
2783
+
2784
+ logger.info(
2785
+ `DatabaseServerService: moved endpoint ${held.alias.endpoint} from database ${held.stored.databaseServerId} (${
2786
+ goneWorkload
2787
+ ? "its workload is gone"
2788
+ : "an untouched duplicate application traces created"
2789
+ }) to workload ${data.workloadIdentifier}.`,
2790
+ { projectId: data.projectId.toString() } as LogAttributes,
2791
+ );
2792
+ }
2793
+
2794
+ return notTaken;
2795
+ }
2796
+
2797
+ /*
2798
+ * Of `databaseServerIds`, the trace-discovered rows nobody invested in:
2799
+ * duplicates of a workload whose endpoints the workload may take over.
2800
+ * Taking them empties the row, which the sweep then archives, so beyond
2801
+ * UNTOUCHED_DATABASE_SERVER_PREDICATE a person's Restore within the grace
2802
+ * the sweep gives it, a description and a name that is not the one
2803
+ * discovery generated (hasDiscoveryGeneratedName) all count as investment.
2804
+ */
2805
+ private async findUntouchedTraceRows(
2806
+ projectId: ObjectID,
2807
+ now: Date,
2808
+ databaseServerIds: Array<string>,
2809
+ ): Promise<Set<string>> {
2810
+ if (databaseServerIds.length === 0) {
2811
+ return new Set<string>();
2812
+ }
2813
+
2814
+ const restoreGraceCutoff: Date = OneUptimeDate.addRemoveDays(
2815
+ now,
2816
+ -Math.max(MANUAL_RESTORE_GRACE_DAYS, this.getAutoArchiveDays()),
2817
+ );
2818
+
2819
+ const rows: unknown = await this.getRepository().manager.query(
2820
+ `SELECT ds."_id" AS "_id", ds."name" AS "name", ds."dbSystem" AS "dbSystem",
2821
+ ds."serverAddress" AS "serverAddress", ds."serverPort" AS "serverPort"
2822
+ FROM "DatabaseServer" ds
2823
+ WHERE ds."projectId" = $1
2824
+ AND ds."_id" = ANY($2::uuid[])
2825
+ AND ds."deletedAt" IS NULL
2826
+ AND ds."workloadIdentifier" IS NULL
2827
+ AND ds."discoverySource" = $3
2828
+ AND (ds."manuallyRestoredAt" IS NULL OR ds."manuallyRestoredAt" < $4)
2829
+ AND COALESCE(BTRIM(ds."description"), '') = ''
2830
+ AND ${UNTOUCHED_DATABASE_SERVER_PREDICATE}`,
2831
+ [
2832
+ projectId.toString(),
2833
+ databaseServerIds,
2834
+ DatabaseServerDiscoverySource.ClientSpans,
2835
+ restoreGraceCutoff,
2836
+ ],
2837
+ );
2838
+
2839
+ const ids: Set<string> = new Set<string>();
2840
+
2841
+ for (const row of Array.isArray(rows) ? rows : []) {
2842
+ const found: {
2843
+ _id?: unknown;
2844
+ name?: unknown;
2845
+ dbSystem?: unknown;
2846
+ serverAddress?: unknown;
2847
+ serverPort?: unknown;
2848
+ } = (row || {}) as Record<string, unknown>;
2849
+
2850
+ if (
2851
+ !found._id ||
2852
+ !hasDiscoveryGeneratedName({
2853
+ name: typeof found.name === "string" ? found.name : undefined,
2854
+ dbSystem:
2855
+ typeof found.dbSystem === "string" ? found.dbSystem : undefined,
2856
+ serverAddress:
2857
+ typeof found.serverAddress === "string"
2858
+ ? found.serverAddress
2859
+ : undefined,
2860
+ serverPort:
2861
+ found.serverPort === null || found.serverPort === undefined
2862
+ ? undefined
2863
+ : Number(found.serverPort),
2864
+ })
2865
+ ) {
2866
+ continue;
2867
+ }
2868
+
2869
+ ids.add(String(found._id).toLowerCase());
2870
+ }
2871
+
2872
+ return ids;
2873
+ }
2874
+
2875
+ private async createEndpointDatabaseServer(data: {
2876
+ projectId: ObjectID;
2877
+ dbSystem: string;
2878
+ endpoint: DatabaseEndpoint;
2879
+ formattedEndpoint: string;
2880
+ discoverySource: DatabaseServerDiscoverySource;
2881
+ displayName?: string | undefined;
2882
+ collector?: DatabaseServerCollectorReport | undefined;
2883
+ }): Promise<Model | null> {
2884
+ const now: Date = OneUptimeDate.getCurrentDate();
2885
+ const databaseIdentifier: string = truncateLongText(
2886
+ buildDatabaseServerIdentifier(data.dbSystem, data.endpoint),
2887
+ );
2888
+ const isCollector: boolean =
2889
+ data.discoverySource === DatabaseServerDiscoverySource.Collector;
2890
+
2891
+ const newRow: Model = new Model();
2892
+ newRow.projectId = data.projectId;
2893
+ newRow.name = truncateShortText(
2894
+ (typeof data.displayName === "string" ? data.displayName.trim() : "") ||
2895
+ buildDatabaseServerDisplayName({
2896
+ system: data.dbSystem,
2897
+ endpoint: data.endpoint,
2898
+ }),
2899
+ );
2900
+ newRow.dbSystem = data.dbSystem;
2901
+ newRow.serverAddress = truncateLongText(data.endpoint.host);
2902
+ if (data.endpoint.port !== null) {
2903
+ newRow.serverPort = data.endpoint.port;
2904
+ }
2905
+ newRow.databaseIdentifier = databaseIdentifier;
2906
+ newRow.discoverySource = data.discoverySource;
2907
+ newRow.lastSeenAt = now;
2908
+
2909
+ /*
2910
+ * A collector-created row is connected from its first moment; every
2911
+ * other source leaves otelCollectorStatus empty - no collector has
2912
+ * reported, so none has "disconnected" either (the column has no
2913
+ * default).
2914
+ *
2915
+ * It also carries the agent and engine versions the creating batch
2916
+ * reported, so it never depends on the heartbeat right after it: that
2917
+ * write is FOR UPDATE SKIP LOCKED, the create's own Created feed item
2918
+ * (its foreign key share-locks the row) and rule runs are still in
2919
+ * flight, and a skipped heartbeat waits out the whole ingest maintenance
2920
+ * fence - forever, if the agent stops before it expires.
2921
+ */
2922
+ if (isCollector) {
2923
+ newRow.otelCollectorStatus = "connected";
2924
+ newRow.collectorLastSeenAt = now;
2925
+
2926
+ const agentVersion: string | null = cleanShortText(
2927
+ data.collector?.agentVersion,
2928
+ );
2929
+ if (agentVersion) {
2930
+ newRow.agentVersion = agentVersion;
2931
+ }
2932
+
2933
+ const dbVersion: string | null = cleanShortText(
2934
+ data.collector?.dbVersion,
2935
+ );
2936
+ if (dbVersion) {
2937
+ newRow.dbVersion = dbVersion;
2938
+ }
2939
+ }
2940
+
2941
+ let row: Model;
2942
+ let createdHere: boolean = false;
2943
+
2944
+ try {
2945
+ /*
2946
+ * Without hooks: the Created feed item and the rule engines run only
2947
+ * once this row has actually won its endpoint (below), so an orphan
2948
+ * that loses the claim leaves no trace behind.
2949
+ */
2950
+ row = await this.create({
2951
+ data: newRow,
2952
+ props: {
2953
+ isRoot: true,
2954
+ ignoreHooks: true,
2955
+ },
2956
+ });
2957
+ createdHere = true;
2958
+ } catch (error) {
2959
+ // Same engine family + endpoint created by a racing writer.
2960
+ const existing: Model | null = await this.findOneBy({
2961
+ query: {
2962
+ projectId: data.projectId,
2963
+ databaseIdentifier: databaseIdentifier,
2964
+ },
2965
+ select: DISCOVERY_SELECT,
2966
+ props: {
2967
+ isRoot: true,
2968
+ },
2969
+ });
2970
+
2971
+ if (!existing) {
2972
+ throw error;
2973
+ }
2974
+
2975
+ // Neither claimed for nor restored by a trace (isRetiredWorkloadRow).
2976
+ if (this.isRetiredFor(existing, data.discoverySource)) {
2977
+ return null;
2978
+ }
2979
+
2980
+ row = existing;
2981
+ }
2982
+
2983
+ if (!row.id) {
2984
+ return null;
2985
+ }
2986
+
2987
+ let claim: DatabaseServerEndpointClaimResult;
2988
+
2989
+ try {
2990
+ claim = await DatabaseServerEndpointService.claimEndpoint({
2991
+ projectId: data.projectId,
2992
+ databaseServerId: row.id,
2993
+ endpoint: data.formattedEndpoint,
2994
+ isPrimary: true,
2995
+ source: "auto",
2996
+ });
2997
+ } catch (error) {
2998
+ if (createdHere) {
2999
+ await this.deleteOrphanedDatabaseServer(data.projectId, row.id);
3000
+ }
3001
+
3002
+ throw error;
3003
+ }
3004
+
3005
+ if (claim === "owned-by-other") {
3006
+ if (createdHere) {
3007
+ await this.deleteOrphanedDatabaseServer(data.projectId, row.id);
3008
+ }
3009
+
3010
+ const owner: DatabaseServerEndpointOwner | null =
3011
+ await DatabaseServerEndpointService.findOwnerByEndpoint(
3012
+ data.projectId,
3013
+ data.formattedEndpoint,
3014
+ );
3015
+
3016
+ if (!owner) {
3017
+ return null;
3018
+ }
3019
+
3020
+ const ownerRow: Model | null = await this.findByIdInProject(
3021
+ data.projectId,
3022
+ owner.databaseServerId,
3023
+ );
3024
+
3025
+ // The same sighting findOrCreateByEndpoint gives an owner it finds first.
3026
+ if (ownerRow && this.isRetiredFor(ownerRow, data.discoverySource)) {
3027
+ return null;
3028
+ }
3029
+
3030
+ if (ownerRow) {
3031
+ await this.recordOwnerSighting(ownerRow, data);
3032
+ }
3033
+
3034
+ return ownerRow;
3035
+ }
3036
+
3037
+ if (createdHere) {
3038
+ this.noteAutoCreated(data.projectId);
3039
+ this.runCreatedSideEffects(row, undefined, {
3040
+ endpoint: data.formattedEndpoint,
3041
+ collector: isCollector ? data.collector : undefined,
3042
+ });
3043
+ } else {
3044
+ await this.restoreIfAutoArchived(row);
3045
+ }
3046
+
3047
+ return row;
3048
+ }
3049
+
3050
+ /*
3051
+ * The collector path's budget gate (see findOrCreateByEndpoint): false
3052
+ * when the project has reached its auto-create budget - warned about once
3053
+ * per project per ten minutes, since ingest would otherwise repeat it for
3054
+ * every batch - or when the count cannot be read (fail closed, like the
3055
+ * workers' AutoCreateBudget).
3056
+ */
3057
+ private async allowsCollectorAutoCreate(
3058
+ projectId: ObjectID,
3059
+ formattedEndpoint: string,
3060
+ ): Promise<boolean> {
3061
+ let underBudget: boolean = false;
3062
+
3063
+ try {
3064
+ underBudget = await this.isUnderAutoCreateBudgetCached(projectId);
3065
+ } catch (error) {
3066
+ logger.error(
3067
+ `DatabaseServerService: auto-create budget check failed for project ${projectId.toString()}; not creating a database for collector endpoint ${formattedEndpoint}: ${
3068
+ error instanceof Error ? error.message : String(error)
3069
+ }`,
3070
+ { projectId: projectId.toString() } as LogAttributes,
3071
+ );
3072
+
3073
+ return false;
3074
+ }
3075
+
3076
+ if (!underBudget) {
3077
+ const key: string = projectId.toString();
3078
+
3079
+ if (!this.autoCreateBudgetWarningMemo.get(key)) {
3080
+ this.autoCreateBudgetWarningMemo.set(key, true);
3081
+
3082
+ logger.warn(
3083
+ `DatabaseServerService: project ${key} reached its auto-create budget (DATABASE_SERVER_AUTO_CREATE_BUDGET=${this.getAutoCreateBudget()}); not creating a database for collector endpoint ${formattedEndpoint} (and further new collector endpoints for the next ten minutes). Raise the budget, or add the database manually and link its agent with DATABASE_SERVER_ID.`,
3084
+ { projectId: key } as LogAttributes,
3085
+ );
3086
+ }
3087
+ }
3088
+
3089
+ return underBudget;
3090
+ }
3091
+
3092
+ // A row was auto-created: count it against the cached budget straight away.
3093
+ private noteAutoCreated(projectId: ObjectID): void {
3094
+ const key: string = projectId.toString();
3095
+ const cached: AutoCreateCount | undefined =
3096
+ this.autoCreateCountMemo.get(key);
3097
+
3098
+ if (!cached) {
3099
+ return;
3100
+ }
3101
+
3102
+ const remainingMs: number =
3103
+ cached.readAtMs + AUTO_CREATE_BUDGET_CACHE_MS - Date.now();
3104
+
3105
+ if (remainingMs <= 0) {
3106
+ this.autoCreateCountMemo.delete(key);
3107
+ return;
3108
+ }
3109
+
3110
+ this.autoCreateCountMemo.set(
3111
+ key,
3112
+ { count: cached.count + 1, readAtMs: cached.readAtMs },
3113
+ remainingMs,
3114
+ );
3115
+ }
3116
+
3117
+ private async deleteOrphanedDatabaseServer(
3118
+ projectId: ObjectID,
3119
+ databaseServerId: ObjectID,
3120
+ ): Promise<void> {
3121
+ try {
3122
+ await this.deleteBy({
3123
+ query: {
3124
+ _id: databaseServerId.toString(),
3125
+ projectId: projectId,
3126
+ },
3127
+ limit: 1,
3128
+ skip: 0,
3129
+ props: {
3130
+ isRoot: true,
3131
+ ignoreHooks: true,
3132
+ },
3133
+ });
3134
+ } catch (error) {
3135
+ logger.warn(
3136
+ `DatabaseServerService: could not delete orphaned database ${databaseServerId.toString()}: ${
3137
+ error instanceof Error ? error.message : String(error)
3138
+ }`,
3139
+ { projectId: projectId.toString() } as LogAttributes,
3140
+ );
3141
+ }
3142
+ }
3143
+
3144
+ /*
3145
+ * A manual create inserts the row first and claims its endpoint second
3146
+ * (the claim needs the row's id). onBeforeCreate already refused an owned
3147
+ * endpoint, so losing here means a concurrent writer claimed it in
3148
+ * between: the new row is removed and the person gets the same message
3149
+ * they would have got a moment later.
3150
+ */
3151
+ private async claimManualPrimaryEndpoint(data: {
3152
+ projectId: ObjectID;
3153
+ databaseServerId: ObjectID;
3154
+ endpoint: string;
3155
+ props: DatabaseCommonInteractionProps;
3156
+ }): Promise<void> {
3157
+ let claim: DatabaseServerEndpointClaimResult;
3158
+
3159
+ try {
3160
+ claim = await DatabaseServerEndpointService.claimEndpoint({
3161
+ projectId: data.projectId,
3162
+ databaseServerId: data.databaseServerId,
3163
+ endpoint: data.endpoint,
3164
+ isPrimary: true,
3165
+ source: "user",
3166
+ });
3167
+ } catch (error) {
3168
+ await this.deleteOrphanedDatabaseServer(
3169
+ data.projectId,
3170
+ data.databaseServerId,
3171
+ );
3172
+ throw error;
3173
+ }
3174
+
3175
+ if (claim !== "owned-by-other") {
3176
+ return;
3177
+ }
3178
+
3179
+ await this.deleteOrphanedDatabaseServer(
3180
+ data.projectId,
3181
+ data.databaseServerId,
3182
+ );
3183
+
3184
+ const owner: DatabaseServerEndpointOwner | null =
3185
+ await DatabaseServerEndpointService.findOwnerByEndpoint(
3186
+ data.projectId,
3187
+ data.endpoint,
3188
+ );
3189
+
3190
+ throw new BadDataException(
3191
+ owner
3192
+ ? await getOwnedByOtherDatabaseMessage(data.endpoint, owner, data.props)
3193
+ : `${data.endpoint} already belongs to another database. An endpoint can belong to only one database in a project.`,
3194
+ );
3195
+ }
3196
+
3197
+ /*
3198
+ * A Kubernetes Service name typed without its cluster
3199
+ * (`pg.shop.svc.cluster.local`, no `@<cluster>`) in a project that has
3200
+ * Kubernetes clusters. Pods whose telemetry goes through the Kubernetes
3201
+ * agent report their cluster, so their calls are keyed
3202
+ * `<endpoint>@<cluster>`: a row holding only the unqualified name would
3203
+ * stay empty while discovery creates a second row for the qualified one.
3204
+ * The person is asked to name the cluster instead, from the cluster names
3205
+ * they may read - a cluster is its own resource, with its own read
3206
+ * permission and label scope, so a caller who may read none gets the
3207
+ * generic `<endpoint>@<cluster>` advice.
3208
+ *
3209
+ * Only Service names: a private IP or a private-zone name (`.internal`,
3210
+ * `.local`) is also what virtual machines and the Database Agent report,
3211
+ * unqualified, so it stays a valid manual endpoint. And only in a project
3212
+ * with clusters - without one, nothing could report the qualified form.
3213
+ * If the clusters cannot be read the create goes ahead: the advice is not
3214
+ * worth failing the create for.
3215
+ */
3216
+ private async refuseUnqualifiedKubernetesServiceName(data: {
3217
+ projectId: ObjectID;
3218
+ manual: ManualDatabaseEndpoint;
3219
+ serverAddress: unknown;
3220
+ serverPort: unknown;
3221
+ dbSystem: string;
3222
+ props: DatabaseCommonInteractionProps;
3223
+ }): Promise<void> {
3224
+ const endpoint: DatabaseEndpoint | null = data.manual.endpoint;
3225
+
3226
+ if (
3227
+ !endpoint ||
3228
+ !data.manual.clusterQualifierHint ||
3229
+ !isKubernetesServiceDnsHost(endpoint.host)
3230
+ ) {
3231
+ return;
3232
+ }
3233
+
3234
+ let hasClusters: boolean = false;
3235
+
3236
+ try {
3237
+ hasClusters = await this.hasKubernetesClusters(data.projectId);
3238
+ } catch (error) {
3239
+ logger.warn(
3240
+ `DatabaseServerService: could not read the Kubernetes clusters of project ${data.projectId.toString()}; adding ${formatDatabaseEndpoint(endpoint)} unqualified: ${
3241
+ error instanceof Error ? error.message : String(error)
3242
+ }`,
3243
+ { projectId: data.projectId.toString() } as LogAttributes,
3244
+ );
3245
+
3246
+ return;
3247
+ }
3248
+
3249
+ if (!hasClusters) {
3250
+ return;
3251
+ }
3252
+
3253
+ const clusterNames: Array<string> =
3254
+ await this.findReadableKubernetesClusterNames(data.projectId, data.props);
3255
+
3256
+ // The same value again, now with the cluster names the advice lists.
3257
+ const advice: string | null =
3258
+ clusterNames.length > 0
3259
+ ? parseManualDatabaseEndpoint(data.serverAddress, {
3260
+ system: data.dbSystem,
3261
+ port: data.serverPort,
3262
+ knownClusterNames: clusterNames,
3263
+ }).clusterQualifierHint
3264
+ : null;
3265
+
3266
+ throw new BadDataException(
3267
+ `${advice || data.manual.clusterQualifierHint} To also match a Database Agent or applications that do not report their cluster, add ${formatDatabaseEndpoint(
3268
+ endpoint,
3269
+ )} as an endpoint on the database's Endpoints tab once it is created.`,
3270
+ );
3271
+ }
3272
+
3273
+ /*
3274
+ * True when the project has a live Kubernetes cluster with a name
3275
+ * (k8s.cluster.name). Read as root: whether a cluster could report the
3276
+ * qualified form decides the refusal, whoever asks - but no name is read.
3277
+ */
3278
+ private async hasKubernetesClusters(projectId: ObjectID): Promise<boolean> {
3279
+ const rows: unknown = await this.getRepository().manager.query(
3280
+ `SELECT 1 AS "found"
3281
+ FROM "KubernetesCluster" kc
3282
+ WHERE kc."projectId" = $1
3283
+ AND kc."deletedAt" IS NULL
3284
+ AND kc."clusterIdentifier" IS NOT NULL
3285
+ AND kc."clusterIdentifier" <> ''
3286
+ LIMIT 1`,
3287
+ [projectId.toString()],
3288
+ );
3289
+
3290
+ return Array.isArray(rows) && rows.length > 0;
3291
+ }
3292
+
3293
+ /*
3294
+ * A few of the project's cluster names, sorted - only those the caller may
3295
+ * read, through their own KubernetesCluster permissions (label scope
3296
+ * included). A caller refused the read gets none. Never throws.
3297
+ */
3298
+ private async findReadableKubernetesClusterNames(
3299
+ projectId: ObjectID,
3300
+ props: DatabaseCommonInteractionProps,
3301
+ ): Promise<Array<string>> {
3302
+ let clusters: Array<KubernetesCluster> = [];
3303
+
3304
+ try {
3305
+ clusters = await KubernetesClusterService.findBy({
3306
+ query: {
3307
+ projectId: projectId,
3308
+ },
3309
+ select: {
3310
+ clusterIdentifier: true,
3311
+ },
3312
+ sort: {
3313
+ clusterIdentifier: SortOrder.Ascending,
3314
+ },
3315
+ limit: MAX_LISTED_CLUSTER_NAMES,
3316
+ skip: 0,
3317
+ props: props,
3318
+ });
3319
+ } catch (error) {
3320
+ logger.debug(
3321
+ `DatabaseServerService: no Kubernetes cluster names readable for the manual create in project ${projectId.toString()}: ${
3322
+ error instanceof Error ? error.message : String(error)
3323
+ }`,
3324
+ );
3325
+
3326
+ return [];
3327
+ }
3328
+
3329
+ const names: Array<string> = [];
3330
+
3331
+ for (const cluster of clusters) {
3332
+ const name: string =
3333
+ typeof cluster.clusterIdentifier === "string"
3334
+ ? cluster.clusterIdentifier.trim()
3335
+ : "";
3336
+
3337
+ if (name && !names.includes(name)) {
3338
+ names.push(name);
3339
+ }
3340
+ }
3341
+
3342
+ return names;
3343
+ }
3344
+
3345
+ /*
3346
+ * Rules run once, on creation only - exact parity with the other resource
3347
+ * types. Label engine first: it syncs the in-memory labels so the owner
3348
+ * engine can match rule-added labels. Then the Created feed item. Both are
3349
+ * fire and forget: neither may fail the create it follows.
3350
+ */
3351
+ private runCreatedSideEffects(
3352
+ createdItem: Model,
3353
+ createdByUserId: ObjectID | undefined,
3354
+ context?: DatabaseServerCreationContext | undefined,
3355
+ ): void {
3356
+ if (createdItem.projectId && createdItem.id) {
3357
+ Promise.resolve()
3358
+ .then(async () => {
3359
+ await DatabaseServerLabelRuleEngineService.applyRulesToDatabaseServer(
3360
+ createdItem,
3361
+ );
3362
+ })
3363
+ .then(async () => {
3364
+ await DatabaseServerOwnerRuleEngineService.applyRulesToDatabaseServer(
3365
+ createdItem,
3366
+ );
3367
+ })
3368
+ .catch((error: Error) => {
3369
+ logger.error(
3370
+ `Error applying database rules in DatabaseServerService.onCreateSuccess: ${error}`,
3371
+ {
3372
+ projectId: createdItem.projectId?.toString(),
3373
+ databaseServerId: createdItem.id?.toString(),
3374
+ } as LogAttributes,
3375
+ );
3376
+ });
3377
+ }
3378
+
3379
+ this.writeDatabaseServerCreatedFeed(
3380
+ createdItem,
3381
+ createdByUserId,
3382
+ context,
3383
+ ).catch((error: Error) => {
3384
+ logger.error(error);
3385
+ });
3386
+ }
3387
+
3388
+ private async writeDatabaseServerCreatedFeed(
3389
+ createdItem: Model,
3390
+ createdByUserId: ObjectID | undefined,
3391
+ context?: DatabaseServerCreationContext | undefined,
3392
+ ): Promise<void> {
3393
+ const projectId: ObjectID | undefined = createdItem.projectId;
3394
+ const databaseServerId: ObjectID | undefined = createdItem.id || undefined;
3395
+
3396
+ if (!projectId || !databaseServerId) {
3397
+ return;
3398
+ }
3399
+
3400
+ const resourceMarkdownLink: string =
3401
+ await this.getDatabaseServerMarkdownLink(projectId, databaseServerId);
3402
+
3403
+ const discoveredFrom: string = `**Discovered from**: ${getDatabaseServerDiscoverySourceLabel(
3404
+ createdItem.discoverySource,
3405
+ )}`;
3406
+
3407
+ let markdown: {
3408
+ feedInfoInMarkdown: string;
3409
+ moreInformationInMarkdown: string;
3410
+ };
3411
+
3412
+ if (createdByUserId) {
3413
+ const created: {
3414
+ feedInfoInMarkdown: string;
3415
+ moreInformationInMarkdown: string;
3416
+ } = await ResourceFeedUtil.getCreatedFeedMarkdown({
3417
+ resourceTypeName: "database",
3418
+ resourceMarkdownLink: resourceMarkdownLink,
3419
+ projectId: projectId,
3420
+ createdByUserId: createdByUserId,
3421
+ identifierName: "Database identifier",
3422
+ identifierValue: createdItem.databaseIdentifier,
3423
+ description: createdItem.description,
3424
+ });
3425
+
3426
+ markdown = {
3427
+ feedInfoInMarkdown: created.feedInfoInMarkdown,
3428
+ moreInformationInMarkdown: `${created.moreInformationInMarkdown}\n\n${discoveredFrom}`,
3429
+ };
3430
+ } else {
3431
+ /*
3432
+ * Five discovery paths create rows on their own, and "the first time
3433
+ * telemetry for it arrived" is true of none of the workload ones: say
3434
+ * what found this database, where.
3435
+ */
3436
+ const origin: DatabaseServerCreationOrigin =
3437
+ await this.describeCreationOrigin(createdItem, context);
3438
+
3439
+ const details: Array<string> = [];
3440
+ if (createdItem.databaseIdentifier) {
3441
+ details.push(
3442
+ `**Database identifier**: \`${createdItem.databaseIdentifier}\``,
3443
+ );
3444
+ }
3445
+ if (createdItem.description) {
3446
+ details.push(`**Description**: ${createdItem.description}`);
3447
+ }
3448
+
3449
+ markdown = {
3450
+ feedInfoInMarkdown: `${origin.emoji} ${resourceMarkdownLink} ${origin.summary}.`,
3451
+ moreInformationInMarkdown: [
3452
+ origin.createdBy,
3453
+ `**How it was created**: ${origin.explanation}`,
3454
+ discoveredFrom,
3455
+ ...details,
3456
+ ].join("\n\n"),
3457
+ };
3458
+ }
3459
+
3460
+ await DatabaseServerFeedService.createDatabaseServerFeedItem({
3461
+ databaseServerId: databaseServerId,
3462
+ projectId: projectId,
3463
+ databaseServerFeedEventType:
3464
+ DatabaseServerFeedEventType.DatabaseServerCreated,
3465
+ displayColor: Green500,
3466
+ feedInfoInMarkdown: markdown.feedInfoInMarkdown,
3467
+ moreInformationInMarkdown: markdown.moreInformationInMarkdown,
3468
+ userId: createdByUserId,
3469
+ });
3470
+ }
3471
+
3472
+ /*
3473
+ * What the Created feed item says about a row no person added: which
3474
+ * discovery path found it, and where - the workload and its cluster or
3475
+ * host, the endpoint applications called, the agent or collector that
3476
+ * reported it. A name that cannot be read is left out, never guessed.
3477
+ */
3478
+ private async describeCreationOrigin(
3479
+ row: Model,
3480
+ context: DatabaseServerCreationContext | undefined,
3481
+ ): Promise<DatabaseServerCreationOrigin> {
3482
+ const automatic: string =
3483
+ "**Created by**: No user. OneUptime created this database on its own.";
3484
+ const endpoint: string = describeCreationEndpoint(row, context);
3485
+
3486
+ switch (row.discoverySource) {
3487
+ case DatabaseServerDiscoverySource.Kubernetes: {
3488
+ const clusterName: string | null = await this.readParentName(
3489
+ "kubernetes",
3490
+ row.kubernetesClusterId,
3491
+ );
3492
+ const workload: string = describeKubernetesWorkload(row);
3493
+
3494
+ return {
3495
+ emoji: "🤖",
3496
+ summary: `was created automatically: detected from ${workload}${
3497
+ clusterName ? ` on Kubernetes cluster ${clusterName}` : ""
3498
+ }`,
3499
+ createdBy: automatic,
3500
+ explanation:
3501
+ "The Kubernetes agent reports the pods of the cluster. OneUptime found a database engine running in this workload's pods and registered the workload as one database; the Kubernetes Services in front of it became its endpoints.",
3502
+ };
3503
+ }
3504
+ case DatabaseServerDiscoverySource.Docker:
3505
+ case DatabaseServerDiscoverySource.Podman: {
3506
+ const isDocker: boolean =
3507
+ row.discoverySource === DatabaseServerDiscoverySource.Docker;
3508
+ const platform: string = isDocker ? "Docker" : "Podman";
3509
+ const hostName: string | null = isDocker
3510
+ ? await this.readParentName("docker", row.dockerHostId)
3511
+ : await this.readParentName("podman", row.podmanHostId);
3512
+ const workload: string = describeContainerWorkload(row);
3513
+ const service: string | null = containerServiceKind(row);
3514
+
3515
+ return {
3516
+ emoji: "🤖",
3517
+ summary: `was created automatically: detected from ${workload}${
3518
+ hostName
3519
+ ? ` on ${platform} host ${hostName}`
3520
+ : ` on a ${platform} host`
3521
+ }`,
3522
+ createdBy: automatic,
3523
+ explanation: service
3524
+ ? `The ${platform} agent reports the containers of the host. OneUptime found a database engine running in the containers of this ${service} and registered the service as one database, its containers as members.`
3525
+ : `The ${platform} agent reports the containers of the host. OneUptime found a database engine running in this container and registered it as a database.`,
3526
+ };
3527
+ }
3528
+ case DatabaseServerDiscoverySource.ClientSpans:
3529
+ return {
3530
+ emoji: "🤖",
3531
+ summary: `was created automatically: detected from application traces${
3532
+ endpoint ? ` calling ${endpoint}` : ""
3533
+ }`,
3534
+ createdBy: automatic,
3535
+ explanation:
3536
+ "Instrumented applications sent database CLIENT spans (OpenTelemetry) that call this server, often enough in a 15-minute window, so OneUptime registered the server they call.",
3537
+ };
3538
+ case DatabaseServerDiscoverySource.Collector: {
3539
+ const report: DatabaseServerCollectorReport | undefined =
3540
+ context?.collector;
3541
+ const agentVersion: string | null = cleanShortText(
3542
+ report?.agentVersion,
3543
+ );
3544
+ const reporter: string =
3545
+ report?.reportedByDatabaseAgent === true
3546
+ ? `the Database Agent${agentVersion ? ` ${agentVersion}` : ""}`
3547
+ : "an OpenTelemetry Collector database receiver";
3548
+
3549
+ return {
3550
+ emoji: "🤖",
3551
+ summary: `was created automatically: reported by ${reporter}${
3552
+ endpoint ? `, which sent engine metrics for ${endpoint}` : ""
3553
+ }`,
3554
+ createdBy: automatic,
3555
+ explanation: `${
3556
+ report?.reportedByDatabaseAgent === true
3557
+ ? "The OneUptime Database Agent"
3558
+ : "An OpenTelemetry Collector with a database receiver"
3559
+ } sent engine metrics for a database this project did not have yet, so OneUptime registered it so the metrics had somewhere to land.`,
3560
+ };
3561
+ }
3562
+ case DatabaseServerDiscoverySource.Manual:
3563
+ return {
3564
+ emoji: "🚀",
3565
+ summary: "was added manually",
3566
+ createdBy:
3567
+ "**Created by**: An API key or integration, not a signed-in user.",
3568
+ explanation:
3569
+ "Added from the OneUptime dashboard or through the OneUptime API.",
3570
+ };
3571
+ default:
3572
+ return {
3573
+ emoji: "🤖",
3574
+ summary: "was created automatically by OneUptime",
3575
+ createdBy: automatic,
3576
+ explanation:
3577
+ "A OneUptime discovery path reported a database this project did not have yet, so OneUptime registered it.",
3578
+ };
3579
+ }
3580
+ }
3581
+
3582
+ // The name of a database's cluster or host, for its feed; null when unreadable.
3583
+ private async readParentName(
3584
+ parent: "kubernetes" | "docker" | "podman",
3585
+ id: ObjectID | undefined,
3586
+ ): Promise<string | null> {
3587
+ if (!id) {
3588
+ return null;
3589
+ }
3590
+
3591
+ try {
3592
+ let name: string | undefined = undefined;
3593
+
3594
+ if (parent === "kubernetes") {
3595
+ const cluster: KubernetesCluster | null =
3596
+ await KubernetesClusterService.findOneById({
3597
+ id: id,
3598
+ select: { name: true },
3599
+ props: { isRoot: true },
3600
+ });
3601
+ name = cluster?.name;
3602
+ } else if (parent === "docker") {
3603
+ const host: DockerHost | null = await DockerHostService.findOneById({
3604
+ id: id,
3605
+ select: { name: true },
3606
+ props: { isRoot: true },
3607
+ });
3608
+ name = host?.name;
3609
+ } else {
3610
+ const host: PodmanHost | null = await PodmanHostService.findOneById({
3611
+ id: id,
3612
+ select: { name: true },
3613
+ props: { isRoot: true },
3614
+ });
3615
+ name = host?.name;
3616
+ }
3617
+
3618
+ return cleanShortText(name);
3619
+ } catch (error) {
3620
+ logger.warn(
3621
+ `DatabaseServerService: could not read the name of ${id.toString()} for a Created feed item: ${
3622
+ error instanceof Error ? error.message : String(error)
3623
+ }`,
3624
+ );
3625
+
3626
+ return null;
3627
+ }
3628
+ }
3629
+
3630
+ private async writeDatabaseServerUpdatedFeed(
3631
+ onUpdate: OnUpdate<Model>,
3632
+ updatedItemIds: Array<ObjectID>,
3633
+ ): Promise<void> {
3634
+ const updateData: JSONObject = onUpdate.updateBy
3635
+ .data as unknown as JSONObject;
3636
+
3637
+ /*
3638
+ * Heartbeats and discovery write lastSeenAt / otelCollectorStatus /
3639
+ * member keys constantly - and hook-free, so they never get here. Only
3640
+ * the columns a person would recognise as a change earn a feed item -
3641
+ * see MEANINGFUL_UPDATE_COLUMNS.
3642
+ */
3643
+ const changedColumns: Array<string> =
3644
+ ResourceFeedUtil.getUpdatedColumnsWorthRecording(updateData);
3645
+
3646
+ if (changedColumns.length === 0 || updatedItemIds.length === 0) {
3647
+ return;
3648
+ }
3649
+
3650
+ const isArchiveChange: boolean =
3651
+ ResourceFeedUtil.isArchiveChange(updateData);
3652
+ const isArchived: boolean = Boolean(updateData["isArchived"]);
3653
+ const otherColumns: Array<string> = changedColumns.filter(
3654
+ (column: string) => {
3655
+ return column !== "isArchived";
3656
+ },
3657
+ );
3658
+
3659
+ const updatedByUserId: ObjectID | undefined =
3660
+ onUpdate.updateBy.props.userId || undefined;
3661
+
3662
+ for (const databaseServerId of updatedItemIds) {
3663
+ const databaseServer: Model | null = await this.findOneById({
3664
+ id: databaseServerId,
3665
+ select: {
3666
+ projectId: true,
3667
+ },
3668
+ props: {
3669
+ isRoot: true,
3670
+ },
3671
+ });
3672
+
3673
+ const projectId: ObjectID | undefined = databaseServer?.projectId;
3674
+
3675
+ if (!projectId) {
3676
+ continue;
3677
+ }
3678
+
3679
+ const resourceMarkdownLink: string =
3680
+ await this.getDatabaseServerMarkdownLink(projectId, databaseServerId);
3681
+
3682
+ if (isArchiveChange) {
3683
+ await DatabaseServerFeedService.createDatabaseServerFeedItem({
3684
+ databaseServerId: databaseServerId,
3685
+ projectId: projectId,
3686
+ databaseServerFeedEventType: isArchived
3687
+ ? DatabaseServerFeedEventType.DatabaseServerArchived
3688
+ : DatabaseServerFeedEventType.DatabaseServerRestored,
3689
+ displayColor: isArchived ? Yellow500 : Blue500,
3690
+ feedInfoInMarkdown: isArchived
3691
+ ? `🗄️ ${resourceMarkdownLink} was archived.`
3692
+ : `♻️ ${resourceMarkdownLink} was restored from the archive.`,
3693
+ userId: updatedByUserId,
3694
+ });
3695
+ }
3696
+
3697
+ if (otherColumns.length > 0) {
3698
+ const markdown: {
3699
+ feedInfoInMarkdown: string;
3700
+ moreInformationInMarkdown: string;
3701
+ } = ResourceFeedUtil.getUpdatedFeedMarkdown({
3702
+ resourceMarkdownLink: resourceMarkdownLink,
3703
+ columns: otherColumns,
3704
+ });
3705
+
3706
+ await DatabaseServerFeedService.createDatabaseServerFeedItem({
3707
+ databaseServerId: databaseServerId,
3708
+ projectId: projectId,
3709
+ databaseServerFeedEventType:
3710
+ DatabaseServerFeedEventType.DatabaseServerUpdated,
3711
+ displayColor: Gray500,
3712
+ feedInfoInMarkdown: markdown.feedInfoInMarkdown,
3713
+ moreInformationInMarkdown: markdown.moreInformationInMarkdown,
3714
+ userId: updatedByUserId,
3715
+ });
3716
+ }
3717
+ }
3718
+ }
3719
+
3720
+ /*
3721
+ * A person archived (restored = false) or restored a row: it is no longer
3722
+ * discovery's archive (autoArchivedAt cleared), and a restore is stamped
3723
+ * so the sweep leaves the row alone until it is seen again or the grace
3724
+ * period passes (manuallyRestoredAt; cleared again on an archive).
3725
+ */
3726
+ private async recordArchiveDecisionByPerson(
3727
+ databaseServerIds: Array<ObjectID>,
3728
+ restored: boolean,
3729
+ ): Promise<void> {
3730
+ const now: Date = OneUptimeDate.getCurrentDate();
3731
+
3732
+ for (const databaseServerId of databaseServerIds) {
3733
+ try {
3734
+ await this.updateColumnsByIdWithoutHooks({
3735
+ id: databaseServerId,
3736
+ data: {
3737
+ autoArchivedAt: null,
3738
+ manuallyRestoredAt: restored ? now : null,
3739
+ },
3740
+ skipUpdateDateColumn: true,
3741
+ });
3742
+ } catch (error) {
3743
+ logger.warn(
3744
+ `DatabaseServerService: could not record the ${
3745
+ restored ? "restore" : "archive"
3746
+ } of database ${databaseServerId.toString()}: ${
3747
+ error instanceof Error ? error.message : String(error)
3748
+ }`,
3749
+ );
3750
+ }
3751
+ }
3752
+ }
3753
+
3754
+ private async recordDatabaseSystemSetByPerson(
3755
+ databaseServerIds: Array<ObjectID>,
3756
+ ): Promise<void> {
3757
+ for (const databaseServerId of databaseServerIds) {
3758
+ try {
3759
+ await this.updateColumnsByIdWithoutHooks({
3760
+ id: databaseServerId,
3761
+ data: {
3762
+ dbSystemSource: DatabaseSystemEvidence.Manual,
3763
+ },
3764
+ skipUpdateDateColumn: true,
3765
+ });
3766
+ } catch (error) {
3767
+ logger.warn(
3768
+ `DatabaseServerService: could not record the engine a person set on database ${databaseServerId.toString()}: ${
3769
+ error instanceof Error ? error.message : String(error)
3770
+ }`,
3771
+ );
3772
+ }
3773
+ }
3774
+ }
3775
+
3776
+ /*
3777
+ * Restore a row the auto-archive sweep archived, now that it was seen
3778
+ * again. The conditions live in the UPDATE itself, so a row a person
3779
+ * archived (autoArchivedAt cleared - see onUpdateSuccess) or a row a person
3780
+ * restored in the meantime is never touched, and two writers restoring at
3781
+ * once record one feed item. Never throws: a restore must not fail the
3782
+ * discovery that triggered it.
3783
+ */
3784
+ private async restoreIfAutoArchived(row: Model): Promise<boolean> {
3785
+ if (!row.isArchived || !row.autoArchivedAt || !row.id || !row.projectId) {
3786
+ return false;
3787
+ }
3788
+
3789
+ try {
3790
+ const restored: Array<{ _id: string }> =
3791
+ await this.getRepository().manager.query(
3792
+ `WITH "restored" AS (
3793
+ UPDATE "DatabaseServer"
3794
+ SET "isArchived" = false,
3795
+ "archivedAt" = NULL,
3796
+ "archivedByUserId" = NULL,
3797
+ "autoArchivedAt" = NULL,
3798
+ "updatedAt" = CURRENT_TIMESTAMP
3799
+ WHERE "_id" = $1
3800
+ AND "projectId" = $2
3801
+ AND "isArchived" = true
3802
+ AND "autoArchivedAt" IS NOT NULL
3803
+ AND "deletedAt" IS NULL
3804
+ RETURNING "_id"
3805
+ )
3806
+ SELECT "_id" FROM "restored"`,
3807
+ [row.id.toString(), row.projectId.toString()],
3808
+ );
3809
+
3810
+ /*
3811
+ * Nothing matched: a person restored it, or archived it themselves, in
3812
+ * the meantime. Their state stands - the in-memory row is left as read.
3813
+ */
3814
+ if (!Array.isArray(restored) || restored.length === 0) {
3815
+ return false;
3816
+ }
3817
+
3818
+ row.isArchived = false;
3819
+ delete row.autoArchivedAt;
3820
+
3821
+ await DatabaseServerFeedService.createDatabaseServerFeedItem({
3822
+ databaseServerId: row.id,
3823
+ projectId: row.projectId,
3824
+ databaseServerFeedEventType:
3825
+ DatabaseServerFeedEventType.DatabaseServerRestored,
3826
+ displayColor: Blue500,
3827
+ feedInfoInMarkdown: `♻️ ${await this.getDatabaseServerMarkdownLink(
3828
+ row.projectId,
3829
+ row.id,
3830
+ )} was restored from the archive automatically - it was seen again after being archived for inactivity.`,
3831
+ });
3832
+
3833
+ return true;
3834
+ } catch (error) {
3835
+ logger.warn(
3836
+ `DatabaseServerService: could not restore auto-archived database ${row.id.toString()}: ${
3837
+ error instanceof Error ? error.message : String(error)
3838
+ }`,
3839
+ { projectId: row.projectId.toString() } as LogAttributes,
3840
+ );
3841
+
3842
+ return false;
3843
+ }
3844
+ }
3845
+
3846
+ /*
3847
+ * The throttled part of a collector heartbeat (see recordCollectorHeartbeat):
3848
+ * one primary-key read per database per window, fleet-wide, then the
3849
+ * auto-archive restore and the engine evidence.
3850
+ */
3851
+ private async reconcileAfterHeartbeat(
3852
+ databaseServerId: ObjectID,
3853
+ reportedSystem: string | undefined,
3854
+ ): Promise<void> {
3855
+ const key: string = databaseServerId.toString();
3856
+
3857
+ if (this.autoRestoreCheckMemo.get(key)) {
3858
+ return;
3859
+ }
3860
+
3861
+ this.autoRestoreCheckMemo.set(key, true);
3862
+
3863
+ let claimed: boolean = false;
3864
+
3865
+ try {
3866
+ claimed = await GlobalCache.setStringIfNotExists(
3867
+ AUTO_RESTORE_CHECK_CACHE_NAMESPACE,
3868
+ key,
3869
+ "1",
3870
+ {
3871
+ expiresInSeconds: GlobalCache.withJitter(
3872
+ AUTO_RESTORE_CHECK_WINDOW_SECONDS,
3873
+ ),
3874
+ },
3875
+ );
3876
+ } catch {
3877
+ /*
3878
+ * Fail closed: a missed restore is retried next window, while failing
3879
+ * open would turn a Redis outage into a read per heartbeat.
3880
+ */
3881
+ claimed = false;
3882
+ }
3883
+
3884
+ if (!claimed) {
3885
+ return;
3886
+ }
3887
+
3888
+ try {
3889
+ const row: Model | null = await this.findOneById({
3890
+ id: databaseServerId,
3891
+ select: DISCOVERY_SELECT,
3892
+ props: {
3893
+ isRoot: true,
3894
+ },
3895
+ });
3896
+
3897
+ if (row) {
3898
+ if (reportedSystem) {
3899
+ await this.applyDatabaseSystemEvidence(row, {
3900
+ system: reportedSystem,
3901
+ evidence: DatabaseSystemEvidence.Collector,
3902
+ });
3903
+ }
3904
+
3905
+ await this.restoreIfAutoArchived(row);
3906
+ }
3907
+ } catch (error) {
3908
+ logger.warn(
3909
+ `DatabaseServerService: auto-archive check failed for database ${key}: ${
3910
+ error instanceof Error ? error.message : String(error)
3911
+ }`,
3912
+ );
3913
+ }
3914
+ }
3915
+
3916
+ /*
3917
+ * The archive feed item. A workload's row says what brings it back: its
3918
+ * workload or a collector, never application traces (see
3919
+ * isRetiredWorkloadRow).
3920
+ */
3921
+ private async writeAutoArchiveFeed(data: {
3922
+ databaseServerId: ObjectID;
3923
+ projectId: ObjectID;
3924
+ days: number;
3925
+ isWorkload: boolean;
3926
+ }): Promise<void> {
3927
+ const restoredBy: string = data.isWorkload
3928
+ ? "It is restored automatically when its Kubernetes, Docker or Podman workload, or a collector, reports it again - application traces alone do not restore it."
3929
+ : "It is restored automatically as soon as it is seen again.";
3930
+
3931
+ try {
3932
+ await DatabaseServerFeedService.createDatabaseServerFeedItem({
3933
+ databaseServerId: data.databaseServerId,
3934
+ projectId: data.projectId,
3935
+ databaseServerFeedEventType:
3936
+ DatabaseServerFeedEventType.DatabaseServerArchived,
3937
+ displayColor: Yellow500,
3938
+ feedInfoInMarkdown: `🗄️ ${await this.getDatabaseServerMarkdownLink(
3939
+ data.projectId,
3940
+ data.databaseServerId,
3941
+ )} was automatically archived - not seen for ${data.days} ${
3942
+ data.days === 1 ? "day" : "days"
3943
+ }.`,
3944
+ moreInformationInMarkdown: `No collector, application trace or container inventory has reported this database for ${data.days} ${
3945
+ data.days === 1 ? "day" : "days"
3946
+ } while the Kubernetes cluster or container host it was found on (if any) kept reporting, and nobody has labelled it, owned it, linked it to an incident, alert or scheduled maintenance, added an endpoint to it, changed its retention or recently restored it from the archive. Labels and owners that rules or telemetry attached on their own do not count. ${restoredBy}`,
3947
+ });
3948
+ } catch (error) {
3949
+ logger.warn(
3950
+ `DatabaseServerService: could not record the auto-archive of database ${data.databaseServerId.toString()}: ${
3951
+ error instanceof Error ? error.message : String(error)
3952
+ }`,
3953
+ );
3954
+ }
3955
+ }
3956
+ }
3957
+
3958
+ /*
3959
+ * The endpoint a person typed into the create form, read by
3960
+ * parseManualDatabaseEndpoint: the port field, when filled, replaces any
3961
+ * port in the address (and a SQL Server `\instance`), the engine's default
3962
+ * port fills a missing one, and a value that cannot be used is refused with
3963
+ * a message saying what to change. The result always has an endpoint.
3964
+ */
3965
+ function parseManualEndpoint(data: {
3966
+ serverAddress: unknown;
3967
+ serverPort: unknown;
3968
+ dbSystem: string;
3969
+ }): ManualDatabaseEndpoint {
3970
+ const manual: ManualDatabaseEndpoint = parseManualDatabaseEndpoint(
3971
+ data.serverAddress,
3972
+ {
3973
+ system: data.dbSystem,
3974
+ port: data.serverPort,
3975
+ },
3976
+ );
3977
+
3978
+ if (!manual.endpoint) {
3979
+ throw new BadDataException(
3980
+ manual.error ||
3981
+ "Enter the host name or IP address applications use to reach this database, for example orders-db.example.com:5432.",
3982
+ );
3983
+ }
3984
+
3985
+ return manual;
3986
+ }
3987
+
3988
+ /*
3989
+ * True when writing `update` (the workload path's columns) would change the
3990
+ * row beyond its liveness: a member key added or aged out, the instance
3991
+ * count, the version, the workload's kind / name / namespace / parent, or
3992
+ * the engine. lastSeenAt / workloadLastSeenAt and the member keys' own
3993
+ * timestamps are liveness, refreshed at least every
3994
+ * WORKLOAD_REFRESH_MINUTES anyway.
3995
+ */
3996
+ export function workloadColumnsChanged(
3997
+ row: Model,
3998
+ update: Record<string, unknown>,
3999
+ ): boolean {
4000
+ for (const [column, value] of Object.entries(update)) {
4001
+ if (column === "lastSeenAt" || column === "workloadLastSeenAt") {
4002
+ continue;
4003
+ }
4004
+
4005
+ if (column === "memberEntityKeys") {
4006
+ if (!hasSameMemberKeys(row.memberEntityKeys, value)) {
4007
+ return true;
4008
+ }
4009
+ continue;
4010
+ }
4011
+
4012
+ if (
4013
+ !isSameColumnValue(
4014
+ (row as unknown as Record<string, unknown>)[column],
4015
+ value,
4016
+ )
4017
+ ) {
4018
+ return true;
4019
+ }
4020
+ }
4021
+
4022
+ return false;
4023
+ }
4024
+
4025
+ // An id compares by its text (ObjectID or string), anything else strictly.
4026
+ function isSameColumnValue(current: unknown, next: unknown): boolean {
4027
+ if (current instanceof ObjectID || next instanceof ObjectID) {
4028
+ return (
4029
+ current !== null &&
4030
+ current !== undefined &&
4031
+ next !== null &&
4032
+ next !== undefined &&
4033
+ String(current).toLowerCase() === String(next).toLowerCase()
4034
+ );
4035
+ }
4036
+
4037
+ return current === next;
4038
+ }
4039
+
4040
+ // The same member keys, whatever their timestamps.
4041
+ function hasSameMemberKeys(stored: unknown, merged: unknown): boolean {
4042
+ const keysOf: (value: unknown) => Array<string> = (
4043
+ value: unknown,
4044
+ ): Array<string> => {
4045
+ return value && typeof value === "object" && !Array.isArray(value)
4046
+ ? Object.keys(value as Record<string, unknown>).sort()
4047
+ : [];
4048
+ };
4049
+
4050
+ const storedKeys: Array<string> = keysOf(stored);
4051
+ const mergedKeys: Array<string> = keysOf(merged);
4052
+
4053
+ return (
4054
+ storedKeys.length === mergedKeys.length &&
4055
+ storedKeys.every((key: string, index: number): boolean => {
4056
+ return key === mergedKeys[index];
4057
+ })
4058
+ );
4059
+ }
4060
+
4061
+ /*
4062
+ * The worker's aliases, re-canonicalized (a stored or hand-built alias must
4063
+ * compare byte for byte with what ingest keys), deduped, in order, capped.
4064
+ */
4065
+ function canonicalizeWorkloadAliases(
4066
+ aliases: Array<string> | null | undefined,
4067
+ dbSystem: string,
4068
+ ): Array<WorkloadAlias> {
4069
+ const result: Array<WorkloadAlias> = [];
4070
+ const seen: Set<string> = new Set<string>();
4071
+
4072
+ for (const alias of Array.isArray(aliases) ? aliases : []) {
4073
+ if (result.length >= MAX_WORKLOAD_ALIASES) {
4074
+ break;
4075
+ }
4076
+
4077
+ const parsed: DatabaseEndpoint | null = parseDatabaseEndpointString(alias, {
4078
+ system: dbSystem,
4079
+ });
4080
+
4081
+ if (!parsed) {
4082
+ continue;
4083
+ }
4084
+
4085
+ const endpoint: string = formatDatabaseEndpoint(parsed);
4086
+
4087
+ if (!endpoint || seen.has(endpoint)) {
4088
+ continue;
4089
+ }
4090
+
4091
+ seen.add(endpoint);
4092
+ result.push({
4093
+ endpoint: endpoint,
4094
+ scope: getDatabaseEndpointScope(parsed),
4095
+ parsed: parsed,
4096
+ });
4097
+ }
4098
+
4099
+ return result;
4100
+ }
4101
+
4102
+ function cleanShortText(value: string | null | undefined): string | null {
4103
+ const trimmed: string = typeof value === "string" ? value.trim() : "";
4104
+ return trimmed ? truncateShortText(trimmed) : null;
4105
+ }
4106
+
4107
+ function cleanLongText(value: string | null | undefined): string | null {
4108
+ const trimmed: string = typeof value === "string" ? value.trim() : "";
4109
+ return trimmed ? truncateLongText(trimmed) : null;
4110
+ }
4111
+
4112
+ /*
4113
+ * "the pods of StatefulSet data/postgres", "pod data/pg-debug" - the
4114
+ * workload a Kubernetes row was detected from, for its Created feed item.
4115
+ */
4116
+ function describeKubernetesWorkload(row: Model): string {
4117
+ const name: string = [
4118
+ cleanShortText(row.kubernetesNamespace),
4119
+ cleanLongText(row.workloadName),
4120
+ ]
4121
+ .filter((part: string | null): part is string => {
4122
+ return Boolean(part);
4123
+ })
4124
+ .join("/");
4125
+ const kind: string | null = cleanShortText(row.workloadKind);
4126
+
4127
+ if (!name) {
4128
+ return "a Kubernetes workload's pods";
4129
+ }
4130
+
4131
+ if (kind === "Pod") {
4132
+ return `pod ${name}`;
4133
+ }
4134
+
4135
+ return `the pods of ${kind ? `${kind} ` : ""}${name}`;
4136
+ }
4137
+
4138
+ /*
4139
+ * "Compose service" / "Swarm service" when a Docker or Podman row's
4140
+ * containers were grouped by one; null for a container of its own.
4141
+ */
4142
+ function containerServiceKind(row: Model): string | null {
4143
+ const kind: string | null = cleanShortText(row.workloadKind);
4144
+
4145
+ return kind === ContainerWorkloadKind.ComposeService ||
4146
+ kind === ContainerWorkloadKind.SwarmService
4147
+ ? kind
4148
+ : null;
4149
+ }
4150
+
4151
+ /*
4152
+ * "container e2e-receivers-mariadb", "Compose service shop-postgres",
4153
+ * "Swarm service mystack_db" - the workload a Docker or Podman row was
4154
+ * detected from, for its Created feed item.
4155
+ */
4156
+ function describeContainerWorkload(row: Model): string {
4157
+ const name: string | null = cleanLongText(row.workloadName);
4158
+ const service: string | null = containerServiceKind(row);
4159
+
4160
+ if (service) {
4161
+ return name ? `${service} ${name}` : `a ${service}`;
4162
+ }
4163
+
4164
+ return name ? `container ${name}` : "a container";
4165
+ }
4166
+
4167
+ // The endpoint a row was created for: what discovery saw, else its own columns.
4168
+ function describeCreationEndpoint(
4169
+ row: Model,
4170
+ context: DatabaseServerCreationContext | undefined,
4171
+ ): string {
4172
+ const seen: string | null = cleanLongText(context?.endpoint);
4173
+
4174
+ if (seen) {
4175
+ return seen;
4176
+ }
4177
+
4178
+ const host: string | null = cleanLongText(row.serverAddress);
4179
+
4180
+ if (!host) {
4181
+ return "";
4182
+ }
4183
+
4184
+ return typeof row.serverPort === "number"
4185
+ ? formatDatabaseEndpoint({ host: host, port: row.serverPort })
4186
+ : host;
4187
+ }
4188
+
4189
+ function toInstanceCount(value: unknown): number {
4190
+ return typeof value === "number" && Number.isFinite(value) && value > 0
4191
+ ? Math.floor(value)
4192
+ : 0;
4193
+ }
4194
+
4195
+ /*
4196
+ * A positive-integer env setting: unset or unparseable → the default, below
4197
+ * the floor → the floor.
4198
+ */
4199
+ function readIntegerEnv(data: {
4200
+ name: string;
4201
+ defaultValue: number;
4202
+ min: number;
4203
+ }): number {
4204
+ const raw: string | undefined = process.env[data.name];
4205
+
4206
+ if (raw === undefined || raw.trim() === "") {
4207
+ return data.defaultValue;
4208
+ }
4209
+
4210
+ const parsed: number = Number(raw.trim());
4211
+
4212
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
4213
+ return data.defaultValue;
4214
+ }
4215
+
4216
+ return Math.max(parsed, data.min);
4217
+ }
4218
+
4219
+ function readCount(rows: unknown): number {
4220
+ if (!Array.isArray(rows) || rows.length === 0) {
4221
+ return 0;
4222
+ }
4223
+
4224
+ const count: number = Number((rows[0] as { count?: unknown })?.count);
4225
+
4226
+ return Number.isFinite(count) ? count : 0;
4227
+ }
4228
+
4229
+ /**
4230
+ * The evidence a discovery source's own engine report carries: what the
4231
+ * collector path, the trace path and the container paths each know. A row's
4232
+ * discoverySource read through this is the evidence its engine was first
4233
+ * set from.
4234
+ */
4235
+ export function getDatabaseSystemEvidenceForSource(
4236
+ source: string | null | undefined,
4237
+ ): DatabaseSystemEvidence | null {
4238
+ switch (source) {
4239
+ case DatabaseServerDiscoverySource.Manual:
4240
+ return DatabaseSystemEvidence.Manual;
4241
+ case DatabaseServerDiscoverySource.Collector:
4242
+ return DatabaseSystemEvidence.Collector;
4243
+ case DatabaseServerDiscoverySource.Kubernetes:
4244
+ case DatabaseServerDiscoverySource.Docker:
4245
+ case DatabaseServerDiscoverySource.Podman:
4246
+ return DatabaseSystemEvidence.Container;
4247
+ case DatabaseServerDiscoverySource.ClientSpans:
4248
+ return DatabaseSystemEvidence.ClientSpans;
4249
+ default:
4250
+ return null;
4251
+ }
4252
+ }
4253
+
4254
+ /*
4255
+ * The evidence behind a row's current engine: dbSystemSource once anything
4256
+ * refined it, else the source that created the row.
4257
+ */
4258
+ export function getStoredDatabaseSystemEvidence(row: {
4259
+ dbSystemSource?: string | undefined;
4260
+ discoverySource?: string | undefined;
4261
+ }): DatabaseSystemEvidence | null {
4262
+ const stored: string | undefined = row.dbSystemSource;
4263
+
4264
+ if (
4265
+ stored &&
4266
+ Object.values(DatabaseSystemEvidence).includes(
4267
+ stored as DatabaseSystemEvidence,
4268
+ )
4269
+ ) {
4270
+ return stored as DatabaseSystemEvidence;
4271
+ }
4272
+
4273
+ return getDatabaseSystemEvidenceForSource(row.discoverySource);
4274
+ }
4275
+
4276
+ /**
4277
+ * The engine (and the evidence for it) a row should carry once `incoming`
4278
+ * evidence arrives, or null to leave the row as it is. The evidence stored
4279
+ * is always the evidence that named the engine the row then shows.
4280
+ * - an engine a person chose is never changed;
4281
+ * - within one family, a fork refines the family's own engine (mysql ->
4282
+ * mariadb, postgresql -> cockroachdb) on evidence at least as strong as
4283
+ * what named the current engine, or from a container image, which names
4284
+ * the exact engine where a family receiver cannot (the redis receiver
4285
+ * says "redis" for Valkey). Client spans alone never refine what an
4286
+ * image or a collector determined: a client names the protocol it
4287
+ * speaks, and MariaDB Connector/J says "mariadb" to a MySQL server;
4288
+ * - the family's engine undoes a fork only when an image names it and
4289
+ * nothing stronger than client spans named the fork. A collector or a
4290
+ * client never undoes one, because they cannot tell them apart (the
4291
+ * mysql receiver says "mysql" for MariaDB);
4292
+ * - any other different engine - another family, a sibling fork, an
4293
+ * unknown engine - replaces the current one only on strictly stronger
4294
+ * evidence (the image says CockroachDB where a client said MySQL);
4295
+ * equal or weaker evidence never flips it back and forth;
4296
+ * - the same engine on stronger evidence keeps the engine but records the
4297
+ * stronger evidence, so weaker evidence cannot move it later.
4298
+ * A row with no engine takes the incoming one. Pure.
4299
+ */
4300
+ export function decideDatabaseSystem(data: {
4301
+ current: {
4302
+ system: string | null | undefined;
4303
+ evidence: DatabaseSystemEvidence | null;
4304
+ };
4305
+ incoming: {
4306
+ system: string | null | undefined;
4307
+ evidence: DatabaseSystemEvidence;
4308
+ };
4309
+ }): DatabaseSystemDetermination | null {
4310
+ const incomingSystem: string | null = cleanShortText(
4311
+ normalizeDatabaseSystem(data.incoming.system),
4312
+ );
4313
+
4314
+ if (!incomingSystem) {
4315
+ return null;
4316
+ }
4317
+
4318
+ const incoming: DatabaseSystemDetermination = {
4319
+ system: incomingSystem,
4320
+ evidence: data.incoming.evidence,
4321
+ };
4322
+
4323
+ const currentSystem: string | null = normalizeDatabaseSystem(
4324
+ data.current.system,
4325
+ );
4326
+
4327
+ if (!currentSystem) {
4328
+ return incoming;
4329
+ }
4330
+
4331
+ if (data.current.evidence === DatabaseSystemEvidence.Manual) {
4332
+ return null;
4333
+ }
4334
+
4335
+ const currentRank: number = data.current.evidence
4336
+ ? DATABASE_SYSTEM_EVIDENCE_RANK[data.current.evidence]
4337
+ : 0;
4338
+ const incomingRank: number = DATABASE_SYSTEM_EVIDENCE_RANK[incoming.evidence];
4339
+
4340
+ if (incomingSystem === currentSystem) {
4341
+ return incomingRank > currentRank
4342
+ ? { system: currentSystem, evidence: incoming.evidence }
4343
+ : null;
4344
+ }
4345
+
4346
+ const fromImage: boolean =
4347
+ incoming.evidence === DatabaseSystemEvidence.Container;
4348
+
4349
+ // The incoming engine is a fork of the current one: a refinement.
4350
+ if (
4351
+ getMoreSpecificDatabaseSystem(currentSystem, incomingSystem) ===
4352
+ incomingSystem
4353
+ ) {
4354
+ return incomingRank >= currentRank || fromImage ? incoming : null;
4355
+ }
4356
+
4357
+ /*
4358
+ * The current engine is a fork of the incoming one: only an image undoes
4359
+ * a fork, and only one no more than client spans named.
4360
+ */
4361
+ if (
4362
+ getMoreSpecificDatabaseSystem(incomingSystem, currentSystem) ===
4363
+ currentSystem
4364
+ ) {
4365
+ return fromImage && incomingRank > currentRank ? incoming : null;
4366
+ }
4367
+
4368
+ return incomingRank > currentRank ? incoming : null;
4369
+ }
4370
+
4371
+ // The columns a row's generated name is built from.
4372
+ export interface DatabaseServerNameSource {
4373
+ name?: string | undefined;
4374
+ dbSystem?: string | undefined;
4375
+ serverAddress?: string | undefined;
4376
+ serverPort?: number | undefined;
4377
+ kubernetesNamespace?: string | undefined;
4378
+ workloadName?: string | undefined;
4379
+ }
4380
+
4381
+ interface GeneratedNameForm {
4382
+ endpoint?: DatabaseEndpoint | undefined;
4383
+ namespace?: string | undefined;
4384
+ workloadName?: string | undefined;
4385
+ }
4386
+
4387
+ /**
4388
+ * The row's name for a new engine - but only when the row still carries the
4389
+ * name discovery generated for its old engine (endpoint form "PostgreSQL
4390
+ * db:5432" or workload form "PostgreSQL ns/name"). A name a person chose is
4391
+ * kept: null.
4392
+ */
4393
+ export function renameForDatabaseSystem(
4394
+ row: DatabaseServerNameSource,
4395
+ newSystem: string,
4396
+ ): string | null {
4397
+ const form: GeneratedNameForm | null = findGeneratedNameForm(row);
4398
+
4399
+ if (!form) {
4400
+ return null;
4401
+ }
4402
+
4403
+ const renamed: string = truncateShortText(
4404
+ buildDatabaseServerDisplayName({ system: newSystem, ...form }),
4405
+ );
4406
+
4407
+ return renamed !== (row.name || "").trim() ? renamed : null;
4408
+ }
4409
+
4410
+ /**
4411
+ * True while the row carries the name discovery generated for it and its
4412
+ * engine (see renameForDatabaseSystem): a name no generated form produces
4413
+ * is one a person chose.
4414
+ */
4415
+ export function hasDiscoveryGeneratedName(
4416
+ row: DatabaseServerNameSource,
4417
+ ): boolean {
4418
+ return findGeneratedNameForm(row) !== null;
4419
+ }
4420
+
4421
+ // The generated form the row's name matches, or null when a person named it.
4422
+ function findGeneratedNameForm(
4423
+ row: DatabaseServerNameSource,
4424
+ ): GeneratedNameForm | null {
4425
+ const name: string = typeof row.name === "string" ? row.name.trim() : "";
4426
+
4427
+ if (!name || !row.dbSystem) {
4428
+ return null;
4429
+ }
4430
+
4431
+ const forms: Array<GeneratedNameForm> = [];
4432
+
4433
+ if (row.serverAddress) {
4434
+ forms.push({
4435
+ endpoint: {
4436
+ host: row.serverAddress,
4437
+ port:
4438
+ typeof row.serverPort === "number" && row.serverPort > 0
4439
+ ? row.serverPort
4440
+ : null,
4441
+ },
4442
+ });
4443
+ }
4444
+
4445
+ if (row.workloadName) {
4446
+ forms.push({
4447
+ namespace: row.kubernetesNamespace,
4448
+ workloadName: row.workloadName,
4449
+ });
4450
+ }
4451
+
4452
+ for (const form of forms) {
4453
+ const generated: string = truncateShortText(
4454
+ buildDatabaseServerDisplayName({ system: row.dbSystem, ...form }),
4455
+ );
4456
+
4457
+ if (generated === name) {
4458
+ return form;
4459
+ }
4460
+ }
4461
+
4462
+ return null;
4463
+ }
4464
+
4465
+ /*
4466
+ * Every catalogued engine of `system`'s family, the family's own engine
4467
+ * first ("valkey" -> redis, valkey, keydb, ...). An engine outside the
4468
+ * catalog is a family of one.
4469
+ */
4470
+ export function getDatabaseSystemsOfFamily(system: string): Array<string> {
4471
+ const family: string | null = getDatabaseSystemFamily(system);
4472
+
4473
+ if (!family) {
4474
+ return [];
4475
+ }
4476
+
4477
+ const systems: Array<string> = [family];
4478
+
4479
+ for (const descriptor of DATABASE_SYSTEMS as ReadonlyArray<DatabaseSystemDescriptor>) {
4480
+ if (
4481
+ (descriptor.family || descriptor.system) === family &&
4482
+ !systems.includes(descriptor.system)
4483
+ ) {
4484
+ systems.push(descriptor.system);
4485
+ }
4486
+ }
4487
+
4488
+ return systems;
4489
+ }
4490
+
4491
+ /*
4492
+ * The ids in a relation payload (an update's `labels`): model instances,
4493
+ * `{ _id }` / `{ id }` objects, ObjectIDs or id strings. Anything else is
4494
+ * skipped.
4495
+ */
4496
+ function readRelationIds(value: unknown): Array<string> {
4497
+ const ids: Array<string> = [];
4498
+
4499
+ for (const item of Array.isArray(value) ? value : []) {
4500
+ let id: unknown = item;
4501
+
4502
+ if (item && typeof item === "object" && !(item instanceof ObjectID)) {
4503
+ id =
4504
+ (item as { _id?: unknown; id?: unknown })._id ||
4505
+ (item as { _id?: unknown; id?: unknown }).id;
4506
+ }
4507
+
4508
+ const text: string =
4509
+ id instanceof ObjectID || typeof id === "string" ? id.toString() : "";
4510
+
4511
+ if (text) {
4512
+ ids.push(text);
4513
+ }
4514
+ }
4515
+
4516
+ return ids;
4517
+ }
4518
+
4519
+ /*
4520
+ * Assignment ids as automaticAssignments stores them: lowercase UUID strings
4521
+ * (what Postgres prints for a uuid column), deduped, invalid ones dropped.
4522
+ */
4523
+ function normalizeAssignmentIds(ids: Array<ObjectID | string>): Array<string> {
4524
+ const result: Array<string> = [];
4525
+
4526
+ for (const id of Array.isArray(ids) ? ids : []) {
4527
+ const text: string = (id ? id.toString() : "").trim().toLowerCase();
4528
+
4529
+ if (text && ObjectID.isValidUUID(text) && !result.includes(text)) {
4530
+ result.push(text);
4531
+ }
4532
+ }
4533
+
4534
+ return result;
4535
+ }
4536
+
4537
+ function fingerprintLabelIds(labelIds: Array<ObjectID>): string {
4538
+ const sorted: Array<string> = labelIds
4539
+ .map((id: ObjectID) => {
4540
+ return id.toString();
4541
+ })
4542
+ .sort();
4543
+ return crypto.createHash("sha256").update(sorted.join(",")).digest("hex");
4544
+ }
4545
+
4546
+ export default new Service();