@homeflare/alchemy 0.28.0 → 0.29.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 +2 -0
- package/dist/caddy/admin-address.d.ts +27 -0
- package/dist/caddy/admin-address.d.ts.map +1 -0
- package/dist/caddy/admin-calls.d.ts +25 -28
- package/dist/caddy/admin-calls.d.ts.map +1 -1
- package/dist/caddy/admin.d.ts +28 -38
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/caddy-http-client.d.ts +25 -0
- package/dist/caddy/caddy-http-client.d.ts.map +1 -0
- package/dist/caddy/config-errors.d.ts +24 -0
- package/dist/caddy/config-errors.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +20 -49
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config-reconcile.d.ts +45 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +4 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +10 -2
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +3 -3
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +487 -444
- package/dist/caddy/index.js.map +16 -12
- package/dist/caddy/local-admin.d.ts +20 -34
- package/dist/caddy/local-admin.d.ts.map +1 -1
- package/dist/caddy/providers.d.ts +19 -3
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +4 -4
- package/dist/forgejo/org-actions-secrets.d.ts +4 -4
- package/dist/forgejo/org-label.d.ts +4 -4
- package/dist/forgejo/org-team.d.ts +4 -4
- package/dist/forgejo/repo-webhook.d.ts +4 -4
- package/dist/forgejo/repository.d.ts +4 -4
- package/dist/forgejo/team-member.d.ts +4 -4
- package/dist/github/declare-repo-baseline.d.ts +1 -1
- package/dist/github/declare-repo-baseline.d.ts.map +1 -1
- package/dist/github/index.d.ts +8 -4
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +63 -23
- package/dist/github/index.js.map +13 -12
- package/dist/github/repo-baseline-data.d.ts +10 -1
- package/dist/github/repo-baseline-data.d.ts.map +1 -1
- package/dist/github/repo-policy.d.ts +1 -1
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-errors.d.ts +3 -2
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
- package/dist/github/repository-ruleset-form.d.ts.map +1 -1
- package/dist/github/repository-ruleset-guards.d.ts +3 -2
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
- package/dist/github/repository-ruleset-octokit.d.ts +49 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
- package/dist/github/repository-ruleset-probe.d.ts +2 -1
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
- package/dist/github/repository-ruleset-props.d.ts +7 -4
- package/dist/github/repository-ruleset-props.d.ts.map +1 -1
- package/dist/github/repository-ruleset-providers.d.ts +42 -0
- package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
- package/dist/github/repository-ruleset.d.ts +26 -4
- package/dist/github/repository-ruleset.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts +27 -16
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/fake-stack.d.ts +9 -4
- package/dist/litellm/fake-stack.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +7 -5
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +53 -123
- package/dist/litellm/index.js.map +8 -8
- package/dist/litellm/operations.d.ts +60 -0
- package/dist/litellm/operations.d.ts.map +1 -0
- package/dist/litellm/pass-through-endpoint.d.ts +10 -28
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
- package/dist/litellm/pass-through-errors.d.ts +37 -0
- package/dist/litellm/pass-through-errors.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +11 -4
- package/dist/litellm/pass-through-form.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +7 -5
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/netbox/prefix.d.ts +4 -4
- package/dist/paperless/custom-field.d.ts +21 -21
- package/dist/paperless/custom-field.d.ts.map +1 -1
- package/dist/paperless/document-type.d.ts +34 -4
- package/dist/paperless/document-type.d.ts.map +1 -1
- package/dist/paperless/fake-paperless.d.ts +36 -19
- package/dist/paperless/fake-paperless.d.ts.map +1 -1
- package/dist/paperless/index.d.ts +7 -2
- package/dist/paperless/index.d.ts.map +1 -1
- package/dist/paperless/index.js +164 -228
- package/dist/paperless/index.js.map +10 -13
- package/dist/paperless/matching-locate.d.ts +42 -20
- package/dist/paperless/matching-locate.d.ts.map +1 -1
- package/dist/paperless/matching-types.d.ts +47 -18
- package/dist/paperless/matching-types.d.ts.map +1 -1
- package/dist/paperless/matching.d.ts +30 -27
- package/dist/paperless/matching.d.ts.map +1 -1
- package/dist/paperless/providers.d.ts +13 -7
- package/dist/paperless/providers.d.ts.map +1 -1
- package/dist/paperless/storage-path.d.ts +34 -4
- package/dist/paperless/storage-path.d.ts.map +1 -1
- package/dist/paperless/tag.d.ts +43 -8
- package/dist/paperless/tag.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts +30 -23
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/distilled-pve.d.ts +89 -0
- package/dist/proxmox/distilled-pve.d.ts.map +1 -0
- package/dist/proxmox/index.js +663 -609
- package/dist/proxmox/index.js.map +12 -11
- package/dist/proxmox/members.d.ts +29 -10
- package/dist/proxmox/members.d.ts.map +1 -1
- package/docs/caddy-admin.md +1 -1
- package/docs/caddy.md +3 -2
- package/docs/credentials.md +16 -0
- package/docs/litellm.md +21 -13
- package/docs/paperless.md +12 -0
- package/docs/peers.md +5 -0
- package/docs/repo-policy.md +16 -1
- package/docs/repository-ruleset.md +53 -13
- package/docs/upstream-conformance.md +126 -10
- package/package.json +5 -2
- package/src/caddy/admin-address.ts +71 -0
- package/src/caddy/admin-calls.ts +48 -100
- package/src/caddy/admin.ts +37 -41
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/caddy-http-client.ts +229 -0
- package/src/caddy/config-adopt-provider.test.ts +139 -0
- package/src/caddy/config-adopt.test.ts +60 -126
- package/src/caddy/config-errors.ts +47 -0
- package/src/caddy/config-lifecycle.test.ts +55 -42
- package/src/caddy/config-lifecycle.ts +72 -162
- package/src/caddy/config-reconcile.ts +138 -0
- package/src/caddy/config.ts +36 -42
- package/src/caddy/fake-caddy.ts +20 -2
- package/src/caddy/index.ts +3 -8
- package/src/caddy/local-admin.test.ts +35 -21
- package/src/caddy/local-admin.ts +60 -167
- package/src/caddy/providers.test.ts +14 -6
- package/src/caddy/providers.ts +20 -4
- package/src/github/declare-repo-baseline.ts +6 -6
- package/src/github/index.ts +8 -4
- package/src/github/repo-baseline-data.test.ts +13 -0
- package/src/github/repo-baseline-data.ts +11 -2
- package/src/github/repo-policy-stack.test.ts +59 -0
- package/src/github/repo-policy.ts +3 -2
- package/src/github/repository-ruleset-errors.ts +6 -4
- package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +14 -9
- package/src/github/repository-ruleset-form.ts +7 -3
- package/src/github/repository-ruleset-guards.ts +3 -2
- package/src/github/repository-ruleset-octokit.test.ts +141 -0
- package/src/github/repository-ruleset-octokit.ts +121 -30
- package/src/github/repository-ruleset-probe.ts +2 -1
- package/src/github/repository-ruleset-props.ts +7 -4
- package/src/github/repository-ruleset-providers.ts +46 -0
- package/src/github/repository-ruleset-wire.test.ts +38 -27
- package/src/github/repository-ruleset.ts +27 -3
- package/src/litellm/fake-litellm.ts +83 -78
- package/src/litellm/fake-stack.ts +13 -5
- package/src/litellm/index.ts +7 -21
- package/src/litellm/operations.ts +149 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
- package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
- package/src/litellm/pass-through-endpoint.test.ts +8 -8
- package/src/litellm/pass-through-endpoint.ts +21 -31
- package/src/litellm/pass-through-errors.ts +37 -0
- package/src/litellm/pass-through-form.ts +20 -10
- package/src/litellm/providers.ts +8 -7
- package/src/paperless/custom-field.test.ts +49 -40
- package/src/paperless/custom-field.ts +57 -33
- package/src/paperless/document-type.test.ts +48 -0
- package/src/paperless/document-type.ts +51 -22
- package/src/paperless/fake-paperless.ts +63 -72
- package/src/paperless/index.ts +7 -13
- package/src/paperless/matching-locate.test.ts +102 -0
- package/src/paperless/matching-locate.ts +80 -86
- package/src/paperless/matching-types.ts +49 -16
- package/src/paperless/matching.ts +77 -90
- package/src/paperless/providers.ts +14 -10
- package/src/paperless/storage-path.test.ts +56 -0
- package/src/paperless/storage-path.ts +53 -24
- package/src/paperless/tag-identity.test.ts +127 -0
- package/src/paperless/tag.test.ts +100 -0
- package/src/paperless/tag.ts +62 -32
- package/src/proxmox/acl.test.ts +145 -0
- package/src/proxmox/acl.ts +128 -150
- package/src/proxmox/distilled-pve.test.ts +231 -0
- package/src/proxmox/distilled-pve.ts +147 -0
- package/src/proxmox/members-timeout.test.ts +161 -0
- package/src/proxmox/members.test.ts +9 -0
- package/src/proxmox/members.ts +36 -2
- package/dist/litellm/client.d.ts +0 -76
- package/dist/litellm/client.d.ts.map +0 -1
- package/dist/litellm/credentials.d.ts +0 -48
- package/dist/litellm/credentials.d.ts.map +0 -1
- package/dist/litellm/generated/pass-through.d.ts +0 -71
- package/dist/litellm/generated/pass-through.d.ts.map +0 -1
- package/dist/paperless/client.d.ts +0 -37
- package/dist/paperless/client.d.ts.map +0 -1
- package/dist/paperless/credentials.d.ts +0 -40
- package/dist/paperless/credentials.d.ts.map +0 -1
- package/dist/paperless/errors.d.ts +0 -49
- package/dist/paperless/errors.d.ts.map +0 -1
- package/src/litellm/client.ts +0 -195
- package/src/litellm/credentials.ts +0 -73
- package/src/litellm/generated/pass-through.ts +0 -73
- package/src/paperless/client.ts +0 -88
- package/src/paperless/credentials.ts +0 -69
- package/src/paperless/errors.ts +0 -62
- package/src/paperless/matching-identity.test.ts +0 -194
- package/src/paperless/matching.test.ts +0 -128
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CaddyConfig's
|
|
3
|
-
*
|
|
2
|
+
* CaddyConfig's read / diff / reconcile against the fake admin API (fake-caddy.ts) — a real HTTP
|
|
3
|
+
* server, so this drives the real distilled wire protocol: apply, a refused config rolled back,
|
|
4
|
+
* drift, and the refusals that must happen before anything is sent.
|
|
4
5
|
*/
|
|
5
6
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
6
|
-
import type
|
|
7
|
-
import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
|
|
7
|
+
import { type FakeCaddy, fakeDefaultCaddy, runCaddy } from './fake-caddy.ts';
|
|
8
8
|
import { configDigest } from './digest.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { readLive } from './config-lifecycle.ts';
|
|
10
|
+
import { diffConfig, reconcileConfig } from './config-reconcile.ts';
|
|
10
11
|
|
|
11
12
|
const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
|
|
12
13
|
const OTHER = 'www.example.com respond <hello> & bye';
|
|
@@ -32,7 +33,7 @@ const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/
|
|
|
32
33
|
describe('apply', () => {
|
|
33
34
|
test('loads the Caddyfile, reads it back, and stores the digest Caddy reports', async () => {
|
|
34
35
|
const { admin, caddy } = setup();
|
|
35
|
-
const applied = await reconcileConfig(
|
|
36
|
+
const applied = await runCaddy(reconcileConfig({ caddyfile: OTHER }, OWNED), admin);
|
|
36
37
|
expect(applied.loaded).toBe(true);
|
|
37
38
|
// ★ The fake's /config/ is sorted and escaped differently from /adapt; the digests still agree.
|
|
38
39
|
expect(applied.attributes.configSha256).toBe(configDigest(caddy.adapt(OTHER)));
|
|
@@ -44,8 +45,8 @@ describe('apply', () => {
|
|
|
44
45
|
|
|
45
46
|
test('a Caddy already running this config is not reloaded', async () => {
|
|
46
47
|
const { admin, caddy } = setup();
|
|
47
|
-
await reconcileConfig(
|
|
48
|
-
const again = await reconcileConfig(
|
|
48
|
+
await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
|
|
49
|
+
const again = await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
|
|
49
50
|
expect(again.loaded).toBe(false);
|
|
50
51
|
expect(loads(caddy)).toHaveLength(1);
|
|
51
52
|
});
|
|
@@ -53,7 +54,10 @@ describe('apply', () => {
|
|
|
53
54
|
test('sourceFile rides as the headers `caddy reload` sends, and lands in the attributes', async () => {
|
|
54
55
|
const { admin, caddy } = setup();
|
|
55
56
|
const path = '/usr/local/etc/Caddyfile';
|
|
56
|
-
const applied = await
|
|
57
|
+
const applied = await runCaddy(
|
|
58
|
+
reconcileConfig({ caddyfile: SITE, sourceFile: path }, OWNED),
|
|
59
|
+
admin,
|
|
60
|
+
);
|
|
57
61
|
expect(loads(caddy)[0]?.headers.get('caddy-config-source-file')).toBe(path);
|
|
58
62
|
expect(loads(caddy)[0]?.headers.get('caddy-config-source-adapter')).toBe('caddyfile');
|
|
59
63
|
expect(applied.attributes.sourceFile).toBe(path);
|
|
@@ -61,7 +65,7 @@ describe('apply', () => {
|
|
|
61
65
|
|
|
62
66
|
test('adapter warnings are returned, not refused', async () => {
|
|
63
67
|
const { admin } = setup();
|
|
64
|
-
const applied = await reconcileConfig(
|
|
68
|
+
const applied = await runCaddy(reconcileConfig({ caddyfile: `${SITE}\t` }, OWNED), admin);
|
|
65
69
|
expect(applied.warnings).toEqual(['Caddyfile:1: Caddyfile input is not formatted']);
|
|
66
70
|
});
|
|
67
71
|
});
|
|
@@ -69,43 +73,39 @@ describe('apply', () => {
|
|
|
69
73
|
describe('a config Caddy refuses', () => {
|
|
70
74
|
test('surfaces Caddy’s reason and confirms the previous config is still running', async () => {
|
|
71
75
|
const { admin, caddy } = setup();
|
|
72
|
-
await reconcileConfig(
|
|
76
|
+
await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
|
|
73
77
|
const before = caddy.running;
|
|
74
78
|
await expect(
|
|
75
|
-
reconcileConfig(
|
|
79
|
+
runCaddy(reconcileConfig({ caddyfile: `${SITE}\nPROVISION_ERROR` }, OWNED), admin),
|
|
76
80
|
).rejects.toThrow(/address already in use.*kept the previous config/);
|
|
77
81
|
expect(caddy.running).toEqual(before);
|
|
78
82
|
});
|
|
79
83
|
|
|
80
84
|
test('a refusal that came back as a 200 (warnings first) is still a refusal', async () => {
|
|
81
85
|
const { admin, caddy } = setup();
|
|
82
|
-
await reconcileConfig(
|
|
86
|
+
await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
|
|
83
87
|
await expect(
|
|
84
|
-
reconcileConfig(
|
|
85
|
-
).rejects.toThrow(
|
|
88
|
+
runCaddy(reconcileConfig({ caddyfile: `${SITE}\t\nPROVISION_ERROR` }, OWNED), admin),
|
|
89
|
+
).rejects.toThrow(/address already in use.*LoadRefused.*200.*kept the previous/);
|
|
86
90
|
expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
|
|
87
91
|
});
|
|
88
92
|
|
|
89
93
|
test('a load answered 200 that Caddy did not apply fails the deploy (the read-back)', async () => {
|
|
90
94
|
const { admin, caddy } = setup();
|
|
91
|
-
await reconcileConfig(
|
|
95
|
+
await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
|
|
92
96
|
// ⚠️ A concurrent writer, or a 200 that carried no change: state must not record OTHER.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
);
|
|
97
|
+
// fake-caddy.ts's SWALLOW_LOAD keyword answers `/load` 200 without applying anything.
|
|
98
|
+
await expect(
|
|
99
|
+
runCaddy(reconcileConfig({ caddyfile: `${OTHER}\nSWALLOW_LOAD` }, OWNED), admin),
|
|
100
|
+
).rejects.toThrow(/after the load Caddy runs config \w+, not the \w+ this Caddyfile adapts to/);
|
|
101
101
|
expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
test('a Caddyfile that does not adapt is refused before any load', async () => {
|
|
105
105
|
const { admin, caddy } = setup();
|
|
106
|
-
await expect(
|
|
107
|
-
|
|
108
|
-
);
|
|
106
|
+
await expect(
|
|
107
|
+
runCaddy(reconcileConfig({ caddyfile: 'SYNTAX_ERROR' }, OWNED), admin),
|
|
108
|
+
).rejects.toThrow(/POST \/adapt failed.*Caddyfile:1 - Error during parsing/);
|
|
109
109
|
expect(loads(caddy)).toHaveLength(0);
|
|
110
110
|
});
|
|
111
111
|
});
|
|
@@ -116,7 +116,7 @@ describe('refused before anything is sent', () => {
|
|
|
116
116
|
['an empty Caddyfile', ' \n'],
|
|
117
117
|
])('%s', async (_, caddyfile) => {
|
|
118
118
|
const { admin, caddy } = setup();
|
|
119
|
-
await expect(reconcileConfig(
|
|
119
|
+
await expect(runCaddy(reconcileConfig({ caddyfile }, OWNED), admin)).rejects.toThrow(
|
|
120
120
|
/Caddy\.Config at http/,
|
|
121
121
|
);
|
|
122
122
|
expect(caddy.seen).toHaveLength(0);
|
|
@@ -127,14 +127,16 @@ describe('refused before anything is sent', () => {
|
|
|
127
127
|
['only global options', 'admin localhost:2019'],
|
|
128
128
|
])('a Caddyfile of %s adapts to no apps, and is never loaded', async (_, caddyfile) => {
|
|
129
129
|
const { admin, caddy } = setup();
|
|
130
|
-
await expect(reconcileConfig(
|
|
130
|
+
await expect(runCaddy(reconcileConfig({ caddyfile }, OWNED), admin)).rejects.toThrow(
|
|
131
|
+
/adapts to no apps/,
|
|
132
|
+
);
|
|
131
133
|
expect(loads(caddy)).toHaveLength(0);
|
|
132
134
|
});
|
|
133
135
|
|
|
134
136
|
test('a Caddyfile that would turn the admin API off is never loaded', async () => {
|
|
135
137
|
const { admin, caddy } = setup();
|
|
136
138
|
await expect(
|
|
137
|
-
reconcileConfig(
|
|
139
|
+
runCaddy(reconcileConfig({ caddyfile: `admin off\n${SITE}` }, OWNED), admin),
|
|
138
140
|
).rejects.toThrow(/admin off/);
|
|
139
141
|
expect(loads(caddy)).toHaveLength(0);
|
|
140
142
|
});
|
|
@@ -143,53 +145,64 @@ describe('refused before anything is sent', () => {
|
|
|
143
145
|
describe('drift', () => {
|
|
144
146
|
const applied = async () => {
|
|
145
147
|
const { admin, caddy } = setup();
|
|
146
|
-
const { attributes } = await reconcileConfig(
|
|
148
|
+
const { attributes } = await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
|
|
147
149
|
return { admin, attributes, caddy };
|
|
148
150
|
};
|
|
149
151
|
|
|
150
152
|
test('converged: declared, live and stored agree → noop', async () => {
|
|
151
153
|
const { admin, attributes } = await applied();
|
|
152
|
-
expect(await diffConfig(
|
|
154
|
+
expect(await runCaddy(diffConfig({ caddyfile: SITE }, attributes), admin)).toEqual({
|
|
155
|
+
action: 'noop',
|
|
156
|
+
});
|
|
153
157
|
});
|
|
154
158
|
|
|
155
159
|
test('a changed declaration → update', async () => {
|
|
156
160
|
const { admin, attributes } = await applied();
|
|
157
|
-
expect(await diffConfig(
|
|
161
|
+
expect(await runCaddy(diffConfig({ caddyfile: OTHER }, attributes), admin)).toEqual({
|
|
162
|
+
action: 'update',
|
|
163
|
+
});
|
|
158
164
|
});
|
|
159
165
|
|
|
160
166
|
test('a hand edit through the API → update, and the apply puts the declaration back', async () => {
|
|
161
167
|
const { admin, attributes, caddy } = await applied();
|
|
162
168
|
caddy.running = { apps: { http: { servers: {} } } };
|
|
163
|
-
expect(await diffConfig(
|
|
164
|
-
|
|
169
|
+
expect(await runCaddy(diffConfig({ caddyfile: SITE }, attributes), admin)).toEqual({
|
|
170
|
+
action: 'update',
|
|
171
|
+
});
|
|
172
|
+
const again = await runCaddy(reconcileConfig({ caddyfile: SITE }, OWNED), admin);
|
|
165
173
|
expect(again.attributes.configSha256).toBe(attributes.configSha256);
|
|
166
174
|
});
|
|
167
175
|
|
|
168
176
|
test('a restart with a different file → update', async () => {
|
|
169
177
|
const { admin, attributes, caddy } = await applied();
|
|
170
178
|
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(
|
|
179
|
+
expect((await runCaddy(readLive(), admin)).configSha256).not.toBe(attributes.configSha256);
|
|
180
|
+
expect(await runCaddy(diffConfig({ caddyfile: SITE }, attributes), admin)).toEqual({
|
|
181
|
+
action: 'update',
|
|
182
|
+
});
|
|
173
183
|
});
|
|
174
184
|
|
|
175
185
|
test('state that lags the live config → update, so the stored digest is refreshed', async () => {
|
|
176
186
|
const { admin, attributes } = await applied();
|
|
177
187
|
const stale = { ...attributes, configSha256: configDigest({ apps: { before: true } }) };
|
|
178
|
-
expect(await diffConfig(
|
|
188
|
+
expect(await runCaddy(diffConfig({ caddyfile: SITE }, stale), admin)).toEqual({
|
|
189
|
+
action: 'update',
|
|
190
|
+
});
|
|
179
191
|
});
|
|
180
192
|
|
|
181
193
|
test('the transport now reaches Caddy at another endpoint → update (load there)', async () => {
|
|
182
194
|
const { admin, attributes } = await applied();
|
|
183
195
|
const moved = { ...attributes, endpoint: 'unix:///run/caddy/admin.sock' };
|
|
184
|
-
expect(await diffConfig(
|
|
196
|
+
expect(await runCaddy(diffConfig({ caddyfile: SITE }, moved), admin)).toEqual({
|
|
197
|
+
action: 'update',
|
|
198
|
+
});
|
|
185
199
|
});
|
|
186
200
|
|
|
187
201
|
test('a new sourceFile → update, so state records the file this config mirrors', async () => {
|
|
188
202
|
const { admin, attributes } = await applied();
|
|
189
|
-
const diff = await
|
|
203
|
+
const diff = await runCaddy(
|
|
204
|
+
diffConfig({ caddyfile: SITE, sourceFile: '/etc/Caddyfile' }, attributes),
|
|
190
205
|
admin,
|
|
191
|
-
{ caddyfile: SITE, sourceFile: '/etc/Caddyfile' },
|
|
192
|
-
attributes,
|
|
193
206
|
);
|
|
194
207
|
expect(diff).toEqual({ action: 'update' });
|
|
195
208
|
});
|
|
@@ -1,26 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CaddyConfig's
|
|
3
|
-
* lifecycle runs against a fake admin server in tests
|
|
2
|
+
* CaddyConfig's validate / read / probe as Effects over `@distilled.cloud/caddy`'s typed `admin`
|
|
3
|
+
* operations (admin-calls.ts), so the lifecycle runs against a fake admin server in tests — and,
|
|
4
|
+
* since fake-caddy.ts is a real HTTP server, against the real distilled wire protocol too.
|
|
5
|
+
* config-reconcile.ts covers `diff`/`reconcile`, the other half of the same lifecycle.
|
|
4
6
|
*
|
|
5
7
|
* The comparison is always between DIGESTS OF ADAPTED JSON (digest.ts): what `POST /adapt` makes
|
|
6
|
-
* of the declared Caddyfile
|
|
7
|
-
*
|
|
8
|
-
* · declared ≠ stored — the declaration changed.
|
|
9
|
-
* · live ≠ stored — DRIFT: a hand `curl` to the API, or a restart that loaded a different file
|
|
10
|
-
* (or an `autosave.json` holding someone else's change, under `--resume`).
|
|
11
|
-
* · declared = live — nothing to load, whatever the state says.
|
|
12
|
-
* With NO state there is no stored digest, and the question is whose config is running: see
|
|
13
|
-
* `claimable` below, which both the adoption probe and the apply ask.
|
|
8
|
+
* of the declared Caddyfile and what `GET /config/` reports — `claimable` below asks whether a
|
|
9
|
+
* running config is provably the declared one, which both the adoption probe and the apply need.
|
|
14
10
|
*/
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
loadCaddyfile,
|
|
21
|
-
readRunningConfig,
|
|
22
|
-
} from './admin-calls.ts';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import * as Result from 'effect/Result';
|
|
13
|
+
import type * as Caddy from '@distilled.cloud/caddy';
|
|
14
|
+
import { CaddyAdminService } from './admin.ts';
|
|
15
|
+
import { adaptCaddyfile, readRunningConfig } from './admin-calls.ts';
|
|
23
16
|
import { adminProblems } from './admin-guard.ts';
|
|
17
|
+
import { isUnreachable } from './caddy-http-client.ts';
|
|
18
|
+
import { refuse, wrapOperation } from './config-errors.ts';
|
|
24
19
|
import {
|
|
25
20
|
type CaddyConfigAttributes,
|
|
26
21
|
type CaddyConfigProps,
|
|
@@ -28,11 +23,6 @@ import {
|
|
|
28
23
|
} from './config-form.ts';
|
|
29
24
|
import { configDigest } from './digest.ts';
|
|
30
25
|
|
|
31
|
-
const refuse = (admin: CaddyAdmin, message: string): Error =>
|
|
32
|
-
new Error(`Caddy.Config at ${admin.endpoint}: ${message}`);
|
|
33
|
-
|
|
34
|
-
const short = (digest: string): string => digest.slice(0, 12);
|
|
35
|
-
|
|
36
26
|
type Desired = { readonly digest: string; readonly warnings: readonly string[] };
|
|
37
27
|
|
|
38
28
|
/**
|
|
@@ -41,7 +31,7 @@ type Desired = { readonly digest: string; readonly warnings: readonly string[] }
|
|
|
41
31
|
* 2026-09-21 on a throwaway Caddy 2.11.4 — and loading it stops every server: the same outage,
|
|
42
32
|
* from the same templating bug, that the empty check exists to prevent.
|
|
43
33
|
*/
|
|
44
|
-
const servesNothing = (config: unknown): boolean => {
|
|
34
|
+
export const servesNothing = (config: unknown): boolean => {
|
|
45
35
|
const apps =
|
|
46
36
|
typeof config === 'object' && config !== null ? (config as { apps?: unknown }).apps : undefined;
|
|
47
37
|
return typeof apps !== 'object' || apps === null || Object.keys(apps).length === 0;
|
|
@@ -53,37 +43,49 @@ const servesNothing = (config: unknown): boolean => {
|
|
|
53
43
|
* Caddyfile that does not adapt, carries a literal secret, or would strand the admin API fails the
|
|
54
44
|
* PLAN, before any resource (a HostFile holding the same text included) is applied.
|
|
55
45
|
*/
|
|
56
|
-
export const desiredConfig =
|
|
57
|
-
admin: CaddyAdmin,
|
|
46
|
+
export const desiredConfig = (
|
|
58
47
|
props: CaddyConfigProps,
|
|
59
|
-
):
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
48
|
+
): Effect.Effect<Desired, Error | Caddy.CaddyOpError, CaddyAdminService | Caddy.CaddyOpContext> =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const { endpoint, listener } = yield* CaddyAdminService;
|
|
51
|
+
const found = configProblems(props);
|
|
52
|
+
if (found.length > 0) return yield* Effect.fail(refuse(endpoint, found.join('; ')));
|
|
53
|
+
const adapted = yield* adaptCaddyfile(props.caddyfile).pipe(
|
|
54
|
+
wrapOperation(endpoint, 'POST /adapt'),
|
|
55
|
+
);
|
|
56
|
+
if (servesNothing(adapted.config)) {
|
|
57
|
+
return yield* Effect.fail(
|
|
58
|
+
refuse(endpoint, 'the Caddyfile adapts to no apps — loading it would stop every site'),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const guard = adminProblems(adapted.config, listener);
|
|
62
|
+
if (guard.length > 0) return yield* Effect.fail(refuse(endpoint, guard.join('; ')));
|
|
63
|
+
return { digest: configDigest(adapted.config), warnings: adapted.warnings };
|
|
64
|
+
});
|
|
70
65
|
|
|
71
|
-
const attributesOf = (
|
|
72
|
-
|
|
66
|
+
export const attributesOf = (
|
|
67
|
+
endpoint: string,
|
|
73
68
|
running: unknown,
|
|
74
69
|
sourceFile?: string,
|
|
75
70
|
): CaddyConfigAttributes => ({
|
|
76
71
|
configSha256: configDigest(running),
|
|
77
|
-
endpoint
|
|
72
|
+
endpoint,
|
|
78
73
|
...(sourceFile === undefined ? {} : { sourceFile }),
|
|
79
74
|
});
|
|
80
75
|
|
|
81
76
|
/** What is running now, as attributes. `sourceFile` is carried over, never discovered. */
|
|
82
|
-
export const readLive =
|
|
83
|
-
admin: CaddyAdmin,
|
|
77
|
+
export const readLive = (
|
|
84
78
|
sourceFile?: string,
|
|
85
|
-
):
|
|
86
|
-
|
|
79
|
+
): Effect.Effect<
|
|
80
|
+
CaddyConfigAttributes,
|
|
81
|
+
Error | Caddy.CaddyOpError,
|
|
82
|
+
CaddyAdminService | Caddy.CaddyOpContext
|
|
83
|
+
> =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const { endpoint } = yield* CaddyAdminService;
|
|
86
|
+
const running = yield* readRunningConfig().pipe(wrapOperation(endpoint, 'GET /config/'));
|
|
87
|
+
return attributesOf(endpoint, running, sourceFile);
|
|
88
|
+
});
|
|
87
89
|
|
|
88
90
|
/**
|
|
89
91
|
* ⛔ WITH NO STATE, A RUNNING CONFIG IS THIS STACK'S ONLY IF IT IS THE DECLARED ONE (decision,
|
|
@@ -112,128 +114,36 @@ export type Probe = {
|
|
|
112
114
|
* adapt then would fail every later plan, the one carrying the fix included. What cannot be
|
|
113
115
|
* compared is "not proven ours", with the reason; the next diff or apply validates the Caddyfile.
|
|
114
116
|
*/
|
|
115
|
-
export const probeLive =
|
|
116
|
-
admin: CaddyAdmin,
|
|
117
|
+
export const probeLive = (
|
|
117
118
|
caddyfile: string | undefined,
|
|
118
119
|
sourceFile?: string,
|
|
119
|
-
):
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
120
|
+
): Effect.Effect<Probe | undefined, Caddy.CaddyOpError, CaddyAdminService | Caddy.CaddyOpContext> =>
|
|
121
|
+
Effect.gen(function* () {
|
|
122
|
+
const { endpoint } = yield* CaddyAdminService;
|
|
123
|
+
// ⚠️ NOT wrapOperation here: an unreachable Caddy must still raise the raw, narrowable
|
|
124
|
+
// HttpClientError (config.ts's plan-time escape hatch), and every OTHER failure reading the
|
|
125
|
+
// running config mid-probe is "not proven ours" below, never a thrown refusal.
|
|
126
|
+
const running = yield* readRunningConfig();
|
|
127
|
+
if (servesNothing(running)) return undefined;
|
|
128
|
+
const attributes = attributesOf(endpoint, running, sourceFile);
|
|
129
|
+
if (caddyfile === undefined) {
|
|
130
|
+
return { attributes, ours: false, unchecked: 'the stored Caddyfile is not plain text' };
|
|
131
|
+
}
|
|
132
|
+
// ⚠️ Unreachable mid-probe is still "no Caddy" (config.ts plans without it), not "not ours" —
|
|
133
|
+
// re-failed with the raw, still-narrowable error, never swallowed into `unchecked`. Every
|
|
134
|
+
// OTHER refusal desiredConfig raises mid-probe (a bad admin block, a Caddyfile that will not
|
|
135
|
+
// adapt) is "not proven ours", with the reason — never a thrown refusal over the DECLARATION
|
|
136
|
+
// (the ⛔ above). `Effect.result`, per S20, rather than a `catchAll` this Effect version
|
|
137
|
+
// does not export.
|
|
138
|
+
const desired = yield* Effect.result(desiredConfig({ caddyfile }));
|
|
139
|
+
if (Result.isSuccess(desired)) {
|
|
140
|
+
return { attributes, ours: claimable(running, desired.success.digest) };
|
|
141
|
+
}
|
|
142
|
+
if (isUnreachable(desired.failure)) return yield* Effect.fail(desired.failure);
|
|
143
|
+
const cause = desired.failure;
|
|
132
144
|
return {
|
|
133
145
|
attributes,
|
|
134
146
|
ours: false,
|
|
135
147
|
unchecked: cause instanceof Error ? cause.message : String(cause),
|
|
136
148
|
};
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
|
|
142
|
-
* the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business — see
|
|
143
|
-
* the ⚠️ on `endpoint` in the diff.)
|
|
144
|
-
*/
|
|
145
|
-
export const diffConfig = async (
|
|
146
|
-
admin: CaddyAdmin,
|
|
147
|
-
news: CaddyConfigProps,
|
|
148
|
-
output: CaddyConfigAttributes,
|
|
149
|
-
): Promise<Diff> => {
|
|
150
|
-
const want = await desiredConfig(admin, news);
|
|
151
|
-
const live = await readLive(admin);
|
|
152
|
-
const converged =
|
|
153
|
-
want.digest === live.configSha256 &&
|
|
154
|
-
live.configSha256 === output.configSha256 &&
|
|
155
|
-
news.sourceFile === output.sourceFile &&
|
|
156
|
-
// ⚠️ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the
|
|
157
|
-
// old one keeps what it has — the same as a delete, which never unloads (see config.ts).
|
|
158
|
-
// Planned as an update, but the state does not vouch for that Caddy: see Authority.
|
|
159
|
-
admin.endpoint === output.endpoint;
|
|
160
|
-
return converged ? { action: 'noop' } : { action: 'update' };
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
export type Applied = {
|
|
164
|
-
readonly attributes: CaddyConfigAttributes;
|
|
165
|
-
/** The adapter's warnings (e.g. "Caddyfile input is not formatted") — log them, never refuse. */
|
|
166
|
-
readonly warnings: readonly string[];
|
|
167
|
-
/** Whether a `POST /load` was sent (false when Caddy already ran this config). */
|
|
168
|
-
readonly loaded: boolean;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* What this apply may load over — config.ts decides from the state and the adopt setting:
|
|
173
|
-
* · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
|
|
174
|
-
* correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
|
|
175
|
-
* resource's own `adopt(true)` — ownership/adopt.ts adoptEnabled).
|
|
176
|
-
* · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
|
|
177
|
-
* Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
|
|
178
|
-
*/
|
|
179
|
-
export type Authority = { readonly takeOver: boolean; readonly stored?: string };
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches.
|
|
183
|
-
* ⛔ WITHOUT `takeOver`, A CONFIG THE STACK CANNOT CLAIM IS NEVER LOADED OVER. The engine's adoption
|
|
184
|
-
* probe cannot guard every create: it is skipped while `news` holds an Output — ALWAYS on
|
|
185
|
-
* caddyWithFile's first deploy (`sourceFile` is its HostFile's path) — and a Caddy that was down at
|
|
186
|
-
* plan time read as nothing. Nor does state vouch for a Caddy the transport NOW reaches at another
|
|
187
|
-
* endpoint (diff plans that as an update). Refused here, before any `/load`, like HostFile's.
|
|
188
|
-
*/
|
|
189
|
-
export const reconcileConfig = async (
|
|
190
|
-
admin: CaddyAdmin,
|
|
191
|
-
props: CaddyConfigProps,
|
|
192
|
-
authority: Authority,
|
|
193
|
-
): Promise<Applied> => {
|
|
194
|
-
const want = await desiredConfig(admin, props);
|
|
195
|
-
const running = await readRunningConfig(admin);
|
|
196
|
-
const before = configDigest(running);
|
|
197
|
-
const known = before === authority.stored || claimable(running, want.digest);
|
|
198
|
-
if (!authority.takeOver && !known) {
|
|
199
|
-
throw refuse(
|
|
200
|
-
admin,
|
|
201
|
-
`Caddy runs config ${short(before)}, which this stack did not load, and the Caddyfile adapts ` +
|
|
202
|
-
`to ${short(want.digest)} — loading it would replace every site that config serves. ` +
|
|
203
|
-
'Deploy with --adopt (or wrap the resource in adopt(true)) to take this Caddy over, or ' +
|
|
204
|
-
'point caddyProviders() at the Caddy you meant.',
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
let warnings = want.warnings;
|
|
208
|
-
const loaded = before !== want.digest;
|
|
209
|
-
if (loaded) {
|
|
210
|
-
try {
|
|
211
|
-
warnings = await loadCaddyfile(admin, props.caddyfile, props.sourceFile);
|
|
212
|
-
} catch (cause) {
|
|
213
|
-
if (!(cause instanceof CaddyAdminError)) throw cause;
|
|
214
|
-
// ★ SAY WHAT IS RUNNING NOW. Caddy restores the previous config on a failed load (caddy.go
|
|
215
|
-
// changeConfig); confirm it rather than promise it, because the deploy log is where the
|
|
216
|
-
// person decides whether sites are down.
|
|
217
|
-
const after = await readLive(admin).catch(() => undefined);
|
|
218
|
-
const state =
|
|
219
|
-
after === undefined
|
|
220
|
-
? 'and the running config could not be read back'
|
|
221
|
-
: after.configSha256 === before
|
|
222
|
-
? 'Caddy kept the previous config (still serving)'
|
|
223
|
-
: `and the running config is now ${short(after.configSha256)}, not the previous ${short(before)}`;
|
|
224
|
-
throw refuse(admin, `Caddy refused the Caddyfile — ${cause.message}; ${state}`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
// ⚠️ READ BACK, never echo the declaration: a concurrent writer, or a load Caddy answered 200 but
|
|
228
|
-
// did not apply (admin-calls.ts), shows up here as a refusal instead of as stored state that is
|
|
229
|
-
// already wrong.
|
|
230
|
-
const attributes = await readLive(admin, props.sourceFile);
|
|
231
|
-
if (attributes.configSha256 !== want.digest) {
|
|
232
|
-
throw refuse(
|
|
233
|
-
admin,
|
|
234
|
-
`after the load Caddy runs config ${short(attributes.configSha256)}, not the ` +
|
|
235
|
-
`${short(want.digest)} this Caddyfile adapts to — another writer changed it, or it was not applied`,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
return { attributes, loaded, warnings };
|
|
239
|
-
};
|
|
149
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CaddyConfig's diff / reconcile — the apply half of the lifecycle config-lifecycle.ts's
|
|
3
|
+
* `desiredConfig`/`readLive`/`probeLive` compute the inputs for. Split out at the 250-line file
|
|
4
|
+
* cap; the two files together are one lifecycle, read config-lifecycle.ts's module doc first.
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Result from 'effect/Result';
|
|
8
|
+
import type * as Diff from 'alchemy/Diff';
|
|
9
|
+
import type * as Caddy from '@distilled.cloud/caddy';
|
|
10
|
+
import { CaddyAdminService } from './admin.ts';
|
|
11
|
+
import { loadCaddyfile, readRunningConfig } from './admin-calls.ts';
|
|
12
|
+
import { isUnreachable } from './caddy-http-client.ts';
|
|
13
|
+
import { messageOf, refuse, short, wrapOperation } from './config-errors.ts';
|
|
14
|
+
import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
15
|
+
import { claimable, desiredConfig, readLive } from './config-lifecycle.ts';
|
|
16
|
+
import { configDigest } from './digest.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
|
|
20
|
+
* the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business — see
|
|
21
|
+
* the ⚠️ on `endpoint` in the diff.)
|
|
22
|
+
*/
|
|
23
|
+
export const diffConfig = (
|
|
24
|
+
news: CaddyConfigProps,
|
|
25
|
+
output: CaddyConfigAttributes,
|
|
26
|
+
): Effect.Effect<Diff.Diff, Error | Caddy.CaddyOpError, CaddyAdminService | Caddy.CaddyOpContext> =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const { endpoint } = yield* CaddyAdminService;
|
|
29
|
+
const want = yield* desiredConfig(news);
|
|
30
|
+
const live = yield* readLive();
|
|
31
|
+
const converged =
|
|
32
|
+
want.digest === live.configSha256 &&
|
|
33
|
+
live.configSha256 === output.configSha256 &&
|
|
34
|
+
news.sourceFile === output.sourceFile &&
|
|
35
|
+
// ⚠️ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the
|
|
36
|
+
// old one keeps what it has — the same as a delete, which never unloads (see config.ts).
|
|
37
|
+
// Planned as an update, but the state does not vouch for that Caddy: see Authority.
|
|
38
|
+
endpoint === output.endpoint;
|
|
39
|
+
return converged ? { action: 'noop' } : { action: 'update' };
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export type Applied = {
|
|
43
|
+
readonly attributes: CaddyConfigAttributes;
|
|
44
|
+
/** The adapter's warnings (e.g. "Caddyfile input is not formatted") — log them, never refuse. */
|
|
45
|
+
readonly warnings: readonly string[];
|
|
46
|
+
/** Whether a `POST /load` was sent (false when Caddy already ran this config). */
|
|
47
|
+
readonly loaded: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What this apply may load over — config.ts decides from the state and the adopt setting:
|
|
52
|
+
* · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
|
|
53
|
+
* correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
|
|
54
|
+
* resource's own `adopt(true)` — ownership/adopt.ts adoptEnabled).
|
|
55
|
+
* · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
|
|
56
|
+
* Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
|
|
57
|
+
*/
|
|
58
|
+
export type Authority = { readonly takeOver: boolean; readonly stored?: string };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches.
|
|
62
|
+
* ⛔ WITHOUT `takeOver`, A CONFIG THE STACK CANNOT CLAIM IS NEVER LOADED OVER. The engine's adoption
|
|
63
|
+
* probe cannot guard every create: it is skipped while `news` holds an Output — ALWAYS on
|
|
64
|
+
* caddyWithFile's first deploy (`sourceFile` is its HostFile's path) — and a Caddy that was down at
|
|
65
|
+
* plan time read as nothing. Nor does state vouch for a Caddy the transport NOW reaches at another
|
|
66
|
+
* endpoint (diff plans that as an update). Refused here, before any `/load`, like HostFile's.
|
|
67
|
+
*/
|
|
68
|
+
export const reconcileConfig = (
|
|
69
|
+
props: CaddyConfigProps,
|
|
70
|
+
authority: Authority,
|
|
71
|
+
): Effect.Effect<Applied, Error | Caddy.CaddyOpError, CaddyAdminService | Caddy.CaddyOpContext> =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const { endpoint } = yield* CaddyAdminService;
|
|
74
|
+
const want = yield* desiredConfig(props);
|
|
75
|
+
const running = yield* readRunningConfig().pipe(wrapOperation(endpoint, 'GET /config/'));
|
|
76
|
+
const before = configDigest(running);
|
|
77
|
+
const known = before === authority.stored || claimable(running, want.digest);
|
|
78
|
+
if (!authority.takeOver && !known) {
|
|
79
|
+
return yield* Effect.fail(
|
|
80
|
+
refuse(
|
|
81
|
+
endpoint,
|
|
82
|
+
`Caddy runs config ${short(before)}, which this stack did not load, and the Caddyfile ` +
|
|
83
|
+
`adapts to ${short(want.digest)} — loading it would replace every site that config ` +
|
|
84
|
+
'serves. Deploy with --adopt (or wrap the resource in adopt(true)) to take this Caddy ' +
|
|
85
|
+
'over, or point caddyProviders() at the Caddy you meant.',
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
let warnings = want.warnings;
|
|
90
|
+
const loaded = before !== want.digest;
|
|
91
|
+
if (loaded) {
|
|
92
|
+
const outcome = yield* Effect.result(loadCaddyfile(props.caddyfile, props.sourceFile));
|
|
93
|
+
if (Result.isFailure(outcome)) {
|
|
94
|
+
const error = outcome.failure;
|
|
95
|
+
// ⚠️ Caddy going unreachable MID-load is not a refusal — nothing to "say what's running
|
|
96
|
+
// now" about, and re-sending it is unsafe (admin-calls.ts's module doc). Propagated raw,
|
|
97
|
+
// still narrowable, matching every other unreachable path in this file.
|
|
98
|
+
if (isUnreachable(error)) return yield* Effect.fail(error);
|
|
99
|
+
// ★ SAY WHAT IS RUNNING NOW. Caddy restores the previous config on a failed load (caddy.go
|
|
100
|
+
// changeConfig); confirm it rather than promise it, because the deploy log is where the
|
|
101
|
+
// person decides whether sites are down.
|
|
102
|
+
const afterRead = yield* Effect.result(readLive());
|
|
103
|
+
const after = Result.isSuccess(afterRead) ? afterRead.success : undefined;
|
|
104
|
+
const state =
|
|
105
|
+
after === undefined
|
|
106
|
+
? 'and the running config could not be read back'
|
|
107
|
+
: after.configSha256 === before
|
|
108
|
+
? 'Caddy kept the previous config (still serving)'
|
|
109
|
+
: `and the running config is now ${short(after.configSha256)}, not the previous ${short(before)}`;
|
|
110
|
+
// ⚠️ SAY SO WHEN THE STATUS LIED, or a bare "refused" reads like a normal 4xx/5xx.
|
|
111
|
+
// `LoadRefused` IS the 200-embedded-error trap (admin-calls.ts's module doc): Caddy
|
|
112
|
+
// answered 200, and the failure was only in the body.
|
|
113
|
+
const reason =
|
|
114
|
+
error._tag === 'LoadRefused'
|
|
115
|
+
? `${error.message} (LoadRefused: a 200 whose body carried the error, not a failure status)`
|
|
116
|
+
: messageOf(error);
|
|
117
|
+
return yield* Effect.fail(
|
|
118
|
+
refuse(endpoint, `Caddy refused the Caddyfile — ${reason}; ${state}`),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
warnings = outcome.success;
|
|
122
|
+
}
|
|
123
|
+
// ⚠️ READ BACK, never echo the declaration: a concurrent writer, or a load Caddy answered 200 but
|
|
124
|
+
// did not apply (admin-calls.ts), shows up here as a refusal instead of as stored state that is
|
|
125
|
+
// already wrong.
|
|
126
|
+
const attributes = yield* readLive(props.sourceFile);
|
|
127
|
+
if (attributes.configSha256 !== want.digest) {
|
|
128
|
+
return yield* Effect.fail(
|
|
129
|
+
refuse(
|
|
130
|
+
endpoint,
|
|
131
|
+
`after the load Caddy runs config ${short(attributes.configSha256)}, not the ` +
|
|
132
|
+
`${short(want.digest)} this Caddyfile adapts to — another writer changed it, or it was ` +
|
|
133
|
+
'not applied',
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return { attributes, loaded, warnings };
|
|
138
|
+
});
|