@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.
- package/Models/DatabaseModels/Alert.ts +55 -0
- package/Models/DatabaseModels/DatabaseServer.ts +1650 -0
- package/Models/DatabaseModels/DatabaseServerEndpoint.ts +540 -0
- package/Models/DatabaseModels/DatabaseServerFeed.ts +625 -0
- package/Models/DatabaseModels/DatabaseServerLabelRule.ts +514 -0
- package/Models/DatabaseModels/DatabaseServerOwnerRule.ts +596 -0
- package/Models/DatabaseModels/DatabaseServerOwnerTeam.ts +491 -0
- package/Models/DatabaseModels/DatabaseServerOwnerUser.ts +490 -0
- package/Models/DatabaseModels/Incident.ts +55 -0
- package/Models/DatabaseModels/Index.ts +16 -0
- package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +26 -0
- package/Models/DatabaseModels/ScheduledMaintenance.ts +55 -0
- package/Models/DatabaseModels/TelemetryException.ts +2 -0
- package/Models/DatabaseModels/UserProjectSsoConsent.ts +146 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.ts +673 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.ts +51 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Middleware/MultipartFormData.ts +1 -1
- package/Server/Middleware/TelemetryIngest.ts +15 -8
- package/Server/Services/AlertReminderRuleService.ts +100 -24
- package/Server/Services/DatabaseServerEndpointService.ts +895 -0
- package/Server/Services/DatabaseServerFeedService.ts +128 -0
- package/Server/Services/DatabaseServerLabelRuleEngineService.ts +340 -0
- package/Server/Services/DatabaseServerLabelRuleService.ts +14 -0
- package/Server/Services/DatabaseServerOwnerRuleEngineService.ts +425 -0
- package/Server/Services/DatabaseServerOwnerRuleService.ts +14 -0
- package/Server/Services/DatabaseServerOwnerTeamService.ts +180 -0
- package/Server/Services/DatabaseServerOwnerUserService.ts +173 -0
- package/Server/Services/DatabaseServerService.ts +4546 -0
- package/Server/Services/DatabaseService.ts +14 -8
- package/Server/Services/DockerResourceService.ts +43 -2
- package/Server/Services/IncidentReminderRuleService.ts +103 -23
- package/Server/Services/Index.ts +16 -0
- package/Server/Services/InventoryItemService.ts +113 -5
- package/Server/Services/IoTDeviceService.ts +145 -37
- package/Server/Services/OpenTelemetryIngestService.ts +19 -0
- package/Server/Services/PodmanResourceService.ts +43 -2
- package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +107 -23
- package/Server/Services/ServiceService.ts +1 -1
- package/Server/Services/TelemetryUsageBillingService.ts +29 -2
- package/Server/Services/UserProjectSsoConsentService.ts +74 -0
- package/Server/Services/UserService.ts +14 -1
- package/Server/Types/Database/Permissions/OwnerTableRegistry.ts +18 -0
- package/Server/Types/Database/QueryHelper.ts +30 -1
- package/Server/Utils/AI/Toolbox/ResourceTools.ts +69 -1
- package/Server/Utils/Database/AffectedResourceRelations.ts +6 -0
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +188 -6
- package/Server/Utils/Monitor/MonitorAlert.ts +28 -1
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +2262 -755
- package/Server/Utils/Monitor/MonitorIncident.ts +28 -1
- package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +268 -6
- package/Server/Utils/Monitor/MonitorResource.ts +47 -11
- package/Server/Utils/Monitor/MonitorResourceContext.ts +1 -0
- package/Server/Utils/Monitor/MonitorStepResourceIdentity.ts +239 -21
- package/Server/Utils/Monitor/PerSeriesResolutionRootCause.ts +43 -0
- package/Server/Utils/Monitor/SeriesResourceLabels.ts +291 -0
- package/Server/Utils/Monitor/SeriesResourceLinker.ts +111 -12
- package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +25 -0
- package/Server/Utils/StartServer.ts +26 -6
- package/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.ts +2 -0
- package/Server/Utils/Telemetry/DatabaseEndpointDiscovery.ts +688 -0
- package/Server/Utils/Telemetry/EntityRegistry.ts +5 -1
- package/Server/Utils/Telemetry/LogExceptionExtractor.ts +18 -1
- package/Server/Utils/Telemetry/ResourceEntityFilter.ts +175 -12
- package/Server/Utils/Telemetry/ResourceFacetResolver.ts +12 -0
- package/Server/Utils/Telemetry/ServiceDependencyDiscovery.ts +606 -36
- package/Server/Utils/Telemetry/Telemetry.ts +16 -0
- package/Server/Utils/Telemetry/TelemetryEntity.ts +22 -22
- package/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.ts +16 -10
- package/Tests/App/Accounts/SignedInRedirect.test.tsx +241 -0
- package/Tests/App/AdminDashboard/AdminHeaderSmallScreens.test.tsx +221 -4
- package/Tests/App/Dashboard/AIInsightsResourcePageContext.test.ts +42 -0
- package/Tests/App/Dashboard/AffectedResourcesDisplay.test.tsx +100 -0
- package/Tests/App/Dashboard/AffectedResourcesPicker.test.tsx +86 -0
- package/Tests/App/Dashboard/AffectedResourcesPickerHiddenTypes.test.tsx +2 -0
- package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +19 -0
- package/Tests/App/Dashboard/BreadcrumbCoverage.test.tsx +14 -0
- package/Tests/App/Dashboard/CorrelateFilterBuilder.test.tsx +26 -1
- package/Tests/App/Dashboard/DashboardAppSsoError.test.tsx +501 -0
- package/Tests/App/Dashboard/DashboardHeaderRequests.test.tsx +64 -1
- package/Tests/App/Dashboard/DashboardHeaderSmallScreens.test.tsx +178 -18
- package/Tests/App/Dashboard/DashboardMasterPageSsoRedirect.test.tsx +444 -0
- package/Tests/App/Dashboard/DashboardNavBarForeignHiddenRule.test.tsx +573 -0
- package/Tests/App/Dashboard/DatabaseChartAxes.test.tsx +431 -0
- package/Tests/App/Dashboard/DatabaseContainerDetailBadge.test.tsx +249 -0
- package/Tests/App/Dashboard/DatabaseDashboardE2EFixes.test.ts +681 -0
- package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +1801 -0
- package/Tests/App/Dashboard/DatabaseFleetSummaryRefresh.test.tsx +280 -0
- package/Tests/App/Dashboard/DatabaseFormHelpers.test.tsx +321 -0
- package/Tests/App/Dashboard/DatabaseMetricMonitorLink.test.tsx +795 -0
- package/Tests/App/Dashboard/DatabaseOverviewHeader.test.tsx +580 -0
- package/Tests/App/Dashboard/DatabaseRecommendationsEngineMetrics.test.ts +270 -0
- package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +1486 -0
- package/Tests/App/Dashboard/DatabaseServerEngineMetricsFilter.test.ts +223 -0
- package/Tests/App/Dashboard/DatabaseServerPresentation.test.ts +435 -0
- package/Tests/App/Dashboard/DatabaseServerSummary.test.ts +186 -0
- package/Tests/App/Dashboard/DatabaseServerTelemetryQueries.test.ts +1553 -0
- package/Tests/App/Dashboard/DatabaseTableTruncation.test.tsx +326 -0
- package/Tests/App/Dashboard/DatabaseTelemetryScope.test.ts +512 -0
- package/Tests/App/Dashboard/DatabaseTelemetryTabs.test.tsx +774 -0
- package/Tests/App/Dashboard/DatabaseViewLayout.test.tsx +233 -0
- package/Tests/App/Dashboard/DatabaseWorkloadBadge.test.tsx +972 -0
- package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +5 -0
- package/Tests/App/Dashboard/DiscoveryReviewUnnamedHostHint.test.tsx +1061 -0
- package/Tests/App/Dashboard/EventOverlayScope.test.ts +147 -0
- package/Tests/App/Dashboard/EventOverlayScopeHook.test.tsx +1 -0
- package/Tests/App/Dashboard/LogsResourceFacetChips.test.tsx +1 -0
- package/Tests/App/Dashboard/MetricsViewerDrillDown.test.tsx +310 -0
- package/Tests/App/Dashboard/MetricsViewerEntityScopedRows.test.tsx +495 -0
- package/Tests/App/Dashboard/MonitorCreateFromMonitorBackedDevice.test.tsx +132 -1
- package/Tests/App/Dashboard/NavigationSearchKeywords.test.tsx +5 -0
- package/Tests/App/Dashboard/ProductOrientation.test.tsx +4 -3
- package/Tests/App/Dashboard/RecommendationsLoadedContext.test.tsx +349 -0
- package/Tests/App/Dashboard/ResourceArchiveSettings.test.tsx +62 -0
- package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +8 -1
- package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +14 -0
- package/Tests/App/Dashboard/ScheduledMaintenanceOverviewPage.test.tsx +2 -0
- package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +1085 -0
- package/Tests/App/Dashboard/TelemetryResourceRetentionSettings.test.tsx +10 -0
- package/Tests/App/Dashboard/TracesResourceFacets.test.tsx +2 -1
- package/Tests/App/StatusPage/StatusPageHeaderForeignHiddenRule.test.tsx +284 -0
- package/Tests/ClassStrings.ts +178 -0
- package/Tests/Fixtures/MobilePasskeyUser.json +1 -2
- package/Tests/ForeignHiddenRuleGuard.ts +2152 -0
- package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +2250 -0
- package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +24 -5
- package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
- package/Tests/RenderedMarkup.ts +518 -0
- package/Tests/ResponsiveVisibility.test.ts +490 -4
- package/Tests/ResponsiveVisibility.ts +505 -52
- package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
- package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +541 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserProjectSsoConsentMigration.test.ts +234 -0
- package/Tests/Server/Services/ContainerSnapshotUpsertPostgres.test.ts +319 -0
- package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +13 -0
- package/Tests/Server/Services/DatabaseServerEndpointService.test.ts +2033 -0
- package/Tests/Server/Services/DatabaseServerFeedAndOwnerServices.test.ts +529 -0
- package/Tests/Server/Services/DatabaseServerLifecycle.test.ts +3302 -0
- package/Tests/Server/Services/DatabaseServerRegistries.test.ts +603 -0
- package/Tests/Server/Services/DatabaseServerRuleEngines.test.ts +754 -0
- package/Tests/Server/Services/DatabaseServerService.test.ts +3558 -0
- package/Tests/Server/Services/DatabaseServerSqlPostgres.test.ts +2045 -0
- package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedPostgres.test.ts +362 -0
- package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedReply.test.ts +210 -0
- package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +18 -0
- package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +2 -0
- package/Tests/Server/Services/IncomingCallPolicyEscalationRuleTarget.test.ts +225 -0
- package/Tests/Server/Services/InventoryItemDisplayName.test.ts +280 -2
- package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +8 -2
- package/Tests/Server/Services/IoTDeviceLatestMetricsPostgres.test.ts +689 -0
- package/Tests/Server/Services/IoTDeviceService.test.ts +379 -0
- package/Tests/Server/Services/LatestMetricsValuesShape.test.ts +360 -0
- package/Tests/Server/Services/LlmModelPriceService.test.ts +402 -0
- package/Tests/Server/Services/OpenTelemetryResourceRetentionMemo.test.ts +7 -1
- package/Tests/Server/Services/OwnerRowUniqueness.test.ts +61 -9
- package/Tests/Server/Services/ReminderRuleLookupPostgres.test.ts +2250 -0
- package/Tests/Server/Services/ReminderRuleRefreshOpenSubjectsPaging.test.ts +653 -0
- package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +68 -20
- package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +3 -3
- package/Tests/Server/Services/TelemetryResourceFacetCounting.test.ts +9 -0
- package/Tests/Server/Services/UserProjectSsoConsentPostgres.test.ts +153 -0
- package/Tests/Server/Services/UserProjectSsoConsentService.test.ts +192 -0
- package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +71 -0
- package/Tests/Server/Services/UserSignupIdTakeoverPostgres.test.ts +238 -0
- package/Tests/Server/Services/WorkspaceNotificationSummaryHelpers.test.ts +592 -0
- package/Tests/Server/Types/Billing/PayAsYouGoMeteredAuthorization.test.ts +3 -0
- package/Tests/Server/Types/Database/EventOverlayScopePostgres.test.ts +1 -0
- package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +11 -0
- package/Tests/Server/Types/Database/Permissions/InfrastructureOwnershipScope.test.ts +8 -0
- package/Tests/Server/Types/Database/QueryHelperBooleanForCriteriaBackedRule.test.ts +1094 -0
- package/Tests/Server/Utils/AI/ResourceTools.test.ts +134 -0
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +41 -0
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +279 -9
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaDisplayContext.test.ts +933 -0
- package/Tests/Server/Utils/Monitor/DatabaseMaintenanceSuppression.test.ts +582 -0
- package/Tests/Server/Utils/Monitor/DatabaseServerMonitorLinking.test.ts +753 -0
- package/Tests/Server/Utils/Monitor/MonitorActionTimestamps.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorAlertCriteriaReferences.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +119 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +60 -25
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +1719 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesAffectedResources.test.ts +1447 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesRatioEmail.test.ts +352 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformMetricUnits.test.ts +717 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformRawScan.test.ts +2125 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformSeriesAffectedResources.test.ts +1288 -0
- package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorIncidentCriteriaReferences.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorIncidentMemberRoleMembership.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +88 -0
- package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +205 -0
- package/Tests/Server/Utils/Monitor/MonitorResourceContext.test.ts +62 -0
- package/Tests/Server/Utils/Monitor/MonitorResourceDatabaseMaintenance.test.ts +803 -0
- package/Tests/Server/Utils/Monitor/MonitorStepResourceIdentity.test.ts +133 -3
- package/Tests/Server/Utils/Monitor/MonitorSummaryPersistence.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/PerSeriesRecoveryResolution.test.ts +472 -1
- package/Tests/Server/Utils/Monitor/PerSeriesSecondEntityAlerting.test.ts +1 -0
- package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +236 -2
- package/Tests/Server/Utils/SeriesResourceLabels.test.ts +70 -0
- package/Tests/Server/Utils/StartServerProtobufContentType.test.ts +54 -0
- package/Tests/Server/Utils/Telemetry/DatabaseEndpointDiscovery.test.ts +1895 -0
- package/Tests/Server/Utils/Telemetry/EntityRegistry.test.ts +7 -0
- package/Tests/Server/Utils/Telemetry/LogExceptionExtractorSeverityGate.test.ts +90 -0
- package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +570 -0
- package/Tests/Server/Utils/Telemetry/ResourceFacetResolver.test.ts +97 -2
- package/Tests/Server/Utils/Telemetry/ServiceDependencyDiscovery.test.ts +1337 -0
- package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +295 -0
- package/Tests/Types/AI/AIResourceContext.test.ts +30 -0
- package/Tests/Types/DatabaseServer/DatabaseContainerClassifier.test.ts +3644 -0
- package/Tests/Types/DatabaseServer/DatabaseContainerCommand.test.ts +340 -0
- package/Tests/Types/DatabaseServer/DatabaseContainerCommandCorpus.ts +826 -0
- package/Tests/Types/DatabaseServer/DatabaseContainerCommandPostgres.test.ts +150 -0
- package/Tests/Types/DatabaseServer/DatabaseEndpoint.test.ts +2657 -0
- package/Tests/Types/DatabaseServer/DatabaseServerDiscoverySource.test.ts +64 -0
- package/Tests/Types/DatabaseServer/DatabaseServerEntityKeys.test.ts +952 -0
- package/Tests/Types/DatabaseServer/DatabaseServerMetricCatalog.test.ts +1161 -0
- package/Tests/Types/DatabaseServer/DatabaseSystem.test.ts +1514 -0
- package/Tests/Types/DatabaseServer/DatabaseTelemetryResolver.test.ts +1558 -0
- package/Tests/Types/Monitor/CephAlertTemplates.test.ts +255 -6
- package/Tests/Types/Monitor/DatabaseAlertTemplates.test.ts +2254 -0
- package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +298 -0
- package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +271 -0
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +253 -9
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCoverage.test.ts +13 -5
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +4 -2
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +111 -2
- package/Tests/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.test.ts +61 -0
- package/Tests/Types/Monitor/SeriesContext/SeriesLabelDisplay.test.ts +114 -0
- package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +181 -0
- package/Tests/Types/NetworkDevice/DiscoveredHostNamingStatus.test.ts +401 -0
- package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +4 -1
- package/Tests/Types/Telemetry/ResourceFacetCatalog.test.ts +24 -2
- package/Tests/UI/Components/Card.test.tsx +93 -2
- package/Tests/UI/Components/FeedOptions.test.ts +6 -0
- package/Tests/UI/Components/FeedOptionsButton.test.tsx +8 -1
- package/Tests/UI/Components/ForeignHiddenRuleResilience.test.tsx +1147 -0
- package/Tests/UI/Components/HeaderRightRail.test.tsx +41 -1
- package/Tests/UI/Components/LogsEntityNames.test.ts +16 -1
- package/Tests/UI/Components/LogsFacetSidebarHiddenFacets.test.tsx +7 -3
- package/Tests/UI/Components/ResourceFacetConfigs.test.ts +2 -1
- package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
- package/Tests/UI/Components/SrOnlyForeignRule.test.tsx +994 -0
- package/Tests/UI/Components/StatusPage/ResourceListSelection.test.tsx +34 -1
- package/Tests/UI/Components/TelemetryFacetSidebarHiddenFacets.test.tsx +4 -1
- package/Tests/UI/ForeignHiddenRuleGuard.test.ts +1951 -0
- package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +19 -1
- package/Tests/UI/Rum/ReplayTabSwitcher.test.tsx +19 -1
- package/Tests/UI/TailwindPlayCdnForeignHiddenRule.test.ts +1005 -0
- package/Tests/UI/ThemeGroupHoverReveal.test.ts +847 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +70 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +21 -0
- package/Tests/UI/Utils/ModelAPISsoRedirect.test.ts +127 -0
- package/Tests/UI/Utils/Navigation.test.ts +102 -0
- package/Tests/UI/Utils/Telemetry/TelemetryEntityNames.test.ts +23 -0
- package/Tests/Utils/LabelRuleImportExport.test.ts +5 -0
- package/Tests/Utils/MetricUnitUtil.test.ts +227 -2
- package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +288 -2
- package/Tests/Utils/Monitor/PlatformResourceIdentityAndUnits.test.ts +1559 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.test.ts +2546 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +315 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +336 -1
- package/Tests/Utils/NetworkDiscovery/NetbiosNameUtil.test.ts +244 -0
- package/Tests/Utils/ValueFormatter.test.ts +50 -0
- package/Tests/tsconfig.json +16 -0
- package/Types/AI/AIResourceContext.ts +7 -0
- package/Types/Dashboard/DashboardTemplates.ts +2 -1
- package/Types/DatabaseServer/DatabaseConnectionSpan.ts +77 -0
- package/Types/DatabaseServer/DatabaseContainerClassifier.ts +2068 -0
- package/Types/DatabaseServer/DatabaseContainerCommand.ts +801 -0
- package/Types/DatabaseServer/DatabaseEndpoint.ts +1943 -0
- package/Types/DatabaseServer/DatabaseServerDiscoverySource.ts +68 -0
- package/Types/DatabaseServer/DatabaseServerMetricCatalog.ts +966 -0
- package/Types/DatabaseServer/DatabaseSystem.ts +2124 -0
- package/Types/DatabaseServer/DatabaseTelemetryResolver.ts +555 -0
- package/Types/Email/EmailTemplateType.ts +1 -0
- package/Types/Monitor/CephAlertTemplates.ts +18 -0
- package/Types/Monitor/DatabaseAlertTemplates.ts +2020 -0
- package/Types/Monitor/KubernetesAlertTemplates.ts +64 -24
- package/Types/Monitor/KubernetesMetricCatalog.ts +118 -19
- package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +14 -0
- package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +93 -10
- package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +109 -8
- package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +36 -7
- package/Types/Monitor/Recommendation/MonitorRecommendationUtil.ts +43 -2
- package/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.ts +41 -6
- package/Types/Monitor/SeriesContext/SeriesLabelDisplay.ts +26 -0
- package/Types/NetworkDevice/DiscoveredHostNamingStatus.ts +147 -0
- package/Types/Permission.ts +267 -0
- package/Types/Probe/ProbeApiIngestResponse.ts +8 -0
- package/Types/Rules/RuleCriteriaFieldRegistry.ts +10 -0
- package/Types/Rules/RuleRun.ts +12 -0
- package/Types/Telemetry/EntityType.ts +23 -0
- package/Types/Telemetry/ResourceFacetCatalog.ts +14 -0
- package/Types/Telemetry/ServiceType.ts +8 -0
- package/UI/Components/Banner/Banner.tsx +1 -1
- package/UI/Components/Card/Card.tsx +1 -1
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +2 -2
- package/UI/Components/Charts/Line/LineChart.tsx +7 -0
- package/UI/Components/CommandPalette/CommandPalette.tsx +1 -1
- package/UI/Components/Date/StartAndEndDate.tsx +5 -5
- package/UI/Components/Diagram/HorizontalStepChain.tsx +1 -1
- package/UI/Components/Feed/FeedOptionsButton.tsx +1 -1
- package/UI/Components/Forms/BasicForm.tsx +1 -1
- package/UI/Components/Header/Header.tsx +1 -1
- package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -1
- package/UI/Components/HeaderAlert/HeaderAlert.tsx +1 -1
- package/UI/Components/LogsViewer/components/FacetValueRow.tsx +1 -1
- package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +1 -1
- package/UI/Components/Modal/ModalFooter.tsx +1 -1
- package/UI/Components/ModelDetail/CardModelDetail.tsx +2 -2
- package/UI/Components/ModelTable/BaseModelTable.tsx +4 -4
- package/UI/Components/Navbar/NavBar.tsx +9 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +2 -2
- package/UI/Components/Page/Page.tsx +1 -1
- package/UI/Components/Pagination/Pagination.tsx +3 -3
- package/UI/Components/ProgressButtons/ProgressButtonItem.tsx +1 -1
- package/UI/Components/SideMenu/SideMenu.tsx +4 -1
- package/UI/Components/StatusPage/ResourceGroupNavigator.tsx +4 -2
- package/UI/Components/StatusPage/ResourceList.tsx +6 -6
- package/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.tsx +1 -1
- package/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.tsx +1 -1
- package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +1 -1
- package/UI/Components/Workflow/ComponentsModal.tsx +2 -2
- package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
- package/UI/Styles/Theme.css +8 -1
- package/UI/Utils/Navigation.ts +89 -42
- package/UI/Utils/Telemetry/TelemetryEntityNames.ts +7 -0
- package/UI/Utils/TelemetryService.ts +4 -3
- package/Utils/LabelRuleImportExport.ts +2 -0
- package/Utils/MetricUnitUtil.ts +103 -11
- package/Utils/Monitor/MetricValueFormatter.ts +66 -13
- package/Utils/Monitor/PlatformMetricUnitUtil.ts +215 -0
- package/Utils/Monitor/PlatformResourceIdentity.ts +514 -0
- package/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.ts +425 -0
- package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +32 -0
- package/Utils/NetworkDiscovery/NetbiosNameUtil.ts +73 -0
- package/Utils/Telemetry/DatabaseServerEntityKeys.ts +275 -0
- package/Utils/Telemetry/EntityKey.ts +176 -0
- package/Utils/ValueFormatter.ts +16 -0
- package/build/dist/Models/DatabaseModels/Alert.js +54 -0
- package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DatabaseServer.js +1683 -0
- package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js +559 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js +648 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js +522 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js +603 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js +506 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js.map +1 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js +505 -0
- package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js.map +1 -0
- package/build/dist/Models/DatabaseModels/Incident.js +54 -0
- package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +16 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +54 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryException.js +2 -0
- package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js +162 -0
- package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js +240 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/MultipartFormData.js +1 -1
- package/build/dist/Server/Middleware/TelemetryIngest.js +15 -8
- package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
- package/build/dist/Server/Services/AlertReminderRuleService.js +72 -23
- package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseServerEndpointService.js +694 -0
- package/build/dist/Server/Services/DatabaseServerEndpointService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerFeedService.js +110 -0
- package/build/dist/Server/Services/DatabaseServerFeedService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js +272 -0
- package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerLabelRuleService.js +13 -0
- package/build/dist/Server/Services/DatabaseServerLabelRuleService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js +340 -0
- package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js +13 -0
- package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js +160 -0
- package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerOwnerUserService.js +153 -0
- package/build/dist/Server/Services/DatabaseServerOwnerUserService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseServerService.js +3272 -0
- package/build/dist/Server/Services/DatabaseServerService.js.map +1 -0
- package/build/dist/Server/Services/DatabaseService.js +8 -4
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DockerResourceService.js +30 -3
- package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
- package/build/dist/Server/Services/IncidentReminderRuleService.js +73 -23
- package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +16 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemService.js +88 -5
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceService.js +111 -24
- package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +24 -9
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/PodmanResourceService.js +30 -3
- package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +75 -23
- package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/ServiceService.js +1 -1
- package/build/dist/Server/Services/ServiceService.js.map +1 -1
- package/build/dist/Server/Services/TelemetryUsageBillingService.js +23 -2
- package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
- package/build/dist/Server/Services/UserProjectSsoConsentService.js +79 -0
- package/build/dist/Server/Services/UserProjectSsoConsentService.js.map +1 -0
- package/build/dist/Server/Services/UserService.js +13 -1
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js +18 -0
- package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +26 -1
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +46 -0
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
- package/build/dist/Server/Utils/Database/AffectedResourceRelations.js +6 -0
- package/build/dist/Server/Utils/Database/AffectedResourceRelations.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +134 -13
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -6
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1489 -524
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +22 -6
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +196 -6
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +36 -9
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js +175 -17
- package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js +29 -0
- package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +211 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +82 -10
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -1
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +25 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +20 -4
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js +2 -0
- package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js +442 -0
- package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +5 -1
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +15 -1
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +121 -5
- package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js +12 -0
- package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js +438 -36
- package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/Telemetry.js +12 -0
- package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +17 -18
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js +16 -10
- package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js.map +1 -1
- package/build/dist/Types/AI/AIResourceContext.js +7 -0
- package/build/dist/Types/AI/AIResourceContext.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardTemplates.js +2 -1
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js +71 -0
- package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js +1462 -0
- package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js +691 -0
- package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js +1407 -0
- package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js +60 -0
- package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js +804 -0
- package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseSystem.js +1860 -0
- package/build/dist/Types/DatabaseServer/DatabaseSystem.js.map +1 -0
- package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js +406 -0
- package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/Monitor/CephAlertTemplates.js +18 -0
- package/build/dist/Types/Monitor/CephAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/DatabaseAlertTemplates.js +1518 -0
- package/build/dist/Types/Monitor/DatabaseAlertTemplates.js.map +1 -0
- package/build/dist/Types/Monitor/KubernetesAlertTemplates.js +57 -24
- package/build/dist/Types/Monitor/KubernetesAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/KubernetesMetricCatalog.js +104 -18
- package/build/dist/Types/Monitor/KubernetesMetricCatalog.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +80 -3
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +10 -3
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js +29 -0
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js +21 -6
- package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js.map +1 -1
- package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js +25 -0
- package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js.map +1 -1
- package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js +136 -0
- package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js.map +1 -0
- package/build/dist/Types/Permission.js +234 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +10 -0
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
- package/build/dist/Types/Rules/RuleRun.js +4 -0
- package/build/dist/Types/Rules/RuleRun.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityType.js +23 -0
- package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
- package/build/dist/Types/Telemetry/ResourceFacetCatalog.js +14 -0
- package/build/dist/Types/Telemetry/ResourceFacetCatalog.js.map +1 -1
- package/build/dist/Types/Telemetry/ServiceType.js +8 -0
- package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
- package/build/dist/UI/Components/Banner/Banner.js +1 -1
- package/build/dist/UI/Components/Banner/Banner.js.map +1 -1
- package/build/dist/UI/Components/Card/Card.js +1 -1
- package/build/dist/UI/Components/Card/Card.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +2 -2
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/CommandPalette/CommandPalette.js +1 -1
- package/build/dist/UI/Components/CommandPalette/CommandPalette.js.map +1 -1
- package/build/dist/UI/Components/Date/StartAndEndDate.js +5 -5
- package/build/dist/UI/Components/Date/StartAndEndDate.js.map +1 -1
- package/build/dist/UI/Components/Diagram/HorizontalStepChain.js +1 -1
- package/build/dist/UI/Components/Diagram/HorizontalStepChain.js.map +1 -1
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js +1 -1
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -1
- package/build/dist/UI/Components/Forms/BasicForm.js +1 -1
- package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
- package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js +1 -1
- package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js +1 -1
- package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
- package/build/dist/UI/Components/Modal/ModalFooter.js +1 -1
- package/build/dist/UI/Components/Modal/ModalFooter.js.map +1 -1
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +2 -2
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +4 -4
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBar.js +9 -2
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Components/Page/Page.js +1 -1
- package/build/dist/UI/Components/Page/Page.js.map +1 -1
- package/build/dist/UI/Components/Pagination/Pagination.js +3 -3
- package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
- package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js +1 -1
- package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js.map +1 -1
- package/build/dist/UI/Components/SideMenu/SideMenu.js +4 -1
- package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
- package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js +3 -1
- package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js.map +1 -1
- package/build/dist/UI/Components/StatusPage/ResourceList.js +6 -6
- package/build/dist/UI/Components/StatusPage/ResourceList.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +1 -1
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentsModal.js +2 -2
- package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
- package/build/dist/UI/Utils/Navigation.js +66 -33
- package/build/dist/UI/Utils/Navigation.js.map +1 -1
- package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js +7 -0
- package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js.map +1 -1
- package/build/dist/UI/Utils/TelemetryService.js +3 -2
- package/build/dist/UI/Utils/TelemetryService.js.map +1 -1
- package/build/dist/Utils/LabelRuleImportExport.js +2 -0
- package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
- package/build/dist/Utils/MetricUnitUtil.js +77 -13
- package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
- package/build/dist/Utils/Monitor/MetricValueFormatter.js +58 -13
- package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -1
- package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js +161 -0
- package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/PlatformResourceIdentity.js +323 -0
- package/build/dist/Utils/Monitor/PlatformResourceIdentity.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js +270 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +23 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js +61 -0
- package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js.map +1 -1
- package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js +178 -0
- package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js.map +1 -0
- package/build/dist/Utils/Telemetry/EntityKey.js +133 -0
- package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
- package/build/dist/Utils/ValueFormatter.js +15 -0
- package/build/dist/Utils/ValueFormatter.js.map +1 -1
- package/package.json +1 -1
- 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();
|