@homeflare/alchemy 0.49.0 → 0.51.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/dist/unifi/acl-rule-ordering.d.ts +6 -1
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
- package/dist/unifi/acl-rule.d.ts +6 -1
- package/dist/unifi/acl-rule.d.ts.map +1 -1
- package/dist/unifi/dns-policy.d.ts +6 -1
- package/dist/unifi/dns-policy.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +4 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
- package/dist/unifi/firewall-policy.d.ts +6 -1
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +6 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +5 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +339 -86
- package/dist/unifi/index.js.map +10 -6
- package/dist/unifi/network-scope.d.ts +31 -0
- package/dist/unifi/network-scope.d.ts.map +1 -0
- package/dist/unifi/network-update.d.ts +44 -0
- package/dist/unifi/network-update.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +32 -12
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/policy.d.ts +89 -1
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +17 -43
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +6 -1
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
- package/dist/unifi/update-reconcile.d.ts +31 -0
- package/dist/unifi/update-reconcile.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +6 -1
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
- package/dist/unifi/wire-guard.d.ts +33 -0
- package/dist/unifi/wire-guard.d.ts.map +1 -0
- package/docs/kubernetes-ready.md +146 -0
- package/docs/peers.md +13 -0
- package/docs/provider-standard.md +7 -0
- package/docs/unifi-network-write.md +90 -0
- package/docs/unifi.md +36 -36
- 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
- package/src/unifi/fake-unifi.ts +15 -2
- package/src/unifi/handler-allow-news.test.ts +80 -0
- package/src/unifi/index.ts +21 -5
- package/src/unifi/network-scope.test.ts +203 -0
- package/src/unifi/network-scope.ts +75 -0
- package/src/unifi/network-update.test.ts +250 -0
- package/src/unifi/network-update.ts +95 -0
- package/src/unifi/network.test.ts +27 -1
- package/src/unifi/network.ts +56 -12
- package/src/unifi/policy.ts +128 -10
- package/src/unifi/resource.test.ts +15 -3
- package/src/unifi/resource.ts +64 -105
- package/src/unifi/update-reconcile.ts +126 -0
- package/src/unifi/wire-guard-anchor.test.ts +198 -0
- package/src/unifi/wire-guard-round2.test.ts +120 -0
- package/src/unifi/wire-guard.test.ts +218 -0
- package/src/unifi/wire-guard.ts +192 -0
- package/src/unifi/write-op-reference.test.ts +38 -2
- package/src/unifi/get-only-guard.test.ts +0 -165
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# `Unifi.Network` write scope — what the one PUT may and may not do
|
|
2
|
+
|
|
3
|
+
`docs/unifi.md` describes the three-way update. This page records the limits placed on it after
|
|
4
|
+
the red team rounds 1 and 2 of PR 377. The only intended consumer is ONE VLAN's
|
|
5
|
+
`ipv6Configuration`: a patch may SET only `ipv6Configuration` and REMOVE only `ipv6Configuration`;
|
|
6
|
+
everything else a whole-object `PUT` could reach is refused with a typed error that carries field
|
|
7
|
+
names only, never values.
|
|
8
|
+
|
|
9
|
+
| refusal | when |
|
|
10
|
+
| ------------------------------- | --------------------------------------------------------------------------- |
|
|
11
|
+
| `UnifiFieldNotSettable` | a patch sets any key but `ipv6Configuration` (`enabled`, `dhcpGuarding`, …) |
|
|
12
|
+
| `UnifiFieldNotRemovable` | a patch drops any key but `ipv6Configuration` |
|
|
13
|
+
| `UnifiManagementNetworkRefused` | the live object has `default: true` (the management LAN) |
|
|
14
|
+
| `UnifiImmutableFieldChanged` | a patch sets or drops `zoneId`, `vlanId`, `management`, `deviceId` |
|
|
15
|
+
| `UnifiIdentityChanged` | `olds` names another `siteId`/`networkId` than `news` |
|
|
16
|
+
| `UnifiUpdateWouldBeNoop` | the merged body equals live |
|
|
17
|
+
|
|
18
|
+
Every one of them fires after the live GET and before any PUT.
|
|
19
|
+
|
|
20
|
+
⛔ **Why a removable allowlist.** A key in `patch.unset` is deleted from the PUT body, and the
|
|
21
|
+
controller reads an omitted `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration` as "off". So
|
|
22
|
+
leaving a field out of a declaration would turn it off on the console. Only `ipv6Configuration`
|
|
23
|
+
(the block this path exists for, and the revert target) may be removed. `network-scope.ts` holds
|
|
24
|
+
the lists (`SETTABLE_KEYS`, `REMOVABLE_KEYS`, `IMMUTABLE_KEYS`); widening them is a kit change.
|
|
25
|
+
|
|
26
|
+
⚠️ **No version check: a hand edit between the read and the write is overwritten.** The PUT is the
|
|
27
|
+
live GET plus the patch, sent whole; the API offers no ETag or revision, so a change made on the
|
|
28
|
+
console between that GET and the PUT is replaced by the GET's copy. The window is one reconcile.
|
|
29
|
+
|
|
30
|
+
Non-USER `metadata.origin` is deliberately not handled: there is no rule for it yet.
|
|
31
|
+
|
|
32
|
+
## The wire guard (`wire-guard.ts`)
|
|
33
|
+
|
|
34
|
+
⛔ **The allowed PUT is anchored to the configured base URL**, not matched by suffix. The request
|
|
35
|
+
must share the base's origin, its path must be exactly `<base path>/v1/sites/<site>/networks/<id>`,
|
|
36
|
+
and it must carry no query string or fragment. `?force=true`, a nested
|
|
37
|
+
`/v1/sites/OTHER/v1/sites/s/networks/n`, a different base path and `//v1/...` all die before the
|
|
38
|
+
transport. `unifiHandlers` reads the base URL from the same `Credentials` the SDK uses; with none
|
|
39
|
+
the guard allows nothing.
|
|
40
|
+
|
|
41
|
+
⛔ **`redirect: 'manual'` is set per request, on the calling fiber's `RequestInit`, for EVERY
|
|
42
|
+
guarded request — GETs included — and any 3xx answer dies with `UnifiRefusedRequest`, whatever the
|
|
43
|
+
method.** Effect 4.0.1 `layerMergedContext` lets an outer `FetchHttpClient.RequestInit` REPLACE one
|
|
44
|
+
the layer provides, so a layer-provided `redirect: 'manual'` is silently lost under any consumer
|
|
45
|
+
that sets its own `RequestInit`. The guard reads the fiber's value and merges `redirect: 'manual'`
|
|
46
|
+
over it, the `openbao/bao-http.ts` `overSocket` pattern. `wire-guard-anchor.test.ts` proves it
|
|
47
|
+
against a real `Bun.serve` answering 301/302/303/307/308.
|
|
48
|
+
|
|
49
|
+
A GET's redirect is refused for the same reason a PUT's is, under both postures: fetch would
|
|
50
|
+
follow it and re-send the request — still carrying the API-key header — to a URL the guard never
|
|
51
|
+
vetted. Before the 2026-10-10 fix only reconcile's GETs ran `manual` (a 3xx surfaced raw and failed
|
|
52
|
+
the SDK decode) while `read`/`diff` followed it silently; one rule everywhere now, fail closed.
|
|
53
|
+
|
|
54
|
+
⛔ **Where a TLS `RequestInit` must live: on the CALLING FIBER**, provided around the program with
|
|
55
|
+
`Effect.provideService(FetchHttpClient.RequestInit, { tls: … })`, not inside the fetch layer's
|
|
56
|
+
build context (`FetchHttpClient.layer.pipe(Layer.provide(…))`). The guard re-provides `RequestInit` per
|
|
57
|
+
request, merged over the fiber's value, so an option set only inside the layer is overridden on every
|
|
58
|
+
guarded request, GETs included, and a consumer wired that way silently loses its TLS option (it
|
|
59
|
+
fails closed: the handshake is refused, nothing is sent insecurely).
|
|
60
|
+
|
|
61
|
+
⛔ **Backstop: the response URL must equal the request URL.** If a consumer's own transform provides
|
|
62
|
+
`RequestInit` WITHOUT merging, `manual` is lost and fetch follows a redirect. The guard dies when
|
|
63
|
+
`res.url` differs from `HttpClientRequest.toUrl(res.request)`. The allowed-path check likewise reads
|
|
64
|
+
`toUrl`, so a `setUrlParam` query is seen and refused. Tests: `wire-guard-round2.test.ts`.
|
|
65
|
+
|
|
66
|
+
⛔ **Refusal messages redact the cloud connector Console ID**: the path segment after `/consoles/`
|
|
67
|
+
becomes `<redacted>`.
|
|
68
|
+
|
|
69
|
+
## The static check (`write-op-reference.test.ts`)
|
|
70
|
+
|
|
71
|
+
It harvests the SDK's real write-op export names straight from its own service modules (every
|
|
72
|
+
name starting `create`/`update`/`delete`/`patch`/`execute`/`remove`/`adopt`) and fails any file
|
|
73
|
+
that mentions the SDK's package specifier and contains one of those names as a token anywhere — an
|
|
74
|
+
identifier, a bracket key, a destructured binding, a re-export or a dynamic-import property access
|
|
75
|
+
all read the same way, so one scan catches every syntax form without parsing which one it is. Its
|
|
76
|
+
`WRITE_OP_ALLOWLIST` is exactly `network-update.ts` → `updateNetwork`; any other file naming that
|
|
77
|
+
op, or that file naming `createNetwork`/`deleteNetwork`, is an offense.
|
|
78
|
+
|
|
79
|
+
## The PUT is attempted once
|
|
80
|
+
|
|
81
|
+
`updateNetwork` runs under `Retry.none`. The SDK's default policy would replay a write whose first
|
|
82
|
+
attempt may already have landed; a replay then runs against a controller state this reconcile never
|
|
83
|
+
read. A failure surfaces for the operator instead. Reads keep the default policy.
|
|
84
|
+
|
|
85
|
+
## After a PUT that landed
|
|
86
|
+
|
|
87
|
+
`UnifiUpdateDidNotConverge` means the PUT LANDED and live now differs from the declaration. It is
|
|
88
|
+
not rolled back. The next deploy sees drift and answers `UnifiLiveDriftedSinceDeploy`, whose
|
|
89
|
+
message names both causes (a hand edit, or an earlier non-converging PUT). Recovery in both cases
|
|
90
|
+
is a re-import of the live object.
|
package/docs/unifi.md
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# UniFi Network — `@homeflare/alchemy/unifi`
|
|
2
2
|
|
|
3
|
-
⛔ **
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
|
|
3
|
+
⛔ **ADOPT-ONLY, EXCEPT ONE WRITE (Tim 2026-09-24, lifted for one case 2026-10-10).** Every UniFi
|
|
4
|
+
family reads and adopts; `Unifi.Network` alone may UPDATE (a three-way whole-object PUT, below).
|
|
5
|
+
Create and delete, and every other family's update, fail with a typed `UnifiWriteRefused`. Widening
|
|
6
|
+
the rule is a kit change, not a flag a stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
|
|
8
7
|
|
|
9
8
|
This page covers `Unifi.Network`/`Unifi.FirewallZone` only. Every other read-only family has its
|
|
10
9
|
own doc, per family (`docs/unifi.md` would breach the 200-line cap otherwise): [`Unifi.DnsPolicy`](./unifi-dns-policy.md), [`Unifi.AclRule`/`Unifi.AclRuleOrdering`](./unifi-acl-rule.md),
|
|
@@ -59,7 +58,7 @@ credential, untouched by this family. Only reads were probed, by hand, outside t
|
|
|
59
58
|
test suite — nothing in this PR's code exercises the vendor API live, and a write path would still
|
|
60
59
|
need its own verification.
|
|
61
60
|
|
|
62
|
-
## Adopt is the default posture, and
|
|
61
|
+
## Adopt is the default posture, and the one write is a three-way Network update
|
|
63
62
|
|
|
64
63
|
`adopt(true)` is piped onto every resource by its convenience constructor (`network(...)`,
|
|
65
64
|
`firewallZone(...)`) — H5. A cold read of a live match answers `Unowned(attrs)` (H1, the
|
|
@@ -68,36 +67,36 @@ objects carry no tag or metadata field this stack could stamp), and `adopt(true)
|
|
|
68
67
|
a silent bind instead of an `OwnedBySomeoneElse` refusal — the deploy never needs `--adopt`.
|
|
69
68
|
|
|
70
69
|
⚠️ **A forced post-adoption reconcile still runs once (H6), and it still makes no write.**
|
|
71
|
-
Beta.
|
|
70
|
+
Beta.81 forces one `reconcile` call after every cold adoption whether or not `diff` said noop.
|
|
72
71
|
`resource.ts`'s `reconcile` handles this the same way `discord/resource.ts` does: it reads the
|
|
73
|
-
live object again
|
|
74
|
-
|
|
72
|
+
live object again; an exact match returns the live attributes and calls nothing, a missing object
|
|
73
|
+
refuses `create`, and drift refuses `update` unless the row has prior state (`olds` AND `output`)
|
|
74
|
+
and a `Unifi.Network` spec: then `update-reconcile.ts` refuses if live differs from `olds` (a hand
|
|
75
|
+
edit), else PUTs live-plus-the-`news≠olds`-patch (`network-update.ts`; a dropped key is removed).
|
|
76
|
+
|
|
77
|
+
The limits on that PUT (removable allowlist, management LAN, immutable fields, anchored path,
|
|
78
|
+
no retry) are in [`unifi-network-write.md`](./unifi-network-write.md).
|
|
75
79
|
|
|
76
80
|
## `list` answers `[]`
|
|
77
81
|
|
|
78
82
|
`GET /v1/sites/{siteId}/networks` answers every network on the site. Adoption stays explicit —
|
|
79
83
|
the same reasoning `Proxmox.User`'s and NetBox's own `list` give.
|
|
80
84
|
|
|
81
|
-
## Defense in depth: GET-only at the wire (2026-09-26)
|
|
82
|
-
|
|
83
|
-
`policy.ts`'s `UnifiWriteRefused` stops write INTENT at `reconcile`/`delete`
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
`
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
same way, so one scan catches every syntax form without parsing which one it is.
|
|
97
|
-
`get-only-guard.test.ts` proves the wire guard's mechanism directly against a fake `HttpClient`,
|
|
98
|
-
AND that `unifiHandlers` actually installs it (a handler-level probe, not just the mechanism in
|
|
99
|
-
isolation — `network.test.ts`/`firewall-zone.test.ts` call the lower-level `unifiOperations(spec)`
|
|
100
|
-
directly and never exercise `withCredentials` at all).
|
|
85
|
+
## Defense in depth: GET-only at the wire, one per-row exception (2026-09-26, 2026-10-10)
|
|
86
|
+
|
|
87
|
+
`policy.ts`'s `UnifiWriteRefused` stops write INTENT at `reconcile`/`delete`. `wire-guard.ts`'s
|
|
88
|
+
`guardedHttpClient(allow)` (`GetOnlyHttpClient` = empty list; barrel-exported so `homeflare-network`'s
|
|
89
|
+
import-layer guard can reuse it), installed in `unifiHandlers`, stops it one layer lower, AT THE
|
|
90
|
+
WIRE: any non-`GET` dies with `UnifiRefusedRequest` (alias `UnifiNonGetRequest`; PATH only, never
|
|
91
|
+
the host — a cloud base URL embeds the Console ID) unless `(method, path)` matches the row's own
|
|
92
|
+
allow entry, `PUT /v1/sites/<siteId>/networks/<networkId>`, supplied only to `reconcile` and only
|
|
93
|
+
by a spec with `update`. The guard also sets fetch `redirect: 'manual'` on every request, GETs
|
|
94
|
+
included, and dies on any 3xx answer (fetch would re-send the request — a PUT with its body, a GET
|
|
95
|
+
with the API key — to a URL the guard never saw).
|
|
96
|
+
`wire-guard.test.ts` proves the guard directly against a fake `HttpClient`, AND that
|
|
97
|
+
`unifiHandlers` installs it (the family tests call `unifiOperations(spec)` and never exercise
|
|
98
|
+
`withCredentials`). The static check `write-op-reference.test.ts` is described in
|
|
99
|
+
[`unifi-network-write.md`](./unifi-network-write.md).
|
|
101
100
|
|
|
102
101
|
## Pagination helper
|
|
103
102
|
|
|
@@ -144,8 +143,7 @@ UniFi Network Integration API **10.4.57** (OpenAPI 3.1.0, 44 paths), via
|
|
|
144
143
|
(`docs/distilled-interim.md`). Traps in the underlying API that this read-only family cannot
|
|
145
144
|
trigger but a future write path must respect — whole-object `PUT`, ordering endpoints that
|
|
146
145
|
replace the whole list, `removeDevice` unadopting and factory-resetting hardware — are recorded
|
|
147
|
-
in the distilled package's own `README.md`
|
|
148
|
-
(outside this repo).
|
|
146
|
+
in the distilled package's own `README.md` (`packages/distilled-unifi-network`).
|
|
149
147
|
|
|
150
148
|
## Spec-version check against the live-measured console (2026-09-24)
|
|
151
149
|
|
|
@@ -192,8 +190,10 @@ attributed, declared or compared. Both declaration renderers and both `matches`
|
|
|
192
190
|
`value == null` / `deepEqual(..., { stripNullish: true })` consistently, per `network-form.ts`'s
|
|
193
191
|
own header. No departure found — nothing in this family needed changing.
|
|
194
192
|
|
|
195
|
-
⚠️ **Correction (2026-09-26):** the line above is no longer true as written.
|
|
196
|
-
has exactly
|
|
197
|
-
|
|
198
|
-
NEW defect for a failure mode outside every
|
|
199
|
-
|
|
193
|
+
⚠️ **Correction (2026-09-26, recounted 2026-10-10):** the line above is no longer true as written.
|
|
194
|
+
The family has exactly TWO `Effect.die` call sites, both in `wire-guard.ts`'s guard (see "Defense
|
|
195
|
+
in depth" above): the request-side refusal of a non-GET off the row's allow list, and the
|
|
196
|
+
response-side refusal of a 3xx answer. Each raises a NEW defect for a failure mode outside every
|
|
197
|
+
generated SDK operation's declared error union, per distilled-doctrine's "classify every
|
|
198
|
+
die/orDie" — neither converts an existing typed failure the way `orDie` would. Still no
|
|
199
|
+
`Effect.orDie` anywhere.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.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';
|