@homeflare/alchemy 0.6.0 → 0.8.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 +74 -41
- 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 +91 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +34 -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 +54 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +22 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +970 -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 +51 -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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +16 -12
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +36 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.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.map +1 -1
- package/dist/openbao/index.js +393 -234
- package/dist/openbao/index.js.map +23 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.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/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +107 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +199 -0
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/mesh-node.md +188 -0
- package/docs/peers.md +39 -0
- package/package.json +7 -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/adopt-scope.test.ts +82 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +196 -0
- package/src/caddy/config-lifecycle.ts +239 -0
- package/src/caddy/config.ts +153 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +179 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +26 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +229 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +66 -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 +1 -0
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/secret-tripwire.ts +1 -1
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +126 -22
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +22 -8
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +126 -0
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- 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 +52 -36
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +166 -0
- package/src/openbao/role-rename.test.ts +205 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetchMeshNodeToken` — a Mesh node's enrolment token, fetched on demand for a one-off Bun
|
|
3
|
+
* `mesh-enroll` step. ⛔ Not a Resource, and never an attribute: see mesh-node-form.ts for why
|
|
4
|
+
* the token must stay out of Alchemy state.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ WHERE THE VALUE MAY GO. Straight into a root-owned `0600` file on the node's host, which the
|
|
7
|
+
* enrolment reads (`warp-cli --accept-tos connector new <token>`, per the Mesh get-started page,
|
|
8
|
+
* read 2026-09-21). Nowhere else: not Alchemy state, not a secrets store (a runtime credential
|
|
9
|
+
* belongs to the host that runs it), not a log line, not a CI variable. It is returned
|
|
10
|
+
* `Redacted`, so an accidental `console.log` or `Effect.log` prints `<redacted>`; call
|
|
11
|
+
* `Redacted.value` only at the write.
|
|
12
|
+
* ⚠️ `warp-cli` takes the token as an ARGUMENT, so it is visible in the host's process list for
|
|
13
|
+
* the life of that command. Run the enrolment as root on the node itself, never over a shell
|
|
14
|
+
* whose history is kept.
|
|
15
|
+
* ⚠️ THE ENDPOINT NEEDS A WRITE PERMISSION. Its API reference (read 2026-09-21) accepts
|
|
16
|
+
* "Cloudflare One Connectors Write", "Cloudflare One Connector: cloudflared Write" or
|
|
17
|
+
* "Cloudflare Tunnel Write"; the Mesh get-started guide also names "Cloudflare One Connector:
|
|
18
|
+
* WARP Write". Connectors Write is in both lists. Mint a short-lived token for this step alone;
|
|
19
|
+
* a read-only plan token gets a 403 here.
|
|
20
|
+
*
|
|
21
|
+
* ★ Requires distilled `Credentials` and an `HttpClient`, not Alchemy's CloudflareEnvironment: a
|
|
22
|
+
* Bun script has no Alchemy profile, so the account id is an argument. Provide
|
|
23
|
+
* `Credentials.fromApiToken({ apiToken })` (from `@distilled.cloud/cloudflare/Credentials`) and
|
|
24
|
+
* `FetchHttpClient.layer`. ⛔ Not `Credentials.fromEnv()`: it falls back to the Global API Key
|
|
25
|
+
* (`CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`) when no token is set — and `refuseUnsafe` below
|
|
26
|
+
* refuses that key rather than trusting the caller to have read this.
|
|
27
|
+
*/
|
|
28
|
+
import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
|
|
29
|
+
import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
|
|
30
|
+
import * as Effect from 'effect/Effect';
|
|
31
|
+
import * as Redacted from 'effect/Redacted';
|
|
32
|
+
import { MeshNodeError, findNodeByName } from './mesh-node-api.ts';
|
|
33
|
+
|
|
34
|
+
/** Which node: by id (the `MeshNode` attribute) or by its exact name. */
|
|
35
|
+
export type MeshNodeRef = { readonly accountId: string } & (
|
|
36
|
+
| { readonly id: string; readonly name?: undefined }
|
|
37
|
+
| { readonly name: string; readonly id?: undefined }
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ⛔ REFUSED BEFORE ANY REQUEST IS SENT:
|
|
42
|
+
* - `DISTILLED_DEBUG_HTTP` set. distilled's Cloudflare protocol then `console.error`s the first
|
|
43
|
+
* 400 characters of EVERY response body (protocol.ts, measured in 1.0.0-rc.12) — the token
|
|
44
|
+
* response is `{"success":true,…,"result":"<token>"}`, so the whole token lands on stderr and in
|
|
45
|
+
* whatever captures it. The check reads `process.env` because that is what distilled reads.
|
|
46
|
+
* - The Global API Key (`apiKey` credentials). Account-wide and long-lived: the one credential a
|
|
47
|
+
* one-off enrolment must never use.
|
|
48
|
+
* - An EMPTY API token. A denied secrets-store grant renders empty, and Cloudflare then answers
|
|
49
|
+
* like a bad permission — which would send the operator to widen a grant that was fine
|
|
50
|
+
* (client.ts says the same for `CLOUDFLARE_API_TOKEN`).
|
|
51
|
+
*/
|
|
52
|
+
const refuseUnsafe = Effect.gen(function* () {
|
|
53
|
+
if ((globalThis.process?.env?.['DISTILLED_DEBUG_HTTP'] ?? '') !== '') {
|
|
54
|
+
return yield* Effect.fail(
|
|
55
|
+
new MeshNodeError({
|
|
56
|
+
message:
|
|
57
|
+
'Refusing to fetch a Mesh node token while DISTILLED_DEBUG_HTTP is set: the SDK would print the token to stderr. Unset it and run again.',
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const credentials = yield* yield* Credentials;
|
|
62
|
+
if (credentials.type === 'apiKey') {
|
|
63
|
+
return yield* Effect.fail(
|
|
64
|
+
new MeshNodeError({
|
|
65
|
+
message:
|
|
66
|
+
'Refusing the Global API Key for a Mesh node token. Mint a short-lived API token with "Cloudflare One Connectors Write" and pass it with fromApiToken.',
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (credentials.type === 'apiToken' && Redacted.value(credentials.apiToken).length === 0) {
|
|
71
|
+
return yield* Effect.fail(
|
|
72
|
+
new MeshNodeError({
|
|
73
|
+
message:
|
|
74
|
+
'The Cloudflare API token is EMPTY. That is a missing or denied secrets grant, not a Cloudflare permission: re-mint the token before changing any grant.',
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const resolveId = (ref: MeshNodeRef) => {
|
|
81
|
+
if (ref.id !== undefined) return Effect.succeed(ref.id);
|
|
82
|
+
const name = ref.name;
|
|
83
|
+
return Effect.flatMap(findNodeByName(ref.accountId, name), (node) =>
|
|
84
|
+
node === undefined
|
|
85
|
+
? Effect.fail(new MeshNodeError({ message: `No live Mesh node named "${name}".` }))
|
|
86
|
+
: Effect.succeed(node.id),
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* ⛔ An EMPTY token fails closed: `warp-cli connector new ""` would not enrol, and a script that
|
|
92
|
+
* wrote an empty file would report success over a host that never joined.
|
|
93
|
+
* ⛔ The failures made here name the node and account and quote no response body, because the
|
|
94
|
+
* success body IS the token. Other SDK errors pass through; they come only from non-2xx
|
|
95
|
+
* responses, which carry an error envelope, not a token.
|
|
96
|
+
*/
|
|
97
|
+
export const fetchMeshNodeToken = (ref: MeshNodeRef) =>
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
yield* refuseUnsafe;
|
|
100
|
+
const nodeId = yield* resolveId(ref);
|
|
101
|
+
const token = yield* zeroTrust
|
|
102
|
+
.getTunnelWarpConnectorToken({ accountId: ref.accountId, tunnelId: nodeId })
|
|
103
|
+
.pipe(
|
|
104
|
+
Effect.catchTag('TunnelNotFound', () =>
|
|
105
|
+
Effect.fail(
|
|
106
|
+
new MeshNodeError({
|
|
107
|
+
message: `Mesh node ${nodeId} does not exist in account ${ref.accountId}.`,
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
Effect.catchTag('Forbidden', () =>
|
|
112
|
+
Effect.fail(
|
|
113
|
+
new MeshNodeError({
|
|
114
|
+
message: `Reading Mesh node ${nodeId}'s token was refused (403). The endpoint needs a Write permission such as "Cloudflare One Connectors Write", on a token scoped to account ${ref.accountId}.`,
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
120
|
+
return yield* Effect.fail(
|
|
121
|
+
new MeshNodeError({ message: `Mesh node ${nodeId} returned an empty token.` }),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return Redacted.make(token);
|
|
125
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeshNodeProvider against a fake Cloudflare (fake-mesh.ts), its handlers called the way the
|
|
3
|
+
* engine calls them.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE ASSERTION THAT MATTERS MOST: no lifecycle handler ever requests `/token`, and nothing
|
|
6
|
+
* the provider returns contains the node token — even though the fake's create response carries
|
|
7
|
+
* one, as Cloudflare's HA page says the real one does.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
11
|
+
import type { StackServices } from 'alchemy/Stack';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import type * as Layer from 'effect/Layer';
|
|
14
|
+
import { FAKE_ACCOUNT, FAKE_API_TOKEN, fakeMesh } from './fake-mesh.ts';
|
|
15
|
+
import { diff, extra, ids, read, reconcile, run, stored, tokenReads } from './mesh-node-harness.ts';
|
|
16
|
+
import { providers } from './providers.ts';
|
|
17
|
+
|
|
18
|
+
// ⛔ Compile-time: a stack's `providers` must be `Layer<…, never, StackServices>`. Adding MeshNode
|
|
19
|
+
// first leaked Alchemy's auth errors into the error channel; `bun run types` now catches that.
|
|
20
|
+
type Built = Layer.Success<ReturnType<typeof providers>>;
|
|
21
|
+
const _stackShaped: Layer.Layer<Built, never, StackServices> = providers();
|
|
22
|
+
void _stackShaped;
|
|
23
|
+
|
|
24
|
+
describe('MeshNodeProvider', () => {
|
|
25
|
+
test('create sends ha, and the attributes carry no token', async () => {
|
|
26
|
+
const fake = fakeMesh();
|
|
27
|
+
const out = await run(fake, (p) => reconcile(p, { name: 'door-a', ha: true }));
|
|
28
|
+
const post = fake.seen.find((s) => s.method === 'POST');
|
|
29
|
+
expect(post?.body).toEqual({ name: 'door-a', ha: true });
|
|
30
|
+
expect(out).toMatchObject({
|
|
31
|
+
name: 'door-a',
|
|
32
|
+
ha: true,
|
|
33
|
+
accountId: FAKE_ACCOUNT,
|
|
34
|
+
status: 'inactive',
|
|
35
|
+
});
|
|
36
|
+
expect(Object.keys(out).sort()).toEqual(['accountId', 'ha', 'id', 'name', 'status']);
|
|
37
|
+
expect(JSON.stringify(out)).not.toContain('fake-node-token');
|
|
38
|
+
expect(tokenReads(fake)).toBe(0);
|
|
39
|
+
expect(new Set(fake.auth)).toEqual(new Set([`Bearer ${FAKE_API_TOKEN}`]));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('read with state refreshes by id; without state it adopts by exact name, Unowned', async () => {
|
|
43
|
+
const fake = fakeMesh();
|
|
44
|
+
fake.seed({ name: 'door-a-old' });
|
|
45
|
+
fake.seed({ name: 'door-a-older' });
|
|
46
|
+
const node = fake.seed({ name: 'door-a', status: 'healthy' });
|
|
47
|
+
const props = { name: 'door-a', ha: false };
|
|
48
|
+
const [owned, probe] = await run(fake, (p) =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const output = stored(FAKE_ACCOUNT, { id: node.id });
|
|
51
|
+
return [yield* read(p, props, output), yield* read(p, props)] as const;
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
expect(Unowned.is(owned)).toBe(false);
|
|
55
|
+
expect(owned).toMatchObject({ id: node.id, status: 'healthy' });
|
|
56
|
+
expect(Unowned.is(probe)).toBe(true);
|
|
57
|
+
expect(probe).toMatchObject({ id: node.id, name: 'door-a', ha: false });
|
|
58
|
+
expect(tokenReads(fake)).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a prefix-only match or a deleted node is not adopted', async () => {
|
|
62
|
+
const fake = fakeMesh();
|
|
63
|
+
fake.seed({ name: 'door-a-2' });
|
|
64
|
+
fake.seed({ name: 'door-a', deleted_at: '2026-09-20T00:00:00Z' });
|
|
65
|
+
const probe = await run(fake, (p) => read(p, { name: 'door-a', ha: false }));
|
|
66
|
+
expect(probe).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a rename is a PATCH on the same id', async () => {
|
|
70
|
+
const fake = fakeMesh();
|
|
71
|
+
const [first, second] = await run(fake, (p) =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const first = yield* reconcile(p, { name: 'door-a', ha: false });
|
|
74
|
+
return [first, yield* reconcile(p, { name: 'door-b', ha: false }, first)] as const;
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
expect(second.id).toBe(first.id);
|
|
78
|
+
expect(second.name).toBe('door-b');
|
|
79
|
+
expect(fake.seen.filter((s) => s.method === 'POST')).toHaveLength(1);
|
|
80
|
+
expect(fake.seen.find((s) => s.method === 'PATCH')?.body).toEqual({ name: 'door-b' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an unchanged node costs no write', async () => {
|
|
84
|
+
const fake = fakeMesh();
|
|
85
|
+
await run(fake, (p) =>
|
|
86
|
+
Effect.flatMap(reconcile(p, { name: 'door-a', ha: false }), (out) =>
|
|
87
|
+
reconcile(p, { name: 'door-a', ha: false }, out),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
expect(fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method)).toEqual(['POST']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('reconcile refuses an ha change that reached it as an update', async () => {
|
|
94
|
+
const fake = fakeMesh();
|
|
95
|
+
const failure = await run(fake, (p) =>
|
|
96
|
+
Effect.flip(
|
|
97
|
+
Effect.flatMap(reconcile(p, { name: 'door-a', ha: false }), (out) =>
|
|
98
|
+
reconcile(p, { name: 'door-a', ha: true }, out),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
expect(String(failure)).toContain('create-only');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('create never converges on a node it did not create (a retained old generation)', async () => {
|
|
106
|
+
const fake = fakeMesh();
|
|
107
|
+
fake.seed({ name: 'door-a', ha: false });
|
|
108
|
+
const failure = await run(fake, (p) => Effect.flip(reconcile(p, { name: 'door-a', ha: true })));
|
|
109
|
+
expect(String(failure)).toContain('already exists');
|
|
110
|
+
expect(fake.seen.some((s) => s.method === 'POST')).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a node deleted out of band is created again', async () => {
|
|
114
|
+
const fake = fakeMesh();
|
|
115
|
+
const [first, second] = await run(fake, (p) =>
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
const first = yield* reconcile(p, { name: 'door-a', ha: false });
|
|
118
|
+
const node = fake.nodes.get(first.id);
|
|
119
|
+
if (node !== undefined) node.deleted_at = '2026-09-21T02:00:00Z';
|
|
120
|
+
return [first, yield* reconcile(p, { name: 'door-a', ha: false }, first)] as const;
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
expect(second.id).not.toBe(first.id);
|
|
124
|
+
expect(fake.live()).toHaveLength(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('delete removes the node, and deleting it again succeeds', async () => {
|
|
128
|
+
const fake = fakeMesh();
|
|
129
|
+
await run(fake, (p) =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const output = yield* reconcile(p, { name: 'door-a', ha: false });
|
|
132
|
+
const olds = { name: 'door-a', ha: false };
|
|
133
|
+
yield* p.delete({ ...ids, ...extra, olds, output });
|
|
134
|
+
yield* p.delete({ ...ids, ...extra, olds, output });
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
expect(fake.live()).toHaveLength(0);
|
|
138
|
+
expect(fake.seen.filter((s) => s.method === 'DELETE')).toHaveLength(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('diff reads the account from the provider environment', async () => {
|
|
142
|
+
const fake = fakeMesh();
|
|
143
|
+
const props = { name: 'door-a', ha: false };
|
|
144
|
+
const plan = (p: Parameters<typeof diff>[0]) => diff(p, props, props, stored(FAKE_ACCOUNT));
|
|
145
|
+
expect(await run(fake, plan)).toBeUndefined();
|
|
146
|
+
expect(await run(fake, plan, '00000000000000000000000000000002')).toEqual({
|
|
147
|
+
action: 'replace',
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('an invalid declaration fails the PLAN, before a delete-first replace tears anything down', async () => {
|
|
152
|
+
const fake = fakeMesh();
|
|
153
|
+
const failure = await run(fake, (p) =>
|
|
154
|
+
Effect.flip(
|
|
155
|
+
diff(p, { name: 'door-a ', ha: true }, { name: 'door-a', ha: false }, stored(FAKE_ACCOUNT)),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
expect(String(failure)).toContain('whitespace');
|
|
159
|
+
expect(fake.seen).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('list is empty and nuke skips it: Alchemy WarpConnector already enumerates these', async () => {
|
|
163
|
+
const fake = fakeMesh();
|
|
164
|
+
fake.seed({ name: 'door-a' });
|
|
165
|
+
const [listed, nuke] = await run(fake, (p) =>
|
|
166
|
+
Effect.map(p.list(), (rows) => [rows, p.nuke] as const),
|
|
167
|
+
);
|
|
168
|
+
expect(listed).toEqual([]);
|
|
169
|
+
expect(nuke).toEqual({ skip: true });
|
|
170
|
+
expect(fake.seen).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
});
|