@homeflare/alchemy 0.35.0 → 0.37.0
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/README.md +14 -0
- package/dist/argocd/cluster.d.ts +102 -0
- package/dist/argocd/cluster.d.ts.map +1 -0
- package/dist/argocd/credentials.d.ts +37 -0
- package/dist/argocd/credentials.d.ts.map +1 -0
- package/dist/argocd/fake-argocd.d.ts +42 -0
- package/dist/argocd/fake-argocd.d.ts.map +1 -0
- package/dist/argocd/git-credentials.d.ts +29 -0
- package/dist/argocd/git-credentials.d.ts.map +1 -0
- package/dist/argocd/index.d.ts +19 -0
- package/dist/argocd/index.d.ts.map +1 -0
- package/dist/argocd/index.js +306 -0
- package/dist/argocd/index.js.map +17 -0
- package/dist/argocd/providers.d.ts +13 -0
- package/dist/argocd/providers.d.ts.map +1 -0
- package/dist/argocd/repo-creds.d.ts +67 -0
- package/dist/argocd/repo-creds.d.ts.map +1 -0
- package/dist/argocd/repository.d.ts +87 -0
- package/dist/argocd/repository.d.ts.map +1 -0
- package/dist/argocd/resource.d.ts +90 -0
- package/dist/argocd/resource.d.ts.map +1 -0
- package/dist/cloudflare/index.js +4 -4
- package/dist/cloudflare/index.js.map +3 -3
- package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/grafana/index.d.ts +5 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +204 -90
- package/dist/grafana/index.js.map +7 -4
- package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
- package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
- package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
- package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
- package/dist/grafana/notification-policy-model.d.ts +53 -0
- package/dist/grafana/notification-policy-model.d.ts.map +1 -0
- package/dist/grafana/notification-policy.d.ts +57 -0
- package/dist/grafana/notification-policy.d.ts.map +1 -0
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/litellm/index.js.map +2 -2
- package/dist/litellm/operations.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
- package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon-wire.d.ts +33 -0
- package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon.d.ts +35 -0
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-distilled.d.ts +14 -0
- package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
- package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts +31 -6
- package/dist/proxmox/ceph-fs.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +17 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd.d.ts +6 -0
- package/dist/proxmox/ceph-osd.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +10 -4
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-wire.d.ts +46 -0
- package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool.d.ts +10 -5
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +1 -1
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +611 -477
- package/dist/proxmox/index.js.map +15 -12
- package/dist/proxmox/storage-wire.d.ts +8 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/user-wire.d.ts +1 -1
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/docs/argocd-kubernetes.md +100 -0
- package/docs/argocd.md +139 -0
- package/docs/credentials.md +9 -0
- package/docs/grafana-alerting.md +3 -2
- package/docs/grafana-notification-policy.md +127 -0
- package/docs/grafana.md +12 -8
- package/docs/litellm.md +17 -0
- package/docs/peers.md +7 -0
- package/docs/unifi.md +45 -0
- package/docs/upstream-conformance.md +19 -5
- package/package.json +8 -3
- package/src/argocd/cluster.test.ts +120 -0
- package/src/argocd/cluster.ts +222 -0
- package/src/argocd/credentials.ts +38 -0
- package/src/argocd/fake-argocd.ts +60 -0
- package/src/argocd/git-credentials.ts +71 -0
- package/src/argocd/index.ts +40 -0
- package/src/argocd/providers.ts +17 -0
- package/src/argocd/repo-creds.test.ts +123 -0
- package/src/argocd/repo-creds.ts +115 -0
- package/src/argocd/repository.test.ts +167 -0
- package/src/argocd/repository.ts +165 -0
- package/src/argocd/resource.ts +150 -0
- package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
- package/src/cloudflare/r2-bucket-lock.ts +25 -18
- package/src/grafana/index.ts +11 -4
- package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
- package/src/grafana/notification-policy-drop-warning.ts +60 -0
- package/src/grafana/notification-policy-fixtures.ts +27 -0
- package/src/grafana/notification-policy-model.test.ts +104 -0
- package/src/grafana/notification-policy-model.ts +100 -0
- package/src/grafana/notification-policy-refusals.test.ts +119 -0
- package/src/grafana/notification-policy.test.ts +86 -0
- package/src/grafana/notification-policy.ts +191 -0
- package/src/grafana/providers.ts +2 -0
- package/src/litellm/operations.ts +8 -4
- package/src/proxmox/ceph-daemon-form.ts +7 -8
- package/src/proxmox/ceph-daemon-wire.ts +115 -0
- package/src/proxmox/ceph-daemon-write.test.ts +158 -0
- package/src/proxmox/ceph-daemon.ts +65 -47
- package/src/proxmox/ceph-fs-delete.test.ts +81 -0
- package/src/proxmox/ceph-fs-distilled.ts +102 -0
- package/src/proxmox/ceph-fs-wire.ts +17 -31
- package/src/proxmox/ceph-fs-write.test.ts +67 -0
- package/src/proxmox/ceph-fs.ts +66 -69
- package/src/proxmox/ceph-osd-tree.ts +14 -5
- package/src/proxmox/ceph-osd-write.test.ts +61 -0
- package/src/proxmox/ceph-osd-write.ts +33 -6
- package/src/proxmox/ceph-osd.ts +6 -0
- package/src/proxmox/ceph-pool-settle.ts +13 -7
- package/src/proxmox/ceph-pool-wire.ts +110 -0
- package/src/proxmox/ceph-pool-write.test.ts +145 -0
- package/src/proxmox/ceph-pool.ts +87 -98
- package/src/proxmox/distilled-task-await.test.ts +0 -124
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shape for every Argo CD REST object — Repository, RepoCreds, Cluster — the same four
|
|
3
|
+
* operations, different distilled call.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THIS IS A NEAR-VERBATIM COPY OF `../forgejo/resource.ts`'S ENGINE, ON PURPOSE. Both SDKs speak
|
|
6
|
+
* plain bearer-authenticated REST with a real `NotFound` tag (see the family's own docs.md for
|
|
7
|
+
* why Argo CD, UNLIKE Discord, gets a genuine typed 404 — measured from `protocol.ts`), so the
|
|
8
|
+
* same read/diff/reconcile/destroy shape applies unchanged. A second hand-rolled engine here
|
|
9
|
+
* would just be forgejo's with the names swapped.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ NO `Unowned`/`adopt(true)` WRAPPING HERE — mirrors forgejo's own choice (not netbox's or
|
|
12
|
+
* discord's `Unowned` marker), not an oversight. A live match on a cold read (no persisted
|
|
13
|
+
* `output`) is treated as ours, same as every other forgejo-shaped family in this kit today.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE.
|
|
16
|
+
* `Effect.catchTag`'s tag-literal inference needs a concrete error union to resolve `"NotFound"`
|
|
17
|
+
* against; a spec's `E` is only concrete at each resource file's own call site. So every
|
|
18
|
+
* `fetchLive` ends `.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined)))` itself.
|
|
19
|
+
*/
|
|
20
|
+
import { isResolved } from 'alchemy/Diff';
|
|
21
|
+
import type { Input } from 'alchemy/Input';
|
|
22
|
+
import { CredentialsFromEnv } from '@distilled.cloud/argocd/Credentials';
|
|
23
|
+
import type { ArgocdOpContext } from '@distilled.cloud/argocd/Protocol';
|
|
24
|
+
import * as Effect from 'effect/Effect';
|
|
25
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
26
|
+
|
|
27
|
+
/** What every handler needs from the caller's runtime, once `argocdHandlers` bakes in credentials. */
|
|
28
|
+
export type ArgocdRequirements = HttpClient.HttpClient;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Strips undefined-valued keys, mapping each to a genuinely optional key rather than a present
|
|
32
|
+
* key typed `X | undefined`. `tsconfig.base.json` turns on `exactOptionalPropertyTypes`, and every
|
|
33
|
+
* generated Argo CD request type declares its optional fields the strict way (`foo?: X`, never
|
|
34
|
+
* `foo?: X | undefined`) — so `{ foo: props.foo }` where `props.foo?: X` does not typecheck against
|
|
35
|
+
* it even when `foo` is genuinely absent at runtime. Every resource file's request body goes
|
|
36
|
+
* through this once, mirroring `forgejo/repository.ts#editForm`'s conditional-spread idiom, just
|
|
37
|
+
* generalized instead of repeated per field.
|
|
38
|
+
*/
|
|
39
|
+
export const present = <T extends object>(obj: T): { [K in keyof T]?: Exclude<T[K], undefined> } =>
|
|
40
|
+
Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as never;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One Argo CD object's distilled calls. `Live` is whatever the SDK decodes (`V1alpha1Repository`,
|
|
44
|
+
* `V1alpha1Cluster`, …); `E` is left to each resource file to declare.
|
|
45
|
+
*/
|
|
46
|
+
export type ArgocdSpec<Props extends object, Live, Attributes, E> = {
|
|
47
|
+
/**
|
|
48
|
+
* The identity key's value — `repo`/`url`/`server` depending on the resource — read off
|
|
49
|
+
* declared props and off persisted attributes respectively. `diff` compares the two to detect
|
|
50
|
+
* an identity change, which `upsert`'s single create-or-converge call cannot express as an
|
|
51
|
+
* in-place update: Argo CD indexes each of these objects BY this value, so a changed identity
|
|
52
|
+
* means a create at the new key, never a rename, and the object left at the old key must be
|
|
53
|
+
* deleted — exactly what a `replace` diff action tells Alchemy's own Plan/Apply to do (delete
|
|
54
|
+
* the old physical object, using `olds`, then reconcile `news`). Without this, a changed
|
|
55
|
+
* `repo`/`url`/`server` orphans the old, still-credentialed object with no state pointing at it
|
|
56
|
+
* — found in adversarial review 2026-09-24, fixed here.
|
|
57
|
+
*/
|
|
58
|
+
readonly identityOfProps: (props: Props) => string;
|
|
59
|
+
readonly identityOfAttributes: (attributes: Attributes) => string;
|
|
60
|
+
/** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */
|
|
61
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, ArgocdOpContext>;
|
|
62
|
+
readonly attributes: (live: Live, props: Props) => Attributes | undefined;
|
|
63
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
64
|
+
/**
|
|
65
|
+
* A single upsert call — every Argo CD create endpoint this family uses takes `upsert: true`
|
|
66
|
+
* and converges create-or-update in one request, so there is no separate update path to wire.
|
|
67
|
+
*/
|
|
68
|
+
readonly upsert: (props: Props) => Effect.Effect<unknown, E, ArgocdOpContext>;
|
|
69
|
+
readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, ArgocdOpContext>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const argocdOperations = <Props extends object, Live, Attributes, E>(
|
|
73
|
+
spec: ArgocdSpec<Props, Live, Attributes, E>,
|
|
74
|
+
) => {
|
|
75
|
+
const read = (props: Props) =>
|
|
76
|
+
spec
|
|
77
|
+
.fetchLive(props)
|
|
78
|
+
.pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
read,
|
|
82
|
+
|
|
83
|
+
diff: (news: Input<Props>, output: Attributes | undefined) =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
86
|
+
// The identity key changed (repo/url/server) — `upsert` cannot rename in place, it would
|
|
87
|
+
// create at the new key and leave the old, still-live object orphaned. See the spec type.
|
|
88
|
+
if (spec.identityOfProps(news) !== spec.identityOfAttributes(output)) {
|
|
89
|
+
return { action: 'replace' } as const;
|
|
90
|
+
}
|
|
91
|
+
const live = yield* read(news);
|
|
92
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
93
|
+
return spec.matches(live, news)
|
|
94
|
+
? ({ action: 'noop' } as const)
|
|
95
|
+
: ({ action: 'update' } as const);
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
reconcile: (news: Props) =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const before = yield* read(news);
|
|
101
|
+
// S10: no write when what's declared already matches what's live.
|
|
102
|
+
if (before === undefined || !spec.matches(before, news)) {
|
|
103
|
+
yield* spec.upsert(news);
|
|
104
|
+
}
|
|
105
|
+
const after = yield* read(news);
|
|
106
|
+
if (after === undefined) {
|
|
107
|
+
return yield* Effect.die(
|
|
108
|
+
new Error(
|
|
109
|
+
'the write returned no error but the object is still absent — read back rather ' +
|
|
110
|
+
'than trust the status code.',
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return after;
|
|
115
|
+
}),
|
|
116
|
+
|
|
117
|
+
destroy: (olds: Props) =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
const live = yield* spec.fetchLive(olds);
|
|
120
|
+
if (live === undefined) return;
|
|
121
|
+
yield* spec.destroy(olds, live);
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The four provider handlers for a spec'd Argo CD object, wired once.
|
|
128
|
+
*
|
|
129
|
+
* ★ CREDENTIALS ARE PROVIDED HERE, ONCE. `CredentialsFromEnv` resolves `ARGOCD_TOKEN` /
|
|
130
|
+
* `ARGOCD_SERVER` on the calling fiber per request (credentials.ts), never captured at load.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ `list` answers empty — Argo CD's list endpoints return every object of that kind on the whole
|
|
133
|
+
* instance; adoption stays explicit (a declared resource's own `read`), same reasoning as
|
|
134
|
+
* forgejo and discord.
|
|
135
|
+
*/
|
|
136
|
+
export const argocdHandlers = <Props extends object, Live, Attributes extends object, E>(
|
|
137
|
+
spec: ArgocdSpec<Props, Live, Attributes, E>,
|
|
138
|
+
) => {
|
|
139
|
+
const ops = argocdOperations(spec);
|
|
140
|
+
const withCredentials = <A>(effect: Effect.Effect<A, E, ArgocdOpContext>) =>
|
|
141
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
142
|
+
return {
|
|
143
|
+
list: () => Effect.succeed([]),
|
|
144
|
+
read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
|
|
145
|
+
diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
146
|
+
withCredentials(ops.diff(news, output)),
|
|
147
|
+
reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
|
|
148
|
+
delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
|
|
149
|
+
};
|
|
150
|
+
};
|
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* ★ THE POINT OF THIS FILE IS THE TAGGED-ERROR PATHS, per the transport swap onto
|
|
7
7
|
* `@distilled.cloud/cloudflare/r2`: `NoSuchBucket` is matched by Cloudflare's own error code
|
|
8
|
-
* (10006), never by HTTP status, and only `readLock`
|
|
9
|
-
* `reconcileLock
|
|
8
|
+
* (10006), never by HTTP status, and only `readLock`/`deleteLock` treat it as "nothing to do" —
|
|
9
|
+
* `reconcileLock` still fails on it, but as a typed SDK error, not the `Effect.orDie`
|
|
10
|
+
* defect this family used to raise (WI-7, decision 49 "upstream wins", 2026-09-24).
|
|
10
11
|
*/
|
|
12
|
+
import * as Retry from '@distilled.cloud/cloudflare/Retry';
|
|
11
13
|
import type { CloudflareOpContext } from '@distilled.cloud/cloudflare/r2';
|
|
12
14
|
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
13
15
|
import { describe, expect, test } from 'bun:test';
|
|
14
|
-
import * as Cause from 'effect/Cause';
|
|
15
16
|
import * as Effect from 'effect/Effect';
|
|
16
|
-
import * as Exit from 'effect/Exit';
|
|
17
17
|
import {
|
|
18
18
|
FAKE_ACCOUNT,
|
|
19
19
|
type FakeR2Lock,
|
|
@@ -28,8 +28,14 @@ import { type R2BucketLockProps, deleteLock, readLock, reconcileLock } from './r
|
|
|
28
28
|
const run = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
|
|
29
29
|
Effect.runPromise(effect.pipe(Effect.provide(fakeR2LockLayer(fake))));
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The operation's typed refusal, never a defect — asserts S20/S21 (WI-7). `Retry.none` keeps a
|
|
33
|
+
* simulated 429 from actually waiting out distilled's real, unbounded-under-test throttling
|
|
34
|
+
* backoff (`applyRetry`'s `makeDefault` in `@distilled.cloud/core/api.ts`) — the house's own
|
|
35
|
+
* speed doctrine (S26, `testing-speed-doctrine`) never lets a test wait on a real retry schedule.
|
|
36
|
+
*/
|
|
37
|
+
const refused = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
|
|
38
|
+
Effect.runPromise(Effect.flip(effect).pipe(Retry.none, Effect.provide(fakeR2LockLayer(fake))));
|
|
33
39
|
|
|
34
40
|
const rule: R2LockRule = {
|
|
35
41
|
id: 'keep-30d',
|
|
@@ -83,11 +89,18 @@ describe('readLock', () => {
|
|
|
83
89
|
test('a different tagged error at 404 is NOT swallowed as NoSuchBucket', async () => {
|
|
84
90
|
// ⛔ code 7003 is `InvalidRoute`, not `NoSuchBucket` — even at the same 404 status a plain
|
|
85
91
|
// status check would have treated as "no bucket". `catchTag('NoSuchBucket', …)` leaves it
|
|
86
|
-
// alone, and `Effect.orDie`
|
|
92
|
+
// alone, and it now surfaces as a typed SDK error, never an `Effect.orDie` defect.
|
|
87
93
|
const fake = fakeR2Lock({ onRoute: () => fakeFailure(404, 7003, 'Invalid route') });
|
|
88
|
-
const
|
|
89
|
-
expect(
|
|
90
|
-
|
|
94
|
+
const refusal = await refused(fake, readLock(props(), false));
|
|
95
|
+
expect(refusal._tag).toBe('InvalidRoute');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a rate-limited read surfaces as a typed refusal, not a crash', async () => {
|
|
99
|
+
// ⛔ 429 WITH NO MATCHED CODE maps to distilled's `TooManyRequests` (protocol.ts step 3,
|
|
100
|
+
// "Throttling"), one of the `CloudflareOpError` members this resource does not special-case.
|
|
101
|
+
const fake = fakeR2Lock({ onRoute: () => fakeFailure(429, 0, 'rate limited') });
|
|
102
|
+
const refusal = await refused(fake, readLock(props(), false));
|
|
103
|
+
expect(refusal._tag).toBe('TooManyRequests');
|
|
91
104
|
});
|
|
92
105
|
});
|
|
93
106
|
|
|
@@ -108,11 +121,10 @@ describe('reconcileLock', () => {
|
|
|
108
121
|
expect(fake.locks.get('my-backups')).toEqual([rule]);
|
|
109
122
|
});
|
|
110
123
|
|
|
111
|
-
test('NoSuchBucket is NOT caught here: a genuinely missing bucket
|
|
124
|
+
test('NoSuchBucket is NOT caught here: a genuinely missing bucket is a typed refusal', async () => {
|
|
112
125
|
const fake = fakeR2Lock();
|
|
113
|
-
const
|
|
114
|
-
expect(
|
|
115
|
-
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
|
|
126
|
+
const refusal = await refused(fake, reconcileLock(props(), undefined));
|
|
127
|
+
expect(refusal._tag).toBe('NoSuchBucket');
|
|
116
128
|
});
|
|
117
129
|
});
|
|
118
130
|
|
|
@@ -126,14 +138,38 @@ describe('deleteLock', () => {
|
|
|
126
138
|
expect(fake.locks.has('my-backups')).toBe(false);
|
|
127
139
|
});
|
|
128
140
|
|
|
129
|
-
test('
|
|
141
|
+
test('deleting when the bucket is already gone succeeds (S11, P10: delete is idempotent)', async () => {
|
|
142
|
+
// ⛔ THE ONE BEHAVIOUR CHANGE WI-7 MAKES: unlike `reconcileLock`, `deleteLock` now catches
|
|
143
|
+
// `NoSuchBucket` into success — a bucket that no longer exists has nothing left to unlock.
|
|
130
144
|
const fake = fakeR2Lock();
|
|
131
145
|
const output = await run(
|
|
132
146
|
fakeR2Lock({ existingBuckets: ['my-backups'] }),
|
|
133
147
|
reconcileLock(props(), undefined),
|
|
134
148
|
);
|
|
135
|
-
|
|
136
|
-
expect(
|
|
137
|
-
|
|
149
|
+
await run(fake, deleteLock(output, FAKE_ACCOUNT));
|
|
150
|
+
expect(writes(fake)).toEqual(['PUT']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('delete does not swallow InvalidRoute at 404', async () => {
|
|
154
|
+
const fake = fakeR2Lock({ onRoute: () => fakeFailure(404, 7003, 'Invalid route') });
|
|
155
|
+
const refusal = await refused(
|
|
156
|
+
fake,
|
|
157
|
+
deleteLock(
|
|
158
|
+
{ bucketName: 'my-backups', jurisdiction: 'default', rules: [rule] },
|
|
159
|
+
FAKE_ACCOUNT,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
expect(refusal._tag).toBe('InvalidRoute');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a rate-limited delete surfaces as a typed refusal, not a crash', async () => {
|
|
166
|
+
const setup = fakeR2Lock({ existingBuckets: ['my-backups'] });
|
|
167
|
+
const output = await run(setup, reconcileLock(props(), undefined));
|
|
168
|
+
const limited = fakeR2Lock({
|
|
169
|
+
existingBuckets: ['my-backups'],
|
|
170
|
+
onRoute: () => fakeFailure(429, 0, 'rate limited'),
|
|
171
|
+
});
|
|
172
|
+
const refusal = await refused(limited, deleteLock(output, FAKE_ACCOUNT));
|
|
173
|
+
expect(refusal._tag).toBe('TooManyRequests');
|
|
138
174
|
});
|
|
139
175
|
});
|
|
@@ -103,7 +103,9 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
|
|
|
103
103
|
* same case the status check used to cover — but by the vendor's own error identity, not by
|
|
104
104
|
* reading a number off the response. Letting it propagate would make the resource impossible to
|
|
105
105
|
* plan; swallowing every failure would also hide a 403 from a mis-scoped token, which is the
|
|
106
|
-
* failure this estate spends the most time on. So: `NoSuchBucket` → absent, everything else
|
|
106
|
+
* failure this estate spends the most time on. So: `NoSuchBucket` → absent, everything else
|
|
107
|
+
* propagates with its SDK tag. Upstream `Cloudflare/R2/BucketSippy.ts` at beta.79 uses this
|
|
108
|
+
* same catch-and-propagate shape; a blanket remap would hide those typed identities (S20/S21).
|
|
107
109
|
*
|
|
108
110
|
* ⛔ AN EMPTY LIVE RULE SET IS "NO LOCK", NOT "A LOCK WITH NOTHING IN IT", so it reads as absent
|
|
109
111
|
* and the resource CREATES. The API answers `{}` for a bucket that has never been locked and
|
|
@@ -117,15 +119,16 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
|
|
|
117
119
|
export const readLock = (
|
|
118
120
|
props: { accountId: string; bucketName: string; jurisdiction?: Jurisdiction },
|
|
119
121
|
written: boolean,
|
|
120
|
-
): Effect.Effect<
|
|
122
|
+
): Effect.Effect<
|
|
123
|
+
R2BucketLockAttributes | undefined,
|
|
124
|
+
Exclude<r2.GetBucketLockError, { _tag: 'NoSuchBucket' }>,
|
|
125
|
+
CloudflareOpContext
|
|
126
|
+
> =>
|
|
121
127
|
Effect.gen(function* () {
|
|
122
128
|
const jurisdiction = jurisdictionOf(props);
|
|
123
129
|
const live = yield* r2
|
|
124
130
|
.getBucketLock({ accountId: props.accountId, bucketName: props.bucketName, jurisdiction })
|
|
125
|
-
.pipe(
|
|
126
|
-
Effect.catchTag('NoSuchBucket', () => Effect.succeed(undefined)),
|
|
127
|
-
Effect.orDie,
|
|
128
|
-
);
|
|
131
|
+
.pipe(Effect.catchTag('NoSuchBucket', () => Effect.succeed(undefined)));
|
|
129
132
|
if (live === undefined) return undefined;
|
|
130
133
|
const rules = (live.rules ?? []) as readonly R2LockRule[];
|
|
131
134
|
if (rules.length === 0) return undefined;
|
|
@@ -144,23 +147,21 @@ export const readLock = (
|
|
|
144
147
|
* persisted (or what `read` just adopted), so the comparison costs nothing either.
|
|
145
148
|
* ⛔ `NoSuchBucket` IS NOT CAUGHT HERE. Unlike `read`, a `reconcile` that hits it means the bucket
|
|
146
149
|
* this declaration names is genuinely gone — that is a real failure to surface, not a case to
|
|
147
|
-
* paper over, so it
|
|
150
|
+
* paper over, so it propagates like every other typed SDK error `putBucketLock` can raise.
|
|
148
151
|
*/
|
|
149
152
|
export const reconcileLock = (
|
|
150
153
|
news: R2BucketLockProps,
|
|
151
154
|
output: R2BucketLockAttributes | undefined,
|
|
152
|
-
): Effect.Effect<R2BucketLockAttributes,
|
|
155
|
+
): Effect.Effect<R2BucketLockAttributes, r2.PutBucketLockError, CloudflareOpContext> =>
|
|
153
156
|
Effect.gen(function* () {
|
|
154
157
|
const jurisdiction = jurisdictionOf(news);
|
|
155
158
|
if (output !== undefined && rulesEqual(output.rules, news.rules)) return output;
|
|
156
|
-
yield* r2
|
|
157
|
-
.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
})
|
|
163
|
-
.pipe(Effect.orDie);
|
|
159
|
+
yield* r2.putBucketLock({
|
|
160
|
+
accountId: news.accountId,
|
|
161
|
+
bucketName: news.bucketName,
|
|
162
|
+
jurisdiction,
|
|
163
|
+
...toBody(news.rules),
|
|
164
|
+
});
|
|
164
165
|
return { bucketName: news.bucketName, jurisdiction, rules: news.rules };
|
|
165
166
|
});
|
|
166
167
|
|
|
@@ -170,11 +171,17 @@ export const reconcileLock = (
|
|
|
170
171
|
* rule is configured, so retiring the repository has to start here — and it is behind
|
|
171
172
|
* `defaultRemovalPolicy: 'retain'` and the estate CLI's refusal of `destroy` precisely because
|
|
172
173
|
* no ordinary deploy should ever reach it.
|
|
174
|
+
* ⛔ IDEMPOTENT (S11): a missing bucket has nothing left to unlock. Catch only `NoSuchBucket`;
|
|
175
|
+
* every other SDK error propagates, like upstream `R2/BucketSippy.ts` at beta.79.
|
|
173
176
|
*/
|
|
174
177
|
export const deleteLock = (
|
|
175
178
|
output: R2BucketLockAttributes,
|
|
176
179
|
accountId: string,
|
|
177
|
-
): Effect.Effect<
|
|
180
|
+
): Effect.Effect<
|
|
181
|
+
void,
|
|
182
|
+
Exclude<r2.PutBucketLockError, { _tag: 'NoSuchBucket' }>,
|
|
183
|
+
CloudflareOpContext
|
|
184
|
+
> =>
|
|
178
185
|
Effect.gen(function* () {
|
|
179
186
|
yield* r2
|
|
180
187
|
.putBucketLock({
|
|
@@ -183,7 +190,7 @@ export const deleteLock = (
|
|
|
183
190
|
jurisdiction: output.jurisdiction,
|
|
184
191
|
rules: [],
|
|
185
192
|
})
|
|
186
|
-
.pipe(Effect.
|
|
193
|
+
.pipe(Effect.catchTag('NoSuchBucket', () => Effect.void));
|
|
187
194
|
});
|
|
188
195
|
|
|
189
196
|
export const R2BucketLockProvider = () =>
|
package/src/grafana/index.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* depend on.
|
|
8
8
|
*
|
|
9
9
|
* `Grafana.Datasource`, `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.ContactPoint`,
|
|
10
|
-
* `Grafana.MuteTiming`, `Grafana.MessageTemplate
|
|
11
|
-
* `Grafana.NotificationPolicy`
|
|
12
|
-
*
|
|
13
|
-
* docs/grafana-
|
|
10
|
+
* `Grafana.MuteTiming`, `Grafana.MessageTemplate`, `Grafana.AlertRuleGroup` and
|
|
11
|
+
* `Grafana.NotificationPolicy` all ship here — see docs/grafana.md, docs/grafana-folder-dashboard.md,
|
|
12
|
+
* docs/grafana-alerting.md, docs/grafana-alerting-rules.md and
|
|
13
|
+
* docs/grafana-notification-policy.md for what shipped.
|
|
14
14
|
*/
|
|
15
15
|
export {
|
|
16
16
|
type AlertRuleGroupAttributes,
|
|
@@ -19,6 +19,13 @@ export {
|
|
|
19
19
|
GrafanaAlertRuleGroup,
|
|
20
20
|
GrafanaAlertRuleGroupProvider,
|
|
21
21
|
} from './alert-rule-group.ts';
|
|
22
|
+
export {
|
|
23
|
+
type NotificationPolicyAttributes,
|
|
24
|
+
type NotificationPolicyProps,
|
|
25
|
+
type PolicyRoute,
|
|
26
|
+
GrafanaNotificationPolicy,
|
|
27
|
+
GrafanaNotificationPolicyProvider,
|
|
28
|
+
} from './notification-policy.ts';
|
|
22
29
|
export {
|
|
23
30
|
type DatasourceAttributes,
|
|
24
31
|
type DatasourceProps,
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `warnOnDroppedRoutes` — pure logic, then wired through `diff`/`update` on a fake Grafana. Mirrors
|
|
3
|
+
* `alert-rule-group-drop-warning.test.ts`: captures `Effect.logWarning` with a `Logger`, the house
|
|
4
|
+
* pattern `caddy/config-format-warning.test.ts` established.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Logger from 'effect/Logger';
|
|
9
|
+
import { PATH, liveTree, props } from './notification-policy-fixtures.ts';
|
|
10
|
+
import { handlers, spec } from './notification-policy.ts';
|
|
11
|
+
import { warnOnDroppedRoutes } from './notification-policy-drop-warning.ts';
|
|
12
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
13
|
+
import { grafanaOperations } from './resource.ts';
|
|
14
|
+
|
|
15
|
+
const withWarnings = async <A>(effect: Effect.Effect<A, unknown, never>) => {
|
|
16
|
+
const warnings: string[] = [];
|
|
17
|
+
const collect = Logger.make((options) => {
|
|
18
|
+
if (options.logLevel === 'Warn') warnings.push(String(options.message));
|
|
19
|
+
});
|
|
20
|
+
const value = await Effect.runPromise(effect.pipe(Effect.provide(Logger.layer([collect]))));
|
|
21
|
+
return { value, warnings };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('warnOnDroppedRoutes — pure', () => {
|
|
25
|
+
test('logs nothing when the declared tree already has every live route', async () => {
|
|
26
|
+
const tree = { receiver: 'default', routes: [{ receiver: 'pagerduty' }] };
|
|
27
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(tree, tree));
|
|
28
|
+
expect(warnings).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('names a dropped nested route by receiver and path', async () => {
|
|
32
|
+
const declared = { receiver: 'default', routes: [{ receiver: 'pagerduty' }] };
|
|
33
|
+
const live = {
|
|
34
|
+
receiver: 'default',
|
|
35
|
+
routes: [{ receiver: 'pagerduty' }, { matchers: ['team=x'], receiver: 'slack' }],
|
|
36
|
+
};
|
|
37
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(declared, live));
|
|
38
|
+
expect(warnings).toHaveLength(1);
|
|
39
|
+
expect(warnings[0]).toContain('slack (root.1)');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a changed root receiver is never reported as "dropped" — root is replaced, not removed', async () => {
|
|
43
|
+
const { warnings } = await withWarnings(
|
|
44
|
+
warnOnDroppedRoutes({ receiver: 'new' }, { receiver: 'old' }),
|
|
45
|
+
);
|
|
46
|
+
expect(warnings).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the same two routes reordered logs nothing — reordering is not dropping', async () => {
|
|
50
|
+
// ⛔ THE POINT: `matches()` (subset-match's position-significant array rule) still shows
|
|
51
|
+
// `update` for a reorder — this warning stays silent because nothing is actually REMOVED,
|
|
52
|
+
// complementing rather than duplicating that signal.
|
|
53
|
+
const a = { receiver: 'pagerduty' };
|
|
54
|
+
const b = { receiver: 'slack' };
|
|
55
|
+
const { warnings } = await withWarnings(
|
|
56
|
+
warnOnDroppedRoutes(
|
|
57
|
+
{ receiver: 'default', routes: [b, a] },
|
|
58
|
+
{ receiver: 'default', routes: [a, b] },
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
expect(warnings).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a duplicate route added live warns — the multiset fix, not the original Set collision', async () => {
|
|
65
|
+
// ⛔ THE REGRESSION AN ADVERSARIAL REVIEW OF THIS PR FOUND: the first version's `Set`-based
|
|
66
|
+
// identity collided this duplicate with the already-declared one and warned nothing, even
|
|
67
|
+
// though the PUT drops it. `severity=warning` -> `slack` is declared once; live has it TWICE.
|
|
68
|
+
const declared = {
|
|
69
|
+
receiver: 'default',
|
|
70
|
+
routes: [
|
|
71
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
72
|
+
{ matchers: ['severity=critical'], receiver: 'pagerduty' },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
const live = {
|
|
76
|
+
receiver: 'default',
|
|
77
|
+
routes: [
|
|
78
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
79
|
+
{ matchers: ['severity=critical'], receiver: 'pagerduty' },
|
|
80
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(declared, live));
|
|
84
|
+
expect(warnings).toHaveLength(1);
|
|
85
|
+
expect(warnings[0]).toContain('REMOVES 1 route');
|
|
86
|
+
expect(warnings[0]).toContain('slack (root.2)');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an identical route under a DIFFERENT (undeclared) parent still warns — distinct lineage', async () => {
|
|
90
|
+
const declared = {
|
|
91
|
+
receiver: 'default',
|
|
92
|
+
routes: [{ receiver: 'p1', routes: [{ receiver: 'slack' }] }],
|
|
93
|
+
};
|
|
94
|
+
const live = {
|
|
95
|
+
receiver: 'default',
|
|
96
|
+
routes: [
|
|
97
|
+
{ receiver: 'p1', routes: [{ receiver: 'slack' }] },
|
|
98
|
+
{ receiver: 'p2', routes: [{ receiver: 'slack' }] },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(declared, live));
|
|
102
|
+
expect(warnings).toHaveLength(1);
|
|
103
|
+
// ⛔ NOT root.0.0 (that one IS declared, under p1) — the dropped one is the p2 lineage's.
|
|
104
|
+
expect(warnings[0]).toContain('slack (root.1.0)');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('two declared duplicates vs two live duplicates warns nothing — counts match', async () => {
|
|
108
|
+
const pair = {
|
|
109
|
+
receiver: 'default',
|
|
110
|
+
routes: [{ receiver: 'slack' }, { receiver: 'slack' }],
|
|
111
|
+
};
|
|
112
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(pair, pair));
|
|
113
|
+
expect(warnings).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('wired into diff and update', () => {
|
|
118
|
+
test('diff logs the dropped-route warning before anything is written', async () => {
|
|
119
|
+
const extra = { matchers: ['team=x'], receiver: 'slack' };
|
|
120
|
+
const withExtra = { ...liveTree, routes: [...liveTree.routes, extra] };
|
|
121
|
+
const fake = fakeGrafana((method, url) =>
|
|
122
|
+
method === 'GET' && url.pathname === PATH
|
|
123
|
+
? Response.json(withExtra)
|
|
124
|
+
: fakeFailure(500, 'diff should not write'),
|
|
125
|
+
);
|
|
126
|
+
const { warnings, value } = await withWarnings(
|
|
127
|
+
handlers
|
|
128
|
+
.diff({ news: props, output: { provenance: 'api', route: props.route } })
|
|
129
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))) as Effect.Effect<
|
|
130
|
+
unknown,
|
|
131
|
+
unknown,
|
|
132
|
+
never
|
|
133
|
+
>,
|
|
134
|
+
);
|
|
135
|
+
// ⚠️ NOT 'noop': subset-match's array rule requires EXACT length, so live carrying one MORE
|
|
136
|
+
// route than declared already disagrees on its own — the warning and the mismatch are the
|
|
137
|
+
// same signal here, not independent (see the file header on `matches` vs this warning).
|
|
138
|
+
expect(value).toEqual({ action: 'update' });
|
|
139
|
+
expect(warnings).toHaveLength(1);
|
|
140
|
+
expect(warnings[0]).toContain('slack');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('update logs the same warning right before the PUT that would drop the route', async () => {
|
|
144
|
+
const extra = { matchers: ['team=x'], receiver: 'slack' };
|
|
145
|
+
const withExtra = { ...liveTree, routes: [...liveTree.routes, extra] };
|
|
146
|
+
const fake = fakeGrafana((method, url) => {
|
|
147
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(withExtra);
|
|
148
|
+
if (method === 'PUT' && url.pathname === PATH) return Response.json(withExtra);
|
|
149
|
+
return fakeFailure(500, 'unexpected request');
|
|
150
|
+
});
|
|
151
|
+
const { warnings } = await withWarnings(
|
|
152
|
+
grafanaOperations(spec)
|
|
153
|
+
.reconcile({ route: { ...props.route, receiver: 'changed' } })
|
|
154
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))) as Effect.Effect<
|
|
155
|
+
unknown,
|
|
156
|
+
unknown,
|
|
157
|
+
never
|
|
158
|
+
>,
|
|
159
|
+
);
|
|
160
|
+
expect(warnings).toHaveLength(1);
|
|
161
|
+
expect(warnings[0]).toContain('slack');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The warning `Grafana.NotificationPolicy` logs when a whole-tree `PUT` would silently drop a live
|
|
3
|
+
* sub-route the declaration doesn't account for — same shape as `alert-rule-group-drop-warning.ts`
|
|
4
|
+
* (kit PR 254's own fix, after an adversarial review found the identical risk for rule groups):
|
|
5
|
+
* `PUT /v1/provisioning/policies` REPLACES the entire tree, so a nested route someone added
|
|
6
|
+
* out-of-band is removed with no error and no distinct `Diff` action to report it under.
|
|
7
|
+
*
|
|
8
|
+
* ★ `Effect.logWarning`, NEVER A REFUSAL — same reasoning as `alert-rule-group-drop-warning.ts`'s
|
|
9
|
+
* own header, and further upstream `proxmox/unreadable-read.ts`'s `unreadableWarning`. A
|
|
10
|
+
* whole-tree write dropping an out-of-band route is EXPECTED for a resource whose whole point is
|
|
11
|
+
* to own the tree — only a silent one is worth surfacing loudly.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ MULTISET COMPARISON, NOT SET MEMBERSHIP — fixed after an adversarial review of this PR found
|
|
14
|
+
* the first version's `Set`-based check collided ACROSS THE WHOLE TREE: any two nodes anywhere
|
|
15
|
+
* with identical own-fields shared one `Set` entry, so a duplicate route added out-of-band
|
|
16
|
+
* (e.g. a second `severity=warning -> slack` alongside an already-declared one) matched the
|
|
17
|
+
* existing entry and went unwarned even though the PUT would still drop it — worse than the
|
|
18
|
+
* documented "siblings only" limitation the original comment claimed. Every key from
|
|
19
|
+
* `FlatRoute.key` (ancestry-scoped — see that field's own doc) is now counted per side, and a
|
|
20
|
+
* `key` is only "dropped" for the amount its LIVE count exceeds its DECLARED count — N identical
|
|
21
|
+
* live occurrences under the same parent are dropped only past however many the declaration
|
|
22
|
+
* itself repeats, never all of them at once just because one is declared.
|
|
23
|
+
*/
|
|
24
|
+
import * as Effect from 'effect/Effect';
|
|
25
|
+
import { type PolicyRoute, flattenRoutes, normalizeRoute } from './notification-policy-model.ts';
|
|
26
|
+
|
|
27
|
+
const countByKey = (routes: ReadonlyArray<{ readonly key: string }>): Map<string, number> => {
|
|
28
|
+
const counts = new Map<string, number>();
|
|
29
|
+
for (const route of routes) counts.set(route.key, (counts.get(route.key) ?? 0) + 1);
|
|
30
|
+
return counts;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const warnOnDroppedRoutes = (
|
|
34
|
+
declared: PolicyRoute,
|
|
35
|
+
live: PolicyRoute,
|
|
36
|
+
): Effect.Effect<void> =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
// Root is never "dropped" — it is always replaced, not removed (the file header's own note).
|
|
39
|
+
const declaredNodes = flattenRoutes(normalizeRoute(declared)).filter((r) => r.path !== 'root');
|
|
40
|
+
const liveNodes = flattenRoutes(normalizeRoute(live)).filter((r) => r.path !== 'root');
|
|
41
|
+
const declaredCounts = countByKey(declaredNodes);
|
|
42
|
+
const liveCounts = countByKey(liveNodes);
|
|
43
|
+
|
|
44
|
+
const dropped: Array<{ readonly path: string; readonly receiver: string }> = [];
|
|
45
|
+
for (const [key, liveCount] of liveCounts) {
|
|
46
|
+
const extra = liveCount - (declaredCounts.get(key) ?? 0);
|
|
47
|
+
if (extra <= 0) continue;
|
|
48
|
+
// Report the LAST `extra` live occurrences of this key, by path — an arbitrary but
|
|
49
|
+
// deterministic choice among indistinguishable duplicates (multiset identity, no position).
|
|
50
|
+
const occurrences = liveNodes.filter((route) => route.key === key);
|
|
51
|
+
dropped.push(...occurrences.slice(-extra));
|
|
52
|
+
}
|
|
53
|
+
if (dropped.length === 0) return;
|
|
54
|
+
const names = dropped.map((route) => `${route.receiver} (${route.path})`).join(', ');
|
|
55
|
+
yield* Effect.logWarning(
|
|
56
|
+
`Grafana.NotificationPolicy: this update REMOVES ${dropped.length} route(s)/matcher(s) not ` +
|
|
57
|
+
`in the declaration: ${names}. Whole-tree replacement is by design — add them to the ` +
|
|
58
|
+
`declared tree to keep them.`,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the `notification-policy*.test.ts` files — split to keep each file under
|
|
3
|
+
* the house's 250-line cap. Deliberately NOT named `*.test.ts` — see `contact-point-fixtures.ts`'s
|
|
4
|
+
* own header for why.
|
|
5
|
+
*/
|
|
6
|
+
export const PATH = '/api/v1/provisioning/policies';
|
|
7
|
+
|
|
8
|
+
export const liveTree = {
|
|
9
|
+
group_by: ['alertname'],
|
|
10
|
+
provenance: 'api',
|
|
11
|
+
receiver: 'default-receiver',
|
|
12
|
+
routes: [
|
|
13
|
+
{ matchers: ['severity=critical'], receiver: 'pagerduty' },
|
|
14
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const declaredRoute = {
|
|
19
|
+
group_by: ['alertname'],
|
|
20
|
+
receiver: 'default-receiver',
|
|
21
|
+
routes: [
|
|
22
|
+
{ matchers: ['severity=critical'], receiver: 'pagerduty' },
|
|
23
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const props = { route: declaredRoute };
|