@checkstack/slo-backend 0.3.5 → 0.4.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/CHANGELOG.md +75 -0
- package/package.json +22 -20
- package/src/index.ts +14 -0
- package/src/slo-gitops-kinds.test.ts +238 -0
- package/src/slo-gitops-kinds.ts +174 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,80 @@
|
|
|
1
1
|
# @checkstack/slo-backend
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [7c97b43]
|
|
8
|
+
- Updated dependencies [9016526]
|
|
9
|
+
- @checkstack/healthcheck-backend@1.1.0
|
|
10
|
+
- @checkstack/common@0.10.0
|
|
11
|
+
- @checkstack/catalog-common@2.2.0
|
|
12
|
+
- @checkstack/healthcheck-common@1.1.0
|
|
13
|
+
- @checkstack/dependency-common@1.1.0
|
|
14
|
+
- @checkstack/slo-common@0.4.0
|
|
15
|
+
- @checkstack/gitops-common@0.4.0
|
|
16
|
+
- @checkstack/integration-common@0.4.0
|
|
17
|
+
- @checkstack/backend-api@0.15.2
|
|
18
|
+
- @checkstack/catalog-backend@1.1.1
|
|
19
|
+
- @checkstack/command-backend@0.1.26
|
|
20
|
+
- @checkstack/gitops-backend@0.3.1
|
|
21
|
+
- @checkstack/integration-backend@0.1.26
|
|
22
|
+
- @checkstack/signal-common@0.2.3
|
|
23
|
+
- @checkstack/cache-api@0.3.1
|
|
24
|
+
- @checkstack/queue-api@0.3.1
|
|
25
|
+
- @checkstack/cache-utils@0.2.6
|
|
26
|
+
|
|
27
|
+
## 0.4.0
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- f6f9a5c: Add a GitOps `SLO` kind so reliability targets can be declared in YAML.
|
|
32
|
+
|
|
33
|
+
The kind references its target system via `systemRef` and may optionally
|
|
34
|
+
narrow to a single healthcheck via `healthcheckRef`. Excluded
|
|
35
|
+
dependencies are referenced by ref and resolved to system IDs at
|
|
36
|
+
reconcile time.
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
apiVersion: checkstack.io/v1alpha1
|
|
40
|
+
kind: SLO
|
|
41
|
+
metadata:
|
|
42
|
+
name: payments-availability
|
|
43
|
+
spec:
|
|
44
|
+
systemRef: { kind: System, name: payments-api }
|
|
45
|
+
target: 99.9
|
|
46
|
+
windowDays: 30
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Reconcile maps to `SloService.createObjective` /
|
|
50
|
+
`updateObjective` / `deleteObjective`; the entity ID stored in
|
|
51
|
+
provenance is the SLO objective UUID, so renames in YAML preserve
|
|
52
|
+
identity.
|
|
53
|
+
|
|
54
|
+
### Patch Changes
|
|
55
|
+
|
|
56
|
+
- Updated dependencies [42abfff]
|
|
57
|
+
- Updated dependencies [f6f9a5c]
|
|
58
|
+
- Updated dependencies [1ef2e79]
|
|
59
|
+
- Updated dependencies [aa89bc5]
|
|
60
|
+
- @checkstack/common@0.9.0
|
|
61
|
+
- @checkstack/gitops-common@0.3.0
|
|
62
|
+
- @checkstack/gitops-backend@0.3.0
|
|
63
|
+
- @checkstack/catalog-common@2.1.0
|
|
64
|
+
- @checkstack/catalog-backend@1.1.0
|
|
65
|
+
- @checkstack/queue-api@0.3.0
|
|
66
|
+
- @checkstack/cache-api@0.3.0
|
|
67
|
+
- @checkstack/backend-api@0.15.1
|
|
68
|
+
- @checkstack/command-backend@0.1.25
|
|
69
|
+
- @checkstack/dependency-common@1.0.2
|
|
70
|
+
- @checkstack/healthcheck-backend@1.0.4
|
|
71
|
+
- @checkstack/healthcheck-common@1.0.2
|
|
72
|
+
- @checkstack/integration-backend@0.1.25
|
|
73
|
+
- @checkstack/integration-common@0.3.2
|
|
74
|
+
- @checkstack/signal-common@0.2.2
|
|
75
|
+
- @checkstack/slo-common@0.3.3
|
|
76
|
+
- @checkstack/cache-utils@0.2.5
|
|
77
|
+
|
|
3
78
|
## 0.3.5
|
|
4
79
|
|
|
5
80
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/slo-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,30 +14,32 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.
|
|
18
|
-
"@checkstack/cache-api": "0.
|
|
19
|
-
"@checkstack/cache-utils": "0.2.
|
|
20
|
-
"@checkstack/slo-common": "0.3.
|
|
21
|
-
"@checkstack/healthcheck-common": "1.0.
|
|
22
|
-
"@checkstack/healthcheck-backend": "1.0.
|
|
23
|
-
"@checkstack/dependency-common": "1.0.
|
|
24
|
-
"@checkstack/catalog-common": "2.
|
|
25
|
-
"@checkstack/catalog-backend": "1.0
|
|
26
|
-
"@checkstack/command-backend": "0.1.
|
|
27
|
-
"@checkstack/signal-common": "0.2.
|
|
28
|
-
"@checkstack/integration-backend": "0.1.
|
|
29
|
-
"@checkstack/integration-common": "0.3.
|
|
30
|
-
"@checkstack/
|
|
31
|
-
"@checkstack/
|
|
17
|
+
"@checkstack/backend-api": "0.15.1",
|
|
18
|
+
"@checkstack/cache-api": "0.3.0",
|
|
19
|
+
"@checkstack/cache-utils": "0.2.5",
|
|
20
|
+
"@checkstack/slo-common": "0.3.3",
|
|
21
|
+
"@checkstack/healthcheck-common": "1.0.2",
|
|
22
|
+
"@checkstack/healthcheck-backend": "1.0.4",
|
|
23
|
+
"@checkstack/dependency-common": "1.0.2",
|
|
24
|
+
"@checkstack/catalog-common": "2.1.0",
|
|
25
|
+
"@checkstack/catalog-backend": "1.1.0",
|
|
26
|
+
"@checkstack/command-backend": "0.1.25",
|
|
27
|
+
"@checkstack/signal-common": "0.2.2",
|
|
28
|
+
"@checkstack/integration-backend": "0.1.25",
|
|
29
|
+
"@checkstack/integration-common": "0.3.2",
|
|
30
|
+
"@checkstack/gitops-backend": "0.3.0",
|
|
31
|
+
"@checkstack/gitops-common": "0.3.0",
|
|
32
|
+
"@checkstack/common": "0.9.0",
|
|
33
|
+
"@checkstack/queue-api": "0.3.0",
|
|
32
34
|
"drizzle-orm": "^0.45.0",
|
|
33
35
|
"zod": "^4.2.1",
|
|
34
36
|
"@orpc/server": "^1.13.2"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
|
-
"@checkstack/drizzle-helper": "0.0.
|
|
38
|
-
"@checkstack/scripts": "0.1
|
|
39
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
40
|
-
"@checkstack/tsconfig": "0.0.
|
|
39
|
+
"@checkstack/drizzle-helper": "0.0.5",
|
|
40
|
+
"@checkstack/scripts": "0.3.1",
|
|
41
|
+
"@checkstack/test-utils-backend": "0.1.25",
|
|
42
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
41
43
|
"@types/bun": "^1.0.0",
|
|
42
44
|
"drizzle-kit": "^0.31.10",
|
|
43
45
|
"typescript": "^5.0.0"
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ import { sloHooks } from "./hooks";
|
|
|
24
24
|
import { setupDailySnapshotJob } from "./streak-calculator";
|
|
25
25
|
import { setupWeeklyDigestJob } from "./weekly-digest";
|
|
26
26
|
import { evaluateAchievements } from "./achievement-evaluator";
|
|
27
|
+
import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
|
|
28
|
+
import { registerSloGitOpsKinds } from "./slo-gitops-kinds";
|
|
27
29
|
|
|
28
30
|
// =============================================================================
|
|
29
31
|
// Integration Event Payload Schemas
|
|
@@ -168,6 +170,17 @@ export default createBackendPlugin({
|
|
|
168
170
|
|
|
169
171
|
// Shared references across init/afterPluginsReady (maintenance-backend pattern)
|
|
170
172
|
let sharedEngine: SloEngine;
|
|
173
|
+
let gitopsService: SloService | undefined;
|
|
174
|
+
|
|
175
|
+
// ─── GitOps Entity Kind Registration ─────────────────────────────
|
|
176
|
+
const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
|
|
177
|
+
registerSloGitOpsKinds({
|
|
178
|
+
kindRegistry,
|
|
179
|
+
getService: () => {
|
|
180
|
+
if (!gitopsService) throw new Error("SloService not initialized");
|
|
181
|
+
return gitopsService;
|
|
182
|
+
},
|
|
183
|
+
});
|
|
171
184
|
|
|
172
185
|
env.registerInit({
|
|
173
186
|
schema,
|
|
@@ -190,6 +203,7 @@ export default createBackendPlugin({
|
|
|
190
203
|
logger.debug("🔧 Initializing SLO Backend...");
|
|
191
204
|
|
|
192
205
|
const service = new SloService(database as SafeDatabase<typeof schema>);
|
|
206
|
+
gitopsService = service;
|
|
193
207
|
const engine = new SloEngine({
|
|
194
208
|
service,
|
|
195
209
|
signalService,
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CHECKSTACK_API_VERSION,
|
|
4
|
+
type ReconcileContext,
|
|
5
|
+
} from "@checkstack/gitops-common";
|
|
6
|
+
import type { SloService } from "./service";
|
|
7
|
+
import type { SloObjective } from "@checkstack/slo-common";
|
|
8
|
+
import { buildSloKind } from "./slo-gitops-kinds";
|
|
9
|
+
|
|
10
|
+
const noopLogger = {
|
|
11
|
+
debug: () => {},
|
|
12
|
+
info: () => {},
|
|
13
|
+
warn: () => {},
|
|
14
|
+
error: () => {},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const buildContext = (
|
|
18
|
+
overrides: Partial<ReconcileContext> = {},
|
|
19
|
+
): ReconcileContext =>
|
|
20
|
+
({
|
|
21
|
+
logger: noopLogger,
|
|
22
|
+
resolveEntityRef: async () => undefined,
|
|
23
|
+
resolveSecretsBySchema: async ({ value }) => ({
|
|
24
|
+
resolved: value,
|
|
25
|
+
warnings: [],
|
|
26
|
+
}),
|
|
27
|
+
...overrides,
|
|
28
|
+
}) as ReconcileContext;
|
|
29
|
+
|
|
30
|
+
const stubService = () => {
|
|
31
|
+
const created: SloObjective = {
|
|
32
|
+
id: "slo-1",
|
|
33
|
+
systemId: "sys-1",
|
|
34
|
+
healthCheckConfigurationId: null,
|
|
35
|
+
target: 99.9,
|
|
36
|
+
windowDays: 30,
|
|
37
|
+
dependencyExclusion: "strict",
|
|
38
|
+
excludedDependencyIds: [],
|
|
39
|
+
burnRateThresholds: {
|
|
40
|
+
warningPercent: 50,
|
|
41
|
+
criticalPercent: 80,
|
|
42
|
+
fastBurnMultiplier: 5,
|
|
43
|
+
},
|
|
44
|
+
createdAt: new Date(),
|
|
45
|
+
updatedAt: new Date(),
|
|
46
|
+
};
|
|
47
|
+
const createObjective = mock(async () => created);
|
|
48
|
+
const updateObjective = mock(async () => created);
|
|
49
|
+
const deleteObjective = mock(async () => true);
|
|
50
|
+
return {
|
|
51
|
+
created,
|
|
52
|
+
createObjective,
|
|
53
|
+
updateObjective,
|
|
54
|
+
deleteObjective,
|
|
55
|
+
service: {
|
|
56
|
+
createObjective,
|
|
57
|
+
updateObjective,
|
|
58
|
+
deleteObjective,
|
|
59
|
+
} as unknown as SloService,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const refResolver =
|
|
64
|
+
(table: Record<string, string>) =>
|
|
65
|
+
async ({ entityName }: { kind: string; entityName: string }) =>
|
|
66
|
+
table[entityName];
|
|
67
|
+
|
|
68
|
+
const mkEntity = <TSpec>(name: string, spec: TSpec) => ({
|
|
69
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
70
|
+
kind: "SLO",
|
|
71
|
+
metadata: { name },
|
|
72
|
+
spec,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("buildSloKind", () => {
|
|
76
|
+
let stub: ReturnType<typeof stubService>;
|
|
77
|
+
let kind: ReturnType<typeof buildSloKind>;
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
stub = stubService();
|
|
81
|
+
kind = buildSloKind({ getService: () => stub.service });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("registers under SLO kind", () => {
|
|
85
|
+
expect(kind.kind).toBe("SLO");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("creates an objective when no existing entity id is supplied", async () => {
|
|
89
|
+
const result = await kind.reconcile({
|
|
90
|
+
entity: mkEntity("payments-availability", {
|
|
91
|
+
systemRef: { kind: "System", name: "payments-api" },
|
|
92
|
+
target: 99.9,
|
|
93
|
+
windowDays: 30,
|
|
94
|
+
}),
|
|
95
|
+
context: buildContext({
|
|
96
|
+
resolveEntityRef: refResolver({ "payments-api": "sys-1" }),
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(result.entityId).toBe("slo-1");
|
|
101
|
+
expect(stub.createObjective).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(stub.createObjective).toHaveBeenCalledWith({
|
|
103
|
+
input: {
|
|
104
|
+
systemId: "sys-1",
|
|
105
|
+
healthCheckConfigurationId: undefined,
|
|
106
|
+
target: 99.9,
|
|
107
|
+
windowDays: 30,
|
|
108
|
+
dependencyExclusion: "strict",
|
|
109
|
+
excludedDependencyIds: [],
|
|
110
|
+
burnRateThresholds: {
|
|
111
|
+
warningPercent: 50,
|
|
112
|
+
criticalPercent: 80,
|
|
113
|
+
fastBurnMultiplier: 5,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("resolves an optional healthcheck ref into healthCheckConfigurationId", async () => {
|
|
120
|
+
await kind.reconcile({
|
|
121
|
+
entity: mkEntity("payments-http-availability", {
|
|
122
|
+
systemRef: { kind: "System", name: "payments-api" },
|
|
123
|
+
healthcheckRef: { kind: "Healthcheck", name: "payments-http" },
|
|
124
|
+
target: 99.95,
|
|
125
|
+
windowDays: 7,
|
|
126
|
+
}),
|
|
127
|
+
context: buildContext({
|
|
128
|
+
resolveEntityRef: refResolver({
|
|
129
|
+
"payments-api": "sys-1",
|
|
130
|
+
"payments-http": "hc-1",
|
|
131
|
+
}),
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(stub.createObjective).toHaveBeenCalledWith({
|
|
136
|
+
input: expect.objectContaining({
|
|
137
|
+
systemId: "sys-1",
|
|
138
|
+
healthCheckConfigurationId: "hc-1",
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("resolves excluded dependency refs to system ids", async () => {
|
|
144
|
+
await kind.reconcile({
|
|
145
|
+
entity: mkEntity("core", {
|
|
146
|
+
systemRef: { kind: "System", name: "core" },
|
|
147
|
+
target: 99.9,
|
|
148
|
+
windowDays: 30,
|
|
149
|
+
dependencyExclusion: "self-only",
|
|
150
|
+
excludedDependencyRefs: [
|
|
151
|
+
{ kind: "System", name: "third-party-payments" },
|
|
152
|
+
{ kind: "System", name: "third-party-email" },
|
|
153
|
+
],
|
|
154
|
+
}),
|
|
155
|
+
context: buildContext({
|
|
156
|
+
resolveEntityRef: refResolver({
|
|
157
|
+
core: "sys-core",
|
|
158
|
+
"third-party-payments": "sys-pay",
|
|
159
|
+
"third-party-email": "sys-mail",
|
|
160
|
+
}),
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(stub.createObjective).toHaveBeenCalledWith({
|
|
165
|
+
input: expect.objectContaining({
|
|
166
|
+
excludedDependencyIds: ["sys-pay", "sys-mail"],
|
|
167
|
+
dependencyExclusion: "self-only",
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("updates an existing objective when existingEntityId is supplied", async () => {
|
|
173
|
+
await kind.reconcile({
|
|
174
|
+
entity: mkEntity("payments", {
|
|
175
|
+
systemRef: { kind: "System", name: "payments-api" },
|
|
176
|
+
target: 99.95,
|
|
177
|
+
windowDays: 30,
|
|
178
|
+
}),
|
|
179
|
+
existingEntityId: "slo-existing",
|
|
180
|
+
context: buildContext({
|
|
181
|
+
resolveEntityRef: refResolver({ "payments-api": "sys-1" }),
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(stub.createObjective).not.toHaveBeenCalled();
|
|
186
|
+
expect(stub.updateObjective).toHaveBeenCalledWith({
|
|
187
|
+
input: expect.objectContaining({
|
|
188
|
+
id: "slo-existing",
|
|
189
|
+
target: 99.95,
|
|
190
|
+
windowDays: 30,
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("treats pending-* entityIds as fresh creates", async () => {
|
|
196
|
+
await kind.reconcile({
|
|
197
|
+
entity: mkEntity("payments", {
|
|
198
|
+
systemRef: { kind: "System", name: "payments-api" },
|
|
199
|
+
target: 99.9,
|
|
200
|
+
windowDays: 30,
|
|
201
|
+
}),
|
|
202
|
+
existingEntityId: "pending-abc",
|
|
203
|
+
context: buildContext({
|
|
204
|
+
resolveEntityRef: refResolver({ "payments-api": "sys-1" }),
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(stub.createObjective).toHaveBeenCalledTimes(1);
|
|
209
|
+
expect(stub.updateObjective).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("throws when systemRef cannot be resolved", async () => {
|
|
213
|
+
await expect(
|
|
214
|
+
kind.reconcile({
|
|
215
|
+
entity: mkEntity("broken", {
|
|
216
|
+
systemRef: { kind: "System", name: "nope" },
|
|
217
|
+
target: 99.9,
|
|
218
|
+
windowDays: 30,
|
|
219
|
+
}),
|
|
220
|
+
context: buildContext({ resolveEntityRef: async () => undefined }),
|
|
221
|
+
}),
|
|
222
|
+
).rejects.toThrow(/Cannot resolve system ref "nope"/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("delegates delete to the service", async () => {
|
|
226
|
+
await kind.delete!({
|
|
227
|
+
entityName: "payments",
|
|
228
|
+
entityId: "slo-1",
|
|
229
|
+
context: buildContext(),
|
|
230
|
+
});
|
|
231
|
+
expect(stub.deleteObjective).toHaveBeenCalledWith({ id: "slo-1" });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("delete is a no-op when entityId is missing", async () => {
|
|
235
|
+
await kind.delete!({ entityName: "x", context: buildContext() });
|
|
236
|
+
expect(stub.deleteObjective).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
CHECKSTACK_API_VERSION,
|
|
4
|
+
entityRefSchema,
|
|
5
|
+
type EntityKindDefinition,
|
|
6
|
+
type EntityKindRegistry,
|
|
7
|
+
type ReconcileContext,
|
|
8
|
+
} from "@checkstack/gitops-common";
|
|
9
|
+
import {
|
|
10
|
+
DependencyExclusionModeSchema,
|
|
11
|
+
BurnRateThresholdsSchema,
|
|
12
|
+
} from "@checkstack/slo-common";
|
|
13
|
+
import type { SloService } from "./service";
|
|
14
|
+
|
|
15
|
+
interface SloGitOpsKindsDeps {
|
|
16
|
+
/** Lazy accessor — populated during init(), invoked at reconcile time. */
|
|
17
|
+
getService: () => SloService;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── SLO Spec Schema ───────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GitOps spec for `kind: SLO`.
|
|
24
|
+
*
|
|
25
|
+
* An SLO targets a specific system (required) and may optionally narrow to a
|
|
26
|
+
* single healthcheck on that system. When `healthcheckRef` is omitted the SLO
|
|
27
|
+
* covers the system's aggregate availability across all of its healthchecks.
|
|
28
|
+
*/
|
|
29
|
+
const sloSpecSchema = z.object({
|
|
30
|
+
systemRef: entityRefSchema,
|
|
31
|
+
healthcheckRef: entityRefSchema.optional(),
|
|
32
|
+
target: z.number().min(0).max(100),
|
|
33
|
+
windowDays: z.number().int().positive(),
|
|
34
|
+
dependencyExclusion: DependencyExclusionModeSchema.optional(),
|
|
35
|
+
excludedDependencyRefs: z.array(entityRefSchema).optional(),
|
|
36
|
+
burnRateThresholds: BurnRateThresholdsSchema.optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
type SloSpec = z.infer<typeof sloSpecSchema>;
|
|
40
|
+
|
|
41
|
+
// ─── Kind builder ───────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const resolveRequiredRef = async ({
|
|
44
|
+
context,
|
|
45
|
+
ref,
|
|
46
|
+
refKindLabel,
|
|
47
|
+
}: {
|
|
48
|
+
context: ReconcileContext;
|
|
49
|
+
ref: { kind: string; name: string };
|
|
50
|
+
refKindLabel: string;
|
|
51
|
+
}): Promise<string> => {
|
|
52
|
+
const id = await context.resolveEntityRef({
|
|
53
|
+
kind: ref.kind,
|
|
54
|
+
entityName: ref.name,
|
|
55
|
+
});
|
|
56
|
+
if (!id) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Cannot resolve ${refKindLabel} ref "${ref.name}" (kind: ${ref.kind}) — ensure the entity exists`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return id;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function buildSloKind(
|
|
65
|
+
deps: SloGitOpsKindsDeps,
|
|
66
|
+
): EntityKindDefinition<SloSpec> {
|
|
67
|
+
return {
|
|
68
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
69
|
+
kind: "SLO",
|
|
70
|
+
specSchema: sloSpecSchema,
|
|
71
|
+
|
|
72
|
+
reconcile: async ({
|
|
73
|
+
entity,
|
|
74
|
+
existingEntityId,
|
|
75
|
+
context,
|
|
76
|
+
}: {
|
|
77
|
+
entity: {
|
|
78
|
+
metadata: { name: string; title?: string; description?: string };
|
|
79
|
+
spec: SloSpec;
|
|
80
|
+
};
|
|
81
|
+
existingEntityId?: string;
|
|
82
|
+
context: ReconcileContext;
|
|
83
|
+
}) => {
|
|
84
|
+
const { spec } = entity;
|
|
85
|
+
const service = deps.getService();
|
|
86
|
+
|
|
87
|
+
const systemId = await resolveRequiredRef({
|
|
88
|
+
context,
|
|
89
|
+
ref: spec.systemRef,
|
|
90
|
+
refKindLabel: "system",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let healthCheckConfigurationId: string | undefined;
|
|
94
|
+
if (spec.healthcheckRef) {
|
|
95
|
+
healthCheckConfigurationId = await resolveRequiredRef({
|
|
96
|
+
context,
|
|
97
|
+
ref: spec.healthcheckRef,
|
|
98
|
+
refKindLabel: "healthcheck",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const excludedDependencyIds = spec.excludedDependencyRefs
|
|
103
|
+
? await Promise.all(
|
|
104
|
+
spec.excludedDependencyRefs.map((ref) =>
|
|
105
|
+
resolveRequiredRef({
|
|
106
|
+
context,
|
|
107
|
+
ref,
|
|
108
|
+
refKindLabel: "excluded dependency",
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
: [];
|
|
113
|
+
|
|
114
|
+
if (existingEntityId && !existingEntityId.startsWith("pending-")) {
|
|
115
|
+
await service.updateObjective({
|
|
116
|
+
input: {
|
|
117
|
+
id: existingEntityId,
|
|
118
|
+
target: spec.target,
|
|
119
|
+
windowDays: spec.windowDays,
|
|
120
|
+
dependencyExclusion: spec.dependencyExclusion,
|
|
121
|
+
excludedDependencyIds,
|
|
122
|
+
burnRateThresholds: spec.burnRateThresholds,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
context.logger.info(
|
|
126
|
+
`GitOps: updated SLO "${entity.metadata.name}" (id: ${existingEntityId})`,
|
|
127
|
+
);
|
|
128
|
+
return { entityId: existingEntityId };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const created = await service.createObjective({
|
|
132
|
+
input: {
|
|
133
|
+
systemId,
|
|
134
|
+
healthCheckConfigurationId,
|
|
135
|
+
target: spec.target,
|
|
136
|
+
windowDays: spec.windowDays,
|
|
137
|
+
dependencyExclusion: spec.dependencyExclusion ?? "strict",
|
|
138
|
+
excludedDependencyIds,
|
|
139
|
+
burnRateThresholds: spec.burnRateThresholds ?? {
|
|
140
|
+
warningPercent: 50,
|
|
141
|
+
criticalPercent: 80,
|
|
142
|
+
fastBurnMultiplier: 5,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
context.logger.info(
|
|
147
|
+
`GitOps: created SLO "${entity.metadata.name}" (id: ${created.id})`,
|
|
148
|
+
);
|
|
149
|
+
return { entityId: created.id };
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
delete: async ({
|
|
153
|
+
entityId,
|
|
154
|
+
context,
|
|
155
|
+
}: {
|
|
156
|
+
entityName: string;
|
|
157
|
+
entityId?: string;
|
|
158
|
+
context: ReconcileContext;
|
|
159
|
+
}) => {
|
|
160
|
+
if (!entityId) return;
|
|
161
|
+
await deps.getService().deleteObjective({ id: entityId });
|
|
162
|
+
context.logger.info(`GitOps: deleted SLO (id: ${entityId})`);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function registerSloGitOpsKinds({
|
|
168
|
+
kindRegistry,
|
|
169
|
+
...deps
|
|
170
|
+
}: SloGitOpsKindsDeps & {
|
|
171
|
+
kindRegistry: EntityKindRegistry;
|
|
172
|
+
}): void {
|
|
173
|
+
kindRegistry.registerKind(buildSloKind(deps));
|
|
174
|
+
}
|