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