@homeflare/alchemy 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -55
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +45 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +30 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +43 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +21 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +912 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +47 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +24 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/fake-engines.d.ts +30 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1518 -310
- package/dist/openbao/index.js.map +40 -17
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +106 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/caddy.md +180 -0
- package/docs/launchd.md +167 -0
- package/docs/mesh-node.md +188 -0
- package/package.json +12 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +218 -0
- package/src/caddy/config-lifecycle.ts +148 -0
- package/src/caddy/config.ts +116 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +160 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +25 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +244 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +62 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +2 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +163 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +62 -37
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/role-rename.test.ts +205 -0
|
@@ -0,0 +1,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
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* import { BaoMount } from '@homeflare/alchemy/openbao';
|
|
10
10
|
* import { TalosCluster } from '@homeflare/alchemy/talos';
|
|
11
11
|
* import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
|
|
12
|
+
* import { LaunchdJob } from '@homeflare/alchemy/launchd';
|
|
13
|
+
* import { CaddyConfig } from '@homeflare/alchemy/caddy';
|
|
12
14
|
*
|
|
13
15
|
* ★ WHY THESE EXIST AT ALL. When Alchemy has no resource for something, the alternative
|
|
14
16
|
* is a runbook step a human runs once — and a plan can never show a missing runbook
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory host for the tests beside it: a filesystem in a Map and a scripted launchd.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ TEST-ONLY. No provider imports this file, it is not on the barrel, and it never touches the
|
|
5
|
+
* real filesystem or runs a real program — which is the only way the lifecycle tests can prove
|
|
6
|
+
* "bootout before bootstrap" without writing /Library or loading a job.
|
|
7
|
+
* ★ IT MODELS THE BEHAVIOURS THE PROVIDER DEPENDS ON, as launchctl.ts documents them: `print` of an
|
|
8
|
+
* unknown target exits 113; bootstrap of a loaded or disabled label fails; bootout of an absent
|
|
9
|
+
* job exits non-zero; a job can linger after bootout (`lingerPrints`); a gui uid with no login
|
|
10
|
+
* session answers 112 (`loggedOut`); writes into a root-owned directory, or a chown to another
|
|
11
|
+
* uid, need root.
|
|
12
|
+
*/
|
|
13
|
+
import { LAUNCHCTL, NOT_FOUND, NO_DOMAIN } from './launchctl.ts';
|
|
14
|
+
import type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
|
|
15
|
+
|
|
16
|
+
type Entry = { bytes: Uint8Array; mode: number; uid: number; gid: number; kind: FileStat['kind'] };
|
|
17
|
+
|
|
18
|
+
export type FakeOptions = {
|
|
19
|
+
euid?: number;
|
|
20
|
+
privileged?: boolean;
|
|
21
|
+
/** Directories that exist, and their owner uid. */
|
|
22
|
+
dirs?: Record<string, number>;
|
|
23
|
+
users?: Record<string, HostUser>;
|
|
24
|
+
groups?: Record<string, number>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
|
|
28
|
+
const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
|
|
29
|
+
const errno = (code: string, path: string) =>
|
|
30
|
+
Object.assign(new Error(`${code}: ${path}`), { code });
|
|
31
|
+
const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
|
|
32
|
+
|
|
33
|
+
/** The shape `launchctl print` produced on macOS 27.2 (see launchctl.ts), trimmed. */
|
|
34
|
+
const printBlock = (target: string, pid: number | undefined, lastExit: string) =>
|
|
35
|
+
[
|
|
36
|
+
`${target} = {`,
|
|
37
|
+
'\tactive count = 1',
|
|
38
|
+
`\tstate = ${pid === undefined ? 'not running' : 'running'}`,
|
|
39
|
+
'\targuments = {',
|
|
40
|
+
'\t\t/bin/sh',
|
|
41
|
+
'}',
|
|
42
|
+
'\t}',
|
|
43
|
+
'\tresource coalition = {',
|
|
44
|
+
'\t\tstate = active',
|
|
45
|
+
'\t}',
|
|
46
|
+
...(pid === undefined ? [] : [`\tpid = ${String(pid)}`]),
|
|
47
|
+
`\tlast exit code = ${lastExit}`,
|
|
48
|
+
'}',
|
|
49
|
+
].join('\n');
|
|
50
|
+
|
|
51
|
+
export const fakeRunner = (options: FakeOptions = {}) => {
|
|
52
|
+
const euid = options.euid ?? 501;
|
|
53
|
+
const privileged = options.privileged ?? false;
|
|
54
|
+
const root = privileged || euid === 0;
|
|
55
|
+
const dirs = new Map(Object.entries(options.dirs ?? {}));
|
|
56
|
+
const files = new Map<string, Entry>();
|
|
57
|
+
const calls: string[][] = [];
|
|
58
|
+
/** target → pid (undefined = loaded, not running). */
|
|
59
|
+
const loaded = new Map<string, number | undefined>();
|
|
60
|
+
const disabled = new Set<string>();
|
|
61
|
+
const state = {
|
|
62
|
+
lingerPrints: 0,
|
|
63
|
+
nextPid: 4000,
|
|
64
|
+
bootstrapFailure: undefined as ExecResult | undefined,
|
|
65
|
+
/** Returned by bootout in place of success. The job still goes, unless `bootoutKeepsJob`. */
|
|
66
|
+
bootoutFailure: undefined as ExecResult | undefined,
|
|
67
|
+
bootoutKeepsJob: false,
|
|
68
|
+
/** gui uids with no login session: every launchctl call into `gui/<uid>` exits 112. */
|
|
69
|
+
loggedOut: new Set<number>(),
|
|
70
|
+
};
|
|
71
|
+
/** Booted-out targets launchd has not let go of yet → prints left before they vanish. */
|
|
72
|
+
const lingering = new Map<string, number>();
|
|
73
|
+
|
|
74
|
+
const labelIn = (path: string) =>
|
|
75
|
+
/<key>Label<\/key>\s*<string>([^<]*)<\/string>/.exec(
|
|
76
|
+
new TextDecoder().decode(files.get(path)?.bytes),
|
|
77
|
+
)?.[1];
|
|
78
|
+
|
|
79
|
+
const launchctl = (args: readonly string[]): ExecResult => {
|
|
80
|
+
const [sub, first, second] = args;
|
|
81
|
+
const gui = /^gui\/(\d+)(?:\/|$)/.exec(first ?? '')?.[1];
|
|
82
|
+
if (gui !== undefined && state.loggedOut.has(Number(gui))) {
|
|
83
|
+
// The measured shape (launchctl.ts): 112, "Bad request." then the domain line.
|
|
84
|
+
return fail(NO_DOMAIN, `Bad request.\nCould not find domain for user gui: ${gui}`);
|
|
85
|
+
}
|
|
86
|
+
if (sub === 'print' && first !== undefined) {
|
|
87
|
+
const left = lingering.get(first);
|
|
88
|
+
if (left !== undefined) {
|
|
89
|
+
if (left <= 0) {
|
|
90
|
+
lingering.delete(first);
|
|
91
|
+
loaded.delete(first);
|
|
92
|
+
} else lingering.set(first, left - 1);
|
|
93
|
+
}
|
|
94
|
+
if (!loaded.has(first)) return fail(NOT_FOUND, `Could not find service "${first}"`);
|
|
95
|
+
return ok(printBlock(first, loaded.get(first), '(never exited)'));
|
|
96
|
+
}
|
|
97
|
+
if (sub === 'print-disabled' && first !== undefined) {
|
|
98
|
+
const lines = [...disabled]
|
|
99
|
+
.filter((target) => target.startsWith(`${first}/`))
|
|
100
|
+
.map((target) => `\t\t"${target.slice(first.length + 1)}" => disabled`);
|
|
101
|
+
return ok(['\tdisabled services = {', ...lines, '\t}'].join('\n'));
|
|
102
|
+
}
|
|
103
|
+
if (sub === 'bootstrap' && first !== undefined && second !== undefined) {
|
|
104
|
+
if (state.bootstrapFailure !== undefined) return state.bootstrapFailure;
|
|
105
|
+
const label = labelIn(second);
|
|
106
|
+
if (label === undefined) return fail(5, 'Bootstrap failed: 5: Input/output error');
|
|
107
|
+
const target = `${first}/${label}`;
|
|
108
|
+
if (loaded.has(target) || disabled.has(target))
|
|
109
|
+
return fail(5, 'Bootstrap failed: 5: Input/output error');
|
|
110
|
+
state.nextPid += 1;
|
|
111
|
+
loaded.set(target, state.nextPid);
|
|
112
|
+
return ok();
|
|
113
|
+
}
|
|
114
|
+
if (sub === 'bootout' && first !== undefined) {
|
|
115
|
+
if (!loaded.has(first) || lingering.has(first))
|
|
116
|
+
return fail(3, 'Boot-out failed: 3: No such process');
|
|
117
|
+
if (state.bootoutKeepsJob) return state.bootoutFailure ?? fail(5, 'Boot-out failed: 5');
|
|
118
|
+
// ⚠️ Model launchd letting go late: the job stays visible for `lingerPrints` more prints.
|
|
119
|
+
if (state.lingerPrints === 0) loaded.delete(first);
|
|
120
|
+
else lingering.set(first, state.lingerPrints);
|
|
121
|
+
return state.bootoutFailure ?? ok();
|
|
122
|
+
}
|
|
123
|
+
return fail(64, `fake launchctl: unsupported ${args.join(' ')}`);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const runner: HostRunner = {
|
|
127
|
+
effectiveUid: () => euid,
|
|
128
|
+
exec: async (argv) => {
|
|
129
|
+
calls.push([...argv]);
|
|
130
|
+
if (argv[0] === LAUNCHCTL) return launchctl(argv.slice(1));
|
|
131
|
+
return fail(127, `fake: no program ${String(argv[0])}`);
|
|
132
|
+
},
|
|
133
|
+
lookupGroup: async (nameOrId) => options.groups?.[nameOrId],
|
|
134
|
+
lookupUser: async (nameOrId) =>
|
|
135
|
+
options.users?.[nameOrId] ??
|
|
136
|
+
Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
|
|
137
|
+
privileged,
|
|
138
|
+
readFile: async (path) => files.get(path)?.bytes,
|
|
139
|
+
removeFile: async (path) => {
|
|
140
|
+
if (files.has(path) && (dirs.get(parent(path)) ?? 0) !== euid && !root)
|
|
141
|
+
throw errno('EACCES', path);
|
|
142
|
+
files.delete(path);
|
|
143
|
+
},
|
|
144
|
+
// ★ No real waiting: a lingering job drains one `print` per poll instead.
|
|
145
|
+
sleep: async () => undefined,
|
|
146
|
+
stat: async (path) => {
|
|
147
|
+
const entry = files.get(path);
|
|
148
|
+
if (entry !== undefined)
|
|
149
|
+
return {
|
|
150
|
+
gid: entry.gid,
|
|
151
|
+
kind: entry.kind,
|
|
152
|
+
mode: entry.mode,
|
|
153
|
+
size: entry.bytes.length,
|
|
154
|
+
uid: entry.uid,
|
|
155
|
+
};
|
|
156
|
+
return dirs.has(path)
|
|
157
|
+
? { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 }
|
|
158
|
+
: undefined;
|
|
159
|
+
},
|
|
160
|
+
writeFileAtomic: async (path: string, bytes: Uint8Array, write: WriteOptions) => {
|
|
161
|
+
calls.push(['write', path]);
|
|
162
|
+
const owner = dirs.get(parent(path));
|
|
163
|
+
if (owner === undefined) throw errno('ENOENT', parent(path));
|
|
164
|
+
if (owner !== euid && !root) throw errno('EACCES', path);
|
|
165
|
+
if (write.uid !== undefined && write.uid !== euid && !root) throw errno('EPERM', path);
|
|
166
|
+
files.set(path, {
|
|
167
|
+
bytes,
|
|
168
|
+
gid: write.gid ?? 20,
|
|
169
|
+
kind: 'file',
|
|
170
|
+
mode: write.mode,
|
|
171
|
+
uid: write.uid ?? euid,
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
return { calls, disabled, dirs, files, loaded, runner, state };
|
|
176
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
|
|
3
|
+
* `news` both providers' diffs share.
|
|
4
|
+
*
|
|
5
|
+
* ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
|
|
6
|
+
* runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
|
|
7
|
+
* ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
|
|
8
|
+
* thrown is wrapped, so the failure channel is always an Error with a readable message.
|
|
9
|
+
*/
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
|
|
12
|
+
export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
|
|
13
|
+
Effect.tryPromise({
|
|
14
|
+
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
15
|
+
try: step,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A string prop of a diff's `news`, when that one prop is already resolved; `undefined` while it
|
|
20
|
+
* is still an Output (a function-typed proxy), an Effect or a Config.
|
|
21
|
+
* ★ For the identity props a diff must see even when the rest of `news` is unresolved — a plain
|
|
22
|
+
* string is resolved by definition, so `typeof` is the whole test.
|
|
23
|
+
*/
|
|
24
|
+
export const resolvedString = (news: unknown, key: string): string | undefined => {
|
|
25
|
+
if (typeof news !== 'object' || news === null) return undefined;
|
|
26
|
+
const value = (news as Record<string, unknown>)[key];
|
|
27
|
+
return typeof value === 'string' ? value : undefined;
|
|
28
|
+
};
|