@homeflare/alchemy 0.37.7 → 0.38.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/dist/caddy/admin.d.ts +42 -8
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +9 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +5 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.js +10 -6
- package/dist/caddy/index.js.map +5 -5
- package/dist/caddy/providers.d.ts +19 -12
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/linux/container-form.d.ts +1 -1
- package/dist/linux/container-generator.d.ts +28 -0
- package/dist/linux/container-generator.d.ts.map +1 -1
- package/dist/linux/container-lifecycle.d.ts +18 -1
- package/dist/linux/container-lifecycle.d.ts.map +1 -1
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -1
- package/dist/linux/container-settle.d.ts.map +1 -1
- package/dist/linux/fake-sudo.d.ts +14 -2
- package/dist/linux/fake-sudo.d.ts.map +1 -1
- package/dist/linux/index.js +76 -29
- package/dist/linux/index.js.map +11 -10
- package/dist/linux/sudo-exec.d.ts +16 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -1
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/linux/unit-text.d.ts +35 -0
- package/dist/linux/unit-text.d.ts.map +1 -0
- package/docs/linux-sudo.md +18 -7
- package/package.json +1 -1
- package/src/caddy/admin-transport-scope.test.ts +127 -0
- package/src/caddy/admin.ts +45 -9
- package/src/caddy/config-adopt-provider.test.ts +2 -2
- package/src/caddy/config-format-warning.test.ts +9 -3
- package/src/caddy/config.ts +20 -4
- package/src/caddy/fake-caddy.ts +10 -1
- package/src/caddy/providers.test.ts +3 -2
- package/src/caddy/providers.ts +18 -11
- package/src/linux/container-form.ts +1 -1
- package/src/linux/container-generator.test.ts +33 -0
- package/src/linux/container-generator.ts +72 -0
- package/src/linux/container-lifecycle.test.ts +6 -3
- package/src/linux/container-lifecycle.ts +23 -2
- package/src/linux/container-preflight.ts +47 -0
- package/src/linux/container-settle.ts +12 -1
- package/src/linux/container-shadow.test.ts +131 -0
- package/src/linux/fake-sudo.ts +21 -5
- package/src/linux/sudo-exec.ts +60 -10
- package/src/linux/sudo-lifecycle.test.ts +64 -3
- package/src/linux/unit-form.ts +4 -18
- package/src/linux/unit-text.test.ts +67 -0
- package/src/linux/unit-text.ts +58 -0
|
@@ -23,6 +23,22 @@
|
|
|
23
23
|
* is a valid type in `unit-form.ts`'s `TYPES` set) would also have elevated. Grepped: no code
|
|
24
24
|
* in this package ever calls `enable`/`start`/`restart` on a unit it has not itself just
|
|
25
25
|
* written a file for, so narrowing the exemption to `stop`/`disable` costs nothing legitimate.
|
|
26
|
+
* 🔴 MEASURED, CT100 deploy 2026-09-26 00:11Z: a THIRD gap, this one refusing a call that should
|
|
27
|
+
* have been allowed. `Podman.Container caddy`'s generated `caddy.service` has
|
|
28
|
+
* `FragmentPath=/run/systemd/generator/caddy.service` — Quadlet's generator output, never a
|
|
29
|
+
* path this runner would ever declare as a prefix (nothing writes there directly) — so `restart`
|
|
30
|
+
* was refused outright, even though the `.container` FILE THAT GENERATED IT lives at
|
|
31
|
+
* `/etc/containers/systemd/caddy.container`, squarely under this runner's own
|
|
32
|
+
* `/etc/containers/systemd` prefix, and this SAME runner had just written it. `SourcePath` is
|
|
33
|
+
* the generator's own record of that file (systemctl.ts's header, MEASURED on CT100). So a
|
|
34
|
+
* generated unit is now also reachable when its `FragmentPath` is the generator's OWN output
|
|
35
|
+
* directory (`GENERATOR_DIRECTORIES` below) AND its `SourcePath` is under a declared prefix —
|
|
36
|
+
* the thing this runner actually owns is the `.container` file, and the generated unit is
|
|
37
|
+
* systemd's own derivative of it, not a foreign unit. Everything the check refused before still
|
|
38
|
+
* refuses: a vendor unit's `FragmentPath` (`/usr/lib/systemd/system/...`) is not under a
|
|
39
|
+
* generator directory at all; a generated unit with no `SourcePath`, or one outside every
|
|
40
|
+
* prefix (a `.container` file elsewhere in Quadlet's OWN search path —
|
|
41
|
+
* `podman-systemd.unit(5)` — that this runner was never told to own), still refuses.
|
|
26
42
|
*/
|
|
27
43
|
import type { ExecResult, HostRunner } from '../launchd/runner.ts';
|
|
28
44
|
import type { Elevate } from './sudo-write.ts';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sudo-exec.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-exec.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"sudo-exec.d.ts","sourceRoot":"","sources":["../../src/linux/sudo-exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAInE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AA4D/C,iGAAiG;AACjG,eAAO,MAAM,YAAY,SACjB,UAAU,WACP,OAAO,YACN,SAAS,MAAM,EAAE,QACrB,SAAS,MAAM,EAAE,KACtB,OAAO,CAAC,UAAU,CAuDpB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unit-form.d.ts","sourceRoot":"","sources":["../../src/linux/unit-form.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"unit-form.d.ts","sourceRoot":"","sources":["../../src/linux/unit-form.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,WAAW,GAAG;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;CACpE,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iGAAiG;IACjG,QAAQ,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAClC,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gDAAgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,iGAAiG;AACjG,eAAO,MAAM,sBAAsB,wBAAwB,CAAC;AAE5D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,YAClC,qBAAqB,EACrB,qBAAqB,EACrB,+BAA+B,EAC/B,yBAAyB,EACzB,qBAAqB,CACb,CAAC;AAEX,mGAAmG;AACnG,eAAO,MAAM,UAAU;aAAK,GAAG,EAAE,CAAC;aAAE,IAAI,EAAE,GAAK;aAAE,GAAG,EAAE,CAAC;CAAW,CAAC;AAEnE,eAAO,MAAM,QAAQ,SAAU,MAAM,KAAG,MAAmD,CAAC;AAE5F,4FAA4F;AAC5F,eAAO,MAAM,YAAY,cAAe,SAAS,MAAM,EAAE,GAAG,SAAS,KAAG,MAChC,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,aAAa,gBACX,MAAM,YACT,MAAM,GAAG,SAAS,WACnB,OAAO,KACf,OAKF,CAAC;AAEF,kGAAkG;AAClG,eAAO,MAAM,UAAU,aAAc,SAAS,WAAW,EAAE,KAAG,MAKzC,CAAC;AAEtB,eAAO,MAAM,QAAQ,UAAW,gBAAgB,KAAG,MACA,CAAC;AAEpD,eAAO,MAAM,WAAW,UAAW,gBAAgB,KAAG,MACQ,CAAC;AAE/D,mGAAmG;AACnG,eAAO,MAAM,IAAI,QAA6C,CAAC;AAgB/D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,UAAW,gBAAgB,WAAW,MAAM,KAAG,MAAM,EAmC7E,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of `unit-form.ts`'s validation that inspect the unit file's TEXT rather than its
|
|
3
|
+
* props struct — split out so unit-form.ts stays under the file cap, and because these are one
|
|
4
|
+
* coherent concern: is this INI shape something systemd would actually load. Takes plain strings,
|
|
5
|
+
* never `SystemdUnitProps`, so it never needs to import back from unit-form.ts.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ `[Install]` KEYS ARE `unit-form.ts`'s OWN LIST, NOT SYSTEMD'S FULL VOCABULARY — WantedBy,
|
|
8
|
+
* RequiredBy, UpsertedBy, Also, Alias, per `systemd.unit(5)` §[Install]. `container-form.ts`'s
|
|
9
|
+
* own header notes it deliberately does NOT reuse this set: a `.container` file's `[Install]`
|
|
10
|
+
* supports only Alias/WantedBy/RequiredBy (`podman-systemd.unit(5)`), narrower than this.
|
|
11
|
+
*/
|
|
12
|
+
export declare const INSTALL_KEYS: Set<string>;
|
|
13
|
+
/** Whether `text` has an `[Install]` section carrying at least one key systemd actually links on. */
|
|
14
|
+
export declare const hasInstallSection: (text: string) => boolean;
|
|
15
|
+
/**
|
|
16
|
+
* The first line that is neither blank nor a comment — the line whose shape actually decides
|
|
17
|
+
* whether the file "begins with a [Section]", per `textProblems` below.
|
|
18
|
+
* ★ systemd.syntax(7): "Empty lines and lines starting with '#' or ';' are ignored", with no
|
|
19
|
+
* carve-out for the position before the first section — a comment header is ordinary content
|
|
20
|
+
* there, same as anywhere else in the file.
|
|
21
|
+
*/
|
|
22
|
+
export declare const firstSubstantiveLine: (text: string) => string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* 🔴 MEASURED FALSE, CT100 deploy 2026-09-26 00:11Z: the openbao-agent.service asset's first eight
|
|
25
|
+
* lines are `#`-comments recording where the file was recovered from (verbatim from the live
|
|
26
|
+
* host — homeflare-ct100's `src/openbao-agent/files/openbao-agent.service`), and the file is
|
|
27
|
+
* byte-identical to what systemd already loads there. The old check tested the WHOLE text
|
|
28
|
+
* against `/^\s*\[[A-Za-z]+]/` — `\s*` skips blank lines and indentation, never a `#`/`;`
|
|
29
|
+
* comment — so a leading comment line failed it outright. This checks only the first
|
|
30
|
+
* SUBSTANTIVE line, so any number of leading blank lines and comments are fine and a genuine
|
|
31
|
+
* `key=value` before any section — the one real error this exists to catch — still fails, since
|
|
32
|
+
* that line is neither blank nor a comment either.
|
|
33
|
+
*/
|
|
34
|
+
export declare const textProblems: (text: string) => string[];
|
|
35
|
+
//# sourceMappingURL=unit-text.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unit-text.d.ts","sourceRoot":"","sources":["../../src/linux/unit-text.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,YAAY,aAAqE,CAAC;AAE/F,qGAAqG;AACrG,eAAO,MAAM,iBAAiB,SAAU,MAAM,KAAG,OAIhD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,SAAU,MAAM,KAAG,MAAM,GAAG,SAG6B,CAAC;AAE3F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,SAAU,MAAM,KAAG,MAAM,EAajD,CAAC"}
|
package/docs/linux-sudo.md
CHANGED
|
@@ -76,15 +76,26 @@ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path.
|
|
|
76
76
|
`enable`/`disable`/`start`/`stop`/`restart` elevate only when the unit is not
|
|
77
77
|
**masked** (`LoadState`, read alongside `FragmentPath` in the same call —
|
|
78
78
|
masking is someone's decision, never overridden here) and its **own
|
|
79
|
-
`FragmentPath`** is either under a declared prefix,
|
|
79
|
+
`FragmentPath`** is either under a declared prefix, empty AND the verb is
|
|
80
80
|
`stop`/`disable` — the only two `unit-lifecycle.ts` `deleteUnit` ever sends
|
|
81
81
|
against a unit whose file it may just have removed, so a second, idempotent
|
|
82
|
-
delete pass still works
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
delete pass still works — **or a systemd GENERATOR's own output** (measured,
|
|
83
|
+
CT100 deploy 2026-09-26: `/run/systemd/generator`, and `.late`) **whose
|
|
84
|
+
`SourcePath` is under a declared prefix.** That is exactly `Podman.Container`:
|
|
85
|
+
Quadlet's generator writes `caddy.service`'s `FragmentPath` under
|
|
86
|
+
`/run/systemd/generator`, never a path this runner would declare, but stamps
|
|
87
|
+
`SourcePath` with the `.container` file that produced it — the thing this
|
|
88
|
+
runner actually wrote, under `/etc/containers/systemd`. `SourcePath` is read
|
|
89
|
+
in the same `systemctl show` as `FragmentPath`/`LoadState`, so no extra host
|
|
90
|
+
round trip. A generated unit with no `SourcePath`, or one outside every
|
|
91
|
+
prefix (a `.container` file elsewhere in Quadlet's OWN search path this
|
|
92
|
+
runner was never told to own), still refuses. `enable`/`start`/`restart`
|
|
93
|
+
with an empty `FragmentPath` are refused, closing a kernel-generated
|
|
94
|
+
pseudo-unit (`init.scope`, a `session-N.scope`) as a target. A vendor unit
|
|
95
|
+
like `pveproxy.service`, whose `FragmentPath` is `/usr/lib/systemd/system/…`
|
|
96
|
+
— not a generator directory — is refused before sudo ever runs: this runner
|
|
97
|
+
cannot touch what it did not declare, directly or through a generator.
|
|
98
|
+
`--user`, `--global`, `-H`, `-M` and `--root` are refused outright,
|
|
88
99
|
anywhere in the argv. `directory-lifecycle.ts`'s own `chown` argv is bare
|
|
89
100
|
(`0`, `:0`, `0:0`); `canonicalize()` rewrites it to the `+`-forced form above
|
|
90
101
|
before the allowlist ever sees it, so the unprivileged caller never has to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The leak this fix closes: `caddyProviders()` used to merge Caddy's admin `HttpClient.HttpClient`
|
|
3
|
+
* (and `Credentials`) straight into ITS OWN Layer output (providers.ts's `provideMerge`). Once Caddy
|
|
4
|
+
* sits in a stack's `Layer.mergeAll(…the stack's providers…)` (the exact shape homeflare-mini's
|
|
5
|
+
* `alchemy.run.ts` uses), its leaked client can shadow the stack's own ambient one for every sibling
|
|
6
|
+
* fetch-based provider merged alongside it — homeflare-mini's Forgejo and LiteLLM rows, per the PR
|
|
7
|
+
* evidence. Split from providers.test.ts at the 250-line cap.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ A HAND-BUILT `Effect.provide(Layer.mergeAll(ambient, caddyProviders(admin), someProvider))` CANNOT
|
|
10
|
+
* REPRODUCE THIS — measured 2026-09-26 (red-team review), correcting an earlier version of this file
|
|
11
|
+
* that claimed it did. `Layer.mergeAll` does not thread one member's output into another member's
|
|
12
|
+
* requirement, so making a sibling provider that requires `HttpClient.HttpClient` typecheck inside
|
|
13
|
+
* the same `mergeAll` as `ambient` needs `someProvider.pipe(Layer.provideMerge(ambient))` — but that
|
|
14
|
+
* `provideMerge` also re-outputs `ambient` as the LAST layer effect's own build, which then wins any
|
|
15
|
+
* later flat-context lookup regardless of whether Caddy leaked, defeating the setup whether the bug
|
|
16
|
+
* is present or not (0 fail either way — confirmed by re-swapping in the pre-fix admin.ts/config.ts/
|
|
17
|
+
* providers.ts and re-running: still 2 pass, 0 fail, with or without `--rerun-each 3`).
|
|
18
|
+
* ★ THE REAL SHAPE INSTEAD: `verify/fake-engine.ts`'s `engineOver` runs the ACTUAL `Alchemy.Stack` +
|
|
19
|
+
* `Plan.make` + `apply` over an in-memory state store — the same merge Stack.ts itself performs
|
|
20
|
+
* (providers' combined context over the platform's ambient `FetchHttpClient`), not a hand-assembled
|
|
21
|
+
* stand-in for it. `Layer.mergeAll(caddyProviders(admin), ProbeProvider()).pipe(Layer.provideMerge
|
|
22
|
+
* (ambient))` mirrors a real stack's `providers` option with `ambient` standing in for the platform
|
|
23
|
+
* layer Stack.ts itself provides underneath — CAPTURED FAILURE on pre-fix admin.ts/config.ts/
|
|
24
|
+
* providers.ts (bun test, this file):
|
|
25
|
+
* (fail) ENGINE: Alchemy.Stack + Plan + apply, ambient under the merged providers
|
|
26
|
+
* error: expect(received).toEqual(expected)
|
|
27
|
+
* Expected: Set {"ambient"}
|
|
28
|
+
* Received: Set {"{\"error\":\"host not allowed: 127.0.0.1:<port>\"}\n"}
|
|
29
|
+
* (the sibling's `client.get('http://sibling.invalid/probe')` dialled Caddy's OWN admin transport —
|
|
30
|
+
* a real loopback connect to the fake Caddy server, which answered its Host check (admin-guard.ts's
|
|
31
|
+
* own concern, at the wire) rather than ever reaching `sibling.invalid`); passes post-fix.
|
|
32
|
+
*/
|
|
33
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
34
|
+
import { Resource } from 'alchemy';
|
|
35
|
+
import * as Provider from 'alchemy/Provider';
|
|
36
|
+
import * as Effect from 'effect/Effect';
|
|
37
|
+
import * as Layer from 'effect/Layer';
|
|
38
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
39
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
40
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
41
|
+
import { CaddyConfig } from './config.ts';
|
|
42
|
+
import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
|
|
43
|
+
import { caddyProviders } from './providers.ts';
|
|
44
|
+
|
|
45
|
+
const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
|
|
46
|
+
const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
|
|
47
|
+
|
|
48
|
+
/** Stands in for a real fetch-based sibling (Forgejo, LiteLLM): a `Provider` whose handlers do
|
|
49
|
+
* exactly what a distilled SDK's generated operation does — `yield* HttpClient.HttpClient`. */
|
|
50
|
+
interface Probe extends Resource<'Test.Probe', { name: string }, { via: string }> {}
|
|
51
|
+
const Probe = Resource<Probe>('Test.Probe');
|
|
52
|
+
|
|
53
|
+
const ProbeProvider = (vias: string[]) => {
|
|
54
|
+
const hit = Effect.gen(function* () {
|
|
55
|
+
const client = yield* HttpClient.HttpClient;
|
|
56
|
+
const response = yield* client.get('http://sibling.invalid/probe');
|
|
57
|
+
const via = yield* response.text;
|
|
58
|
+
vias.push(via);
|
|
59
|
+
return { via };
|
|
60
|
+
});
|
|
61
|
+
return Provider.succeed(Probe, {
|
|
62
|
+
read: () => Effect.as(hit, undefined),
|
|
63
|
+
reconcile: () => hit,
|
|
64
|
+
delete: () => Effect.void,
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Answers instantly and records that IT (not some other client) was the one asked. */
|
|
69
|
+
const labeledClient = (label: string, seen: string[]): Layer.Layer<HttpClient.HttpClient> =>
|
|
70
|
+
Layer.succeed(
|
|
71
|
+
HttpClient.HttpClient,
|
|
72
|
+
HttpClient.make((request) => {
|
|
73
|
+
seen.push(label);
|
|
74
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(label)));
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
let fake: FakeCaddy | undefined;
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
fake?.stop();
|
|
81
|
+
fake = undefined;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('caddyProviders() admin-transport scoping', () => {
|
|
85
|
+
test('a sibling provider merged alongside it gets the AMBIENT client, never the admin one', async () => {
|
|
86
|
+
// ★ The real engine (Alchemy.Stack + Plan + apply, see the file header's ★): `ambient` stands in
|
|
87
|
+
// for the platform's own `FetchHttpClient`, `Layer.provideMerge`d under the stack's merged
|
|
88
|
+
// providers exactly as Stack.ts does. This never starts the fake Caddy's own traffic — it only
|
|
89
|
+
// proves WHICH client the sibling resolves, so the admin transport can point anywhere;
|
|
90
|
+
// `fakeDefaultCaddy()` gives it a real one.
|
|
91
|
+
const { admin, caddy } = fakeDefaultCaddy();
|
|
92
|
+
fake = caddy;
|
|
93
|
+
const vias: string[] = [];
|
|
94
|
+
const ambient = labeledClient('ambient', vias);
|
|
95
|
+
const engine = engineOver(
|
|
96
|
+
Layer.mergeAll(caddyProviders(admin), ProbeProvider(vias)).pipe(Layer.provideMerge(ambient)),
|
|
97
|
+
);
|
|
98
|
+
await engine.deploy(Probe('p', { name: 'sibling' }));
|
|
99
|
+
expect(vias.length).toBeGreaterThan(0);
|
|
100
|
+
// ★ Every hit the sibling made must read the ambient client's own label, never an error body from
|
|
101
|
+
// Caddy's admin transport (admin-guard.ts's Host check, reached only by a wrong client).
|
|
102
|
+
expect(new Set(vias)).toEqual(new Set(['ambient']));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("Caddy's own admin calls still go through ITS transport, never the stack's ambient client", async () => {
|
|
106
|
+
const { admin, caddy } = fakeDefaultCaddy();
|
|
107
|
+
fake = caddy;
|
|
108
|
+
const seen: string[] = [];
|
|
109
|
+
const stack = Layer.mergeAll(labeledClient('ambient', seen), caddyProviders(admin));
|
|
110
|
+
await Effect.runPromise(
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const provider = yield* CaddyConfig.Provider;
|
|
113
|
+
return yield* provider.reconcile({
|
|
114
|
+
...ids,
|
|
115
|
+
bindings: [] as never,
|
|
116
|
+
news: { caddyfile: SITE },
|
|
117
|
+
olds: { caddyfile: SITE },
|
|
118
|
+
output: { configSha256: 'earlier', endpoint: caddy.address },
|
|
119
|
+
session: undefined as never,
|
|
120
|
+
});
|
|
121
|
+
}).pipe(Effect.provide(stack)),
|
|
122
|
+
);
|
|
123
|
+
expect(caddy.seen.some((call) => call.path === '/load')).toBe(true);
|
|
124
|
+
// ★ The ambient stub is never asked anything: every admin call reached `admin`'s own transport.
|
|
125
|
+
expect(seen).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/caddy/admin.ts
CHANGED
|
@@ -10,12 +10,25 @@
|
|
|
10
10
|
* caddyconfig/load.go and admin.go, and in the API docs (caddyserver.com/docs/api).
|
|
11
11
|
* ★ `CaddyAdminService` CARRIES ONLY MESSAGE/GUARD METADATA (endpoint, listener) — never a
|
|
12
12
|
* credential, and never the transport itself. The transport is `@distilled.cloud/caddy`'s own
|
|
13
|
-
* `Credentials` and `HttpClient.HttpClient` services
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `Credentials` and `HttpClient.HttpClient` services — admin-calls.ts calls the SDK's operations
|
|
14
|
+
* directly rather than through a house-specific `.request()` seam. A consumer plugs in another
|
|
15
|
+
* route to the API (an SSH-forwarded socket today; a remote runner later) by providing a different
|
|
16
|
+
* `CaddyTransport` to `caddyAdminLayer`.
|
|
17
17
|
* ⛔ THE ADMIN API HAS NO AUTHENTICATION. Anyone who reaches it can replace every site. It stays on
|
|
18
18
|
* loopback or a unix socket; localCaddyAdmin() refuses anything else (local-admin.ts).
|
|
19
|
+
* ⛔ `caddyAdminLayer`'s OWN OUTPUT NEVER CARRIES `Credentials`/`HttpClient.HttpClient` DIRECTLY —
|
|
20
|
+
* MEASURED 2026-09-26: it used to (`Layer.mergeAll(CaddyAdminService-layer, transport.layer)`,
|
|
21
|
+
* what `caddyProviders()` then `provideMerge`d into a stack), and a stack that applies its own
|
|
22
|
+
* ambient client OUTSIDE the providers' merge — `Layer.mergeAll(providers…).pipe(Layer.provide
|
|
23
|
+
* (ambient))`, the shape `argocd`/`discord`/`opnsense`/`unifi`'s own `providers.ts` document —
|
|
24
|
+
* never got it back: `Layer.provide(A, B)`'s result outputs only `A`'s own services, so once `A`
|
|
25
|
+
* (the merged providers) already carries `HttpClient.HttpClient` from Caddy, no outer `B` overrides
|
|
26
|
+
* it. Every OTHER fetch-based provider merged alongside `caddyProviders()` (homeflare-mini's
|
|
27
|
+
* Forgejo, LiteLLM) then dialled Caddy's admin API instead of its own target — reproduced in
|
|
28
|
+
* admin-transport-scope.test.ts. `caddyAdminLayer` now stores `transport.layer` itself as a plain
|
|
29
|
+
* VALUE, under `CaddyAdminTransport` — a house-only tag nothing outside caddy/ ever reads — and
|
|
30
|
+
* config.ts is the one place that `Effect.provide`s it, scoped to exactly the effects that call
|
|
31
|
+
* `@distilled.cloud/caddy`'s operations.
|
|
19
32
|
*/
|
|
20
33
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
21
34
|
import * as Context from 'effect/Context';
|
|
@@ -43,7 +56,16 @@ export interface CaddyTarget {
|
|
|
43
56
|
|
|
44
57
|
/** What `localCaddyAdmin()` (or a consumer's own transport) hands `caddyAdminLayer`. */
|
|
45
58
|
export interface CaddyTransport extends CaddyTarget {
|
|
46
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Provides the SDK's own `Credentials` and `HttpClient.HttpClient` — see local-admin.ts.
|
|
61
|
+
* ⚠️ BUILT AND TORN DOWN ON EVERY `read`/`diff`/`reconcile` CALL — config.ts's `Effect.provide
|
|
62
|
+
* (transportLayer)` runs fresh each time, scoped to that one call (the scoping fix this file's
|
|
63
|
+
* own ⛔ documents). `localCaddyAdmin()`'s layer is three plain `Layer.succeed` values, so this
|
|
64
|
+
* costs nothing; a transport that opens something stateful (an SSH forward, a connection pool)
|
|
65
|
+
* would open and close it per call instead of once per stack. No such transport exists in the
|
|
66
|
+
* tray today (2026-09-26) — keep `layer` cheap and stateless, or build once and hand config.ts a
|
|
67
|
+
* pre-built `Context` instead, if that changes.
|
|
68
|
+
*/
|
|
47
69
|
readonly layer: Layer.Layer<Credentials | HttpClient.HttpClient>;
|
|
48
70
|
}
|
|
49
71
|
|
|
@@ -52,19 +74,33 @@ export class CaddyAdminService extends Context.Service<CaddyAdminService, CaddyT
|
|
|
52
74
|
'homeflare/caddy/CaddyAdmin',
|
|
53
75
|
) {}
|
|
54
76
|
|
|
77
|
+
/**
|
|
78
|
+
* `transport.layer` ITSELF, carried as a plain value rather than merged into the ambient context —
|
|
79
|
+
* the scoping fix (see the file header's ⛔). Only config.ts reads this tag, and only to
|
|
80
|
+
* `Effect.provide` it locally around the handful of effects that actually call
|
|
81
|
+
* `@distilled.cloud/caddy`'s operations; nothing else in this directory, and nothing outside it,
|
|
82
|
+
* has a reason to.
|
|
83
|
+
*/
|
|
84
|
+
export class CaddyAdminTransport extends Context.Service<
|
|
85
|
+
CaddyAdminTransport,
|
|
86
|
+
Layer.Layer<Credentials | HttpClient.HttpClient>
|
|
87
|
+
>()('homeflare/caddy/CaddyAdminTransport') {}
|
|
88
|
+
|
|
55
89
|
/**
|
|
56
90
|
* Provide a transport to the provider: `CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(localCaddyAdmin())))`
|
|
57
91
|
* — never plain `Layer.provide` here (see providers.ts's own ⚠️): `CaddyConfigProvider()`'s handlers
|
|
58
|
-
* keep needing
|
|
59
|
-
*
|
|
92
|
+
* keep needing `CaddyAdminService`/`CaddyAdminTransport` every time the engine calls them, not just
|
|
93
|
+
* once while it is built. Neither tag is a generic platform service, so merging them into a stack's
|
|
94
|
+
* ambient context (unlike the raw `Credentials`/`HttpClient.HttpClient` this used to carry — the
|
|
95
|
+
* file header's ⛔) is safe: nothing outside this directory looks either up.
|
|
60
96
|
*/
|
|
61
97
|
export const caddyAdminLayer = (
|
|
62
98
|
transport: CaddyTransport,
|
|
63
|
-
): Layer.Layer<CaddyAdminService |
|
|
99
|
+
): Layer.Layer<CaddyAdminService | CaddyAdminTransport> =>
|
|
64
100
|
Layer.mergeAll(
|
|
65
101
|
Layer.succeed(CaddyAdminService, {
|
|
66
102
|
endpoint: transport.endpoint,
|
|
67
103
|
listener: transport.listener,
|
|
68
104
|
}),
|
|
69
|
-
transport.layer,
|
|
105
|
+
Layer.succeed(CaddyAdminTransport, transport.layer),
|
|
70
106
|
);
|
|
@@ -9,7 +9,6 @@ import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
|
|
|
9
9
|
import { AlchemyContext } from 'alchemy/AlchemyContext';
|
|
10
10
|
import * as Effect from 'effect/Effect';
|
|
11
11
|
import * as Layer from 'effect/Layer';
|
|
12
|
-
import type * as Caddy from '@distilled.cloud/caddy';
|
|
13
12
|
import { type CaddyAdminService, type CaddyTransport, caddyAdminLayer } from './admin.ts';
|
|
14
13
|
import { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
15
14
|
import { configDigest } from './digest.ts';
|
|
@@ -40,9 +39,10 @@ type Deploy = { readonly adopt?: boolean; readonly contextAdopt?: boolean };
|
|
|
40
39
|
const withProvider = <A>(
|
|
41
40
|
admin: CaddyTransport,
|
|
42
41
|
deploy: Deploy,
|
|
42
|
+
// ★ `CaddyAdminService` ONLY — see providers.test.ts's own `withProvider` for why.
|
|
43
43
|
use: (
|
|
44
44
|
p: Effect.Success<typeof CaddyConfig.Provider>,
|
|
45
|
-
) => Effect.Effect<A, unknown, CaddyAdminService
|
|
45
|
+
) => Effect.Effect<A, unknown, CaddyAdminService>,
|
|
46
46
|
) => {
|
|
47
47
|
// ⚠️ `Layer.provideMerge`, not `Layer.provide` — providers.ts's own doc on `caddyAdminLayer` says
|
|
48
48
|
// why: `CaddyConfigProvider()`'s handlers still need these services every time they run, not
|
|
@@ -13,7 +13,7 @@ import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
|
13
13
|
import * as Effect from 'effect/Effect';
|
|
14
14
|
import * as Layer from 'effect/Layer';
|
|
15
15
|
import * as Logger from 'effect/Logger';
|
|
16
|
-
import { caddyAdminLayer } from './admin.ts';
|
|
16
|
+
import { type CaddyTransport, caddyAdminLayer } from './admin.ts';
|
|
17
17
|
import { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
18
18
|
import { diffConfig } from './config-reconcile.ts';
|
|
19
19
|
import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
|
|
@@ -34,6 +34,12 @@ const setup = () => {
|
|
|
34
34
|
return made;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/** `diffConfig` still needs the raw `Credentials`/`HttpClient` directly — `caddyAdminLayer` alone
|
|
38
|
+
* (admin.ts's own scoping fix) only carries `CaddyAdminService`/`CaddyAdminTransport`; merge
|
|
39
|
+
* `admin.layer` in too, the same fix `fake-caddy.ts`'s `runCaddy` makes for this exact shape. */
|
|
40
|
+
const withTransport = (admin: CaddyTransport) =>
|
|
41
|
+
Layer.mergeAll(caddyAdminLayer(admin), admin.layer);
|
|
42
|
+
|
|
37
43
|
/** Runs `effect` and returns its result plus every `Effect.logWarning` line it emitted. */
|
|
38
44
|
const withWarnings = async <A>(effect: Effect.Effect<A, unknown, never>) => {
|
|
39
45
|
const warnings: string[] = [];
|
|
@@ -49,7 +55,7 @@ describe('the formatting warning is surfaced distinctly', () => {
|
|
|
49
55
|
const { admin } = setup();
|
|
50
56
|
const output = { configSha256: 'stub', endpoint: admin.endpoint };
|
|
51
57
|
const { value, warnings } = await withWarnings(
|
|
52
|
-
diffConfig({ caddyfile: UNFORMATTED }, output).pipe(Effect.provide(
|
|
58
|
+
diffConfig({ caddyfile: UNFORMATTED }, output).pipe(Effect.provide(withTransport(admin))),
|
|
53
59
|
);
|
|
54
60
|
expect(value).toEqual({ action: 'update' });
|
|
55
61
|
expect(warnings).toHaveLength(1);
|
|
@@ -61,7 +67,7 @@ describe('the formatting warning is surfaced distinctly', () => {
|
|
|
61
67
|
const { admin } = setup();
|
|
62
68
|
const output = { configSha256: 'stub', endpoint: admin.endpoint };
|
|
63
69
|
const { warnings } = await withWarnings(
|
|
64
|
-
diffConfig({ caddyfile: SITE }, output).pipe(Effect.provide(
|
|
70
|
+
diffConfig({ caddyfile: SITE }, output).pipe(Effect.provide(withTransport(admin))),
|
|
65
71
|
);
|
|
66
72
|
expect(warnings).toEqual([]);
|
|
67
73
|
});
|
package/src/caddy/config.ts
CHANGED
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
* engine skips that probe, reconcile refuses the same takeover (config-lifecycle.ts).
|
|
22
22
|
* ⛔ `caddyfile` IS NEVER A SECRET — see config-form.ts; refused before anything is sent.
|
|
23
23
|
* ⚠️ WHICH CADDY is the transport's (providers.ts): `CaddyAdminService` plus the `Credentials`/
|
|
24
|
-
* `HttpClient`
|
|
24
|
+
* `HttpClient` layer `CaddyAdminTransport` carries — one per stack, not a prop.
|
|
25
|
+
* ⚠️ `transportLayer` IS `Effect.provide`D LOCALLY IN EACH HANDLER BELOW, NEVER MERGED AHEAD OF
|
|
26
|
+
* TIME. `caddyAdminLayer` (admin.ts) stores it under `CaddyAdminTransport` rather than merging its
|
|
27
|
+
* `Credentials`/`HttpClient.HttpClient` into the ambient context precisely so this file is the ONE
|
|
28
|
+
* place that discharges them — scoped to the handful of effects that actually call
|
|
29
|
+
* `@distilled.cloud/caddy`'s operations, never leaking into a sibling fetch-based provider merged
|
|
30
|
+
* into the same stack (admin.ts's own ⛔; MEASURED 2026-09-26, admin-transport-scope.test.ts).
|
|
25
31
|
*/
|
|
26
32
|
import { Resource } from 'alchemy';
|
|
27
33
|
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
@@ -30,7 +36,7 @@ import * as Provider from 'alchemy/Provider';
|
|
|
30
36
|
import * as Effect from 'effect/Effect';
|
|
31
37
|
import { resolvedString } from '../launchd/host-effect.ts';
|
|
32
38
|
import { adoptEnabled } from '../ownership/adopt.ts';
|
|
33
|
-
import { CaddyAdminService } from './admin.ts';
|
|
39
|
+
import { CaddyAdminService, CaddyAdminTransport } from './admin.ts';
|
|
34
40
|
import { isUnreachable } from './caddy-http-client.ts';
|
|
35
41
|
import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
36
42
|
import { probeLive, readLive } from './config-lifecycle.ts';
|
|
@@ -72,6 +78,7 @@ export const CaddyConfigProvider = () =>
|
|
|
72
78
|
CaddyConfig,
|
|
73
79
|
Effect.gen(function* () {
|
|
74
80
|
const admin = yield* CaddyAdminService;
|
|
81
|
+
const transportLayer = yield* CaddyAdminTransport;
|
|
75
82
|
/** The probe, branded for the engine: plain when ours, `Unowned` otherwise. */
|
|
76
83
|
const probe = (caddyfile: string | undefined, sourceFile: string | undefined) =>
|
|
77
84
|
Effect.flatMap(probeLive(caddyfile, sourceFile), (found) => {
|
|
@@ -104,6 +111,9 @@ export const CaddyConfigProvider = () =>
|
|
|
104
111
|
? yield* probe(resolvedString(olds, 'caddyfile'), resolvedString(olds, 'sourceFile'))
|
|
105
112
|
: yield* readLive(resolvedString(olds, 'sourceFile'));
|
|
106
113
|
}).pipe(
|
|
114
|
+
// ★ Scoped to Caddy's own transport — see the file header's ⚠️ — before the catch below,
|
|
115
|
+
// so `isUnreachable` still narrows the SAME raw error this always raised.
|
|
116
|
+
Effect.provide(transportLayer),
|
|
107
117
|
// ★ Nothing to adopt from a Caddy that is down: plan a create, which is the same load.
|
|
108
118
|
Effect.catchIf(isUnreachable, planWithoutCaddy(admin, undefined)),
|
|
109
119
|
),
|
|
@@ -112,7 +122,10 @@ export const CaddyConfigProvider = () =>
|
|
|
112
122
|
if (output === undefined) return Effect.succeed(undefined);
|
|
113
123
|
const update = planWithoutCaddy(admin, { action: 'update' as const });
|
|
114
124
|
if (isResolved(news)) {
|
|
115
|
-
return diffConfig(news, output).pipe(
|
|
125
|
+
return diffConfig(news, output).pipe(
|
|
126
|
+
Effect.provide(transportLayer),
|
|
127
|
+
Effect.catchIf(isUnreachable, update),
|
|
128
|
+
);
|
|
116
129
|
}
|
|
117
130
|
/**
|
|
118
131
|
* ⚠️ `sourceFile` is an Output while its HostFile changes in the same deploy
|
|
@@ -123,6 +136,7 @@ export const CaddyConfigProvider = () =>
|
|
|
123
136
|
const caddyfile = resolvedString(news, 'caddyfile');
|
|
124
137
|
if (caddyfile === undefined) return Effect.succeed(undefined);
|
|
125
138
|
return Effect.as(diffConfig({ caddyfile }, output), { action: 'update' as const }).pipe(
|
|
139
|
+
Effect.provide(transportLayer),
|
|
126
140
|
Effect.catchIf(isUnreachable, update),
|
|
127
141
|
);
|
|
128
142
|
},
|
|
@@ -136,7 +150,9 @@ export const CaddyConfigProvider = () =>
|
|
|
136
150
|
const sameCaddy = output !== undefined && output.endpoint === admin.endpoint;
|
|
137
151
|
const takeOver = sameCaddy || (yield* adoptEnabled(fqn));
|
|
138
152
|
const stored = output === undefined ? {} : { stored: output.configSha256 };
|
|
139
|
-
const applied = yield* reconcileConfig(news, { takeOver, ...stored })
|
|
153
|
+
const applied = yield* reconcileConfig(news, { takeOver, ...stored }).pipe(
|
|
154
|
+
Effect.provide(transportLayer),
|
|
155
|
+
);
|
|
140
156
|
// ★ THE FORMATTING WARNING GETS ITS OWN LINE, NAMING THE FIX — separate from the rest,
|
|
141
157
|
// which keep logging exactly as before (format-warnings.ts).
|
|
142
158
|
const { formatting, rest } = splitFormattingWarning(applied.warnings);
|
package/src/caddy/fake-caddy.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* fake really listens on an ephemeral one: a Caddy on its default :2019 behind a forward.
|
|
19
19
|
*/
|
|
20
20
|
import * as Effect from 'effect/Effect';
|
|
21
|
+
import * as Layer from 'effect/Layer';
|
|
21
22
|
import type * as Caddy from '@distilled.cloud/caddy';
|
|
22
23
|
import { type CaddyAdminService, type CaddyTransport, caddyAdminLayer } from './admin.ts';
|
|
23
24
|
import { localCaddyAdmin } from './local-admin.ts';
|
|
@@ -190,8 +191,16 @@ export const fakeDefaultCaddy = (
|
|
|
190
191
|
* Runs an Effect that needs `CaddyAdminService` and `@distilled.cloud/caddy`'s own `Credentials`/
|
|
191
192
|
* `HttpClient` against one transport — the one line every test in this directory used to spend on
|
|
192
193
|
* `Effect.provide(caddyAdminLayer(admin))` before this helper existed.
|
|
194
|
+
* ⚠️ TWO LAYERS, NOT ONE: `caddyAdminLayer(admin)` only carries `CaddyAdminService` and the
|
|
195
|
+
* `CaddyAdminTransport`-wrapped VALUE of `admin.layer` (admin.ts's own scoping fix) — the
|
|
196
|
+
* lifecycle functions this helper drives (`probeLive`, `reconcileConfig`, …) still need the raw
|
|
197
|
+
* `Credentials`/`HttpClient.HttpClient` directly in context, so `admin.layer` is provided again,
|
|
198
|
+
* built this time, alongside it.
|
|
193
199
|
*/
|
|
194
200
|
export const runCaddy = <A, E>(
|
|
195
201
|
effect: Effect.Effect<A, E, CaddyAdminService | Caddy.CaddyOpContext>,
|
|
196
202
|
admin: CaddyTransport,
|
|
197
|
-
): Promise<A> =>
|
|
203
|
+
): Promise<A> =>
|
|
204
|
+
Effect.runPromise(
|
|
205
|
+
effect.pipe(Effect.provide(Layer.mergeAll(caddyAdminLayer(admin), admin.layer))),
|
|
206
|
+
);
|
|
@@ -7,7 +7,6 @@ import * as Output from 'alchemy/Output';
|
|
|
7
7
|
import { Stack } from 'alchemy/Stack';
|
|
8
8
|
import * as Effect from 'effect/Effect';
|
|
9
9
|
import * as Layer from 'effect/Layer';
|
|
10
|
-
import type * as Caddy from '@distilled.cloud/caddy';
|
|
11
10
|
import { type CaddyAdminService, caddyAdminLayer } from './admin.ts';
|
|
12
11
|
import { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
13
12
|
import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
|
|
@@ -31,9 +30,11 @@ const handler = <F>(name: string, fn: F | undefined): F => {
|
|
|
31
30
|
};
|
|
32
31
|
|
|
33
32
|
const withProvider = <A>(
|
|
33
|
+
// ★ `CaddyAdminService` ONLY — `read`/`diff`/`reconcile` no longer need the admin transport from
|
|
34
|
+
// OUTSIDE post-fix: config.ts's handlers `Effect.provide` it locally now (admin.ts's own ⛔).
|
|
34
35
|
use: (
|
|
35
36
|
p: Effect.Success<typeof CaddyConfig.Provider>,
|
|
36
|
-
) => Effect.Effect<A, unknown, CaddyAdminService
|
|
37
|
+
) => Effect.Effect<A, unknown, CaddyAdminService>,
|
|
37
38
|
address?: string,
|
|
38
39
|
) => {
|
|
39
40
|
// ★ A Caddy on its default :2019 behind a forward, as in config-lifecycle.test.ts.
|
package/src/caddy/providers.ts
CHANGED
|
@@ -12,17 +12,24 @@
|
|
|
12
12
|
* and `HttpClient.HttpClient`.
|
|
13
13
|
*
|
|
14
14
|
* ⚠️ `Layer.provideMerge`, NEVER PLAIN `Layer.provide`, for `caddyAdminLayer`. `CaddyConfigProvider()`'s
|
|
15
|
-
* `read`/`diff`/`reconcile` handlers each still need `CaddyAdminService` and
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
15
|
+
* `read`/`diff`/`reconcile` handlers each still need `CaddyAdminService` and `CaddyAdminTransport`
|
|
16
|
+
* — Alchemy's own `Provider.effect` types those as part of the LAYER's overall requirement
|
|
17
|
+
* (`ProviderService`'s per-handler `Req` type parameters, `alchemy/Provider.ts`), for exactly this
|
|
18
|
+
* reason. Plain `Layer.provide` SEALS a layer — it satisfies `CaddyConfigProvider()`'s requirement
|
|
19
|
+
* only for BUILDING the `Provider` value (the `admin`/`transportLayer` in its outer `Effect.gen`),
|
|
20
|
+
* then hides those services from anything that runs LATER, when the returned `read`/`diff`/
|
|
21
|
+
* `reconcile` FUNCTIONS actually execute. MEASURED 2026-09-23: with plain `Layer.provide`,
|
|
22
|
+
* `caddyProviders()`'s own `reconcile` handler dies with "Service not found: homeflare/caddy/
|
|
23
|
+
* CaddyAdmin" the moment the ENGINE calls it. `provideMerge` feeds `caddyAdminLayer`'s output into
|
|
24
|
+
* `CaddyConfigProvider()`'s requirement AND keeps that same output live in the result, so every
|
|
25
|
+
* later handler call still runs with it in context.
|
|
26
|
+
* ⛔ THIS `caddyProviders()` LAYER NEVER CARRIES `Credentials`/`HttpClient.HttpClient` ITSELF (fixed
|
|
27
|
+
* 2026-09-26 — admin.ts's own ⛔ has the mechanism and the measured leak). `caddyAdminLayer`'s
|
|
28
|
+
* output is `CaddyAdminService | CaddyAdminTransport`, both house-only tags safe to `provideMerge`
|
|
29
|
+
* into any host stack; the SDK's own `Credentials`/`HttpClient.HttpClient` stay inside
|
|
30
|
+
* `CaddyAdminTransport`'s stored value, `Effect.provide`d locally by config.ts around exactly the
|
|
31
|
+
* effects that need them. A stack merging `caddyProviders()` with any other fetch-based provider
|
|
32
|
+
* (`forgejoProviders()`, `litellmProviders()`, …) is safe from this file's own composition alone.
|
|
26
33
|
*/
|
|
27
34
|
import * as Layer from 'effect/Layer';
|
|
28
35
|
import { type CaddyTransport, caddyAdminLayer } from './admin.ts';
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* or renders that file's contents.
|
|
19
19
|
* ⛔ `[Install]` SUPPORTS ONLY `Alias=`/`WantedBy=`/`RequiredBy=` FOR A `.container` FILE (same doc,
|
|
20
20
|
* "Currently, only the Alias, WantedBy and RequiredBy keys are supported") — NOT `Also=`, which
|
|
21
|
-
* plain `systemd.unit(5)` allows and unit-
|
|
21
|
+
* plain `systemd.unit(5)` allows and unit-text.ts's `INSTALL_KEYS` therefore lists. See
|
|
22
22
|
* container-generator.ts's header for why there is no `enabled` prop at all.
|
|
23
23
|
*/
|
|
24
24
|
import { type UnitSection, configDigest, digestOf, renderUnit } from './unit-form.ts';
|
|
@@ -192,4 +192,37 @@ describe('verifyGenerated, directly', () => {
|
|
|
192
192
|
}),
|
|
193
193
|
).toThrow(/earlier in Quadlet/);
|
|
194
194
|
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* ⛔ REGRESSION — found on adversarial review. Before the FragmentPath check was added, a real,
|
|
198
|
+
* already-loaded PLAIN unit (e.g. an admin's own `/etc/systemd/system/…` sharing this
|
|
199
|
+
* container's service name) reported `LoadState=loaded` (it is a genuinely valid unit) and no
|
|
200
|
+
* `SourcePath` (hand-written units never carry one) — both existing checks passed it silently,
|
|
201
|
+
* so `verifyGenerated` was not the backstop its own doc comments claimed: a shadow that slipped
|
|
202
|
+
* past `assertUnshadowed` (container-preflight.ts) would misapply silently, not throw loudly.
|
|
203
|
+
*/
|
|
204
|
+
test('a loaded plain unit with no SourcePath is still caught, via FragmentPath', () => {
|
|
205
|
+
expect(() =>
|
|
206
|
+
verifyGenerated(NAME, PATH, SERVICE, {
|
|
207
|
+
activeState: 'active',
|
|
208
|
+
fragmentPath: '/etc/systemd/system/hf-example.service',
|
|
209
|
+
known: true,
|
|
210
|
+
loadState: 'loaded',
|
|
211
|
+
needDaemonReload: false,
|
|
212
|
+
}),
|
|
213
|
+
).toThrow(/not from Quadlet.s generator/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('a real generated FragmentPath is not mistaken for a shadow', () => {
|
|
217
|
+
expect(() =>
|
|
218
|
+
verifyGenerated(NAME, PATH, SERVICE, {
|
|
219
|
+
activeState: 'active',
|
|
220
|
+
fragmentPath: `/run/systemd/generator/${SERVICE}`,
|
|
221
|
+
known: true,
|
|
222
|
+
loadState: 'loaded',
|
|
223
|
+
needDaemonReload: false,
|
|
224
|
+
sourcePath: PATH,
|
|
225
|
+
}),
|
|
226
|
+
).not.toThrow();
|
|
227
|
+
});
|
|
195
228
|
});
|