@homeflare/alchemy 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +74 -41
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +91 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +34 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +54 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +22 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +970 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +51 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-sudo.d.ts +50 -0
  53. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +18 -9
  55. package/dist/launchd/host-effect.d.ts.map +1 -1
  56. package/dist/launchd/host-file-form.d.ts +5 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -1
  58. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  59. package/dist/launchd/index.d.ts +2 -0
  60. package/dist/launchd/index.d.ts.map +1 -1
  61. package/dist/launchd/index.js +342 -8
  62. package/dist/launchd/index.js.map +16 -12
  63. package/dist/launchd/job-preflight.d.ts.map +1 -1
  64. package/dist/launchd/job-validate.d.ts +2 -0
  65. package/dist/launchd/job-validate.d.ts.map +1 -1
  66. package/dist/launchd/local-runner.d.ts +2 -0
  67. package/dist/launchd/local-runner.d.ts.map +1 -1
  68. package/dist/launchd/providers.d.ts +2 -1
  69. package/dist/launchd/providers.d.ts.map +1 -1
  70. package/dist/launchd/runner.d.ts +7 -5
  71. package/dist/launchd/runner.d.ts.map +1 -1
  72. package/dist/launchd/secret-tripwire.d.ts +1 -0
  73. package/dist/launchd/secret-tripwire.d.ts.map +1 -1
  74. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  75. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  76. package/dist/launchd/sudo-guard.d.ts +56 -0
  77. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  78. package/dist/launchd/sudo-runner.d.ts +60 -0
  79. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  80. package/dist/launchd/sudo-stage.d.ts +7 -0
  81. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  82. package/dist/openbao/auth-role-form.d.ts +0 -9
  83. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  84. package/dist/openbao/auth-role.d.ts +3 -2
  85. package/dist/openbao/auth-role.d.ts.map +1 -1
  86. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  87. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  88. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  89. package/dist/openbao/fake-engines.d.ts +36 -0
  90. package/dist/openbao/fake-engines.d.ts.map +1 -0
  91. package/dist/openbao/fake-families.d.ts +49 -0
  92. package/dist/openbao/fake-families.d.ts.map +1 -0
  93. package/dist/openbao/fake-stack.d.ts +29 -0
  94. package/dist/openbao/fake-stack.d.ts.map +1 -0
  95. package/dist/openbao/host-approles.d.ts.map +1 -1
  96. package/dist/openbao/index.js +393 -234
  97. package/dist/openbao/index.js.map +23 -19
  98. package/dist/openbao/jwt-config.d.ts +5 -0
  99. package/dist/openbao/jwt-config.d.ts.map +1 -1
  100. package/dist/openbao/jwt-role.d.ts +2 -1
  101. package/dist/openbao/jwt-role.d.ts.map +1 -1
  102. package/dist/openbao/kubernetes-role.d.ts +2 -1
  103. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  104. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  105. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  106. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  107. package/dist/openbao/pki-role.d.ts.map +1 -1
  108. package/dist/openbao/plugin.d.ts.map +1 -1
  109. package/dist/openbao/policy.d.ts +2 -0
  110. package/dist/openbao/policy.d.ts.map +1 -1
  111. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  112. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  113. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  114. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  115. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  116. package/dist/openbao/rename-identity.d.ts +69 -0
  117. package/dist/openbao/rename-identity.d.ts.map +1 -0
  118. package/dist/openbao/rename.d.ts +107 -0
  119. package/dist/openbao/rename.d.ts.map +1 -0
  120. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  121. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  122. package/dist/openbao/ssh-role.d.ts.map +1 -1
  123. package/docs/caddy-admin.md +24 -0
  124. package/docs/caddy.md +199 -0
  125. package/docs/launchd-sudo.md +122 -0
  126. package/docs/launchd.md +7 -4
  127. package/docs/mesh-node.md +188 -0
  128. package/docs/peers.md +39 -0
  129. package/package.json +7 -2
  130. package/src/caddy/admin-calls.ts +128 -0
  131. package/src/caddy/admin-guard.ts +170 -0
  132. package/src/caddy/admin.ts +74 -0
  133. package/src/caddy/adopt-scope.test.ts +82 -0
  134. package/src/caddy/caddyfile-tripwire.ts +133 -0
  135. package/src/caddy/config-adopt.test.ts +235 -0
  136. package/src/caddy/config-form.ts +64 -0
  137. package/src/caddy/config-lifecycle.test.ts +196 -0
  138. package/src/caddy/config-lifecycle.ts +239 -0
  139. package/src/caddy/config.ts +153 -0
  140. package/src/caddy/digest.ts +44 -0
  141. package/src/caddy/fake-caddy.ts +179 -0
  142. package/src/caddy/guards.test.ts +156 -0
  143. package/src/caddy/index.ts +26 -0
  144. package/src/caddy/local-admin.test.ts +160 -0
  145. package/src/caddy/local-admin.ts +189 -0
  146. package/src/caddy/providers.test.ts +229 -0
  147. package/src/caddy/providers.ts +17 -0
  148. package/src/caddy/with-file.ts +66 -0
  149. package/src/cloudflare/fake-mesh.ts +181 -0
  150. package/src/cloudflare/index.ts +9 -3
  151. package/src/cloudflare/mesh-node-api.ts +127 -0
  152. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  153. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  154. package/src/cloudflare/mesh-node-form.ts +115 -0
  155. package/src/cloudflare/mesh-node-harness.ts +124 -0
  156. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  157. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  158. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  159. package/src/cloudflare/mesh-node-token.ts +125 -0
  160. package/src/cloudflare/mesh-node.test.ts +172 -0
  161. package/src/cloudflare/mesh-node.ts +120 -0
  162. package/src/cloudflare/providers.ts +20 -2
  163. package/src/index.ts +1 -0
  164. package/src/launchd/fake-sudo.ts +136 -0
  165. package/src/launchd/host-effect.ts +33 -1
  166. package/src/launchd/host-file-form.ts +10 -2
  167. package/src/launchd/host-file-lifecycle.ts +2 -1
  168. package/src/launchd/index.ts +2 -0
  169. package/src/launchd/job-preflight.ts +3 -2
  170. package/src/launchd/job-validate.ts +1 -1
  171. package/src/launchd/local-runner.ts +6 -3
  172. package/src/launchd/providers.test.ts +3 -0
  173. package/src/launchd/providers.ts +2 -1
  174. package/src/launchd/runner.ts +7 -5
  175. package/src/launchd/secret-tripwire.ts +1 -1
  176. package/src/launchd/sudo-allowlist.test.ts +191 -0
  177. package/src/launchd/sudo-allowlist.ts +213 -0
  178. package/src/launchd/sudo-guard.test.ts +100 -0
  179. package/src/launchd/sudo-guard.ts +151 -0
  180. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  181. package/src/launchd/sudo-runner-files.test.ts +240 -0
  182. package/src/launchd/sudo-runner.test.ts +126 -0
  183. package/src/launchd/sudo-runner.ts +235 -0
  184. package/src/launchd/sudo-stage.ts +28 -0
  185. package/src/openbao/README.md +8 -2
  186. package/src/openbao/REPLACE.md +126 -22
  187. package/src/openbao/auth-role-form.test.ts +2 -16
  188. package/src/openbao/auth-role-form.ts +0 -11
  189. package/src/openbao/auth-role.ts +35 -14
  190. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  191. package/src/openbao/cloudflare-role.ts +22 -8
  192. package/src/openbao/fake-engines-roles.ts +114 -0
  193. package/src/openbao/fake-engines.ts +126 -0
  194. package/src/openbao/fake-families.ts +182 -0
  195. package/src/openbao/fake-stack.ts +112 -0
  196. package/src/openbao/host-approles.test.ts +15 -2
  197. package/src/openbao/host-approles.ts +19 -4
  198. package/src/openbao/jwt-config.ts +26 -5
  199. package/src/openbao/jwt-role.ts +24 -8
  200. package/src/openbao/kubernetes-role.ts +20 -8
  201. package/src/openbao/mfa-enforcement.ts +23 -8
  202. package/src/openbao/mfa-totp.ts +21 -8
  203. package/src/openbao/pki-role.ts +19 -7
  204. package/src/openbao/plugin.ts +41 -15
  205. package/src/openbao/policy-rename.test.ts +112 -0
  206. package/src/openbao/policy-wire.test.ts +7 -26
  207. package/src/openbao/policy.ts +27 -4
  208. package/src/openbao/proxmox-role-form.ts +27 -8
  209. package/src/openbao/proxmox-role-wire.ts +24 -0
  210. package/src/openbao/proxmox-role.ts +52 -36
  211. package/src/openbao/rename-adoption.test.ts +82 -0
  212. package/src/openbao/rename-families.test.ts +122 -0
  213. package/src/openbao/rename-identity.test.ts +129 -0
  214. package/src/openbao/rename-identity.ts +117 -0
  215. package/src/openbao/rename-occupied.test.ts +166 -0
  216. package/src/openbao/rename.ts +166 -0
  217. package/src/openbao/role-rename.test.ts +205 -0
  218. package/src/openbao/ssh-role-erase.ts +50 -0
  219. package/src/openbao/ssh-role.ts +16 -54
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Ownership with no state — decision 2026-09-21: CaddyConfig never adopts silently. The adoption
3
+ * probe (`read` with no output) and the apply of a create the engine never probed must agree on
4
+ * what is claimable: the declared config itself, or a Caddy serving nothing. Anything else is
5
+ * `Unowned` at plan time and refused at apply, unless the deploy runs with `--adopt` — and state
6
+ * lends authority only over the Caddy it was applied to.
7
+ */
8
+ import { afterEach, describe, expect, test } from 'bun:test';
9
+ import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
10
+ import { AlchemyContext } from 'alchemy/AlchemyContext';
11
+ import * as Effect from 'effect/Effect';
12
+ import * as Layer from 'effect/Layer';
13
+ import { type CaddyAdmin, CaddyUnreachableError, caddyAdminLayer } from './admin.ts';
14
+ import { CaddyConfig, CaddyConfigProvider } from './config.ts';
15
+ import { probeLive, reconcileConfig } from './config-lifecycle.ts';
16
+ import { configDigest } from './digest.ts';
17
+ import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
18
+
19
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
20
+ /** A config someone else loaded: it serves a site, and it is not what the stack declares. */
21
+ const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
22
+ const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
23
+
24
+ let fake: FakeCaddy | undefined;
25
+ afterEach(() => {
26
+ fake?.stop();
27
+ fake = undefined;
28
+ });
29
+
30
+ const setup = (running?: unknown) => {
31
+ const made = fakeDefaultCaddy(running);
32
+ fake = made.caddy;
33
+ return made;
34
+ };
35
+
36
+ /** A Caddy already running the declared config — as one started from caddyWithFile's file is. */
37
+ const runningDeclared = () => {
38
+ const made = setup();
39
+ made.caddy.running = made.caddy.adapt(SITE);
40
+ return made;
41
+ };
42
+
43
+ const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
44
+
45
+ describe('the probe: read with no state', () => {
46
+ test('a Caddy already running the declared config is ours — adopting it changes nothing', async () => {
47
+ const { admin, caddy } = runningDeclared();
48
+ const probe = await probeLive(admin, SITE);
49
+ expect(probe).toMatchObject({ attributes: { configSha256: configDigest(caddy.adapt(SITE)) } });
50
+ expect(probe?.ours).toBe(true);
51
+ expect(loads(caddy)).toHaveLength(0);
52
+ });
53
+
54
+ test('a Caddy running any other config is not ours', async () => {
55
+ const { admin } = setup(FOREIGN);
56
+ const probe = await probeLive(admin, SITE, '/usr/local/etc/Caddyfile');
57
+ expect(probe?.ours).toBe(false);
58
+ expect(probe?.unchecked).toBeUndefined();
59
+ expect(probe?.attributes).toEqual({
60
+ configSha256: configDigest(FOREIGN),
61
+ endpoint: fake?.address ?? '',
62
+ sourceFile: '/usr/local/etc/Caddyfile',
63
+ });
64
+ });
65
+
66
+ test.each([
67
+ ['no config at all', null],
68
+ ['only an admin block', { admin: { listen: 'localhost:2019' } }],
69
+ ])('a Caddy serving nothing (%s) reads as absent: a create', async (_, running) => {
70
+ const { admin } = setup(running);
71
+ expect(await probeLive(admin, SITE)).toBeUndefined();
72
+ });
73
+
74
+ test.each([
75
+ ['does not adapt', 'SYNTAX_ERROR', /Error during parsing/],
76
+ ['is not plain text (a stripped Output)', undefined, /not plain text/],
77
+ ])('a declaration that %s is not ours — and never fails the read', async (_, text, reason) => {
78
+ // ⛔ The engine replays this read with an interrupted create's props: a throw here would fail
79
+ // every later plan, the one carrying the fix included.
80
+ const { admin } = setup(FOREIGN);
81
+ const probe = await probeLive(admin, text);
82
+ expect(probe?.ours).toBe(false);
83
+ expect(probe?.unchecked).toMatch(reason);
84
+ });
85
+
86
+ test('Caddy vanishing mid-probe stays "no Caddy", never "not ours"', async () => {
87
+ const { admin } = setup(FOREIGN);
88
+ const flaky: CaddyAdmin = {
89
+ ...admin,
90
+ request: (call) =>
91
+ call.path === '/adapt'
92
+ ? Promise.reject(new CaddyUnreachableError('gone'))
93
+ : admin.request(call),
94
+ };
95
+ await expect(probeLive(flaky, SITE)).rejects.toThrow('gone');
96
+ });
97
+ });
98
+
99
+ /** A create the engine never probed, or state from another endpoint (config.ts decides which). */
100
+ describe('the apply without authority over the running config', () => {
101
+ test('without authority, a config the stack cannot claim is refused before any load', async () => {
102
+ const { admin, caddy } = setup(FOREIGN);
103
+ await expect(reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false })).rejects.toThrow(
104
+ /which this stack did not load.*Deploy with --adopt/,
105
+ );
106
+ expect(loads(caddy)).toHaveLength(0);
107
+ expect(caddy.running).toEqual(FOREIGN);
108
+ });
109
+
110
+ test('the declared config already running is taken as it is — no load', async () => {
111
+ const { admin, caddy } = runningDeclared();
112
+ const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false });
113
+ expect(applied.loaded).toBe(false);
114
+ expect(loads(caddy)).toHaveLength(0);
115
+ });
116
+
117
+ test('a Caddy serving nothing is loaded — there is nothing to take over', async () => {
118
+ const { admin, caddy } = setup();
119
+ const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false });
120
+ expect(applied.loaded).toBe(true);
121
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
122
+ });
123
+
124
+ test('the config the state last stored is known — the same Caddy at a new endpoint', async () => {
125
+ const { admin } = setup(FOREIGN);
126
+ const authority = { stored: configDigest(FOREIGN), takeOver: false };
127
+ expect((await reconcileConfig(admin, { caddyfile: SITE }, authority)).loaded).toBe(true);
128
+ });
129
+
130
+ test('with authority, the foreign config is replaced', async () => {
131
+ const { admin, caddy } = setup(FOREIGN);
132
+ const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: true });
133
+ expect(applied.loaded).toBe(true);
134
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
135
+ });
136
+ });
137
+
138
+ type Deploy = { readonly adopt?: boolean; readonly contextAdopt?: boolean };
139
+
140
+ /** The provider over `admin`, run under the services a deploy provides (the CLI's `--adopt`). */
141
+ const withProvider = <A>(
142
+ admin: CaddyAdmin,
143
+ deploy: Deploy,
144
+ use: (p: Effect.Success<typeof CaddyConfig.Provider>) => Effect.Effect<A, unknown>,
145
+ ) => {
146
+ let program = Effect.gen(function* () {
147
+ return yield* use(yield* CaddyConfig.Provider);
148
+ }).pipe(Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(admin)))));
149
+ if (deploy.adopt !== undefined) {
150
+ program = program.pipe(Effect.provideService(AdoptPolicy, deploy.adopt));
151
+ }
152
+ if (deploy.contextAdopt !== undefined) {
153
+ const context = { adopt: deploy.contextAdopt, dev: false, dotAlchemy: '.alchemy' };
154
+ program = program.pipe(Effect.provideService(AlchemyContext, context));
155
+ }
156
+ return Effect.runPromise(program);
157
+ };
158
+
159
+ const read = (admin: CaddyAdmin, caddyfile: unknown, output?: object) =>
160
+ withProvider(admin, {}, (provider) => {
161
+ if (provider.read === undefined) throw new Error('provider has no read handler');
162
+ return provider.read({ ...ids, olds: { caddyfile } as never, output: output as never });
163
+ });
164
+
165
+ /** Apply, then check that exactly the expected loads reached Caddy — or none, and why. */
166
+ const expectApply = async (applying: Promise<unknown>, caddy: FakeCaddy, loaded: boolean) => {
167
+ if (loaded) await applying;
168
+ else await expect(applying).rejects.toThrow(/Deploy with --adopt/);
169
+ expect(loads(caddy)).toHaveLength(loaded ? 1 : 0);
170
+ };
171
+
172
+ const reconcile = (admin: CaddyAdmin, deploy: Deploy, output?: object) =>
173
+ withProvider(admin, deploy, (provider) =>
174
+ provider.reconcile({
175
+ ...ids,
176
+ bindings: [] as never,
177
+ news: { caddyfile: SITE },
178
+ olds: undefined,
179
+ output: output as never,
180
+ session: undefined as never,
181
+ }),
182
+ );
183
+
184
+ describe('CaddyConfigProvider', () => {
185
+ test('read: Unowned with no state, plain with state, plain when it is the declared config', async () => {
186
+ const { admin, caddy } = setup(FOREIGN);
187
+ const probe = await read(admin, SITE);
188
+ expect(Unowned.is(probe)).toBe(true);
189
+ expect(probe).toMatchObject({ configSha256: configDigest(FOREIGN) });
190
+ const owned = await read(admin, SITE, { configSha256: 'stored', endpoint: caddy.address });
191
+ expect(owned).toBeDefined();
192
+ expect(Unowned.is(owned)).toBe(false);
193
+ caddy.running = caddy.adapt(SITE);
194
+ const same = await read(admin, SITE);
195
+ expect(same).toBeDefined();
196
+ expect(Unowned.is(same)).toBe(false);
197
+ });
198
+
199
+ test('read with no state: nothing served is a create; an unreadable declaration is Unowned', async () => {
200
+ expect(await read(setup().admin, SITE)).toBeUndefined();
201
+ fake?.stop();
202
+ const { admin } = setup(FOREIGN);
203
+ expect(Unowned.is(await read(admin, 'SYNTAX_ERROR'))).toBe(true);
204
+ expect(Unowned.is(await read(admin, {}))).toBe(true);
205
+ });
206
+
207
+ test.each([
208
+ ['no adopt setting at all', {}, false],
209
+ ['AdoptPolicy off', { adopt: false }, false],
210
+ ['--adopt (AdoptPolicy)', { adopt: true }, true],
211
+ ['--adopt reaching only AlchemyContext', { contextAdopt: true }, true],
212
+ ['AdoptPolicy off over AlchemyContext on', { adopt: false, contextAdopt: true }, false],
213
+ ] as const)('reconcile with no state, %s', async (_, deploy, loaded) => {
214
+ const { admin, caddy } = setup(FOREIGN);
215
+ await expectApply(reconcile(admin, deploy), caddy, loaded);
216
+ });
217
+
218
+ test('reconcile with state for this Caddy loads over anything, without --adopt', async () => {
219
+ // ★ An update correcting drift (a hand edit since the stored digest), or an adopted create.
220
+ const { admin, caddy } = setup(FOREIGN);
221
+ const output = { configSha256: 'before-a-hand-edit', endpoint: caddy.address };
222
+ await expectApply(reconcile(admin, {}, output), caddy, true);
223
+ });
224
+
225
+ test.each([
226
+ ['a config it never stored, without --adopt', 'stored-elsewhere', {}, false],
227
+ ['a config it never stored, with --adopt', 'stored-elsewhere', { adopt: true }, true],
228
+ ['the config it last stored (the same Caddy, renamed)', configDigest(FOREIGN), {}, true],
229
+ ] as const)('reconcile with state from another endpoint: %s', async (_, sha, deploy, loaded) => {
230
+ // ⛔ State vouches for the Caddy it was applied to — not for whatever the transport now reaches.
231
+ const { admin, caddy } = setup(FOREIGN);
232
+ const output = { configSha256: sha, endpoint: 'unix:///run/elsewhere.sock' };
233
+ await expectApply(reconcile(admin, deploy, output), caddy, loaded);
234
+ });
235
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * CaddyConfig's props, attributes and validation — the pure half of the provider.
3
+ *
4
+ * ⛔ `caddyfile` IS NEVER A SECRET. Alchemy stores props unencrypted, so the whole Caddyfile sits in
5
+ * the state store and in every plan diff. Secrets reach Caddy as runtime placeholders
6
+ * (`{env.NAME}`, `{file./path}`) resolved from files a secret renderer writes — see
7
+ * caddyfile-tripwire.ts, which refuses the obvious literals.
8
+ */
9
+ import { caddyfileSecretProblems } from './caddyfile-tripwire.ts';
10
+
11
+ export interface CaddyConfigProps {
12
+ /**
13
+ * The whole Caddyfile. Applied with `POST /load` (Content-Type `text/caddyfile`); it REPLACES the
14
+ * running config. ⛔ Never a secret. ⚠️ `import` paths resolve against Caddy's working directory
15
+ * (the API adapts with no file name), so keep them absolute.
16
+ */
17
+ caddyfile: string;
18
+ /**
19
+ * The file Caddy was started with (`caddy run --config <this>`), when the same Caddyfile is also
20
+ * written there — `caddyWithFile()` sets it from its HostFile. Sent as
21
+ * `Caddy-Config-Source-File` so a load keeps Caddy's SIGUSR1 reload-from-file working.
22
+ */
23
+ sourceFile?: string;
24
+ }
25
+
26
+ export interface CaddyConfigAttributes {
27
+ /**
28
+ * SHA-256 of the ADAPTED JSON Caddy reported after the last apply (digest.ts canonical form) —
29
+ * never the config itself. Drift is the live config hashing to anything else.
30
+ */
31
+ configSha256: string;
32
+ /** The admin endpoint the config was applied through (no credential; the API has none). */
33
+ endpoint: string;
34
+ /** The `sourceFile` prop as applied. */
35
+ sourceFile?: string;
36
+ }
37
+
38
+ const absoluteNormalised = (path: string): boolean =>
39
+ path.startsWith('/') &&
40
+ !path.endsWith('/') &&
41
+ !path.includes('\x00') &&
42
+ !path
43
+ .split('/')
44
+ .slice(1)
45
+ .some((part) => part === '' || part === '.' || part === '..');
46
+
47
+ export const configProblems = (props: CaddyConfigProps): string[] => {
48
+ const found: string[] = [];
49
+ /**
50
+ * ⛔ AN EMPTY CADDYFILE IS NOT "NOTHING TO DO". It adapts to a config with no apps, and loading
51
+ * it stops every server Caddy runs — every site down, on a templating bug that rendered ''.
52
+ * Emptying Caddy is a deliberate act for a person at the admin API, not a declaration.
53
+ * Text that is not empty but serves nothing (only comments, only global options) is caught
54
+ * after `/adapt`, where it shows as no apps (config-lifecycle.ts servesNothing).
55
+ */
56
+ if (props.caddyfile.trim() === '') {
57
+ found.push('caddyfile is empty — loading it would stop every site Caddy serves');
58
+ }
59
+ if (props.sourceFile !== undefined && !absoluteNormalised(props.sourceFile)) {
60
+ found.push('sourceFile must be an absolute, normalised path');
61
+ }
62
+ found.push(...caddyfileSecretProblems(props.caddyfile));
63
+ return found;
64
+ };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * CaddyConfig's lifecycle against the fake admin API (fake-caddy.ts): apply, a refused config
3
+ * rolled back, drift, and the refusals that must happen before anything is sent.
4
+ */
5
+ import { afterEach, describe, expect, test } from 'bun:test';
6
+ import type { CaddyAdmin } from './admin.ts';
7
+ import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
8
+ import { configDigest } from './digest.ts';
9
+ import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
10
+
11
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
12
+ const OTHER = 'www.example.com respond <hello> & bye';
13
+
14
+ let fake: FakeCaddy | undefined;
15
+ afterEach(() => {
16
+ fake?.stop();
17
+ fake = undefined;
18
+ });
19
+
20
+ /** A Caddy on its default :2019 (fake-caddy.ts fakeDefaultCaddy), stopped after each test. */
21
+ const setup = (running?: unknown) => {
22
+ const made = fakeDefaultCaddy(running);
23
+ fake = made.caddy;
24
+ return made;
25
+ };
26
+
27
+ /** ★ These are the resource's own applies (it has state); config-adopt.test.ts covers a create. */
28
+ const OWNED = { takeOver: true } as const;
29
+
30
+ const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
31
+
32
+ describe('apply', () => {
33
+ test('loads the Caddyfile, reads it back, and stores the digest Caddy reports', async () => {
34
+ const { admin, caddy } = setup();
35
+ const applied = await reconcileConfig(admin, { caddyfile: OTHER }, OWNED);
36
+ expect(applied.loaded).toBe(true);
37
+ // ★ The fake's /config/ is sorted and escaped differently from /adapt; the digests still agree.
38
+ expect(applied.attributes.configSha256).toBe(configDigest(caddy.adapt(OTHER)));
39
+ expect(applied.attributes.endpoint).toBe(caddy.address);
40
+ const [load] = loads(caddy);
41
+ expect(load?.headers.get('content-type')).toBe('text/caddyfile');
42
+ expect(load?.body).toBe(OTHER);
43
+ });
44
+
45
+ test('a Caddy already running this config is not reloaded', async () => {
46
+ const { admin, caddy } = setup();
47
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
48
+ const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
49
+ expect(again.loaded).toBe(false);
50
+ expect(loads(caddy)).toHaveLength(1);
51
+ });
52
+
53
+ test('sourceFile rides as the headers `caddy reload` sends, and lands in the attributes', async () => {
54
+ const { admin, caddy } = setup();
55
+ const path = '/usr/local/etc/Caddyfile';
56
+ const applied = await reconcileConfig(admin, { caddyfile: SITE, sourceFile: path }, OWNED);
57
+ expect(loads(caddy)[0]?.headers.get('caddy-config-source-file')).toBe(path);
58
+ expect(loads(caddy)[0]?.headers.get('caddy-config-source-adapter')).toBe('caddyfile');
59
+ expect(applied.attributes.sourceFile).toBe(path);
60
+ });
61
+
62
+ test('adapter warnings are returned, not refused', async () => {
63
+ const { admin } = setup();
64
+ const applied = await reconcileConfig(admin, { caddyfile: `${SITE}\t` }, OWNED);
65
+ expect(applied.warnings).toEqual(['Caddyfile:1: Caddyfile input is not formatted']);
66
+ });
67
+ });
68
+
69
+ describe('a config Caddy refuses', () => {
70
+ test('surfaces Caddy’s reason and confirms the previous config is still running', async () => {
71
+ const { admin, caddy } = setup();
72
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
73
+ const before = caddy.running;
74
+ await expect(
75
+ reconcileConfig(admin, { caddyfile: `${SITE}\nPROVISION_ERROR` }, OWNED),
76
+ ).rejects.toThrow(/address already in use.*kept the previous config/);
77
+ expect(caddy.running).toEqual(before);
78
+ });
79
+
80
+ test('a refusal that came back as a 200 (warnings first) is still a refusal', async () => {
81
+ const { admin, caddy } = setup();
82
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
83
+ await expect(
84
+ reconcileConfig(admin, { caddyfile: `${SITE}\t\nPROVISION_ERROR` }, OWNED),
85
+ ).rejects.toThrow(/→ 200: loading config.*error in a 200 body.*kept the previous/);
86
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
87
+ });
88
+
89
+ test('a load answered 200 that Caddy did not apply fails the deploy (the read-back)', async () => {
90
+ const { admin, caddy } = setup();
91
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
92
+ // ⚠️ A concurrent writer, or a 200 that carried no change: state must not record OTHER.
93
+ const swallowing: CaddyAdmin = {
94
+ ...admin,
95
+ request: (call) =>
96
+ call.path === '/load' ? Promise.resolve({ body: '', status: 200 }) : admin.request(call),
97
+ };
98
+ await expect(reconcileConfig(swallowing, { caddyfile: OTHER }, OWNED)).rejects.toThrow(
99
+ /after the load Caddy runs config \w+, not the \w+ this Caddyfile adapts to/,
100
+ );
101
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
102
+ });
103
+
104
+ test('a Caddyfile that does not adapt is refused before any load', async () => {
105
+ const { admin, caddy } = setup();
106
+ await expect(reconcileConfig(admin, { caddyfile: 'SYNTAX_ERROR' }, OWNED)).rejects.toThrow(
107
+ /\/adapt → 400: Caddyfile:1 - Error during parsing/,
108
+ );
109
+ expect(loads(caddy)).toHaveLength(0);
110
+ });
111
+ });
112
+
113
+ describe('refused before anything is sent', () => {
114
+ test.each([
115
+ ['a literal token', 'example.com {\n dns cloudflare abcdef0123456789\n}'],
116
+ ['an empty Caddyfile', ' \n'],
117
+ ])('%s', async (_, caddyfile) => {
118
+ const { admin, caddy } = setup();
119
+ await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(
120
+ /Caddy\.Config at http/,
121
+ );
122
+ expect(caddy.seen).toHaveLength(0);
123
+ });
124
+
125
+ test.each([
126
+ ['only a comment', '# the template rendered nothing'],
127
+ ['only global options', 'admin localhost:2019'],
128
+ ])('a Caddyfile of %s adapts to no apps, and is never loaded', async (_, caddyfile) => {
129
+ const { admin, caddy } = setup();
130
+ await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(/adapts to no apps/);
131
+ expect(loads(caddy)).toHaveLength(0);
132
+ });
133
+
134
+ test('a Caddyfile that would turn the admin API off is never loaded', async () => {
135
+ const { admin, caddy } = setup();
136
+ await expect(
137
+ reconcileConfig(admin, { caddyfile: `admin off\n${SITE}` }, OWNED),
138
+ ).rejects.toThrow(/admin off/);
139
+ expect(loads(caddy)).toHaveLength(0);
140
+ });
141
+ });
142
+
143
+ describe('drift', () => {
144
+ const applied = async () => {
145
+ const { admin, caddy } = setup();
146
+ const { attributes } = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
147
+ return { admin, attributes, caddy };
148
+ };
149
+
150
+ test('converged: declared, live and stored agree → noop', async () => {
151
+ const { admin, attributes } = await applied();
152
+ expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'noop' });
153
+ });
154
+
155
+ test('a changed declaration → update', async () => {
156
+ const { admin, attributes } = await applied();
157
+ expect(await diffConfig(admin, { caddyfile: OTHER }, attributes)).toEqual({ action: 'update' });
158
+ });
159
+
160
+ test('a hand edit through the API → update, and the apply puts the declaration back', async () => {
161
+ const { admin, attributes, caddy } = await applied();
162
+ caddy.running = { apps: { http: { servers: {} } } };
163
+ expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'update' });
164
+ const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
165
+ expect(again.attributes.configSha256).toBe(attributes.configSha256);
166
+ });
167
+
168
+ test('a restart with a different file → update', async () => {
169
+ const { admin, attributes, caddy } = await applied();
170
+ caddy.running = JSON.parse(JSON.stringify(caddy.adapt(OTHER))) as unknown;
171
+ expect((await readLive(admin)).configSha256).not.toBe(attributes.configSha256);
172
+ expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'update' });
173
+ });
174
+
175
+ test('state that lags the live config → update, so the stored digest is refreshed', async () => {
176
+ const { admin, attributes } = await applied();
177
+ const stale = { ...attributes, configSha256: configDigest({ apps: { before: true } }) };
178
+ expect(await diffConfig(admin, { caddyfile: SITE }, stale)).toEqual({ action: 'update' });
179
+ });
180
+
181
+ test('the transport now reaches Caddy at another endpoint → update (load there)', async () => {
182
+ const { admin, attributes } = await applied();
183
+ const moved = { ...attributes, endpoint: 'unix:///run/caddy/admin.sock' };
184
+ expect(await diffConfig(admin, { caddyfile: SITE }, moved)).toEqual({ action: 'update' });
185
+ });
186
+
187
+ test('a new sourceFile → update, so state records the file this config mirrors', async () => {
188
+ const { admin, attributes } = await applied();
189
+ const diff = await diffConfig(
190
+ admin,
191
+ { caddyfile: SITE, sourceFile: '/etc/Caddyfile' },
192
+ attributes,
193
+ );
194
+ expect(diff).toEqual({ action: 'update' });
195
+ });
196
+ });