@homeflare/alchemy 0.48.1 → 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 (126) hide show
  1. package/README.md +11 -0
  2. package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
  3. package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
  4. package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
  5. package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
  6. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
  7. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
  8. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
  9. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
  10. package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
  11. package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
  12. package/dist/cloudflare/fake-access.d.ts +40 -0
  13. package/dist/cloudflare/fake-access.d.ts.map +1 -0
  14. package/dist/cloudflare/fake-mesh.d.ts +7 -2
  15. package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
  16. package/dist/cloudflare/fake-tunnel.d.ts +48 -0
  17. package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
  18. package/dist/cloudflare/index.d.ts +15 -4
  19. package/dist/cloudflare/index.d.ts.map +1 -1
  20. package/dist/cloudflare/index.js +518 -7
  21. package/dist/cloudflare/index.js.map +15 -4
  22. package/dist/cloudflare/providers.d.ts +1 -1
  23. package/dist/cloudflare/providers.d.ts.map +1 -1
  24. package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
  25. package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
  26. package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
  27. package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
  28. package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
  29. package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
  30. package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
  31. package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
  32. package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
  33. package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
  34. package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
  35. package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
  36. package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
  37. package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
  38. package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
  39. package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
  40. package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
  41. package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
  42. package/dist/cloudflare/saas-oidc.d.ts +36 -0
  43. package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
  44. package/dist/kubernetes/helm-chart-bounded.d.ts +44 -0
  45. package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
  46. package/dist/kubernetes/index.d.ts +12 -0
  47. package/dist/kubernetes/index.d.ts.map +1 -0
  48. package/dist/kubernetes/index.js +585 -0
  49. package/dist/kubernetes/index.js.map +19 -0
  50. package/dist/kubernetes/ready-checks.d.ts +45 -0
  51. package/dist/kubernetes/ready-checks.d.ts.map +1 -0
  52. package/dist/kubernetes/ready-classify.d.ts +23 -0
  53. package/dist/kubernetes/ready-classify.d.ts.map +1 -0
  54. package/dist/kubernetes/ready-connect.d.ts +29 -0
  55. package/dist/kubernetes/ready-connect.d.ts.map +1 -0
  56. package/dist/kubernetes/ready-declare.d.ts +5 -0
  57. package/dist/kubernetes/ready-declare.d.ts.map +1 -0
  58. package/dist/kubernetes/ready-errors.d.ts +80 -0
  59. package/dist/kubernetes/ready-errors.d.ts.map +1 -0
  60. package/dist/kubernetes/ready-poll.d.ts +61 -0
  61. package/dist/kubernetes/ready-poll.d.ts.map +1 -0
  62. package/dist/kubernetes/ready.d.ts +74 -0
  63. package/dist/kubernetes/ready.d.ts.map +1 -0
  64. package/dist/talos/fake-apiserver.d.ts +11 -2
  65. package/dist/talos/fake-apiserver.d.ts.map +1 -1
  66. package/docs/cloudflared-tunnel.md +136 -0
  67. package/docs/credentials.md +1 -1
  68. package/docs/kubernetes-ready.md +146 -0
  69. package/docs/ownership.md +3 -2
  70. package/docs/peers.md +13 -0
  71. package/docs/provider-standard.md +7 -0
  72. package/docs/saas-oidc.md +185 -0
  73. package/docs/upstream-conformance-families.md +1 -1
  74. package/docs/upstream-conformance.md +3 -0
  75. package/package.json +7 -1
  76. package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
  77. package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
  78. package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
  79. package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
  80. package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
  81. package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
  82. package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
  83. package/src/cloudflare/cloudflared-tunnel.ts +116 -0
  84. package/src/cloudflare/fake-access.ts +196 -0
  85. package/src/cloudflare/fake-mesh.ts +6 -3
  86. package/src/cloudflare/fake-tunnel.ts +148 -0
  87. package/src/cloudflare/index.ts +27 -4
  88. package/src/cloudflare/providers.ts +16 -3
  89. package/src/cloudflare/saas-oidc-api.ts +224 -0
  90. package/src/cloudflare/saas-oidc-consumer.ts +35 -0
  91. package/src/cloudflare/saas-oidc-error.ts +52 -0
  92. package/src/cloudflare/saas-oidc-form.test.ts +227 -0
  93. package/src/cloudflare/saas-oidc-form.ts +190 -0
  94. package/src/cloudflare/saas-oidc-harness.ts +140 -0
  95. package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
  96. package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
  97. package/src/cloudflare/saas-oidc-props.ts +147 -0
  98. package/src/cloudflare/saas-oidc-read.test.ts +85 -0
  99. package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
  100. package/src/cloudflare/saas-oidc-state.test.ts +128 -0
  101. package/src/cloudflare/saas-oidc-team.test.ts +88 -0
  102. package/src/cloudflare/saas-oidc-team.ts +70 -0
  103. package/src/cloudflare/saas-oidc-wire.ts +116 -0
  104. package/src/cloudflare/saas-oidc.test.ts +201 -0
  105. package/src/cloudflare/saas-oidc.ts +107 -0
  106. package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
  107. package/src/kubernetes/helm-chart-bounded.ts +110 -0
  108. package/src/kubernetes/index.ts +28 -0
  109. package/src/kubernetes/ready-backoff.test.ts +124 -0
  110. package/src/kubernetes/ready-checks.test.ts +106 -0
  111. package/src/kubernetes/ready-checks.ts +165 -0
  112. package/src/kubernetes/ready-classify.ts +76 -0
  113. package/src/kubernetes/ready-connect.ts +52 -0
  114. package/src/kubernetes/ready-declare.test.ts +85 -0
  115. package/src/kubernetes/ready-declare.ts +50 -0
  116. package/src/kubernetes/ready-errors.ts +102 -0
  117. package/src/kubernetes/ready-first-install.test.ts +126 -0
  118. package/src/kubernetes/ready-poll-states.test.ts +92 -0
  119. package/src/kubernetes/ready-poll.test.ts +194 -0
  120. package/src/kubernetes/ready-poll.ts +215 -0
  121. package/src/kubernetes/ready-rollout.test.ts +58 -0
  122. package/src/kubernetes/ready-secrecy.test.ts +131 -0
  123. package/src/kubernetes/ready.fixtures.ts +103 -0
  124. package/src/kubernetes/ready.test.ts +205 -0
  125. package/src/kubernetes/ready.ts +188 -0
  126. package/src/talos/fake-apiserver.ts +21 -3
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The FIRST install of a `HomeFlare.Kubernetes.Ready` row (round 1, findings 2 and 4): Alchemy runs
3
+ * a deferred adoption `read` before `reconcile`, and `reconcile` connects (the cluster-identity GET)
4
+ * before its first look. An apiserver restarting under a CNI rollout must not fail either.
5
+ */
6
+ import { expect, test } from 'bun:test';
7
+ import * as Cause from 'effect/Cause';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Exit from 'effect/Exit';
10
+ import type { FakeObject } from '../talos/fake-apiserver.ts';
11
+ import { readHandler, readReady, reconcileReady } from './ready.ts';
12
+ import {
13
+ CRD_PATH,
14
+ DEP_PATH,
15
+ DS_PATH,
16
+ HOST,
17
+ IDENTITY_PATH,
18
+ checksOf,
19
+ connection,
20
+ driven,
21
+ get,
22
+ readyCrd,
23
+ readyDep,
24
+ readyDs,
25
+ sequence,
26
+ withCluster,
27
+ } from './ready.fixtures.ts';
28
+
29
+ const props = { checks: checksOf(), connection, pollInterval: '5s', waitTimeout: '5m' };
30
+ const allReady = (): Record<string, FakeObject> => ({
31
+ [get(CRD_PATH)]: { body: readyCrd },
32
+ [get(DEP_PATH)]: { body: readyDep },
33
+ [get(DS_PATH)]: { body: readyDs },
34
+ });
35
+ const identity = `GET ${HOST}${IDENTITY_PATH}`;
36
+ const settle = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
37
+ driven(effect, 400).pipe(
38
+ Effect.map((exit) =>
39
+ Exit.isSuccess(exit)
40
+ ? { ok: exit.value }
41
+ : { error: Cause.squash(exit.cause) as { _tag?: string } },
42
+ ),
43
+ );
44
+
45
+ test('read on a first create (no prior output) adopts nothing and touches no cluster', async () => {
46
+ // every GET would fail: a read that looked would propagate it
47
+ const down = { ...allReady(), [identity]: { status: 503 } };
48
+ const out = await withCluster(down, settle(readHandler({ olds: props, output: undefined })));
49
+ expect(out).toEqual({ ok: undefined });
50
+ });
51
+
52
+ test('read of an existing row still looks (the live pass is what diff/refresh rely on)', async () => {
53
+ const out = await withCluster(
54
+ allReady(),
55
+ readHandler({ olds: props, output: { checks: '', connection, ready: false } }),
56
+ );
57
+ expect((out as { ready: boolean }).ready).toBe(true);
58
+ });
59
+
60
+ for (const [name, first] of [
61
+ ['a 503', { status: 503, body: { message: 'secret-needle' } }],
62
+ ['a 429', { status: 429 }],
63
+ ['a hung GET', { hang: true }],
64
+ ] as const) {
65
+ test(`first create: ${name} on the identity GET is tolerated, then the gate opens`, async () => {
66
+ const objects = sequence(allReady(), identity, [first, first, undefined]);
67
+ expect(await withCluster(objects, settle(reconcileReady(props)))).toMatchObject({
68
+ ok: { ready: true },
69
+ });
70
+ });
71
+
72
+ test(`first create: ${name} right after apply (first check GET) is tolerated`, async () => {
73
+ const objects = sequence(allReady(), get(DEP_PATH), [first, first, { body: readyDep }]);
74
+ expect(await withCluster(objects, settle(reconcileReady(props)))).toMatchObject({
75
+ ok: { ready: true },
76
+ });
77
+ });
78
+ }
79
+
80
+ test('an identity GET that never recovers times out as the gate, naming the transient', async () => {
81
+ const out = (await withCluster(
82
+ { ...allReady(), [identity]: { status: 503 } },
83
+ settle(reconcileReady({ ...props, waitTimeout: '30s' })),
84
+ )) as { error: { _tag: string; lastTransient?: unknown; failing: string[] } };
85
+ expect(out.error._tag).toBe('KubernetesReadyTimeout');
86
+ expect(out.error.lastTransient).toMatchObject({ status: 503, tag: 'KubernetesApiError' });
87
+ expect(out.error.failing).toHaveLength(3);
88
+ });
89
+
90
+ test('401/403 on the identity GET propagate at once, scrubbed of the response body', async () => {
91
+ for (const status of [401, 403]) {
92
+ const out = (await withCluster(
93
+ { ...allReady(), [identity]: { body: { message: 'identity-body-needle' }, status } },
94
+ settle(reconcileReady(props)),
95
+ )) as { error: { _tag: string; statusCode: number } };
96
+ expect(out.error).toMatchObject({ _tag: 'KubernetesReadyApiError', statusCode: status });
97
+ expect(JSON.stringify(out.error) + String(out.error)).not.toContain('identity-body-needle');
98
+ }
99
+ });
100
+
101
+ test('a one-pass read/diff connect scrubs the identity GET body too (5xx and 4xx)', async () => {
102
+ for (const status of [503, 401, 403]) {
103
+ const out = (await withCluster(
104
+ { ...allReady(), [identity]: { body: { message: 'identity-body-needle' }, status } },
105
+ settle(readReady(props)),
106
+ )) as { error: { _tag: string; statusCode: number } };
107
+ expect(out.error).toMatchObject({ _tag: 'KubernetesReadyApiError', statusCode: status });
108
+ expect(JSON.stringify(out.error) + String(out.error)).not.toContain('identity-body-needle');
109
+ }
110
+ });
111
+
112
+ test('a vault failure and a uid mismatch still propagate at once (never waited out)', async () => {
113
+ const down = () => ({ exitCode: 2, stderr: 'permission denied' });
114
+ const out = (await withCluster(allReady(), settle(reconcileReady(props)), down)) as {
115
+ error: { _tag?: string };
116
+ };
117
+ expect(out.error._tag).not.toBe('KubernetesReadyTimeout');
118
+ const other = {
119
+ ...allReady(),
120
+ [identity]: { body: { metadata: { uid: 'uid-other' } } },
121
+ };
122
+ const mismatch = (await withCluster(other, settle(reconcileReady(props)))) as {
123
+ error: { _tag: string };
124
+ };
125
+ expect(mismatch.error._tag).toBe('TalosClusterIdentityMismatch');
126
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Round 1, findings 3 and 7 through the poll: what the timeout reports, defects that propagate, a
3
+ * cleared transient that is not blamed, and the strategy failure's typed reason.
4
+ */
5
+ import { expect, test } from 'bun:test';
6
+ import * as Cause from 'effect/Cause';
7
+ import * as Duration from 'effect/Duration';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Exit from 'effect/Exit';
10
+ import { connectCluster } from 'alchemy/Kubernetes/internal/client';
11
+ import type { FakeObject } from '../talos/fake-apiserver.ts';
12
+ import { poll } from './ready-poll.ts';
13
+ import {
14
+ CRD_PATH,
15
+ DEP_PATH,
16
+ DS_PATH,
17
+ checksOf,
18
+ connection,
19
+ driven,
20
+ get,
21
+ readyCrd,
22
+ readyDep,
23
+ readyDs,
24
+ sequence,
25
+ withCluster,
26
+ } from './ready.fixtures.ts';
27
+
28
+ const WAIT = Duration.minutes(10);
29
+ const EVERY = Duration.seconds(10);
30
+ const allReady = (): Record<string, FakeObject> => ({
31
+ [get(CRD_PATH)]: { body: readyCrd },
32
+ [get(DEP_PATH)]: { body: readyDep },
33
+ [get(DS_PATH)]: { body: readyDs },
34
+ });
35
+ const run = (objects: Record<string, FakeObject>, checks = checksOf()) =>
36
+ withCluster(
37
+ objects,
38
+ driven(poll(() => connectCluster(connection), checks, WAIT, EVERY)).pipe(
39
+ Effect.map((exit) => (Exit.isSuccess(exit) ? 'ok' : Cause.squash(exit.cause))),
40
+ ),
41
+ ) as Promise<Record<string, unknown> & Error>;
42
+
43
+ test('the timeout records per key what the last pass saw: notFound, or the counts (no body)', async () => {
44
+ const objects = allReady();
45
+ objects[get(CRD_PATH)] = { body: { message: 'state-body-needle' }, status: 404 };
46
+ const stuck = { ...readyDs, status: { ...readyDs.status, updatedNumberScheduled: 1 } };
47
+ objects[get(DS_PATH)] = { body: stuck };
48
+ const error = await run(objects);
49
+ expect(error['_tag']).toBe('KubernetesReadyTimeout');
50
+ expect(error['states']).toEqual({
51
+ 'CustomResourceDefinition/x.cilium.io': { notFound: true },
52
+ 'DaemonSet/kube-system/cilium': {
53
+ counts: {
54
+ desiredNumberScheduled: 3,
55
+ generation: 2,
56
+ numberAvailable: 3,
57
+ observedGeneration: 2,
58
+ updatedNumberScheduled: 1,
59
+ },
60
+ },
61
+ });
62
+ expect(error.message).toContain('x.cilium.io (not found)');
63
+ expect(error.message).toContain('updatedNumberScheduled=1');
64
+ expect(JSON.stringify(error) + error.message).not.toContain('state-body-needle');
65
+ });
66
+
67
+ test('an untagged defect (upstream path-builder throw) propagates instead of waiting ten minutes', async () => {
68
+ // a namespaced kind with an empty namespace makes upstream's path builder throw a plain Error
69
+ const bad = [{ kind: 'Deployment', name: 'x', namespace: '' }] as never;
70
+ const error = await run(allReady(), bad);
71
+ expect(error).toBeInstanceOf(Error);
72
+ expect(error['_tag']).toBeUndefined();
73
+ expect(error.message).toContain('requires a namespace');
74
+ });
75
+
76
+ test('a transient that has cleared is not blamed by the timeout', async () => {
77
+ const stuck = { ...readyDs, status: { ...readyDs.status, updatedNumberScheduled: 0 } };
78
+ // 503 on the first look, then answered (and never ready)
79
+ const flaky = sequence(allReady(), get(DS_PATH), [{ status: 503 }, { body: stuck }]);
80
+ const error = await run(flaky);
81
+ expect(error['_tag']).toBe('KubernetesReadyTimeout');
82
+ expect(error['lastTransient']).toBeUndefined();
83
+ });
84
+
85
+ test('OnDelete ends the poll with KubernetesRolloutFailed and its own reason', async () => {
86
+ const objects = allReady();
87
+ objects[get(DS_PATH)] = { body: { ...readyDs, spec: { updateStrategy: { type: 'OnDelete' } } } };
88
+ const error = await run(objects);
89
+ expect(error['_tag']).toBe('KubernetesRolloutFailed');
90
+ expect(error['reason']).toBe('UpdateStrategyNotRollingUpdate');
91
+ expect(error['check']).toBe('DaemonSet/kube-system/cilium');
92
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Error classes of the poll and of a single pass, through the real adapter and `readObject` over
3
+ * the fake apiserver. Time is TestClock's: the 10 m deadline costs no real seconds.
4
+ */
5
+ import { expect, test } from 'bun:test';
6
+ import * as Duration from 'effect/Duration';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Exit from 'effect/Exit';
9
+ import * as Cause from 'effect/Cause';
10
+ import { connectCluster } from 'alchemy/Kubernetes/internal/client';
11
+ import { type FakeObject } from '../talos/fake-apiserver.ts';
12
+ import { isNotFound, pass, poll, transientOf } from './ready-poll.ts';
13
+ import {
14
+ CRD_PATH,
15
+ DEP_PATH,
16
+ DS_PATH,
17
+ connection,
18
+ crdCheck,
19
+ depCheck,
20
+ driven,
21
+ dsCheck,
22
+ get,
23
+ readyCrd,
24
+ readyDep,
25
+ readyDs,
26
+ withCluster,
27
+ } from './ready.fixtures.ts';
28
+
29
+ const WAIT = Duration.minutes(10);
30
+ const EVERY = Duration.seconds(10);
31
+ const checks = [dsCheck, depCheck, crdCheck];
32
+ const allReady = (): Record<string, FakeObject> => ({
33
+ [get(CRD_PATH)]: { body: readyCrd },
34
+ [get(DEP_PATH)]: { body: readyDep },
35
+ [get(DS_PATH)]: { body: readyDs },
36
+ });
37
+
38
+ /** The failure of a polled run, or `ok`. */
39
+ const pollOutcome = (objects: Record<string, FakeObject>, mutate?: () => void) =>
40
+ withCluster(
41
+ objects,
42
+ driven(
43
+ Effect.gen(function* () {
44
+ mutate?.();
45
+ yield* poll(() => connectCluster(connection), checks, WAIT, EVERY);
46
+ }),
47
+ ).pipe(
48
+ Effect.map((exit) =>
49
+ Exit.isSuccess(exit)
50
+ ? { ok: true }
51
+ : { error: Cause.squash(exit.cause) as { _tag?: string } },
52
+ ),
53
+ ),
54
+ );
55
+
56
+ test('an all-ready cluster passes on the first look', async () => {
57
+ const outcome = await pollOutcome(allReady());
58
+ expect(outcome).toEqual({ ok: true });
59
+ });
60
+
61
+ test('404 in either shape is pending: the poll times out naming the keys, no transient', async () => {
62
+ const objects = allReady();
63
+ objects[get(CRD_PATH)] = { body: { kind: 'Status' }, status: 404 };
64
+ const outcome = (await pollOutcome(objects)) as { error: Record<string, unknown> };
65
+ expect(outcome.error['_tag']).toBe('KubernetesReadyTimeout');
66
+ expect(outcome.error['failing']).toEqual(['CustomResourceDefinition/x.cilium.io']);
67
+ expect(outcome.error['seconds']).toBe(600);
68
+ expect(outcome.error['lastTransient']).toBeUndefined();
69
+ // the second shape (a later alchemy raises KubernetesNotFound), matched by tag string alone
70
+ expect(isNotFound({ _tag: 'KubernetesNotFound' })).toBe(true);
71
+ expect(isNotFound({ _tag: 'KubernetesApiError', statusCode: 404 })).toBe(true);
72
+ expect(isNotFound({ _tag: 'KubernetesApiError', statusCode: 403 })).toBe(false);
73
+ expect(isNotFound(new Error('404'))).toBe(false);
74
+ });
75
+
76
+ for (const [name, entry, expected] of [
77
+ ['5xx', { status: 503, body: { secret: 'x' } }, { status: 503, tag: 'KubernetesApiError' }],
78
+ ['429', { status: 429 }, { status: 429, tag: 'KubernetesApiError' }],
79
+ ['a per-GET timeout', { hang: true }, { tag: 'KubernetesReadyGetTimeout' }],
80
+ // ★ upstream retries a transport error every 5 s up to 8 times, about 40 s
81
+ // (`client.ts:158-161,166-172`), longer than the 5 s per-GET bound, so through the fake socket it
82
+ // surfaces as the GET timeout; the classifier below is what a plain Error from a later upstream
83
+ // (no retry) would reach.
84
+ ['a transport error', { error: 'ECONNREFUSED 10.0.0.1' }, { tag: 'KubernetesReadyGetTimeout' }],
85
+ ] as const) {
86
+ test(`${name} is pending in a poll and named as lastTransient (tag + status, no body)`, async () => {
87
+ const objects = allReady();
88
+ objects[get(DEP_PATH)] = entry;
89
+ const outcome = (await pollOutcome(objects)) as { error: Record<string, unknown> };
90
+ expect(outcome.error['_tag']).toBe('KubernetesReadyTimeout');
91
+ // ⚠️ after a timed-out GET the rest of the pass is pending unread (socket rationing)
92
+ const unread = 'tag' in expected && expected.tag === 'KubernetesReadyGetTimeout';
93
+ expect(outcome.error['failing']).toEqual([
94
+ 'Deployment/kube-system/cilium-operator',
95
+ ...(unread ? ['CustomResourceDefinition/x.cilium.io'] : []),
96
+ ]);
97
+ expect(outcome.error['lastTransient']).toEqual(expected);
98
+ expect(JSON.stringify(outcome.error)).not.toContain('ECONNREFUSED');
99
+ expect(JSON.stringify(outcome.error)).not.toContain('secret');
100
+ });
101
+ }
102
+
103
+ test('transientOf: tolerable classes only; 4xx, tagged adapter errors and non-errors are not', () => {
104
+ expect(transientOf(new Error('Failed Kubernetes GET /x: ECONNRESET'))).toEqual({
105
+ tag: 'TransportError',
106
+ });
107
+ expect(transientOf({ _tag: 'KubernetesApiError', statusCode: 502 })).toEqual({
108
+ status: 502,
109
+ tag: 'KubernetesApiError',
110
+ });
111
+ expect(transientOf({ _tag: 'KubernetesApiError', statusCode: 429 })?.status).toBe(429);
112
+ for (const error of [
113
+ { _tag: 'KubernetesApiError', statusCode: 401 },
114
+ { _tag: 'KubernetesApiError', statusCode: 403 },
115
+ { _tag: 'KubernetesApiError', statusCode: 404 },
116
+ { _tag: 'TalosVaultKeyMissing' },
117
+ // an untagged Error that is not upstream's transport wrapper is a defect (path-builder throw)
118
+ new Error('Kubernetes object apps/v1/Deployment/x requires a namespace'),
119
+ new Error('ECONNRESET'),
120
+ Object.assign(new Error('tagged'), { _tag: 'TalosOpenBaoConnectTimeout' }),
121
+ 'a string',
122
+ ]) {
123
+ expect(transientOf(error)).toBeUndefined();
124
+ }
125
+ });
126
+
127
+ test('the poll recovers when a transient clears (the cluster answers on a later pass)', async () => {
128
+ const objects = allReady();
129
+ objects[get(DEP_PATH)] = { status: 503 };
130
+ const outcome = await pollOutcome(objects, () => {
131
+ // flip to ready after the first pass has looked: mutate on the live table
132
+ setTimeout(() => (objects[get(DEP_PATH)] = { body: readyDep }), 0);
133
+ });
134
+ // the timer runs on real time; the poll sleeps virtual seconds, so the next pass sees it ready
135
+ expect(outcome).toEqual({ ok: true });
136
+ });
137
+
138
+ for (const status of [401, 403, 400, 409]) {
139
+ test(`${status} propagates from a poll instead of waiting out the deadline`, async () => {
140
+ const objects = allReady();
141
+ objects[get(DS_PATH)] = { status, body: { message: 'nope' } };
142
+ const outcome = (await pollOutcome(objects)) as { error: Record<string, unknown> };
143
+ expect(outcome.error['_tag']).toBe('KubernetesReadyApiError');
144
+ expect(outcome.error['statusCode']).toBe(status);
145
+ });
146
+ }
147
+
148
+ test('ProgressDeadlineExceeded ends the poll with the typed failure, immediately', async () => {
149
+ const objects = allReady();
150
+ objects[get(DEP_PATH)] = {
151
+ body: {
152
+ ...readyDep,
153
+ status: {
154
+ ...readyDep.status,
155
+ conditions: [{ reason: 'ProgressDeadlineExceeded', type: 'Progressing' }],
156
+ },
157
+ },
158
+ };
159
+ const outcome = (await pollOutcome(objects)) as { error: Record<string, unknown> };
160
+ expect(outcome.error['_tag']).toBe('KubernetesRolloutFailed');
161
+ expect(outcome.error['check']).toBe('Deployment/kube-system/cilium-operator');
162
+ });
163
+
164
+ test('a single pass tolerates 404 only: 5xx, 429, timeout and transport errors propagate', async () => {
165
+ const run = (entry: FakeObject) =>
166
+ withCluster(
167
+ { ...allReady(), [get(DEP_PATH)]: entry },
168
+ driven(
169
+ Effect.gen(function* () {
170
+ const transport = yield* connectCluster(connection);
171
+ return yield* pass(transport, checks, false);
172
+ }),
173
+ 30,
174
+ ).pipe(
175
+ Effect.map((exit) =>
176
+ Exit.isSuccess(exit)
177
+ ? exit.value
178
+ : { error: Cause.squash(exit.cause) as { _tag?: string } },
179
+ ),
180
+ ),
181
+ );
182
+ expect(await run({ status: 404 })).toMatchObject({
183
+ failed: [],
184
+ pending: ['Deployment/kube-system/cilium-operator'],
185
+ });
186
+ for (const [entry, tag] of [
187
+ [{ status: 503 }, 'KubernetesReadyApiError'],
188
+ [{ status: 429 }, 'KubernetesReadyApiError'],
189
+ [{ hang: true }, 'KubernetesReadyGetTimeout'],
190
+ ] as const) {
191
+ expect(((await run(entry)) as { error: { _tag: string } }).error._tag).toBe(tag);
192
+ }
193
+ expect(((await run({ error: 'ECONNRESET' })) as { error: unknown }).error).toBeInstanceOf(Error);
194
+ });
@@ -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
+ });