@homeflare/alchemy 0.7.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.
- package/README.md +26 -37
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +71 -13
- package/dist/caddy/index.js.map +9 -9
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +15 -11
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +359 -280
- package/dist/openbao/index.js.map +21 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/peers.md +39 -0
- package/package.json +1 -1
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -11
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +65 -37
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role.ts +6 -14
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy.ts +7 -7
- package/src/openbao/proxmox-role.ts +7 -16
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename.ts +19 -15
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
package/docs/launchd.md
CHANGED
|
@@ -112,8 +112,11 @@ The `system` domain, and another user's `gui/<uid>`, need root. The provider ref
|
|
|
112
112
|
unless the runner is root (`effectiveUid() === 0`) or was deliberately built with
|
|
113
113
|
`privileged: true`. `localRunner()` — the default — is never privileged and never calls `sudo`, so:
|
|
114
114
|
|
|
115
|
-
- **system daemons
|
|
116
|
-
|
|
115
|
+
- **system daemons, deployed as yourself:** `launchdProviders(sudoRunner({ prefixes }))` sends only
|
|
116
|
+
the privileged calls through `sudo -n`, from a fixed allowlist, logging each. It never prompts.
|
|
117
|
+
See [launchd-sudo.md](./launchd-sudo.md).
|
|
118
|
+
- **system daemons, deployed as root:** start the whole deploy as root; or pass
|
|
119
|
+
`launchdProviders(yourRunner)` where `yourRunner` is a `HostRunner` that already holds root.
|
|
117
120
|
- **your own agents** (`gui/<your uid>`): no root needed.
|
|
118
121
|
|
|
119
122
|
Reading (`launchctl print`, `print-disabled`) needs no root. ⚠️ A `HostFile` whose mode hides it
|
|
@@ -163,5 +166,5 @@ Nix job is booted out first. Per job:
|
|
|
163
166
|
`HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
|
|
164
167
|
`stat` (lstat), `writeFileAtomic`, `removeFile`, `lookupUser`, `lookupGroup`, `sleep`, plus
|
|
165
168
|
`privileged` and `effectiveUid()`. `localRunner()` implements it with `node:fs` and
|
|
166
|
-
`node:child_process`;
|
|
167
|
-
writes outside a temp directory.
|
|
169
|
+
`node:child_process`; `sudoRunner()` wraps it and elevates the allowlist. Tests use an in-memory
|
|
170
|
+
fake, so nothing in the test suite runs `launchctl` or `sudo`, or writes outside a temp directory.
|
package/docs/peers.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Peers — and why each pin exists
|
|
2
|
+
|
|
3
|
+
The install line for `@homeflare/alchemy`, and why every peer on it and every entry in the
|
|
4
|
+
`overrides` block is required. The block itself, which every consumer copies, is in the
|
|
5
|
+
[README](../README.md#peers--and-one-override-you-need).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @homeflare/alchemy alchemy@2.0.0-beta.79 effect@4.0.0-rc.115 \
|
|
9
|
+
@effect/platform-node@4.0.0-rc.115 cloudflare@4.5.0 mime@4.1.0 \
|
|
10
|
+
@distilled.cloud/cloudflare@1.0.0-rc.12
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
🔴 **Why, measured 2026-09-16 on 0.1.0 and re-checked 2026-09-17 against Alchemy 78.**
|
|
14
|
+
Effect's `rc` line is not semver-compatible with itself. Alchemy 78's peer is
|
|
15
|
+
`effect >= 4.0.0-rc.115`; an unlocked `@effect/platform-node-shared` still floats to the
|
|
16
|
+
next rc and breaks at import. Pin the whole set.
|
|
17
|
+
|
|
18
|
+
| what resolves | what happens |
|
|
19
|
+
| ------------------------------------------------------------- | ------------------------------------------------------ |
|
|
20
|
+
| Alchemy 77 + `effect` → rc.115 (measured 2026-09-16) | `TypeError: Config.string is not a function` at import |
|
|
21
|
+
| `@effect/platform-node-shared` newer than the pinned `effect` | `Cannot find module 'effect/ByteSize'` |
|
|
22
|
+
| `rolldown` → 1.2.9 via vite's `~1.2.6` (measured 2026-09-16) | `GET …/rolldown-1.2.9.tgz - 404` at `bun add` |
|
|
23
|
+
| no `mime` (measured 2026-09-17 against Alchemy 78) | `Cannot find package 'mime'` from cloudflare-runtime |
|
|
24
|
+
|
|
25
|
+
Alchemy 78 adapted to rc.115 — that first row is why we used to pin 112, not a reason to
|
|
26
|
+
stay there. The override is still the only thing that holds the set together.
|
|
27
|
+
|
|
28
|
+
⚠️ `@effect/platform-node` is **required, not optional**: Alchemy's module graph reaches
|
|
29
|
+
`Cloudflare/Workers/WorkerBridge → @effect/platform-node/NodeServices` even when you only
|
|
30
|
+
import the Proxmox subpath.
|
|
31
|
+
|
|
32
|
+
⚠️ So is `mime`. Alchemy 78's `@alchemy.run/cloudflare-runtime` imported it without
|
|
33
|
+
declaring it (measured 2026-09-17). 79 declares it; the peer stays so a consumer
|
|
34
|
+
that followed the 78 README does not drop a required line.
|
|
35
|
+
|
|
36
|
+
⚠️ So is `cloudflare`. It was marked optional in 0.1.1, which claimed the `/cloudflare`
|
|
37
|
+
subpath would degrade without it — measured 2026-09-16, the subpath does not load at all:
|
|
38
|
+
`Cannot find package 'cloudflare'`. An optional peer should mean a feature is absent, not
|
|
39
|
+
that an import fails.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A resource-scoped `adopt(…)` at apply — the create Alchemy never probed (caddyWithFile's first
|
|
3
|
+
* deploy). The planner resolves adoption as `resource.Adopt ?? --adopt` (alchemy beta.79 Plan.ts);
|
|
4
|
+
* the apply-time check must resolve it the same way, BOTH ways, or `--adopt` would take over a
|
|
5
|
+
* Caddy the stack declared `.pipe(adopt(false))`.
|
|
6
|
+
* ★ Registered through the REAL `Resource` (caddyWithFile under a Stack), not a hand-built map: the
|
|
7
|
+
* lookup reads what Alchemy records at registration, so an upgrade that stops recording `Adopt`
|
|
8
|
+
* in `Stack.resources` fails here (host-effect.ts adoptEnabled).
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, expect, test } from 'bun:test';
|
|
11
|
+
import { push } from 'alchemy';
|
|
12
|
+
import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import { Stack } from 'alchemy/Stack';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import * as Layer from 'effect/Layer';
|
|
16
|
+
import { caddyAdminLayer } from './admin.ts';
|
|
17
|
+
import { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
18
|
+
import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
|
|
19
|
+
import { caddyWithFile } from './with-file.ts';
|
|
20
|
+
|
|
21
|
+
const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
|
|
22
|
+
/** A config someone else loaded: it serves a site, and it is not what the stack declares. */
|
|
23
|
+
const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
|
|
24
|
+
|
|
25
|
+
let fake: FakeCaddy | undefined;
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fake?.stop();
|
|
28
|
+
fake = undefined;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
type Scope = 'adopt(true)' | 'adopt(false)' | 'none';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Declare caddyWithFile with `scope` under a deploy whose `--adopt` is `flag` — the session provides
|
|
35
|
+
* AdoptPolicy to registration AND apply, as Alchemist does — then apply the CaddyConfig with no state.
|
|
36
|
+
*/
|
|
37
|
+
const applyFirstDeploy = (scope: Scope, flag: boolean) => {
|
|
38
|
+
const made = fakeDefaultCaddy(FOREIGN);
|
|
39
|
+
fake = made.caddy;
|
|
40
|
+
const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
|
|
41
|
+
const declared = caddyWithFile('caddy', { caddyfile: SITE, path: '/usr/local/etc/Caddyfile' });
|
|
42
|
+
const scoped =
|
|
43
|
+
scope === 'none'
|
|
44
|
+
? declared
|
|
45
|
+
: (declared.pipe(adopt(scope === 'adopt(true)')) as typeof declared);
|
|
46
|
+
const program = Effect.gen(function* () {
|
|
47
|
+
// ★ Under a namespace, so the FQN (`edge/caddy`) differs from the logical id (`caddy`).
|
|
48
|
+
yield* push('edge', scoped);
|
|
49
|
+
const provider = yield* CaddyConfig.Provider;
|
|
50
|
+
return yield* provider.reconcile({
|
|
51
|
+
bindings: [] as never,
|
|
52
|
+
fqn: 'edge/caddy',
|
|
53
|
+
id: 'caddy',
|
|
54
|
+
instanceId: 'i-1',
|
|
55
|
+
news: { caddyfile: SITE, sourceFile: '/usr/local/etc/Caddyfile' },
|
|
56
|
+
olds: undefined,
|
|
57
|
+
output: undefined,
|
|
58
|
+
session: undefined as never,
|
|
59
|
+
});
|
|
60
|
+
}).pipe(
|
|
61
|
+
Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(made.admin)))),
|
|
62
|
+
Effect.provideService(AdoptPolicy, flag),
|
|
63
|
+
Effect.provideService(Stack, spec as never),
|
|
64
|
+
);
|
|
65
|
+
return { applying: Effect.runPromise(program as Effect.Effect<unknown, unknown>), spec };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const loads = () => fake?.seen.filter((call) => call.path === '/load') ?? [];
|
|
69
|
+
|
|
70
|
+
test.each([
|
|
71
|
+
['adopt(false) under --adopt', 'adopt(false)', true, false],
|
|
72
|
+
['adopt(true) without --adopt', 'adopt(true)', false, true],
|
|
73
|
+
['no scope under --adopt', 'none', true, true],
|
|
74
|
+
['no scope without --adopt', 'none', false, false],
|
|
75
|
+
] as const)('a first deploy with %s', async (_, scope, flag, loaded) => {
|
|
76
|
+
const { applying, spec } = applyFirstDeploy(scope, flag);
|
|
77
|
+
if (loaded) await applying;
|
|
78
|
+
else await expect(applying).rejects.toThrow(/which this stack did not load.*--adopt/);
|
|
79
|
+
// ★ The lookup key is the FQN the engine hands reconcile — the one registration used.
|
|
80
|
+
expect(Object.keys(spec.resources)).toContain('edge/caddy');
|
|
81
|
+
expect(loads()).toHaveLength(loaded ? 1 : 0);
|
|
82
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
6
6
|
import type { CaddyAdmin } from './admin.ts';
|
|
7
|
-
import { type FakeCaddy,
|
|
7
|
+
import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
|
|
8
8
|
import { configDigest } from './digest.ts';
|
|
9
9
|
import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
|
|
10
|
-
import { localCaddyAdmin } from './local-admin.ts';
|
|
11
10
|
|
|
12
11
|
const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
|
|
13
12
|
const OTHER = 'www.example.com respond <hello> & bye';
|
|
@@ -18,27 +17,22 @@ afterEach(() => {
|
|
|
18
17
|
fake = undefined;
|
|
19
18
|
});
|
|
20
19
|
|
|
21
|
-
/**
|
|
22
|
-
* ★ Addressed as a Caddy on its DEFAULT :2019 behind a forward (`hostHeader`), so a Caddyfile with
|
|
23
|
-
* no `admin` line — the common case — passes the guard. A Caddy reached on any other port needs
|
|
24
|
-
* `admin <address>` declared, or the first load would move it (admin-guard.ts).
|
|
25
|
-
*/
|
|
20
|
+
/** A Caddy on its default :2019 (fake-caddy.ts fakeDefaultCaddy), stopped after each test. */
|
|
26
21
|
const setup = (running?: unknown) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
hostHeader: '127.0.0.1:2019',
|
|
31
|
-
retries: 0,
|
|
32
|
-
});
|
|
33
|
-
return { admin, caddy: fake };
|
|
22
|
+
const made = fakeDefaultCaddy(running);
|
|
23
|
+
fake = made.caddy;
|
|
24
|
+
return made;
|
|
34
25
|
};
|
|
35
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
|
+
|
|
36
30
|
const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
|
|
37
31
|
|
|
38
32
|
describe('apply', () => {
|
|
39
33
|
test('loads the Caddyfile, reads it back, and stores the digest Caddy reports', async () => {
|
|
40
34
|
const { admin, caddy } = setup();
|
|
41
|
-
const applied = await reconcileConfig(admin, { caddyfile: OTHER });
|
|
35
|
+
const applied = await reconcileConfig(admin, { caddyfile: OTHER }, OWNED);
|
|
42
36
|
expect(applied.loaded).toBe(true);
|
|
43
37
|
// ★ The fake's /config/ is sorted and escaped differently from /adapt; the digests still agree.
|
|
44
38
|
expect(applied.attributes.configSha256).toBe(configDigest(caddy.adapt(OTHER)));
|
|
@@ -50,8 +44,8 @@ describe('apply', () => {
|
|
|
50
44
|
|
|
51
45
|
test('a Caddy already running this config is not reloaded', async () => {
|
|
52
46
|
const { admin, caddy } = setup();
|
|
53
|
-
await reconcileConfig(admin, { caddyfile: SITE });
|
|
54
|
-
const again = await reconcileConfig(admin, { caddyfile: SITE });
|
|
47
|
+
await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
|
|
48
|
+
const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
|
|
55
49
|
expect(again.loaded).toBe(false);
|
|
56
50
|
expect(loads(caddy)).toHaveLength(1);
|
|
57
51
|
});
|
|
@@ -59,7 +53,7 @@ describe('apply', () => {
|
|
|
59
53
|
test('sourceFile rides as the headers `caddy reload` sends, and lands in the attributes', async () => {
|
|
60
54
|
const { admin, caddy } = setup();
|
|
61
55
|
const path = '/usr/local/etc/Caddyfile';
|
|
62
|
-
const applied = await reconcileConfig(admin, { caddyfile: SITE, sourceFile: path });
|
|
56
|
+
const applied = await reconcileConfig(admin, { caddyfile: SITE, sourceFile: path }, OWNED);
|
|
63
57
|
expect(loads(caddy)[0]?.headers.get('caddy-config-source-file')).toBe(path);
|
|
64
58
|
expect(loads(caddy)[0]?.headers.get('caddy-config-source-adapter')).toBe('caddyfile');
|
|
65
59
|
expect(applied.attributes.sourceFile).toBe(path);
|
|
@@ -67,7 +61,7 @@ describe('apply', () => {
|
|
|
67
61
|
|
|
68
62
|
test('adapter warnings are returned, not refused', async () => {
|
|
69
63
|
const { admin } = setup();
|
|
70
|
-
const applied = await reconcileConfig(admin, { caddyfile: `${SITE}\t` });
|
|
64
|
+
const applied = await reconcileConfig(admin, { caddyfile: `${SITE}\t` }, OWNED);
|
|
71
65
|
expect(applied.warnings).toEqual(['Caddyfile:1: Caddyfile input is not formatted']);
|
|
72
66
|
});
|
|
73
67
|
});
|
|
@@ -75,33 +69,33 @@ describe('apply', () => {
|
|
|
75
69
|
describe('a config Caddy refuses', () => {
|
|
76
70
|
test('surfaces Caddy’s reason and confirms the previous config is still running', async () => {
|
|
77
71
|
const { admin, caddy } = setup();
|
|
78
|
-
await reconcileConfig(admin, { caddyfile: SITE });
|
|
72
|
+
await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
|
|
79
73
|
const before = caddy.running;
|
|
80
|
-
await expect(
|
|
81
|
-
|
|
82
|
-
);
|
|
74
|
+
await expect(
|
|
75
|
+
reconcileConfig(admin, { caddyfile: `${SITE}\nPROVISION_ERROR` }, OWNED),
|
|
76
|
+
).rejects.toThrow(/address already in use.*kept the previous config/);
|
|
83
77
|
expect(caddy.running).toEqual(before);
|
|
84
78
|
});
|
|
85
79
|
|
|
86
80
|
test('a refusal that came back as a 200 (warnings first) is still a refusal', async () => {
|
|
87
81
|
const { admin, caddy } = setup();
|
|
88
|
-
await reconcileConfig(admin, { caddyfile: SITE });
|
|
82
|
+
await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
|
|
89
83
|
await expect(
|
|
90
|
-
reconcileConfig(admin, { caddyfile: `${SITE}\t\nPROVISION_ERROR` }),
|
|
84
|
+
reconcileConfig(admin, { caddyfile: `${SITE}\t\nPROVISION_ERROR` }, OWNED),
|
|
91
85
|
).rejects.toThrow(/→ 200: loading config.*error in a 200 body.*kept the previous/);
|
|
92
86
|
expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
|
|
93
87
|
});
|
|
94
88
|
|
|
95
89
|
test('a load answered 200 that Caddy did not apply fails the deploy (the read-back)', async () => {
|
|
96
90
|
const { admin, caddy } = setup();
|
|
97
|
-
await reconcileConfig(admin, { caddyfile: SITE });
|
|
91
|
+
await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
|
|
98
92
|
// ⚠️ A concurrent writer, or a 200 that carried no change: state must not record OTHER.
|
|
99
93
|
const swallowing: CaddyAdmin = {
|
|
100
94
|
...admin,
|
|
101
95
|
request: (call) =>
|
|
102
96
|
call.path === '/load' ? Promise.resolve({ body: '', status: 200 }) : admin.request(call),
|
|
103
97
|
};
|
|
104
|
-
await expect(reconcileConfig(swallowing, { caddyfile: OTHER })).rejects.toThrow(
|
|
98
|
+
await expect(reconcileConfig(swallowing, { caddyfile: OTHER }, OWNED)).rejects.toThrow(
|
|
105
99
|
/after the load Caddy runs config \w+, not the \w+ this Caddyfile adapts to/,
|
|
106
100
|
);
|
|
107
101
|
expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
|
|
@@ -109,7 +103,7 @@ describe('a config Caddy refuses', () => {
|
|
|
109
103
|
|
|
110
104
|
test('a Caddyfile that does not adapt is refused before any load', async () => {
|
|
111
105
|
const { admin, caddy } = setup();
|
|
112
|
-
await expect(reconcileConfig(admin, { caddyfile: 'SYNTAX_ERROR' })).rejects.toThrow(
|
|
106
|
+
await expect(reconcileConfig(admin, { caddyfile: 'SYNTAX_ERROR' }, OWNED)).rejects.toThrow(
|
|
113
107
|
/\/adapt → 400: Caddyfile:1 - Error during parsing/,
|
|
114
108
|
);
|
|
115
109
|
expect(loads(caddy)).toHaveLength(0);
|
|
@@ -122,7 +116,9 @@ describe('refused before anything is sent', () => {
|
|
|
122
116
|
['an empty Caddyfile', ' \n'],
|
|
123
117
|
])('%s', async (_, caddyfile) => {
|
|
124
118
|
const { admin, caddy } = setup();
|
|
125
|
-
await expect(reconcileConfig(admin, { caddyfile })).rejects.toThrow(
|
|
119
|
+
await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(
|
|
120
|
+
/Caddy\.Config at http/,
|
|
121
|
+
);
|
|
126
122
|
expect(caddy.seen).toHaveLength(0);
|
|
127
123
|
});
|
|
128
124
|
|
|
@@ -131,15 +127,15 @@ describe('refused before anything is sent', () => {
|
|
|
131
127
|
['only global options', 'admin localhost:2019'],
|
|
132
128
|
])('a Caddyfile of %s adapts to no apps, and is never loaded', async (_, caddyfile) => {
|
|
133
129
|
const { admin, caddy } = setup();
|
|
134
|
-
await expect(reconcileConfig(admin, { caddyfile })).rejects.toThrow(/adapts to no apps/);
|
|
130
|
+
await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(/adapts to no apps/);
|
|
135
131
|
expect(loads(caddy)).toHaveLength(0);
|
|
136
132
|
});
|
|
137
133
|
|
|
138
134
|
test('a Caddyfile that would turn the admin API off is never loaded', async () => {
|
|
139
135
|
const { admin, caddy } = setup();
|
|
140
|
-
await expect(
|
|
141
|
-
|
|
142
|
-
);
|
|
136
|
+
await expect(
|
|
137
|
+
reconcileConfig(admin, { caddyfile: `admin off\n${SITE}` }, OWNED),
|
|
138
|
+
).rejects.toThrow(/admin off/);
|
|
143
139
|
expect(loads(caddy)).toHaveLength(0);
|
|
144
140
|
});
|
|
145
141
|
});
|
|
@@ -147,7 +143,7 @@ describe('refused before anything is sent', () => {
|
|
|
147
143
|
describe('drift', () => {
|
|
148
144
|
const applied = async () => {
|
|
149
145
|
const { admin, caddy } = setup();
|
|
150
|
-
const { attributes } = await reconcileConfig(admin, { caddyfile: SITE });
|
|
146
|
+
const { attributes } = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
|
|
151
147
|
return { admin, attributes, caddy };
|
|
152
148
|
};
|
|
153
149
|
|
|
@@ -165,7 +161,7 @@ describe('drift', () => {
|
|
|
165
161
|
const { admin, attributes, caddy } = await applied();
|
|
166
162
|
caddy.running = { apps: { http: { servers: {} } } };
|
|
167
163
|
expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'update' });
|
|
168
|
-
const again = await reconcileConfig(admin, { caddyfile: SITE });
|
|
164
|
+
const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
|
|
169
165
|
expect(again.attributes.configSha256).toBe(attributes.configSha256);
|
|
170
166
|
});
|
|
171
167
|
|
|
@@ -198,21 +194,3 @@ describe('drift', () => {
|
|
|
198
194
|
expect(diff).toEqual({ action: 'update' });
|
|
199
195
|
});
|
|
200
196
|
});
|
|
201
|
-
|
|
202
|
-
describe('adopt', () => {
|
|
203
|
-
test('a running Caddy reads as its live config; the first apply replaces it', async () => {
|
|
204
|
-
const { admin, caddy } = setup({
|
|
205
|
-
apps: { http: { servers: { legacy: { listen: [':80'] } } } },
|
|
206
|
-
});
|
|
207
|
-
const adopted = await readLive(admin);
|
|
208
|
-
expect(adopted.configSha256).toBe(configDigest(caddy.running));
|
|
209
|
-
expect(await diffConfig(admin, { caddyfile: SITE }, adopted)).toEqual({ action: 'update' });
|
|
210
|
-
await reconcileConfig(admin, { caddyfile: SITE });
|
|
211
|
-
expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test('an empty Caddy (config null) is adoptable too', async () => {
|
|
215
|
-
const { admin } = setup();
|
|
216
|
-
expect((await readLive(admin)).configSha256).toBe(configDigest(null));
|
|
217
|
-
});
|
|
218
|
-
});
|