@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,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeshNodeProvider's refusals and recoveries — the cases a mutation of each guard must break.
|
|
3
|
+
*
|
|
4
|
+
* ★ Every test here was written against a guard that a mutation pass (2026-09-21) could delete
|
|
5
|
+
* without any other test noticing: the two-match refusal, the 404 on a stored id, the stored
|
|
6
|
+
* account in `read`, the rename collision, the `stables` list and the 1013-after-lookup race.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import { FAKE_ACCOUNT, fakeFailure, fakeMesh } from './fake-mesh.ts';
|
|
12
|
+
import { read, reconcile, run, stored } from './mesh-node-harness.ts';
|
|
13
|
+
|
|
14
|
+
const OTHER_ACCOUNT = '00000000000000000000000000000002';
|
|
15
|
+
const UNKNOWN_ID = '00000000-0000-4000-8000-00000000ffff';
|
|
16
|
+
|
|
17
|
+
describe('MeshNodeProvider edges', () => {
|
|
18
|
+
test('lookups filter by name and live-only on the wire', async () => {
|
|
19
|
+
const fake = fakeMesh();
|
|
20
|
+
fake.seed({ name: 'door-a', deleted_at: '2026-09-20T00:00:00Z' });
|
|
21
|
+
await run(fake, (p) => read(p, { name: 'door-a', ha: false }));
|
|
22
|
+
const list = fake.seen.find((s) => s.method === 'GET');
|
|
23
|
+
expect(list?.path).toContain('name=door-a');
|
|
24
|
+
expect(list?.path).toContain('is_deleted=false');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('two live nodes with the declared name are refused, both named', async () => {
|
|
28
|
+
const fake = fakeMesh();
|
|
29
|
+
const a = fake.seed({ name: 'door-a' });
|
|
30
|
+
const b = fake.seed({ name: 'door-a' });
|
|
31
|
+
const failure = await run(fake, (p) => Effect.flip(read(p, { name: 'door-a', ha: false })));
|
|
32
|
+
expect(failure.message).toContain(a.id);
|
|
33
|
+
expect(failure.message).toContain(b.id);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a stored id the API no longer knows (404): read falls back to the name, reconcile creates', async () => {
|
|
37
|
+
const fake = fakeMesh();
|
|
38
|
+
const node = fake.seed({ name: 'door-a' });
|
|
39
|
+
const gone = stored(FAKE_ACCOUNT, { id: UNKNOWN_ID, name: 'door-b' });
|
|
40
|
+
const probe = await run(fake, (p) => read(p, { name: 'door-a', ha: false }, gone));
|
|
41
|
+
expect(Unowned.is(probe)).toBe(true);
|
|
42
|
+
expect(probe).toMatchObject({ id: node.id });
|
|
43
|
+
|
|
44
|
+
const fresh = fakeMesh();
|
|
45
|
+
const created = await run(fresh, (p) => reconcile(p, { name: 'door-b', ha: true }, gone));
|
|
46
|
+
expect(created.id).not.toBe(UNKNOWN_ID);
|
|
47
|
+
expect(fresh.seen.find((s) => s.method === 'POST')?.body).toEqual({ name: 'door-b', ha: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('read refreshes in the account the node was written to, not the current environment', async () => {
|
|
51
|
+
const fake = fakeMesh();
|
|
52
|
+
const node = fake.seed({ name: 'door-a', status: 'healthy' });
|
|
53
|
+
const output = stored(FAKE_ACCOUNT, { id: node.id });
|
|
54
|
+
// ⚠️ The fake throws on any other account, so reading in OTHER_ACCOUNT fails this test.
|
|
55
|
+
const fresh = await run(
|
|
56
|
+
fake,
|
|
57
|
+
(p) => read(p, { name: 'door-a', ha: false }, output),
|
|
58
|
+
OTHER_ACCOUNT,
|
|
59
|
+
);
|
|
60
|
+
expect(fresh).toMatchObject({ id: node.id, accountId: FAKE_ACCOUNT, status: 'healthy' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a rename onto a taken name fails with a sentence and changes nothing', async () => {
|
|
64
|
+
const fake = fakeMesh();
|
|
65
|
+
fake.seed({ name: 'door-b' });
|
|
66
|
+
const node = fake.seed({ name: 'door-a' });
|
|
67
|
+
const output = stored(FAKE_ACCOUNT, { id: node.id });
|
|
68
|
+
const failure = await run(fake, (p) =>
|
|
69
|
+
Effect.flip(reconcile(p, { name: 'door-b', ha: false }, output)),
|
|
70
|
+
);
|
|
71
|
+
expect(failure.message).toContain('"door-b" already exists');
|
|
72
|
+
expect(fake.nodes.get(node.id)?.name).toBe('door-a');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('only id, accountId and ha are stable across an update (a rename changes name)', async () => {
|
|
76
|
+
const fake = fakeMesh();
|
|
77
|
+
const stables = await run(fake, (p) => Effect.succeed(p.stables));
|
|
78
|
+
expect(stables).toEqual(['id', 'accountId', 'ha']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a 1013 after a clean lookup names the node that appeared, and does not adopt it', async () => {
|
|
82
|
+
let appeared = '';
|
|
83
|
+
const fake = fakeMesh({
|
|
84
|
+
onCreate: (name) => {
|
|
85
|
+
// What a retried POST meets: the node its first, lost-response attempt created.
|
|
86
|
+
appeared = fake.seed({ name, ha: true }).id;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const failure = await run(fake, (p) => Effect.flip(reconcile(p, { name: 'door-a', ha: true })));
|
|
90
|
+
expect(failure.message).toContain(appeared);
|
|
91
|
+
expect(failure.message).toContain('adopt(true)');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a 1013 with no live Mesh node of that name blames another tunnel type', async () => {
|
|
95
|
+
const fake = fakeMesh({
|
|
96
|
+
onCreate: () => fakeFailure(409, 1013, 'Tunnel with name already exists'),
|
|
97
|
+
});
|
|
98
|
+
const failure = await run(fake, (p) =>
|
|
99
|
+
Effect.flip(reconcile(p, { name: 'door-a', ha: false })),
|
|
100
|
+
);
|
|
101
|
+
expect(failure.message).toContain('another tunnel type');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of MeshNode: which edits are an update and which a replace — and in which order.
|
|
3
|
+
*
|
|
4
|
+
* ★ The replace ORDER is the part worth testing. Names are unique per account, so an `ha` change
|
|
5
|
+
* that keeps the name must delete first; one that also renames must not, or it would take the
|
|
6
|
+
* node offline for no reason.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { type MeshNodeAttributes, diffMeshNode, validateMeshNode } from './mesh-node-form.ts';
|
|
11
|
+
|
|
12
|
+
const ACCOUNT = '00000000000000000000000000000001';
|
|
13
|
+
const output: MeshNodeAttributes = {
|
|
14
|
+
id: '00000000-0000-4000-8000-000000000001',
|
|
15
|
+
accountId: ACCOUNT,
|
|
16
|
+
name: 'door-a',
|
|
17
|
+
status: 'healthy',
|
|
18
|
+
ha: false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('diffMeshNode', () => {
|
|
22
|
+
test('no prior output: no opinion, the engine creates', () => {
|
|
23
|
+
expect(diffMeshNode({ name: 'door-a', ha: false }, undefined, ACCOUNT)).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('nothing changed: no opinion', () => {
|
|
27
|
+
expect(diffMeshNode({ name: 'door-a', ha: false }, output, ACCOUNT)).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a rename alone is an in-place update (PATCH keeps id, token and Mesh IPs)', () => {
|
|
31
|
+
expect(diffMeshNode({ name: 'door-b', ha: false }, output, ACCOUNT)).toEqual({
|
|
32
|
+
action: 'update',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('ha changed under the same name: replace, delete first (names are unique)', () => {
|
|
37
|
+
expect(diffMeshNode({ name: 'door-a', ha: true }, output, ACCOUNT)).toEqual({
|
|
38
|
+
action: 'replace',
|
|
39
|
+
deleteFirst: true,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('ha changed with a new name: replace, create first (nothing collides)', () => {
|
|
44
|
+
expect(diffMeshNode({ name: 'door-b', ha: true }, output, ACCOUNT)).toEqual({
|
|
45
|
+
action: 'replace',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('ha changed while the name is unresolved: the order that cannot collide', () => {
|
|
50
|
+
const news = { name: Effect.succeed('door-a'), ha: true } as never;
|
|
51
|
+
expect(diffMeshNode(news, output, ACCOUNT)).toEqual({ action: 'replace', deleteFirst: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('an unresolved ha gives no answer; reconcile re-checks it', () => {
|
|
55
|
+
const news = { name: 'door-a', ha: Effect.succeed(true) } as never;
|
|
56
|
+
expect(diffMeshNode(news, output, ACCOUNT)).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('another account is a create-first replace', () => {
|
|
60
|
+
const other = '00000000000000000000000000000002';
|
|
61
|
+
expect(diffMeshNode({ name: 'door-a', ha: false }, output, other)).toEqual({
|
|
62
|
+
action: 'replace',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('validateMeshNode', () => {
|
|
68
|
+
test('accepts a plain declaration', () => {
|
|
69
|
+
expect(validateMeshNode({ name: 'door-a', ha: true })).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('refuses an empty or padded name, because adoption matches exactly', () => {
|
|
73
|
+
expect(validateMeshNode({ name: ' ', ha: false })?.message).toContain('non-empty');
|
|
74
|
+
expect(validateMeshNode({ name: ' door-a', ha: false })?.message).toContain('whitespace');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('refuses a missing ha: it is create-only, so there is no default', () => {
|
|
78
|
+
expect(validateMeshNode({ name: 'door-a' } as never)?.message).toContain('no default');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Cloudflare.MeshNode` — its props, its attributes, and the pure `diff` that decides between
|
|
3
|
+
* update and replace.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE ATTRIBUTES NEVER HOLD THE NODE TOKEN. That is the whole reason this resource exists:
|
|
6
|
+
* Alchemy beta.79's `Cloudflare.Tunnel.WarpConnector` calls `getTunnelWarpConnectorToken` in
|
|
7
|
+
* `toAttributes`, so EVERY read, reconcile and list fetches the token, and Alchemy state (which
|
|
8
|
+
* is not encrypted) stores it — `Redacted` hides it from logs, not from the state store. It also
|
|
9
|
+
* has no `ha` prop. The token is a runtime credential: fetch it on demand with
|
|
10
|
+
* `fetchMeshNodeToken` (mesh-node-token.ts) and write it straight to the host.
|
|
11
|
+
* ★ A BONUS OF NOT READING IT: the token endpoint needs a Write permission ("Cloudflare One
|
|
12
|
+
* Connectors Write", per its API reference, read 2026-09-21), so a plan over this resource can
|
|
13
|
+
* run on a read-only token. WarpConnector's cannot.
|
|
14
|
+
*/
|
|
15
|
+
import type { Diff } from 'alchemy/Diff';
|
|
16
|
+
import type { Input } from 'alchemy/Input';
|
|
17
|
+
import { MeshNodeError, type MeshNodeStatus } from './mesh-node-api.ts';
|
|
18
|
+
|
|
19
|
+
export interface MeshNodeProps {
|
|
20
|
+
/**
|
|
21
|
+
* The node's name in the account — and its identity when adopting (names are unique per
|
|
22
|
+
* account). ★ Changing it RENAMES the node in place (`PATCH`): the id, the token, the
|
|
23
|
+
* enrolled replicas and their Mesh IPs all survive, so a rename never re-enrols a host.
|
|
24
|
+
*/
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/**
|
|
27
|
+
* High availability (active-passive replicas sharing one node token).
|
|
28
|
+
*
|
|
29
|
+
* ⛔ CREATE-ONLY, AND REQUIRED FOR THAT REASON. Cloudflare's Mesh HA page: "High availability
|
|
30
|
+
* is set at node creation time and cannot be changed afterward." The PATCH body carries no
|
|
31
|
+
* `ha`. So a change REPLACES the node — a new id, a new token, a new Mesh IP per replica —
|
|
32
|
+
* and a default would silently fix an irreversible choice. The API's own default is `false`;
|
|
33
|
+
* the dashboard's is `true`. Say which one you mean.
|
|
34
|
+
* ★ A NODE WITH NO ROUTES — A DOOR — IS `ha: false`, AND A SECOND DOOR IS A SECOND NODE. HA
|
|
35
|
+
* fails over the CIDR routes a node advertises; "Nodes without routes do not benefit from HA
|
|
36
|
+
* failover", because the node's Mesh IP is tied to the individual replica (same page, "When to
|
|
37
|
+
* use" and "Considerations", read 2026-09-21). After a failover the promoted replica answers
|
|
38
|
+
* on its own Mesh IP, so nothing that dials a door's first replica follows it. Declare the
|
|
39
|
+
* second door as its own `MeshNode` (own name, token, Mesh IP) and have callers list both.
|
|
40
|
+
* ⚠️ `ha` NEEDS MASQUE on the node's device profile (same page).
|
|
41
|
+
*/
|
|
42
|
+
readonly ha: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MeshNodeAttributes {
|
|
46
|
+
/** The node's UUID (the `warp_connector` tunnel id). Pass it to `fetchMeshNodeToken`. */
|
|
47
|
+
readonly id: string;
|
|
48
|
+
/** The account the provider environment resolved when the node was written. */
|
|
49
|
+
readonly accountId: string;
|
|
50
|
+
readonly name: string;
|
|
51
|
+
/** As of the last read or write; it moves on its own as replicas connect and drop. */
|
|
52
|
+
readonly status: MeshNodeStatus | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* The value the node was CREATED with (or declared with when adopted — see mesh-node.md).
|
|
55
|
+
* ⚠️ No documented read returns it: `GET /warp_connector/{id}` has no `ha` field (API
|
|
56
|
+
* reference, read 2026-09-21), so drift here cannot be observed, only prevented.
|
|
57
|
+
*/
|
|
58
|
+
readonly ha: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** ⛔ Fail closed on a declaration the API would accept but nobody meant. */
|
|
62
|
+
export const validateMeshNode = (props: MeshNodeProps): MeshNodeError | undefined => {
|
|
63
|
+
if (typeof props.name !== 'string' || props.name.trim().length === 0) {
|
|
64
|
+
return new MeshNodeError({ message: 'MeshNode `name` must be a non-empty string.' });
|
|
65
|
+
}
|
|
66
|
+
if (props.name !== props.name.trim()) {
|
|
67
|
+
return new MeshNodeError({
|
|
68
|
+
message: `MeshNode name "${props.name}" has leading or trailing whitespace; adoption matches names exactly.`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (typeof props.ha !== 'boolean') {
|
|
72
|
+
return new MeshNodeError({
|
|
73
|
+
message: 'MeshNode `ha` must be true or false. It is create-only, so it has no default.',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* update or replace, from the declaration and what was last written.
|
|
81
|
+
*
|
|
82
|
+
* - Account changed → `replace`, create-first: names are unique per ACCOUNT, so the two
|
|
83
|
+
* generations cannot collide.
|
|
84
|
+
* - `ha` changed → `replace`. ⛔ `deleteFirst` when the name stays the same: the new node needs
|
|
85
|
+
* the name the old one holds, so create-first would fail with code 1013 (or, worse, a
|
|
86
|
+
* create path that "converged" on the old node would record an `ha` it does not have).
|
|
87
|
+
* Create-first when the name changes in the same edit, because then nothing collides.
|
|
88
|
+
* ⚠️ UNDER THE DEFAULT `retain` (mesh-node.ts) the engine skips the old node's delete in both
|
|
89
|
+
* orders: delete-first then REFUSES at the create (mesh-node-lifecycle.ts) until the deploy
|
|
90
|
+
* opts in with `RemovalPolicy.destroy()`, and create-first leaves the old node live, unmanaged.
|
|
91
|
+
* The answer here is the same under both policies: `diff` is not handed the policy (Provider.ts,
|
|
92
|
+
* its input), and delete-first is exactly what lets the opted-in deploy reuse the name.
|
|
93
|
+
* - Name changed alone → `update` (a PATCH rename).
|
|
94
|
+
* - Otherwise → no opinion; the engine compares props.
|
|
95
|
+
*
|
|
96
|
+
* ★ An unresolved `ha` gives no answer here; `reconcile` re-checks it once resolved and refuses
|
|
97
|
+
* rather than updating across a create-only change. An unresolved `name` next to a changed `ha`
|
|
98
|
+
* counts as "unchanged", which picks the delete-first order that cannot collide.
|
|
99
|
+
*/
|
|
100
|
+
export const diffMeshNode = (
|
|
101
|
+
news: Input<MeshNodeProps>,
|
|
102
|
+
output: MeshNodeAttributes | undefined,
|
|
103
|
+
accountId: string,
|
|
104
|
+
): Diff | undefined => {
|
|
105
|
+
if (output === undefined) return undefined;
|
|
106
|
+
if (output.accountId !== accountId) return { action: 'replace' };
|
|
107
|
+
const fields = news as { readonly name?: unknown; readonly ha?: unknown };
|
|
108
|
+
const name = typeof fields.name === 'string' ? fields.name : undefined;
|
|
109
|
+
if (typeof fields.ha === 'boolean' && fields.ha !== output.ha) {
|
|
110
|
+
const sameName = name === undefined || name === output.name;
|
|
111
|
+
return sameName ? { action: 'replace', deleteFirst: true } : { action: 'replace' };
|
|
112
|
+
}
|
|
113
|
+
if (name !== undefined && name !== output.name) return { action: 'update' };
|
|
114
|
+
return undefined;
|
|
115
|
+
};
|
|
@@ -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);
|