@homeflare/alchemy 0.6.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 +54 -10
- 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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/index.js.map +2 -2
- package/dist/launchd/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.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/index.js +231 -151
- package/dist/openbao/index.js.map +9 -7
- 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.d.ts +106 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/docs/caddy.md +180 -0
- package/docs/mesh-node.md +188 -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/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 +1 -0
- package/src/launchd/secret-tripwire.ts +1 -1
- package/src/openbao/REPLACE.md +97 -21
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- 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/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-rename.test.ts +205 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Cloudflare.MeshNode` — a Cloudflare Mesh node (a `warp_connector` object), declared without
|
|
3
|
+
* its token ever touching Alchemy state. Usage and the enrolment step: docs/mesh-node.md.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Tunnel.WarpConnector`. A Mesh node that fronts a
|
|
6
|
+
* private service needs a node record in the graph, so the Gateway rules and DNS records that
|
|
7
|
+
* name it deploy together. Alchemy beta.79's WarpConnector would put the node token — the
|
|
8
|
+
* credential that lets any Linux host join the account's Mesh as that node — into plaintext
|
|
9
|
+
* state on every read, and it cannot create an HA node. See mesh-node-form.ts.
|
|
10
|
+
*
|
|
11
|
+
* ★ REMOVAL POLICY: `retain` BY DEFAULT — see the ★ on `MeshNode` below, and what it does to an
|
|
12
|
+
* `ha` replace in mesh-node-lifecycle.ts.
|
|
13
|
+
*/
|
|
14
|
+
import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
|
|
15
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
16
|
+
import { isResolved } from 'alchemy/Diff';
|
|
17
|
+
import * as Provider from 'alchemy/Provider';
|
|
18
|
+
import { Resource, type ResourceClass } from 'alchemy/Resource';
|
|
19
|
+
import * as Context from 'effect/Context';
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
22
|
+
import {
|
|
23
|
+
type MeshNodeAttributes,
|
|
24
|
+
type MeshNodeProps,
|
|
25
|
+
diffMeshNode,
|
|
26
|
+
validateMeshNode,
|
|
27
|
+
} from './mesh-node-form.ts';
|
|
28
|
+
import { deleteMeshNode, readMeshNode, reconcileMeshNode } from './mesh-node-lifecycle.ts';
|
|
29
|
+
import type { Providers } from './providers.ts';
|
|
30
|
+
|
|
31
|
+
export interface MeshNode extends Resource<
|
|
32
|
+
'Cloudflare.MeshNode',
|
|
33
|
+
MeshNodeProps,
|
|
34
|
+
MeshNodeAttributes,
|
|
35
|
+
never,
|
|
36
|
+
Providers
|
|
37
|
+
> {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ★ `defaultRemovalPolicy: 'retain'`, LIKE EVERY KIT RESOURCE WHOSE DELETION BREAKS ITS CONSUMERS
|
|
41
|
+
* (the `Bao.*` families, `R2BucketLock`, the Proxmox storage families; the reasoning is written
|
|
42
|
+
* once in proxmox/resource.ts). Deleting a node cuts every enrolled replica off the Mesh, and
|
|
43
|
+
* nothing brings it back: a new node has a new id, a new token and a new Mesh IP per replica, so
|
|
44
|
+
* each host must be re-enrolled by hand and every Gateway rule or allow-list that pins the old
|
|
45
|
+
* Mesh IP breaks. So dropping the declaration, or `alchemy destroy`, leaves the node live.
|
|
46
|
+
* ⚠️ IT ALSO SKIPS THE OLD NODE'S DELETE IN A REPLACE (Apply.ts, `deleteOldGenerations` and the
|
|
47
|
+
* GC's `retainOldGeneration`, beta.79). A same-name `ha` change therefore REFUSES, deleting
|
|
48
|
+
* nothing, until that deploy opts in with `.pipe(RemovalPolicy.destroy())`; a create-first
|
|
49
|
+
* replace leaves the old node live and unmanaged. Both measured in mesh-node-policy.test.ts.
|
|
50
|
+
* ⛔ `delete` STAYS FULLY IMPLEMENTED. Retain is Terraform's `prevent_destroy`, not a stub: it runs
|
|
51
|
+
* the moment a declaration opts in with `.pipe(RemovalPolicy.destroy())`.
|
|
52
|
+
*/
|
|
53
|
+
export const MeshNode: ResourceClass<MeshNode> = Resource<MeshNode>('Cloudflare.MeshNode', {
|
|
54
|
+
defaultRemovalPolicy: 'retain',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** What every call below needs: the account, credentials and HTTP — Alchemy's own trio. */
|
|
58
|
+
export type MeshNodeServices =
|
|
59
|
+
| Cloudflare.CloudflareEnvironment
|
|
60
|
+
| Credentials
|
|
61
|
+
| HttpClient.HttpClient;
|
|
62
|
+
|
|
63
|
+
/** The account id, resolved the way every `alchemy/Cloudflare` provider resolves it. */
|
|
64
|
+
const accountId = Effect.gen(function* () {
|
|
65
|
+
const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
|
|
66
|
+
return resolved.accountId;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD. `Provider.succeed` (what Alchemy's
|
|
71
|
+
* own providers use) leaves each handler to find CloudflareEnvironment and Credentials in the
|
|
72
|
+
* engine's context at call time — which works for `Cloudflare.providers()` only because it
|
|
73
|
+
* `provideMerge`s them into the stack. Capturing them here keeps this provider correct whether
|
|
74
|
+
* or not the stack also merges `Cloudflare.providers()`.
|
|
75
|
+
*/
|
|
76
|
+
export const MeshNodeProvider = () =>
|
|
77
|
+
Provider.effect(
|
|
78
|
+
MeshNode,
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const services = Context.make(
|
|
81
|
+
Cloudflare.CloudflareEnvironment,
|
|
82
|
+
yield* Cloudflare.CloudflareEnvironment,
|
|
83
|
+
).pipe(
|
|
84
|
+
Context.add(Credentials, yield* Credentials),
|
|
85
|
+
Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
|
86
|
+
);
|
|
87
|
+
const run = <A, E>(effect: Effect.Effect<A, E, MeshNodeServices>) =>
|
|
88
|
+
Effect.provideContext(effect, services);
|
|
89
|
+
|
|
90
|
+
return MeshNode.Provider.of({
|
|
91
|
+
stables: ['id', 'accountId', 'ha'],
|
|
92
|
+
/**
|
|
93
|
+
* ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Tunnel.WarpConnector` already lists
|
|
94
|
+
* every `warp_connector` in the account for `alchemy unsafe nuke`; a second listing here
|
|
95
|
+
* would make nuke delete each node twice. `read` does not depend on this.
|
|
96
|
+
*/
|
|
97
|
+
list: () => Effect.succeed([]),
|
|
98
|
+
nuke: { skip: true },
|
|
99
|
+
/**
|
|
100
|
+
* ⛔ VALIDATE AT PLAN TIME, NOT ONLY IN `reconcile`. An `ha` replace is delete-first: the
|
|
101
|
+
* engine deletes the old node BEFORE the new generation's reconcile runs, so a
|
|
102
|
+
* declaration refused there would leave no node at all. launchd's job-preflight.ts
|
|
103
|
+
* refuses at plan time for the same reason.
|
|
104
|
+
*/
|
|
105
|
+
diff: ({ news, output }) =>
|
|
106
|
+
run(
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const invalid = isResolved(news) ? validateMeshNode(news) : undefined;
|
|
109
|
+
if (invalid !== undefined) return yield* Effect.fail(invalid);
|
|
110
|
+
return diffMeshNode(news, output, yield* accountId);
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
113
|
+
read: ({ olds, output }) =>
|
|
114
|
+
run(Effect.flatMap(accountId, (account) => readMeshNode(account, olds, output))),
|
|
115
|
+
reconcile: ({ news, output }) =>
|
|
116
|
+
run(Effect.flatMap(accountId, (account) => reconcileMeshNode(account, news, output))),
|
|
117
|
+
delete: ({ output }) => run(deleteMeshNode(output)),
|
|
118
|
+
});
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
@@ -8,16 +8,34 @@
|
|
|
8
8
|
* both a `Cloudflare.R2.Bucket` and this lock merges the two:
|
|
9
9
|
* `Layer.mergeAll(Cloudflare.providers(), HomeflareCloudflare.providers())` — the shape
|
|
10
10
|
* a consuming stack names for one talking to more than one account.
|
|
11
|
+
*
|
|
12
|
+
* ★ TWO CREDENTIAL PATHS, ONE PER SDK. `R2BucketLock` uses the `cloudflare` SDK through
|
|
13
|
+
* `CloudflareApiLive` (`CLOUDFLARE_API_TOKEN`, fail-closed). `MeshNode` uses Alchemy's own SDK
|
|
14
|
+
* and resolves credentials and the account through `Cloudflare.CloudflareApiLive()` — the same
|
|
15
|
+
* source `Cloudflare.providers()` uses (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
|
|
16
|
+
* `CLOUDFLARE_ACCOUNT_ID` in CI), so a node lands in the account its Gateway rules do.
|
|
17
|
+
* ⚠️ That second layer is built again here even when the stack also merges
|
|
18
|
+
* `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: it feeds MeshNode only and adds
|
|
19
|
+
* nothing to the stack's context.
|
|
11
20
|
*/
|
|
21
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
12
22
|
import * as Provider from 'alchemy/Provider';
|
|
13
23
|
import * as Layer from 'effect/Layer';
|
|
14
24
|
import { CloudflareApiLive } from './client.ts';
|
|
25
|
+
import { MeshNode, MeshNodeProvider } from './mesh-node.ts';
|
|
15
26
|
import { R2BucketLock, R2BucketLockProvider } from './r2-bucket-lock.ts';
|
|
16
27
|
|
|
17
28
|
export class Providers extends Provider.ProviderCollection<Providers>()('HomeflareCloudflare') {}
|
|
18
29
|
|
|
19
30
|
export const providers = () =>
|
|
20
|
-
Layer.effect(Providers, Provider.collection([R2BucketLock])).pipe(
|
|
21
|
-
Layer.provide(
|
|
31
|
+
Layer.effect(Providers, Provider.collection([R2BucketLock, MeshNode])).pipe(
|
|
32
|
+
Layer.provide(
|
|
33
|
+
Layer.mergeAll(
|
|
34
|
+
R2BucketLockProvider(),
|
|
35
|
+
// ⛔ `orDie`, as `Cloudflare.providers()` ends with: a stack's providers layer must not
|
|
36
|
+
// fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
|
|
37
|
+
MeshNodeProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
22
40
|
Layer.provideMerge(CloudflareApiLive),
|
|
23
41
|
);
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* import { TalosCluster } from '@homeflare/alchemy/talos';
|
|
11
11
|
* import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
|
|
12
12
|
* import { LaunchdJob } from '@homeflare/alchemy/launchd';
|
|
13
|
+
* import { CaddyConfig } from '@homeflare/alchemy/caddy';
|
|
13
14
|
*
|
|
14
15
|
* ★ WHY THESE EXIST AT ALL. When Alchemy has no resource for something, the alternative
|
|
15
16
|
* is a runbook step a human runs once — and a plan can never show a missing runbook
|
|
@@ -28,7 +28,7 @@ const INLINE_FLAG = /^--?[a-z0-9-]*(?:token|password|passwd|passphrase|secret|ap
|
|
|
28
28
|
/** A flag whose NEXT argv element is the secret: `--token abc`. */
|
|
29
29
|
const BARE_FLAG = /^--?(?:[a-z0-9]+-)*(?:token|password|passwd|passphrase|secret|api-?key)$/i;
|
|
30
30
|
|
|
31
|
-
const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
|
|
31
|
+
export const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
|
|
32
32
|
|
|
33
33
|
/** Paths of every string leaf in a plist value holding a private key. */
|
|
34
34
|
const keyLeaves = (value: PlistValue, path: string): string[] => {
|
package/src/openbao/REPLACE.md
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# Replace semantics: the `Bao.*` providers
|
|
2
2
|
|
|
3
|
-
Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source
|
|
4
|
-
provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
|
|
3
|
+
Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source, in two batches.
|
|
4
|
+
Every kit provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
|
|
5
|
+
|
|
6
|
+
★ **Measured, not only read.** `fake-stack.ts` runs beta.79's own `Plan.make` and `apply` over an
|
|
7
|
+
in-memory state store against the fake engines in `fake-engines.ts`. `policy-rename.test.ts`,
|
|
8
|
+
`role-rename.test.ts` and `rename-occupied.test.ts` deploy, rename, and deploy again, then check
|
|
9
|
+
the plan and every call that reached the fake.
|
|
5
10
|
|
|
6
11
|
## What Alchemy does with each answer
|
|
7
12
|
|
|
@@ -9,6 +14,12 @@ provider must answer `replace` correctly (vault consolidation plan, "Replace fre
|
|
|
9
14
|
(its path) is unchanged.
|
|
10
15
|
- **`replace`**: create-first by default. The new generation is created, dependents are updated in
|
|
11
16
|
the same graph, then the old generation is deleted (`Apply.ts`, delete-first comment near :1180).
|
|
17
|
+
★ That delete gets the **old** generation's attributes (`output: old.attr`), so a provider that
|
|
18
|
+
deletes by `output` deletes the old path. Measured: `PUT` of the new path, then `DELETE` of the
|
|
19
|
+
old one.
|
|
20
|
+
- **No answer** (`undefined`, which every diff returns while `news` holds a pending Output): the
|
|
21
|
+
engine plans `update` if any prop changed (`Plan.ts`, `havePropsChanged`). ⚠️ That is the bug
|
|
22
|
+
class below whenever the identity changed too.
|
|
12
23
|
- **`deleteFirst: true`**: tears the old one down first. It is only for identities that cannot
|
|
13
24
|
coexist, such as a unique physical name. No `Bao.*` provider uses it. A rename is a new API
|
|
14
25
|
path, which never collides with the old one. ⚠️ A mount or auth method whose `type` changes
|
|
@@ -27,25 +38,88 @@ state record, so no later plan would mention it. For a mount this meant an **emp
|
|
|
27
38
|
beside the retained one that held every secret. For an AppRole it meant the old role's secret_ids
|
|
28
39
|
kept working for their whole TTL.
|
|
29
40
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
| `Bao.
|
|
39
|
-
| `Bao.
|
|
40
|
-
| `Bao.
|
|
41
|
-
| `Bao.
|
|
42
|
-
| `Bao.
|
|
41
|
+
🔴 Reproduced through the engine on 2026-09-21 for `Bao.Policy` under `RemovalPolicy.destroy()`:
|
|
42
|
+
deploy `app-old`, redeclare it as `app-new`, deploy. The plan said `update`, and both policies
|
|
43
|
+
stayed live. `destroy` could not help, because nothing recorded the old one.
|
|
44
|
+
|
|
45
|
+
## Answers, by resource
|
|
46
|
+
|
|
47
|
+
| Resource | Identity change | Answer now | Other changes |
|
|
48
|
+
| ------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
49
|
+
| `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
|
|
50
|
+
| `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
|
|
51
|
+
| `Bao.AuthRole` | `name` | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. | `update` |
|
|
52
|
+
| `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
|
|
53
|
+
| `Bao.JwtRole` | `mount`, `name` | `replace` | `update` (merge write; every managed field is sent) |
|
|
54
|
+
| `Bao.KubernetesRole` | `mount`, `name` | `replace` | `update` |
|
|
55
|
+
| `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. | `update` (full-replace write) |
|
|
56
|
+
| `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret | `update` (the upsert keeps the id) |
|
|
57
|
+
| `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
|
|
58
|
+
| `Bao.Policy` | `name` | `replace` (batch 3A). Compared as OpenBao keys it, trimmed and lowercased, so `Admin` → `admin` is the same policy: `noop`. ⛔ **Fails the plan** onto a name that exists (below). | `update` (fragments digest) |
|
|
59
|
+
| `Bao.CloudflareRole` | `mount`, `name` | `replace` (batch 3A). Exact; a trailing `/` on the mount is the same path. ⛔ **Fails the plan** onto a role that exists (below). | `update` |
|
|
60
|
+
| `Bao.ProxmoxRole` | `mount`, `name` | `replace` (batch 3A). ⛔ **Fails the plan** onto a role that exists, or if the rename also changes `mintUser` unless `allowMintUserChange` names the old one. | `update`. A `mintUser` change at the same path is refused at apply, never replaced. |
|
|
43
61
|
|
|
44
62
|
`hostAppRoles` makes no resources, but its names are identities: renaming a host or a class
|
|
45
63
|
renames the role. With the logical id taken from the role name, as the README does, the old id
|
|
46
64
|
leaves the stack: that is an orphan delete, not a `replace`. With a stable logical id,
|
|
47
65
|
`Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live.
|
|
48
66
|
|
|
67
|
+
## When the new identity is not known at plan
|
|
68
|
+
|
|
69
|
+
- **Only another prop is pending** (say `fragments` is an Output of an upstream that is also
|
|
70
|
+
changing). `Bao.Policy`, `Bao.CloudflareRole` and `Bao.ProxmoxRole` judge the identity props
|
|
71
|
+
before `isResolved(news)` (`rename.ts`), so the rename still plans `replace`. Measured.
|
|
72
|
+
- **The identity itself is pending.** Then the diff defers: it never answers `replace` on an
|
|
73
|
+
unknown identity, because under `destroy` a replace onto the same path deletes what it just
|
|
74
|
+
wrote. The engine plans `update`, and reconcile, seeing the old attributes name another
|
|
75
|
+
object, **refuses before any write**. The row is left `updating`, with the new props and the
|
|
76
|
+
old attributes. The next deploy sees both names and plans `replace`. Measured, for all three.
|
|
77
|
+
- `Bao.ProxmoxRole` defers the same way while `mintUser` or `allowMintUserChange` is pending on a
|
|
78
|
+
rename, because the re-scope guard cannot be checked. Measured for `allowMintUserChange`.
|
|
79
|
+
|
|
80
|
+
## A move onto an object that already exists fails the plan
|
|
81
|
+
|
|
82
|
+
⛔ All three read the new path at plan (`judgeMove`, `rename.ts`) and refuse the move if anything
|
|
83
|
+
is there. 🔴 Measured before the guard: two policies that swapped names under
|
|
84
|
+
`RemovalPolicy.destroy()` both planned `replace`. Each new generation wrote over the other's live
|
|
85
|
+
policy, and each old generation's delete then removed the name the other had just written. The
|
|
86
|
+
deploy was green, and both policies were gone, every grant revoked. Measured the same way:
|
|
87
|
+
|
|
88
|
+
- a shift (`a → b` while `b → c`) deleted `b`, and a move onto the name of a resource leaving the
|
|
89
|
+
stack deleted the name it moved onto;
|
|
90
|
+
- reverting a move whose new generation failed. The old generation still holds the name, and its
|
|
91
|
+
delete runs after the revert rewrites it. So the diff compares against the props of an
|
|
92
|
+
unfinished create or replacement when there are no attributes yet. Declaring the new name again
|
|
93
|
+
finishes the move instead.
|
|
94
|
+
|
|
95
|
+
⚠️ The diff cannot see the removal policy, so this refuses under `retain` too, where a swap would
|
|
96
|
+
have been harmless. So would a move back onto a retained old generation. Move in two deploys
|
|
97
|
+
through a name nothing holds, or remove the target by hand first. A change of policy-name case, or
|
|
98
|
+
a trailing `/` on a mount, is the same object, so it is not a move and is never refused.
|
|
99
|
+
|
|
100
|
+
## What `retain` means for these three
|
|
101
|
+
|
|
102
|
+
Under the default `retain` the old generation is kept, and the apply says so ("Replaced resource
|
|
103
|
+
retained."). It is live and unmanaged from then on:
|
|
104
|
+
|
|
105
|
+
- **`Bao.Policy`**: the old policy keeps every grant, for every token, role and group that still
|
|
106
|
+
names it. Under `destroy` they lose those grants at the delete. ⚠️ Only a role that takes the
|
|
107
|
+
policy's `name` Output follows the rename in the same graph. A role that names the policy as a
|
|
108
|
+
literal string (`tokenPolicies: ['deploy']`) keeps the old name, so change it in the same PR.
|
|
109
|
+
- **`Bao.CloudflareRole`**: the old role still mints, for any token whose ACL reaches its
|
|
110
|
+
`creds/<name>`. Under `destroy` every consumer still minting from the old path fails. Tokens
|
|
111
|
+
already minted are leases and live to their own expiry either way.
|
|
112
|
+
- **`Bao.ProxmoxRole`**: the same, and the old role keeps minting under its old `mint_user`. The
|
|
113
|
+
plugin's delete leaves outstanding leases revocable (its own
|
|
114
|
+
`TestDeletingARoleLeavesItsOutstandingLeasesRevocable`).
|
|
115
|
+
|
|
116
|
+
★ A stack that puts `mount` and `name` in the logical id, as homeflare-openbao's
|
|
117
|
+
`declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
|
|
118
|
+
orphan delete, which `retain` also keeps live.
|
|
119
|
+
|
|
120
|
+
⚠️ A NEW declaration (a new logical id) of a name that already exists still takes that object over
|
|
121
|
+
and rewrites it: these families' `read` never answers `Unowned`, so Alchemy adopts it.
|
|
122
|
+
|
|
49
123
|
## Why the moves fail instead of replacing
|
|
50
124
|
|
|
51
125
|
- **Mount and auth path.** A `replace` would create an empty mount. Deleting the old one would
|
|
@@ -78,10 +152,12 @@ take every secret with it. (Corrected 2026-09-21: the first audit listed this as
|
|
|
78
152
|
3. **Unique names need `deleteFirst`.** Only a mount's `type` change keeps its unique path, and
|
|
79
153
|
it fails on purpose instead (above).
|
|
80
154
|
|
|
81
|
-
##
|
|
155
|
+
## Open
|
|
82
156
|
|
|
83
|
-
- ⚠️
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
157
|
+
- ⚠️ **Nine families still check the identity after `isResolved(news)`**: `Bao.AuthRole`,
|
|
158
|
+
`Bao.PkiRole`, `Bao.JwtRole`, `Bao.KubernetesRole`, `Bao.JwtAuthConfig`, `Bao.MfaTotpMethod`,
|
|
159
|
+
`Bao.MfaLoginEnforcement`, `Bao.SshRole` and `Bao.Plugin`. A rename that lands in the same
|
|
160
|
+
deploy as any pending Output plans `update`, and none of their reconciles has the moved-update
|
|
161
|
+
guard. For `Bao.MfaTotpMethod` that writes a second method and strands every enrolment. The
|
|
162
|
+
helpers in `rename.ts` close it the same way. (`Bao.Mount` and `Bao.AuthMethod` are safe: their
|
|
163
|
+
reconcile re-plans the move from the stated path.)
|