@homeflare/alchemy 0.49.0 → 0.50.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.
Files changed (47) hide show
  1. package/dist/kubernetes/helm-chart-bounded.d.ts +44 -0
  2. package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
  3. package/dist/kubernetes/index.d.ts +12 -0
  4. package/dist/kubernetes/index.d.ts.map +1 -0
  5. package/dist/kubernetes/index.js +585 -0
  6. package/dist/kubernetes/index.js.map +19 -0
  7. package/dist/kubernetes/ready-checks.d.ts +45 -0
  8. package/dist/kubernetes/ready-checks.d.ts.map +1 -0
  9. package/dist/kubernetes/ready-classify.d.ts +23 -0
  10. package/dist/kubernetes/ready-classify.d.ts.map +1 -0
  11. package/dist/kubernetes/ready-connect.d.ts +29 -0
  12. package/dist/kubernetes/ready-connect.d.ts.map +1 -0
  13. package/dist/kubernetes/ready-declare.d.ts +5 -0
  14. package/dist/kubernetes/ready-declare.d.ts.map +1 -0
  15. package/dist/kubernetes/ready-errors.d.ts +80 -0
  16. package/dist/kubernetes/ready-errors.d.ts.map +1 -0
  17. package/dist/kubernetes/ready-poll.d.ts +61 -0
  18. package/dist/kubernetes/ready-poll.d.ts.map +1 -0
  19. package/dist/kubernetes/ready.d.ts +74 -0
  20. package/dist/kubernetes/ready.d.ts.map +1 -0
  21. package/dist/talos/fake-apiserver.d.ts +11 -2
  22. package/dist/talos/fake-apiserver.d.ts.map +1 -1
  23. package/docs/kubernetes-ready.md +146 -0
  24. package/docs/peers.md +13 -0
  25. package/docs/provider-standard.md +7 -0
  26. package/package.json +7 -1
  27. package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
  28. package/src/kubernetes/helm-chart-bounded.ts +110 -0
  29. package/src/kubernetes/index.ts +28 -0
  30. package/src/kubernetes/ready-backoff.test.ts +124 -0
  31. package/src/kubernetes/ready-checks.test.ts +106 -0
  32. package/src/kubernetes/ready-checks.ts +165 -0
  33. package/src/kubernetes/ready-classify.ts +76 -0
  34. package/src/kubernetes/ready-connect.ts +52 -0
  35. package/src/kubernetes/ready-declare.test.ts +85 -0
  36. package/src/kubernetes/ready-declare.ts +50 -0
  37. package/src/kubernetes/ready-errors.ts +102 -0
  38. package/src/kubernetes/ready-first-install.test.ts +126 -0
  39. package/src/kubernetes/ready-poll-states.test.ts +92 -0
  40. package/src/kubernetes/ready-poll.test.ts +194 -0
  41. package/src/kubernetes/ready-poll.ts +215 -0
  42. package/src/kubernetes/ready-rollout.test.ts +58 -0
  43. package/src/kubernetes/ready-secrecy.test.ts +131 -0
  44. package/src/kubernetes/ready.fixtures.ts +103 -0
  45. package/src/kubernetes/ready.test.ts +205 -0
  46. package/src/kubernetes/ready.ts +188 -0
  47. package/src/talos/fake-apiserver.ts +21 -3
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The poll loop of `HomeFlare.Kubernetes.Ready`: one bounded GET per check, and the deadline.
3
+ * Reads go through alchemy's own `readObject` (TLS, CA and client-cert handling stay upstream's),
4
+ * over the transport `connectCluster` proved against the pinned uid.
5
+ *
6
+ * ⛔ Error classes live in `ready-classify.ts` (404 pending; transients tolerated ONLY in a poll;
7
+ * everything else propagates). A single pass (`tolerant: false`, used by `read`/`diff`)
8
+ * tolerates ONLY 404. The poll covers the CONNECT too (`ready-connect.ts`): the identity GET of
9
+ * a cluster whose apiserver is restarting under a CNI rollout is the same transient.
10
+ * ⚠️ upstream `readObject` takes no signal: a timed-out GET ABANDONS its socket rather than
11
+ * aborting it (`client.ts:71-74`); nothing here can close it, and process exit is NOT a bound
12
+ * for a long apply. So a timeout is rationed: once one GET of a pass times out the rest of that
13
+ * pass is pending without a GET, and the poll doubles its pause per consecutive timeout (cap
14
+ * 30 s), resetting on a clean pass. A hung apiserver then abandons a few dozen sockets in a 10 m
15
+ * wait, not ~120. ★ STANDING WATCH: drop this rationing when upstream's `readObject` takes an
16
+ * AbortSignal (alchemy PR 1948, per-attempt deadlines).
17
+ * ⚠️ upstream also retries a transport error itself, for about 40 s (`Schedule.max([spaced 5 s,
18
+ * recurs(8)])`, `client.ts:158-161`): longer than `GET_TIMEOUT`, so a dead socket surfaces here
19
+ * as the GET timeout, not as the transport error.
20
+ */
21
+ import type { ClusterTransport } from 'alchemy/Kubernetes/ClusterAdapter';
22
+ import { readObject } from 'alchemy/Kubernetes/internal/client';
23
+ import * as Data from 'effect/Data';
24
+ import * as Duration from 'effect/Duration';
25
+ import * as Effect from 'effect/Effect';
26
+ import * as Ref from 'effect/Ref';
27
+ import { isNotFound, scrubbed, transientOf } from './ready-classify.ts';
28
+ import { type ReadyCheck, checkKey, countsOf, evaluate } from './ready-checks.ts';
29
+ import {
30
+ type KeyState,
31
+ KubernetesReadyTimeout,
32
+ KubernetesRolloutFailed,
33
+ type LastTransient,
34
+ } from './ready-errors.ts';
35
+
36
+ export { KubernetesReadyApiError, isNotFound, transientOf } from './ready-classify.ts';
37
+
38
+ /** Each GET is bounded like `readClusterUid` (`talos/cluster-identity.ts`). */
39
+ export const GET_TIMEOUT = Duration.seconds(5);
40
+
41
+ /** One GET exceeded `GET_TIMEOUT`. Pending inside a poll, propagated by a single pass. */
42
+ export class KubernetesReadyGetTimeout extends Data.TaggedError('KubernetesReadyGetTimeout')<{
43
+ readonly check: string;
44
+ readonly seconds: number;
45
+ }> {
46
+ override get message(): string {
47
+ return `Kubernetes.Ready: GET for ${this.check} timed out after ${this.seconds}s`;
48
+ }
49
+ }
50
+
51
+ const REFS = {
52
+ CustomResourceDefinition: {
53
+ apiVersion: 'apiextensions.k8s.io/v1',
54
+ kind: 'CustomResourceDefinition',
55
+ },
56
+ DaemonSet: { apiVersion: 'apps/v1', kind: 'DaemonSet' },
57
+ Deployment: { apiVersion: 'apps/v1', kind: 'Deployment' },
58
+ } as const;
59
+
60
+ const refOf = (check: ReadyCheck) => ({
61
+ ...REFS[check.kind],
62
+ name: check.name,
63
+ ...('namespace' in check ? { namespace: check.namespace } : {}),
64
+ });
65
+
66
+ /** Longest pause the poll backs off to after consecutive GET timeouts. */
67
+ export const BACKOFF_CAP = Duration.seconds(30);
68
+
69
+ export interface PassResult {
70
+ /** Keys of checks that are not ready yet. */
71
+ readonly pending: readonly string[];
72
+ /** Keys of checks that are terminally failed (see `KubernetesRolloutFailed`). */
73
+ readonly failed: readonly string[];
74
+ /** What this pass saw per key it reached; a key that hit a transient has none. */
75
+ readonly states: Readonly<Record<string, KeyState>>;
76
+ readonly lastTransient?: LastTransient;
77
+ /** A GET timed out (its socket is abandoned): the poll backs off. */
78
+ readonly timedOut: boolean;
79
+ }
80
+
81
+ const getOne = (transport: ClusterTransport, check: ReadyCheck) =>
82
+ readObject({ object: refOf(check), transport }).pipe(
83
+ Effect.timeoutOrElse({
84
+ duration: GET_TIMEOUT,
85
+ orElse: () =>
86
+ Effect.fail(
87
+ new KubernetesReadyGetTimeout({
88
+ check: checkKey(check),
89
+ seconds: Duration.toSeconds(GET_TIMEOUT),
90
+ }),
91
+ ),
92
+ }),
93
+ );
94
+
95
+ /** One look at every check. `tolerant` selects the poll rules; see the header. */
96
+ export const pass = (
97
+ transport: ClusterTransport,
98
+ checks: readonly ReadyCheck[],
99
+ tolerant: boolean,
100
+ ): Effect.Effect<PassResult, unknown> =>
101
+ Effect.gen(function* () {
102
+ const pending: string[] = [];
103
+ const failed: string[] = [];
104
+ const states: Record<string, KeyState> = {};
105
+ let lastTransient: LastTransient | undefined;
106
+ let timedOut = false;
107
+ for (const check of checks) {
108
+ const key = checkKey(check);
109
+ // ⛔ After a timeout its socket is abandoned: issue no further GET this pass.
110
+ if (timedOut) {
111
+ pending.push(key);
112
+ continue;
113
+ }
114
+ const outcome = yield* getOne(transport, check).pipe(
115
+ Effect.map((body) => {
116
+ states[key] = countsOf(check, body);
117
+ return evaluate(check, body);
118
+ }),
119
+ Effect.catch((error: unknown) => {
120
+ if (isNotFound(error)) {
121
+ states[key] = { notFound: true };
122
+ return Effect.succeed('pending' as const);
123
+ }
124
+ const transient = tolerant ? transientOf(error) : undefined;
125
+ if (transient === undefined) return Effect.fail(scrubbed(error));
126
+ lastTransient = transient;
127
+ timedOut = timedOut || transient.tag === 'KubernetesReadyGetTimeout';
128
+ return Effect.succeed('pending' as const);
129
+ }),
130
+ );
131
+ if (outcome === 'pending') pending.push(key);
132
+ if (outcome === 'failed') failed.push(key);
133
+ }
134
+ return {
135
+ failed,
136
+ pending,
137
+ states,
138
+ timedOut,
139
+ ...(lastTransient === undefined ? {} : { lastTransient }),
140
+ };
141
+ });
142
+
143
+ /**
144
+ * Poll until every check is ready. A terminal failure ends it at once; otherwise the deadline
145
+ * fails with the check keys that were pending on the LAST pass, what that pass saw of each, and
146
+ * the transient error of the last pass only (⚠️ a transient that cleared is not to be blamed).
147
+ * `connect` runs inside the deadline (it retries its own transients, `ready-connect.ts`).
148
+ */
149
+ export const poll = <R>(
150
+ connect: (
151
+ note: (transient: LastTransient) => Effect.Effect<void>,
152
+ ) => Effect.Effect<ClusterTransport, unknown, R>,
153
+ checks: readonly ReadyCheck[],
154
+ waitTimeout: Duration.Duration,
155
+ pollInterval: Duration.Duration,
156
+ ) =>
157
+ Effect.gen(function* () {
158
+ const pendingNow = checks.map(checkKey);
159
+ const last = yield* Ref.make<{ pending: readonly string[]; states: Record<string, KeyState> }>({
160
+ pending: pendingNow,
161
+ states: {},
162
+ });
163
+ const transient = yield* Ref.make<LastTransient | undefined>(undefined);
164
+ const loop = Effect.gen(function* () {
165
+ const transport = yield* connect((t) => Ref.set(transient, t));
166
+ let pause = pollInterval;
167
+ while (true) {
168
+ const result = yield* pass(transport, checks, true);
169
+ yield* Ref.update(last, (prior) => ({
170
+ pending: result.pending,
171
+ states: { ...prior.states, ...result.states },
172
+ }));
173
+ // ★ Overwritten every pass, so a transient that has since cleared is not blamed.
174
+ yield* Ref.set(transient, result.lastTransient);
175
+ const [firstFailed] = result.failed;
176
+ if (firstFailed !== undefined) {
177
+ return yield* Effect.fail(
178
+ new KubernetesRolloutFailed({
179
+ check: firstFailed,
180
+ reason: firstFailed.startsWith('DaemonSet/')
181
+ ? 'UpdateStrategyNotRollingUpdate'
182
+ : 'ProgressDeadlineExceeded',
183
+ }),
184
+ );
185
+ }
186
+ if (result.pending.length === 0) return;
187
+ // ★ Double per consecutive timeout (cap 30 s, or the interval if longer); a clean pass resets.
188
+ pause = result.timedOut
189
+ ? Duration.min(Duration.times(pause, 2), Duration.max(BACKOFF_CAP, pollInterval))
190
+ : pollInterval;
191
+ yield* Effect.sleep(pause);
192
+ }
193
+ });
194
+ return yield* loop.pipe(
195
+ Effect.timeoutOrElse({
196
+ duration: waitTimeout,
197
+ orElse: () =>
198
+ Effect.gen(function* () {
199
+ const { pending, states } = yield* Ref.get(last);
200
+ const lastTransient = yield* Ref.get(transient);
201
+ const seen = Object.fromEntries(
202
+ pending.flatMap((k) => (states[k] ? [[k, states[k]]] : [])),
203
+ );
204
+ return yield* Effect.fail(
205
+ new KubernetesReadyTimeout({
206
+ failing: pending,
207
+ seconds: Duration.toSeconds(waitTimeout),
208
+ states: seen,
209
+ ...(lastTransient === undefined ? {} : { lastTransient }),
210
+ }),
211
+ );
212
+ }),
213
+ }),
214
+ );
215
+ });
@@ -0,0 +1,58 @@
1
+ /** Round 1, findings 1 and 7: the lower bound on updated-and-available pods, and the strategy rule. */
2
+ import { expect, test } from 'bun:test';
3
+ import { type ReadyCheck, evaluate } from './ready-checks.ts';
4
+
5
+ const check = (minReady?: number): ReadyCheck => ({
6
+ kind: 'DaemonSet',
7
+ name: 'cilium',
8
+ namespace: 'kube-system',
9
+ ...(minReady === undefined ? {} : { minReady }),
10
+ });
11
+ const ds = (status: object, minReady?: number, spec: object = {}) =>
12
+ evaluate(check(minReady), { metadata: { generation: 2 }, spec, status });
13
+ const status = (desired: number, updated: number, available: number) => ({
14
+ desiredNumberScheduled: desired,
15
+ numberAvailable: available,
16
+ numberReady: available,
17
+ observedGeneration: 2,
18
+ updatedNumberScheduled: updated,
19
+ });
20
+
21
+ // `numberAvailable` counts AVAILABLE OLD-template pods too (kubectl demands updated == desired).
22
+ test('(a) 4 nodes, minReady 2: 2 new pods crashloop, 2 old pods available is pending', () => {
23
+ expect(ds(status(4, 2, 2), 2)).toBe('pending');
24
+ });
25
+
26
+ test('(b) 3 nodes: new pod on A works, new pod on B crashloops, C runs the old pod: pending', () => {
27
+ expect(ds(status(3, 2, 2), 2)).toBe('pending');
28
+ });
29
+
30
+ test('once the new pods really are available the same shapes are ready', () => {
31
+ expect(ds(status(4, 2, 4), 2)).toBe('ready'); // 2 updated, 2 old: 4 - 2 = 2 updated-available
32
+ expect(ds(status(3, 2, 3), 2)).toBe('ready');
33
+ });
34
+
35
+ test('the bound is a lower bound, not an exact count', () => {
36
+ // all 3 on the new template, 2 available: 2 - 0 >= 2
37
+ expect(ds(status(3, 3, 2), 2)).toBe('ready');
38
+ // 3 on the new template and 1 available is not
39
+ expect(ds(status(3, 3, 1), 2)).toBe('pending');
40
+ // updated below the bar fails even when everything is available
41
+ expect(ds(status(4, 1, 4), 2)).toBe('pending');
42
+ });
43
+
44
+ test('the default minReady (every node) is unchanged by the bound', () => {
45
+ expect(ds(status(3, 3, 3))).toBe('ready');
46
+ expect(ds(status(3, 2, 3))).toBe('pending');
47
+ });
48
+
49
+ test('a non-RollingUpdate strategy is a terminal failure; absent means RollingUpdate', () => {
50
+ const rolled = status(3, 3, 3);
51
+ expect(ds(rolled, undefined, { updateStrategy: { type: 'OnDelete' } })).toBe('failed');
52
+ expect(ds(rolled, undefined, { updateStrategy: { type: 'RollingUpdate' } })).toBe('ready');
53
+ expect(ds(rolled, undefined, {})).toBe('ready');
54
+ // checked before the generation rule, as kubectl does
55
+ expect(
56
+ ds({ ...rolled, observedGeneration: 1 }, undefined, { updateStrategy: { type: 'x' } }),
57
+ ).toBe('failed');
58
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Secrecy of `HomeFlare.Kubernetes.Ready`: no PEM marker, kubeconfig byte or apiserver response
3
+ * body reaches an attribute, an error, a log line, or a file. The vault is the fake `bao` of the
4
+ * adapter fixtures (it serves a kubeconfig with PEM blocks); the apiserver bodies carry a needle.
5
+ */
6
+ import { expect, spyOn, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Exit from 'effect/Exit';
9
+ import * as Cause from 'effect/Cause';
10
+ import * as Logger from 'effect/Logger';
11
+ import * as References from 'effect/References';
12
+ import type { FakeObject } from '../talos/fake-apiserver.ts';
13
+ import { vault } from '../talos/cluster-adapter.fixtures.ts';
14
+ import { diffReady, readReady, reconcileReady } from './ready.ts';
15
+ import {
16
+ CRD_PATH,
17
+ DEP_PATH,
18
+ DS_PATH,
19
+ connection,
20
+ crdCheck,
21
+ depCheck,
22
+ driven,
23
+ dsCheck,
24
+ get,
25
+ readyCrd,
26
+ readyDep,
27
+ readyDs,
28
+ withCluster,
29
+ } from './ready.fixtures.ts';
30
+
31
+ const BODY_NEEDLE = 'apiserver-body-needle-7f3a';
32
+ const pemMarker = `-----${'BEGIN'}`;
33
+ const b64 = (text: string) => Buffer.from(text).toString('base64');
34
+ const props = {
35
+ checks: [dsCheck, depCheck, crdCheck],
36
+ connection,
37
+ waitTimeout: '20s',
38
+ pollInterval: '5s',
39
+ };
40
+
41
+ // The vault's own document is the needle source: every field value the fake `bao` serves.
42
+ const served = JSON.parse(
43
+ vault({ args: ['talos-c1/data/kubeconfig'], command: 'bao' }).stdout ?? '{}',
44
+ ) as {
45
+ data: { data: { kubeconfig: string } };
46
+ };
47
+ const needles = [
48
+ BODY_NEEDLE,
49
+ pemMarker,
50
+ served.data.data.kubeconfig,
51
+ b64(`${pemMarker} CERTIFICATE-----`),
52
+ b64(`${pemMarker} PRIVATE KEY-----`),
53
+ 'client-key-data',
54
+ 'certificate-authority-data',
55
+ ];
56
+ const leaks = (text: string) => needles.filter((needle) => text.includes(needle));
57
+ const dump = (value: unknown) =>
58
+ JSON.stringify(value, value instanceof Object ? Object.getOwnPropertyNames(value) : undefined) +
59
+ String(value);
60
+
61
+ const ready: Record<string, FakeObject> = {
62
+ [get(CRD_PATH)]: { body: readyCrd },
63
+ [get(DEP_PATH)]: { body: readyDep },
64
+ [get(DS_PATH)]: { body: readyDs },
65
+ };
66
+
67
+ test('attributes, errors and logs of every path carry no vault or response byte', async () => {
68
+ const logged: string[] = [];
69
+ const capture = Logger.layer([
70
+ Logger.make(({ message }) => void logged.push(JSON.stringify(message))),
71
+ ]);
72
+ const saved = { ...console };
73
+ for (const level of ['log', 'debug', 'info', 'warn', 'error'] as const) {
74
+ console[level] = (...args: unknown[]) => void logged.push(args.map(String).join(' '));
75
+ }
76
+ const writes = [spyOn(Bun, 'write'), spyOn(Bun, 'spawn')];
77
+ const outputs: unknown[] = [];
78
+ try {
79
+ const attempt = async (
80
+ objects: Record<string, FakeObject>,
81
+ run: Effect.Effect<unknown, unknown, never>,
82
+ ) => {
83
+ const exit = await withCluster(
84
+ objects,
85
+ driven(
86
+ run.pipe(
87
+ Effect.provideService(References.MinimumLogLevel, 'All'),
88
+ Effect.provide(capture),
89
+ ),
90
+ 100,
91
+ ) as never,
92
+ );
93
+ const result = exit as Exit.Exit<unknown, unknown>;
94
+ outputs.push(Exit.isSuccess(result) ? result.value : Cause.squash(result.cause));
95
+ };
96
+ const failing = (entry: FakeObject): Record<string, FakeObject> => ({
97
+ ...ready,
98
+ [get(DEP_PATH)]: { ...entry, body: { message: BODY_NEEDLE } },
99
+ });
100
+ for (const entry of [
101
+ { status: 200 },
102
+ { status: 404 },
103
+ { status: 503 },
104
+ { status: 403 },
105
+ { status: 429 },
106
+ ]) {
107
+ for (const run of [
108
+ readReady(props),
109
+ reconcileReady(props),
110
+ diffReady(props as never, { checks: '', connection, ready: false }),
111
+ ]) {
112
+ await attempt(failing(entry), run as never);
113
+ }
114
+ }
115
+ await attempt(ready, reconcileReady(props) as never);
116
+ } finally {
117
+ Object.assign(console, saved);
118
+ for (const spy of writes) {
119
+ expect(spy).not.toHaveBeenCalled();
120
+ spy.mockRestore();
121
+ }
122
+ }
123
+ expect(outputs.length).toBeGreaterThan(10);
124
+ expect(leaks(outputs.map(dump).join('\n'))).toEqual([]);
125
+ expect(leaks(logged.join('\n'))).toEqual([]);
126
+ });
127
+
128
+ test('the probe can fail: a response body quoted into an error is caught by the needle check', () => {
129
+ const quoted = new Error(`GET responded 503: ${JSON.stringify({ message: BODY_NEEDLE })}`);
130
+ expect(leaks(dump(quoted))).toEqual([BODY_NEEDLE]);
131
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared harness for the `HomeFlare.Kubernetes.Ready` tests: the REAL `talos-openbao` adapter over a
3
+ * fake `bao` and the fake apiserver (`talos/fake-apiserver.ts`), so uid proof, `readObject` and the
4
+ * TLS-free request path all run for real. Nothing leaves the process.
5
+ */
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Fiber from 'effect/Fiber';
8
+ import * as Layer from 'effect/Layer';
9
+ import * as TestClock from 'effect/testing/TestClock';
10
+ import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
11
+ import { TalosOpenBaoAdapter, talosOpenBaoConnection } from '../talos/cluster-adapter.ts';
12
+ import { UIDS, config, vault } from '../talos/cluster-adapter.fixtures.ts';
13
+ import { type FakeHandler, fakeSpawner } from '../talos/fake-process.ts';
14
+ import { type FakeObject, fakeApiServer } from '../talos/fake-apiserver.ts';
15
+ import type { ReadyCheck } from './ready-checks.ts';
16
+
17
+ export const HOST = 'c1.cluster.invalid';
18
+ export const connection = talosOpenBaoConnection('uid-c1');
19
+
20
+ export const DS_PATH = `/apis/apps/v1/namespaces/kube-system/daemonsets/cilium`;
21
+ export const DEP_PATH = `/apis/apps/v1/namespaces/kube-system/deployments/cilium-operator`;
22
+ export const CRD_PATH = `/apis/apiextensions.k8s.io/v1/customresourcedefinitions/x.cilium.io`;
23
+
24
+ export const dsCheck: ReadyCheck = { kind: 'DaemonSet', name: 'cilium', namespace: 'kube-system' };
25
+ export const depCheck: ReadyCheck = {
26
+ kind: 'Deployment',
27
+ name: 'cilium-operator',
28
+ namespace: 'kube-system',
29
+ };
30
+ export const crdCheck: ReadyCheck = { kind: 'CustomResourceDefinition', name: 'x.cilium.io' };
31
+
32
+ export const checksOf = (): ReadyCheck[] => [dsCheck, depCheck, crdCheck];
33
+
34
+ export const readyDs = {
35
+ metadata: { generation: 2 },
36
+ status: {
37
+ desiredNumberScheduled: 3,
38
+ numberAvailable: 3,
39
+ numberReady: 3,
40
+ observedGeneration: 2,
41
+ updatedNumberScheduled: 3,
42
+ },
43
+ };
44
+ export const readyDep = {
45
+ metadata: { generation: 4 },
46
+ spec: { replicas: 2 },
47
+ status: { availableReplicas: 2, observedGeneration: 4, replicas: 2, updatedReplicas: 2 },
48
+ };
49
+ export const readyCrd = { status: { conditions: [{ status: 'True', type: 'Established' }] } };
50
+
51
+ export const get = (path: string) => `GET ${HOST}${path}`;
52
+
53
+ export const IDENTITY_PATH = '/api/v1/namespaces/kube-system';
54
+
55
+ /**
56
+ * A live table whose `key` answers `entries[n]` on the n-th request (the last one thereafter):
57
+ * a cluster that is flaky for a while and then recovers, without real timers.
58
+ */
59
+ export const sequence = (
60
+ objects: Record<string, FakeObject>,
61
+ key: string,
62
+ entries: readonly (FakeObject | undefined)[],
63
+ ): Record<string, FakeObject> => {
64
+ let calls = 0;
65
+ return new Proxy(objects, {
66
+ get: (target, prop) => {
67
+ if (prop !== key) return target[prop as string];
68
+ return entries[Math.min(calls++, entries.length - 1)];
69
+ },
70
+ });
71
+ };
72
+
73
+ /**
74
+ * Run `effect` against the fake cluster. `objects` is the live table: a test may mutate it from
75
+ * inside `effect`. The fake `https` module is restored on every exit path.
76
+ */
77
+ export const withCluster = async <A, E>(
78
+ objects: Record<string, FakeObject>,
79
+ effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner | never>,
80
+ handler: FakeHandler = vault,
81
+ ) => {
82
+ const api = fakeApiServer(UIDS, [], objects);
83
+ const spawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fakeSpawner(handler));
84
+ try {
85
+ const layer = Layer.mergeAll(TalosOpenBaoAdapter(config).pipe(Layer.provide(spawner)), spawner);
86
+ return await Effect.runPromise(
87
+ effect.pipe(Effect.provide(layer), Effect.provide(TestClock.layer())) as Effect.Effect<A, E>,
88
+ );
89
+ } finally {
90
+ api.restore();
91
+ }
92
+ };
93
+
94
+ /** Drive TestClock until `effect` settles (virtual seconds, no real waiting). */
95
+ export const driven = <A, E, R>(effect: Effect.Effect<A, E, R>, seconds = 1000) =>
96
+ Effect.gen(function* () {
97
+ const fiber = yield* Effect.forkChild(Effect.exit(effect));
98
+ for (let i = 0; i < seconds; i++) {
99
+ yield* Effect.yieldNow;
100
+ yield* TestClock.adjust('1 second');
101
+ }
102
+ return yield* Fiber.join(fiber);
103
+ });