@homeflare/alchemy 0.48.1 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/fake-access.d.ts +40 -0
- package/dist/cloudflare/fake-access.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +7 -2
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
- package/dist/cloudflare/fake-tunnel.d.ts +48 -0
- package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +15 -4
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +518 -7
- package/dist/cloudflare/index.js.map +15 -4
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
- package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
- package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
- package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
- package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc.d.ts +36 -0
- package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
- 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/cloudflared-tunnel.md +136 -0
- package/docs/credentials.md +1 -1
- package/docs/kubernetes-ready.md +146 -0
- package/docs/ownership.md +3 -2
- package/docs/peers.md +13 -0
- package/docs/provider-standard.md +7 -0
- package/docs/saas-oidc.md +185 -0
- package/docs/upstream-conformance-families.md +1 -1
- package/docs/upstream-conformance.md +3 -0
- package/package.json +7 -1
- package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
- package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
- package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
- package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
- package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
- package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
- package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
- package/src/cloudflare/cloudflared-tunnel.ts +116 -0
- package/src/cloudflare/fake-access.ts +196 -0
- package/src/cloudflare/fake-mesh.ts +6 -3
- package/src/cloudflare/fake-tunnel.ts +148 -0
- package/src/cloudflare/index.ts +27 -4
- package/src/cloudflare/providers.ts +16 -3
- package/src/cloudflare/saas-oidc-api.ts +224 -0
- package/src/cloudflare/saas-oidc-consumer.ts +35 -0
- package/src/cloudflare/saas-oidc-error.ts +52 -0
- package/src/cloudflare/saas-oidc-form.test.ts +227 -0
- package/src/cloudflare/saas-oidc-form.ts +190 -0
- package/src/cloudflare/saas-oidc-harness.ts +140 -0
- package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
- package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
- package/src/cloudflare/saas-oidc-props.ts +147 -0
- package/src/cloudflare/saas-oidc-read.test.ts +85 -0
- package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
- package/src/cloudflare/saas-oidc-state.test.ts +128 -0
- package/src/cloudflare/saas-oidc-team.test.ts +88 -0
- package/src/cloudflare/saas-oidc-team.ts +70 -0
- package/src/cloudflare/saas-oidc-wire.ts +116 -0
- package/src/cloudflare/saas-oidc.test.ts +201 -0
- package/src/cloudflare/saas-oidc.ts +107 -0
- 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,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrecy of `HomeFlare.Kubernetes.Ready`: no PEM marker, kubeconfig byte or apiserver response
|
|
3
|
+
* body reaches an attribute, an error, a log line, or a file. The vault is the fake `bao` of the
|
|
4
|
+
* adapter fixtures (it serves a kubeconfig with PEM blocks); the apiserver bodies carry a needle.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, spyOn, test } from 'bun:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Exit from 'effect/Exit';
|
|
9
|
+
import * as Cause from 'effect/Cause';
|
|
10
|
+
import * as Logger from 'effect/Logger';
|
|
11
|
+
import * as References from 'effect/References';
|
|
12
|
+
import type { FakeObject } from '../talos/fake-apiserver.ts';
|
|
13
|
+
import { vault } from '../talos/cluster-adapter.fixtures.ts';
|
|
14
|
+
import { diffReady, readReady, reconcileReady } from './ready.ts';
|
|
15
|
+
import {
|
|
16
|
+
CRD_PATH,
|
|
17
|
+
DEP_PATH,
|
|
18
|
+
DS_PATH,
|
|
19
|
+
connection,
|
|
20
|
+
crdCheck,
|
|
21
|
+
depCheck,
|
|
22
|
+
driven,
|
|
23
|
+
dsCheck,
|
|
24
|
+
get,
|
|
25
|
+
readyCrd,
|
|
26
|
+
readyDep,
|
|
27
|
+
readyDs,
|
|
28
|
+
withCluster,
|
|
29
|
+
} from './ready.fixtures.ts';
|
|
30
|
+
|
|
31
|
+
const BODY_NEEDLE = 'apiserver-body-needle-7f3a';
|
|
32
|
+
const pemMarker = `-----${'BEGIN'}`;
|
|
33
|
+
const b64 = (text: string) => Buffer.from(text).toString('base64');
|
|
34
|
+
const props = {
|
|
35
|
+
checks: [dsCheck, depCheck, crdCheck],
|
|
36
|
+
connection,
|
|
37
|
+
waitTimeout: '20s',
|
|
38
|
+
pollInterval: '5s',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// The vault's own document is the needle source: every field value the fake `bao` serves.
|
|
42
|
+
const served = JSON.parse(
|
|
43
|
+
vault({ args: ['talos-c1/data/kubeconfig'], command: 'bao' }).stdout ?? '{}',
|
|
44
|
+
) as {
|
|
45
|
+
data: { data: { kubeconfig: string } };
|
|
46
|
+
};
|
|
47
|
+
const needles = [
|
|
48
|
+
BODY_NEEDLE,
|
|
49
|
+
pemMarker,
|
|
50
|
+
served.data.data.kubeconfig,
|
|
51
|
+
b64(`${pemMarker} CERTIFICATE-----`),
|
|
52
|
+
b64(`${pemMarker} PRIVATE KEY-----`),
|
|
53
|
+
'client-key-data',
|
|
54
|
+
'certificate-authority-data',
|
|
55
|
+
];
|
|
56
|
+
const leaks = (text: string) => needles.filter((needle) => text.includes(needle));
|
|
57
|
+
const dump = (value: unknown) =>
|
|
58
|
+
JSON.stringify(value, value instanceof Object ? Object.getOwnPropertyNames(value) : undefined) +
|
|
59
|
+
String(value);
|
|
60
|
+
|
|
61
|
+
const ready: Record<string, FakeObject> = {
|
|
62
|
+
[get(CRD_PATH)]: { body: readyCrd },
|
|
63
|
+
[get(DEP_PATH)]: { body: readyDep },
|
|
64
|
+
[get(DS_PATH)]: { body: readyDs },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
test('attributes, errors and logs of every path carry no vault or response byte', async () => {
|
|
68
|
+
const logged: string[] = [];
|
|
69
|
+
const capture = Logger.layer([
|
|
70
|
+
Logger.make(({ message }) => void logged.push(JSON.stringify(message))),
|
|
71
|
+
]);
|
|
72
|
+
const saved = { ...console };
|
|
73
|
+
for (const level of ['log', 'debug', 'info', 'warn', 'error'] as const) {
|
|
74
|
+
console[level] = (...args: unknown[]) => void logged.push(args.map(String).join(' '));
|
|
75
|
+
}
|
|
76
|
+
const writes = [spyOn(Bun, 'write'), spyOn(Bun, 'spawn')];
|
|
77
|
+
const outputs: unknown[] = [];
|
|
78
|
+
try {
|
|
79
|
+
const attempt = async (
|
|
80
|
+
objects: Record<string, FakeObject>,
|
|
81
|
+
run: Effect.Effect<unknown, unknown, never>,
|
|
82
|
+
) => {
|
|
83
|
+
const exit = await withCluster(
|
|
84
|
+
objects,
|
|
85
|
+
driven(
|
|
86
|
+
run.pipe(
|
|
87
|
+
Effect.provideService(References.MinimumLogLevel, 'All'),
|
|
88
|
+
Effect.provide(capture),
|
|
89
|
+
),
|
|
90
|
+
100,
|
|
91
|
+
) as never,
|
|
92
|
+
);
|
|
93
|
+
const result = exit as Exit.Exit<unknown, unknown>;
|
|
94
|
+
outputs.push(Exit.isSuccess(result) ? result.value : Cause.squash(result.cause));
|
|
95
|
+
};
|
|
96
|
+
const failing = (entry: FakeObject): Record<string, FakeObject> => ({
|
|
97
|
+
...ready,
|
|
98
|
+
[get(DEP_PATH)]: { ...entry, body: { message: BODY_NEEDLE } },
|
|
99
|
+
});
|
|
100
|
+
for (const entry of [
|
|
101
|
+
{ status: 200 },
|
|
102
|
+
{ status: 404 },
|
|
103
|
+
{ status: 503 },
|
|
104
|
+
{ status: 403 },
|
|
105
|
+
{ status: 429 },
|
|
106
|
+
]) {
|
|
107
|
+
for (const run of [
|
|
108
|
+
readReady(props),
|
|
109
|
+
reconcileReady(props),
|
|
110
|
+
diffReady(props as never, { checks: '', connection, ready: false }),
|
|
111
|
+
]) {
|
|
112
|
+
await attempt(failing(entry), run as never);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await attempt(ready, reconcileReady(props) as never);
|
|
116
|
+
} finally {
|
|
117
|
+
Object.assign(console, saved);
|
|
118
|
+
for (const spy of writes) {
|
|
119
|
+
expect(spy).not.toHaveBeenCalled();
|
|
120
|
+
spy.mockRestore();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
expect(outputs.length).toBeGreaterThan(10);
|
|
124
|
+
expect(leaks(outputs.map(dump).join('\n'))).toEqual([]);
|
|
125
|
+
expect(leaks(logged.join('\n'))).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('the probe can fail: a response body quoted into an error is caught by the needle check', () => {
|
|
129
|
+
const quoted = new Error(`GET responded 503: ${JSON.stringify({ message: BODY_NEEDLE })}`);
|
|
130
|
+
expect(leaks(dump(quoted))).toEqual([BODY_NEEDLE]);
|
|
131
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared harness for the `HomeFlare.Kubernetes.Ready` tests: the REAL `talos-openbao` adapter over a
|
|
3
|
+
* fake `bao` and the fake apiserver (`talos/fake-apiserver.ts`), so uid proof, `readObject` and the
|
|
4
|
+
* TLS-free request path all run for real. Nothing leaves the process.
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Fiber from 'effect/Fiber';
|
|
8
|
+
import * as Layer from 'effect/Layer';
|
|
9
|
+
import * as TestClock from 'effect/testing/TestClock';
|
|
10
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
11
|
+
import { TalosOpenBaoAdapter, talosOpenBaoConnection } from '../talos/cluster-adapter.ts';
|
|
12
|
+
import { UIDS, config, vault } from '../talos/cluster-adapter.fixtures.ts';
|
|
13
|
+
import { type FakeHandler, fakeSpawner } from '../talos/fake-process.ts';
|
|
14
|
+
import { type FakeObject, fakeApiServer } from '../talos/fake-apiserver.ts';
|
|
15
|
+
import type { ReadyCheck } from './ready-checks.ts';
|
|
16
|
+
|
|
17
|
+
export const HOST = 'c1.cluster.invalid';
|
|
18
|
+
export const connection = talosOpenBaoConnection('uid-c1');
|
|
19
|
+
|
|
20
|
+
export const DS_PATH = `/apis/apps/v1/namespaces/kube-system/daemonsets/cilium`;
|
|
21
|
+
export const DEP_PATH = `/apis/apps/v1/namespaces/kube-system/deployments/cilium-operator`;
|
|
22
|
+
export const CRD_PATH = `/apis/apiextensions.k8s.io/v1/customresourcedefinitions/x.cilium.io`;
|
|
23
|
+
|
|
24
|
+
export const dsCheck: ReadyCheck = { kind: 'DaemonSet', name: 'cilium', namespace: 'kube-system' };
|
|
25
|
+
export const depCheck: ReadyCheck = {
|
|
26
|
+
kind: 'Deployment',
|
|
27
|
+
name: 'cilium-operator',
|
|
28
|
+
namespace: 'kube-system',
|
|
29
|
+
};
|
|
30
|
+
export const crdCheck: ReadyCheck = { kind: 'CustomResourceDefinition', name: 'x.cilium.io' };
|
|
31
|
+
|
|
32
|
+
export const checksOf = (): ReadyCheck[] => [dsCheck, depCheck, crdCheck];
|
|
33
|
+
|
|
34
|
+
export const readyDs = {
|
|
35
|
+
metadata: { generation: 2 },
|
|
36
|
+
status: {
|
|
37
|
+
desiredNumberScheduled: 3,
|
|
38
|
+
numberAvailable: 3,
|
|
39
|
+
numberReady: 3,
|
|
40
|
+
observedGeneration: 2,
|
|
41
|
+
updatedNumberScheduled: 3,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
export const readyDep = {
|
|
45
|
+
metadata: { generation: 4 },
|
|
46
|
+
spec: { replicas: 2 },
|
|
47
|
+
status: { availableReplicas: 2, observedGeneration: 4, replicas: 2, updatedReplicas: 2 },
|
|
48
|
+
};
|
|
49
|
+
export const readyCrd = { status: { conditions: [{ status: 'True', type: 'Established' }] } };
|
|
50
|
+
|
|
51
|
+
export const get = (path: string) => `GET ${HOST}${path}`;
|
|
52
|
+
|
|
53
|
+
export const IDENTITY_PATH = '/api/v1/namespaces/kube-system';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A live table whose `key` answers `entries[n]` on the n-th request (the last one thereafter):
|
|
57
|
+
* a cluster that is flaky for a while and then recovers, without real timers.
|
|
58
|
+
*/
|
|
59
|
+
export const sequence = (
|
|
60
|
+
objects: Record<string, FakeObject>,
|
|
61
|
+
key: string,
|
|
62
|
+
entries: readonly (FakeObject | undefined)[],
|
|
63
|
+
): Record<string, FakeObject> => {
|
|
64
|
+
let calls = 0;
|
|
65
|
+
return new Proxy(objects, {
|
|
66
|
+
get: (target, prop) => {
|
|
67
|
+
if (prop !== key) return target[prop as string];
|
|
68
|
+
return entries[Math.min(calls++, entries.length - 1)];
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run `effect` against the fake cluster. `objects` is the live table: a test may mutate it from
|
|
75
|
+
* inside `effect`. The fake `https` module is restored on every exit path.
|
|
76
|
+
*/
|
|
77
|
+
export const withCluster = async <A, E>(
|
|
78
|
+
objects: Record<string, FakeObject>,
|
|
79
|
+
effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner | never>,
|
|
80
|
+
handler: FakeHandler = vault,
|
|
81
|
+
) => {
|
|
82
|
+
const api = fakeApiServer(UIDS, [], objects);
|
|
83
|
+
const spawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fakeSpawner(handler));
|
|
84
|
+
try {
|
|
85
|
+
const layer = Layer.mergeAll(TalosOpenBaoAdapter(config).pipe(Layer.provide(spawner)), spawner);
|
|
86
|
+
return await Effect.runPromise(
|
|
87
|
+
effect.pipe(Effect.provide(layer), Effect.provide(TestClock.layer())) as Effect.Effect<A, E>,
|
|
88
|
+
);
|
|
89
|
+
} finally {
|
|
90
|
+
api.restore();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Drive TestClock until `effect` settles (virtual seconds, no real waiting). */
|
|
95
|
+
export const driven = <A, E, R>(effect: Effect.Effect<A, E, R>, seconds = 1000) =>
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
const fiber = yield* Effect.forkChild(Effect.exit(effect));
|
|
98
|
+
for (let i = 0; i < seconds; i++) {
|
|
99
|
+
yield* Effect.yieldNow;
|
|
100
|
+
yield* TestClock.adjust('1 second');
|
|
101
|
+
}
|
|
102
|
+
return yield* Fiber.join(fiber);
|
|
103
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `HomeFlare.Kubernetes.Ready` handlers through the real adapter: read, reconcile, diff, and the
|
|
3
|
+
* failures that must propagate. Fake `bao` and fake apiserver only; time is TestClock's.
|
|
4
|
+
*/
|
|
5
|
+
import { expect, test } from 'bun:test';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import * as Duration from 'effect/Duration';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Exit from 'effect/Exit';
|
|
10
|
+
import * as Cause from 'effect/Cause';
|
|
11
|
+
import * as Output from 'alchemy/Output';
|
|
12
|
+
import { talosOpenBaoConnection } from '../talos/cluster-adapter.ts';
|
|
13
|
+
import type { FakeObject } from '../talos/fake-apiserver.ts';
|
|
14
|
+
import { KubernetesReady, diffReady, parseGoDuration, readReady, reconcileReady } from './ready.ts';
|
|
15
|
+
import {
|
|
16
|
+
CRD_PATH,
|
|
17
|
+
DEP_PATH,
|
|
18
|
+
DS_PATH,
|
|
19
|
+
HOST,
|
|
20
|
+
connection,
|
|
21
|
+
crdCheck,
|
|
22
|
+
depCheck,
|
|
23
|
+
driven,
|
|
24
|
+
dsCheck,
|
|
25
|
+
get,
|
|
26
|
+
readyCrd,
|
|
27
|
+
readyDep,
|
|
28
|
+
readyDs,
|
|
29
|
+
withCluster,
|
|
30
|
+
} from './ready.fixtures.ts';
|
|
31
|
+
|
|
32
|
+
const checks = [dsCheck, depCheck, crdCheck];
|
|
33
|
+
const props = (extra: object = {}) => ({ checks, connection, ...extra });
|
|
34
|
+
const allReady = (): Record<string, FakeObject> => ({
|
|
35
|
+
[get(CRD_PATH)]: { body: readyCrd },
|
|
36
|
+
[get(DEP_PATH)]: { body: readyDep },
|
|
37
|
+
[get(DS_PATH)]: { body: readyDs },
|
|
38
|
+
});
|
|
39
|
+
const settle = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
40
|
+
driven(effect, 700).pipe(
|
|
41
|
+
Effect.map((exit) =>
|
|
42
|
+
Exit.isSuccess(exit)
|
|
43
|
+
? { ok: exit.value }
|
|
44
|
+
: { error: Cause.squash(exit.cause) as { _tag?: string } },
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
const csv = checks
|
|
48
|
+
.map((c) =>
|
|
49
|
+
c.kind === 'CustomResourceDefinition'
|
|
50
|
+
? `${c.kind}/${c.name}`
|
|
51
|
+
: `${c.kind}/${c.namespace}/${c.name}`,
|
|
52
|
+
)
|
|
53
|
+
.join(',');
|
|
54
|
+
const prior = (ready = true) => ({ checks: csv, connection, ready });
|
|
55
|
+
|
|
56
|
+
test('read: ready:true when every check passes, false when one is pending (404 included)', async () => {
|
|
57
|
+
expect((await withCluster(allReady(), readReady(props()))).ready).toBe(true);
|
|
58
|
+
const stale = {
|
|
59
|
+
...allReady(),
|
|
60
|
+
[get(DS_PATH)]: { body: { ...readyDs, status: { ...readyDs.status, observedGeneration: 1 } } },
|
|
61
|
+
};
|
|
62
|
+
expect((await withCluster(stale, readReady(props()))).ready).toBe(false);
|
|
63
|
+
const missing = { ...allReady(), [get(CRD_PATH)]: { status: 404 } };
|
|
64
|
+
expect((await withCluster(missing, readReady(props()))).ready).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('read propagates 5xx, 429 and a hung GET (only a poll tolerates them)', async () => {
|
|
68
|
+
for (const [entry, tag] of [
|
|
69
|
+
[{ status: 503 }, 'KubernetesReadyApiError'],
|
|
70
|
+
[{ status: 429 }, 'KubernetesReadyApiError'],
|
|
71
|
+
[{ hang: true }, 'KubernetesReadyGetTimeout'],
|
|
72
|
+
] as const) {
|
|
73
|
+
const out = await withCluster(
|
|
74
|
+
{ ...allReady(), [get(DEP_PATH)]: entry },
|
|
75
|
+
settle(readReady(props())),
|
|
76
|
+
);
|
|
77
|
+
expect((out as { error: { _tag: string } }).error._tag).toBe(tag);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('reconcile: all ready returns ready attributes; a never-ready check times out with its key', async () => {
|
|
82
|
+
const ok = await withCluster(allReady(), settle(reconcileReady(props())));
|
|
83
|
+
expect(ok).toEqual({ ok: { checks: csv, connection, ready: true } });
|
|
84
|
+
const bad = {
|
|
85
|
+
...allReady(),
|
|
86
|
+
[get(DS_PATH)]: {
|
|
87
|
+
body: { ...readyDs, status: { ...readyDs.status, updatedNumberScheduled: 0 } },
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const out = (await withCluster(
|
|
91
|
+
bad,
|
|
92
|
+
settle(reconcileReady(props({ waitTimeout: '30s', pollInterval: '5s' }))),
|
|
93
|
+
)) as {
|
|
94
|
+
error: Record<string, unknown>;
|
|
95
|
+
};
|
|
96
|
+
expect(out.error['_tag']).toBe('KubernetesReadyTimeout');
|
|
97
|
+
expect(out.error['failing']).toEqual(['DaemonSet/kube-system/cilium']);
|
|
98
|
+
expect(out.error['seconds']).toBe(30);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('401 and 403 propagate through read and reconcile, never become pending', async () => {
|
|
102
|
+
for (const status of [401, 403]) {
|
|
103
|
+
const objects = { ...allReady(), [get(DS_PATH)]: { status } };
|
|
104
|
+
for (const run of [readReady(props()), reconcileReady(props())]) {
|
|
105
|
+
const out = (await withCluster(objects, settle(run))) as {
|
|
106
|
+
error: { _tag: string; statusCode: number };
|
|
107
|
+
};
|
|
108
|
+
expect(out.error).toMatchObject({ _tag: 'KubernetesReadyApiError', statusCode: status });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a bao failure propagates (a vault outage is not "not ready yet")', async () => {
|
|
114
|
+
const down = () => ({ exitCode: 2, stderr: 'permission denied' });
|
|
115
|
+
for (const run of [readReady(props()), reconcileReady(props())]) {
|
|
116
|
+
const out = (await withCluster(allReady(), settle(run), down)) as { error: Error };
|
|
117
|
+
expect(String(out.error)).not.toContain('KubernetesReadyTimeout');
|
|
118
|
+
expect(out.error).toBeDefined();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a uid mismatch is the typed identity error, before any check is read', async () => {
|
|
123
|
+
const objects = allReady();
|
|
124
|
+
const other = { ...props(), connection: talosOpenBaoConnection('uid-c1') };
|
|
125
|
+
// the apiserver answers a different kube-system uid than the pin
|
|
126
|
+
const out = await withCluster(
|
|
127
|
+
{
|
|
128
|
+
...objects,
|
|
129
|
+
[`GET ${HOST}/api/v1/namespaces/kube-system`]: { body: { metadata: { uid: 'uid-other' } } },
|
|
130
|
+
},
|
|
131
|
+
settle(readReady(other)),
|
|
132
|
+
);
|
|
133
|
+
expect((out as { error: { _tag: string } }).error._tag).toBe('TalosClusterIdentityMismatch');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('diff: noop when ready and unchanged; update when pending, changed checks or connection', async () => {
|
|
137
|
+
const run = (
|
|
138
|
+
objects: Record<string, FakeObject>,
|
|
139
|
+
output: ReturnType<typeof prior> | undefined,
|
|
140
|
+
news = props(),
|
|
141
|
+
) => withCluster(objects, diffReady(news as never, output));
|
|
142
|
+
expect(await run(allReady(), prior())).toEqual({ action: 'noop' });
|
|
143
|
+
expect(await run(allReady(), undefined)).toBeUndefined();
|
|
144
|
+
expect(await run(allReady(), { ...prior(), checks: 'DaemonSet/kube-system/other' })).toEqual({
|
|
145
|
+
action: 'update',
|
|
146
|
+
});
|
|
147
|
+
// connection is `stables`: a changed (or missing) one REPLACES, so dependants see the new one
|
|
148
|
+
expect(
|
|
149
|
+
await run(allReady(), { ...prior(), connection: talosOpenBaoConnection('uid-c2') }),
|
|
150
|
+
).toEqual({ action: 'replace' });
|
|
151
|
+
const { connection: _gone, ...legacy } = prior();
|
|
152
|
+
expect(await run(allReady(), legacy as never)).toEqual({ action: 'replace' });
|
|
153
|
+
const pending = { ...allReady(), [get(CRD_PATH)]: { status: 404 } };
|
|
154
|
+
expect(await run(pending, prior())).toEqual({ action: 'update' });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('diff refuses a non-literal connection with the typed error, and so does declaration', async () => {
|
|
158
|
+
const bad = {
|
|
159
|
+
...props(),
|
|
160
|
+
connection: {
|
|
161
|
+
auth: { kind: 'talos-openbao', uid: Output.literal('uid-c1') as unknown as string },
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
const error = await withCluster(allReady(), Effect.flip(diffReady(bad as never, prior())));
|
|
165
|
+
expect((error as { _tag: string })._tag).toBe('TalosUidNotLiteral');
|
|
166
|
+
const declared = await Effect.runPromise(
|
|
167
|
+
Effect.flip(KubernetesReady('x', bad as never) as never),
|
|
168
|
+
);
|
|
169
|
+
expect((declared as { _tag: string })._tag).toBe('TalosUidNotLiteral');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('durations: Go style accepted, anything else typed-refused', async () => {
|
|
173
|
+
const ms = (v: string) => Effect.runPromise(parseGoDuration('waitTimeout', v));
|
|
174
|
+
expect(Duration.toMillis(await ms('10m0s'))).toBe(600_000);
|
|
175
|
+
expect(Duration.toMillis(await ms('1h30m'))).toBe(5_400_000);
|
|
176
|
+
for (const bad of ['', '10', 'ten minutes', '10m0', '1.5s', '0s', '0m0s', '0h']) {
|
|
177
|
+
const e = await Effect.runPromise(Effect.flip(parseGoDuration('waitTimeout', bad)));
|
|
178
|
+
expect(e._tag).toBe('KubernetesReadyBadDuration');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('a long adversarial duration is refused fast (no polynomial backtracking)', async () => {
|
|
183
|
+
const hostiles = [`${'1'.repeat(10_000)}x`, `${'1h'.repeat(5_000)}x`, `${'9'.repeat(30)}s`];
|
|
184
|
+
for (const hostile of hostiles) {
|
|
185
|
+
const started = performance.now();
|
|
186
|
+
const e = await Effect.runPromise(Effect.flip(parseGoDuration('waitTimeout', hostile)));
|
|
187
|
+
// A generous sanity bound only (a loaded CI runner must not flake it); the regression guard is
|
|
188
|
+
// the deterministic source check below.
|
|
189
|
+
expect(performance.now() - started).toBeLessThan(1000);
|
|
190
|
+
expect(e._tag).toBe('KubernetesReadyBadDuration');
|
|
191
|
+
// The message quotes a bounded prefix, never the whole hostile value.
|
|
192
|
+
expect(e.message.length).toBeLessThan(120);
|
|
193
|
+
}
|
|
194
|
+
// ⛔ Deterministic guard for CodeQL js/polynomial-redos #18: the parser is the anchored, bounded
|
|
195
|
+
// regex with a length cap, never the old unanchored `(\d+)([hms])` with `g`.
|
|
196
|
+
const source = readFileSync(new URL('./ready.ts', import.meta.url), 'utf8');
|
|
197
|
+
expect(source).toContain('/^(?:(\\d{1,4})h)?(?:(\\d{1,4})m)?(?:(\\d{1,6})s)?$/');
|
|
198
|
+
expect(source).toContain('MAX_DURATION_LENGTH = 20');
|
|
199
|
+
expect(source).not.toContain('(\\d+)([hms])/g');
|
|
200
|
+
// The anchored form takes h, m, s once each and in that order only.
|
|
201
|
+
for (const bad of ['5s1m', '1m1m', '1h 1m']) {
|
|
202
|
+
const e = await Effect.runPromise(Effect.flip(parseGoDuration('waitTimeout', bad)));
|
|
203
|
+
expect(e._tag).toBe('KubernetesReadyBadDuration');
|
|
204
|
+
}
|
|
205
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HomeFlare.Kubernetes.Ready` — a readiness gate other rows depend on via `after`/Output edges.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY IT EXISTS. Upstream ships no readiness resource: `Kubernetes.HelmChart` reconcile returns
|
|
5
|
+
* when server-side apply returns, and `Apply` releases the downstream rows at that moment, so a
|
|
6
|
+
* row chained after the Cilium chart would start before one Cilium pod runs (T10 design v3 §2).
|
|
7
|
+
* ★ SEMANTICS are `kubectl rollout status` (`ready-checks.ts`), the polling rules are in
|
|
8
|
+
* `ready-poll.ts`. Reconcile polls to `waitTimeout`; `read`/`diff` take ONE pass, so a plan is
|
|
9
|
+
* live (like `Talos.ClusterHealth`) but never waits.
|
|
10
|
+
* ⛔ `connection` IS A LITERAL `talos-openbao` CONNECTION, refused otherwise at declaration and in
|
|
11
|
+
* `diff` (`literal-connection.ts`), and it is copied onto the attributes so this row is
|
|
12
|
+
* `ClusterLike` (`stables`, as upstream's `HelmChart`). It carries a uid, never a credential.
|
|
13
|
+
* ⛔ NO SECRET REACHES ANYTHING THIS ROW WRITES: errors and attributes name check keys, counts and
|
|
14
|
+
* an error tag + status only, never a response body.
|
|
15
|
+
* ⚠️ `delete` is a no-op and `list` is empty: the row owns nothing in the cluster.
|
|
16
|
+
*
|
|
17
|
+
* Walked against alchemy@2.0.0-beta.81 (`Kubernetes/internal/client.ts` `readObject`,
|
|
18
|
+
* `KubernetesApiError`); only the fake apiserver was used. Not exercised against a live cluster.
|
|
19
|
+
*/
|
|
20
|
+
import { Resource } from 'alchemy';
|
|
21
|
+
import { isResolved } from 'alchemy/Diff';
|
|
22
|
+
import type { Input } from 'alchemy/Input';
|
|
23
|
+
import type { Output } from 'alchemy/Output';
|
|
24
|
+
import * as Provider from 'alchemy/Provider';
|
|
25
|
+
import * as Duration from 'effect/Duration';
|
|
26
|
+
import * as Effect from 'effect/Effect';
|
|
27
|
+
import type { TalosOpenBaoConnection } from '../talos/cluster-adapter.ts';
|
|
28
|
+
import { literalConnectionRefusal, withLiteralConnection } from '../talos/literal-connection.ts';
|
|
29
|
+
import type { TalosRequirements } from '../talos/resource.ts';
|
|
30
|
+
import { type ReadyCheck, checksCsv } from './ready-checks.ts';
|
|
31
|
+
import { connectForDiff, connectOnce, connectTolerant } from './ready-connect.ts';
|
|
32
|
+
import { declaredReady } from './ready-declare.ts';
|
|
33
|
+
import { KubernetesReadyBadDuration } from './ready-errors.ts';
|
|
34
|
+
import { pass, poll } from './ready-poll.ts';
|
|
35
|
+
|
|
36
|
+
export interface ReadyProps {
|
|
37
|
+
/** Literal `talos-openbao` connection (uid proven at connect). */
|
|
38
|
+
connection: TalosOpenBaoConnection;
|
|
39
|
+
checks: readonly ReadyCheck[];
|
|
40
|
+
/** Reconcile deadline, Go style. Default `10m0s`. */
|
|
41
|
+
waitTimeout?: string;
|
|
42
|
+
/** Pause between passes, Go style. Default `10s`. */
|
|
43
|
+
pollInterval?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Ordering edge: lazy Outputs of the chart, e.g. `[chart.objects]`. ⛔ Not `chart.connection` or
|
|
46
|
+
* any other stable: those resolve at plan time and the row would plan `noop` before the chart
|
|
47
|
+
* updates (refused at declaration, `ready-declare.ts`).
|
|
48
|
+
*/
|
|
49
|
+
after?: readonly Output<unknown>[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ReadyAttributes {
|
|
53
|
+
connection: TalosOpenBaoConnection;
|
|
54
|
+
ready: boolean;
|
|
55
|
+
/** The check keys, comma separated: a changed list is an `update`. */
|
|
56
|
+
checks: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface KubernetesReady extends Resource<
|
|
60
|
+
'HomeFlare.Kubernetes.Ready',
|
|
61
|
+
ReadyProps,
|
|
62
|
+
ReadyAttributes,
|
|
63
|
+
never,
|
|
64
|
+
TalosRequirements
|
|
65
|
+
> {}
|
|
66
|
+
|
|
67
|
+
// ⛔ Declaration-time literal guard: a fresh resource skips `diff` (see `withLiteralConnection`).
|
|
68
|
+
export const KubernetesReady = declaredReady(
|
|
69
|
+
withLiteralConnection(Resource<KubernetesReady>('HomeFlare.Kubernetes.Ready')),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const HOUR_MS = 3_600_000;
|
|
73
|
+
const MINUTE_MS = 60_000;
|
|
74
|
+
const SECOND_MS = 1000;
|
|
75
|
+
|
|
76
|
+
// ⛔ Anchored with bounded repeats, in h-m-s order: the former unanchored `(\d+)([hms])` with `g` was
|
|
77
|
+
// CodeQL js/polynomial-redos #18 once `boundedHelmChartProvider` made it reachable from exported
|
|
78
|
+
// parameters. Length is capped first, so no input can make the match slow.
|
|
79
|
+
const GO_DURATION = /^(?:(\d{1,4})h)?(?:(\d{1,4})m)?(?:(\d{1,6})s)?$/;
|
|
80
|
+
const MAX_DURATION_LENGTH = 20;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `10m0s`, `90s`, `1h30m`: whole-number Go durations in h/m/s. Anything else is typed-refused.
|
|
84
|
+
* `resource` names the row in the refusal (`Kubernetes.Ready` unless the caller is another one).
|
|
85
|
+
*/
|
|
86
|
+
export const parseGoDuration = (field: string, value: string, resource?: string) =>
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const refuse = () =>
|
|
89
|
+
Effect.fail(
|
|
90
|
+
new KubernetesReadyBadDuration({
|
|
91
|
+
field,
|
|
92
|
+
value,
|
|
93
|
+
...(resource === undefined ? {} : { resource }),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
const match = value.length > MAX_DURATION_LENGTH ? null : GO_DURATION.exec(value);
|
|
97
|
+
if (match === null || value === '') return yield* refuse();
|
|
98
|
+
const [, h, m, s] = match;
|
|
99
|
+
const ms = Number(h ?? 0) * HOUR_MS + Number(m ?? 0) * MINUTE_MS + Number(s ?? 0) * SECOND_MS;
|
|
100
|
+
// A zero poll interval would spin a full pass of GETs per scheduler tick against the apiserver,
|
|
101
|
+
// and a zero wait fails before a single pass: both are refused (DeepSeek read of PR 373).
|
|
102
|
+
if (ms <= 0) return yield* refuse();
|
|
103
|
+
return Duration.millis(ms);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const attributes = (props: ReadyProps, ready: boolean): ReadyAttributes => ({
|
|
107
|
+
checks: checksCsv(props.checks),
|
|
108
|
+
connection: props.connection,
|
|
109
|
+
ready,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** ⚠️ A row saved before `connection` existed has none at runtime: missing means "update". */
|
|
113
|
+
const sameConnection = (left: TalosOpenBaoConnection, right: TalosOpenBaoConnection | undefined) =>
|
|
114
|
+
right?.auth?.kind === left.auth.kind &&
|
|
115
|
+
right.auth.uid === left.auth.uid &&
|
|
116
|
+
!Object.hasOwn(right.auth, 'cluster');
|
|
117
|
+
|
|
118
|
+
/** One pass: `ready` only when every check is ready. A terminal failure is not ready either. */
|
|
119
|
+
export const readReady = (props: ReadyProps) =>
|
|
120
|
+
Effect.gen(function* () {
|
|
121
|
+
const transport = yield* connectOnce(props.connection);
|
|
122
|
+
const result = yield* pass(transport, props.checks, false);
|
|
123
|
+
return attributes(props, result.pending.length === 0 && result.failed.length === 0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* ⛔ The `diff` pass: a transient (connect or GET) yields `undefined`, which diff turns into
|
|
128
|
+
* `update`, so a CNI rollout never fails a plan and `reconcile` does the waiting. 401/403, vault
|
|
129
|
+
* failures and a uid mismatch still propagate.
|
|
130
|
+
*/
|
|
131
|
+
const readReadyForDiff = (props: ReadyProps) =>
|
|
132
|
+
Effect.gen(function* () {
|
|
133
|
+
const transport = yield* connectForDiff(props.connection);
|
|
134
|
+
if (transport === undefined) return undefined;
|
|
135
|
+
const result = yield* pass(transport, props.checks, true);
|
|
136
|
+
const settled = result.pending.length === 0 && result.failed.length === 0;
|
|
137
|
+
return settled ? attributes(props, true) : undefined;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const reconcileReady = (props: ReadyProps) =>
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const waitTimeout = yield* parseGoDuration('waitTimeout', props.waitTimeout ?? '10m0s');
|
|
143
|
+
const pollInterval = yield* parseGoDuration('pollInterval', props.pollInterval ?? '10s');
|
|
144
|
+
const connect = connectTolerant(props.connection, pollInterval);
|
|
145
|
+
yield* poll(connect, props.checks, waitTimeout, pollInterval);
|
|
146
|
+
return attributes(props, true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export const diffReady = (news: Input<ReadyProps>, output: ReadyAttributes | undefined) =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
// ⛔ Refuse an Output connection while it is still an Input (`literal-connection.ts`).
|
|
152
|
+
const refused = literalConnectionRefusal((news as { connection?: unknown }).connection);
|
|
153
|
+
if (refused !== undefined) return yield* Effect.fail(refused);
|
|
154
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
155
|
+
// ⛔ `connection` is in `stables`: dependants read it off the saved row, so a changed one must
|
|
156
|
+
// REPLACE (as upstream's HelmChart does, `HelmChart.ts:243-251`) or they would see the old
|
|
157
|
+
// connection for one more deploy. `delete` is a no-op, so a replace touches nothing live.
|
|
158
|
+
// A legacy row with no connection counts as changed.
|
|
159
|
+
if (!sameConnection(news.connection, output.connection)) return { action: 'replace' } as const;
|
|
160
|
+
const live = yield* readReadyForDiff(news);
|
|
161
|
+
return live !== undefined && live.checks === output.checks
|
|
162
|
+
? ({ action: 'noop' } as const)
|
|
163
|
+
: ({ action: 'update' } as const);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const readHandler = ({
|
|
167
|
+
olds,
|
|
168
|
+
output,
|
|
169
|
+
}: {
|
|
170
|
+
olds: ReadyProps;
|
|
171
|
+
output: ReadyAttributes | undefined;
|
|
172
|
+
}) => (output === undefined ? Effect.succeed(undefined) : readReady(olds));
|
|
173
|
+
|
|
174
|
+
const handlers = {
|
|
175
|
+
delete: () => Effect.void,
|
|
176
|
+
diff: ({ news, output }: { news: Input<ReadyProps>; output: ReadyAttributes | undefined }) =>
|
|
177
|
+
diffReady(news, output),
|
|
178
|
+
list: () => Effect.succeed([]),
|
|
179
|
+
// ⛔ No prior output means Alchemy's deferred adoption read on a first create: the row owns
|
|
180
|
+
// nothing, so there is nothing to adopt, and a one-pass read would fail the install on the
|
|
181
|
+
// first 5xx before `reconcile` could poll through it (round 1, finding 2).
|
|
182
|
+
read: readHandler,
|
|
183
|
+
reconcile: ({ news }: { news: ReadyProps }) => reconcileReady(news),
|
|
184
|
+
stables: ['connection'] as (keyof ReadyAttributes)[],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const KubernetesReadyProvider = () =>
|
|
188
|
+
Provider.effect(KubernetesReady, Effect.succeed(KubernetesReady.Provider.of(handlers)));
|
|
@@ -18,11 +18,22 @@ type FakeResponse = EventEmitter & { statusCode?: number };
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* `uids` maps an apiserver hostname to the kube-system uid it reports; `hang` lists hostnames that
|
|
21
|
-
* accept the request and never answer.
|
|
21
|
+
* accept the request and never answer. `objects` (K1, `HomeFlare.Kubernetes.Ready`) answers a
|
|
22
|
+
* `'GET host /path'` key with its `status` and JSON `body`; `hang: true` accepts and never
|
|
23
|
+
* answers, `error` fails the request like a socket error. The table is read per request, so a
|
|
24
|
+
* test may mutate it between polls.
|
|
22
25
|
*/
|
|
26
|
+
export type FakeObject = {
|
|
27
|
+
readonly status?: number;
|
|
28
|
+
readonly body?: unknown;
|
|
29
|
+
readonly hang?: boolean;
|
|
30
|
+
readonly error?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
23
33
|
export const fakeApiServer = (
|
|
24
34
|
uids: Readonly<Record<string, string>>,
|
|
25
35
|
hang: readonly string[] = [],
|
|
36
|
+
objects: Record<string, FakeObject> = {},
|
|
26
37
|
) => {
|
|
27
38
|
const seen: string[] = [];
|
|
28
39
|
const fake = ((
|
|
@@ -35,13 +46,20 @@ export const fakeApiServer = (
|
|
|
35
46
|
request.write = () => undefined;
|
|
36
47
|
request.end = () => {
|
|
37
48
|
if (hang.includes(options.hostname)) return;
|
|
49
|
+
const object = objects[`${options.method} ${options.hostname}${path}`];
|
|
50
|
+
if (object?.hang === true) return;
|
|
51
|
+
if (object?.error !== undefined) {
|
|
52
|
+
request.emit('error', new Error(object.error));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
38
55
|
const response = new EventEmitter() as FakeResponse;
|
|
39
|
-
response.statusCode = 200;
|
|
56
|
+
response.statusCode = object?.status ?? 200;
|
|
40
57
|
callback(response);
|
|
41
58
|
const isIdentity = options.method === 'GET' && path === '/api/v1/namespaces/kube-system';
|
|
42
59
|
const uid = isIdentity ? uids[options.hostname] : 'u1';
|
|
43
60
|
const metadata = uid === undefined ? {} : { uid };
|
|
44
|
-
|
|
61
|
+
const payload = object === undefined ? { metadata } : (object.body ?? {});
|
|
62
|
+
response.emit('data', Buffer.from(JSON.stringify(payload)));
|
|
45
63
|
response.emit('end');
|
|
46
64
|
};
|
|
47
65
|
return request;
|