@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,106 @@
1
+ /** The rollout rules on literal bodies (kubectl `rollout_status.go`), no I/O. */
2
+ import { expect, test } from 'bun:test';
3
+ import { type ReadyCheck, checkKey, checksCsv, evaluate } from './ready-checks.ts';
4
+
5
+ const ds = (status: object, generation = 2, minReady?: number) => ({
6
+ body: { metadata: { generation }, status },
7
+ check: {
8
+ kind: 'DaemonSet',
9
+ name: 'cilium',
10
+ namespace: 'kube-system',
11
+ ...(minReady === undefined ? {} : { minReady }),
12
+ } as ReadyCheck,
13
+ });
14
+ const dsVerdict = (status: object, generation?: number, minReady?: number) => {
15
+ const { body, check } = ds(status, generation, minReady);
16
+ return evaluate(check, body);
17
+ };
18
+ const fullDs = {
19
+ desiredNumberScheduled: 3,
20
+ numberAvailable: 3,
21
+ numberReady: 3,
22
+ observedGeneration: 2,
23
+ updatedNumberScheduled: 3,
24
+ };
25
+
26
+ test('DaemonSet: ready only when the generation is seen and updated and available reach minReady', () => {
27
+ expect(dsVerdict(fullDs)).toBe('ready');
28
+ // stale generation, numberReady 3: the OLD pods look healthy while the new spec is unseen
29
+ expect(dsVerdict({ ...fullDs, observedGeneration: 1 })).toBe('pending');
30
+ // updated 0 with numberReady 3: nothing is on the new template yet
31
+ expect(dsVerdict({ ...fullDs, updatedNumberScheduled: 0 })).toBe('pending');
32
+ // numberAvailable below the bar although numberReady is 3
33
+ expect(dsVerdict({ ...fullDs, numberAvailable: 1 })).toBe('pending');
34
+ });
35
+
36
+ test('DaemonSet: minReady 2 passes 2/3, minReady 3 does not, default is desiredNumberScheduled', () => {
37
+ // all 3 on the new template, one down: 2 updated-and-available meets minReady 2
38
+ const two = { ...fullDs, numberAvailable: 2, numberReady: 2 };
39
+ expect(dsVerdict(two, 2, 2)).toBe('ready');
40
+ expect(dsVerdict(two, 2, 3)).toBe('pending');
41
+ expect(dsVerdict(two)).toBe('pending');
42
+ });
43
+
44
+ test('DaemonSet: a set that wants zero pods is not ready', () => {
45
+ expect(dsVerdict({ desiredNumberScheduled: 0, observedGeneration: 2 })).toBe('pending');
46
+ expect(dsVerdict({ observedGeneration: 2 }, 2, 0)).toBe('pending');
47
+ });
48
+
49
+ const dep: ReadyCheck = { kind: 'Deployment', name: 'cilium-operator', namespace: 'kube-system' };
50
+ const depBody = (status: object, replicas = 2, generation = 4) => ({
51
+ metadata: { generation },
52
+ spec: { replicas },
53
+ status: { observedGeneration: 4, ...status },
54
+ });
55
+ const rolled = { availableReplicas: 2, replicas: 2, updatedReplicas: 2 };
56
+
57
+ test('Deployment: ready only on the full rollout counts', () => {
58
+ expect(evaluate(dep, depBody(rolled))).toBe('ready');
59
+ expect(evaluate(dep, depBody(rolled, 2, 5))).toBe('pending'); // generation not observed
60
+ // Available with updatedReplicas < replicas: the old ReplicaSet still serves
61
+ expect(evaluate(dep, depBody({ ...rolled, updatedReplicas: 1 }))).toBe('pending');
62
+ // surplus old replicas still running: replicas != updatedReplicas
63
+ expect(evaluate(dep, depBody({ ...rolled, replicas: 3 }))).toBe('pending');
64
+ expect(evaluate(dep, depBody({ ...rolled, availableReplicas: 1 }))).toBe('pending');
65
+ // scaled up to 2 but the controller has made only 1 (status is self-consistent at 1)
66
+ const one = { availableReplicas: 1, replicas: 1, updatedReplicas: 1 };
67
+ expect(evaluate(dep, depBody(one))).toBe('pending');
68
+ // scaled DOWN 3 -> 2, all three on the new template and available: kubectl reports this rolled
69
+ // out (it waits only while updated < desired), so Ready does too
70
+ const down = { availableReplicas: 3, replicas: 3, updatedReplicas: 3 };
71
+ expect(evaluate(dep, depBody(down))).toBe('ready');
72
+ });
73
+
74
+ test('Deployment: ProgressDeadlineExceeded is failed, but only for the generation the controller saw', () => {
75
+ const exceeded = { conditions: [{ reason: 'ProgressDeadlineExceeded', type: 'Progressing' }] };
76
+ expect(evaluate(dep, depBody({ ...rolled, ...exceeded }))).toBe('failed');
77
+ expect(evaluate(dep, depBody({ ...exceeded, observedGeneration: 3 }))).toBe('pending');
78
+ const fine = { conditions: [{ reason: 'NewReplicaSetAvailable', type: 'Progressing' }] };
79
+ expect(evaluate(dep, depBody({ ...rolled, ...fine }))).toBe('ready');
80
+ });
81
+
82
+ test('CRD: ready only when Established is True', () => {
83
+ const crd: ReadyCheck = { kind: 'CustomResourceDefinition', name: 'ciliumthings.cilium.io' };
84
+ const cond = (status: string) => ({ status: { conditions: [{ status, type: 'Established' }] } });
85
+ expect(evaluate(crd, cond('True'))).toBe('ready');
86
+ expect(evaluate(crd, cond('False'))).toBe('pending');
87
+ expect(
88
+ evaluate(crd, { status: { conditions: [{ status: 'True', type: 'NamesAccepted' }] } }),
89
+ ).toBe('pending');
90
+ });
91
+
92
+ test('a missing or non-object body is pending, never ready', () => {
93
+ for (const body of [undefined, null, 'ok', 7]) expect(evaluate(dep, body)).toBe('pending');
94
+ });
95
+
96
+ test('check keys are names and thresholds only', () => {
97
+ const checks: ReadyCheck[] = [
98
+ { kind: 'DaemonSet', minReady: 2, name: 'cilium', namespace: 'kube-system' },
99
+ dep,
100
+ { kind: 'CustomResourceDefinition', name: 'x.cilium.io' },
101
+ ];
102
+ expect(checkKey(checks[0] as ReadyCheck)).toBe('DaemonSet/kube-system/cilium:min=2');
103
+ expect(checksCsv(checks)).toBe(
104
+ 'DaemonSet/kube-system/cilium:min=2,Deployment/kube-system/cilium-operator,CustomResourceDefinition/x.cilium.io',
105
+ );
106
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Readiness evaluators for `HomeFlare.Kubernetes.Ready`: pure functions over one object's JSON,
3
+ * plus the typed errors of the gate. No I/O here, so every rule is testable on a literal body.
4
+ *
5
+ * ★ ROLLOUT SEMANTICS, NOT "Available". Copied from `kubectl rollout status` (kubectl v0.34.0
6
+ * `pkg/polymorphichelpers/rollout_status.go:75-89` Deployment, `:107-114` DaemonSet), because a
7
+ * gate that only reads `Available`/`numberReady` passes on the OLD ReplicaSet or pods while a
8
+ * new generation is still rolling out (T10 design v3, red-team round 2, finding 1). The first
9
+ * rule of both is `status.observedGeneration >= metadata.generation`: until the controller has
10
+ * seen the latest spec, every count below describes a spec that no longer exists.
11
+ * ⚠️ A body that is missing or not an object is PENDING, never ready: an empty 200 must not open
12
+ * the gate.
13
+ */
14
+ import { type KeyState, KubernetesReadyBadCheck } from './ready-errors.ts';
15
+
16
+ export type ReadyCheck =
17
+ | {
18
+ readonly kind: 'DaemonSet';
19
+ readonly namespace: string;
20
+ readonly name: string;
21
+ readonly minReady?: number;
22
+ }
23
+ | { readonly kind: 'Deployment'; readonly namespace: string; readonly name: string }
24
+ | { readonly kind: 'CustomResourceDefinition'; readonly name: string };
25
+
26
+ /** `failed` is terminal (kubectl reports it as an error), `pending` is "look again". */
27
+ export type Verdict = 'ready' | 'pending' | 'failed';
28
+
29
+ /** Check keys only: names are public, no body byte ever reaches a key, an error or an attribute. */
30
+ export const checkKey = (check: ReadyCheck): string => {
31
+ switch (check.kind) {
32
+ case 'DaemonSet':
33
+ return `DaemonSet/${check.namespace}/${check.name}${
34
+ check.minReady === undefined ? '' : `:min=${check.minReady}`
35
+ }`;
36
+ case 'Deployment':
37
+ return `Deployment/${check.namespace}/${check.name}`;
38
+ case 'CustomResourceDefinition':
39
+ return `CustomResourceDefinition/${check.name}`;
40
+ }
41
+ };
42
+
43
+ export const checksCsv = (checks: readonly ReadyCheck[]): string => checks.map(checkKey).join(',');
44
+
45
+ /**
46
+ * Declaration-time validation (round 1, finding 3): a check that can never be satisfied is a typed
47
+ * refusal, not a 10 minute timeout. ⚠️ Upstream's path builder THROWS for a namespaced kind with an
48
+ * empty namespace (`objects.ts:189-192`), which would otherwise be read as a transport transient.
49
+ */
50
+ export const validateChecks = (checks: unknown): KubernetesReadyBadCheck | undefined => {
51
+ if (!Array.isArray(checks) || checks.length === 0) {
52
+ return new KubernetesReadyBadCheck({ index: 0, problem: 'must list at least one check' });
53
+ }
54
+ for (const [index, raw] of checks.entries()) {
55
+ const check = obj(raw);
56
+ const bad = (problem: string) => new KubernetesReadyBadCheck({ index, problem });
57
+ const kind = check['kind'];
58
+ if (kind !== 'DaemonSet' && kind !== 'Deployment' && kind !== 'CustomResourceDefinition') {
59
+ return bad('has an unknown kind');
60
+ }
61
+ if (typeof check['name'] !== 'string' || check['name'] === '') {
62
+ return bad('needs a non-empty name');
63
+ }
64
+ if (kind !== 'CustomResourceDefinition') {
65
+ if (typeof check['namespace'] !== 'string' || check['namespace'] === '') {
66
+ return bad(`needs a non-empty namespace (${kind} is namespaced)`);
67
+ }
68
+ }
69
+ const min = check['minReady'];
70
+ if (min !== undefined && (kind !== 'DaemonSet' || !Number.isInteger(min) || Number(min) < 1)) {
71
+ return bad('minReady is an integer >= 1 and only applies to a DaemonSet');
72
+ }
73
+ }
74
+ return undefined;
75
+ };
76
+
77
+ export type Json = Record<string, unknown>;
78
+ export const obj = (value: unknown): Json =>
79
+ typeof value === 'object' && value !== null ? (value as Json) : {};
80
+ export const num = (value: unknown): number => (typeof value === 'number' ? value : 0);
81
+
82
+ /** The numbers a pass saw, for `KubernetesReadyTimeout.states`: counts and generations only. */
83
+ export const countsOf = (check: ReadyCheck, body: unknown): KeyState => {
84
+ const status = obj(obj(body)['status']);
85
+ const names =
86
+ check.kind === 'DaemonSet'
87
+ ? [
88
+ 'observedGeneration',
89
+ 'desiredNumberScheduled',
90
+ 'updatedNumberScheduled',
91
+ 'numberAvailable',
92
+ ]
93
+ : check.kind === 'Deployment'
94
+ ? ['observedGeneration', 'replicas', 'updatedReplicas', 'availableReplicas']
95
+ : [];
96
+ const counts = Object.fromEntries(names.map((name) => [name, num(status[name])]));
97
+ return { counts: { generation: num(obj(obj(body)['metadata'])['generation']), ...counts } };
98
+ };
99
+ const conditions = (status: Json): Json[] =>
100
+ Array.isArray(status['conditions']) ? status['conditions'].map(obj) : [];
101
+
102
+ /** kubectl `rollout_status.go:107-114`; `minReady` defaults to `desiredNumberScheduled`. */
103
+ const daemonSet = (body: Json, minReady: number | undefined): Verdict => {
104
+ const status = obj(body['status']);
105
+ // ⛔ kubectl checks the strategy first (`:104-106`). The apiserver defaults the type, so an
106
+ // absent one is RollingUpdate; `OnDelete` never rolls by itself, so waiting cannot end.
107
+ const strategy = obj(obj(body['spec'])['updateStrategy'])['type'];
108
+ if (strategy !== undefined && strategy !== 'RollingUpdate') return 'failed';
109
+ const generation = num(obj(body['metadata'])['generation']);
110
+ if (num(status['observedGeneration']) < generation) return 'pending';
111
+ const desired = num(status['desiredNumberScheduled']);
112
+ const need = minReady ?? desired;
113
+ // ⚠️ A DaemonSet that wants zero pods (no node matched yet) has rolled out nothing.
114
+ if (need < 1) return 'pending';
115
+ const updated = num(status['updatedNumberScheduled']);
116
+ // ⛔ `numberAvailable` also counts AVAILABLE OLD-TEMPLATE pods, so comparing it to `minReady`
117
+ // alone passes a broken upgrade: minReady 2 of 4 with two crashlooping new pods and two healthy
118
+ // old ones reads as 2 updated and 2 available. At most `desired - updated` of the available
119
+ // pods are old, so the difference is a LOWER BOUND on pods both updated and available (round 1,
120
+ // finding 1; kubectl gets there by demanding updated == desired).
121
+ const oldAtMost = Math.max(desired - updated, 0);
122
+ return updated >= need && num(status['numberAvailable']) - oldAtMost >= need
123
+ ? 'ready'
124
+ : 'pending';
125
+ };
126
+
127
+ /** kubectl `rollout_status.go:75-89`. */
128
+ const deployment = (body: Json): Verdict => {
129
+ const status = obj(body['status']);
130
+ const generation = num(obj(body['metadata'])['generation']);
131
+ if (num(status['observedGeneration']) < generation) return 'pending';
132
+ // ★ Checked after the generation rule, as kubectl does: a deadline condition left over from
133
+ // the previous spec must not fail a rollout the controller has not looked at yet.
134
+ const exceeded = conditions(status).some(
135
+ (c) => c['type'] === 'Progressing' && c['reason'] === 'ProgressDeadlineExceeded',
136
+ );
137
+ if (exceeded) return 'failed';
138
+ const desired =
139
+ obj(body['spec'])['replicas'] === undefined ? 1 : num(obj(body['spec'])['replicas']);
140
+ const updated = num(status['updatedReplicas']);
141
+ // kubectl rollout_status.go waits only while updated < desired, so updated >= desired is the
142
+ // same rule (a scale-down can briefly report more updated replicas than spec.replicas).
143
+ return updated >= desired &&
144
+ num(status['replicas']) === updated &&
145
+ num(status['availableReplicas']) >= updated
146
+ ? 'ready'
147
+ : 'pending';
148
+ };
149
+
150
+ const established = (body: Json): Verdict =>
151
+ conditions(obj(body['status'])).some((c) => c['type'] === 'Established' && c['status'] === 'True')
152
+ ? 'ready'
153
+ : 'pending';
154
+
155
+ export const evaluate = (check: ReadyCheck, body: unknown): Verdict => {
156
+ if (typeof body !== 'object' || body === null) return 'pending';
157
+ switch (check.kind) {
158
+ case 'DaemonSet':
159
+ return daemonSet(body as Json, check.minReady);
160
+ case 'Deployment':
161
+ return deployment(body as Json);
162
+ case 'CustomResourceDefinition':
163
+ return established(body as Json);
164
+ }
165
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Error classification for `HomeFlare.Kubernetes.Ready`, shared by the poll (`ready-poll.ts`) and
3
+ * the connect (`ready-connect.ts`).
4
+ *
5
+ * ⛔ ERROR CLASSES (T10 design v3 §2, round-2 findings 3b and 8, round-1 finding 3):
6
+ * - 404 is PENDING everywhere. Version-proof: `_tag === 'KubernetesNotFound'` OR a
7
+ * `KubernetesApiError` with `statusCode === 404` (beta.81 raises the latter,
8
+ * `Kubernetes/internal/client.ts:32-37`; a later alchemy may raise the former). Matched by
9
+ * tag string, no import, so neither shape needs a kit release.
10
+ * - TRANSIENT, tolerated only inside a poll or its connect: a per-GET timeout, the identity
11
+ * timeout, `KubernetesApiError` 5xx/429, and upstream's transport error, which is the plain
12
+ * `Error` whose message starts `Failed Kubernetes ` (`client.ts:146-148`).
13
+ * - ⚠️ ANY OTHER UNTAGGED `Error` IS A DEFECT AND PROPAGATES. It used to count as transport, which
14
+ * made upstream's path-builder throw for an empty namespace (`objects.ts:189-192`) and a
15
+ * failing `bao` (`credentials.ts` plain `Error`s) look like a slow rollout for ten minutes.
16
+ * - Everything else propagates: 401/403/other 4xx, vault failures (the ClusterHealth rule).
17
+ */
18
+ import * as Data from 'effect/Data';
19
+ import type { LastTransient } from './ready-errors.ts';
20
+
21
+ interface Tagged {
22
+ readonly _tag?: unknown;
23
+ readonly statusCode?: unknown;
24
+ readonly message?: unknown;
25
+ }
26
+
27
+ export const isNotFound = (error: unknown): boolean => {
28
+ const e = error as Tagged | null;
29
+ return (
30
+ e?._tag === 'KubernetesNotFound' || (e?._tag === 'KubernetesApiError' && e.statusCode === 404)
31
+ );
32
+ };
33
+
34
+ /** Upstream's transport failure wrapper (`client.ts:146-148`); its message names no body. */
35
+ const UPSTREAM_TRANSPORT = /^Failed Kubernetes /;
36
+
37
+ /** The tolerable-in-a-poll classes, as a record of tag + status; `undefined` means propagate. */
38
+ export const transientOf = (error: unknown): LastTransient | undefined => {
39
+ const e = error as Tagged | null;
40
+ if (e?._tag === 'KubernetesReadyGetTimeout') return { tag: 'KubernetesReadyGetTimeout' };
41
+ if (e?._tag === 'TalosClusterIdentityTimeout') return { tag: 'TalosClusterIdentityTimeout' };
42
+ if (e?._tag === 'KubernetesApiError') {
43
+ const status = typeof e.statusCode === 'number' ? e.statusCode : 0;
44
+ return status >= 500 || status === 429 ? { status, tag: 'KubernetesApiError' } : undefined;
45
+ }
46
+ return error instanceof Error && e?._tag === undefined && UPSTREAM_TRANSPORT.test(error.message)
47
+ ? { tag: 'TransportError' }
48
+ : undefined;
49
+ };
50
+
51
+ /**
52
+ * A propagated apiserver refusal (401/403/other 4xx, or any status in a single pass). ⛔ Upstream's
53
+ * `KubernetesApiError` message quotes up to 1000 bytes of the response body (`client.ts:38-42`);
54
+ * a body is the server's text, not ours to republish into logs and run output, so it is dropped.
55
+ */
56
+ export class KubernetesReadyApiError extends Data.TaggedError('KubernetesReadyApiError')<{
57
+ readonly method: string;
58
+ readonly path: string;
59
+ readonly statusCode: number;
60
+ }> {
61
+ override get message(): string {
62
+ return `Kubernetes.Ready: ${this.method} ${this.path} responded ${this.statusCode}`;
63
+ }
64
+ }
65
+
66
+ /** ⛔ Wrap every error that leaves a GET, the identity GET of connect included (round 1, finding 4). */
67
+ export const scrubbed = (error: unknown): unknown => {
68
+ const e = error as { _tag?: unknown; method?: unknown; path?: unknown; statusCode?: unknown };
69
+ return e?._tag === 'KubernetesApiError'
70
+ ? new KubernetesReadyApiError({
71
+ method: String(e.method),
72
+ path: String(e.path),
73
+ statusCode: Number(e.statusCode),
74
+ })
75
+ : error;
76
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Connect for `HomeFlare.Kubernetes.Ready`. `connectCluster` reads the vault and then makes the
3
+ * cluster-identity GET (`talos/cluster-identity.ts`), so it reaches the apiserver too.
4
+ *
5
+ * ⛔ A SINGLE PASS (`read`/`diff`) connects once; only errors are scrubbed. Upstream's
6
+ * `KubernetesApiError` message quotes up to 1000 bytes of the response body (`client.ts:38-42`),
7
+ * and a 401/403/5xx on the identity GET carries it (round 1, finding 4).
8
+ * ★ A POLL retries the connect on the poll's transients (identity timeout, 5xx, 429, upstream's
9
+ * transport error) every `pollInterval` until the poll's own deadline: on a first install the
10
+ * apiserver is exactly what a CNI rollout is restarting (round 1, finding 2). Vault failures,
11
+ * 401/403, a uid mismatch and any untagged defect still propagate at once.
12
+ */
13
+ import { connectCluster } from 'alchemy/Kubernetes/internal/client';
14
+ import type { ClusterTransport } from 'alchemy/Kubernetes/ClusterAdapter';
15
+ import type * as Duration from 'effect/Duration';
16
+ import * as Effect from 'effect/Effect';
17
+ import { scrubbed, transientOf } from './ready-classify.ts';
18
+ import type { LastTransient } from './ready-errors.ts';
19
+
20
+ type Connection = Parameters<typeof connectCluster>[0];
21
+
22
+ /** Connect once. */
23
+ export const connectOnce = (connection: Connection) =>
24
+ connectCluster(connection).pipe(Effect.mapError(scrubbed));
25
+
26
+ /**
27
+ * Connect once for `diff`: a transient is `undefined` (the plan says `update`, `reconcile` waits);
28
+ * everything else fails scrubbed. ⚠️ Classified BEFORE scrubbing: the scrubbed 5xx loses its tag.
29
+ */
30
+ export const connectForDiff = (connection: Connection) =>
31
+ Effect.gen(function* () {
32
+ const attempt = yield* Effect.result(connectCluster(connection));
33
+ if (attempt._tag === 'Success') return attempt.success as ClusterTransport;
34
+ return transientOf(attempt.failure) === undefined
35
+ ? yield* Effect.fail(scrubbed(attempt.failure))
36
+ : undefined;
37
+ });
38
+
39
+ /** Connect, retrying transients until the caller's deadline interrupts. */
40
+ export const connectTolerant =
41
+ (connection: Connection, pollInterval: Duration.Duration) =>
42
+ (note: (transient: LastTransient) => Effect.Effect<void>) =>
43
+ Effect.gen(function* () {
44
+ while (true) {
45
+ const attempt = yield* Effect.result(connectCluster(connection));
46
+ if (attempt._tag === 'Success') return attempt.success as ClusterTransport;
47
+ const transient = transientOf(attempt.failure);
48
+ if (transient === undefined) return yield* Effect.fail(scrubbed(attempt.failure));
49
+ yield* note(transient);
50
+ yield* Effect.sleep(pollInterval);
51
+ }
52
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Declaration-time refusals of `HomeFlare.Kubernetes.Ready` (round 1, findings 3 and 5): checks that
3
+ * can never pass, and an `after` that would let the gate be skipped.
4
+ */
5
+ import { expect, test } from 'bun:test';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Output from 'alchemy/Output';
8
+ import { declarationRefusal, declaredReady } from './ready-declare.ts';
9
+ import { KubernetesReady } from './ready.ts';
10
+ import { connection, crdCheck, depCheck, dsCheck } from './ready.fixtures.ts';
11
+
12
+ const refusal = (extra: object) =>
13
+ declarationRefusal({ checks: [dsCheck, depCheck, crdCheck], connection, ...extra }) as
14
+ | { _tag: string; index: number; problem?: string }
15
+ | undefined;
16
+ const badCheck = (check: object) => refusal({ checks: [check] });
17
+
18
+ test('valid checks and no `after` are accepted', () => {
19
+ expect(refusal({})).toBeUndefined();
20
+ expect(refusal({ checks: [{ ...dsCheck, minReady: 2 }] })).toBeUndefined();
21
+ });
22
+
23
+ test('an empty list, an empty name, a missing namespace and a bad minReady are typed refusals', () => {
24
+ expect(refusal({ checks: [] })?._tag).toBe('KubernetesReadyBadCheck');
25
+ for (const check of [
26
+ { ...dsCheck, name: '' },
27
+ { ...depCheck, namespace: '' },
28
+ { kind: 'Deployment', name: 'x' },
29
+ { kind: 'DaemonSet', name: 'x', namespace: undefined },
30
+ { ...dsCheck, minReady: 0 },
31
+ { ...dsCheck, minReady: 1.5 },
32
+ { ...dsCheck, minReady: '2' },
33
+ { ...depCheck, minReady: 1 },
34
+ { ...crdCheck, name: '' },
35
+ { kind: 'Pod', name: 'x', namespace: 'y' },
36
+ ]) {
37
+ expect(badCheck(check)?._tag).toBe('KubernetesReadyBadCheck');
38
+ }
39
+ });
40
+
41
+ // REAL property expressions of a chart (round 2: a plain-object stand-in hid that `chart.connection`
42
+ // passed). Only `chart.objects` stays lazy while the chart updates; every other property is a stable.
43
+ const chart = Output.of({ LogicalId: 'chart', Type: 'Kubernetes.HelmChart' } as never);
44
+ const prop = (name: string) => new Output.PropExpr(chart as never, name as never);
45
+ const lazy = prop('objects');
46
+ const stableLike = { auth: { kind: 'talos-openbao', uid: 'uid-c1' } };
47
+
48
+ test('`after` accepts only chart.objects and refuses every other shape (a chart stable resolves at plan time)', () => {
49
+ expect(refusal({ after: [lazy] })).toBeUndefined();
50
+ for (const stable of ['connection', 'releaseName', 'namespace']) {
51
+ expect(refusal({ after: [prop(stable)] })).toMatchObject({
52
+ _tag: 'KubernetesReadyBadAfter',
53
+ index: 0,
54
+ });
55
+ }
56
+ expect(refusal({ after: [Output.literal([{ kind: 'Deployment' }])] })).toMatchObject({
57
+ _tag: 'KubernetesReadyBadAfter',
58
+ index: 0,
59
+ });
60
+ expect(refusal({ after: [stableLike] })).toMatchObject({
61
+ _tag: 'KubernetesReadyBadAfter',
62
+ index: 0,
63
+ });
64
+ // a bare `chart` is a ResourceExpr whose stables resolve at plan time: also refused
65
+ expect(refusal({ after: [chart] })).toMatchObject({ _tag: 'KubernetesReadyBadAfter', index: 0 });
66
+ expect(refusal({ after: [lazy, 'chart'] })).toMatchObject({
67
+ _tag: 'KubernetesReadyBadAfter',
68
+ index: 1,
69
+ });
70
+ });
71
+
72
+ test('declaration fails with the typed error for plain props and for a props Effect', async () => {
73
+ const bad = { checks: [{ ...depCheck, namespace: '' }], connection };
74
+ const plain = await Effect.runPromise(Effect.flip(KubernetesReady('x', bad as never) as never));
75
+ expect((plain as { _tag: string })._tag).toBe('KubernetesReadyBadCheck');
76
+ // the props-Effect branch, over a stand-in constructor (the real one needs a Stack to run)
77
+ const wrapped = declaredReady((_id: string, props: unknown) => props);
78
+ const viaEffect = wrapped('x', Effect.succeed(bad)) as Effect.Effect<unknown, { _tag: string }>;
79
+ expect((await Effect.runPromise(Effect.flip(viaEffect)))._tag).toBe('KubernetesReadyBadCheck');
80
+ const after = { checks: [dsCheck], connection, after: [stableLike] };
81
+ const skipped = await Effect.runPromise(
82
+ Effect.flip(KubernetesReady('x', after as never) as never),
83
+ );
84
+ expect((skipped as { _tag: string })._tag).toBe('KubernetesReadyBadAfter');
85
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Declaration-time guards for `HomeFlare.Kubernetes.Ready` (the `withLiteralConnection` shape): a
3
+ * fresh resource with unresolved inputs skips `diff`, so anything that must be refused has to be
4
+ * refused when the resource is DECLARED. Plain props fail the declaring Effect; a props Effect
5
+ * fails once it yields.
6
+ *
7
+ * ⛔ `checks`: a check that can never be satisfied (empty name, a namespaced kind with no
8
+ * namespace, `minReady` below 1) is a typed `KubernetesReadyBadCheck`, not a 10 minute timeout
9
+ * (`validateChecks`, round 1 finding 3).
10
+ * ⛔ `after`: ONLY `chart.objects` (a `PropExpr` whose identifier is `objects`) orders the row after
11
+ * the chart's UPDATE. Any other property of the chart (`chart.connection`, `chart.releaseName`)
12
+ * is a stable that Alchemy fills from state while the chart updates, so it resolves to a plain
13
+ * value at plan time, Ready plans `noop` and the gate is silently skipped; a bare `chart`
14
+ * resolves to its stables the same way (round 1 finding 5; round 2: a property-expression test,
15
+ * not a plain-object stand-in, proved `chart.connection` slipped through). Every other shape is
16
+ * `KubernetesReadyBadAfter`.
17
+ */
18
+ import { isPropExpr } from 'alchemy/Output';
19
+ import * as Effect from 'effect/Effect';
20
+ import { validateChecks } from './ready-checks.ts';
21
+ import { KubernetesReadyBadAfter } from './ready-errors.ts';
22
+
23
+ /** The typed refusal for the given props, or `undefined`. */
24
+ export const declarationRefusal = (props: unknown) => {
25
+ const { after, checks } = (props ?? {}) as { after?: unknown; checks?: unknown };
26
+ const badCheck = validateChecks(checks);
27
+ if (badCheck !== undefined) return badCheck;
28
+ if (after === undefined) return undefined;
29
+ const list = Array.isArray(after) ? after : [after];
30
+ const index = list.findIndex((item) => !(isPropExpr(item) && item.identifier === 'objects'));
31
+ return index === -1 ? undefined : new KubernetesReadyBadAfter({ index });
32
+ };
33
+
34
+ export const declaredReady = <C extends (...args: never[]) => unknown>(resource: C): C =>
35
+ new Proxy(resource, {
36
+ apply: (target, thisArg, args: unknown[]) => {
37
+ const props = args[1];
38
+ if (Effect.isEffect(props)) {
39
+ const checked = props.pipe(
40
+ Effect.flatMap((resolved) => {
41
+ const refused = declarationRefusal(resolved);
42
+ return refused === undefined ? Effect.succeed(resolved) : Effect.fail(refused);
43
+ }),
44
+ );
45
+ return Reflect.apply(target, thisArg, [args[0], checked, ...args.slice(2)]);
46
+ }
47
+ const refused = declarationRefusal(props);
48
+ return refused === undefined ? Reflect.apply(target, thisArg, args) : Effect.fail(refused);
49
+ },
50
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The typed errors of `HomeFlare.Kubernetes.Ready` that carry no logic, so a stack can `catchTag`
3
+ * them. ⛔ Every message names check keys, counts, durations and statuses only: never a response
4
+ * body, which is the server's text and not ours to republish.
5
+ */
6
+ import * as Data from 'effect/Data';
7
+
8
+ /** What the poll recorded when a pass last tolerated a transient error: tag and status, no body. */
9
+ export interface LastTransient {
10
+ readonly tag: string;
11
+ readonly status?: number;
12
+ }
13
+
14
+ /**
15
+ * What the last pass saw of one check: the object was absent (404), or the numbers it reported.
16
+ * ★ A 404 that never resolves (a typo'd name, a chart that never creates it) must read differently
17
+ * from a slow rollout, without republishing anything the server said (round 1, finding 3).
18
+ */
19
+ export type KeyState =
20
+ | { readonly notFound: true }
21
+ | { readonly counts: Readonly<Record<string, number>> };
22
+
23
+ const describeState = (state: KeyState | undefined): string => {
24
+ if (state === undefined) return '';
25
+ if ('notFound' in state) return ' (not found)';
26
+ const counts = Object.entries(state.counts).map(([name, value]) => `${name}=${value}`);
27
+ return counts.length === 0 ? '' : ` (${counts.join(' ')})`;
28
+ };
29
+
30
+ /** The gate never opened within `waitTimeout`. `failing` holds check keys only. */
31
+ export class KubernetesReadyTimeout extends Data.TaggedError('KubernetesReadyTimeout')<{
32
+ readonly failing: readonly string[];
33
+ readonly seconds: number;
34
+ readonly lastTransient?: LastTransient;
35
+ /** Per failing key, from the last pass that reached it: `notFound`, or the counts seen. */
36
+ readonly states?: Readonly<Record<string, KeyState>>;
37
+ }> {
38
+ override get message(): string {
39
+ const failing = this.failing.map((key) => `${key}${describeState(this.states?.[key])}`);
40
+ const last =
41
+ this.lastTransient === undefined
42
+ ? ''
43
+ : `; last transient error ${this.lastTransient.tag}${
44
+ this.lastTransient.status === undefined ? '' : ` ${this.lastTransient.status}`
45
+ }`;
46
+ return `Kubernetes.Ready: not ready within ${this.seconds}s: ${failing.join(', ')}${last}`;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * A rollout that will not finish, which is not pending: a Deployment reported
52
+ * `ProgressDeadlineExceeded`, or a DaemonSet's `updateStrategy` is not `RollingUpdate` (kubectl
53
+ * `rollout_status.go:104-106` errors the same way: `OnDelete` pods never roll by themselves).
54
+ */
55
+ export class KubernetesRolloutFailed extends Data.TaggedError('KubernetesRolloutFailed')<{
56
+ readonly check: string;
57
+ readonly reason?: 'ProgressDeadlineExceeded' | 'UpdateStrategyNotRollingUpdate';
58
+ }> {
59
+ override get message(): string {
60
+ return this.reason === 'UpdateStrategyNotRollingUpdate'
61
+ ? `Kubernetes.Ready: ${this.check} does not use the RollingUpdate update strategy`
62
+ : `Kubernetes.Ready: ${this.check} exceeded its progress deadline (ProgressDeadlineExceeded)`;
63
+ }
64
+ }
65
+
66
+ /** A `waitTimeout`/`pollInterval` that is not a Go-style duration (`10m0s`, `90s`, `1h30m`). */
67
+ export class KubernetesReadyBadDuration extends Data.TaggedError('KubernetesReadyBadDuration')<{
68
+ readonly field: string;
69
+ readonly value: string;
70
+ /** The resource whose prop it is; `Kubernetes.Ready` unless the HelmChart bound says otherwise. */
71
+ readonly resource?: string;
72
+ }> {
73
+ override get message(): string {
74
+ // A hostile or typo'd value can be long: the message quotes a bounded prefix only.
75
+ const shown = this.value.length > 40 ? `${this.value.slice(0, 40)}...` : this.value;
76
+ return `${this.resource ?? 'Kubernetes.Ready'}: ${this.field} '${shown}' is not a duration like 10m0s`;
77
+ }
78
+ }
79
+
80
+ /** A check that can never be satisfied, refused at declaration instead of timing out in 10 m. */
81
+ export class KubernetesReadyBadCheck extends Data.TaggedError('KubernetesReadyBadCheck')<{
82
+ /** Index into `checks`. */
83
+ readonly index: number;
84
+ readonly problem: string;
85
+ }> {
86
+ override get message(): string {
87
+ return `Kubernetes.Ready: checks[${this.index}] ${this.problem}`;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * `after` must hold Outputs of the chart (`chart.objects`). ⛔ A plain value, or a stable such as
93
+ * `chart.connection`, resolves at plan time, so the row would plan `noop` before the chart's
94
+ * update and the gate would be skipped.
95
+ */
96
+ export class KubernetesReadyBadAfter extends Data.TaggedError('KubernetesReadyBadAfter')<{
97
+ readonly index: number;
98
+ }> {
99
+ override get message(): string {
100
+ return `Kubernetes.Ready: after[${this.index}] is not chart.objects (the only shape that waits for the chart's update)`;
101
+ }
102
+ }