@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.
- package/dist/kubernetes/helm-chart-bounded.d.ts +44 -0
- package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
- package/dist/kubernetes/index.d.ts +12 -0
- package/dist/kubernetes/index.d.ts.map +1 -0
- package/dist/kubernetes/index.js +585 -0
- package/dist/kubernetes/index.js.map +19 -0
- package/dist/kubernetes/ready-checks.d.ts +45 -0
- package/dist/kubernetes/ready-checks.d.ts.map +1 -0
- package/dist/kubernetes/ready-classify.d.ts +23 -0
- package/dist/kubernetes/ready-classify.d.ts.map +1 -0
- package/dist/kubernetes/ready-connect.d.ts +29 -0
- package/dist/kubernetes/ready-connect.d.ts.map +1 -0
- package/dist/kubernetes/ready-declare.d.ts +5 -0
- package/dist/kubernetes/ready-declare.d.ts.map +1 -0
- package/dist/kubernetes/ready-errors.d.ts +80 -0
- package/dist/kubernetes/ready-errors.d.ts.map +1 -0
- package/dist/kubernetes/ready-poll.d.ts +61 -0
- package/dist/kubernetes/ready-poll.d.ts.map +1 -0
- package/dist/kubernetes/ready.d.ts +74 -0
- package/dist/kubernetes/ready.d.ts.map +1 -0
- package/dist/talos/fake-apiserver.d.ts +11 -2
- package/dist/talos/fake-apiserver.d.ts.map +1 -1
- package/docs/kubernetes-ready.md +146 -0
- package/docs/peers.md +13 -0
- package/docs/provider-standard.md +7 -0
- package/package.json +7 -1
- package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
- package/src/kubernetes/helm-chart-bounded.ts +110 -0
- package/src/kubernetes/index.ts +28 -0
- package/src/kubernetes/ready-backoff.test.ts +124 -0
- package/src/kubernetes/ready-checks.test.ts +106 -0
- package/src/kubernetes/ready-checks.ts +165 -0
- package/src/kubernetes/ready-classify.ts +76 -0
- package/src/kubernetes/ready-connect.ts +52 -0
- package/src/kubernetes/ready-declare.test.ts +85 -0
- package/src/kubernetes/ready-declare.ts +50 -0
- package/src/kubernetes/ready-errors.ts +102 -0
- package/src/kubernetes/ready-first-install.test.ts +126 -0
- package/src/kubernetes/ready-poll-states.test.ts +92 -0
- package/src/kubernetes/ready-poll.test.ts +194 -0
- package/src/kubernetes/ready-poll.ts +215 -0
- package/src/kubernetes/ready-rollout.test.ts +58 -0
- package/src/kubernetes/ready-secrecy.test.ts +131 -0
- package/src/kubernetes/ready.fixtures.ts +103 -0
- package/src/kubernetes/ready.test.ts +205 -0
- package/src/kubernetes/ready.ts +188 -0
- package/src/talos/fake-apiserver.ts +21 -3
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# HomeFlare.Kubernetes.Ready
|
|
2
|
+
|
|
3
|
+
`@homeflare/alchemy/kubernetes` — a readiness gate for a Kubernetes cluster reached through the
|
|
4
|
+
`talos-openbao` adapter. Walked against `alchemy@2.0.0-beta.81`. Proven offline only (fake `bao`,
|
|
5
|
+
fake apiserver); **not** exercised against a live cluster.
|
|
6
|
+
|
|
7
|
+
## Why it exists
|
|
8
|
+
|
|
9
|
+
Upstream ships no readiness resource. `Kubernetes.HelmChart` reconcile returns when server-side
|
|
10
|
+
apply returns, and Alchemy releases every downstream row at that moment. A row chained after the
|
|
11
|
+
Cilium chart would start before one Cilium pod runs. Declare a `Ready` row with
|
|
12
|
+
`after: [chart.objects]` and chain later rows on `ready.ready`.
|
|
13
|
+
|
|
14
|
+
## Declaring it
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
const cniReady =
|
|
18
|
+
yield *
|
|
19
|
+
KubernetesReady('cni-ready', {
|
|
20
|
+
connection: talosOpenBaoConnection(PINNED_UID), // a literal uid, never an Output
|
|
21
|
+
checks: [
|
|
22
|
+
{ kind: 'DaemonSet', namespace: 'kube-system', name: 'cilium', minReady: 2 },
|
|
23
|
+
{ kind: 'Deployment', namespace: 'kube-system', name: 'cilium-operator' },
|
|
24
|
+
{ kind: 'CustomResourceDefinition', name: 'ciliumloadbalancerippools.cilium.io' },
|
|
25
|
+
],
|
|
26
|
+
waitTimeout: '10m0s', // Go style h/m/s, default 10m0s
|
|
27
|
+
pollInterval: '10s', // default 10s
|
|
28
|
+
after: [cilium.objects],
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Providers: `Layer.mergeAll(Kubernetes.providers(), TalosOpenBaoAdapter(...), KubernetesReadyProvider())`.
|
|
33
|
+
The provider requires `ChildProcessSpawner` (the adapter shells to `bao`).
|
|
34
|
+
|
|
35
|
+
## What "ready" means
|
|
36
|
+
|
|
37
|
+
The rules are `kubectl rollout status` (kubectl v0.34.0 `rollout_status.go`), not "Available":
|
|
38
|
+
|
|
39
|
+
| kind | ready when |
|
|
40
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
41
|
+
| DaemonSet | `updateStrategy` is `RollingUpdate`, `observedGeneration >= generation`, `updatedNumberScheduled >= minReady`, `numberAvailable - (desiredNumberScheduled - updatedNumberScheduled) >= minReady` |
|
|
42
|
+
| Deployment | `observedGeneration >= generation`, `updatedReplicas >= spec.replicas`, `replicas == updatedReplicas`, `availableReplicas >= updatedReplicas` |
|
|
43
|
+
| CustomResourceDefinition | condition `Established` is `True` |
|
|
44
|
+
|
|
45
|
+
`minReady` defaults to `desiredNumberScheduled`, and a DaemonSet that wants zero pods is never ready.
|
|
46
|
+
`numberAvailable` also counts available pods of the OLD template, so it is never compared to
|
|
47
|
+
`minReady` alone: subtracting `desired - updated` (the most old pods there can be) leaves a lower
|
|
48
|
+
bound on pods that are both updated and available. Without it, minReady 2 of 4 with two crashlooping
|
|
49
|
+
new pods and two healthy old ones would pass.
|
|
50
|
+
A Deployment whose `Progressing` condition has reason `ProgressDeadlineExceeded` (for the
|
|
51
|
+
generation the controller has observed), and a DaemonSet whose `updateStrategy` is not
|
|
52
|
+
`RollingUpdate` (absent counts as `RollingUpdate`), are typed failures, `KubernetesRolloutFailed`
|
|
53
|
+
(`reason`), not pending.
|
|
54
|
+
|
|
55
|
+
## Refused at declaration
|
|
56
|
+
|
|
57
|
+
- `checks` must be non-empty; each needs a known `kind`, a non-empty `name`, a non-empty
|
|
58
|
+
`namespace` for DaemonSet and Deployment, and `minReady` (DaemonSet only) an integer >= 1:
|
|
59
|
+
`KubernetesReadyBadCheck { index, problem }`.
|
|
60
|
+
- `after` must hold lazy Outputs of the chart (`chart.objects`): `KubernetesReadyBadAfter { index }`
|
|
61
|
+
for a plain value, a bare `chart`, or a stable such as `chart.connection`. Those resolve at plan
|
|
62
|
+
time, so the row would plan `noop` before the chart's update and the gate would be skipped.
|
|
63
|
+
|
|
64
|
+
## Errors
|
|
65
|
+
|
|
66
|
+
| outcome | `read` / `diff` (one pass) | `reconcile` (poll) |
|
|
67
|
+
| -------------------------------------------------------------- | -------------------------- | --------------------------------------------------------- |
|
|
68
|
+
| 404 (`_tag` `KubernetesNotFound`, or `KubernetesApiError` 404) | pending | pending |
|
|
69
|
+
| per-GET timeout (5 s), 5xx, 429, upstream transport `Error` | read: throws; diff: update | pending, recorded as `lastTransient` (tag + status) |
|
|
70
|
+
| the same on the connect's identity GET (incl. its 5 s timeout) | propagates | connect retried every `pollInterval`, inside the deadline |
|
|
71
|
+
| 401, 403, other 4xx | propagates | propagates |
|
|
72
|
+
| uid mismatch, vault (`bao`) failure | propagates | propagates |
|
|
73
|
+
| any other untagged `Error` (a defect, e.g. an empty namespace) | propagates | propagates |
|
|
74
|
+
|
|
75
|
+
- "Upstream transport `Error`" is only the `Failed Kubernetes ...` wrapper (alchemy `client.ts`).
|
|
76
|
+
- `lastTransient` is the transient of the LAST pass; one that has cleared is not blamed.
|
|
77
|
+
- `read` with no prior output (Alchemy's deferred adoption read on a first create) returns
|
|
78
|
+
`undefined` without touching the cluster: the row owns nothing to adopt.
|
|
79
|
+
- Every error leaving a GET, the connect's identity GET included, is scrubbed of the response body.
|
|
80
|
+
|
|
81
|
+
- 404 is matched by tag string with no import, so it is proof against alchemy raising
|
|
82
|
+
`KubernetesNotFound` instead of `KubernetesApiError` (standing watch: drop the second shape once
|
|
83
|
+
upstream settles).
|
|
84
|
+
- A deadline fails with `KubernetesReadyTimeout { failing, seconds, lastTransient?, states? }`.
|
|
85
|
+
`failing` holds **check keys** (`DaemonSet/kube-system/cilium`) only; `states` records per key
|
|
86
|
+
what the last pass saw: `{ notFound: true }` (a 404 that never resolves) or `{ counts }`
|
|
87
|
+
(generation and rollout numbers), so a missing object reads differently from a slow rollout.
|
|
88
|
+
- A propagated apiserver refusal is rewrapped as `KubernetesReadyApiError { method, path,
|
|
89
|
+
statusCode }`: upstream's `KubernetesApiError` message quotes up to 1000 bytes of the response
|
|
90
|
+
body, which this row does not republish.
|
|
91
|
+
- Upstream `readObject` takes no signal, so a timed-out GET abandons its socket rather than
|
|
92
|
+
aborting it, and process exit is not a bound for a long apply. The poll therefore rations
|
|
93
|
+
timeouts: after one GET times out the rest of that pass is pending with no GET, and the pause
|
|
94
|
+
doubles per consecutive timeout (cap 30 s), resetting on a clean pass. Standing watch: drop this
|
|
95
|
+
when upstream's `readObject` takes an AbortSignal (alchemy PR 1948, per-attempt deadlines).
|
|
96
|
+
- Upstream also retries a transport error itself, for about 40 s (a `spaced` 5 s schedule capped
|
|
97
|
+
by `recurs(8)`), longer than the per-GET bound, so a dead socket surfaces as the GET timeout.
|
|
98
|
+
|
|
99
|
+
## Lifecycle
|
|
100
|
+
|
|
101
|
+
- `read`: one pass, `ready: false` when any check is pending. Never waits.
|
|
102
|
+
- `diff`: refuses a non-literal `connection` (`TalosUidNotLiteral`, also at declaration);
|
|
103
|
+
`undefined` when unresolved; `replace` when the connection differs (it is in `stables`, so
|
|
104
|
+
dependants would otherwise see the old one for a deploy; `delete` is a no-op, so nothing live
|
|
105
|
+
is touched); `noop` when the live pass is ready and the check keys are unchanged; otherwise
|
|
106
|
+
`update`. It is live on every plan, like `Talos.ClusterHealth`, but a transient (5xx, 429, a
|
|
107
|
+
timeout, on the connect or a GET) is `update`, never a failed plan: `reconcile` does the
|
|
108
|
+
waiting. 401/403 and vault failures still propagate.
|
|
109
|
+
- `reconcile`: polls until every check passes or fails typed.
|
|
110
|
+
- `delete` is a no-op and `list` is empty: the row owns nothing in the cluster. `connection` is
|
|
111
|
+
`stables`, and is copied onto the attributes so the row is `ClusterLike`.
|
|
112
|
+
|
|
113
|
+
## Secrecy
|
|
114
|
+
|
|
115
|
+
Attributes are `{ connection, ready, checks }`; the connection is `{ kind, uid }`. No kubeconfig,
|
|
116
|
+
PEM, token or response body reaches an attribute, an error, a log or a file
|
|
117
|
+
(`ready-secrecy.test.ts`).
|
|
118
|
+
|
|
119
|
+
## Bounded HelmChart (`boundedHelmChartProvider`, ledger row `t10-k1b`)
|
|
120
|
+
|
|
121
|
+
Upstream's `Kubernetes.HelmChart` has no deadline: alchemy 2.0.0-beta.81's `requestJson` sets no
|
|
122
|
+
timeout and no signal, so a hung apiserver blocks `reconcile`/`read` forever and would hold a 1h
|
|
123
|
+
platform token across a stuck deploy. `boundedHelmChartProvider()` wraps upstream's provider (never
|
|
124
|
+
re-implements it) in `Effect.timeoutOrElse`, failing typed `KubernetesReconcileTimeout { id,
|
|
125
|
+
operation, seconds }`: `HELM_RECONCILE_TIMEOUT = '5m0s'` (render, discovery, ~60 SSA PATCHes) and
|
|
126
|
+
`HELM_READ_TIMEOUT = '1m0s'`.
|
|
127
|
+
|
|
128
|
+
- Merge it beside `Kubernetes.providers()`: `Layer.mergeAll(Kubernetes.providers(),
|
|
129
|
+
boundedHelmChartProvider())`. A direct `Provider(HelmChart)` service beats the collection
|
|
130
|
+
(`Provider.ts` `tryFindProviderRegistrationByType`), so the wrapper is the one the engine resolves.
|
|
131
|
+
- `diff`, `delete`, `stables` and `aliases` are upstream's by spread: state identity and replace
|
|
132
|
+
semantics do not change.
|
|
133
|
+
- The interrupted fiber abandons its socket (upstream takes no signal); the run fails typed and the
|
|
134
|
+
process exits once main completes. Standing watch: upstream puts no `AbortSignal` on its
|
|
135
|
+
requests; drop the wrapper when it ships request deadlines (alchemy PR 1948).
|
|
136
|
+
- A malformed override dies at layer build rather than leaving the call unbounded, as
|
|
137
|
+
`KubernetesReadyBadDuration` naming `Kubernetes.HelmChart` and the field
|
|
138
|
+
(`reconcileTimeout`/`readTimeout`). The timeout message names the row and seconds only.
|
|
139
|
+
- Errors that pass through (an upstream `KubernetesApiError` quotes up to 1000 bytes of the
|
|
140
|
+
apiserver body, `client.ts:39-41`) are scrubbed to the body-free `KubernetesReadyApiError`.
|
|
141
|
+
- The read bound rarely fires with the `talos-openbao` adapter: upstream's read only connects, and
|
|
142
|
+
the kit already caps connect at 10 s and the uid read at 5 s. It is a backstop.
|
|
143
|
+
- `delete` stays unbounded on purpose: every platform row is `retain`, replace cleanup skips
|
|
144
|
+
deletion of retained rows (alchemy `Apply.ts:1111-1112`), and design §8 only drops state.
|
|
145
|
+
- `isBoundedHelmChartProvider(service)` is true for a service this layer built, so a consuming
|
|
146
|
+
stack can assert its real `providers` layer resolves the wrapper and not upstream's.
|
package/docs/peers.md
CHANGED
|
@@ -29,6 +29,8 @@ asserted against that field; change the field first, then copy its `overrides` b
|
|
|
29
29
|
"@effect/platform-node": "4.0.1",
|
|
30
30
|
"@effect/platform-node-shared": "4.0.1",
|
|
31
31
|
"@effect/platform-bun": "4.0.1",
|
|
32
|
+
"@effect/sql-d1": "4.0.1",
|
|
33
|
+
"@effect/sql-sqlite-do": "4.0.1",
|
|
32
34
|
"rolldown": "1.2.8",
|
|
33
35
|
"redis": "6.3.0"
|
|
34
36
|
}
|
|
@@ -50,6 +52,7 @@ next rc and breaks at import. Pin the whole set.
|
|
|
50
52
|
| `rolldown` → 1.2.9 via vite's `~1.2.6` (measured 2026-09-16) | `GET …/rolldown-1.2.9.tgz - 404` at `bun add` |
|
|
51
53
|
| no `mime` (measured 2026-09-17 against Alchemy 78) | `Cannot find package 'mime'` from cloudflare-runtime |
|
|
52
54
|
| `redis` → 6.3.0 via the `>=5.0.0 <7.0.0` peer of platform-node/sql-pg (measured 2026-09-30) | `No version matching "6.3.0" found for specifier "@redis/time-series" (but package exists)` at `bun add` |
|
|
55
|
+
| `@effect/sql-d1`/`-sqlite-do` → 4.0.2 via alchemy's `^4.0.0` (measured 2026-10-09) | unmet peer: 4.0.2 wants `effect ^4.0.2`, the contract pins 4.0.1 |
|
|
53
56
|
|
|
54
57
|
Alchemy 78 adapted to rc.115 — that first row is why we used to pin 112, not a reason to
|
|
55
58
|
stay there. The override is still the only thing that holds the set together.
|
|
@@ -62,6 +65,16 @@ failed outright for that window. The line completed the same hour — every `@re
|
|
|
62
65
|
sub-package published at 6.3.0 (measured 11:12:19Z) — and the pin moved onto that complete
|
|
63
66
|
set. The pin stays exact because the same class of gap can recur with any future redis minor.
|
|
64
67
|
|
|
68
|
+
⛔ **`@effect/sql-d1` and `@effect/sql-sqlite-do` (added 2026-10-09, ledger row
|
|
69
|
+
kit-contract-effect-402) are exact in `overrides` because alchemy beta.81 itself depends on
|
|
70
|
+
both at `^4.0.0`** (`npm view alchemy@2.0.0-beta.81 dependencies`). A fresh, lockless install
|
|
71
|
+
resolves `^4.0.0` to 4.0.2, whose peer is `effect ^4.0.2` — an **unmet peer against the
|
|
72
|
+
contract's effect 4.0.1 on every fresh consumer install**; only an already-locked tree hid it
|
|
73
|
+
(the kit's own `bun.lock` said 4.0.1 because it was locked earlier). 4.0.1 peers on
|
|
74
|
+
`effect ^4.0.1`, so the pin holds the D1 / Durable-Object SQL resources on the version the
|
|
75
|
+
kit is tested on — the runtime path is unverified, as no stack uses those resources yet. They
|
|
76
|
+
are overrides, not install-line pins: they arrive transitively through `alchemy`.
|
|
77
|
+
|
|
65
78
|
⚠️ `@effect/platform-node` is **required, not optional**: Alchemy's module graph reaches
|
|
66
79
|
`Cloudflare/Workers/WorkerBridge → @effect/platform-node/NodeServices` even when you only
|
|
67
80
|
import the Proxmox subpath.
|
|
@@ -98,6 +98,13 @@ already ships is a finding, even when it works.
|
|
|
98
98
|
`Redacted` values in plaintext, and only one state store encrypts them.
|
|
99
99
|
- **Bounded waits (S26).** A retry stops after at most 8 to 10 attempts and under about a
|
|
100
100
|
minute. A poll never runs past about 90 seconds.
|
|
101
|
+
- **The one exception: readiness gates** (`Talos.ClusterHealth`, `Kubernetes.Ready`). For these,
|
|
102
|
+
waiting IS the resource's job: a CNI or control-plane rollout takes minutes. So the wait may
|
|
103
|
+
run to a declared `waitTimeout` (default minutes, never unbounded) when ALL of these hold:
|
|
104
|
+
the timeout is a typed, declared prop; every single request inside it is bounded (a per-call
|
|
105
|
+
deadline); transient errors are pending, never a retry storm; and the timeout is a typed error
|
|
106
|
+
naming what was still pending. Any other resource keeps the 90-second rule. (Added
|
|
107
|
+
2026-10-10 with `Kubernetes.Ready`, PR 373; `Talos.ClusterHealth` already waited 20m.)
|
|
101
108
|
|
|
102
109
|
## The Bun line for this package (S42–S45)
|
|
103
110
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
"types": "./dist/talos/index.d.ts",
|
|
56
56
|
"default": "./dist/talos/index.js"
|
|
57
57
|
},
|
|
58
|
+
"./kubernetes": {
|
|
59
|
+
"types": "./dist/kubernetes/index.d.ts",
|
|
60
|
+
"default": "./dist/kubernetes/index.js"
|
|
61
|
+
},
|
|
58
62
|
"./proxmox": {
|
|
59
63
|
"types": "./dist/proxmox/index.d.ts",
|
|
60
64
|
"default": "./dist/proxmox/index.js"
|
|
@@ -181,6 +185,8 @@
|
|
|
181
185
|
"@effect/platform-bun": "4.0.1",
|
|
182
186
|
"@effect/platform-node": "4.0.1",
|
|
183
187
|
"@effect/platform-node-shared": "4.0.1",
|
|
188
|
+
"@effect/sql-d1": "4.0.1",
|
|
189
|
+
"@effect/sql-sqlite-do": "4.0.1",
|
|
184
190
|
"rolldown": "1.2.8",
|
|
185
191
|
"redis": "6.3.0"
|
|
186
192
|
},
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `boundedHelmChartProvider` THROUGH upstream's real `HelmChartProvider`, the real `talos-openbao`
|
|
3
|
+
* adapter, a fake `bao`, a fake `helm` and the fake apiserver (`talos/fake-apiserver.ts`): a PATCH
|
|
4
|
+
* that never answers must fail reconcile with the typed timeout, a fast apply must return what
|
|
5
|
+
* upstream returns, and the layer must be the DIRECT `Provider(HelmChart)` registration.
|
|
6
|
+
* Deadlines are real (1s): the fake request holds no timer, so nothing stays blocked.
|
|
7
|
+
*/
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { expect, test } from 'bun:test';
|
|
11
|
+
import * as NodeServices from '@effect/platform-node/NodeServices';
|
|
12
|
+
import { HelmChart, HelmChartProvider } from 'alchemy/Kubernetes/HelmChart';
|
|
13
|
+
import * as Kubernetes from 'alchemy/Kubernetes';
|
|
14
|
+
import * as Provider from 'alchemy/Provider';
|
|
15
|
+
import { AlchemyContext } from 'alchemy/AlchemyContext';
|
|
16
|
+
import { Stack } from 'alchemy/Stack';
|
|
17
|
+
import * as Cause from 'effect/Cause';
|
|
18
|
+
import * as Duration from 'effect/Duration';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import * as Exit from 'effect/Exit';
|
|
21
|
+
import * as Layer from 'effect/Layer';
|
|
22
|
+
import * as Option from 'effect/Option';
|
|
23
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
24
|
+
import { TalosOpenBaoAdapter, talosOpenBaoConnection } from '../talos/cluster-adapter.ts';
|
|
25
|
+
import { UIDS, config, helmRow, session, vault } from '../talos/cluster-adapter.fixtures.ts';
|
|
26
|
+
import { fakeApiServer } from '../talos/fake-apiserver.ts';
|
|
27
|
+
import { type FakeHandler, fakeSpawner } from '../talos/fake-process.ts';
|
|
28
|
+
import {
|
|
29
|
+
HELM_READ_TIMEOUT,
|
|
30
|
+
HELM_RECONCILE_TIMEOUT,
|
|
31
|
+
KubernetesReadyBadDuration,
|
|
32
|
+
boundedHelmChartProvider,
|
|
33
|
+
isBoundedHelmChartProvider,
|
|
34
|
+
} from './index.ts';
|
|
35
|
+
import { parseGoDuration } from './ready.ts';
|
|
36
|
+
import { KubernetesReconcileTimeout } from './helm-chart-bounded.ts';
|
|
37
|
+
|
|
38
|
+
const HOST = 'c1.cluster.invalid';
|
|
39
|
+
const NS_PATH = '/api/v1/namespaces/ns-c1';
|
|
40
|
+
const RENDERED = 'apiVersion: v1\nkind: Namespace\nmetadata:\n name: ns-c1\n';
|
|
41
|
+
|
|
42
|
+
// The fake `helm` answers the render; every other command is the fake vault's.
|
|
43
|
+
const handler: FakeHandler = (call) =>
|
|
44
|
+
call.args[0] === 'template' ? { stdout: RENDERED } : vault(call);
|
|
45
|
+
const spawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fakeSpawner(handler));
|
|
46
|
+
const services = Layer.mergeAll(
|
|
47
|
+
NodeServices.layer,
|
|
48
|
+
TalosOpenBaoAdapter(config).pipe(Layer.provide(spawner)),
|
|
49
|
+
// ⚠️ Last, so the fake spawner shadows NodeServices' real one: no real `helm` or `bao` runs.
|
|
50
|
+
spawner,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Kubernetes.providers() builds workload providers that read the ambient Stack at build time.
|
|
54
|
+
const stack = Layer.mergeAll(
|
|
55
|
+
Layer.succeed(Stack, { name: 'bounded-test', stage: 'test' } as never),
|
|
56
|
+
Layer.succeed(AlchemyContext, {
|
|
57
|
+
adopt: false,
|
|
58
|
+
dev: false,
|
|
59
|
+
dotAlchemy: join(tmpdir(), 'hf-bounded'),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const CHART = 'oci://example.invalid/chart';
|
|
64
|
+
const news = {
|
|
65
|
+
chart: CHART,
|
|
66
|
+
cluster: talosOpenBaoConnection('uid-c1'),
|
|
67
|
+
releaseName: 'r',
|
|
68
|
+
} as never;
|
|
69
|
+
|
|
70
|
+
type Service = Provider.ProviderService<HelmChart>;
|
|
71
|
+
const run = <A, E>(
|
|
72
|
+
// The requirements (spawner, FileSystem, Stack...) are all supplied by `services`/`stack` below.
|
|
73
|
+
provider: Layer.Layer<Provider.Provider<HelmChart>, never, unknown>,
|
|
74
|
+
body: (s: Service) => Effect.Effect<A, E, never>,
|
|
75
|
+
) =>
|
|
76
|
+
Effect.runPromise(
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
return yield* body((yield* Provider.Provider<HelmChart>(HelmChart.Type)) as Service);
|
|
79
|
+
}).pipe(Effect.provide(Layer.provideMerge(provider, services))) as Effect.Effect<A, E>,
|
|
80
|
+
);
|
|
81
|
+
const reconcile = (s: Service) =>
|
|
82
|
+
s.reconcile({ id: 'cilium', news, output: undefined, session } as never);
|
|
83
|
+
|
|
84
|
+
test('a PATCH that never answers fails reconcile with the typed timeout inside the deadline', async () => {
|
|
85
|
+
const api = fakeApiServer(UIDS, [], { [`PATCH ${HOST}${NS_PATH}`]: { hang: true } });
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
try {
|
|
88
|
+
// ★ Distinct deadlines: swapping the two (reconcile on the read bound) would wait an hour.
|
|
89
|
+
const failure = await run(boundedHelmChartProvider('1s', '1h'), (s) =>
|
|
90
|
+
reconcile(s).pipe(Effect.flip),
|
|
91
|
+
);
|
|
92
|
+
expect(failure).toBeInstanceOf(KubernetesReconcileTimeout);
|
|
93
|
+
expect(failure).toMatchObject({
|
|
94
|
+
_tag: 'KubernetesReconcileTimeout',
|
|
95
|
+
id: 'cilium',
|
|
96
|
+
operation: 'reconcile',
|
|
97
|
+
seconds: 1,
|
|
98
|
+
});
|
|
99
|
+
expect(Date.now() - started).toBeLessThan(4000);
|
|
100
|
+
expect(api.seen).toContain(`PATCH ${HOST}${NS_PATH}`);
|
|
101
|
+
// The message names the row and the seconds, nothing the server said.
|
|
102
|
+
expect(String(failure)).toBe(
|
|
103
|
+
'KubernetesReconcileTimeout: Kubernetes.HelmChart cilium: reconcile did not finish within 1s (the apiserver stopped answering)',
|
|
104
|
+
);
|
|
105
|
+
} finally {
|
|
106
|
+
api.restore();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a read whose identity GET never answers fails with the typed timeout on the read bound', async () => {
|
|
111
|
+
const api = fakeApiServer(UIDS, [HOST]);
|
|
112
|
+
try {
|
|
113
|
+
const failure = await run(boundedHelmChartProvider('1h', '1s'), (s) =>
|
|
114
|
+
(s.read as NonNullable<Service['read']>)
|
|
115
|
+
.call(s, {
|
|
116
|
+
id: 'cilium',
|
|
117
|
+
olds: undefined,
|
|
118
|
+
output: helmRow({ uid: 'uid-c1' }),
|
|
119
|
+
session,
|
|
120
|
+
} as never)
|
|
121
|
+
.pipe(Effect.flip),
|
|
122
|
+
);
|
|
123
|
+
expect(failure).toMatchObject({
|
|
124
|
+
_tag: 'KubernetesReconcileTimeout',
|
|
125
|
+
operation: 'read',
|
|
126
|
+
seconds: 1,
|
|
127
|
+
});
|
|
128
|
+
} finally {
|
|
129
|
+
api.restore();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a fast apply returns exactly the attributes upstream returns', async () => {
|
|
134
|
+
const api = fakeApiServer(UIDS);
|
|
135
|
+
try {
|
|
136
|
+
const bounded = await run(boundedHelmChartProvider(), reconcile);
|
|
137
|
+
const upstream = await run(HelmChartProvider(), reconcile);
|
|
138
|
+
expect(bounded).toEqual(upstream);
|
|
139
|
+
expect(bounded).toMatchObject({ releaseName: 'r', namespace: 'default', chart: CHART });
|
|
140
|
+
expect(api.seen.some((call) => call.startsWith('PATCH '))).toBe(true);
|
|
141
|
+
} finally {
|
|
142
|
+
api.restore();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('stables, aliases, diff and delete are upstream by spread', async () => {
|
|
147
|
+
const [bounded, upstream] = await Promise.all([
|
|
148
|
+
run(boundedHelmChartProvider(), (s) => Effect.succeed(s)),
|
|
149
|
+
run(HelmChartProvider(), (s) => Effect.succeed(s)),
|
|
150
|
+
]);
|
|
151
|
+
expect(bounded.stables).toEqual(upstream.stables);
|
|
152
|
+
expect(bounded.aliases).toEqual(upstream.aliases);
|
|
153
|
+
expect(Object.keys(bounded).sort()).toEqual(Object.keys(upstream).sort());
|
|
154
|
+
expect(typeof bounded.diff).toBe('function');
|
|
155
|
+
expect(typeof bounded.delete).toBe('function');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('the deadlines are pinned: reconcile 5m0s, read 1m, and they parse as such', async () => {
|
|
159
|
+
expect(HELM_RECONCILE_TIMEOUT).toBe('5m0s');
|
|
160
|
+
expect(HELM_READ_TIMEOUT).toBe('1m0s');
|
|
161
|
+
const ms = (v: string) =>
|
|
162
|
+
Effect.runPromise(parseGoDuration('x', v)).then((d) => Duration.toMillis(d));
|
|
163
|
+
expect(await ms(HELM_RECONCILE_TIMEOUT)).toBe(300_000);
|
|
164
|
+
expect(await ms(HELM_READ_TIMEOUT)).toBe(60_000);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('a malformed deadline dies at layer build naming Kubernetes.HelmChart and the field', async () => {
|
|
168
|
+
const dies = async (reconcileTimeout: string, readTimeout: string) => {
|
|
169
|
+
const exit = await Effect.runPromiseExit(
|
|
170
|
+
Effect.gen(function* () {
|
|
171
|
+
return yield* Provider.Provider<HelmChart>(HelmChart.Type);
|
|
172
|
+
}).pipe(
|
|
173
|
+
Effect.provide(
|
|
174
|
+
Layer.provideMerge(boundedHelmChartProvider(reconcileTimeout, readTimeout), services),
|
|
175
|
+
),
|
|
176
|
+
) as Effect.Effect<unknown>,
|
|
177
|
+
);
|
|
178
|
+
return Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined;
|
|
179
|
+
};
|
|
180
|
+
const reconcileBad = await dies('soon', '1m0s');
|
|
181
|
+
expect(reconcileBad).toBeInstanceOf(KubernetesReadyBadDuration);
|
|
182
|
+
expect((reconcileBad as Error).message).toBe(
|
|
183
|
+
"Kubernetes.HelmChart: reconcileTimeout 'soon' is not a duration like 10m0s",
|
|
184
|
+
);
|
|
185
|
+
const readBad = await dies('5m0s', '0s');
|
|
186
|
+
expect((readBad as Error).message).toBe(
|
|
187
|
+
"Kubernetes.HelmChart: readTimeout '0s' is not a duration like 10m0s",
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('an apiserver refusal passes through body-free (the needle never leaves)', async () => {
|
|
192
|
+
const needle = 'NEEDLE-secret-body-9f3a';
|
|
193
|
+
const api = fakeApiServer(UIDS, [], {
|
|
194
|
+
[`PATCH ${HOST}${NS_PATH}`]: { body: { message: needle }, status: 422 },
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const failure = await run(boundedHelmChartProvider(), (s) => reconcile(s).pipe(Effect.flip));
|
|
198
|
+
expect(failure).toMatchObject({ _tag: 'KubernetesReadyApiError', statusCode: 422 });
|
|
199
|
+
expect(String(failure)).not.toContain(needle);
|
|
200
|
+
expect(JSON.stringify(failure)).not.toContain(needle);
|
|
201
|
+
expect((failure as Error).message).toBe(
|
|
202
|
+
`Kubernetes.Ready: PATCH ${NS_PATH}?fieldManager=alchemy&force=true responded 422`,
|
|
203
|
+
);
|
|
204
|
+
} finally {
|
|
205
|
+
api.restore();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('isBoundedHelmChartProvider is true for the wrapper and false for upstream', async () => {
|
|
210
|
+
const [bounded, upstream] = await Promise.all([
|
|
211
|
+
run(boundedHelmChartProvider(), (s) => Effect.succeed(s)),
|
|
212
|
+
run(HelmChartProvider(), (s) => Effect.succeed(s)),
|
|
213
|
+
]);
|
|
214
|
+
expect(isBoundedHelmChartProvider(bounded)).toBe(true);
|
|
215
|
+
expect(isBoundedHelmChartProvider(upstream)).toBe(false);
|
|
216
|
+
expect(isBoundedHelmChartProvider(undefined)).toBe(false);
|
|
217
|
+
expect(isBoundedHelmChartProvider({ ...bounded })).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('merged beside Kubernetes.providers() the engine-resolved service enforces the deadline', async () => {
|
|
221
|
+
const layer = Layer.mergeAll(Kubernetes.providers(), boundedHelmChartProvider('1s', '1h'));
|
|
222
|
+
const api = fakeApiServer(UIDS, [], { [`PATCH ${HOST}${NS_PATH}`]: { hang: true } });
|
|
223
|
+
try {
|
|
224
|
+
const found = await Effect.runPromise(
|
|
225
|
+
Effect.gen(function* () {
|
|
226
|
+
const registered = yield* Provider.tryFindProviderRegistrationByType<HelmChart>(
|
|
227
|
+
HelmChart.Type,
|
|
228
|
+
);
|
|
229
|
+
if (Option.isNone(registered)) return yield* Effect.die('no registration');
|
|
230
|
+
const service = registered.value as unknown as Service;
|
|
231
|
+
const failure = yield* reconcile(service).pipe(Effect.flip);
|
|
232
|
+
return { failure, marked: isBoundedHelmChartProvider(service) };
|
|
233
|
+
}).pipe(
|
|
234
|
+
Effect.provide(Layer.provideMerge(layer, Layer.merge(services, stack))),
|
|
235
|
+
) as Effect.Effect<{ failure: unknown; marked: boolean }>,
|
|
236
|
+
);
|
|
237
|
+
expect(found.marked).toBe(true);
|
|
238
|
+
expect(found.failure).toBeInstanceOf(KubernetesReconcileTimeout);
|
|
239
|
+
} finally {
|
|
240
|
+
api.restore();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `boundedHelmChartProvider`: upstream's `Kubernetes.HelmChart` provider with a wall-clock deadline
|
|
3
|
+
* on `reconcile` and `read`. Wrapped, never re-implemented (alchemy-provider-standard: use what
|
|
4
|
+
* Alchemy ships).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ WHY (measured 2026-10-09, alchemy 2.0.0-beta.81): `requestJson` in `Kubernetes/internal/client.ts`
|
|
7
|
+
* sets no timeout and no signal on any request, and retries transport errors only. A hung apiserver
|
|
8
|
+
* therefore blocks a HelmChart reconcile or read forever, holding the 1h platform token across a
|
|
9
|
+
* stuck deploy. The deadline turns that into a typed `KubernetesReconcileTimeout`.
|
|
10
|
+
* ★ A DIRECT `Provider(HelmChart)` layer, not a collection member: the engine checks the direct
|
|
11
|
+
* service before any collection (`Provider.ts` `tryFindProviderRegistrationByType`), so merging
|
|
12
|
+
* this beside `Kubernetes.providers()` makes the wrapper win with no change to upstream's layer.
|
|
13
|
+
* ⚠️ The interrupted fiber ABANDONS its socket (upstream takes no signal). The run then fails typed
|
|
14
|
+
* and the process exits once main completes, so a dangling socket cannot hold anything.
|
|
15
|
+
* `diff`, `delete`, `stables` and `aliases` come from upstream by spread, so state identity and
|
|
16
|
+
* replace semantics are exactly upstream's.
|
|
17
|
+
*/
|
|
18
|
+
import { HelmChart, HelmChartProvider } from 'alchemy/Kubernetes/HelmChart';
|
|
19
|
+
import * as Provider from 'alchemy/Provider';
|
|
20
|
+
import type * as Context from 'effect/Context';
|
|
21
|
+
import * as Data from 'effect/Data';
|
|
22
|
+
import * as Duration from 'effect/Duration';
|
|
23
|
+
import * as Effect from 'effect/Effect';
|
|
24
|
+
import * as Layer from 'effect/Layer';
|
|
25
|
+
import { scrubbed } from './ready-classify.ts';
|
|
26
|
+
import { parseGoDuration } from './ready.ts';
|
|
27
|
+
|
|
28
|
+
/** A render + discovery + ~60 server-side-apply PATCHes fit well inside this. */
|
|
29
|
+
export const HELM_RECONCILE_TIMEOUT = '5m0s';
|
|
30
|
+
/** `read` is one connect (identity GET): a minute is generous. */
|
|
31
|
+
export const HELM_READ_TIMEOUT = '1m0s';
|
|
32
|
+
|
|
33
|
+
/** The deadline passed with no answer from the apiserver. Names the row and seconds only. */
|
|
34
|
+
export class KubernetesReconcileTimeout extends Data.TaggedError('KubernetesReconcileTimeout')<{
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly operation: 'reconcile' | 'read';
|
|
37
|
+
readonly seconds: number;
|
|
38
|
+
}> {
|
|
39
|
+
override get message(): string {
|
|
40
|
+
return `Kubernetes.HelmChart ${this.id}: ${this.operation} did not finish within ${this.seconds}s (the apiserver stopped answering)`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const bound = <A, E, R>(
|
|
45
|
+
effect: Effect.Effect<A, E, R>,
|
|
46
|
+
deadline: Duration.Duration,
|
|
47
|
+
id: string,
|
|
48
|
+
operation: 'reconcile' | 'read',
|
|
49
|
+
): Effect.Effect<A, E | KubernetesReconcileTimeout, R> =>
|
|
50
|
+
effect.pipe(
|
|
51
|
+
// ⛔ Upstream's `KubernetesApiError` quotes up to 1000 bytes of the apiserver body
|
|
52
|
+
// (`client.ts:39-41`): scrub it to a body-free error, as `Kubernetes.Ready` does.
|
|
53
|
+
Effect.mapError((error) => scrubbed(error) as E),
|
|
54
|
+
Effect.timeoutOrElse({
|
|
55
|
+
duration: deadline,
|
|
56
|
+
orElse: () =>
|
|
57
|
+
Effect.fail(
|
|
58
|
+
new KubernetesReconcileTimeout({ id, operation, seconds: Duration.toSeconds(deadline) }),
|
|
59
|
+
),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// `Provider(type)` is typed as an Effect; at runtime it is the service key the engine looks up.
|
|
64
|
+
const HelmChartKey = Provider.Provider<HelmChart>(HelmChart.Type) as unknown as Context.Service<
|
|
65
|
+
Provider.Provider<HelmChart>,
|
|
66
|
+
Provider.ProviderService<HelmChart>
|
|
67
|
+
>;
|
|
68
|
+
|
|
69
|
+
const BOUNDED = Symbol.for('@homeflare/alchemy/kubernetes/boundedHelmChartProvider');
|
|
70
|
+
|
|
71
|
+
/** True for a service built by `boundedHelmChartProvider`: lets a stack test its real layer. */
|
|
72
|
+
export const isBoundedHelmChartProvider = (service: unknown): boolean =>
|
|
73
|
+
typeof service === 'object' &&
|
|
74
|
+
service !== null &&
|
|
75
|
+
(service as Record<symbol, unknown>)[BOUNDED] === true;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ⛔ A malformed duration dies at layer build (`Effect.orDie`), never becomes a silent unbounded
|
|
79
|
+
* call: a bad override cannot quietly drop the deadline.
|
|
80
|
+
*/
|
|
81
|
+
export const boundedHelmChartProvider = (
|
|
82
|
+
reconcileTimeout: string = HELM_RECONCILE_TIMEOUT,
|
|
83
|
+
readTimeout: string = HELM_READ_TIMEOUT,
|
|
84
|
+
) =>
|
|
85
|
+
Layer.effect(
|
|
86
|
+
HelmChartKey,
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const inner = yield* HelmChartKey;
|
|
89
|
+
const reconcileDeadline = yield* parseGoDuration(
|
|
90
|
+
'reconcileTimeout',
|
|
91
|
+
reconcileTimeout,
|
|
92
|
+
'Kubernetes.HelmChart',
|
|
93
|
+
);
|
|
94
|
+
const innerRead = inner.read?.bind(inner);
|
|
95
|
+
const readDeadline = yield* parseGoDuration(
|
|
96
|
+
'readTimeout',
|
|
97
|
+
readTimeout,
|
|
98
|
+
'Kubernetes.HelmChart',
|
|
99
|
+
);
|
|
100
|
+
const service = {
|
|
101
|
+
...inner,
|
|
102
|
+
reconcile: (input) =>
|
|
103
|
+
bound(inner.reconcile(input), reconcileDeadline, input.id, 'reconcile'),
|
|
104
|
+
...(innerRead === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { read: (input) => bound(innerRead(input), readDeadline, input.id, 'read') }),
|
|
107
|
+
} satisfies Provider.ProviderService<HelmChart>;
|
|
108
|
+
return Object.defineProperty(service, BOUNDED, { value: true });
|
|
109
|
+
}).pipe(Effect.orDie),
|
|
110
|
+
).pipe(Layer.provide(HelmChartProvider()));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kubernetes helpers for Alchemy that upstream does not ship.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ A DELIBERATELY SMALL BARREL (the `talos/index.ts` rule): the resource, its provider, its
|
|
5
|
+
* props and the typed errors a stack can `catchTag`. The evaluators and the poll stay internal.
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
HELM_READ_TIMEOUT,
|
|
9
|
+
HELM_RECONCILE_TIMEOUT,
|
|
10
|
+
KubernetesReconcileTimeout,
|
|
11
|
+
boundedHelmChartProvider,
|
|
12
|
+
isBoundedHelmChartProvider,
|
|
13
|
+
} from './helm-chart-bounded.ts';
|
|
14
|
+
export {
|
|
15
|
+
KubernetesReady,
|
|
16
|
+
KubernetesReadyProvider,
|
|
17
|
+
type ReadyAttributes,
|
|
18
|
+
type ReadyProps,
|
|
19
|
+
} from './ready.ts';
|
|
20
|
+
export type { ReadyCheck } from './ready-checks.ts';
|
|
21
|
+
export {
|
|
22
|
+
KubernetesReadyBadAfter,
|
|
23
|
+
KubernetesReadyBadCheck,
|
|
24
|
+
KubernetesReadyBadDuration,
|
|
25
|
+
KubernetesReadyTimeout,
|
|
26
|
+
KubernetesRolloutFailed,
|
|
27
|
+
} from './ready-errors.ts';
|
|
28
|
+
export { KubernetesReadyApiError, KubernetesReadyGetTimeout } from './ready-poll.ts';
|