@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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership with no state, through the REAL `CaddyConfig.Provider` (not the lifecycle functions
|
|
3
|
+
* config-adopt.test.ts drives directly) — `read`'s `Unowned` branding and `reconcile`'s `--adopt`
|
|
4
|
+
* resolution (`AdoptPolicy`, `AlchemyContext`), the way the engine actually calls them. Split out
|
|
5
|
+
* at the 250-line file cap; config-adopt.test.ts's module doc states the underlying decision.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
8
|
+
import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
|
|
9
|
+
import { AlchemyContext } from 'alchemy/AlchemyContext';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import type * as Caddy from '@distilled.cloud/caddy';
|
|
13
|
+
import { type CaddyAdminService, type CaddyTransport, caddyAdminLayer } from './admin.ts';
|
|
14
|
+
import { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
15
|
+
import { configDigest } from './digest.ts';
|
|
16
|
+
import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
|
|
17
|
+
|
|
18
|
+
const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
|
|
19
|
+
/** A config someone else loaded: it serves a site, and it is not what the stack declares. */
|
|
20
|
+
const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
|
|
21
|
+
const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
|
|
22
|
+
|
|
23
|
+
let fake: FakeCaddy | undefined;
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
fake?.stop();
|
|
26
|
+
fake = undefined;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const setup = (running?: unknown) => {
|
|
30
|
+
const made = fakeDefaultCaddy(running);
|
|
31
|
+
fake = made.caddy;
|
|
32
|
+
return made;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
|
|
36
|
+
|
|
37
|
+
type Deploy = { readonly adopt?: boolean; readonly contextAdopt?: boolean };
|
|
38
|
+
|
|
39
|
+
/** The provider over `admin`, run under the services a deploy provides (the CLI's `--adopt`). */
|
|
40
|
+
const withProvider = <A>(
|
|
41
|
+
admin: CaddyTransport,
|
|
42
|
+
deploy: Deploy,
|
|
43
|
+
use: (
|
|
44
|
+
p: Effect.Success<typeof CaddyConfig.Provider>,
|
|
45
|
+
) => Effect.Effect<A, unknown, CaddyAdminService | Caddy.CaddyOpContext>,
|
|
46
|
+
) => {
|
|
47
|
+
// ⚠️ `Layer.provideMerge`, not `Layer.provide` — providers.ts's own doc on `caddyAdminLayer` says
|
|
48
|
+
// why: `CaddyConfigProvider()`'s handlers still need these services every time they run, not
|
|
49
|
+
// just once while the provider is built.
|
|
50
|
+
let program = Effect.gen(function* () {
|
|
51
|
+
return yield* use(yield* CaddyConfig.Provider);
|
|
52
|
+
}).pipe(Effect.provide(CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(admin)))));
|
|
53
|
+
if (deploy.adopt !== undefined) {
|
|
54
|
+
program = program.pipe(Effect.provideService(AdoptPolicy, deploy.adopt));
|
|
55
|
+
}
|
|
56
|
+
if (deploy.contextAdopt !== undefined) {
|
|
57
|
+
const context = { adopt: deploy.contextAdopt, dev: false, dotAlchemy: '.alchemy' };
|
|
58
|
+
program = program.pipe(Effect.provideService(AlchemyContext, context));
|
|
59
|
+
}
|
|
60
|
+
return Effect.runPromise(program);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const read = (admin: CaddyTransport, caddyfile: unknown, output?: object) =>
|
|
64
|
+
withProvider(admin, {}, (provider) => {
|
|
65
|
+
if (provider.read === undefined) throw new Error('provider has no read handler');
|
|
66
|
+
return provider.read({ ...ids, olds: { caddyfile } as never, output: output as never });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Apply, then check that exactly the expected loads reached Caddy — or none, and why. */
|
|
70
|
+
const expectApply = async (applying: Promise<unknown>, caddy: FakeCaddy, loaded: boolean) => {
|
|
71
|
+
if (loaded) await applying;
|
|
72
|
+
else await expect(applying).rejects.toThrow(/Deploy with --adopt/);
|
|
73
|
+
expect(loads(caddy)).toHaveLength(loaded ? 1 : 0);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const reconcile = (admin: CaddyTransport, deploy: Deploy, output?: object) =>
|
|
77
|
+
withProvider(admin, deploy, (provider) =>
|
|
78
|
+
provider.reconcile({
|
|
79
|
+
...ids,
|
|
80
|
+
bindings: [] as never,
|
|
81
|
+
news: { caddyfile: SITE },
|
|
82
|
+
olds: undefined,
|
|
83
|
+
output: output as never,
|
|
84
|
+
session: undefined as never,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
describe('CaddyConfigProvider', () => {
|
|
89
|
+
test('read: Unowned with no state, plain with state, plain when it is the declared config', async () => {
|
|
90
|
+
const { admin, caddy } = setup(FOREIGN);
|
|
91
|
+
const probe = await read(admin, SITE);
|
|
92
|
+
expect(Unowned.is(probe)).toBe(true);
|
|
93
|
+
expect(probe).toMatchObject({ configSha256: configDigest(FOREIGN) });
|
|
94
|
+
const owned = await read(admin, SITE, { configSha256: 'stored', endpoint: caddy.address });
|
|
95
|
+
expect(owned).toBeDefined();
|
|
96
|
+
expect(Unowned.is(owned)).toBe(false);
|
|
97
|
+
caddy.running = caddy.adapt(SITE);
|
|
98
|
+
const same = await read(admin, SITE);
|
|
99
|
+
expect(same).toBeDefined();
|
|
100
|
+
expect(Unowned.is(same)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('read with no state: nothing served is a create; an unreadable declaration is Unowned', async () => {
|
|
104
|
+
expect(await read(setup().admin, SITE)).toBeUndefined();
|
|
105
|
+
fake?.stop();
|
|
106
|
+
const { admin } = setup(FOREIGN);
|
|
107
|
+
expect(Unowned.is(await read(admin, 'SYNTAX_ERROR'))).toBe(true);
|
|
108
|
+
expect(Unowned.is(await read(admin, {}))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test.each([
|
|
112
|
+
['no adopt setting at all', {}, false],
|
|
113
|
+
['AdoptPolicy off', { adopt: false }, false],
|
|
114
|
+
['--adopt (AdoptPolicy)', { adopt: true }, true],
|
|
115
|
+
['--adopt reaching only AlchemyContext', { contextAdopt: true }, true],
|
|
116
|
+
['AdoptPolicy off over AlchemyContext on', { adopt: false, contextAdopt: true }, false],
|
|
117
|
+
] as const)('reconcile with no state, %s', async (_, deploy, loaded) => {
|
|
118
|
+
const { admin, caddy } = setup(FOREIGN);
|
|
119
|
+
await expectApply(reconcile(admin, deploy), caddy, loaded);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('reconcile with state for this Caddy loads over anything, without --adopt', async () => {
|
|
123
|
+
// ★ An update correcting drift (a hand edit since the stored digest), or an adopted create.
|
|
124
|
+
const { admin, caddy } = setup(FOREIGN);
|
|
125
|
+
const output = { configSha256: 'before-a-hand-edit', endpoint: caddy.address };
|
|
126
|
+
await expectApply(reconcile(admin, {}, output), caddy, true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test.each([
|
|
130
|
+
['a config it never stored, without --adopt', 'stored-elsewhere', {}, false],
|
|
131
|
+
['a config it never stored, with --adopt', 'stored-elsewhere', { adopt: true }, true],
|
|
132
|
+
['the config it last stored (the same Caddy, renamed)', configDigest(FOREIGN), {}, true],
|
|
133
|
+
] as const)('reconcile with state from another endpoint: %s', async (_, sha, deploy, loaded) => {
|
|
134
|
+
// ⛔ State vouches for the Caddy it was applied to — not for whatever the transport now reaches.
|
|
135
|
+
const { admin, caddy } = setup(FOREIGN);
|
|
136
|
+
const output = { configSha256: sha, endpoint: 'unix:///run/elsewhere.sock' };
|
|
137
|
+
await expectApply(reconcile(admin, deploy, output), caddy, loaded);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -3,23 +3,25 @@
|
|
|
3
3
|
* probe (`read` with no output) and the apply of a create the engine never probed must agree on
|
|
4
4
|
* what is claimable: the declared config itself, or a Caddy serving nothing. Anything else is
|
|
5
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.
|
|
6
|
+
* lends authority only over the Caddy it was applied to. Both drive the LIFECYCLE functions
|
|
7
|
+
* directly; config-adopt-provider.test.ts drives the same scenarios through the real
|
|
8
|
+
* `CaddyConfig.Provider` (its `Unowned` branding, its `--adopt` resolution).
|
|
7
9
|
*/
|
|
8
10
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
9
|
-
import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
|
|
10
|
-
import { AlchemyContext } from 'alchemy/AlchemyContext';
|
|
11
11
|
import * as Effect from 'effect/Effect';
|
|
12
12
|
import * as Layer from 'effect/Layer';
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
13
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
14
|
+
import * as HttpClientError from 'effect/unstable/http/HttpClientError';
|
|
15
|
+
import type { CaddyTransport } from './admin.ts';
|
|
16
|
+
import { isUnreachable } from './caddy-http-client.ts';
|
|
17
|
+
import { probeLive } from './config-lifecycle.ts';
|
|
18
|
+
import { reconcileConfig } from './config-reconcile.ts';
|
|
16
19
|
import { configDigest } from './digest.ts';
|
|
17
|
-
import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
|
|
20
|
+
import { type FakeCaddy, fakeDefaultCaddy, runCaddy } from './fake-caddy.ts';
|
|
18
21
|
|
|
19
22
|
const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
|
|
20
23
|
/** A config someone else loaded: it serves a site, and it is not what the stack declares. */
|
|
21
24
|
const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
|
|
22
|
-
const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
|
|
23
25
|
|
|
24
26
|
let fake: FakeCaddy | undefined;
|
|
25
27
|
afterEach(() => {
|
|
@@ -42,10 +44,37 @@ const runningDeclared = () => {
|
|
|
42
44
|
|
|
43
45
|
const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The same transport, except `/adapt` fails as though nothing were listening — for "Caddy
|
|
49
|
+
* vanishing mid-probe": `probeLive` calls `GET /config/` first and `POST /adapt` second, so
|
|
50
|
+
* stopping the fake server between them can't be timed from outside one Effect. Wrapping the
|
|
51
|
+
* transport's `HttpClient.HttpClient` at exactly one path is the deterministic equivalent.
|
|
52
|
+
*/
|
|
53
|
+
const goneOnAdapt = (admin: CaddyTransport): CaddyTransport => ({
|
|
54
|
+
...admin,
|
|
55
|
+
layer: Layer.effect(
|
|
56
|
+
HttpClient.HttpClient,
|
|
57
|
+
Effect.map(HttpClient.HttpClient, (real) =>
|
|
58
|
+
HttpClient.make((request, url, signal, fiber) =>
|
|
59
|
+
url.pathname === '/adapt'
|
|
60
|
+
? Effect.fail(
|
|
61
|
+
new HttpClientError.HttpClientError({
|
|
62
|
+
reason: new HttpClientError.TransportError({
|
|
63
|
+
cause: Object.assign(new Error('gone'), { code: 'ECONNREFUSED' }),
|
|
64
|
+
request,
|
|
65
|
+
}),
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
: real.execute(request),
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
).pipe(Layer.provideMerge(admin.layer)),
|
|
72
|
+
});
|
|
73
|
+
|
|
45
74
|
describe('the probe: read with no state', () => {
|
|
46
75
|
test('a Caddy already running the declared config is ours — adopting it changes nothing', async () => {
|
|
47
76
|
const { admin, caddy } = runningDeclared();
|
|
48
|
-
const probe = await probeLive(
|
|
77
|
+
const probe = await runCaddy(probeLive(SITE), admin);
|
|
49
78
|
expect(probe).toMatchObject({ attributes: { configSha256: configDigest(caddy.adapt(SITE)) } });
|
|
50
79
|
expect(probe?.ours).toBe(true);
|
|
51
80
|
expect(loads(caddy)).toHaveLength(0);
|
|
@@ -53,7 +82,7 @@ describe('the probe: read with no state', () => {
|
|
|
53
82
|
|
|
54
83
|
test('a Caddy running any other config is not ours', async () => {
|
|
55
84
|
const { admin } = setup(FOREIGN);
|
|
56
|
-
const probe = await probeLive(
|
|
85
|
+
const probe = await runCaddy(probeLive(SITE, '/usr/local/etc/Caddyfile'), admin);
|
|
57
86
|
expect(probe?.ours).toBe(false);
|
|
58
87
|
expect(probe?.unchecked).toBeUndefined();
|
|
59
88
|
expect(probe?.attributes).toEqual({
|
|
@@ -68,7 +97,7 @@ describe('the probe: read with no state', () => {
|
|
|
68
97
|
['only an admin block', { admin: { listen: 'localhost:2019' } }],
|
|
69
98
|
])('a Caddy serving nothing (%s) reads as absent: a create', async (_, running) => {
|
|
70
99
|
const { admin } = setup(running);
|
|
71
|
-
expect(await probeLive(
|
|
100
|
+
expect(await runCaddy(probeLive(SITE), admin)).toBeUndefined();
|
|
72
101
|
});
|
|
73
102
|
|
|
74
103
|
test.each([
|
|
@@ -78,21 +107,18 @@ describe('the probe: read with no state', () => {
|
|
|
78
107
|
// ⛔ The engine replays this read with an interrupted create's props: a throw here would fail
|
|
79
108
|
// every later plan, the one carrying the fix included.
|
|
80
109
|
const { admin } = setup(FOREIGN);
|
|
81
|
-
const probe = await probeLive(
|
|
110
|
+
const probe = await runCaddy(probeLive(text), admin);
|
|
82
111
|
expect(probe?.ours).toBe(false);
|
|
83
112
|
expect(probe?.unchecked).toMatch(reason);
|
|
84
113
|
});
|
|
85
114
|
|
|
86
115
|
test('Caddy vanishing mid-probe stays "no Caddy", never "not ours"', async () => {
|
|
87
116
|
const { admin } = setup(FOREIGN);
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
: admin.request(call),
|
|
94
|
-
};
|
|
95
|
-
await expect(probeLive(flaky, SITE)).rejects.toThrow('gone');
|
|
117
|
+
const failure = runCaddy(probeLive(SITE), goneOnAdapt(admin));
|
|
118
|
+
// ★ Still the raw, narrowable HttpClientError (config.ts's own catch is what turns THIS into
|
|
119
|
+
// "no Caddy" at plan time) — never swallowed into `{ ours: false, unchecked }`.
|
|
120
|
+
await expect(failure).rejects.toBeInstanceOf(HttpClientError.HttpClientError);
|
|
121
|
+
await expect(failure.catch((error: unknown) => isUnreachable(error))).resolves.toBe(true);
|
|
96
122
|
});
|
|
97
123
|
});
|
|
98
124
|
|
|
@@ -100,23 +126,29 @@ describe('the probe: read with no state', () => {
|
|
|
100
126
|
describe('the apply without authority over the running config', () => {
|
|
101
127
|
test('without authority, a config the stack cannot claim is refused before any load', async () => {
|
|
102
128
|
const { admin, caddy } = setup(FOREIGN);
|
|
103
|
-
await expect(
|
|
104
|
-
|
|
105
|
-
);
|
|
129
|
+
await expect(
|
|
130
|
+
runCaddy(reconcileConfig({ caddyfile: SITE }, { takeOver: false }), admin),
|
|
131
|
+
).rejects.toThrow(/which this stack did not load.*Deploy with --adopt/);
|
|
106
132
|
expect(loads(caddy)).toHaveLength(0);
|
|
107
133
|
expect(caddy.running).toEqual(FOREIGN);
|
|
108
134
|
});
|
|
109
135
|
|
|
110
136
|
test('the declared config already running is taken as it is — no load', async () => {
|
|
111
137
|
const { admin, caddy } = runningDeclared();
|
|
112
|
-
const applied = await
|
|
138
|
+
const applied = await runCaddy(
|
|
139
|
+
reconcileConfig({ caddyfile: SITE }, { takeOver: false }),
|
|
140
|
+
admin,
|
|
141
|
+
);
|
|
113
142
|
expect(applied.loaded).toBe(false);
|
|
114
143
|
expect(loads(caddy)).toHaveLength(0);
|
|
115
144
|
});
|
|
116
145
|
|
|
117
146
|
test('a Caddy serving nothing is loaded — there is nothing to take over', async () => {
|
|
118
147
|
const { admin, caddy } = setup();
|
|
119
|
-
const applied = await
|
|
148
|
+
const applied = await runCaddy(
|
|
149
|
+
reconcileConfig({ caddyfile: SITE }, { takeOver: false }),
|
|
150
|
+
admin,
|
|
151
|
+
);
|
|
120
152
|
expect(applied.loaded).toBe(true);
|
|
121
153
|
expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
|
|
122
154
|
});
|
|
@@ -124,112 +156,14 @@ describe('the apply without authority over the running config', () => {
|
|
|
124
156
|
test('the config the state last stored is known — the same Caddy at a new endpoint', async () => {
|
|
125
157
|
const { admin } = setup(FOREIGN);
|
|
126
158
|
const authority = { stored: configDigest(FOREIGN), takeOver: false };
|
|
127
|
-
|
|
159
|
+
const applied = await runCaddy(reconcileConfig({ caddyfile: SITE }, authority), admin);
|
|
160
|
+
expect(applied.loaded).toBe(true);
|
|
128
161
|
});
|
|
129
162
|
|
|
130
163
|
test('with authority, the foreign config is replaced', async () => {
|
|
131
164
|
const { admin, caddy } = setup(FOREIGN);
|
|
132
|
-
const applied = await reconcileConfig(
|
|
165
|
+
const applied = await runCaddy(reconcileConfig({ caddyfile: SITE }, { takeOver: true }), admin);
|
|
133
166
|
expect(applied.loaded).toBe(true);
|
|
134
167
|
expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
|
|
135
168
|
});
|
|
136
169
|
});
|
|
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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a distilled `@distilled.cloud/caddy` operation failure into a readable refusal — shared
|
|
3
|
+
* by config-lifecycle.ts and config-reconcile.ts, so both read the same way.
|
|
4
|
+
*/
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import type * as Caddy from '@distilled.cloud/caddy';
|
|
7
|
+
import { isUnreachable } from './caddy-http-client.ts';
|
|
8
|
+
|
|
9
|
+
export const refuse = (endpoint: string, message: string): Error =>
|
|
10
|
+
new Error(`Caddy.Config at ${endpoint}: ${message}`);
|
|
11
|
+
|
|
12
|
+
export const short = (digest: string): string => digest.slice(0, 12);
|
|
13
|
+
|
|
14
|
+
/** Every `CaddyOpError` member carries `.message` (a `Schema.TaggedError` field) — see errors.ts. */
|
|
15
|
+
export const messageOf = (error: Caddy.CaddyOpError): string =>
|
|
16
|
+
'message' in error && typeof error.message === 'string' ? error.message : String(error);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A distilled operation's typed failure, read the way this resource's own refusals read:
|
|
20
|
+
* `LoadRefused` (the 200-embedded-error trap admin-calls.ts's module doc describes) says so
|
|
21
|
+
* explicitly, so a grep for "kept the previous config" is never mistaken for an ordinary 4xx/5xx.
|
|
22
|
+
*/
|
|
23
|
+
export const operationFailure = (endpoint: string, op: string, error: Caddy.CaddyOpError): Error =>
|
|
24
|
+
error._tag === 'LoadRefused'
|
|
25
|
+
? refuse(
|
|
26
|
+
endpoint,
|
|
27
|
+
`Caddy refused the Caddyfile (${error._tag}, a 200 whose body carried the error, not a ` +
|
|
28
|
+
`failure status) — ${error.message}`,
|
|
29
|
+
)
|
|
30
|
+
: refuse(endpoint, `${op} failed (${error._tag}) — ${messageOf(error)}`);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Humanizes every failure from a distilled operation EXCEPT one nothing accepted the connection
|
|
34
|
+
* for — that stays the raw, still-typed `HttpClientError` so config.ts's plan-time escape hatch
|
|
35
|
+
* (`Effect.catchIf(isUnreachable, …)`) can keep narrowing to it after this wrapping, the same way
|
|
36
|
+
* it could inspect the un-wrapped error before this function ran.
|
|
37
|
+
*/
|
|
38
|
+
export const wrapOperation =
|
|
39
|
+
(endpoint: string, op: string) =>
|
|
40
|
+
<A>(
|
|
41
|
+
effect: Effect.Effect<A, Caddy.CaddyOpError, Caddy.CaddyOpContext>,
|
|
42
|
+
): Effect.Effect<A, Error | Caddy.CaddyOpError, Caddy.CaddyOpContext> =>
|
|
43
|
+
effect.pipe(
|
|
44
|
+
Effect.mapError((error) =>
|
|
45
|
+
isUnreachable(error) ? error : operationFailure(endpoint, op, error),
|
|
46
|
+
),
|
|
47
|
+
);
|