@homeflare/alchemy 0.47.1 → 0.48.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/talos/bao-env.d.ts +7 -0
- package/dist/talos/bao-env.d.ts.map +1 -0
- package/dist/talos/cluster-adapter-errors.d.ts +134 -0
- package/dist/talos/cluster-adapter-errors.d.ts.map +1 -0
- package/dist/talos/cluster-adapter.d.ts +115 -0
- package/dist/talos/cluster-adapter.d.ts.map +1 -0
- package/dist/talos/cluster-identity.d.ts +28 -0
- package/dist/talos/cluster-identity.d.ts.map +1 -0
- package/dist/talos/cluster-transport.d.ts +64 -0
- package/dist/talos/cluster-transport.d.ts.map +1 -0
- package/dist/talos/credentials-write.d.ts.map +1 -1
- package/dist/talos/credentials.d.ts +10 -19
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-apiserver.d.ts +9 -0
- package/dist/talos/fake-apiserver.d.ts.map +1 -0
- package/dist/talos/index.d.ts +7 -0
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +669 -167
- package/dist/talos/index.js.map +22 -10
- package/dist/talos/kubeconfig-attrs.d.ts +14 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -1
- package/dist/talos/kubeconfig-doc.d.ts +23 -0
- package/dist/talos/kubeconfig-doc.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +0 -12
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/literal-connection.d.ts +16 -0
- package/dist/talos/literal-connection.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +1 -1
- package/dist/talos/machine-config-read.d.ts.map +1 -1
- package/dist/talos/mint-temp-file.d.ts +20 -0
- package/dist/talos/mint-temp-file.d.ts.map +1 -0
- package/dist/talos/node-temp.harness.d.ts +2 -0
- package/dist/talos/node-temp.harness.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +15 -15
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-cluster-identity.d.ts +65 -0
- package/dist/talos/talos-cluster-identity.d.ts.map +1 -0
- package/dist/talos/talosctl-binary.d.ts +43 -0
- package/dist/talos/talosctl-binary.d.ts.map +1 -0
- package/dist/talos/talosctl.d.ts +12 -15
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/trust-boundary.d.ts +8 -0
- package/dist/talos/trust-boundary.d.ts.map +1 -0
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/talos/yaml-parse.d.ts +3 -0
- package/dist/talos/yaml-parse.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +2 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/talos-openbao-adapter.md +44 -0
- package/docs/upstream-conformance-bun.md +61 -24
- package/package.json +6 -2
- package/src/talos/bao-env.ts +16 -0
- package/src/talos/bao-vetting.test.ts +97 -0
- package/src/talos/cluster-adapter-deadline.test.ts +110 -0
- package/src/talos/cluster-adapter-errors.ts +192 -0
- package/src/talos/cluster-adapter-move.test.ts +129 -0
- package/src/talos/cluster-adapter-reconcile.test.ts +165 -0
- package/src/talos/cluster-adapter-refusals.test.ts +102 -0
- package/src/talos/cluster-adapter-rename.test.ts +87 -0
- package/src/talos/cluster-adapter-secrecy.test.ts +243 -0
- package/src/talos/cluster-adapter.fixtures.ts +125 -0
- package/src/talos/cluster-adapter.test.ts +240 -0
- package/src/talos/cluster-adapter.ts +249 -0
- package/src/talos/cluster-identity.ts +63 -0
- package/src/talos/cluster-transport.ts +110 -0
- package/src/talos/credentials-write.ts +27 -5
- package/src/talos/credentials.ts +51 -45
- package/src/talos/fake-apiserver.ts +55 -0
- package/src/talos/fake-process.ts +47 -1
- package/src/talos/index.ts +32 -0
- package/src/talos/kubeconfig-adapter.test.ts +37 -0
- package/src/talos/kubeconfig-attrs.ts +12 -28
- package/src/talos/kubeconfig-doc.ts +74 -0
- package/src/talos/kubeconfig.fixtures.ts +52 -0
- package/src/talos/kubeconfig.test.ts +6 -54
- package/src/talos/kubeconfig.ts +18 -34
- package/src/talos/literal-connection.ts +59 -0
- package/src/talos/mint-temp-file.ts +56 -0
- package/src/talos/node-connect.harness.ts +78 -0
- package/src/talos/node-connect.test.ts +27 -0
- package/src/talos/node-temp.harness.ts +40 -0
- package/src/talos/node-temp.test.ts +26 -0
- package/src/talos/talos-cluster-health-declare.test.ts +54 -0
- package/src/talos/talos-cluster-health-literal.test.ts +69 -0
- package/src/talos/talos-cluster-health.test.ts +104 -18
- package/src/talos/talos-cluster-health.ts +46 -14
- package/src/talos/talos-cluster-identity.test.ts +96 -0
- package/src/talos/talos-cluster-identity.ts +130 -0
- package/src/talos/talosctl-binary.ts +150 -0
- package/src/talos/talosctl-vetting.test.ts +212 -0
- package/src/talos/talosctl.test.ts +110 -0
- package/src/talos/talosctl.ts +101 -15
- package/src/talos/trust-boundary.test.ts +17 -0
- package/src/talos/trust-boundary.ts +45 -0
- package/src/talos/values.ts +5 -12
- package/src/talos/yaml-parse.test.ts +41 -0
- package/src/talos/yaml-parse.ts +28 -0
- package/src/verify/fake-engine.ts +10 -0
- package/dist/talos/fake-process.d.ts +0 -56
- package/dist/talos/fake-process.d.ts.map +0 -1
- package/dist/talos/machine-config-fixtures.d.ts +0 -60
- package/dist/talos/machine-config-fixtures.d.ts.map +0 -1
- package/src/postgres/fixtures/pg-REL_18_6-acl.h.txt +0 -147
- package/src/postgres/fixtures/pg-REL_18_6-aclexplode-func.txt +0 -31
- package/src/postgres/fixtures/pg-REL_18_6-alter-default-privileges-synopsis.txt +0 -82
- package/src/postgres/fixtures/pg-REL_18_6-alter_role.sgml.txt +0 -44
- package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +0 -26
- package/src/postgres/fixtures/pg-REL_18_6-create_role.sgml.txt +0 -26
- package/src/postgres/fixtures/pg-REL_18_6-grant-synopsis.txt +0 -95
- package/src/postgres/fixtures/pg-REL_18_6-pg_attribute.h.txt +0 -24
- package/src/postgres/fixtures/pg-REL_18_6-pg_auth_members.h.txt +0 -62
- package/src/postgres/fixtures/pg-REL_18_6-pg_authid.h.txt +0 -70
- package/src/postgres/fixtures/pg-REL_18_6-pg_class.h.txt +0 -33
- package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +0 -67
- package/src/postgres/fixtures/pg-REL_18_6-pg_default_acl.h.txt +0 -81
- package/src/postgres/fixtures/pg-REL_18_6-pg_namespace.h.txt +0 -28
- package/src/postgres/fixtures/pg-REL_18_6-revoke-synopsis.txt +0 -124
- package/src/release/fixtures/openbao/checksums.txt +0 -83
- package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
- package/src/release/fixtures/openbao/darwin-assets.txt +0 -12
- package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +0 -109
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +0 -22
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +0 -8
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mintKvTempFile` — the 0600 session-temp file `readKvValue`'s material lands in. Extracted from
|
|
3
|
+
* credentials.ts (2026-10-06, red team PR 355) once the Node-runtime fix pushed that file past the
|
|
4
|
+
* 250-line cap; the split mirrors credentials-write.ts, which was split out for its own header.
|
|
5
|
+
*/
|
|
6
|
+
import { closeSync, openSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { unlink } from 'node:fs/promises';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import type * as Scope from 'effect/Scope';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Write `raw` to a session-temp, 0600, exclusively-created file whose lifetime is the CALLER's
|
|
16
|
+
* `Effect.scoped`, not this call's. See the ⛔ C1 FIX note at the top of credentials.ts.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ 0600, CREATED EXCLUSIVELY, AND `Bun.write` CANNOT DO EITHER. This file can hold a cluster
|
|
19
|
+
* admin client certificate or a machine config's bootstrap token. `Bun.write` takes no mode, so
|
|
20
|
+
* it lands at the process umask — world-readable on this estate — for as long as the resource
|
|
21
|
+
* runs. The name is unguessable, and "unguessable" is not a permission.
|
|
22
|
+
* ⚠️ `wx` IS THE OTHER HALF. `O_EXCL` means this cannot be made to write through a path an
|
|
23
|
+
* attacker pre-created (the classic /tmp symlink race), and with a UUID name a collision is a
|
|
24
|
+
* genuine error rather than something to paper over.
|
|
25
|
+
* ⚠️ A HARD KILL (SIGKILL, power loss) skips the finalizer and leaves the file behind. 0600 is
|
|
26
|
+
* what makes that survivable rather than a disclosure.
|
|
27
|
+
*/
|
|
28
|
+
export const mintKvTempFile = (
|
|
29
|
+
raw: string,
|
|
30
|
+
label: string,
|
|
31
|
+
): Effect.Effect<{ readonly path: string }, Error, Scope.Scope> =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
// ⛔ NODE, NOT BUN (red team, PR 355): the published dist runs on Node, where `Bun.env` and
|
|
34
|
+
// `Bun.randomUUIDv7` are missing globals — a `Talos.ClusterHealth` check would hit a
|
|
35
|
+
// `ReferenceError` before `talosctl` ever ran. `os.tmpdir()` and `node:crypto` `randomUUID`
|
|
36
|
+
// are the portable equivalents.
|
|
37
|
+
const path = join(tmpdir(), `hf-talos-${label}-${randomUUID()}.yaml`);
|
|
38
|
+
yield* Effect.acquireRelease(
|
|
39
|
+
Effect.try({
|
|
40
|
+
try: () => {
|
|
41
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(fd, raw);
|
|
44
|
+
} finally {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
catch: (cause) => new Error(`writing ${label} temp file: ${String(cause)}`),
|
|
49
|
+
}),
|
|
50
|
+
() =>
|
|
51
|
+
Effect.tryPromise({ try: () => unlink(path), catch: () => undefined }).pipe(
|
|
52
|
+
Effect.orElseSucceed(() => undefined),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
return { path };
|
|
56
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run UNDER NODE by `node-connect.test.ts` (never imported by bun): a successful `talos-openbao`
|
|
3
|
+
* connect with a fake `bao` and a fake apiserver, so any Bun-only API on the connect path (the
|
|
4
|
+
* `Bun.YAML` that once made every kubeconfig "unreadable" on Node) fails CI.
|
|
5
|
+
*
|
|
6
|
+
* ★ The apiserver fake patches `node:https`'s `request` and calls `syncBuiltinESMExports`, which is
|
|
7
|
+
* how a Node program changes what `import * as https` sees. Prints one JSON line, no secrets.
|
|
8
|
+
*/
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
import https from 'node:https';
|
|
11
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
12
|
+
import { ClusterAdapter } from 'alchemy/Kubernetes/ClusterAdapter';
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
16
|
+
import { TalosOpenBaoAdapter, talosOpenBaoConnection } from './cluster-adapter.ts';
|
|
17
|
+
import { fakeSpawner } from './fake-process.ts';
|
|
18
|
+
|
|
19
|
+
const b64 = (text: string) => Buffer.from(text).toString('base64');
|
|
20
|
+
const pem = (label: string) => `-----${'BEGIN'} ${label}-----\n${b64(label)}\n`;
|
|
21
|
+
const kubeconfig = `apiVersion: v1
|
|
22
|
+
kind: Config
|
|
23
|
+
clusters:
|
|
24
|
+
- name: c1
|
|
25
|
+
cluster: { server: 'https://c1.cluster.invalid:6443', certificate-authority-data: ${b64(pem('CERTIFICATE'))} }
|
|
26
|
+
contexts:
|
|
27
|
+
- name: admin@c1
|
|
28
|
+
context: { cluster: c1, user: admin@c1 }
|
|
29
|
+
users:
|
|
30
|
+
- name: admin@c1
|
|
31
|
+
user: { client-certificate-data: ${b64(pem('CERTIFICATE'))}, client-key-data: ${b64(pem('PRIVATE KEY'))} }
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const seen: string[] = [];
|
|
35
|
+
const fakeRequest = (
|
|
36
|
+
options: { hostname: string; method: string; path: string },
|
|
37
|
+
callback: (response: EventEmitter & { statusCode?: number }) => void,
|
|
38
|
+
) => {
|
|
39
|
+
seen.push(`${options.method} ${options.hostname}${options.path}`);
|
|
40
|
+
const request = new EventEmitter() as EventEmitter & { write: () => void; end: () => void };
|
|
41
|
+
request.write = () => undefined;
|
|
42
|
+
request.end = () => {
|
|
43
|
+
const response = new EventEmitter() as EventEmitter & { statusCode?: number };
|
|
44
|
+
response.statusCode = 200;
|
|
45
|
+
callback(response);
|
|
46
|
+
response.emit('data', Buffer.from('{"metadata":{"uid":"uid-c1"}}'));
|
|
47
|
+
response.emit('end');
|
|
48
|
+
};
|
|
49
|
+
return request;
|
|
50
|
+
};
|
|
51
|
+
(https as unknown as { request: unknown }).request = fakeRequest;
|
|
52
|
+
syncBuiltinESMExports();
|
|
53
|
+
|
|
54
|
+
process.env['BAO_ADDR'] ??= 'https://bao.invalid';
|
|
55
|
+
process.env['BAO_TOKEN'] ??= 'fake-test-token';
|
|
56
|
+
|
|
57
|
+
// ⚠️ The trust-boundary seam registers the fixtures dir ASYNC (trust-boundary.ts's computed
|
|
58
|
+
// dynamic import). `readKvValue` now vets `bao`, so the walk must see the boundary before it
|
|
59
|
+
// runs. Awaiting the seam here flushes that registration; without it a bare `node` run can walk
|
|
60
|
+
// past the (755) fixtures dir into a group-writable ancestor and refuse a trusted stub.
|
|
61
|
+
await import('./trust-boundary.seam.ts');
|
|
62
|
+
|
|
63
|
+
const spawner = Layer.succeed(
|
|
64
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
65
|
+
fakeSpawner(() => ({ stdout: JSON.stringify({ data: { data: { kubeconfig } } }) })),
|
|
66
|
+
);
|
|
67
|
+
const config = {
|
|
68
|
+
c1: { context: 'admin@c1', key: 'kubeconfig', mount: 'talos-c1', uid: 'uid-c1' },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const transport = await Effect.runPromise(
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const adapter = yield* ClusterAdapter('talos-openbao');
|
|
74
|
+
return yield* adapter.connect(talosOpenBaoConnection('uid-c1'));
|
|
75
|
+
}).pipe(Effect.provide(TalosOpenBaoAdapter(config).pipe(Layer.provide(spawner)))),
|
|
76
|
+
);
|
|
77
|
+
const hasCert = transport.clientCert !== undefined;
|
|
78
|
+
process.stdout.write(`${JSON.stringify({ endpoint: transport.endpoint, hasCert, seen })}\n`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ THE PUBLISHED CODE RUNS ON NODE. A successful adapter connect is run under a real `node`
|
|
3
|
+
* process (`node-connect.harness.ts`, type stripping), so a Bun-only API on the connect path fails
|
|
4
|
+
* here. Reproduced before the fix: on Node 22 `Bun.YAML` was a missing global, swallowed into
|
|
5
|
+
* "unreadable kubeconfig", and the adapter returned no transport. Bun's own test run cannot see
|
|
6
|
+
* that, which is why this spawns node instead of importing the module.
|
|
7
|
+
*/
|
|
8
|
+
import { expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
test('a successful connect works under node, with no Bun globals', async () => {
|
|
11
|
+
const harness = new URL('./node-connect.harness.ts', import.meta.url).pathname;
|
|
12
|
+
const proc = Bun.spawn(['node', '--experimental-strip-types', '--no-warnings', harness], {
|
|
13
|
+
stderr: 'pipe',
|
|
14
|
+
stdout: 'pipe',
|
|
15
|
+
});
|
|
16
|
+
const [out, err, code] = await Promise.all([
|
|
17
|
+
new Response(proc.stdout).text(),
|
|
18
|
+
new Response(proc.stderr).text(),
|
|
19
|
+
proc.exited,
|
|
20
|
+
]);
|
|
21
|
+
expect({ code, err: err.slice(0, 500) }).toEqual({ code: 0, err: '' });
|
|
22
|
+
expect(JSON.parse(out.trim())).toEqual({
|
|
23
|
+
endpoint: 'https://c1.cluster.invalid:6443',
|
|
24
|
+
hasCert: true,
|
|
25
|
+
seen: ['GET c1.cluster.invalid/api/v1/namespaces/kube-system'],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ THE PUBLISHED CODE RUNS ON NODE. `mintKvTempFile` (credentials.ts) and `reservedTempPath`
|
|
3
|
+
* (credentials-write.ts) once built their path from `Bun.env`/`Bun.randomUUIDv7` — missing globals
|
|
4
|
+
* on Node, where a `Talos.ClusterHealth` check would hit a `ReferenceError` before `talosctl` ran.
|
|
5
|
+
* Run under a real `node` process (type stripping) so a Bun-only API here fails CI.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, statSync } from 'node:fs';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { mintKvTempFile } from './credentials.ts';
|
|
10
|
+
import { reservedTempPath } from './credentials-write.ts';
|
|
11
|
+
|
|
12
|
+
const report = (label: string, value: unknown) =>
|
|
13
|
+
process.stdout.write(`${JSON.stringify({ label, value })}\n`);
|
|
14
|
+
|
|
15
|
+
let tempCreated = false;
|
|
16
|
+
let tempMode = -1;
|
|
17
|
+
let tempGone = false;
|
|
18
|
+
const mintedPath = await Effect.runPromise(
|
|
19
|
+
Effect.scoped(
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const file = yield* mintKvTempFile('node-body\n', 'c1-node-check');
|
|
22
|
+
tempCreated = existsSync(file.path);
|
|
23
|
+
tempMode = statSync(file.path).mode & 0o777;
|
|
24
|
+
return file.path;
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
tempGone = !existsSync(mintedPath);
|
|
29
|
+
|
|
30
|
+
let reservedPath = '';
|
|
31
|
+
const reservedGone = await Effect.runPromise(
|
|
32
|
+
Effect.scoped(
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const reserved = yield* reservedTempPath('c1-node-reserve');
|
|
35
|
+
reservedPath = reserved.path;
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
).then(() => !existsSync(reservedPath));
|
|
39
|
+
|
|
40
|
+
report('temp', { tempCreated, tempMode, tempGone, reservedGone });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ The temp-file minters (credentials.ts `mintKvTempFile`, credentials-write.ts `reservedTempPath`)
|
|
3
|
+
* run on Node in production. They once read `Bun.env`/`Bun.randomUUIDv7`; a Node process would
|
|
4
|
+
* throw `ReferenceError: Bun is not defined`. This spawns a real `node` process (type stripping) so
|
|
5
|
+
* a Bun-only API on that path fails here rather than in a consumer's deploy.
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
test('the temp-file minters work under node, with no Bun globals', async () => {
|
|
10
|
+
const harness = new URL('./node-temp.harness.ts', import.meta.url).pathname;
|
|
11
|
+
const proc = Bun.spawn(['node', '--experimental-strip-types', '--no-warnings', harness], {
|
|
12
|
+
stderr: 'pipe',
|
|
13
|
+
stdout: 'pipe',
|
|
14
|
+
});
|
|
15
|
+
const [out, err, code] = await Promise.all([
|
|
16
|
+
new Response(proc.stdout).text(),
|
|
17
|
+
new Response(proc.stderr).text(),
|
|
18
|
+
proc.exited,
|
|
19
|
+
]);
|
|
20
|
+
expect({ code, err: err.slice(0, 500) }).toEqual({ code: 0, err: '' });
|
|
21
|
+
const line = out.trim().split('\n').pop() ?? '';
|
|
22
|
+
expect(JSON.parse(line)).toEqual({
|
|
23
|
+
label: 'temp',
|
|
24
|
+
value: { tempCreated: true, tempMode: 0o600, tempGone: true, reservedGone: true },
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A FRESH `Talos.ClusterHealth` skips `diff` while its inputs are unresolved, so the literal-uid
|
|
3
|
+
* refusal must already have happened when the resource is DECLARED. Driven through a real
|
|
4
|
+
* `Alchemy.Stack` (the engine's own declaration path, no handler called directly): an Output uid
|
|
5
|
+
* fails the stack before any plan exists, and a literal one still declares.
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from 'bun:test';
|
|
8
|
+
import * as Alchemy from 'alchemy';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { talosOpenBaoConnection } from './cluster-adapter.ts';
|
|
11
|
+
import { TalosClusterHealth, TalosClusterHealthProvider } from './talos-cluster-health.ts';
|
|
12
|
+
import './fake-process.ts';
|
|
13
|
+
|
|
14
|
+
const base = { controlPlaneNodes: ['198.51.100.10'], target: { cluster: 'c1', mount: 'talos-c1' } };
|
|
15
|
+
|
|
16
|
+
const compile = (body: unknown) => {
|
|
17
|
+
const stack = Alchemy.Stack as unknown as (
|
|
18
|
+
name: string,
|
|
19
|
+
options: { providers: unknown; state: unknown },
|
|
20
|
+
body: unknown,
|
|
21
|
+
) => Effect.Effect<unknown, unknown, never>;
|
|
22
|
+
return stack(
|
|
23
|
+
'talos-health-declare',
|
|
24
|
+
{ providers: TalosClusterHealthProvider(), state: Alchemy.inMemoryState() },
|
|
25
|
+
body,
|
|
26
|
+
).pipe(Effect.provideService(Alchemy.Stage, 'test'), Effect.scoped);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test('declaring a fresh health resource whose uid is an Output fails the stack', async () => {
|
|
30
|
+
const failure = await Effect.runPromise(
|
|
31
|
+
compile(
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const first = yield* TalosClusterHealth('First', {
|
|
34
|
+
...base,
|
|
35
|
+
connection: talosOpenBaoConnection('uid-c1'),
|
|
36
|
+
});
|
|
37
|
+
// A real Output: an attribute of another resource, unresolved until it deploys.
|
|
38
|
+
const uid = (first as unknown as { controlPlaneNodes: unknown }).controlPlaneNodes;
|
|
39
|
+
yield* TalosClusterHealth('Second', {
|
|
40
|
+
...base,
|
|
41
|
+
connection: { auth: { kind: 'talos-openbao', uid } },
|
|
42
|
+
} as never);
|
|
43
|
+
}),
|
|
44
|
+
).pipe(Effect.flip),
|
|
45
|
+
);
|
|
46
|
+
expect((failure as { _tag: string })._tag).toBe('TalosUidNotLiteral');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a literal uid declares fine', async () => {
|
|
50
|
+
const compiled = await Effect.runPromise(
|
|
51
|
+
compile(TalosClusterHealth('Only', { ...base, connection: talosOpenBaoConnection('uid-c1') })),
|
|
52
|
+
);
|
|
53
|
+
expect(compiled).toBeDefined();
|
|
54
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.ClusterHealth` hands its `connection` to workloads through `cluster: health`, so an
|
|
3
|
+
* Output there reintroduces the unresolved-uid hole `talosOpenBaoConnection` closes. `diff` still
|
|
4
|
+
* sees the Input, so that is where the refusal happens. Offline: fake-process.ts fakes everything.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import * as Output from 'alchemy/Output';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
11
|
+
import { talosOpenBaoConnection } from './cluster-adapter.ts';
|
|
12
|
+
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
13
|
+
import { diffClusterHealth } from './talos-cluster-health.ts';
|
|
14
|
+
|
|
15
|
+
const connection = talosOpenBaoConnection('uid-c1');
|
|
16
|
+
const prior = {
|
|
17
|
+
connection,
|
|
18
|
+
controlPlaneNodes: '198.51.100.10',
|
|
19
|
+
healthy: true,
|
|
20
|
+
workerNodes: '',
|
|
21
|
+
};
|
|
22
|
+
const base = {
|
|
23
|
+
controlPlaneNodes: ['198.51.100.10'],
|
|
24
|
+
target: { cluster: 'c1', mount: 'talos-c1' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const diffWith = (given: unknown, calls: FakeCall[]) =>
|
|
28
|
+
Effect.runPromise(
|
|
29
|
+
Effect.provideService(
|
|
30
|
+
diffClusterHealth({ ...base, connection: given } as never, prior),
|
|
31
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
32
|
+
fakeSpawner(
|
|
33
|
+
() => ({ stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) }),
|
|
34
|
+
calls,
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
describe('diffClusterHealth — literal connection', () => {
|
|
40
|
+
it('plans noop for a literal connection (the guard does not refuse good input)', async () => {
|
|
41
|
+
const calls: FakeCall[] = [];
|
|
42
|
+
assert.equal((await diffWith(connection, calls))?.action, 'noop');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('refuses an Output uid before any vault or talosctl call', async () => {
|
|
46
|
+
const calls: FakeCall[] = [];
|
|
47
|
+
const uid = Output.literal('uid-c1');
|
|
48
|
+
await assert.rejects(
|
|
49
|
+
diffWith({ auth: { kind: 'talos-openbao', uid } }, calls),
|
|
50
|
+
(error: unknown) => String(error).includes('literal string'),
|
|
51
|
+
);
|
|
52
|
+
assert.deepEqual(calls, []);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('refuses a connection that is itself an Output', async () => {
|
|
56
|
+
const calls: FakeCall[] = [];
|
|
57
|
+
await assert.rejects(diffWith(Output.literal(connection), calls));
|
|
58
|
+
assert.deepEqual(calls, []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('refuses a stock kubeconfig connection at diff time (never reaches $KUBECONFIG)', async () => {
|
|
62
|
+
const calls: FakeCall[] = [];
|
|
63
|
+
await assert.rejects(
|
|
64
|
+
diffWith({ auth: { kind: 'kubeconfig', context: 'admin@hf-c1' } }, calls),
|
|
65
|
+
(error: unknown) => String(error).includes('literal string'),
|
|
66
|
+
);
|
|
67
|
+
assert.deepEqual(calls, []);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import { describe, it } from 'node:test';
|
|
7
|
+
import * as Output from 'alchemy/Output';
|
|
7
8
|
import * as Effect from 'effect/Effect';
|
|
8
9
|
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
9
10
|
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
11
|
+
import { talosOpenBaoConnection } from './cluster-adapter.ts';
|
|
10
12
|
import {
|
|
11
13
|
diffClusterHealth,
|
|
12
14
|
readClusterHealth,
|
|
@@ -14,7 +16,14 @@ import {
|
|
|
14
16
|
} from './talos-cluster-health.ts';
|
|
15
17
|
|
|
16
18
|
const TARGET = { cluster: 'c1', mount: 'talos-c1' };
|
|
17
|
-
const
|
|
19
|
+
const connection = talosOpenBaoConnection('uid-c1');
|
|
20
|
+
const props = () => ({ connection, controlPlaneNodes: ['198.51.100.10'], target: TARGET });
|
|
21
|
+
const prior = (healthy: boolean) => ({
|
|
22
|
+
connection,
|
|
23
|
+
controlPlaneNodes: '198.51.100.10',
|
|
24
|
+
healthy,
|
|
25
|
+
workerNodes: '',
|
|
26
|
+
});
|
|
18
27
|
|
|
19
28
|
const run = <A, E>(
|
|
20
29
|
effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
|
|
@@ -54,6 +63,7 @@ describe('readClusterHealth', () => {
|
|
|
54
63
|
it('reports healthy:true when talosctl health exits zero', async () => {
|
|
55
64
|
const result = await run(readClusterHealth(props()), healthy);
|
|
56
65
|
assert.equal(result.healthy, true);
|
|
66
|
+
assert.deepEqual(result.connection, connection);
|
|
57
67
|
});
|
|
58
68
|
|
|
59
69
|
it('propagates a vault/transport failure instead of reporting healthy:false', async () => {
|
|
@@ -64,30 +74,105 @@ describe('readClusterHealth', () => {
|
|
|
64
74
|
});
|
|
65
75
|
});
|
|
66
76
|
|
|
77
|
+
/** A talosctl vetting refusal (relative HF_TALOSCTL): the binary never runs, so it is no verdict. */
|
|
78
|
+
const withRefusedBinary = async <A>(body: () => Promise<A>): Promise<A> => {
|
|
79
|
+
const saved = process.env['HF_TALOSCTL'];
|
|
80
|
+
process.env['HF_TALOSCTL'] = 'relative/talosctl';
|
|
81
|
+
try {
|
|
82
|
+
return await body();
|
|
83
|
+
} finally {
|
|
84
|
+
if (saved === undefined) delete process.env['HF_TALOSCTL'];
|
|
85
|
+
else process.env['HF_TALOSCTL'] = saved;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const refusal = (error: unknown) =>
|
|
89
|
+
error instanceof Error &&
|
|
90
|
+
error.name === 'TalosBinaryRefused' &&
|
|
91
|
+
error.message.includes('absolute');
|
|
92
|
+
|
|
93
|
+
describe('a talosctl vetting refusal is never a health verdict (fails on 37d835a: healthy:false)', () => {
|
|
94
|
+
it('read propagates it', async () => {
|
|
95
|
+
await withRefusedBinary(() =>
|
|
96
|
+
assert.rejects(run(readClusterHealth(props()), healthy), refusal),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('diff propagates it instead of planning an update', async () => {
|
|
101
|
+
await withRefusedBinary(() =>
|
|
102
|
+
assert.rejects(run(diffClusterHealth(props(), prior(true)), healthy), refusal),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reconcile propagates it without the "cluster not healthy" mislabel', async () => {
|
|
107
|
+
await withRefusedBinary(() =>
|
|
108
|
+
assert.rejects(
|
|
109
|
+
run(reconcileClusterHealth(props()), healthy),
|
|
110
|
+
(error: unknown) => refusal(error) && !String(error).includes('cluster not healthy'),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
67
116
|
describe('diffClusterHealth', () => {
|
|
68
117
|
it('plans update when the cluster genuinely reports unhealthy', async () => {
|
|
118
|
+
const result = await run(diffClusterHealth(props(), prior(false)), unhealthy);
|
|
119
|
+
assert.equal(result?.action, 'update');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('propagates a vault/transport failure rather than planning update silently', async () => {
|
|
123
|
+
await assert.rejects(run(diffClusterHealth(props(), prior(false)), vaultDown));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('plans noop when the cluster is healthy and the connection is unchanged', async () => {
|
|
127
|
+
const result = await run(diffClusterHealth(props(), prior(true)), healthy);
|
|
128
|
+
assert.equal(result?.action, 'noop');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('plans update when the cluster is healthy but the connection changed', async () => {
|
|
132
|
+
const drifted = talosOpenBaoConnection('uid-other');
|
|
69
133
|
const result = await run(
|
|
70
|
-
diffClusterHealth(props(), {
|
|
71
|
-
|
|
72
|
-
healthy: false,
|
|
73
|
-
workerNodes: '',
|
|
74
|
-
}),
|
|
75
|
-
unhealthy,
|
|
134
|
+
diffClusterHealth(props(), { ...prior(true), connection: drifted }),
|
|
135
|
+
healthy,
|
|
76
136
|
);
|
|
77
137
|
assert.equal(result?.action, 'update');
|
|
78
138
|
});
|
|
79
139
|
|
|
80
|
-
it('
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
140
|
+
it('plans update when only the cluster uid changed (a real move)', async () => {
|
|
141
|
+
const moved = talosOpenBaoConnection('uid-c2');
|
|
142
|
+
const result = await run(
|
|
143
|
+
diffClusterHealth(props(), { ...prior(true), connection: moved }),
|
|
144
|
+
healthy,
|
|
145
|
+
);
|
|
146
|
+
assert.equal(result?.action, 'update');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('plans update, not a throw, for a row saved before `connection` existed', async () => {
|
|
150
|
+
const { connection: _dropped, ...old } = prior(true);
|
|
151
|
+
const result = await run(
|
|
152
|
+
diffClusterHealth(props(), old as unknown as ReturnType<typeof prior>),
|
|
153
|
+
healthy,
|
|
90
154
|
);
|
|
155
|
+
assert.equal(result?.action, 'update');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('diffClusterHealth — a legacy auth.cluster row (fails if the hasOwn guard is deleted)', () => {
|
|
160
|
+
it('plans update for a healthy cluster whose saved row still carries auth.cluster', async () => {
|
|
161
|
+
const legacy = { auth: { ...talosOpenBaoConnection('uid-c1').auth, cluster: 'c1' } };
|
|
162
|
+
const result = await run(
|
|
163
|
+
diffClusterHealth(props(), { ...prior(true), connection: legacy as never }),
|
|
164
|
+
healthy,
|
|
165
|
+
);
|
|
166
|
+
assert.equal(result?.action, 'update');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('diffClusterHealth — a non-literal connection is a TYPED failure', () => {
|
|
171
|
+
it('fails (flip would reject on a defect) with TalosUidNotLiteral for an Output uid', async () => {
|
|
172
|
+
const output = Output.literal('uid-c1') as unknown as string;
|
|
173
|
+
const bad = { ...props(), connection: { auth: { kind: 'talos-openbao', uid: output } } };
|
|
174
|
+
const error = await run(Effect.flip(diffClusterHealth(bad as never, prior(true))), healthy);
|
|
175
|
+
assert.equal((error as { _tag: string })._tag, 'TalosUidNotLiteral');
|
|
91
176
|
});
|
|
92
177
|
});
|
|
93
178
|
|
|
@@ -96,6 +181,7 @@ describe('check — I1 fix: --nodes names ONE contact node, not the whole cluste
|
|
|
96
181
|
const calls: FakeCall[] = [];
|
|
97
182
|
await run(
|
|
98
183
|
readClusterHealth({
|
|
184
|
+
connection,
|
|
99
185
|
controlPlaneNodes: ['198.51.100.10', '198.51.100.11', '198.51.100.12'],
|
|
100
186
|
target: TARGET,
|
|
101
187
|
}),
|
|
@@ -116,7 +202,7 @@ describe('check — I1 fix: --nodes names ONE contact node, not the whole cluste
|
|
|
116
202
|
|
|
117
203
|
it('fails closed instead of spawning talosctl when controlPlaneNodes is empty', async () => {
|
|
118
204
|
await assert.rejects(
|
|
119
|
-
run(readClusterHealth({ controlPlaneNodes: [], target: TARGET }), healthy),
|
|
205
|
+
run(readClusterHealth({ connection, controlPlaneNodes: [], target: TARGET }), healthy),
|
|
120
206
|
(error: unknown) =>
|
|
121
207
|
error instanceof Error && error.message.includes('controlPlaneNodes is empty'),
|
|
122
208
|
);
|
|
@@ -22,13 +22,19 @@
|
|
|
22
22
|
* rollout would never learn the real problem was vault. Both `read` and `reconcile` now only
|
|
23
23
|
* catch `TalosError` (`talosctl health` itself ran and exited non-zero — the one case this
|
|
24
24
|
* family may honestly call "not healthy"); anything else propagates untouched.
|
|
25
|
+
* ★ `connection` IS A PASS-THROUGH so this resource is `ClusterLike`. Later rows take
|
|
26
|
+
* `cluster: health`. The health check itself still uses `target` and `talosctl`; the field is
|
|
27
|
+
* copied onto attributes and is not a credential. It is required: an optional field would not
|
|
28
|
+
* satisfy `{ connection: Connection }`.
|
|
25
29
|
*/
|
|
26
30
|
import { Resource } from 'alchemy';
|
|
27
31
|
import { isResolved } from 'alchemy/Diff';
|
|
28
32
|
import type { Input } from 'alchemy/Input';
|
|
29
33
|
import * as Provider from 'alchemy/Provider';
|
|
30
34
|
import * as Effect from 'effect/Effect';
|
|
35
|
+
import type { TalosOpenBaoConnection } from './cluster-adapter.ts';
|
|
31
36
|
import { mintTalosconfig } from './credentials.ts';
|
|
37
|
+
import { literalConnectionRefusal, withLiteralConnection } from './literal-connection.ts';
|
|
32
38
|
import type { TalosRequirements, WithTarget } from './resource.ts';
|
|
33
39
|
import { TalosError, talosctl } from './talosctl.ts';
|
|
34
40
|
|
|
@@ -49,12 +55,19 @@ export interface ClusterHealthProps extends WithTarget {
|
|
|
49
55
|
* on kube-proxy and CoreDNS, both of which wait on a CNI that does not exist yet at bootstrap.
|
|
50
56
|
*/
|
|
51
57
|
after?: readonly unknown[];
|
|
58
|
+
/**
|
|
59
|
+
* Kubernetes connection copied onto attributes so a later row can take `cluster: this`.
|
|
60
|
+
* Not read by the health check. Persist only a secret-free connection (`talos-openbao`).
|
|
61
|
+
*/
|
|
62
|
+
connection: TalosOpenBaoConnection;
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
export interface ClusterHealthAttributes {
|
|
55
66
|
healthy: boolean;
|
|
56
67
|
controlPlaneNodes: string;
|
|
57
68
|
workerNodes: string;
|
|
69
|
+
/** Same object as the prop. Makes these attributes `ClusterLike`. */
|
|
70
|
+
connection: TalosOpenBaoConnection;
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
export interface TalosClusterHealth extends Resource<
|
|
@@ -65,11 +78,34 @@ export interface TalosClusterHealth extends Resource<
|
|
|
65
78
|
TalosRequirements
|
|
66
79
|
> {}
|
|
67
80
|
|
|
68
|
-
|
|
81
|
+
// ⛔ Declaration-time literal guard: a fresh resource skips `diff` (see `withLiteralConnection`).
|
|
82
|
+
export const TalosClusterHealth = withLiteralConnection(
|
|
83
|
+
Resource<TalosClusterHealth>('Talos.ClusterHealth'),
|
|
84
|
+
);
|
|
69
85
|
|
|
70
86
|
const nodeCsv = (nodes: readonly string[] | undefined) =>
|
|
71
87
|
nodes === undefined || nodes.length === 0 ? '' : nodes.join(',');
|
|
72
88
|
|
|
89
|
+
const attributes = (props: ClusterHealthProps, healthy: boolean): ClusterHealthAttributes => ({
|
|
90
|
+
connection: props.connection,
|
|
91
|
+
controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
|
|
92
|
+
healthy,
|
|
93
|
+
workerNodes: nodeCsv(props.workerNodes),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* ⚠️ A ROW SAVED BEFORE `connection` EXISTED HAS NONE — `right` is `undefined` at runtime despite
|
|
98
|
+
* the type, and reading `right.auth` threw on a healthy cluster. Missing means "update".
|
|
99
|
+
*/
|
|
100
|
+
const sameConnection = (
|
|
101
|
+
left: TalosOpenBaoConnection,
|
|
102
|
+
right: TalosOpenBaoConnection | undefined,
|
|
103
|
+
): boolean =>
|
|
104
|
+
right?.auth?.kind === left.auth.kind &&
|
|
105
|
+
right.auth.uid === left.auth.uid &&
|
|
106
|
+
// ⚠️ A row that still carries the legacy `auth.cluster` alias must update, so it leaves state.
|
|
107
|
+
!Object.hasOwn(right.auth, 'cluster');
|
|
108
|
+
|
|
73
109
|
const healthArgs = (props: ClusterHealthProps, waitTimeout: string) => {
|
|
74
110
|
const args = ['health', '--wait-timeout', waitTimeout];
|
|
75
111
|
const cp = nodeCsv(props.controlPlaneNodes);
|
|
@@ -113,11 +149,7 @@ const check = (props: ClusterHealthProps, waitTimeout: string) =>
|
|
|
113
149
|
nodes: [contact],
|
|
114
150
|
talosconfigPath: credential.talosconfigPath,
|
|
115
151
|
});
|
|
116
|
-
return
|
|
117
|
-
controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
|
|
118
|
-
healthy: true,
|
|
119
|
-
workerNodes: nodeCsv(props.workerNodes),
|
|
120
|
-
};
|
|
152
|
+
return attributes(props, true);
|
|
121
153
|
}),
|
|
122
154
|
);
|
|
123
155
|
|
|
@@ -132,13 +164,7 @@ const isTalosError = (cause: unknown): cause is TalosError => cause instanceof T
|
|
|
132
164
|
/** ★ EXPORTED for talos-cluster-health.test.ts — see talos-bootstrap.ts's own note on the pattern. */
|
|
133
165
|
export const readClusterHealth = (props: ClusterHealthProps) =>
|
|
134
166
|
check(props, '5s').pipe(
|
|
135
|
-
Effect.catchIf(isTalosError, () =>
|
|
136
|
-
Effect.succeed({
|
|
137
|
-
controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
|
|
138
|
-
healthy: false,
|
|
139
|
-
workerNodes: nodeCsv(props.workerNodes),
|
|
140
|
-
}),
|
|
141
|
-
),
|
|
167
|
+
Effect.catchIf(isTalosError, () => Effect.succeed(attributes(props, false))),
|
|
142
168
|
);
|
|
143
169
|
|
|
144
170
|
export const diffClusterHealth = (
|
|
@@ -146,9 +172,15 @@ export const diffClusterHealth = (
|
|
|
146
172
|
output: ClusterHealthAttributes | undefined,
|
|
147
173
|
) =>
|
|
148
174
|
Effect.gen(function* () {
|
|
175
|
+
// ⛔ `cluster: health` hands this connection to workloads: refuse an Output here, while it is
|
|
176
|
+
// still an Input (`literal-connection.ts`).
|
|
177
|
+
const refused = literalConnectionRefusal((news as { connection?: unknown }).connection);
|
|
178
|
+
if (refused !== undefined) return yield* Effect.fail(refused);
|
|
149
179
|
if (output === undefined || !isResolved(news)) return undefined;
|
|
150
180
|
const live = yield* readClusterHealth(news);
|
|
151
|
-
if (live.healthy
|
|
181
|
+
if (live.healthy && sameConnection(live.connection, output.connection)) {
|
|
182
|
+
return { action: 'noop' } as const;
|
|
183
|
+
}
|
|
152
184
|
return { action: 'update' } as const;
|
|
153
185
|
});
|
|
154
186
|
|