@homeflare/alchemy 0.7.0 → 0.10.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 +45 -45
- 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 +270 -44
- package/dist/caddy/index.js.map +15 -11
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +51 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +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 +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.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 +642 -49
- package/dist/launchd/index.js.map +24 -14
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- 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/job.d.ts +2 -1
- package/dist/launchd/job.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 +14 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- 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 +83 -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-said.d.ts +10 -0
- package/dist/launchd/sudo-said.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-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- 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-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- 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 +52 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +709 -359
- package/dist/openbao/index.js.map +32 -25
- 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 +12 -5
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.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/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.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/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +147 -0
- package/docs/launchd.md +14 -7
- package/docs/ownership.md +113 -0
- package/docs/peers.md +39 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- 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 -10
- 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/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +1 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +41 -15
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +21 -3
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/job.ts +13 -4
- 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 +14 -5
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +230 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-modes.test.ts +135 -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 +216 -0
- package/src/launchd/sudo-said.ts +58 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +11 -2
- package/src/openbao/REPLACE.md +67 -37
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- 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 +48 -17
- package/src/openbao/cloudflare-role.ts +23 -18
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +188 -0
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +35 -11
- package/src/openbao/jwt-role.ts +33 -14
- package/src/openbao/kubernetes-role.ts +32 -14
- package/src/openbao/mfa-enforcement.ts +42 -13
- package/src/openbao/mfa-totp.ts +42 -14
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +33 -10
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +57 -20
- package/src/openbao/policy.ts +24 -11
- package/src/openbao/proxmox-role.ts +20 -20
- package/src/openbao/rename-adoption.test.ts +95 -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/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +29 -57
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
package/src/caddy/config.ts
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* - create / update — `POST /adapt` (validate), `POST /load` with `text/caddyfile` (a graceful
|
|
6
6
|
* reload; Caddy keeps the old config if it refuses the new one, and the error names why), then
|
|
7
7
|
* `GET /config/` and insist it matches. Skipped when Caddy already runs this config.
|
|
8
|
-
* - read — the digest of `GET /config/`.
|
|
9
|
-
* config becomes the starting point, and the deploy's forced update loads the declared one.
|
|
8
|
+
* - read — the digest of `GET /config/`. With no state it is the adoption probe (below).
|
|
10
9
|
* - diff — declared, live and stored digests (config-lifecycle.ts). Never `replace`.
|
|
11
10
|
* - delete — ⛔ NOTHING. See below.
|
|
12
11
|
*
|
|
@@ -15,17 +14,24 @@
|
|
|
15
14
|
* servers. `retain` is the default (the engine never calls delete); with `destroy` the handler
|
|
16
15
|
* still only forgets. It never calls `/stop` (which exits the process) or `DELETE /config/`.
|
|
17
16
|
* To empty a Caddy, do it by hand at its admin API.
|
|
17
|
+
* ⛔ NOTHING IS ADOPTED SILENTLY (decision, 2026-09-21 — the house rule HostFile and LaunchdJob
|
|
18
|
+
* keep). With no state, a running Caddy whose live config IS the declared one reads as ours (the
|
|
19
|
+
* adoption changes nothing); any other config reads as `Unowned`, and the plan refuses it unless
|
|
20
|
+
* the deploy runs with `--adopt`. A Caddy serving nothing reads as absent: a create. Where the
|
|
21
|
+
* engine skips that probe, reconcile refuses the same takeover (config-lifecycle.ts).
|
|
18
22
|
* ⛔ `caddyfile` IS NEVER A SECRET — see config-form.ts; refused before anything is sent.
|
|
19
23
|
* ⚠️ WHICH CADDY is the transport's (providers.ts), not a prop: one CaddyAdmin per stack.
|
|
20
24
|
*/
|
|
21
25
|
import { Resource } from 'alchemy';
|
|
26
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
22
27
|
import { isResolved } from 'alchemy/Diff';
|
|
23
28
|
import * as Provider from 'alchemy/Provider';
|
|
24
29
|
import * as Effect from 'effect/Effect';
|
|
25
30
|
import { lift, resolvedString } from '../launchd/host-effect.ts';
|
|
31
|
+
import { adoptEnabled } from '../ownership/adopt.ts';
|
|
26
32
|
import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
|
|
27
33
|
import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
28
|
-
import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
|
|
34
|
+
import { diffConfig, probeLive, readLive, reconcileConfig } from './config-lifecycle.ts';
|
|
29
35
|
|
|
30
36
|
export type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
31
37
|
|
|
@@ -64,17 +70,40 @@ export const CaddyConfigProvider = () =>
|
|
|
64
70
|
CaddyConfig,
|
|
65
71
|
Effect.gen(function* () {
|
|
66
72
|
const admin = yield* CaddyAdminService;
|
|
73
|
+
/** The probe, branded for the engine: plain when ours, `Unowned` otherwise. */
|
|
74
|
+
const probe = (caddyfile: string | undefined, sourceFile: string | undefined) =>
|
|
75
|
+
Effect.flatMap(
|
|
76
|
+
lift(() => probeLive(admin, caddyfile, sourceFile)),
|
|
77
|
+
(found) => {
|
|
78
|
+
if (found === undefined) return Effect.succeed(undefined);
|
|
79
|
+
if (found.ours) return Effect.succeed(found.attributes);
|
|
80
|
+
const unowned = Unowned(found.attributes);
|
|
81
|
+
return found.unchecked === undefined
|
|
82
|
+
? Effect.succeed(unowned)
|
|
83
|
+
: Effect.as(
|
|
84
|
+
Effect.logWarning(
|
|
85
|
+
`Caddy.Config at ${admin.endpoint}: the running config could not be compared ` +
|
|
86
|
+
`with the declared Caddyfile (${found.unchecked}), so it is not treated as ours`,
|
|
87
|
+
),
|
|
88
|
+
unowned,
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
);
|
|
67
92
|
return CaddyConfig.Provider.of({
|
|
68
93
|
/** ⛔ A Caddy has one config; there is nothing to enumerate, and nothing for nuke to delete. */
|
|
69
94
|
list: () => Effect.succeed([]),
|
|
70
95
|
|
|
71
96
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
97
|
+
* With state: the live attributes, which diff compares. With none, the ADOPTION PROBE —
|
|
98
|
+
* `olds` is the declaration (Plan.ts passes `news`), and only a live config equal to it is
|
|
99
|
+
* ours. ⚠️ A running Caddy is still the one the stack means (the transport reaches only
|
|
100
|
+
* loopback), but WHOSE CONFIG it runs is not: a person, another tool or another stack.
|
|
75
101
|
*/
|
|
76
|
-
read: ({ olds }) =>
|
|
77
|
-
|
|
102
|
+
read: ({ olds, output }) =>
|
|
103
|
+
(output === undefined
|
|
104
|
+
? probe(resolvedString(olds, 'caddyfile'), resolvedString(olds, 'sourceFile'))
|
|
105
|
+
: lift(() => readLive(admin, resolvedString(olds, 'sourceFile')))
|
|
106
|
+
).pipe(
|
|
78
107
|
// ★ Nothing to adopt from a Caddy that is down: plan a create, which is the same load.
|
|
79
108
|
Effect.catchIf(unreachable, planWithoutCaddy(admin, undefined)),
|
|
80
109
|
),
|
|
@@ -101,9 +130,18 @@ export const CaddyConfigProvider = () =>
|
|
|
101
130
|
}).pipe(Effect.catchIf(unreachable, update));
|
|
102
131
|
},
|
|
103
132
|
|
|
104
|
-
reconcile: ({ news }) =>
|
|
133
|
+
reconcile: ({ fqn, news, output }) =>
|
|
105
134
|
Effect.gen(function* () {
|
|
106
|
-
|
|
135
|
+
// ⛔ State is authority over the Caddy it was applied to, and no other. With none (the
|
|
136
|
+
// engine may not have probed: see reconcileConfig), or with the transport now at
|
|
137
|
+
// another endpoint, only adoption — `--adopt`, or this resource's own `adopt(…)`,
|
|
138
|
+
// as the planner resolves it — loads over a config the stack cannot claim.
|
|
139
|
+
const sameCaddy = output !== undefined && output.endpoint === admin.endpoint;
|
|
140
|
+
const takeOver = sameCaddy || (yield* adoptEnabled(fqn));
|
|
141
|
+
const stored = output === undefined ? {} : { stored: output.configSha256 };
|
|
142
|
+
const applied = yield* lift(() =>
|
|
143
|
+
reconcileConfig(admin, news, { takeOver, ...stored }),
|
|
144
|
+
);
|
|
107
145
|
for (const warning of applied.warnings) {
|
|
108
146
|
yield* Effect.logWarning(`Caddy.Config at ${admin.endpoint}: ${warning}`);
|
|
109
147
|
}
|
package/src/caddy/fake-caddy.ts
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* ★ `listenPort` is the port Caddy BELIEVES it listens on — its Host check uses it — while the
|
|
18
18
|
* fake really listens on an ephemeral one: a Caddy on its default :2019 behind a forward.
|
|
19
19
|
*/
|
|
20
|
+
import type { CaddyAdmin } from './admin.ts';
|
|
21
|
+
import { localCaddyAdmin } from './local-admin.ts';
|
|
22
|
+
|
|
20
23
|
export type Seen = {
|
|
21
24
|
readonly method: string;
|
|
22
25
|
readonly path: string;
|
|
@@ -158,3 +161,19 @@ export const fakeCaddy = (
|
|
|
158
161
|
stop: () => void server.stop(true),
|
|
159
162
|
};
|
|
160
163
|
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A fake Caddy on its DEFAULT :2019 behind a forward (`hostHeader`), and the transport to it.
|
|
167
|
+
* ★ Addressed that way so a Caddyfile with no `admin` line — the common case — passes the guard.
|
|
168
|
+
* A Caddy reached on any other port needs `admin <address>` declared, or the first load would
|
|
169
|
+
* move it (admin-guard.ts).
|
|
170
|
+
*/
|
|
171
|
+
export const fakeDefaultCaddy = (running?: unknown): { admin: CaddyAdmin; caddy: FakeCaddy } => {
|
|
172
|
+
const caddy = fakeCaddy({ listenPort: 2019, ...(running === undefined ? {} : { running }) });
|
|
173
|
+
const admin = localCaddyAdmin({
|
|
174
|
+
address: caddy.address,
|
|
175
|
+
hostHeader: '127.0.0.1:2019',
|
|
176
|
+
retries: 0,
|
|
177
|
+
});
|
|
178
|
+
return { admin, caddy };
|
|
179
|
+
};
|
package/src/caddy/index.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* the digest, the guards and the admin calls are internals; an `export *` would publish them.
|
|
7
7
|
* ⛔ THE ADMIN API STAYS ON LOOPBACK OR A UNIX SOCKET. It has no authentication; localCaddyAdmin()
|
|
8
8
|
* refuses any other address, and a Caddyfile that would move or expose it is refused before load.
|
|
9
|
-
*
|
|
9
|
+
* ⛔ NOTHING IS ADOPTED SILENTLY: a running config that is not the declared one needs `--adopt`.
|
|
10
|
+
* Guide, order of file and load, adoption, `--resume` and secrets: docs/caddy.md.
|
|
10
11
|
*/
|
|
11
12
|
export type {
|
|
12
13
|
CaddyAdmin,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* way the engine calls them; caddyWithFile's registration; and the barrel, the public API.
|
|
4
4
|
*/
|
|
5
5
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
6
|
-
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
7
6
|
import * as Output from 'alchemy/Output';
|
|
8
7
|
import { Stack } from 'alchemy/Stack';
|
|
9
8
|
import * as Effect from 'effect/Effect';
|
|
@@ -52,37 +51,23 @@ const withProvider = <A>(
|
|
|
52
51
|
};
|
|
53
52
|
|
|
54
53
|
describe('CaddyConfigProvider', () => {
|
|
55
|
-
test('
|
|
56
|
-
const probe = await withProvider((provider) =>
|
|
57
|
-
handler('read', provider.read)({ ...ids, olds: { caddyfile: SITE }, output: undefined }),
|
|
58
|
-
);
|
|
59
|
-
expect(Unowned.is(probe)).toBe(false);
|
|
60
|
-
expect(probe).toMatchObject({ endpoint: fake?.address });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('reconcile applies; delete then sends nothing at all', async () => {
|
|
54
|
+
test('reconcile applies over its own state; delete then sends nothing at all', async () => {
|
|
64
55
|
await withProvider((provider) =>
|
|
65
56
|
Effect.gen(function* () {
|
|
66
|
-
yield* provider.reconcile({
|
|
57
|
+
const output = yield* provider.reconcile({
|
|
67
58
|
...ids,
|
|
68
59
|
bindings: [] as never,
|
|
69
60
|
news: { caddyfile: SITE },
|
|
70
|
-
olds:
|
|
71
|
-
|
|
61
|
+
olds: { caddyfile: SITE },
|
|
62
|
+
// ★ State from an earlier deploy: the running `legacy` config is this stack's to replace.
|
|
63
|
+
output: { configSha256: 'earlier', endpoint: fake?.address ?? '' },
|
|
72
64
|
session: undefined as never,
|
|
73
65
|
});
|
|
66
|
+
expect(fake?.seen.some((call) => call.path === '/load')).toBe(true);
|
|
74
67
|
const before = fake?.seen.length;
|
|
75
|
-
const output = yield* handler(
|
|
76
|
-
'read',
|
|
77
|
-
provider.read,
|
|
78
|
-
)({
|
|
79
|
-
...ids,
|
|
80
|
-
olds: { caddyfile: SITE },
|
|
81
|
-
output: undefined,
|
|
82
|
-
});
|
|
83
68
|
yield* provider.delete({ ...ids, olds: { caddyfile: SITE }, output } as never);
|
|
84
|
-
// ★
|
|
85
|
-
expect(fake?.seen.length).toBe(
|
|
69
|
+
// ★ Delete never unloads, never stops: not one more call reached Caddy.
|
|
70
|
+
expect(fake?.seen.length).toBe(before);
|
|
86
71
|
}),
|
|
87
72
|
);
|
|
88
73
|
});
|
package/src/caddy/with-file.ts
CHANGED
|
@@ -14,9 +14,13 @@
|
|
|
14
14
|
* still an Output, Alchemy skips the adoption probe, and the check runs at apply instead.)
|
|
15
15
|
* · A Caddyfile that adapts but that Caddy then REFUSES (a port in use, a missing cert file) is
|
|
16
16
|
* on disk when `/load` fails. Caddy keeps serving the old config, the deploy fails with Caddy's
|
|
17
|
-
* reason — and a
|
|
18
|
-
*
|
|
19
|
-
* ACCEPTED, and the file is only the
|
|
17
|
+
* reason — and a Caddy started without `--resume` would load the refused file on its next
|
|
18
|
+
* restart. Managed Caddies run with `--resume` (decision, 2026-09-21; docs/caddy.md): they
|
|
19
|
+
* restart from `autosave.json`, the last config Caddy ACCEPTED, and the file is only the
|
|
20
|
+
* first-boot fallback.
|
|
21
|
+
* · The skipped probe is also why the ADOPTION check of a first deploy happens at apply: a Caddy
|
|
22
|
+
* running a config this stack did not load is refused before `/load` unless the deploy runs
|
|
23
|
+
* with `--adopt` (config-lifecycle.ts reconcileConfig) — after this file is written.
|
|
20
24
|
* ⛔ SECRETS ARE REFUSED HERE, AT DECLARATION, before the HostFile exists: its `content` is a prop
|
|
21
25
|
* too, and would carry a literal into state even if the CaddyConfig then refused it.
|
|
22
26
|
* ★ BOTH RETAIN. Removing this from a stack must not delete the file a restart needs.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--adopt` and `adopt(…)` at APPLY, for HostFile and LaunchdJob — the create Alchemy never probed
|
|
3
|
+
* (a prop still an Output at plan time). Before 2026-09-21 these reconciles refused a foreign file
|
|
4
|
+
* or job whatever the deploy said, so the flag the refusal told you to use could not work there.
|
|
5
|
+
* They now resolve adoption the way the planner does (ownership/adopt.ts adoptEnabled), BOTH ways —
|
|
6
|
+
* for a CREATE only: the row Apply committed for this instance is `creating`. ⛔ A replace's new
|
|
7
|
+
* generation (a `replacing` row, `output` undefined all the same) is never adoptable at apply.
|
|
8
|
+
* ★ Registered through the REAL `Resource` under a Stack, as caddy/adopt-scope.test.ts is, over a
|
|
9
|
+
* state store holding the row Apply commits before it calls reconcile.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import { Artifacts, makeScopedArtifacts } from 'alchemy/Artifacts';
|
|
14
|
+
import { InMemoryService } from 'alchemy/State/InMemoryState';
|
|
15
|
+
import type { ResourceState } from 'alchemy/State/ResourceState';
|
|
16
|
+
import { State } from 'alchemy/State/State';
|
|
17
|
+
import { Stack } from 'alchemy/Stack';
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import * as Layer from 'effect/Layer';
|
|
20
|
+
import { noteUnfinished } from '../ownership/resume.ts';
|
|
21
|
+
import { fakeRunner } from './fake-runner.ts';
|
|
22
|
+
import { HostFile, HostFileProvider } from './host-file.ts';
|
|
23
|
+
import { LaunchdJob, LaunchdJobProvider } from './job.ts';
|
|
24
|
+
import type { LaunchdJobAttributes } from './job-form.ts';
|
|
25
|
+
import { hostRunnerLayer } from './runner.ts';
|
|
26
|
+
|
|
27
|
+
type Scope = 'adopt(true)' | 'adopt(false)' | 'none';
|
|
28
|
+
|
|
29
|
+
const FOREIGN = new TextEncoder().encode('someone else wrote this\n');
|
|
30
|
+
const PATH = '/etc/example/a.conf';
|
|
31
|
+
const PLIST = '/Library/LaunchDaemons/com.example.job.plist';
|
|
32
|
+
const JOB = { domain: 'system', label: 'com.example.job', programArguments: ['/bin/job'] } as const;
|
|
33
|
+
|
|
34
|
+
const host = () => {
|
|
35
|
+
const fake = fakeRunner({ dirs: { '/Library/LaunchDaemons': 0, '/etc/example': 0 }, euid: 0 });
|
|
36
|
+
for (const path of [PATH, PLIST]) {
|
|
37
|
+
fake.files.set(path, { bytes: FOREIGN, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
38
|
+
}
|
|
39
|
+
return fake;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const scoped = <A extends Effect.Effect<unknown, unknown, unknown>>(declared: A, scope: Scope) =>
|
|
43
|
+
scope === 'none' ? declared : (declared.pipe(adopt(scope === 'adopt(true)')) as A);
|
|
44
|
+
|
|
45
|
+
/** The row Apply commits before reconcile: a create's, or a replace's new generation's. */
|
|
46
|
+
type Generation = 'creating' | 'replacing';
|
|
47
|
+
|
|
48
|
+
/** Register `family` with `scope` under a deploy whose `--adopt` is `flag`; reconcile it statelessly. */
|
|
49
|
+
const firstApply = (
|
|
50
|
+
family: 'file' | 'job',
|
|
51
|
+
scope: Scope,
|
|
52
|
+
flag: boolean,
|
|
53
|
+
output?: unknown,
|
|
54
|
+
generation: Generation = 'creating',
|
|
55
|
+
/** The plan's diff saw this generation's row with no attributes (ownership/resume.ts). */
|
|
56
|
+
unfinished = false,
|
|
57
|
+
) => {
|
|
58
|
+
const fake = host();
|
|
59
|
+
const fqn = family === 'file' ? 'conf' : 'job';
|
|
60
|
+
const row = { fqn, instanceId: 'i-1', status: generation } as unknown as ResourceState;
|
|
61
|
+
const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
|
|
62
|
+
const call = {
|
|
63
|
+
bindings: [] as never,
|
|
64
|
+
instanceId: 'i-1',
|
|
65
|
+
olds: undefined,
|
|
66
|
+
session: undefined as never,
|
|
67
|
+
};
|
|
68
|
+
const program: Effect.Effect<unknown, unknown, AdoptPolicy | Stack> =
|
|
69
|
+
family === 'file'
|
|
70
|
+
? Effect.gen(function* () {
|
|
71
|
+
yield* scoped(HostFile('conf', { content: 'x', path: PATH }), scope);
|
|
72
|
+
const provider = yield* HostFile.Provider;
|
|
73
|
+
const news = { content: 'x', path: PATH };
|
|
74
|
+
return yield* provider.reconcile({
|
|
75
|
+
...call,
|
|
76
|
+
fqn: 'conf',
|
|
77
|
+
id: 'conf',
|
|
78
|
+
news,
|
|
79
|
+
output: undefined,
|
|
80
|
+
});
|
|
81
|
+
}).pipe(
|
|
82
|
+
Effect.provide(HostFileProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner)))),
|
|
83
|
+
)
|
|
84
|
+
: Effect.gen(function* () {
|
|
85
|
+
yield* scoped(LaunchdJob('job', JOB), scope);
|
|
86
|
+
const provider = yield* LaunchdJob.Provider;
|
|
87
|
+
const prior = output as LaunchdJobAttributes | undefined;
|
|
88
|
+
return yield* provider.reconcile({
|
|
89
|
+
...call,
|
|
90
|
+
fqn: 'job',
|
|
91
|
+
id: 'job',
|
|
92
|
+
news: JOB,
|
|
93
|
+
output: prior,
|
|
94
|
+
});
|
|
95
|
+
}).pipe(
|
|
96
|
+
Effect.provide(LaunchdJobProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner)))),
|
|
97
|
+
);
|
|
98
|
+
const bag = makeScopedArtifacts(new Map(), fqn);
|
|
99
|
+
const noted = unfinished ? noteUnfinished('i-1') : Effect.void;
|
|
100
|
+
const applying = Effect.runPromise(
|
|
101
|
+
Effect.andThen(noted, program).pipe(
|
|
102
|
+
Effect.provideService(Artifacts, bag),
|
|
103
|
+
Effect.provideService(AdoptPolicy, flag),
|
|
104
|
+
Effect.provideService(State, InMemoryService({ test: { test: { [fqn]: row } } })),
|
|
105
|
+
Effect.provideService(Stack, spec as never),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
return { applying, fake };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const REFUSED = /already exists and is not this resource|is already on this host/;
|
|
112
|
+
|
|
113
|
+
describe.each(['file', 'job'] as const)('%s: a first apply over a foreign object', (family) => {
|
|
114
|
+
test.each([
|
|
115
|
+
['adopt(false) under --adopt', 'adopt(false)', true, false],
|
|
116
|
+
['adopt(true) without --adopt', 'adopt(true)', false, true],
|
|
117
|
+
['no scope under --adopt', 'none', true, true],
|
|
118
|
+
['no scope without --adopt', 'none', false, false],
|
|
119
|
+
] as const)('with %s', async (_, scope, flag, takesOver) => {
|
|
120
|
+
const { applying, fake } = firstApply(family, scope, flag);
|
|
121
|
+
const path = family === 'file' ? PATH : PLIST;
|
|
122
|
+
if (takesOver) {
|
|
123
|
+
await applying;
|
|
124
|
+
expect(fake.files.get(path)?.bytes).not.toEqual(FOREIGN);
|
|
125
|
+
} else {
|
|
126
|
+
await expect(applying).rejects.toThrow(REFUSED);
|
|
127
|
+
expect(fake.files.get(path)?.bytes).toEqual(FOREIGN);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a LaunchdJob rename onto a foreign job stays refused, --adopt or not', async () => {
|
|
133
|
+
const old = {
|
|
134
|
+
domain: 'system',
|
|
135
|
+
label: 'com.example.old',
|
|
136
|
+
loaded: false,
|
|
137
|
+
plistPath: '/Library/LaunchDaemons/com.example.old.plist',
|
|
138
|
+
plistSha256: '',
|
|
139
|
+
serviceTarget: 'system/com.example.old',
|
|
140
|
+
};
|
|
141
|
+
const { applying, fake } = firstApply('job', 'adopt(true)', true, old);
|
|
142
|
+
await expect(applying).rejects.toThrow(REFUSED);
|
|
143
|
+
expect(fake.files.get(PLIST)?.bytes).toEqual(FOREIGN);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe.each(['file', 'job'] as const)('%s: a replace onto a foreign object', (family) => {
|
|
147
|
+
test.each([
|
|
148
|
+
['--adopt', 'none', true],
|
|
149
|
+
['adopt(true)', 'adopt(true)', false],
|
|
150
|
+
] as const)(
|
|
151
|
+
'stays refused under %s: the plan never offered its new generation adoption',
|
|
152
|
+
async (_, scope, flag) => {
|
|
153
|
+
const { applying, fake } = firstApply(family, scope, flag, undefined, 'replacing');
|
|
154
|
+
await expect(applying).rejects.toThrow(REFUSED);
|
|
155
|
+
expect(fake.files.get(family === 'file' ? PATH : PLIST)?.bytes).toEqual(FOREIGN);
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
test('an UNFINISHED replace (its row seen by the plan) resumes under --adopt, as a create does', async () => {
|
|
160
|
+
const refused = firstApply(family, 'none', false, undefined, 'replacing', true);
|
|
161
|
+
await expect(refused.applying).rejects.toThrow(REFUSED);
|
|
162
|
+
const { applying, fake } = firstApply(family, 'none', true, undefined, 'replacing', true);
|
|
163
|
+
await applying;
|
|
164
|
+
expect(fake.files.get(family === 'file' ? PATH : PLIST)?.bytes).not.toEqual(FOREIGN);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* job exits non-zero; a job can linger after bootout (`lingerPrints`); a gui uid with no login
|
|
10
10
|
* session answers 112 (`loggedOut`); writes into a root-owned directory, or a chown to another
|
|
11
11
|
* uid, need root.
|
|
12
|
+
* ★ Every directory ABOVE a declared one exists, root's and `0755`, as `/`, `/Library` and `/opt`
|
|
13
|
+
* are on a Mac; `acls` holds the `ls -lden` entry lines a directory prints (sudo-acl.ts).
|
|
12
14
|
*/
|
|
13
15
|
import { LAUNCHCTL, NOT_FOUND, NO_DOMAIN } from './launchctl.ts';
|
|
14
16
|
import type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
|
|
@@ -54,6 +56,16 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
54
56
|
const root = privileged || euid === 0;
|
|
55
57
|
const dirs = new Map(Object.entries(options.dirs ?? {}));
|
|
56
58
|
const files = new Map<string, Entry>();
|
|
59
|
+
/** Directory → the ACL entry lines `ls -lden` prints under it. */
|
|
60
|
+
const acls = new Map<string, string[]>();
|
|
61
|
+
/** The directories above the declared ones, as the host starts: root's, `0755`. */
|
|
62
|
+
const above = new Set<string>(['/']);
|
|
63
|
+
for (const dir of dirs.keys()) {
|
|
64
|
+
const parts = dir.split('/').slice(1, -1);
|
|
65
|
+
for (const index of parts.keys()) above.add(`/${parts.slice(0, index + 1).join('/')}`);
|
|
66
|
+
}
|
|
67
|
+
for (const dir of dirs.keys()) above.delete(dir);
|
|
68
|
+
const implied = (path: string) => above.has(path);
|
|
57
69
|
const calls: string[][] = [];
|
|
58
70
|
/** target → pid (undefined = loaded, not running). */
|
|
59
71
|
const loaded = new Map<string, number | undefined>();
|
|
@@ -128,6 +140,16 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
128
140
|
exec: async (argv) => {
|
|
129
141
|
calls.push([...argv]);
|
|
130
142
|
if (argv[0] === LAUNCHCTL) return launchctl(argv.slice(1));
|
|
143
|
+
if (argv[0] === '/bin/ls' && argv[1] === '-lden' && argv[2] === '--') {
|
|
144
|
+
const paths = argv.slice(3);
|
|
145
|
+
if (paths.some((path) => !dirs.has(path) && !files.has(path) && !implied(path)))
|
|
146
|
+
return fail(1, 'ls: No such file or directory');
|
|
147
|
+
const lines = paths.flatMap((path) => [
|
|
148
|
+
`drwxr-xr-x 2 0 0 64 Sep 21 10:00 ${path}`,
|
|
149
|
+
...(acls.get(path) ?? []),
|
|
150
|
+
]);
|
|
151
|
+
return ok(lines.join('\n'));
|
|
152
|
+
}
|
|
131
153
|
return fail(127, `fake: no program ${String(argv[0])}`);
|
|
132
154
|
},
|
|
133
155
|
lookupGroup: async (nameOrId) => options.groups?.[nameOrId],
|
|
@@ -153,9 +175,9 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
153
175
|
size: entry.bytes.length,
|
|
154
176
|
uid: entry.uid,
|
|
155
177
|
};
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
if (dirs.has(path) || implied(path))
|
|
179
|
+
return { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 };
|
|
180
|
+
return undefined;
|
|
159
181
|
},
|
|
160
182
|
writeFileAtomic: async (path: string, bytes: Uint8Array, write: WriteOptions) => {
|
|
161
183
|
calls.push(['write', path]);
|
|
@@ -172,5 +194,5 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
172
194
|
});
|
|
173
195
|
},
|
|
174
196
|
};
|
|
175
|
-
return { calls, disabled, dirs, files, loaded, runner, state };
|
|
197
|
+
return { acls, calls, disabled, dirs, files, loaded, runner, state };
|
|
176
198
|
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sudo runner over fake-runner.ts, for the tests beside it: the deploying user is uid 501 and
|
|
3
|
+
* unprivileged, and `/usr/bin/sudo` is a tiny interpreter that runs the three allowlisted programs
|
|
4
|
+
* against the same in-memory host, as root.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ TEST-ONLY, like fake-runner.ts: no provider imports it, it is not on the barrel, and it never
|
|
7
|
+
* runs a real program. No test anywhere runs a real sudo.
|
|
8
|
+
* ★ THE INTERPRETER READS ONLY WHAT THE ARGV SAYS — mode, owner, group, source, destination — so a
|
|
9
|
+
* runner that dropped `-o` or wrote the wrong path shows up as the wrong file, not a passing test.
|
|
10
|
+
* ★ THE BASE STAYS UNPRIVILEGED, so a write under /Library/LaunchDaemons that skipped sudo fails
|
|
11
|
+
* with the fake's EACCES instead of quietly succeeding.
|
|
12
|
+
* ★ SUDO REFUSES A CALL THAT WAS NOT LOGGED FIRST, so every test holds the runner to "logged
|
|
13
|
+
* before it runs", not merely "logged at some point".
|
|
14
|
+
*/
|
|
15
|
+
import { fakeRunner } from './fake-runner.ts';
|
|
16
|
+
import { LAUNCHCTL } from './launchctl.ts';
|
|
17
|
+
import type { ExecResult, HostRunner } from './runner.ts';
|
|
18
|
+
import { INSTALL, RM, SUDO } from './sudo-allowlist.ts';
|
|
19
|
+
import { makeSudoRunner } from './sudo-runner.ts';
|
|
20
|
+
|
|
21
|
+
export const OPERATOR = 501;
|
|
22
|
+
export const PREFIXES = ['/Library/LaunchDaemons', '/opt/example'];
|
|
23
|
+
|
|
24
|
+
const ok: ExecResult = { exitCode: 0, stderr: '', stdout: '' };
|
|
25
|
+
|
|
26
|
+
/** What `sudo -n` prints (exit 1) when it will not run the command. Reasoned; see sudo-runner.ts. */
|
|
27
|
+
export const SUDO_SAYS = {
|
|
28
|
+
denied:
|
|
29
|
+
"Sorry, user someone is not allowed to execute '/bin/launchctl bootout system/x' as root on example.\n",
|
|
30
|
+
'not-in-sudoers': 'someone is not in the sudoers file.\n',
|
|
31
|
+
'not-on-host': 'someone is not allowed to run sudo on example.\n',
|
|
32
|
+
password: 'sudo: a password is required\n',
|
|
33
|
+
} as const;
|
|
34
|
+
const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
|
|
35
|
+
|
|
36
|
+
/** Parse `install -S -m MODE [-o UID] [-g GID] SRC DEST` the way install(1) would. */
|
|
37
|
+
const parseInstall = (args: readonly string[]) => {
|
|
38
|
+
const mode = Number.parseInt(args[2] ?? '', 8);
|
|
39
|
+
const rest = args.slice(3);
|
|
40
|
+
const flag = (name: string) => {
|
|
41
|
+
const at = rest.indexOf(name);
|
|
42
|
+
return at === -1 ? undefined : Number(rest[at + 1]);
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
dest: rest[rest.length - 1] ?? '',
|
|
46
|
+
gid: flag('-g'),
|
|
47
|
+
mode,
|
|
48
|
+
source: rest[rest.length - 2] ?? '',
|
|
49
|
+
uid: flag('-o'),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** `groups` is the operator's `id -G`; `'unknown'` models that lookup failing. */
|
|
54
|
+
export const fakeSudoHost = (
|
|
55
|
+
groups: readonly number[] | 'unknown' = [20, 80],
|
|
56
|
+
prefixes: readonly string[] = PREFIXES,
|
|
57
|
+
) => {
|
|
58
|
+
const fake = fakeRunner({
|
|
59
|
+
dirs: {
|
|
60
|
+
'/Library/LaunchDaemons': 0,
|
|
61
|
+
'/opt/example': 0,
|
|
62
|
+
'/opt/example/app': 0,
|
|
63
|
+
'/Users/someone/Library/LaunchAgents': OPERATOR,
|
|
64
|
+
'/Users/other/Library/LaunchAgents': 502,
|
|
65
|
+
// Root-owned, and deliberately NOT a prefix: the forgotten-prefix case.
|
|
66
|
+
'/etc/example': 0,
|
|
67
|
+
},
|
|
68
|
+
euid: OPERATOR,
|
|
69
|
+
users: {
|
|
70
|
+
other: { gid: 20, home: '/Users/other', uid: 502 },
|
|
71
|
+
someone: { gid: 20, home: '/Users/someone', uid: OPERATOR },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const staged = new Map<string, Uint8Array>();
|
|
75
|
+
const logs: string[] = [];
|
|
76
|
+
const sudoCalls: string[][] = [];
|
|
77
|
+
const state = {
|
|
78
|
+
/** What sudo itself answers: runs the command, or refuses it with one of SUDO_SAYS. */
|
|
79
|
+
sudo: 'ok' as 'ok' | keyof typeof SUDO_SAYS,
|
|
80
|
+
/** Returned by `install` in place of success; nothing is written. */
|
|
81
|
+
installFailure: undefined as ExecResult | undefined,
|
|
82
|
+
stagedCount: 0,
|
|
83
|
+
/** The staged bytes as `install` saw them, by destination. */
|
|
84
|
+
installedFrom: new Map<string, string>(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const asRoot = async (argv: readonly string[]): Promise<ExecResult> => {
|
|
88
|
+
const [program, ...args] = argv;
|
|
89
|
+
if (program === LAUNCHCTL) return fake.runner.exec(argv);
|
|
90
|
+
if (program === INSTALL) {
|
|
91
|
+
if (state.installFailure !== undefined) return state.installFailure;
|
|
92
|
+
const { dest, gid, mode, source, uid } = parseInstall(args);
|
|
93
|
+
const bytes = staged.get(source);
|
|
94
|
+
if (bytes === undefined) return fail(71, `install: ${source}: No such file or directory`);
|
|
95
|
+
state.installedFrom.set(dest, new TextDecoder().decode(bytes));
|
|
96
|
+
// ★ No -g: the new file takes its directory's group, as a BSD file create does.
|
|
97
|
+
const dirGid = (await fake.runner.stat(dest.slice(0, dest.lastIndexOf('/'))))?.gid ?? 0;
|
|
98
|
+
fake.files.set(dest, { bytes, gid: gid ?? dirGid, kind: 'file', mode, uid: uid ?? 0 });
|
|
99
|
+
return ok;
|
|
100
|
+
}
|
|
101
|
+
if (program === RM) {
|
|
102
|
+
fake.files.delete(args[2] ?? '');
|
|
103
|
+
return ok;
|
|
104
|
+
}
|
|
105
|
+
return fail(1, `sudo: ${String(program)}: command not found`);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const base: HostRunner = {
|
|
109
|
+
...fake.runner,
|
|
110
|
+
exec: async (argv) => {
|
|
111
|
+
if (argv[0] !== SUDO) return fake.runner.exec(argv);
|
|
112
|
+
sudoCalls.push([...argv]);
|
|
113
|
+
if (logs[logs.length - 1] !== `homeflare/launchd sudo -n ${JSON.stringify(argv.slice(3))}`) {
|
|
114
|
+
throw new Error(`fake sudo: ${JSON.stringify(argv)} was not logged before it ran`);
|
|
115
|
+
}
|
|
116
|
+
if (state.sudo !== 'ok') return fail(1, SUDO_SAYS[state.sudo]);
|
|
117
|
+
// argv is [sudo, -n, --, ...command]
|
|
118
|
+
return asRoot(argv.slice(3));
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const runner = makeSudoRunner(prefixes, {
|
|
123
|
+
base,
|
|
124
|
+
groups: async () => (groups === 'unknown' ? undefined : groups),
|
|
125
|
+
log: (line) => logs.push(line),
|
|
126
|
+
stage: async (bytes) => {
|
|
127
|
+
state.stagedCount += 1;
|
|
128
|
+
const path = `/private/tmp/hf-sudo-${String(state.stagedCount)}/staged`;
|
|
129
|
+
staged.set(path, bytes);
|
|
130
|
+
return { dispose: async () => void staged.delete(path), path };
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
/** The privileged commands run, without the `sudo -n --` in front. */
|
|
134
|
+
const privileged = () => sudoCalls.map((call) => call.slice(3));
|
|
135
|
+
return { fake, logs, privileged, runner, staged, state, sudoCalls };
|
|
136
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
|
|
3
|
-
* `news` both providers' diffs share.
|
|
3
|
+
* `news` both providers' diffs share. (The engine's adopt setting lives in ownership/adopt.ts.)
|
|
4
4
|
*
|
|
5
5
|
* ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
|
|
6
6
|
* runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
|
|
@@ -46,9 +46,12 @@ const identityProblems = (name: string, value: string | number | undefined): str
|
|
|
46
46
|
: [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* An absolute, normalised path, or the reasons it is not. ★ Shared with sudo-allowlist.ts, so a
|
|
51
|
+
* path the sudo runner may touch as root obeys exactly the rule a declared file does.
|
|
52
|
+
*/
|
|
53
|
+
export const pathProblems = (path: string): string[] => {
|
|
50
54
|
const found: string[] = [];
|
|
51
|
-
const { path } = props;
|
|
52
55
|
if (!path.startsWith('/')) found.push('path must be absolute');
|
|
53
56
|
// ★ One spelling per file: `a/./b`, `a//b` and `a/../b` would each be a second resource for the
|
|
54
57
|
// same inode, and `..` is how a derived path walks out of the directory it was meant for.
|
|
@@ -62,6 +65,11 @@ export const fileProblems = (props: HostFileProps): string[] => {
|
|
|
62
65
|
found.push('path must be normalised (no trailing slash, no empty, "." or ".." segments)');
|
|
63
66
|
}
|
|
64
67
|
if (path.includes('\x00')) found.push('path contains NUL');
|
|
68
|
+
return found;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const fileProblems = (props: HostFileProps): string[] => {
|
|
72
|
+
const found = pathProblems(props.path);
|
|
65
73
|
const mode = props.mode ?? DEFAULT_MODE;
|
|
66
74
|
if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) found.push('mode must be 0–0o7777');
|
|
67
75
|
found.push(...identityProblems('owner', props.owner));
|