@homeflare/alchemy 0.5.0 → 0.7.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 +68 -55
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +45 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +30 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +43 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +21 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +912 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +47 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +24 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/fake-engines.d.ts +30 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1518 -310
- package/dist/openbao/index.js.map +40 -17
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +106 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/caddy.md +180 -0
- package/docs/launchd.md +167 -0
- package/docs/mesh-node.md +188 -0
- package/package.json +12 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +218 -0
- package/src/caddy/config-lifecycle.ts +148 -0
- package/src/caddy/config.ts +116 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +160 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +25 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +244 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +62 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +2 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +163 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +62 -37
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/role-rename.test.ts +205 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeshNodeProvider's handlers, called the way the engine calls them, over the fake Cloudflare in
|
|
3
|
+
* fake-mesh.ts — and `engine`, which runs Alchemy's real plan/apply over the same fake. Shared by
|
|
4
|
+
* mesh-node.test.ts, mesh-node-edges.test.ts and mesh-node-policy.test.ts.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ TEST-ONLY, like fake-mesh.ts: no provider imports this file and it is not on the barrel.
|
|
7
|
+
*/
|
|
8
|
+
import { apply } from 'alchemy/Apply';
|
|
9
|
+
import * as Plan from 'alchemy/Plan';
|
|
10
|
+
import { type CompiledStack, make as makeStack } from 'alchemy/Stack';
|
|
11
|
+
import { Stage } from 'alchemy/Stage';
|
|
12
|
+
import * as State from 'alchemy/State';
|
|
13
|
+
import * as Cause from 'effect/Cause';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import * as Exit from 'effect/Exit';
|
|
16
|
+
import * as Layer from 'effect/Layer';
|
|
17
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
18
|
+
import { type FakeMesh, fakeProviderLayer } from './fake-mesh.ts';
|
|
19
|
+
import { MeshNode, MeshNodeProvider } from './mesh-node.ts';
|
|
20
|
+
import type { MeshNodeAttributes, MeshNodeProps } from './mesh-node-form.ts';
|
|
21
|
+
|
|
22
|
+
export const ids = { fqn: 'stack/door', id: 'door', instanceId: 'i-1' };
|
|
23
|
+
export const extra = { bindings: [] as never, session: undefined as never };
|
|
24
|
+
|
|
25
|
+
export type P = Effect.Success<typeof MeshNode.Provider>;
|
|
26
|
+
|
|
27
|
+
export const handler = <F>(name: string, fn: F | undefined): F => {
|
|
28
|
+
if (fn === undefined) throw new Error(`provider has no ${name} handler`);
|
|
29
|
+
return fn;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Build the provider over the fake (as `accountId`'s environment) and hand it to `use`. */
|
|
33
|
+
export const run = <A, E>(fake: FakeMesh, use: (p: P) => Effect.Effect<A, E>, accountId?: string) =>
|
|
34
|
+
Effect.runPromise(
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
return yield* use(yield* MeshNode.Provider);
|
|
37
|
+
}).pipe(
|
|
38
|
+
Effect.provide(
|
|
39
|
+
MeshNodeProvider().pipe(Layer.provideMerge(fakeProviderLayer(fake, accountId))),
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
export const reconcile = (p: P, news: MeshNodeProps, output?: MeshNodeAttributes) =>
|
|
45
|
+
p.reconcile({ ...ids, ...extra, news, olds: undefined, output });
|
|
46
|
+
|
|
47
|
+
export const read = (p: P, olds: MeshNodeProps, output?: MeshNodeAttributes) =>
|
|
48
|
+
handler('read', p.read)({ ...ids, olds, output });
|
|
49
|
+
|
|
50
|
+
export const diff = (p: P, news: MeshNodeProps, olds: MeshNodeProps, output: MeshNodeAttributes) =>
|
|
51
|
+
handler(
|
|
52
|
+
'diff',
|
|
53
|
+
p.diff,
|
|
54
|
+
)({
|
|
55
|
+
...ids,
|
|
56
|
+
news,
|
|
57
|
+
olds,
|
|
58
|
+
newBindings: [] as never,
|
|
59
|
+
oldBindings: [] as never,
|
|
60
|
+
output,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** A stored attribute set for a node that may or may not exist in the fake. */
|
|
64
|
+
export const stored = (
|
|
65
|
+
accountId: string,
|
|
66
|
+
over: Partial<MeshNodeAttributes> = {},
|
|
67
|
+
): MeshNodeAttributes => ({
|
|
68
|
+
id: 'x',
|
|
69
|
+
accountId,
|
|
70
|
+
name: 'door-a',
|
|
71
|
+
status: undefined,
|
|
72
|
+
ha: false,
|
|
73
|
+
...over,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const tokenReads = (fake: FakeMesh) =>
|
|
77
|
+
fake.seen.filter((s) => s.path.endsWith('/token')).length;
|
|
78
|
+
|
|
79
|
+
/** The writes the fake saw, in order: `['POST', 'DELETE', …]`. */
|
|
80
|
+
export const writes = (fake: FakeMesh) =>
|
|
81
|
+
fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method);
|
|
82
|
+
|
|
83
|
+
type Rows = Record<string, Record<string, Record<string, State.ResourceState>>>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* ★ THE REAL ENGINE, NOT A MODEL OF IT: Alchemy's own `Plan.make` + `apply` over an in-memory
|
|
87
|
+
* state store, which is what its Test harness's scratch stacks run (Test/Core.ts
|
|
88
|
+
* `scratchStack`), minus the profile, config and telemetry layers a fake Cloudflare does not
|
|
89
|
+
* need. Removal policy lives in the engine, so only this can show what it does to a replace.
|
|
90
|
+
* ⚠️ `FetchHttpClient.Fetch` IS READ PER REQUEST, from the fiber running the engine, so it is
|
|
91
|
+
* provided around the whole deploy. Provided only under the provider layer, the requests went
|
|
92
|
+
* to the real `fetch` (measured 2026-09-21: ENOTFOUND api.example.com, the RFC 2606 host, so
|
|
93
|
+
* no API answered — but a real DNS lookup went out).
|
|
94
|
+
*/
|
|
95
|
+
export const engine = (fake: FakeMesh) => {
|
|
96
|
+
const rows: Rows = {};
|
|
97
|
+
const state = Layer.succeed(State.State, State.InMemoryService(rows));
|
|
98
|
+
const providers = MeshNodeProvider().pipe(Layer.provide(fakeProviderLayer(fake)));
|
|
99
|
+
const deploy = <A, E, R>(declare: Effect.Effect<A, E, R>) =>
|
|
100
|
+
Effect.runPromiseExit(
|
|
101
|
+
declare.pipe(
|
|
102
|
+
makeStack({ name: 'mesh', providers, state }) as never,
|
|
103
|
+
Effect.flatMap((compiled: CompiledStack) =>
|
|
104
|
+
Plan.make(compiled).pipe(Effect.flatMap(apply), Effect.provide(compiled.services)),
|
|
105
|
+
),
|
|
106
|
+
Effect.provide(Layer.succeed(Stage, 'test')),
|
|
107
|
+
Effect.scoped,
|
|
108
|
+
Effect.provideService(FetchHttpClient.Fetch, fake.fetch),
|
|
109
|
+
) as Effect.Effect<unknown, unknown>,
|
|
110
|
+
);
|
|
111
|
+
return {
|
|
112
|
+
deploy,
|
|
113
|
+
/** The stored row's status (`created`, `replacing`, …), or `undefined` when there is none. */
|
|
114
|
+
status: (fqn: string) => rows['mesh']?.['test']?.[fqn]?.status,
|
|
115
|
+
/** ★ What `alchemy state rm` does ("Delete state records without deleting cloud resources"). */
|
|
116
|
+
forget: (fqn: string) => {
|
|
117
|
+
delete rows['mesh']?.['test']?.[fqn];
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** The failure sentence of a deploy, or `''` when it succeeded. */
|
|
123
|
+
export const failureOf = (exit: Exit.Exit<unknown, unknown>) =>
|
|
124
|
+
Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : '';
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read / reconcile / delete for `Cloudflare.MeshNode`, as plain Effects over the SDK calls in
|
|
3
|
+
* mesh-node-api.ts. mesh-node.ts only wires them into Alchemy.
|
|
4
|
+
*/
|
|
5
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import {
|
|
8
|
+
MeshNodeError,
|
|
9
|
+
type ObservedNode,
|
|
10
|
+
createNode,
|
|
11
|
+
deleteNode,
|
|
12
|
+
findNodeByName,
|
|
13
|
+
getNode,
|
|
14
|
+
renameNode,
|
|
15
|
+
} from './mesh-node-api.ts';
|
|
16
|
+
import { type MeshNodeAttributes, type MeshNodeProps, validateMeshNode } from './mesh-node-form.ts';
|
|
17
|
+
|
|
18
|
+
const attributesOf = (node: ObservedNode, accountId: string, ha: boolean): MeshNodeAttributes => ({
|
|
19
|
+
id: node.id,
|
|
20
|
+
accountId,
|
|
21
|
+
name: node.name,
|
|
22
|
+
status: node.status,
|
|
23
|
+
ha,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Owned: refresh by the stored id. Cold: find the live node by exact name and hand it back
|
|
28
|
+
* `Unowned`, so Alchemy refuses to take it over until the stack says `adopt(true)` — the same
|
|
29
|
+
* gate `Cloudflare.Tunnel.WarpConnector` uses, because a `warp_connector` carries no ownership
|
|
30
|
+
* marker to prove it was ours.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ ADOPTION RECORDS THE DECLARED `ha`, BECAUSE NOTHING ELSE CAN. No documented read returns a
|
|
33
|
+
* node's HA flag. Adopting an existing node with the wrong `ha` records the wrong value, and no
|
|
34
|
+
* later plan can notice. Check the node's HA badge in the dashboard before adopting.
|
|
35
|
+
*/
|
|
36
|
+
export const readMeshNode = (
|
|
37
|
+
accountId: string,
|
|
38
|
+
olds: MeshNodeProps | undefined,
|
|
39
|
+
output: MeshNodeAttributes | undefined,
|
|
40
|
+
) =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const account = output?.accountId ?? accountId;
|
|
43
|
+
if (output !== undefined) {
|
|
44
|
+
const node = yield* getNode(account, output.id);
|
|
45
|
+
if (node !== undefined) return attributesOf(node, account, output.ha);
|
|
46
|
+
}
|
|
47
|
+
const name = olds?.name ?? output?.name;
|
|
48
|
+
const ha = olds?.ha ?? output?.ha;
|
|
49
|
+
if (name === undefined || ha === undefined) return undefined;
|
|
50
|
+
const match = yield* findNodeByName(account, name);
|
|
51
|
+
return match === undefined ? undefined : Unowned(attributesOf(match, account, ha));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ⛔ NEVER CONVERGE ON A NODE THIS RESOURCE DID NOT FIND BY ITS OWN ID. Alchemy's WarpConnector
|
|
56
|
+
* answers `DuplicateTunnelName` by re-reading the node that holds the name and returning it.
|
|
57
|
+
* Here that would be wrong in exactly the case that matters: an `ha` replace whose old node is
|
|
58
|
+
* still alive (the default `retain` skips the delete-first teardown, mesh-node.ts) would
|
|
59
|
+
* "create" by silently re-using the old node and record the new `ha` on it. Adoption belongs to
|
|
60
|
+
* `read` and `adopt(true)`. An interrupted create is recovered through `read` too, which returns
|
|
61
|
+
* the node `Unowned`, so the engine asks for `adopt(true)` there as well (as for WarpConnector).
|
|
62
|
+
*/
|
|
63
|
+
const nameTaken = (name: string, holder: string) =>
|
|
64
|
+
new MeshNodeError({
|
|
65
|
+
message:
|
|
66
|
+
`A tunnel named "${name}" already exists (${holder}). Names are unique per account, and ` +
|
|
67
|
+
'this resource never takes over a node it did not create by its own id.',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* ★ HOW TO TAKE A NODE OVER WHILE THIS RESOURCE HAS A STATE ROW, WRITTEN ONCE. The plan asks
|
|
72
|
+
* `read` to adopt only for a resource with NO row (Plan.ts, `oldState === undefined`) or for an
|
|
73
|
+
* interrupted create's `creating` row. A replace's `replacing` row gets neither, so `adopt(true)`
|
|
74
|
+
* alone changes nothing there and the create refuses again (measured in mesh-node-policy.test.ts).
|
|
75
|
+
* `alchemy state rm` is Alchemy's "Delete state records without deleting cloud resources".
|
|
76
|
+
*/
|
|
77
|
+
const REOWN =
|
|
78
|
+
"drop this resource's state row (alchemy state rm, which leaves the node alone), then deploy " +
|
|
79
|
+
'under adopt(true)';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 🔴 THE REFUSAL AN `ha` CHANGE MEETS UNDER THE DEFAULT, SO IT NAMES EVERY WAY ON. The plan says
|
|
83
|
+
* `replace` (delete-first), the engine skips that delete because of `retain`, and the old node
|
|
84
|
+
* still holds the name here. A node made outside the stack is caught earlier, by the plan's
|
|
85
|
+
* adoption probe in `read`.
|
|
86
|
+
* ⛔ BUT THE HOLDER IS NOT ALWAYS THE OLD NODE, AND `create` CANNOT TELL: the engine passes it no
|
|
87
|
+
* `olds` (Apply.ts, the replace branch). An `ha` + `name` change is a create-first replace, and
|
|
88
|
+
* there the holder of the NEW name is some other node, which `RemovalPolicy.destroy()` never
|
|
89
|
+
* touches. So the old-node advice is conditional, and the other case says not to delete it: a
|
|
90
|
+
* sentence that called any holder "the old node" would send an operator to delete a live node
|
|
91
|
+
* and cut its replicas off the Mesh (measured 2026-09-21 in mesh-node-policy.test.ts).
|
|
92
|
+
* ⚠️ REVERTING `ha` DOES NOT UNDO IT. The failed deploy leaves a `replacing` row, and the next
|
|
93
|
+
* plan resumes that replace (Plan.ts, the `replacing` branch), so it refuses again. Dropping the
|
|
94
|
+
* row and adopting is the way back. All measured in mesh-node-policy.test.ts.
|
|
95
|
+
*/
|
|
96
|
+
const oldNodeHoldsName = (name: string, holder: string) =>
|
|
97
|
+
new MeshNodeError({
|
|
98
|
+
message:
|
|
99
|
+
`Mesh node "${name}" already exists (${holder}) and is not the node this resource tracks, ` +
|
|
100
|
+
'so it is left alone: names are unique per account. If this deploy changed `ha` and kept ' +
|
|
101
|
+
"the name, it is this resource's old node, kept by MeshNode's default " +
|
|
102
|
+
'RemovalPolicy.retain because HA cannot change in place. To replace it (a new id, token and ' +
|
|
103
|
+
'Mesh IP per replica), deploy once with .pipe(RemovalPolicy.destroy()), which deletes it ' +
|
|
104
|
+
`first, or delete it by hand. To keep it, reverting \`ha\` is not enough: ${REOWN}, with ` +
|
|
105
|
+
'the original `ha`. Otherwise it is another node: do not delete it. Choose another name, ' +
|
|
106
|
+
`or, if it is meant to be this one, check its HA badge, then ${REOWN}.`,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 🔴 A 1013 AFTER A CLEAN LOOKUP IS USUALLY THIS DEPLOY'S OWN NODE. distilled retries a POST on
|
|
111
|
+
* a transport error or a 5xx (core `isTransientError`, measured in its source 2026-09-21), so a
|
|
112
|
+
* create that succeeded but lost its response is sent again and answered 1013 by the node the
|
|
113
|
+
* first attempt made. The lookup is repeated so the sentence names that node instead of blaming
|
|
114
|
+
* another tunnel type; it is still not adopted here (see above), because a concurrent creator
|
|
115
|
+
* looks the same and its `ha` is unknown.
|
|
116
|
+
* ⚠️ `adopt(true)` ALONE ONLY RECOVERS A FIRST CREATE. Inside a replace (any `ha` change, either
|
|
117
|
+
* order) the row is `replacing`, which the plan never probes, so the sentence names `REOWN` for
|
|
118
|
+
* that case (measured in mesh-node-policy.test.ts).
|
|
119
|
+
*/
|
|
120
|
+
const raced = (accountId: string, name: string) =>
|
|
121
|
+
findNodeByName(accountId, name).pipe(
|
|
122
|
+
Effect.flatMap((holder) =>
|
|
123
|
+
Effect.fail(
|
|
124
|
+
holder === undefined
|
|
125
|
+
? // ⚠️ No live warp_connector of that name: another tunnel TYPE holds it (a cloudflared
|
|
126
|
+
// tunnel), or — UNMEASURED — a node deleted moments ago (a delete-first `ha`
|
|
127
|
+
// replace) still reserves it.
|
|
128
|
+
nameTaken(name, 'not a live Mesh node: another tunnel type, or a just-deleted node')
|
|
129
|
+
: new MeshNodeError({
|
|
130
|
+
message:
|
|
131
|
+
`Mesh node "${name}" (${holder.id}) appeared between this deploy's lookup and its ` +
|
|
132
|
+
'create. The likeliest cause is this deploy itself: the SDK retries a create whose ' +
|
|
133
|
+
'response was lost. If so, check its HA badge matches the declared `ha`, then ' +
|
|
134
|
+
'adopt it: adopt(true) after a first create, but after a replace ' +
|
|
135
|
+
`${REOWN}, because adopt(true) alone does not act on a replace's row. ` +
|
|
136
|
+
'Otherwise another creator raced this one.',
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const createFresh = (accountId: string, news: MeshNodeProps) =>
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
const existing = yield* findNodeByName(accountId, news.name);
|
|
145
|
+
if (existing !== undefined) return yield* Effect.fail(oldNodeHoldsName(news.name, existing.id));
|
|
146
|
+
const node = yield* createNode(accountId, news.name, news.ha).pipe(
|
|
147
|
+
Effect.catchTag('DuplicateTunnelName', () => raced(accountId, news.name)),
|
|
148
|
+
);
|
|
149
|
+
return attributesOf(node, accountId, news.ha);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export const reconcileMeshNode = (
|
|
153
|
+
accountId: string,
|
|
154
|
+
news: MeshNodeProps,
|
|
155
|
+
output: MeshNodeAttributes | undefined,
|
|
156
|
+
) =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
const invalid = validateMeshNode(news);
|
|
159
|
+
if (invalid !== undefined) return yield* Effect.fail(invalid);
|
|
160
|
+
const owned = output !== undefined && output.accountId === accountId ? output : undefined;
|
|
161
|
+
const live = owned === undefined ? undefined : yield* getNode(accountId, owned.id);
|
|
162
|
+
// ★ Gone out of band (or a replace's new generation): create, under the same refusals.
|
|
163
|
+
if (owned === undefined || live === undefined) return yield* createFresh(accountId, news);
|
|
164
|
+
if (owned.ha !== news.ha) {
|
|
165
|
+
// ⛔ The plan would have said `replace`; it can only reach here with `ha` unresolved then.
|
|
166
|
+
return yield* Effect.fail(
|
|
167
|
+
new MeshNodeError({
|
|
168
|
+
message: `MeshNode "${news.name}": \`ha\` is create-only (stored ${owned.ha}, declared ${news.ha}). Plan again so the change is a replace.`,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (live.name === news.name) return attributesOf(live, accountId, owned.ha);
|
|
173
|
+
const renamed = yield* renameNode(accountId, live.id, news.name).pipe(
|
|
174
|
+
Effect.catchTag('DuplicateTunnelName', () =>
|
|
175
|
+
Effect.fail(nameTaken(news.name, 'held by another tunnel')),
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
return attributesOf(renamed, accountId, owned.ha);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* ⚠️ Cloudflare may refuse to delete a node while replicas are connected (Alchemy's WarpConnector
|
|
183
|
+
* notes "Deleting requires all connector sessions to be down"; unmeasured here). Stop the
|
|
184
|
+
* client on every replica first. A node already gone is a successful delete.
|
|
185
|
+
*/
|
|
186
|
+
export const deleteMeshNode = (output: MeshNodeAttributes) =>
|
|
187
|
+
deleteNode(output.accountId, output.id);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeshNode's removal policy, through Alchemy's real plan/apply (`engine` in mesh-node-harness.ts)
|
|
3
|
+
* over the fake Cloudflare. The policy lives in the engine, not the provider, so the handler-level
|
|
4
|
+
* tests beside this one cannot see it.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ WHAT THESE PIN: `retain` is the default (a dropped declaration deletes nothing), a same-name
|
|
7
|
+
* `ha` change refuses and writes nothing until the deploy opts in with
|
|
8
|
+
* `RemovalPolicy.destroy()`, and the sentence's way back (drop the row, adopt) works while a
|
|
9
|
+
* plain revert does not. Deleting `defaultRemovalPolicy`, or turning `deleteFirst` off in
|
|
10
|
+
* mesh-node-form.ts, fails at least one test here.
|
|
11
|
+
* ⛔ AND THE SENTENCES NEVER SEND AN OPERATOR THE WRONG WAY: a node holding a create-first
|
|
12
|
+
* replace's NEW name is not called the old node, and `adopt(true)` alone, which does nothing to
|
|
13
|
+
* a `replacing` row, is never offered as the way back from inside a replace.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
17
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import { fakeFailure, fakeMesh } from './fake-mesh.ts';
|
|
20
|
+
import { engine, failureOf, writes } from './mesh-node-harness.ts';
|
|
21
|
+
import { MeshNode } from './mesh-node.ts';
|
|
22
|
+
|
|
23
|
+
/** The logical id every test declares; at the stack root it is also the state row's fqn. */
|
|
24
|
+
const DOOR = 'door';
|
|
25
|
+
const door = (ha: boolean, name = 'door-a') => MeshNode(DOOR, { name, ha });
|
|
26
|
+
const liveNodes = (fake: ReturnType<typeof fakeMesh>) =>
|
|
27
|
+
fake.live().map((node) => ({ id: node.id, name: node.name, ha: node.ha }));
|
|
28
|
+
|
|
29
|
+
describe('MeshNode removal policy (the real engine)', () => {
|
|
30
|
+
test('retain is the default: dropping the declaration leaves the node and sends no DELETE', async () => {
|
|
31
|
+
const fake = fakeMesh();
|
|
32
|
+
const stack = engine(fake);
|
|
33
|
+
expect(failureOf(await stack.deploy(door(false)))).toBe('');
|
|
34
|
+
expect(failureOf(await stack.deploy(Effect.void))).toBe('');
|
|
35
|
+
expect(stack.status(DOOR)).toBeUndefined();
|
|
36
|
+
expect(liveNodes(fake)).toHaveLength(1);
|
|
37
|
+
expect(writes(fake)).toEqual(['POST']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('RemovalPolicy.destroy() still reaches the implemented delete', async () => {
|
|
41
|
+
const fake = fakeMesh();
|
|
42
|
+
const stack = engine(fake);
|
|
43
|
+
await stack.deploy(door(false).pipe(RemovalPolicy.destroy()));
|
|
44
|
+
expect(failureOf(await stack.deploy(Effect.void))).toBe('');
|
|
45
|
+
expect(liveNodes(fake)).toHaveLength(0);
|
|
46
|
+
expect(writes(fake)).toEqual(['POST', 'DELETE']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('an `ha` change under the default refuses, writes nothing, and names the opt-in', async () => {
|
|
50
|
+
const fake = fakeMesh();
|
|
51
|
+
const stack = engine(fake);
|
|
52
|
+
await stack.deploy(door(false));
|
|
53
|
+
const [before] = liveNodes(fake);
|
|
54
|
+
const failure = failureOf(await stack.deploy(door(true)));
|
|
55
|
+
expect(failure).toContain(`"door-a" already exists (${before?.id})`);
|
|
56
|
+
expect(failure).toContain('RemovalPolicy.retain');
|
|
57
|
+
expect(failure).toContain('.pipe(RemovalPolicy.destroy())');
|
|
58
|
+
expect(failure).toContain('reverting `ha` is not enough');
|
|
59
|
+
expect(liveNodes(fake)).toEqual([{ id: before?.id ?? '', name: 'door-a', ha: false }]);
|
|
60
|
+
expect(writes(fake)).toEqual(['POST']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('opting in with RemovalPolicy.destroy() completes it delete-first, then retain is back', async () => {
|
|
64
|
+
const fake = fakeMesh();
|
|
65
|
+
const stack = engine(fake);
|
|
66
|
+
await stack.deploy(door(false));
|
|
67
|
+
await stack.deploy(door(true));
|
|
68
|
+
const done = await stack.deploy(door(true).pipe(RemovalPolicy.destroy()));
|
|
69
|
+
expect(failureOf(done)).toBe('');
|
|
70
|
+
expect(writes(fake)).toEqual(['POST', 'DELETE', 'POST']);
|
|
71
|
+
expect(liveNodes(fake)).toMatchObject([{ name: 'door-a', ha: true }]);
|
|
72
|
+
// ★ The opt-in lasts one deploy: without it the row is re-committed as retain.
|
|
73
|
+
await stack.deploy(door(true));
|
|
74
|
+
await stack.deploy(Effect.void);
|
|
75
|
+
expect(liveNodes(fake)).toHaveLength(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('reverting `ha` does not undo a refused replace; dropping the row and adopting does', async () => {
|
|
79
|
+
const fake = fakeMesh();
|
|
80
|
+
const stack = engine(fake);
|
|
81
|
+
await stack.deploy(door(false));
|
|
82
|
+
const [original] = liveNodes(fake);
|
|
83
|
+
await stack.deploy(door(true));
|
|
84
|
+
expect(stack.status(DOOR)).toBe('replacing');
|
|
85
|
+
expect(failureOf(await stack.deploy(door(false)))).toContain('already exists');
|
|
86
|
+
|
|
87
|
+
stack.forget(DOOR);
|
|
88
|
+
const back = await stack.deploy(door(false).pipe(adopt(true)));
|
|
89
|
+
expect(failureOf(back)).toBe('');
|
|
90
|
+
expect(stack.status(DOOR)).not.toBe('replacing');
|
|
91
|
+
expect(liveNodes(fake)).toEqual([{ id: original?.id ?? '', name: 'door-a', ha: false }]);
|
|
92
|
+
expect(writes(fake)).toEqual(['POST']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('`ha` and `name` together replace create-first, and the old node stays live', async () => {
|
|
96
|
+
const fake = fakeMesh();
|
|
97
|
+
const stack = engine(fake);
|
|
98
|
+
await stack.deploy(door(false));
|
|
99
|
+
expect(failureOf(await stack.deploy(door(true, 'door-b')))).toBe('');
|
|
100
|
+
expect(liveNodes(fake).map((node) => [node.name, node.ha])).toEqual([
|
|
101
|
+
['door-a', false],
|
|
102
|
+
['door-b', true],
|
|
103
|
+
]);
|
|
104
|
+
expect(writes(fake)).toEqual(['POST', 'POST']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('`ha` and `name` into a name another node holds: not called the old node, and nothing deletes it', async () => {
|
|
108
|
+
const fake = fakeMesh();
|
|
109
|
+
const stack = engine(fake);
|
|
110
|
+
await stack.deploy(door(false));
|
|
111
|
+
const other = fake.seed({ name: 'door-b', ha: false });
|
|
112
|
+
const refused = failureOf(await stack.deploy(door(true, 'door-b')));
|
|
113
|
+
expect(refused).toContain(`"door-b" already exists (${other.id})`);
|
|
114
|
+
expect(refused).toContain('If this deploy changed `ha` and kept the name');
|
|
115
|
+
expect(refused).toContain('Otherwise it is another node: do not delete it.');
|
|
116
|
+
// ★ destroy() reaches only the old generation, never the name holder: refused again.
|
|
117
|
+
const optedIn = door(true, 'door-b').pipe(RemovalPolicy.destroy());
|
|
118
|
+
expect(failureOf(await stack.deploy(optedIn))).toContain('already exists');
|
|
119
|
+
// ⚠️ adopt(true) alone does not act on the `replacing` row either.
|
|
120
|
+
const adopted = door(true, 'door-b').pipe(adopt(true));
|
|
121
|
+
expect(failureOf(await stack.deploy(adopted))).toContain('already exists');
|
|
122
|
+
expect(liveNodes(fake).map((node) => node.name)).toEqual(['door-a', 'door-b']);
|
|
123
|
+
expect(writes(fake)).toEqual(['POST']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a lost-response retry inside an opted-in replace names state rm, which adopt(true) alone is not', async () => {
|
|
127
|
+
let lose = false;
|
|
128
|
+
const fake = fakeMesh({
|
|
129
|
+
onCreate: (name) => {
|
|
130
|
+
if (!lose) return undefined;
|
|
131
|
+
lose = false;
|
|
132
|
+
// What a retried POST meets: the node its first, lost-response attempt created.
|
|
133
|
+
fake.seed({ name, ha: true });
|
|
134
|
+
return fakeFailure(409, 1013, 'Tunnel with name already exists');
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
const stack = engine(fake);
|
|
138
|
+
await stack.deploy(door(false));
|
|
139
|
+
lose = true;
|
|
140
|
+
const raced = failureOf(await stack.deploy(door(true).pipe(RemovalPolicy.destroy())));
|
|
141
|
+
expect(raced).toContain('appeared between');
|
|
142
|
+
expect(raced).toContain("after a replace drop this resource's state row (alchemy state rm");
|
|
143
|
+
expect(stack.status(DOOR)).toBe('replacing');
|
|
144
|
+
expect(failureOf(await stack.deploy(door(true).pipe(adopt(true))))).toContain('already exists');
|
|
145
|
+
|
|
146
|
+
stack.forget(DOOR);
|
|
147
|
+
expect(failureOf(await stack.deploy(door(true).pipe(adopt(true))))).toBe('');
|
|
148
|
+
expect(liveNodes(fake)).toMatchObject([{ name: 'door-a', ha: true }]);
|
|
149
|
+
expect(writes(fake)).toEqual(['POST', 'DELETE', 'POST']);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetchMeshNodeToken against the fake Cloudflare, provided the way a Bun `mesh-enroll` script
|
|
3
|
+
* provides it: distilled Credentials from an API token, plus FetchHttpClient.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ The value comes back `Redacted`: printing it, logging it or serialising it shows
|
|
6
|
+
* `<redacted>`. Only `Redacted.value` yields the token, and only the write to the host's 0600
|
|
7
|
+
* file should call it.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import {
|
|
11
|
+
type Credentials,
|
|
12
|
+
fromApiKey,
|
|
13
|
+
fromApiToken,
|
|
14
|
+
} from '@distilled.cloud/cloudflare/Credentials';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import type * as Layer from 'effect/Layer';
|
|
17
|
+
import * as Redacted from 'effect/Redacted';
|
|
18
|
+
import { FAKE_ACCOUNT, FAKE_BASE, type FakeMesh, fakeClientLayer, fakeMesh } from './fake-mesh.ts';
|
|
19
|
+
import { type MeshNodeRef, fetchMeshNodeToken } from './mesh-node-token.ts';
|
|
20
|
+
|
|
21
|
+
const token = (fake: FakeMesh, ref: MeshNodeRef) =>
|
|
22
|
+
Effect.runPromise(fetchMeshNodeToken(ref).pipe(Effect.provide(fakeClientLayer(fake))));
|
|
23
|
+
|
|
24
|
+
const failure = (fake: FakeMesh, ref: MeshNodeRef, credentials?: Layer.Layer<Credentials>) =>
|
|
25
|
+
Effect.runPromise(
|
|
26
|
+
Effect.flip(fetchMeshNodeToken(ref)).pipe(Effect.provide(fakeClientLayer(fake, credentials))),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
describe('fetchMeshNodeToken', () => {
|
|
30
|
+
test('by id: the token, redacted everywhere but Redacted.value', async () => {
|
|
31
|
+
const fake = fakeMesh();
|
|
32
|
+
const node = fake.seed({ name: 'door-a' });
|
|
33
|
+
const value = await token(fake, { accountId: FAKE_ACCOUNT, id: node.id });
|
|
34
|
+
expect(Redacted.value(value)).toBe(node.token);
|
|
35
|
+
expect(String(value)).not.toContain(node.token);
|
|
36
|
+
expect(JSON.stringify({ value })).not.toContain(node.token);
|
|
37
|
+
expect(fake.seen.map((s) => s.path)).toEqual([
|
|
38
|
+
`/client/v4/accounts/${FAKE_ACCOUNT}/warp_connector/${node.id}/token`,
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('by name: resolved by exact match first', async () => {
|
|
43
|
+
const fake = fakeMesh();
|
|
44
|
+
fake.seed({ name: 'door-a-2' });
|
|
45
|
+
const node = fake.seed({ name: 'door-a' });
|
|
46
|
+
const value = await token(fake, { accountId: FAKE_ACCOUNT, name: 'door-a' });
|
|
47
|
+
expect(Redacted.value(value)).toBe(node.token);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('an unknown node fails with a sentence naming it', async () => {
|
|
51
|
+
const fake = fakeMesh();
|
|
52
|
+
const missing = '00000000-0000-4000-8000-00000000ffff';
|
|
53
|
+
expect((await failure(fake, { accountId: FAKE_ACCOUNT, id: missing })).message).toContain(
|
|
54
|
+
missing,
|
|
55
|
+
);
|
|
56
|
+
expect((await failure(fake, { accountId: FAKE_ACCOUNT, name: 'nope' })).message).toContain(
|
|
57
|
+
'No live Mesh node named "nope"',
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an empty token fails closed rather than enrolling nothing', async () => {
|
|
62
|
+
const fake = fakeMesh();
|
|
63
|
+
const node = fake.seed({ name: 'door-a', token: '' });
|
|
64
|
+
const error = await failure(fake, { accountId: FAKE_ACCOUNT, id: node.id });
|
|
65
|
+
expect(error.message).toContain('empty token');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a 403 names the Write permission the endpoint needs', async () => {
|
|
69
|
+
const fake = fakeMesh();
|
|
70
|
+
const node = fake.seed({ name: 'door-a' });
|
|
71
|
+
const denying: FakeMesh = {
|
|
72
|
+
...fake,
|
|
73
|
+
fetch: (async () =>
|
|
74
|
+
Response.json(
|
|
75
|
+
{
|
|
76
|
+
success: false,
|
|
77
|
+
errors: [{ code: 9109, message: 'Unauthorized to access requested resource' }],
|
|
78
|
+
},
|
|
79
|
+
{ status: 403 },
|
|
80
|
+
)) as unknown as typeof globalThis.fetch,
|
|
81
|
+
};
|
|
82
|
+
const error = await failure(denying, { accountId: FAKE_ACCOUNT, id: node.id });
|
|
83
|
+
expect(error.message).toContain('Cloudflare One Connectors Write');
|
|
84
|
+
expect(error.message).toContain(FAKE_ACCOUNT);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('refused before any request while DISTILLED_DEBUG_HTTP would print the token', async () => {
|
|
88
|
+
// ⛔ distilled console.errors the first 400 bytes of every response body when this is set.
|
|
89
|
+
const fake = fakeMesh();
|
|
90
|
+
const node = fake.seed({ name: 'door-a' });
|
|
91
|
+
const before = process.env['DISTILLED_DEBUG_HTTP'];
|
|
92
|
+
process.env['DISTILLED_DEBUG_HTTP'] = '1';
|
|
93
|
+
try {
|
|
94
|
+
const error = await failure(fake, { accountId: FAKE_ACCOUNT, id: node.id });
|
|
95
|
+
expect(error.message).toContain('DISTILLED_DEBUG_HTTP');
|
|
96
|
+
} finally {
|
|
97
|
+
if (before === undefined) delete process.env['DISTILLED_DEBUG_HTTP'];
|
|
98
|
+
else process.env['DISTILLED_DEBUG_HTTP'] = before;
|
|
99
|
+
}
|
|
100
|
+
expect(fake.seen).toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the Global API Key and an empty API token are refused before any request', async () => {
|
|
104
|
+
const fake = fakeMesh();
|
|
105
|
+
const node = fake.seed({ name: 'door-a' });
|
|
106
|
+
const ref = { accountId: FAKE_ACCOUNT, id: node.id };
|
|
107
|
+
const globalKey = fromApiKey({
|
|
108
|
+
apiKey: 'placeholder-global-key',
|
|
109
|
+
email: 'ops@example.com',
|
|
110
|
+
apiBaseUrl: FAKE_BASE,
|
|
111
|
+
});
|
|
112
|
+
expect((await failure(fake, ref, globalKey)).message).toContain('Global API Key');
|
|
113
|
+
const empty = fromApiToken({ apiToken: '', apiBaseUrl: FAKE_BASE });
|
|
114
|
+
expect((await failure(fake, ref, empty)).message).toContain('EMPTY');
|
|
115
|
+
expect(fake.seen).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
});
|