@oneuptime/common 14.0.0 → 14.0.1
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/Index.ts +4 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveFeed.ts +2 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.ts +522 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.ts +38 -4
- package/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.ts +603 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.ts +248 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.ts +19 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/DatabaseService.ts +10 -0
- package/Server/Services/Index.ts +4 -0
- package/Server/Services/MonitorService.ts +3 -2
- package/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.ts +385 -0
- package/Server/Services/ServiceLevelObjectiveLabelRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +92 -16
- package/Server/Services/ServiceLevelObjectiveMonitorRuleService.ts +36 -1
- package/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.ts +521 -0
- package/Server/Services/ServiceLevelObjectiveOwnerRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +28 -0
- package/Server/Types/Workflow/Components/Telegram/SendMessageToChat.ts +27 -1
- package/Server/Utils/AI/Toolbox/SloTools.ts +1 -0
- package/Server/Utils/Monitor/AffectedResourceList.ts +31 -73
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +1 -1
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +1 -1
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +181 -136
- package/Server/Utils/Monitor/RootCauseList.ts +139 -0
- package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +28 -0
- package/Server/Utils/Slo/SloRulePatternValidator.ts +42 -0
- package/Tests/App/Dashboard/FeedFilterSortIntegration.test.tsx +1632 -0
- package/Tests/App/Dashboard/FeedOptionsLocales.test.ts +250 -0
- package/Tests/App/Dashboard/FeedOrdering.test.ts +279 -9
- package/Tests/App/Dashboard/InvestigationFeedRefresh.test.tsx +60 -1
- package/Tests/App/Dashboard/InvestigationPanel.test.tsx +594 -3
- package/Tests/App/Dashboard/ProductOrientation.test.tsx +646 -0
- package/Tests/App/Dashboard/ResourceFeed.test.tsx +612 -2
- package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +30 -0
- package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +1202 -0
- package/Tests/App/Dashboard/ScheduledMaintenanceFeedRefresh.test.tsx +52 -1
- package/Tests/App/Dashboard/SloFeed.test.tsx +106 -1
- package/Tests/App/Dashboard/SloMonitorRuleCriteria.test.tsx +566 -0
- package/Tests/App/Dashboard/SloNavigationWiring.test.tsx +6 -4
- package/Tests/App/Dashboard/SloNoticeBanner.test.tsx +186 -0
- package/Tests/App/Dashboard/SloOverviewKpiStrip.test.tsx +388 -44
- package/Tests/App/Dashboard/SloOverviewLayout.test.tsx +613 -0
- package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +0 -8
- package/Tests/Models/DatabaseModels/SloLabelAndOwnerRuleModels.test.ts +404 -0
- package/Tests/Models/DatabaseModels/SloOverhaulModels.test.ts +17 -1
- package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
- package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloLabelAndOwnerRulesMigration.test.ts +407 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloMonitorRuleMonitorTypeMigration.test.ts +171 -0
- package/Tests/Server/Infrastructure/Postgres/SloProductOverhaulMigration.test.ts +88 -4
- package/Tests/Server/Services/DatabaseServiceRuleCriteriaValidation.test.ts +104 -0
- package/Tests/Server/Services/RuleRun/LabelRuleEnginesGroupBRun.test.ts +48 -1
- package/Tests/Server/Services/RuleRun/OwnerRuleEnginesGroupBRun.test.ts +30 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleEngines.test.ts +1170 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleServices.test.ts +172 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +323 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleService.test.ts +115 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleTriggers.test.ts +21 -2
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorTypePostgres.test.ts +480 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +162 -0
- package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +45 -6
- package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +745 -0
- package/Tests/Server/Types/Markdown.test.ts +2 -1
- package/Tests/Server/Types/Workflow/Components/ApiComponentSsrf.test.ts +49 -0
- package/Tests/Server/Types/Workflow/Components/TelegramSendMessageToChat.test.ts +331 -0
- package/Tests/Server/Utils/AI/SloTools.test.ts +10 -0
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +405 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorBreachingSamples.test.ts +1101 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +106 -80
- package/Tests/Server/Utils/Monitor/RootCauseList.test.ts +252 -0
- package/Tests/Server/Utils/Rules/RuleRun/RuleRunPermission.test.ts +154 -0
- package/Tests/Server/Utils/SSRFProtection.test.ts +111 -0
- package/Tests/Server/Utils/Slo/SloRulePatternValidator.test.ts +67 -0
- package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +213 -0
- package/Tests/Types/API/Route.test.ts +56 -0
- package/Tests/Types/API/URLSchemeLikePath.test.ts +304 -0
- package/Tests/UI/Components/FeedOptions.test.ts +1404 -0
- package/Tests/UI/Components/FeedOptionsButton.test.tsx +2603 -0
- package/Tests/UI/Components/Icon.test.tsx +26 -0
- package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
- package/Tests/UI/Components/useFeedItemsViewKey.test.tsx +1017 -0
- package/Tests/UI/Components/useFeedOptions.test.tsx +1107 -0
- package/Tests/Utils/LabelRuleImportExport.test.ts +102 -0
- package/Tests/Utils/Slo/SloDuration.test.ts +115 -24
- package/Tests/Utils/Slo/SloHealth.test.ts +93 -10
- package/Tests/Utils/Slo/SloMonitorRuleCriteria.test.ts +109 -0
- package/Tests/Utils/Slo/SloOverviewText.test.ts +64 -0
- package/Types/API/Route.ts +19 -2
- package/Types/API/URL.ts +26 -2
- package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +6 -5
- package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +9 -0
- package/Types/Permission.ts +90 -0
- package/Types/Rules/RuleCriteriaFieldRegistry.ts +11 -0
- package/Types/Rules/RuleRun.ts +4 -0
- package/UI/Components/Feed/FeedOptions.ts +427 -0
- package/UI/Components/Feed/FeedOptionsButton.tsx +692 -0
- package/UI/Components/Feed/useFeedItems.ts +28 -9
- package/UI/Components/Feed/useFeedOptions.ts +225 -0
- package/UI/Components/Icon/Icon.tsx +1 -1
- package/Utils/LabelRuleImportExport.ts +27 -9
- package/Utils/Monitor/MetricValueFormatter.ts +2 -2
- package/Utils/Rules/MonitorTypeRuleCriteria.ts +31 -0
- package/Utils/Slo/SloDuration.ts +22 -6
- package/Utils/Slo/SloHealth.ts +8 -7
- package/Utils/Slo/SloMonitorRuleCriteria.ts +33 -1
- package/Utils/Slo/SloOverviewText.ts +33 -0
- package/Utils/Slo/SloProjection.ts +4 -3
- package/build/dist/Models/DatabaseModels/Index.js +4 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js +530 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js +39 -4
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js +612 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.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/Services/DatabaseService.js +7 -0
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +4 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +3 -2
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js +295 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +59 -14
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js +22 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js +400 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +21 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js +20 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js +22 -51
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +139 -109
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/RootCauseList.js +101 -0
- package/build/dist/Server/Utils/Monitor/RootCauseList.js.map +1 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +28 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js +28 -0
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js.map +1 -0
- package/build/dist/Types/API/Route.js +18 -2
- package/build/dist/Types/API/Route.js.map +1 -1
- package/build/dist/Types/API/URL.js +23 -2
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/Types/Permission.js +84 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +11 -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/UI/Components/Feed/FeedOptions.js +235 -0
- package/build/dist/UI/Components/Feed/FeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js +346 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -0
- package/build/dist/UI/Components/Feed/useFeedItems.js +17 -7
- package/build/dist/UI/Components/Feed/useFeedItems.js.map +1 -1
- package/build/dist/UI/Components/Feed/useFeedOptions.js +130 -0
- package/build/dist/UI/Components/Feed/useFeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Icon/Icon.js +1 -1
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/Utils/LabelRuleImportExport.js +22 -6
- package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
- package/build/dist/Utils/Monitor/MetricValueFormatter.js +2 -2
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js +18 -0
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js.map +1 -0
- package/build/dist/Utils/Slo/SloDuration.js +13 -5
- package/build/dist/Utils/Slo/SloDuration.js.map +1 -1
- package/build/dist/Utils/Slo/SloHealth.js +8 -7
- package/build/dist/Utils/Slo/SloHealth.js.map +1 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js +16 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js.map +1 -1
- package/build/dist/Utils/Slo/SloOverviewText.js +16 -0
- package/build/dist/Utils/Slo/SloOverviewText.js.map +1 -1
- package/build/dist/Utils/Slo/SloProjection.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `jest` is the global (as in ServiceLevelObjectiveService.test.ts), not the
|
|
3
|
+
* @jest/globals export: that export's SpiedFunction type does not accept what
|
|
4
|
+
* jest.spyOn returns with this repo's jest-mock version.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
|
|
7
|
+
import Label from "../../../Models/DatabaseModels/Label";
|
|
8
|
+
import ServiceLevelObjective from "../../../Models/DatabaseModels/ServiceLevelObjective";
|
|
9
|
+
import { ServiceLevelObjectiveFeedEventType } from "../../../Models/DatabaseModels/ServiceLevelObjectiveFeed";
|
|
10
|
+
import ServiceLevelObjectiveLabelRule from "../../../Models/DatabaseModels/ServiceLevelObjectiveLabelRule";
|
|
11
|
+
import ServiceLevelObjectiveOwnerRule from "../../../Models/DatabaseModels/ServiceLevelObjectiveOwnerRule";
|
|
12
|
+
import ServiceLevelObjectiveOwnerTeam from "../../../Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam";
|
|
13
|
+
import ServiceLevelObjectiveOwnerUser from "../../../Models/DatabaseModels/ServiceLevelObjectiveOwnerUser";
|
|
14
|
+
import ServiceLevelObjectiveFeedService from "../../../Server/Services/ServiceLevelObjectiveFeedService";
|
|
15
|
+
import ServiceLevelObjectiveLabelRuleEngineService from "../../../Server/Services/ServiceLevelObjectiveLabelRuleEngineService";
|
|
16
|
+
import ServiceLevelObjectiveLabelRuleService from "../../../Server/Services/ServiceLevelObjectiveLabelRuleService";
|
|
17
|
+
import ServiceLevelObjectiveOwnerRuleEngineService from "../../../Server/Services/ServiceLevelObjectiveOwnerRuleEngineService";
|
|
18
|
+
import ServiceLevelObjectiveOwnerRuleService from "../../../Server/Services/ServiceLevelObjectiveOwnerRuleService";
|
|
19
|
+
import ServiceLevelObjectiveOwnerTeamService from "../../../Server/Services/ServiceLevelObjectiveOwnerTeamService";
|
|
20
|
+
import ServiceLevelObjectiveOwnerUserService from "../../../Server/Services/ServiceLevelObjectiveOwnerUserService";
|
|
21
|
+
import ServiceLevelObjectiveService from "../../../Server/Services/ServiceLevelObjectiveService";
|
|
22
|
+
import logger from "../../../Server/Utils/Logger";
|
|
23
|
+
import {
|
|
24
|
+
RuleApplicationResult,
|
|
25
|
+
RuleApplicationResultUtil,
|
|
26
|
+
} from "../../../Server/Utils/Rules/RuleRun/RuleApplication";
|
|
27
|
+
import { Purple500 } from "../../../Types/BrandColors";
|
|
28
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
29
|
+
import FilterCondition from "../../../Types/Filter/FilterCondition";
|
|
30
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
31
|
+
import RuleCriteria, {
|
|
32
|
+
RULE_CRITERIA_SCHEMA_VERSION,
|
|
33
|
+
RuleCriteriaFilter,
|
|
34
|
+
RuleCriteriaOperator,
|
|
35
|
+
} from "../../../Types/Rules/RuleCriteria";
|
|
36
|
+
import { MAX_RULES_EVALUATED_PER_PROJECT } from "../../../Utils/Rules/RuleEngineLimits";
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* SLO label and owner rules - what is specific to SLOs.
|
|
40
|
+
*
|
|
41
|
+
* LabelRuleEnginesGroupBRun / OwnerRuleEnginesGroupBRun already hold both
|
|
42
|
+
* engines to the contract every label and owner engine shares (selects, dedupe,
|
|
43
|
+
* already-applied, failures as results). This suite covers what the SLO
|
|
44
|
+
* engines add on top of it:
|
|
45
|
+
*
|
|
46
|
+
* - matching on everything an SLO rule offers: its labels (any-of), its
|
|
47
|
+
* name and description, regexes AND '*' wildcards - the syntax the SLO
|
|
48
|
+
* monitor rules take - and configurable criteria;
|
|
49
|
+
* - the SLO feed: which rules changed the SLO, with every rule name escaped
|
|
50
|
+
* (the feed renders markdown without safe mode), and a feed failure never
|
|
51
|
+
* turning an SLO the rules DID change into a failed one;
|
|
52
|
+
* - owners go through the SLO owner services, which refuse a user who left
|
|
53
|
+
* the project: that owner is skipped, the rest are still added, and a run
|
|
54
|
+
* whose every owner was refused says so instead of "already applied";
|
|
55
|
+
* - who is notified: the rule's Notify Owners, and the run's own opt-in.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
59
|
+
"22222222-2222-4222-8222-222222222222",
|
|
60
|
+
);
|
|
61
|
+
const SLO_ID: ObjectID = new ObjectID("cccccccc-cccc-4ccc-8ccc-cccccccccccc");
|
|
62
|
+
const RULE_ID: ObjectID = new ObjectID("77777777-7777-4777-8777-777777777777");
|
|
63
|
+
const OTHER_RULE_ID: ObjectID = new ObjectID(
|
|
64
|
+
"88888888-8888-4888-8888-888888888888",
|
|
65
|
+
);
|
|
66
|
+
const LABEL_PRODUCTION: ObjectID = new ObjectID(
|
|
67
|
+
"11111111-1111-4111-8111-111111111111",
|
|
68
|
+
);
|
|
69
|
+
const LABEL_CHECKOUT: ObjectID = new ObjectID(
|
|
70
|
+
"33333333-3333-4333-8333-333333333333",
|
|
71
|
+
);
|
|
72
|
+
const LABEL_STAGING: ObjectID = new ObjectID(
|
|
73
|
+
"44444444-4444-4444-8444-444444444444",
|
|
74
|
+
);
|
|
75
|
+
const USER_A: ObjectID = new ObjectID("55555555-5555-4555-8555-555555555555");
|
|
76
|
+
const USER_B: ObjectID = new ObjectID("66666666-6666-4666-8666-666666666666");
|
|
77
|
+
const TEAM_A: ObjectID = new ObjectID("99999999-9999-4999-8999-999999999999");
|
|
78
|
+
|
|
79
|
+
const SLO_NAME: string = "Checkout API availability";
|
|
80
|
+
const SLO_DESCRIPTION: string = "Customer-facing checkout, tier 1";
|
|
81
|
+
const SLO_LINK: string =
|
|
82
|
+
"[SLO Checkout API availability](https://oneuptime.test/dashboard/p/slos/s)";
|
|
83
|
+
|
|
84
|
+
type AsyncSpy = jest.SpyInstance;
|
|
85
|
+
|
|
86
|
+
function label(id: ObjectID): Label {
|
|
87
|
+
const result: Label = new Label();
|
|
88
|
+
result.id = id;
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ref(id: ObjectID): { id: ObjectID; _id: string } {
|
|
93
|
+
return { id: id, _id: id.toString() };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The SLO a run hands the engine: resourceSelectForRuleRun is ids only.
|
|
97
|
+
function runTarget(): ServiceLevelObjective {
|
|
98
|
+
const slo: ServiceLevelObjective = new ServiceLevelObjective();
|
|
99
|
+
slo.id = SLO_ID;
|
|
100
|
+
slo.projectId = PROJECT_ID;
|
|
101
|
+
return slo;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The SLO the engine reads back.
|
|
105
|
+
function sloDetails(
|
|
106
|
+
data: {
|
|
107
|
+
labels?: Array<ObjectID> | undefined;
|
|
108
|
+
description?: string | undefined;
|
|
109
|
+
name?: string | undefined;
|
|
110
|
+
} = {},
|
|
111
|
+
): ServiceLevelObjective {
|
|
112
|
+
const slo: ServiceLevelObjective = new ServiceLevelObjective();
|
|
113
|
+
slo.id = SLO_ID;
|
|
114
|
+
slo.projectId = PROJECT_ID;
|
|
115
|
+
slo.name = data.name === undefined ? SLO_NAME : data.name;
|
|
116
|
+
slo.description =
|
|
117
|
+
data.description === undefined ? SLO_DESCRIPTION : data.description;
|
|
118
|
+
slo.labels = (data.labels || []).map(label);
|
|
119
|
+
return slo;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function criteria(
|
|
123
|
+
filters: Array<RuleCriteriaFilter>,
|
|
124
|
+
filterCondition: FilterCondition = FilterCondition.All,
|
|
125
|
+
): RuleCriteria {
|
|
126
|
+
return {
|
|
127
|
+
schemaVersion: RULE_CRITERIA_SCHEMA_VERSION,
|
|
128
|
+
filterCondition: filterCondition,
|
|
129
|
+
filters: filters,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface MatchFields {
|
|
134
|
+
id?: ObjectID | undefined;
|
|
135
|
+
name?: string | undefined;
|
|
136
|
+
namePattern?: string | undefined;
|
|
137
|
+
descriptionPattern?: string | undefined;
|
|
138
|
+
sloLabels?: Array<ObjectID> | undefined;
|
|
139
|
+
criteria?: RuleCriteria | undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Unset fields stay undefined, which the model's own typing does not allow.
|
|
143
|
+
function setFields(
|
|
144
|
+
model: ServiceLevelObjectiveLabelRule | ServiceLevelObjectiveOwnerRule,
|
|
145
|
+
fields: Record<string, unknown>,
|
|
146
|
+
): void {
|
|
147
|
+
Object.assign(model as unknown as Record<string, unknown>, fields);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function applyMatchFields(
|
|
151
|
+
rule: ServiceLevelObjectiveLabelRule | ServiceLevelObjectiveOwnerRule,
|
|
152
|
+
data: MatchFields,
|
|
153
|
+
): void {
|
|
154
|
+
setFields(rule, {
|
|
155
|
+
id: data.id || RULE_ID,
|
|
156
|
+
name: data.name === undefined ? "Tag checkout SLOs" : data.name,
|
|
157
|
+
projectId: PROJECT_ID,
|
|
158
|
+
serviceLevelObjectiveNamePattern: data.namePattern,
|
|
159
|
+
serviceLevelObjectiveDescriptionPattern: data.descriptionPattern,
|
|
160
|
+
serviceLevelObjectiveLabels: data.sloLabels
|
|
161
|
+
? data.sloLabels.map(label)
|
|
162
|
+
: undefined,
|
|
163
|
+
criteria: data.criteria,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function labelRule(
|
|
168
|
+
data: MatchFields & { labelsToAdd?: Array<ObjectID> | undefined },
|
|
169
|
+
): ServiceLevelObjectiveLabelRule {
|
|
170
|
+
const rule: ServiceLevelObjectiveLabelRule =
|
|
171
|
+
new ServiceLevelObjectiveLabelRule();
|
|
172
|
+
applyMatchFields(rule, data);
|
|
173
|
+
rule.labelsToAdd = (data.labelsToAdd || [LABEL_CHECKOUT]).map(label);
|
|
174
|
+
return rule;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function ownerRule(
|
|
178
|
+
data: MatchFields & {
|
|
179
|
+
users?: Array<ObjectID> | undefined;
|
|
180
|
+
teams?: Array<ObjectID> | undefined;
|
|
181
|
+
notifyOwners?: boolean | undefined;
|
|
182
|
+
},
|
|
183
|
+
): ServiceLevelObjectiveOwnerRule {
|
|
184
|
+
const rule: ServiceLevelObjectiveOwnerRule =
|
|
185
|
+
new ServiceLevelObjectiveOwnerRule();
|
|
186
|
+
applyMatchFields(rule, data);
|
|
187
|
+
setFields(rule, {
|
|
188
|
+
notifyOwners: data.notifyOwners,
|
|
189
|
+
ownerUsers: (data.users || []).map(ref),
|
|
190
|
+
ownerTeams: (data.teams || []).map(ref),
|
|
191
|
+
});
|
|
192
|
+
return rule;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface FeedPayload {
|
|
196
|
+
serviceLevelObjectiveId: ObjectID;
|
|
197
|
+
projectId: ObjectID;
|
|
198
|
+
serviceLevelObjectiveFeedEventType: ServiceLevelObjectiveFeedEventType;
|
|
199
|
+
feedInfoInMarkdown: string;
|
|
200
|
+
moreInformationInMarkdown?: string | undefined;
|
|
201
|
+
displayColor?: unknown;
|
|
202
|
+
userId?: ObjectID | undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let errorSpy: AsyncSpy;
|
|
206
|
+
let warnSpy: AsyncSpy;
|
|
207
|
+
let linkSpy: AsyncSpy;
|
|
208
|
+
let feedSpy: AsyncSpy;
|
|
209
|
+
let findSloSpy: AsyncSpy;
|
|
210
|
+
|
|
211
|
+
function feedPayloads(): Array<FeedPayload> {
|
|
212
|
+
return feedSpy.mock.calls.map((call: Array<unknown>): FeedPayload => {
|
|
213
|
+
return call[0] as FeedPayload;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
errorSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
|
|
219
|
+
warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {});
|
|
220
|
+
jest.spyOn(logger, "debug").mockImplementation(() => {});
|
|
221
|
+
linkSpy = jest
|
|
222
|
+
.spyOn(ServiceLevelObjectiveService, "getSloMarkdownLink")
|
|
223
|
+
.mockResolvedValue(SLO_LINK);
|
|
224
|
+
feedSpy = jest
|
|
225
|
+
.spyOn(
|
|
226
|
+
ServiceLevelObjectiveFeedService,
|
|
227
|
+
"createServiceLevelObjectiveFeedItem",
|
|
228
|
+
)
|
|
229
|
+
.mockResolvedValue(undefined);
|
|
230
|
+
findSloSpy = jest
|
|
231
|
+
.spyOn(ServiceLevelObjectiveService, "findOneById")
|
|
232
|
+
.mockResolvedValue(sloDetails());
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
afterEach(() => {
|
|
236
|
+
jest.restoreAllMocks();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("ServiceLevelObjectiveLabelRuleEngineService", () => {
|
|
240
|
+
interface LabelWrite {
|
|
241
|
+
relation: string;
|
|
242
|
+
sloId: string;
|
|
243
|
+
labelIds: Array<string>;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let writes: Array<LabelWrite>;
|
|
247
|
+
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
writes = [];
|
|
250
|
+
let pendingRelation: string = "";
|
|
251
|
+
let pendingSloId: string = "";
|
|
252
|
+
|
|
253
|
+
const builder: Record<string, unknown> = {};
|
|
254
|
+
Object.assign(builder, {
|
|
255
|
+
createQueryBuilder: () => {
|
|
256
|
+
return builder;
|
|
257
|
+
},
|
|
258
|
+
relation: (_target: unknown, relation: string) => {
|
|
259
|
+
pendingRelation = relation;
|
|
260
|
+
return builder;
|
|
261
|
+
},
|
|
262
|
+
of: (sloId: string) => {
|
|
263
|
+
pendingSloId = sloId;
|
|
264
|
+
return builder;
|
|
265
|
+
},
|
|
266
|
+
add: async (labelIds: Array<string>) => {
|
|
267
|
+
writes.push({
|
|
268
|
+
relation: pendingRelation,
|
|
269
|
+
sloId: pendingSloId,
|
|
270
|
+
labelIds: [...labelIds],
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
jest
|
|
276
|
+
.spyOn(ServiceLevelObjectiveService, "getRepository")
|
|
277
|
+
.mockReturnValue(builder as never);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
function run(
|
|
281
|
+
rules: Array<ServiceLevelObjectiveLabelRule>,
|
|
282
|
+
slo: ServiceLevelObjective = runTarget(),
|
|
283
|
+
): Promise<RuleApplicationResult> {
|
|
284
|
+
return ServiceLevelObjectiveLabelRuleEngineService.applyRulesToExistingResource(
|
|
285
|
+
{ resource: slo, rules: rules, allowOwnerNotification: false },
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
describe("matching", () => {
|
|
290
|
+
it("matches the SLO name as a case-insensitive regex", async () => {
|
|
291
|
+
const result: RuleApplicationResult = await run([
|
|
292
|
+
labelRule({ namePattern: "^CHECKOUT api" }),
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
expect(result).toEqual(RuleApplicationResultUtil.updated(1));
|
|
296
|
+
expect(writes).toEqual([
|
|
297
|
+
{
|
|
298
|
+
relation: "labels",
|
|
299
|
+
sloId: SLO_ID.toString(),
|
|
300
|
+
labelIds: [LABEL_CHECKOUT.toString()],
|
|
301
|
+
},
|
|
302
|
+
]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// The syntax the SLO monitor rules take, and the one the docs show.
|
|
306
|
+
it("matches a '*' wildcard pattern, which is not a valid regex", async () => {
|
|
307
|
+
expect(await run([labelRule({ namePattern: "*checkout*" })])).toEqual(
|
|
308
|
+
RuleApplicationResultUtil.updated(1),
|
|
309
|
+
);
|
|
310
|
+
expect(await run([labelRule({ namePattern: "*payments*" })])).toEqual(
|
|
311
|
+
RuleApplicationResultUtil.noMatch(),
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("matches the SLO description", async () => {
|
|
316
|
+
expect(await run([labelRule({ descriptionPattern: "tier 1$" })])).toEqual(
|
|
317
|
+
RuleApplicationResultUtil.updated(1),
|
|
318
|
+
);
|
|
319
|
+
expect(await run([labelRule({ descriptionPattern: "tier 2" })])).toEqual(
|
|
320
|
+
RuleApplicationResultUtil.noMatch(),
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("never matches a description pattern against an SLO without a description", async () => {
|
|
325
|
+
findSloSpy.mockResolvedValue(sloDetails({ description: "" }));
|
|
326
|
+
|
|
327
|
+
expect(await run([labelRule({ descriptionPattern: ".*" })])).toEqual(
|
|
328
|
+
RuleApplicationResultUtil.noMatch(),
|
|
329
|
+
);
|
|
330
|
+
expect(writes).toHaveLength(0);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("requires every legacy criterion that is set", async () => {
|
|
334
|
+
expect(
|
|
335
|
+
await run([
|
|
336
|
+
labelRule({
|
|
337
|
+
namePattern: "checkout",
|
|
338
|
+
descriptionPattern: "internal only",
|
|
339
|
+
}),
|
|
340
|
+
]),
|
|
341
|
+
).toEqual(RuleApplicationResultUtil.noMatch());
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("matches the SLO's labels as any-of", async () => {
|
|
345
|
+
findSloSpy.mockResolvedValue(sloDetails({ labels: [LABEL_PRODUCTION] }));
|
|
346
|
+
|
|
347
|
+
expect(
|
|
348
|
+
await run([
|
|
349
|
+
labelRule({ sloLabels: [LABEL_STAGING, LABEL_PRODUCTION] }),
|
|
350
|
+
]),
|
|
351
|
+
).toEqual(RuleApplicationResultUtil.updated(1));
|
|
352
|
+
expect(await run([labelRule({ sloLabels: [LABEL_STAGING] })])).toEqual(
|
|
353
|
+
RuleApplicationResultUtil.noMatch(),
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("does not match a label prerequisite for an SLO with no labels", async () => {
|
|
358
|
+
expect(await run([labelRule({ sloLabels: [LABEL_PRODUCTION] })])).toEqual(
|
|
359
|
+
RuleApplicationResultUtil.noMatch(),
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// A rule with no criteria at all matches every SLO, like every label rule.
|
|
364
|
+
it("matches every SLO when the rule sets no criteria", async () => {
|
|
365
|
+
expect(await run([labelRule({})])).toEqual(
|
|
366
|
+
RuleApplicationResultUtil.updated(1),
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
/*
|
|
371
|
+
* SloRulePatternValidator refuses such a pattern on save; a rule written
|
|
372
|
+
* before that check must quietly match nothing, and say why in the logs.
|
|
373
|
+
*/
|
|
374
|
+
it("never matches an unsupported pattern, and warns about it", async () => {
|
|
375
|
+
const result: RuleApplicationResult = await run([
|
|
376
|
+
labelRule({ namePattern: "checkout-(01" }),
|
|
377
|
+
]);
|
|
378
|
+
|
|
379
|
+
expect(result).toEqual(RuleApplicationResultUtil.noMatch());
|
|
380
|
+
expect(writes).toHaveLength(0);
|
|
381
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
382
|
+
expect.stringContaining("checkout-(01"),
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("evaluates configurable criteria on the SLO's labels", async () => {
|
|
387
|
+
findSloSpy.mockResolvedValue(
|
|
388
|
+
sloDetails({ labels: [LABEL_PRODUCTION, LABEL_CHECKOUT] }),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const hasAll: RuleCriteria = criteria([
|
|
392
|
+
{
|
|
393
|
+
field: "serviceLevelObjectiveLabels",
|
|
394
|
+
operator: RuleCriteriaOperator.HasAllOf,
|
|
395
|
+
value: [LABEL_PRODUCTION.toString(), LABEL_CHECKOUT.toString()],
|
|
396
|
+
},
|
|
397
|
+
]);
|
|
398
|
+
const hasNone: RuleCriteria = criteria([
|
|
399
|
+
{
|
|
400
|
+
field: "serviceLevelObjectiveLabels",
|
|
401
|
+
operator: RuleCriteriaOperator.HasNoneOf,
|
|
402
|
+
value: [LABEL_PRODUCTION.toString()],
|
|
403
|
+
},
|
|
404
|
+
]);
|
|
405
|
+
|
|
406
|
+
expect(
|
|
407
|
+
await run([
|
|
408
|
+
labelRule({ criteria: hasAll, labelsToAdd: [LABEL_STAGING] }),
|
|
409
|
+
]),
|
|
410
|
+
).toEqual(RuleApplicationResultUtil.updated(1));
|
|
411
|
+
expect(
|
|
412
|
+
await run([
|
|
413
|
+
labelRule({ criteria: hasNone, labelsToAdd: [LABEL_STAGING] }),
|
|
414
|
+
]),
|
|
415
|
+
).toEqual(RuleApplicationResultUtil.noMatch());
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("combines criteria with Match any", async () => {
|
|
419
|
+
const anyOf: RuleCriteria = criteria(
|
|
420
|
+
[
|
|
421
|
+
{
|
|
422
|
+
field: "serviceLevelObjectiveNamePattern",
|
|
423
|
+
operator: RuleCriteriaOperator.StartsWith,
|
|
424
|
+
value: "Payments",
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
field: "serviceLevelObjectiveDescriptionPattern",
|
|
428
|
+
operator: RuleCriteriaOperator.Contains,
|
|
429
|
+
value: "checkout",
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
FilterCondition.Any,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
expect(await run([labelRule({ criteria: anyOf })])).toEqual(
|
|
436
|
+
RuleApplicationResultUtil.updated(1),
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("matches a wildcard through configurable criteria too", async () => {
|
|
441
|
+
const wildcard: RuleCriteria = criteria([
|
|
442
|
+
{
|
|
443
|
+
field: "serviceLevelObjectiveNamePattern",
|
|
444
|
+
operator: RuleCriteriaOperator.MatchesPattern,
|
|
445
|
+
value: "*api*",
|
|
446
|
+
},
|
|
447
|
+
]);
|
|
448
|
+
|
|
449
|
+
expect(await run([labelRule({ criteria: wildcard })])).toEqual(
|
|
450
|
+
RuleApplicationResultUtil.updated(1),
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// Criteria take over from the legacy columns entirely.
|
|
455
|
+
it("ignores stale legacy columns once the rule has criteria", async () => {
|
|
456
|
+
const onName: RuleCriteria = criteria([
|
|
457
|
+
{
|
|
458
|
+
field: "serviceLevelObjectiveNamePattern",
|
|
459
|
+
operator: RuleCriteriaOperator.Contains,
|
|
460
|
+
value: "payments",
|
|
461
|
+
},
|
|
462
|
+
]);
|
|
463
|
+
|
|
464
|
+
expect(
|
|
465
|
+
await run([labelRule({ namePattern: "checkout", criteria: onName })]),
|
|
466
|
+
).toEqual(RuleApplicationResultUtil.noMatch());
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("applying", () => {
|
|
471
|
+
it("attaches the union of every matching rule's labels, once each", async () => {
|
|
472
|
+
const result: RuleApplicationResult = await run([
|
|
473
|
+
labelRule({
|
|
474
|
+
namePattern: "checkout",
|
|
475
|
+
labelsToAdd: [LABEL_CHECKOUT, LABEL_PRODUCTION],
|
|
476
|
+
}),
|
|
477
|
+
labelRule({
|
|
478
|
+
id: OTHER_RULE_ID,
|
|
479
|
+
descriptionPattern: "tier 1",
|
|
480
|
+
labelsToAdd: [LABEL_PRODUCTION],
|
|
481
|
+
}),
|
|
482
|
+
labelRule({
|
|
483
|
+
id: new ObjectID("abababab-abab-4bab-8bab-abababababab"),
|
|
484
|
+
namePattern: "^payments",
|
|
485
|
+
labelsToAdd: [LABEL_STAGING],
|
|
486
|
+
}),
|
|
487
|
+
]);
|
|
488
|
+
|
|
489
|
+
expect(result).toEqual(RuleApplicationResultUtil.updated(2));
|
|
490
|
+
expect(writes).toHaveLength(1);
|
|
491
|
+
expect([...writes[0]!.labelIds].sort()).toEqual(
|
|
492
|
+
[LABEL_CHECKOUT.toString(), LABEL_PRODUCTION.toString()].sort(),
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Re-read, not trusted from the caller: a run only names the SLO.
|
|
497
|
+
it("re-reads the SLO's name, description and labels as root", async () => {
|
|
498
|
+
await run([labelRule({ namePattern: "checkout" })]);
|
|
499
|
+
|
|
500
|
+
expect(findSloSpy).toHaveBeenCalledWith({
|
|
501
|
+
id: SLO_ID,
|
|
502
|
+
select: { name: true, description: true, labels: { _id: true } },
|
|
503
|
+
props: { isRoot: true },
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("writes the merged labels back onto the SLO it was handed", async () => {
|
|
508
|
+
findSloSpy.mockResolvedValue(sloDetails({ labels: [LABEL_PRODUCTION] }));
|
|
509
|
+
const slo: ServiceLevelObjective = runTarget();
|
|
510
|
+
|
|
511
|
+
await run([labelRule({ namePattern: "checkout" })], slo);
|
|
512
|
+
|
|
513
|
+
expect(
|
|
514
|
+
(slo.labels || []).map((item: Label) => {
|
|
515
|
+
return item.id!.toString();
|
|
516
|
+
}),
|
|
517
|
+
).toEqual([LABEL_PRODUCTION.toString(), LABEL_CHECKOUT.toString()]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("reports no match without a read for an SLO that has since been deleted", async () => {
|
|
521
|
+
findSloSpy.mockResolvedValue(null);
|
|
522
|
+
|
|
523
|
+
expect(await run([labelRule({ namePattern: "checkout" })])).toEqual(
|
|
524
|
+
RuleApplicationResultUtil.noMatch(),
|
|
525
|
+
);
|
|
526
|
+
expect(writes).toHaveLength(0);
|
|
527
|
+
expect(feedSpy).not.toHaveBeenCalled();
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("feed item", () => {
|
|
532
|
+
it("records which rules attached how many labels, on the SLO's feed", async () => {
|
|
533
|
+
await run([
|
|
534
|
+
labelRule({
|
|
535
|
+
namePattern: "checkout",
|
|
536
|
+
labelsToAdd: [LABEL_CHECKOUT, LABEL_PRODUCTION],
|
|
537
|
+
}),
|
|
538
|
+
]);
|
|
539
|
+
|
|
540
|
+
expect(feedPayloads()).toHaveLength(1);
|
|
541
|
+
const payload: FeedPayload = feedPayloads()[0]!;
|
|
542
|
+
|
|
543
|
+
expect(payload.serviceLevelObjectiveId.toString()).toBe(
|
|
544
|
+
SLO_ID.toString(),
|
|
545
|
+
);
|
|
546
|
+
expect(payload.projectId.toString()).toBe(PROJECT_ID.toString());
|
|
547
|
+
expect(payload.serviceLevelObjectiveFeedEventType).toBe(
|
|
548
|
+
ServiceLevelObjectiveFeedEventType.LabelRuleExecuted,
|
|
549
|
+
);
|
|
550
|
+
expect(payload.displayColor).toBe(Purple500);
|
|
551
|
+
expect(payload.feedInfoInMarkdown).toBe(
|
|
552
|
+
`🏷️ 2 label(s) were attached to ${SLO_LINK} by label rule.`,
|
|
553
|
+
);
|
|
554
|
+
expect(payload.moreInformationInMarkdown).toBe(
|
|
555
|
+
"**Label rules that matched**: **Tag checkout SLOs**",
|
|
556
|
+
);
|
|
557
|
+
// Rules act on their own, never in anybody's name.
|
|
558
|
+
expect(payload.userId).toBeUndefined();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// The link reuses the name the engine just read instead of reading again.
|
|
562
|
+
it("builds the SLO link from the SLO it re-read", async () => {
|
|
563
|
+
await run([labelRule({ namePattern: "checkout" })]);
|
|
564
|
+
|
|
565
|
+
expect(linkSpy).toHaveBeenCalledWith({
|
|
566
|
+
projectId: PROJECT_ID,
|
|
567
|
+
sloId: SLO_ID,
|
|
568
|
+
sloName: SLO_NAME,
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("names every rule that added a label, and only those", async () => {
|
|
573
|
+
await run([
|
|
574
|
+
labelRule({ name: "First", namePattern: "checkout" }),
|
|
575
|
+
labelRule({
|
|
576
|
+
id: OTHER_RULE_ID,
|
|
577
|
+
name: "Second",
|
|
578
|
+
namePattern: "checkout",
|
|
579
|
+
labelsToAdd: [LABEL_PRODUCTION],
|
|
580
|
+
}),
|
|
581
|
+
// Matches, but has nothing to add.
|
|
582
|
+
labelRule({
|
|
583
|
+
id: new ObjectID("abababab-abab-4bab-8bab-abababababab"),
|
|
584
|
+
name: "Empty",
|
|
585
|
+
namePattern: "checkout",
|
|
586
|
+
labelsToAdd: [],
|
|
587
|
+
}),
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
const payload: FeedPayload = feedPayloads()[0]!;
|
|
591
|
+
expect(payload.feedInfoInMarkdown).toContain("by label rules.");
|
|
592
|
+
expect(payload.moreInformationInMarkdown).toBe(
|
|
593
|
+
"**Label rules that matched**: **First**, **Second**",
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// The feed renders markdown without safe mode, and a rule name is typed by a user.
|
|
598
|
+
it("escapes rule names", async () => {
|
|
599
|
+
await run([
|
|
600
|
+
labelRule({
|
|
601
|
+
name: " **bold**",
|
|
602
|
+
namePattern: "checkout",
|
|
603
|
+
}),
|
|
604
|
+
]);
|
|
605
|
+
|
|
606
|
+
const more: string = feedPayloads()[0]!.moreInformationInMarkdown!;
|
|
607
|
+
expect(more).toBe(
|
|
608
|
+
"**Label rules that matched**: **\\!\\[x\\]\\(https://tracker.example/p.png\\) \\*\\*bold\\*\\***",
|
|
609
|
+
);
|
|
610
|
+
expect(more).not.toContain(";
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("records nothing when every label was already there", async () => {
|
|
614
|
+
findSloSpy.mockResolvedValue(sloDetails({ labels: [LABEL_CHECKOUT] }));
|
|
615
|
+
|
|
616
|
+
expect(await run([labelRule({ namePattern: "checkout" })])).toEqual(
|
|
617
|
+
RuleApplicationResultUtil.alreadyApplied(),
|
|
618
|
+
);
|
|
619
|
+
expect(feedSpy).not.toHaveBeenCalled();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
/*
|
|
623
|
+
* The labels are attached before the feed item is written: a failing feed
|
|
624
|
+
* must not make a run report an SLO it changed as one it failed on.
|
|
625
|
+
*/
|
|
626
|
+
it("still reports the SLO as updated when the feed item cannot be written", async () => {
|
|
627
|
+
linkSpy.mockRejectedValue(new Error("database is down"));
|
|
628
|
+
|
|
629
|
+
const result: RuleApplicationResult = await run([
|
|
630
|
+
labelRule({ namePattern: "checkout" }),
|
|
631
|
+
]);
|
|
632
|
+
|
|
633
|
+
expect(result).toEqual(RuleApplicationResultUtil.updated(1));
|
|
634
|
+
expect(writes).toHaveLength(1);
|
|
635
|
+
expect(feedSpy).not.toHaveBeenCalled();
|
|
636
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
637
|
+
expect(String(errorSpy.mock.calls[0]![0])).toContain(
|
|
638
|
+
"label rule feed item",
|
|
639
|
+
);
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
describe("create hook", () => {
|
|
644
|
+
it("evaluates the project's enabled rules, bounded, as root", async () => {
|
|
645
|
+
const findRules: AsyncSpy = jest
|
|
646
|
+
.spyOn(ServiceLevelObjectiveLabelRuleService, "findBy")
|
|
647
|
+
.mockResolvedValue([labelRule({ namePattern: "checkout" })]);
|
|
648
|
+
|
|
649
|
+
await ServiceLevelObjectiveLabelRuleEngineService.applyRulesToServiceLevelObjective(
|
|
650
|
+
runTarget(),
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
expect(findRules).toHaveBeenCalledWith({
|
|
654
|
+
query: { projectId: PROJECT_ID, isEnabled: true },
|
|
655
|
+
props: { isRoot: true },
|
|
656
|
+
select: ServiceLevelObjectiveLabelRuleEngineService.ruleSelect,
|
|
657
|
+
limit: MAX_RULES_EVALUATED_PER_PROJECT,
|
|
658
|
+
skip: 0,
|
|
659
|
+
});
|
|
660
|
+
expect(writes).toHaveLength(1);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("reads nothing for an SLO without an id or project", async () => {
|
|
664
|
+
const findRules: AsyncSpy = jest
|
|
665
|
+
.spyOn(ServiceLevelObjectiveLabelRuleService, "findBy")
|
|
666
|
+
.mockResolvedValue([]);
|
|
667
|
+
|
|
668
|
+
await ServiceLevelObjectiveLabelRuleEngineService.applyRulesToServiceLevelObjective(
|
|
669
|
+
new ServiceLevelObjective(),
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
expect(findRules).not.toHaveBeenCalled();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("never throws into the create, and logs why", async () => {
|
|
676
|
+
jest
|
|
677
|
+
.spyOn(ServiceLevelObjectiveLabelRuleService, "findBy")
|
|
678
|
+
.mockRejectedValue(new Error("database is down"));
|
|
679
|
+
|
|
680
|
+
await expect(
|
|
681
|
+
ServiceLevelObjectiveLabelRuleEngineService.applyRulesToServiceLevelObjective(
|
|
682
|
+
runTarget(),
|
|
683
|
+
),
|
|
684
|
+
).resolves.toBeUndefined();
|
|
685
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
describe("ServiceLevelObjectiveOwnerRuleEngineService", () => {
|
|
691
|
+
interface OwnerWrite {
|
|
692
|
+
kind: "user" | "team";
|
|
693
|
+
ownerId: string;
|
|
694
|
+
serviceLevelObjectiveId: string;
|
|
695
|
+
projectId: string;
|
|
696
|
+
isOwnerNotified: boolean | undefined;
|
|
697
|
+
props: Record<string, unknown>;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
let ownerWrites: Array<OwnerWrite>;
|
|
701
|
+
let existingUserIds: Array<ObjectID>;
|
|
702
|
+
let existingTeamIds: Array<ObjectID>;
|
|
703
|
+
let userCreate: AsyncSpy;
|
|
704
|
+
let teamCreate: AsyncSpy;
|
|
705
|
+
|
|
706
|
+
beforeEach(() => {
|
|
707
|
+
ownerWrites = [];
|
|
708
|
+
existingUserIds = [];
|
|
709
|
+
existingTeamIds = [];
|
|
710
|
+
|
|
711
|
+
jest
|
|
712
|
+
.spyOn(ServiceLevelObjectiveOwnerUserService, "findBy")
|
|
713
|
+
.mockImplementation((async () => {
|
|
714
|
+
return existingUserIds.map((id: ObjectID) => {
|
|
715
|
+
const row: ServiceLevelObjectiveOwnerUser =
|
|
716
|
+
new ServiceLevelObjectiveOwnerUser();
|
|
717
|
+
row.userId = id;
|
|
718
|
+
return row;
|
|
719
|
+
});
|
|
720
|
+
}) as never);
|
|
721
|
+
jest
|
|
722
|
+
.spyOn(ServiceLevelObjectiveOwnerTeamService, "findBy")
|
|
723
|
+
.mockImplementation((async () => {
|
|
724
|
+
return existingTeamIds.map((id: ObjectID) => {
|
|
725
|
+
const row: ServiceLevelObjectiveOwnerTeam =
|
|
726
|
+
new ServiceLevelObjectiveOwnerTeam();
|
|
727
|
+
row.teamId = id;
|
|
728
|
+
return row;
|
|
729
|
+
});
|
|
730
|
+
}) as never);
|
|
731
|
+
|
|
732
|
+
userCreate = jest
|
|
733
|
+
.spyOn(ServiceLevelObjectiveOwnerUserService, "create")
|
|
734
|
+
.mockImplementation((async (data: {
|
|
735
|
+
data: ServiceLevelObjectiveOwnerUser;
|
|
736
|
+
props: Record<string, unknown>;
|
|
737
|
+
}) => {
|
|
738
|
+
ownerWrites.push({
|
|
739
|
+
kind: "user",
|
|
740
|
+
ownerId: data.data.userId!.toString(),
|
|
741
|
+
serviceLevelObjectiveId:
|
|
742
|
+
data.data.serviceLevelObjectiveId!.toString(),
|
|
743
|
+
projectId: data.data.projectId!.toString(),
|
|
744
|
+
isOwnerNotified: data.data.isOwnerNotified,
|
|
745
|
+
props: data.props,
|
|
746
|
+
});
|
|
747
|
+
return data.data;
|
|
748
|
+
}) as never);
|
|
749
|
+
teamCreate = jest
|
|
750
|
+
.spyOn(ServiceLevelObjectiveOwnerTeamService, "create")
|
|
751
|
+
.mockImplementation((async (data: {
|
|
752
|
+
data: ServiceLevelObjectiveOwnerTeam;
|
|
753
|
+
props: Record<string, unknown>;
|
|
754
|
+
}) => {
|
|
755
|
+
ownerWrites.push({
|
|
756
|
+
kind: "team",
|
|
757
|
+
ownerId: data.data.teamId!.toString(),
|
|
758
|
+
serviceLevelObjectiveId:
|
|
759
|
+
data.data.serviceLevelObjectiveId!.toString(),
|
|
760
|
+
projectId: data.data.projectId!.toString(),
|
|
761
|
+
isOwnerNotified: data.data.isOwnerNotified,
|
|
762
|
+
props: data.props,
|
|
763
|
+
});
|
|
764
|
+
return data.data;
|
|
765
|
+
}) as never);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
function run(
|
|
769
|
+
rules: Array<ServiceLevelObjectiveOwnerRule>,
|
|
770
|
+
allowOwnerNotification: boolean = true,
|
|
771
|
+
): Promise<RuleApplicationResult> {
|
|
772
|
+
return ServiceLevelObjectiveOwnerRuleEngineService.applyRulesToExistingResource(
|
|
773
|
+
{
|
|
774
|
+
resource: runTarget(),
|
|
775
|
+
rules: rules,
|
|
776
|
+
allowOwnerNotification: allowOwnerNotification,
|
|
777
|
+
},
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// The same refusal SloOwnerReferenceValidator gives a user outside the project.
|
|
782
|
+
function refuseUser(userId: ObjectID): void {
|
|
783
|
+
userCreate.mockImplementation((async (data: {
|
|
784
|
+
data: ServiceLevelObjectiveOwnerUser;
|
|
785
|
+
props: Record<string, unknown>;
|
|
786
|
+
}) => {
|
|
787
|
+
if (data.data.userId!.toString() === userId.toString()) {
|
|
788
|
+
throw new BadDataException(
|
|
789
|
+
`This SLO owner user names a user who is not a member of this project: "${userId.toString()}". Please pick a user from this project and try again.`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
ownerWrites.push({
|
|
794
|
+
kind: "user",
|
|
795
|
+
ownerId: data.data.userId!.toString(),
|
|
796
|
+
serviceLevelObjectiveId: data.data.serviceLevelObjectiveId!.toString(),
|
|
797
|
+
projectId: data.data.projectId!.toString(),
|
|
798
|
+
isOwnerNotified: data.data.isOwnerNotified,
|
|
799
|
+
props: data.props,
|
|
800
|
+
});
|
|
801
|
+
return data.data;
|
|
802
|
+
}) as never);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
describe("adding owners", () => {
|
|
806
|
+
it("adds the rule's users and teams to the SLO, as root", async () => {
|
|
807
|
+
const result: RuleApplicationResult = await run([
|
|
808
|
+
ownerRule({
|
|
809
|
+
namePattern: "checkout",
|
|
810
|
+
users: [USER_A, USER_B],
|
|
811
|
+
teams: [TEAM_A],
|
|
812
|
+
}),
|
|
813
|
+
]);
|
|
814
|
+
|
|
815
|
+
expect(result).toEqual(RuleApplicationResultUtil.updated(3));
|
|
816
|
+
expect(
|
|
817
|
+
ownerWrites.map((write: OwnerWrite) => {
|
|
818
|
+
return [write.kind, write.ownerId];
|
|
819
|
+
}),
|
|
820
|
+
).toEqual([
|
|
821
|
+
["user", USER_A.toString()],
|
|
822
|
+
["user", USER_B.toString()],
|
|
823
|
+
["team", TEAM_A.toString()],
|
|
824
|
+
]);
|
|
825
|
+
|
|
826
|
+
for (const write of ownerWrites) {
|
|
827
|
+
expect(write.serviceLevelObjectiveId).toBe(SLO_ID.toString());
|
|
828
|
+
expect(write.projectId).toBe(PROJECT_ID.toString());
|
|
829
|
+
expect(write.props).toEqual({ isRoot: true });
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("skips owners the SLO already has", async () => {
|
|
834
|
+
existingUserIds = [USER_A];
|
|
835
|
+
existingTeamIds = [TEAM_A];
|
|
836
|
+
|
|
837
|
+
const result: RuleApplicationResult = await run([
|
|
838
|
+
ownerRule({
|
|
839
|
+
namePattern: "checkout",
|
|
840
|
+
users: [USER_A, USER_B],
|
|
841
|
+
teams: [TEAM_A],
|
|
842
|
+
}),
|
|
843
|
+
]);
|
|
844
|
+
|
|
845
|
+
expect(result).toEqual(RuleApplicationResultUtil.updated(1));
|
|
846
|
+
expect(
|
|
847
|
+
ownerWrites.map((write: OwnerWrite) => {
|
|
848
|
+
return write.ownerId;
|
|
849
|
+
}),
|
|
850
|
+
).toEqual([USER_B.toString()]);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("reports already applied when every owner is already there", async () => {
|
|
854
|
+
existingUserIds = [USER_A];
|
|
855
|
+
|
|
856
|
+
expect(
|
|
857
|
+
await run([ownerRule({ namePattern: "checkout", users: [USER_A] })]),
|
|
858
|
+
).toEqual(RuleApplicationResultUtil.alreadyApplied());
|
|
859
|
+
expect(ownerWrites).toHaveLength(0);
|
|
860
|
+
expect(feedSpy).not.toHaveBeenCalled();
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// Two rules naming the same owner add one row, not two.
|
|
864
|
+
it("adds an owner two matching rules share only once", async () => {
|
|
865
|
+
const result: RuleApplicationResult = await run([
|
|
866
|
+
ownerRule({ namePattern: "checkout", users: [USER_A] }),
|
|
867
|
+
ownerRule({
|
|
868
|
+
id: OTHER_RULE_ID,
|
|
869
|
+
descriptionPattern: "tier 1",
|
|
870
|
+
users: [USER_A],
|
|
871
|
+
}),
|
|
872
|
+
]);
|
|
873
|
+
|
|
874
|
+
expect(result).toEqual(RuleApplicationResultUtil.updated(1));
|
|
875
|
+
expect(ownerWrites).toHaveLength(1);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("adds nothing for a rule that does not match", async () => {
|
|
879
|
+
expect(
|
|
880
|
+
await run([ownerRule({ namePattern: "^payments", users: [USER_A] })]),
|
|
881
|
+
).toEqual(RuleApplicationResultUtil.noMatch());
|
|
882
|
+
expect(ownerWrites).toHaveLength(0);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// Lost a race with another writer: the unique index says it is already there.
|
|
886
|
+
it("treats a unique-index rejection as an owner already added", async () => {
|
|
887
|
+
userCreate.mockRejectedValue(
|
|
888
|
+
Object.assign(new Error("duplicate key value"), {
|
|
889
|
+
postgresErrorCode: "23505",
|
|
890
|
+
}),
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
expect(
|
|
894
|
+
await run([ownerRule({ namePattern: "checkout", users: [USER_A] })]),
|
|
895
|
+
).toEqual(RuleApplicationResultUtil.alreadyApplied());
|
|
896
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
describe("owners the SLO owner services refuse", () => {
|
|
901
|
+
it("skips a user who is no longer a project member and still adds the rest", async () => {
|
|
902
|
+
refuseUser(USER_A);
|
|
903
|
+
|
|
904
|
+
const result: RuleApplicationResult = await run([
|
|
905
|
+
ownerRule({
|
|
906
|
+
namePattern: "checkout",
|
|
907
|
+
users: [USER_A, USER_B],
|
|
908
|
+
teams: [TEAM_A],
|
|
909
|
+
}),
|
|
910
|
+
]);
|
|
911
|
+
|
|
912
|
+
expect(result).toEqual(RuleApplicationResultUtil.updated(2));
|
|
913
|
+
expect(
|
|
914
|
+
ownerWrites.map((write: OwnerWrite) => {
|
|
915
|
+
return write.ownerId;
|
|
916
|
+
}),
|
|
917
|
+
).toEqual([USER_B.toString(), TEAM_A.toString()]);
|
|
918
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
919
|
+
expect(String(warnSpy.mock.calls[0]![0])).toContain(
|
|
920
|
+
"not a member of this project",
|
|
921
|
+
);
|
|
922
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
/*
|
|
926
|
+
* Nothing was added, so "already applied" would claim the SLO has owners
|
|
927
|
+
* it does not. The run counts it as an SLO it could not update.
|
|
928
|
+
*/
|
|
929
|
+
it("reports the SLO as failed when every owner was refused", async () => {
|
|
930
|
+
refuseUser(USER_A);
|
|
931
|
+
|
|
932
|
+
expect(
|
|
933
|
+
await run([ownerRule({ namePattern: "checkout", users: [USER_A] })]),
|
|
934
|
+
).toEqual(RuleApplicationResultUtil.failed());
|
|
935
|
+
expect(ownerWrites).toHaveLength(0);
|
|
936
|
+
expect(feedSpy).not.toHaveBeenCalled();
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it("reports any other write failure as failed, and logs it", async () => {
|
|
940
|
+
teamCreate.mockRejectedValue(new Error("database is down"));
|
|
941
|
+
|
|
942
|
+
expect(
|
|
943
|
+
await run([ownerRule({ namePattern: "checkout", teams: [TEAM_A] })]),
|
|
944
|
+
).toEqual(RuleApplicationResultUtil.failed());
|
|
945
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
946
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
describe("notifications", () => {
|
|
951
|
+
/*
|
|
952
|
+
* isOwnerNotified is "the owner-added job already told them": false means
|
|
953
|
+
* the job will notify, true means the owner is added silently.
|
|
954
|
+
*/
|
|
955
|
+
function notifiedByOwner(): Record<string, boolean | undefined> {
|
|
956
|
+
const result: Record<string, boolean | undefined> = {};
|
|
957
|
+
|
|
958
|
+
for (const write of ownerWrites) {
|
|
959
|
+
result[write.ownerId] = write.isOwnerNotified;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return result;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
it("leaves owners to be notified when the rule asks for it (the default)", async () => {
|
|
966
|
+
await run([
|
|
967
|
+
ownerRule({
|
|
968
|
+
namePattern: "checkout",
|
|
969
|
+
users: [USER_A],
|
|
970
|
+
teams: [TEAM_A],
|
|
971
|
+
}),
|
|
972
|
+
]);
|
|
973
|
+
|
|
974
|
+
expect(notifiedByOwner()).toEqual({
|
|
975
|
+
[USER_A.toString()]: false,
|
|
976
|
+
[TEAM_A.toString()]: false,
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it("adds owners silently when the rule turns Notify Owners off", async () => {
|
|
981
|
+
await run([
|
|
982
|
+
ownerRule({
|
|
983
|
+
namePattern: "checkout",
|
|
984
|
+
users: [USER_A],
|
|
985
|
+
teams: [TEAM_A],
|
|
986
|
+
notifyOwners: false,
|
|
987
|
+
}),
|
|
988
|
+
]);
|
|
989
|
+
|
|
990
|
+
expect(notifiedByOwner()).toEqual({
|
|
991
|
+
[USER_A.toString()]: true,
|
|
992
|
+
[TEAM_A.toString()]: true,
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// A run over every SLO in the project notifies nobody unless asked to.
|
|
997
|
+
it("adds owners silently in a run that did not opt in, whatever the rule says", async () => {
|
|
998
|
+
await run(
|
|
999
|
+
[
|
|
1000
|
+
ownerRule({
|
|
1001
|
+
namePattern: "checkout",
|
|
1002
|
+
users: [USER_A],
|
|
1003
|
+
notifyOwners: true,
|
|
1004
|
+
}),
|
|
1005
|
+
],
|
|
1006
|
+
false,
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
expect(notifiedByOwner()).toEqual({ [USER_A.toString()]: true });
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("notifies an owner once when a notifying and a silent rule both name them", async () => {
|
|
1013
|
+
await run([
|
|
1014
|
+
ownerRule({
|
|
1015
|
+
namePattern: "checkout",
|
|
1016
|
+
users: [USER_A],
|
|
1017
|
+
notifyOwners: false,
|
|
1018
|
+
}),
|
|
1019
|
+
ownerRule({
|
|
1020
|
+
id: OTHER_RULE_ID,
|
|
1021
|
+
namePattern: "checkout",
|
|
1022
|
+
users: [USER_A],
|
|
1023
|
+
notifyOwners: true,
|
|
1024
|
+
}),
|
|
1025
|
+
]);
|
|
1026
|
+
|
|
1027
|
+
expect(ownerWrites).toHaveLength(1);
|
|
1028
|
+
expect(notifiedByOwner()).toEqual({ [USER_A.toString()]: false });
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe("feed item", () => {
|
|
1033
|
+
it("records which rules added owners, on the SLO's feed", async () => {
|
|
1034
|
+
await run([
|
|
1035
|
+
ownerRule({
|
|
1036
|
+
name: "Checkout team owns checkout SLOs",
|
|
1037
|
+
namePattern: "checkout",
|
|
1038
|
+
teams: [TEAM_A],
|
|
1039
|
+
}),
|
|
1040
|
+
]);
|
|
1041
|
+
|
|
1042
|
+
expect(feedPayloads()).toHaveLength(1);
|
|
1043
|
+
const payload: FeedPayload = feedPayloads()[0]!;
|
|
1044
|
+
|
|
1045
|
+
expect(payload.serviceLevelObjectiveId.toString()).toBe(
|
|
1046
|
+
SLO_ID.toString(),
|
|
1047
|
+
);
|
|
1048
|
+
expect(payload.serviceLevelObjectiveFeedEventType).toBe(
|
|
1049
|
+
ServiceLevelObjectiveFeedEventType.OwnerRuleExecuted,
|
|
1050
|
+
);
|
|
1051
|
+
expect(payload.displayColor).toBe(Purple500);
|
|
1052
|
+
expect(payload.feedInfoInMarkdown).toBe(
|
|
1053
|
+
`👥 Owners were added to ${SLO_LINK} by 1 owner rule.`,
|
|
1054
|
+
);
|
|
1055
|
+
expect(payload.moreInformationInMarkdown).toBe(
|
|
1056
|
+
"**Owner rules that matched**: **Checkout team owns checkout SLOs**",
|
|
1057
|
+
);
|
|
1058
|
+
expect(payload.userId).toBeUndefined();
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it("counts and escapes every rule that named an owner", async () => {
|
|
1062
|
+
await run([
|
|
1063
|
+
ownerRule({ name: "[a](b)", namePattern: "checkout", users: [USER_A] }),
|
|
1064
|
+
ownerRule({
|
|
1065
|
+
id: OTHER_RULE_ID,
|
|
1066
|
+
name: "Plain",
|
|
1067
|
+
namePattern: "checkout",
|
|
1068
|
+
users: [USER_B],
|
|
1069
|
+
}),
|
|
1070
|
+
]);
|
|
1071
|
+
|
|
1072
|
+
const payload: FeedPayload = feedPayloads()[0]!;
|
|
1073
|
+
expect(payload.feedInfoInMarkdown).toContain("by 2 owner rules.");
|
|
1074
|
+
expect(payload.moreInformationInMarkdown).toBe(
|
|
1075
|
+
"**Owner rules that matched**: **\\[a\\]\\(b\\)**, **Plain**",
|
|
1076
|
+
);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it("still reports the SLO as updated when the feed item cannot be written", async () => {
|
|
1080
|
+
linkSpy.mockRejectedValue(new Error("database is down"));
|
|
1081
|
+
|
|
1082
|
+
expect(
|
|
1083
|
+
await run([ownerRule({ namePattern: "checkout", users: [USER_A] })]),
|
|
1084
|
+
).toEqual(RuleApplicationResultUtil.updated(1));
|
|
1085
|
+
expect(ownerWrites).toHaveLength(1);
|
|
1086
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
1087
|
+
expect(String(errorSpy.mock.calls[0]![0])).toContain(
|
|
1088
|
+
"owner rule feed item",
|
|
1089
|
+
);
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
describe("matching", () => {
|
|
1094
|
+
it("matches the SLO's labels, name and description like the label rules", async () => {
|
|
1095
|
+
findSloSpy.mockResolvedValue(sloDetails({ labels: [LABEL_PRODUCTION] }));
|
|
1096
|
+
|
|
1097
|
+
expect(
|
|
1098
|
+
await run([
|
|
1099
|
+
ownerRule({
|
|
1100
|
+
sloLabels: [LABEL_PRODUCTION],
|
|
1101
|
+
namePattern: "*checkout*",
|
|
1102
|
+
descriptionPattern: "tier 1",
|
|
1103
|
+
users: [USER_A],
|
|
1104
|
+
}),
|
|
1105
|
+
]),
|
|
1106
|
+
).toEqual(RuleApplicationResultUtil.updated(1));
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it("never matches an unsupported pattern, and warns about it", async () => {
|
|
1110
|
+
expect(
|
|
1111
|
+
await run([ownerRule({ namePattern: "[unclosed", users: [USER_A] })]),
|
|
1112
|
+
).toEqual(RuleApplicationResultUtil.noMatch());
|
|
1113
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1114
|
+
expect.stringContaining("[unclosed"),
|
|
1115
|
+
);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it("evaluates configurable criteria", async () => {
|
|
1119
|
+
const onDescription: RuleCriteria = criteria([
|
|
1120
|
+
{
|
|
1121
|
+
field: "serviceLevelObjectiveDescriptionPattern",
|
|
1122
|
+
operator: RuleCriteriaOperator.DoesNotContain,
|
|
1123
|
+
value: "internal",
|
|
1124
|
+
},
|
|
1125
|
+
]);
|
|
1126
|
+
|
|
1127
|
+
expect(
|
|
1128
|
+
await run([ownerRule({ criteria: onDescription, users: [USER_A] })]),
|
|
1129
|
+
).toEqual(RuleApplicationResultUtil.updated(1));
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
describe("create hook", () => {
|
|
1134
|
+
it("evaluates the project's enabled rules and notifies the owners it adds", async () => {
|
|
1135
|
+
const findRules: AsyncSpy = jest
|
|
1136
|
+
.spyOn(ServiceLevelObjectiveOwnerRuleService, "findBy")
|
|
1137
|
+
.mockResolvedValue([
|
|
1138
|
+
ownerRule({ namePattern: "checkout", users: [USER_A] }),
|
|
1139
|
+
]);
|
|
1140
|
+
|
|
1141
|
+
await ServiceLevelObjectiveOwnerRuleEngineService.applyRulesToServiceLevelObjective(
|
|
1142
|
+
runTarget(),
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
expect(findRules).toHaveBeenCalledWith({
|
|
1146
|
+
query: { projectId: PROJECT_ID, isEnabled: true },
|
|
1147
|
+
props: { isRoot: true },
|
|
1148
|
+
select: ServiceLevelObjectiveOwnerRuleEngineService.ruleSelect,
|
|
1149
|
+
limit: MAX_RULES_EVALUATED_PER_PROJECT,
|
|
1150
|
+
skip: 0,
|
|
1151
|
+
});
|
|
1152
|
+
expect(ownerWrites).toHaveLength(1);
|
|
1153
|
+
// A create is not a bulk run: the rule's own Notify Owners decides.
|
|
1154
|
+
expect(ownerWrites[0]!.isOwnerNotified).toBe(false);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it("never throws into the create, and logs why", async () => {
|
|
1158
|
+
jest
|
|
1159
|
+
.spyOn(ServiceLevelObjectiveOwnerRuleService, "findBy")
|
|
1160
|
+
.mockRejectedValue(new Error("database is down"));
|
|
1161
|
+
|
|
1162
|
+
await expect(
|
|
1163
|
+
ServiceLevelObjectiveOwnerRuleEngineService.applyRulesToServiceLevelObjective(
|
|
1164
|
+
runTarget(),
|
|
1165
|
+
),
|
|
1166
|
+
).resolves.toBeUndefined();
|
|
1167
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
1168
|
+
});
|
|
1169
|
+
});
|
|
1170
|
+
});
|