@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,205 @@
1
+ /**
2
+ * The `HomeFlare.Kubernetes.Ready` handlers through the real adapter: read, reconcile, diff, and the
3
+ * failures that must propagate. Fake `bao` and fake apiserver only; time is TestClock's.
4
+ */
5
+ import { expect, test } from 'bun:test';
6
+ import { readFileSync } from 'node:fs';
7
+ import * as Duration from 'effect/Duration';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Exit from 'effect/Exit';
10
+ import * as Cause from 'effect/Cause';
11
+ import * as Output from 'alchemy/Output';
12
+ import { talosOpenBaoConnection } from '../talos/cluster-adapter.ts';
13
+ import type { FakeObject } from '../talos/fake-apiserver.ts';
14
+ import { KubernetesReady, diffReady, parseGoDuration, readReady, reconcileReady } from './ready.ts';
15
+ import {
16
+ CRD_PATH,
17
+ DEP_PATH,
18
+ DS_PATH,
19
+ HOST,
20
+ connection,
21
+ crdCheck,
22
+ depCheck,
23
+ driven,
24
+ dsCheck,
25
+ get,
26
+ readyCrd,
27
+ readyDep,
28
+ readyDs,
29
+ withCluster,
30
+ } from './ready.fixtures.ts';
31
+
32
+ const checks = [dsCheck, depCheck, crdCheck];
33
+ const props = (extra: object = {}) => ({ checks, connection, ...extra });
34
+ const allReady = (): Record<string, FakeObject> => ({
35
+ [get(CRD_PATH)]: { body: readyCrd },
36
+ [get(DEP_PATH)]: { body: readyDep },
37
+ [get(DS_PATH)]: { body: readyDs },
38
+ });
39
+ const settle = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
40
+ driven(effect, 700).pipe(
41
+ Effect.map((exit) =>
42
+ Exit.isSuccess(exit)
43
+ ? { ok: exit.value }
44
+ : { error: Cause.squash(exit.cause) as { _tag?: string } },
45
+ ),
46
+ );
47
+ const csv = checks
48
+ .map((c) =>
49
+ c.kind === 'CustomResourceDefinition'
50
+ ? `${c.kind}/${c.name}`
51
+ : `${c.kind}/${c.namespace}/${c.name}`,
52
+ )
53
+ .join(',');
54
+ const prior = (ready = true) => ({ checks: csv, connection, ready });
55
+
56
+ test('read: ready:true when every check passes, false when one is pending (404 included)', async () => {
57
+ expect((await withCluster(allReady(), readReady(props()))).ready).toBe(true);
58
+ const stale = {
59
+ ...allReady(),
60
+ [get(DS_PATH)]: { body: { ...readyDs, status: { ...readyDs.status, observedGeneration: 1 } } },
61
+ };
62
+ expect((await withCluster(stale, readReady(props()))).ready).toBe(false);
63
+ const missing = { ...allReady(), [get(CRD_PATH)]: { status: 404 } };
64
+ expect((await withCluster(missing, readReady(props()))).ready).toBe(false);
65
+ });
66
+
67
+ test('read propagates 5xx, 429 and a hung GET (only a poll tolerates them)', async () => {
68
+ for (const [entry, tag] of [
69
+ [{ status: 503 }, 'KubernetesReadyApiError'],
70
+ [{ status: 429 }, 'KubernetesReadyApiError'],
71
+ [{ hang: true }, 'KubernetesReadyGetTimeout'],
72
+ ] as const) {
73
+ const out = await withCluster(
74
+ { ...allReady(), [get(DEP_PATH)]: entry },
75
+ settle(readReady(props())),
76
+ );
77
+ expect((out as { error: { _tag: string } }).error._tag).toBe(tag);
78
+ }
79
+ });
80
+
81
+ test('reconcile: all ready returns ready attributes; a never-ready check times out with its key', async () => {
82
+ const ok = await withCluster(allReady(), settle(reconcileReady(props())));
83
+ expect(ok).toEqual({ ok: { checks: csv, connection, ready: true } });
84
+ const bad = {
85
+ ...allReady(),
86
+ [get(DS_PATH)]: {
87
+ body: { ...readyDs, status: { ...readyDs.status, updatedNumberScheduled: 0 } },
88
+ },
89
+ };
90
+ const out = (await withCluster(
91
+ bad,
92
+ settle(reconcileReady(props({ waitTimeout: '30s', pollInterval: '5s' }))),
93
+ )) as {
94
+ error: Record<string, unknown>;
95
+ };
96
+ expect(out.error['_tag']).toBe('KubernetesReadyTimeout');
97
+ expect(out.error['failing']).toEqual(['DaemonSet/kube-system/cilium']);
98
+ expect(out.error['seconds']).toBe(30);
99
+ });
100
+
101
+ test('401 and 403 propagate through read and reconcile, never become pending', async () => {
102
+ for (const status of [401, 403]) {
103
+ const objects = { ...allReady(), [get(DS_PATH)]: { status } };
104
+ for (const run of [readReady(props()), reconcileReady(props())]) {
105
+ const out = (await withCluster(objects, settle(run))) as {
106
+ error: { _tag: string; statusCode: number };
107
+ };
108
+ expect(out.error).toMatchObject({ _tag: 'KubernetesReadyApiError', statusCode: status });
109
+ }
110
+ }
111
+ });
112
+
113
+ test('a bao failure propagates (a vault outage is not "not ready yet")', async () => {
114
+ const down = () => ({ exitCode: 2, stderr: 'permission denied' });
115
+ for (const run of [readReady(props()), reconcileReady(props())]) {
116
+ const out = (await withCluster(allReady(), settle(run), down)) as { error: Error };
117
+ expect(String(out.error)).not.toContain('KubernetesReadyTimeout');
118
+ expect(out.error).toBeDefined();
119
+ }
120
+ });
121
+
122
+ test('a uid mismatch is the typed identity error, before any check is read', async () => {
123
+ const objects = allReady();
124
+ const other = { ...props(), connection: talosOpenBaoConnection('uid-c1') };
125
+ // the apiserver answers a different kube-system uid than the pin
126
+ const out = await withCluster(
127
+ {
128
+ ...objects,
129
+ [`GET ${HOST}/api/v1/namespaces/kube-system`]: { body: { metadata: { uid: 'uid-other' } } },
130
+ },
131
+ settle(readReady(other)),
132
+ );
133
+ expect((out as { error: { _tag: string } }).error._tag).toBe('TalosClusterIdentityMismatch');
134
+ });
135
+
136
+ test('diff: noop when ready and unchanged; update when pending, changed checks or connection', async () => {
137
+ const run = (
138
+ objects: Record<string, FakeObject>,
139
+ output: ReturnType<typeof prior> | undefined,
140
+ news = props(),
141
+ ) => withCluster(objects, diffReady(news as never, output));
142
+ expect(await run(allReady(), prior())).toEqual({ action: 'noop' });
143
+ expect(await run(allReady(), undefined)).toBeUndefined();
144
+ expect(await run(allReady(), { ...prior(), checks: 'DaemonSet/kube-system/other' })).toEqual({
145
+ action: 'update',
146
+ });
147
+ // connection is `stables`: a changed (or missing) one REPLACES, so dependants see the new one
148
+ expect(
149
+ await run(allReady(), { ...prior(), connection: talosOpenBaoConnection('uid-c2') }),
150
+ ).toEqual({ action: 'replace' });
151
+ const { connection: _gone, ...legacy } = prior();
152
+ expect(await run(allReady(), legacy as never)).toEqual({ action: 'replace' });
153
+ const pending = { ...allReady(), [get(CRD_PATH)]: { status: 404 } };
154
+ expect(await run(pending, prior())).toEqual({ action: 'update' });
155
+ });
156
+
157
+ test('diff refuses a non-literal connection with the typed error, and so does declaration', async () => {
158
+ const bad = {
159
+ ...props(),
160
+ connection: {
161
+ auth: { kind: 'talos-openbao', uid: Output.literal('uid-c1') as unknown as string },
162
+ },
163
+ };
164
+ const error = await withCluster(allReady(), Effect.flip(diffReady(bad as never, prior())));
165
+ expect((error as { _tag: string })._tag).toBe('TalosUidNotLiteral');
166
+ const declared = await Effect.runPromise(
167
+ Effect.flip(KubernetesReady('x', bad as never) as never),
168
+ );
169
+ expect((declared as { _tag: string })._tag).toBe('TalosUidNotLiteral');
170
+ });
171
+
172
+ test('durations: Go style accepted, anything else typed-refused', async () => {
173
+ const ms = (v: string) => Effect.runPromise(parseGoDuration('waitTimeout', v));
174
+ expect(Duration.toMillis(await ms('10m0s'))).toBe(600_000);
175
+ expect(Duration.toMillis(await ms('1h30m'))).toBe(5_400_000);
176
+ for (const bad of ['', '10', 'ten minutes', '10m0', '1.5s', '0s', '0m0s', '0h']) {
177
+ const e = await Effect.runPromise(Effect.flip(parseGoDuration('waitTimeout', bad)));
178
+ expect(e._tag).toBe('KubernetesReadyBadDuration');
179
+ }
180
+ });
181
+
182
+ test('a long adversarial duration is refused fast (no polynomial backtracking)', async () => {
183
+ const hostiles = [`${'1'.repeat(10_000)}x`, `${'1h'.repeat(5_000)}x`, `${'9'.repeat(30)}s`];
184
+ for (const hostile of hostiles) {
185
+ const started = performance.now();
186
+ const e = await Effect.runPromise(Effect.flip(parseGoDuration('waitTimeout', hostile)));
187
+ // A generous sanity bound only (a loaded CI runner must not flake it); the regression guard is
188
+ // the deterministic source check below.
189
+ expect(performance.now() - started).toBeLessThan(1000);
190
+ expect(e._tag).toBe('KubernetesReadyBadDuration');
191
+ // The message quotes a bounded prefix, never the whole hostile value.
192
+ expect(e.message.length).toBeLessThan(120);
193
+ }
194
+ // ⛔ Deterministic guard for CodeQL js/polynomial-redos #18: the parser is the anchored, bounded
195
+ // regex with a length cap, never the old unanchored `(\d+)([hms])` with `g`.
196
+ const source = readFileSync(new URL('./ready.ts', import.meta.url), 'utf8');
197
+ expect(source).toContain('/^(?:(\\d{1,4})h)?(?:(\\d{1,4})m)?(?:(\\d{1,6})s)?$/');
198
+ expect(source).toContain('MAX_DURATION_LENGTH = 20');
199
+ expect(source).not.toContain('(\\d+)([hms])/g');
200
+ // The anchored form takes h, m, s once each and in that order only.
201
+ for (const bad of ['5s1m', '1m1m', '1h 1m']) {
202
+ const e = await Effect.runPromise(Effect.flip(parseGoDuration('waitTimeout', bad)));
203
+ expect(e._tag).toBe('KubernetesReadyBadDuration');
204
+ }
205
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `HomeFlare.Kubernetes.Ready` — a readiness gate other rows depend on via `after`/Output edges.
3
+ *
4
+ * ★ WHY IT EXISTS. Upstream ships no readiness resource: `Kubernetes.HelmChart` reconcile returns
5
+ * when server-side apply returns, and `Apply` releases the downstream rows at that moment, so a
6
+ * row chained after the Cilium chart would start before one Cilium pod runs (T10 design v3 §2).
7
+ * ★ SEMANTICS are `kubectl rollout status` (`ready-checks.ts`), the polling rules are in
8
+ * `ready-poll.ts`. Reconcile polls to `waitTimeout`; `read`/`diff` take ONE pass, so a plan is
9
+ * live (like `Talos.ClusterHealth`) but never waits.
10
+ * ⛔ `connection` IS A LITERAL `talos-openbao` CONNECTION, refused otherwise at declaration and in
11
+ * `diff` (`literal-connection.ts`), and it is copied onto the attributes so this row is
12
+ * `ClusterLike` (`stables`, as upstream's `HelmChart`). It carries a uid, never a credential.
13
+ * ⛔ NO SECRET REACHES ANYTHING THIS ROW WRITES: errors and attributes name check keys, counts and
14
+ * an error tag + status only, never a response body.
15
+ * ⚠️ `delete` is a no-op and `list` is empty: the row owns nothing in the cluster.
16
+ *
17
+ * Walked against alchemy@2.0.0-beta.81 (`Kubernetes/internal/client.ts` `readObject`,
18
+ * `KubernetesApiError`); only the fake apiserver was used. Not exercised against a live cluster.
19
+ */
20
+ import { Resource } from 'alchemy';
21
+ import { isResolved } from 'alchemy/Diff';
22
+ import type { Input } from 'alchemy/Input';
23
+ import type { Output } from 'alchemy/Output';
24
+ import * as Provider from 'alchemy/Provider';
25
+ import * as Duration from 'effect/Duration';
26
+ import * as Effect from 'effect/Effect';
27
+ import type { TalosOpenBaoConnection } from '../talos/cluster-adapter.ts';
28
+ import { literalConnectionRefusal, withLiteralConnection } from '../talos/literal-connection.ts';
29
+ import type { TalosRequirements } from '../talos/resource.ts';
30
+ import { type ReadyCheck, checksCsv } from './ready-checks.ts';
31
+ import { connectForDiff, connectOnce, connectTolerant } from './ready-connect.ts';
32
+ import { declaredReady } from './ready-declare.ts';
33
+ import { KubernetesReadyBadDuration } from './ready-errors.ts';
34
+ import { pass, poll } from './ready-poll.ts';
35
+
36
+ export interface ReadyProps {
37
+ /** Literal `talos-openbao` connection (uid proven at connect). */
38
+ connection: TalosOpenBaoConnection;
39
+ checks: readonly ReadyCheck[];
40
+ /** Reconcile deadline, Go style. Default `10m0s`. */
41
+ waitTimeout?: string;
42
+ /** Pause between passes, Go style. Default `10s`. */
43
+ pollInterval?: string;
44
+ /**
45
+ * Ordering edge: lazy Outputs of the chart, e.g. `[chart.objects]`. ⛔ Not `chart.connection` or
46
+ * any other stable: those resolve at plan time and the row would plan `noop` before the chart
47
+ * updates (refused at declaration, `ready-declare.ts`).
48
+ */
49
+ after?: readonly Output<unknown>[];
50
+ }
51
+
52
+ export interface ReadyAttributes {
53
+ connection: TalosOpenBaoConnection;
54
+ ready: boolean;
55
+ /** The check keys, comma separated: a changed list is an `update`. */
56
+ checks: string;
57
+ }
58
+
59
+ export interface KubernetesReady extends Resource<
60
+ 'HomeFlare.Kubernetes.Ready',
61
+ ReadyProps,
62
+ ReadyAttributes,
63
+ never,
64
+ TalosRequirements
65
+ > {}
66
+
67
+ // ⛔ Declaration-time literal guard: a fresh resource skips `diff` (see `withLiteralConnection`).
68
+ export const KubernetesReady = declaredReady(
69
+ withLiteralConnection(Resource<KubernetesReady>('HomeFlare.Kubernetes.Ready')),
70
+ );
71
+
72
+ const HOUR_MS = 3_600_000;
73
+ const MINUTE_MS = 60_000;
74
+ const SECOND_MS = 1000;
75
+
76
+ // ⛔ Anchored with bounded repeats, in h-m-s order: the former unanchored `(\d+)([hms])` with `g` was
77
+ // CodeQL js/polynomial-redos #18 once `boundedHelmChartProvider` made it reachable from exported
78
+ // parameters. Length is capped first, so no input can make the match slow.
79
+ const GO_DURATION = /^(?:(\d{1,4})h)?(?:(\d{1,4})m)?(?:(\d{1,6})s)?$/;
80
+ const MAX_DURATION_LENGTH = 20;
81
+
82
+ /**
83
+ * `10m0s`, `90s`, `1h30m`: whole-number Go durations in h/m/s. Anything else is typed-refused.
84
+ * `resource` names the row in the refusal (`Kubernetes.Ready` unless the caller is another one).
85
+ */
86
+ export const parseGoDuration = (field: string, value: string, resource?: string) =>
87
+ Effect.gen(function* () {
88
+ const refuse = () =>
89
+ Effect.fail(
90
+ new KubernetesReadyBadDuration({
91
+ field,
92
+ value,
93
+ ...(resource === undefined ? {} : { resource }),
94
+ }),
95
+ );
96
+ const match = value.length > MAX_DURATION_LENGTH ? null : GO_DURATION.exec(value);
97
+ if (match === null || value === '') return yield* refuse();
98
+ const [, h, m, s] = match;
99
+ const ms = Number(h ?? 0) * HOUR_MS + Number(m ?? 0) * MINUTE_MS + Number(s ?? 0) * SECOND_MS;
100
+ // A zero poll interval would spin a full pass of GETs per scheduler tick against the apiserver,
101
+ // and a zero wait fails before a single pass: both are refused (DeepSeek read of PR 373).
102
+ if (ms <= 0) return yield* refuse();
103
+ return Duration.millis(ms);
104
+ });
105
+
106
+ const attributes = (props: ReadyProps, ready: boolean): ReadyAttributes => ({
107
+ checks: checksCsv(props.checks),
108
+ connection: props.connection,
109
+ ready,
110
+ });
111
+
112
+ /** ⚠️ A row saved before `connection` existed has none at runtime: missing means "update". */
113
+ const sameConnection = (left: TalosOpenBaoConnection, right: TalosOpenBaoConnection | undefined) =>
114
+ right?.auth?.kind === left.auth.kind &&
115
+ right.auth.uid === left.auth.uid &&
116
+ !Object.hasOwn(right.auth, 'cluster');
117
+
118
+ /** One pass: `ready` only when every check is ready. A terminal failure is not ready either. */
119
+ export const readReady = (props: ReadyProps) =>
120
+ Effect.gen(function* () {
121
+ const transport = yield* connectOnce(props.connection);
122
+ const result = yield* pass(transport, props.checks, false);
123
+ return attributes(props, result.pending.length === 0 && result.failed.length === 0);
124
+ });
125
+
126
+ /**
127
+ * ⛔ The `diff` pass: a transient (connect or GET) yields `undefined`, which diff turns into
128
+ * `update`, so a CNI rollout never fails a plan and `reconcile` does the waiting. 401/403, vault
129
+ * failures and a uid mismatch still propagate.
130
+ */
131
+ const readReadyForDiff = (props: ReadyProps) =>
132
+ Effect.gen(function* () {
133
+ const transport = yield* connectForDiff(props.connection);
134
+ if (transport === undefined) return undefined;
135
+ const result = yield* pass(transport, props.checks, true);
136
+ const settled = result.pending.length === 0 && result.failed.length === 0;
137
+ return settled ? attributes(props, true) : undefined;
138
+ });
139
+
140
+ export const reconcileReady = (props: ReadyProps) =>
141
+ Effect.gen(function* () {
142
+ const waitTimeout = yield* parseGoDuration('waitTimeout', props.waitTimeout ?? '10m0s');
143
+ const pollInterval = yield* parseGoDuration('pollInterval', props.pollInterval ?? '10s');
144
+ const connect = connectTolerant(props.connection, pollInterval);
145
+ yield* poll(connect, props.checks, waitTimeout, pollInterval);
146
+ return attributes(props, true);
147
+ });
148
+
149
+ export const diffReady = (news: Input<ReadyProps>, output: ReadyAttributes | undefined) =>
150
+ Effect.gen(function* () {
151
+ // ⛔ Refuse an Output connection while it is still an Input (`literal-connection.ts`).
152
+ const refused = literalConnectionRefusal((news as { connection?: unknown }).connection);
153
+ if (refused !== undefined) return yield* Effect.fail(refused);
154
+ if (output === undefined || !isResolved(news)) return undefined;
155
+ // ⛔ `connection` is in `stables`: dependants read it off the saved row, so a changed one must
156
+ // REPLACE (as upstream's HelmChart does, `HelmChart.ts:243-251`) or they would see the old
157
+ // connection for one more deploy. `delete` is a no-op, so a replace touches nothing live.
158
+ // A legacy row with no connection counts as changed.
159
+ if (!sameConnection(news.connection, output.connection)) return { action: 'replace' } as const;
160
+ const live = yield* readReadyForDiff(news);
161
+ return live !== undefined && live.checks === output.checks
162
+ ? ({ action: 'noop' } as const)
163
+ : ({ action: 'update' } as const);
164
+ });
165
+
166
+ export const readHandler = ({
167
+ olds,
168
+ output,
169
+ }: {
170
+ olds: ReadyProps;
171
+ output: ReadyAttributes | undefined;
172
+ }) => (output === undefined ? Effect.succeed(undefined) : readReady(olds));
173
+
174
+ const handlers = {
175
+ delete: () => Effect.void,
176
+ diff: ({ news, output }: { news: Input<ReadyProps>; output: ReadyAttributes | undefined }) =>
177
+ diffReady(news, output),
178
+ list: () => Effect.succeed([]),
179
+ // ⛔ No prior output means Alchemy's deferred adoption read on a first create: the row owns
180
+ // nothing, so there is nothing to adopt, and a one-pass read would fail the install on the
181
+ // first 5xx before `reconcile` could poll through it (round 1, finding 2).
182
+ read: readHandler,
183
+ reconcile: ({ news }: { news: ReadyProps }) => reconcileReady(news),
184
+ stables: ['connection'] as (keyof ReadyAttributes)[],
185
+ };
186
+
187
+ export const KubernetesReadyProvider = () =>
188
+ Provider.effect(KubernetesReady, Effect.succeed(KubernetesReady.Provider.of(handlers)));
@@ -18,11 +18,22 @@ type FakeResponse = EventEmitter & { statusCode?: number };
18
18
 
19
19
  /**
20
20
  * `uids` maps an apiserver hostname to the kube-system uid it reports; `hang` lists hostnames that
21
- * accept the request and never answer.
21
+ * accept the request and never answer. `objects` (K1, `HomeFlare.Kubernetes.Ready`) answers a
22
+ * `'GET host /path'` key with its `status` and JSON `body`; `hang: true` accepts and never
23
+ * answers, `error` fails the request like a socket error. The table is read per request, so a
24
+ * test may mutate it between polls.
22
25
  */
26
+ export type FakeObject = {
27
+ readonly status?: number;
28
+ readonly body?: unknown;
29
+ readonly hang?: boolean;
30
+ readonly error?: string;
31
+ };
32
+
23
33
  export const fakeApiServer = (
24
34
  uids: Readonly<Record<string, string>>,
25
35
  hang: readonly string[] = [],
36
+ objects: Record<string, FakeObject> = {},
26
37
  ) => {
27
38
  const seen: string[] = [];
28
39
  const fake = ((
@@ -35,13 +46,20 @@ export const fakeApiServer = (
35
46
  request.write = () => undefined;
36
47
  request.end = () => {
37
48
  if (hang.includes(options.hostname)) return;
49
+ const object = objects[`${options.method} ${options.hostname}${path}`];
50
+ if (object?.hang === true) return;
51
+ if (object?.error !== undefined) {
52
+ request.emit('error', new Error(object.error));
53
+ return;
54
+ }
38
55
  const response = new EventEmitter() as FakeResponse;
39
- response.statusCode = 200;
56
+ response.statusCode = object?.status ?? 200;
40
57
  callback(response);
41
58
  const isIdentity = options.method === 'GET' && path === '/api/v1/namespaces/kube-system';
42
59
  const uid = isIdentity ? uids[options.hostname] : 'u1';
43
60
  const metadata = uid === undefined ? {} : { uid };
44
- response.emit('data', Buffer.from(JSON.stringify({ metadata })));
61
+ const payload = object === undefined ? { metadata } : (object.body ?? {});
62
+ response.emit('data', Buffer.from(JSON.stringify(payload)));
45
63
  response.emit('end');
46
64
  };
47
65
  return request;