@homeflare/alchemy 0.8.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 +22 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- 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 +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.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.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- 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.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- 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-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.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.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-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- 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/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- 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/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- 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/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -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 +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- 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.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- 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 +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- 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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy.",
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|
|
7
7
|
"license": "MIT",
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"url": "git+https://github.com/taslabs-net/homeflare-kit.git",
|
|
12
12
|
"directory": "packages/alchemy"
|
|
13
13
|
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"hf-adopt-verify": "./dist/verify/cli.js"
|
|
16
|
+
},
|
|
14
17
|
"files": [
|
|
15
18
|
"dist",
|
|
16
19
|
"docs",
|
|
@@ -53,6 +56,10 @@
|
|
|
53
56
|
"types": "./dist/caddy/index.d.ts",
|
|
54
57
|
"default": "./dist/caddy/index.js"
|
|
55
58
|
},
|
|
59
|
+
"./verify": {
|
|
60
|
+
"types": "./dist/verify/index.d.ts",
|
|
61
|
+
"default": "./dist/verify/index.js"
|
|
62
|
+
},
|
|
56
63
|
"./package.json": "./package.json"
|
|
57
64
|
},
|
|
58
65
|
"publishConfig": {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Caddy the stack declared `.pipe(adopt(false))`.
|
|
6
6
|
* ★ Registered through the REAL `Resource` (caddyWithFile under a Stack), not a hand-built map: the
|
|
7
7
|
* lookup reads what Alchemy records at registration, so an upgrade that stops recording `Adopt`
|
|
8
|
-
* in `Stack.resources` fails here (
|
|
8
|
+
* in `Stack.resources` fails here (ownership/adopt.ts adoptEnabled).
|
|
9
9
|
*/
|
|
10
10
|
import { afterEach, expect, test } from 'bun:test';
|
|
11
11
|
import { push } from 'alchemy';
|
|
@@ -172,7 +172,7 @@ export type Applied = {
|
|
|
172
172
|
* What this apply may load over — config.ts decides from the state and the adopt setting:
|
|
173
173
|
* · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
|
|
174
174
|
* correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
|
|
175
|
-
* resource's own `adopt(true)` —
|
|
175
|
+
* resource's own `adopt(true)` — ownership/adopt.ts adoptEnabled).
|
|
176
176
|
* · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
|
|
177
177
|
* Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
|
|
178
178
|
*/
|
package/src/caddy/config.ts
CHANGED
|
@@ -27,7 +27,8 @@ import { Unowned } from 'alchemy/AdoptPolicy';
|
|
|
27
27
|
import { isResolved } from 'alchemy/Diff';
|
|
28
28
|
import * as Provider from 'alchemy/Provider';
|
|
29
29
|
import * as Effect from 'effect/Effect';
|
|
30
|
-
import {
|
|
30
|
+
import { lift, resolvedString } from '../launchd/host-effect.ts';
|
|
31
|
+
import { adoptEnabled } from '../ownership/adopt.ts';
|
|
31
32
|
import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
|
|
32
33
|
import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
33
34
|
import { diffConfig, probeLive, readLive, reconcileConfig } from './config-lifecycle.ts';
|
|
@@ -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
|
};
|
|
@@ -1,17 +1,13 @@
|
|
|
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, …))`.
|
|
7
7
|
* ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
|
|
8
8
|
* thrown is wrapped, so the failure channel is always an Error with a readable message.
|
|
9
9
|
*/
|
|
10
|
-
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
11
|
-
import { AlchemyContext } from 'alchemy/AlchemyContext';
|
|
12
|
-
import { Stack } from 'alchemy/Stack';
|
|
13
10
|
import * as Effect from 'effect/Effect';
|
|
14
|
-
import * as Option from 'effect/Option';
|
|
15
11
|
|
|
16
12
|
export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
|
|
17
13
|
Effect.tryPromise({
|
|
@@ -30,31 +26,3 @@ export const resolvedString = (news: unknown, key: string): string | undefined =
|
|
|
30
26
|
const value = (news as Record<string, unknown>)[key];
|
|
31
27
|
return typeof value === 'string' ? value : undefined;
|
|
32
28
|
};
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Whether the resource at `fqn` may be adopted, resolved exactly as the planner resolves it (alchemy
|
|
36
|
-
* beta.79 Plan.ts: `resource.Adopt ?? shouldAdopt`): a resource-scoped `adopt(…)`, else the
|
|
37
|
-
* `AdoptPolicy` service, else `AlchemyContext.adopt`, else off.
|
|
38
|
-
* ★ FOR A CREATE THE ENGINE NEVER PROBED. Alchemy skips the adoption probe while `news` holds an
|
|
39
|
-
* Output, so an ownership check that must still honour `--adopt` can only run at apply. All three
|
|
40
|
-
* reach the apply: Alchemist runs plan AND apply under the session context, which provides the
|
|
41
|
-
* services from the CLI flag and the Stack the resources registered on (Alchemist/Session.ts,
|
|
42
|
-
* routes/stack.ts; Apply.ts reads the same Stack).
|
|
43
|
-
* ⛔ THE RESOURCE-SCOPED SETTING WINS, BOTH WAYS. The engine captures `adopt(…)` on the resource at
|
|
44
|
-
* registration (Resource.ts `Adopt`, into `Stack.resources[fqn]`) and never hands it to a
|
|
45
|
-
* provider. Reading only the deploy-wide flag let `--adopt` take over a resource declared
|
|
46
|
-
* `.pipe(adopt(false))` — which the planner refuses — and refused one declared `adopt(true)`.
|
|
47
|
-
* ⚠️ `resources` carries an `@internal` comment in Alchemy's source, but it is in the published types
|
|
48
|
-
* and the planner reads `Adopt` from it. caddy/adopt-scope.test.ts registers through the real
|
|
49
|
-
* `Resource`, so an upgrade that stops recording it there fails the suite.
|
|
50
|
-
*/
|
|
51
|
-
export const adoptEnabled = (fqn: string): Effect.Effect<boolean> =>
|
|
52
|
-
Effect.gen(function* () {
|
|
53
|
-
const stack = yield* Effect.serviceOption(Stack);
|
|
54
|
-
const scoped = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
|
|
55
|
-
if (scoped !== undefined) return scoped;
|
|
56
|
-
const policy = yield* Effect.serviceOption(AdoptPolicy);
|
|
57
|
-
if (Option.isSome(policy)) return policy.value;
|
|
58
|
-
const context = yield* Effect.serviceOption(AlchemyContext);
|
|
59
|
-
return Option.isSome(context) ? context.value.adopt : false;
|
|
60
|
-
});
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
fileProblems,
|
|
16
16
|
} from './host-file-form.ts';
|
|
17
17
|
import { sha256Hex } from './job-form.ts';
|
|
18
|
-
import { type HostRunner, canActAsRoot } from './runner.ts';
|
|
18
|
+
import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
|
|
19
19
|
|
|
20
20
|
type Desired = {
|
|
21
21
|
readonly bytes: Uint8Array;
|
|
@@ -72,29 +72,55 @@ export const readFileAttributes = async (
|
|
|
72
72
|
};
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
const writeOptionsOf = (want: Desired): WriteOptions => ({
|
|
76
|
+
mode: want.mode,
|
|
77
|
+
...(want.uid === undefined ? {} : { uid: want.uid }),
|
|
78
|
+
...(want.gid === undefined ? {} : { gid: want.gid }),
|
|
79
|
+
});
|
|
80
|
+
|
|
75
81
|
const matches = (live: HostFileAttributes, want: Desired): boolean =>
|
|
76
82
|
live.sha256 === want.sha256 &&
|
|
77
83
|
live.mode === want.mode &&
|
|
78
84
|
(want.uid === undefined || live.uid === want.uid) &&
|
|
79
85
|
(want.gid === undefined || live.gid === want.gid);
|
|
80
86
|
|
|
87
|
+
/**
|
|
88
|
+
* ★ A PLAN THAT WILL WRITE ASKS THE RUNNER FIRST (`checkWrite`, when it has one): sudoRunner's
|
|
89
|
+
* refusals — a setuid or group-writable root file, a directory under a prefix another user may
|
|
90
|
+
* change — then fail the plan, before any resource is applied, instead of halfway through it.
|
|
91
|
+
*/
|
|
81
92
|
export const diffFile = async (
|
|
82
93
|
runner: HostRunner,
|
|
83
94
|
news: HostFileProps,
|
|
84
95
|
output: HostFileAttributes,
|
|
85
96
|
): Promise<Diff> => {
|
|
86
|
-
// ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
|
|
87
|
-
if (news.path !== output.path) return { action: 'replace' };
|
|
88
97
|
const want = await desiredFile(runner, news);
|
|
89
|
-
|
|
98
|
+
const writes = async (diff: Diff): Promise<Diff> => {
|
|
99
|
+
await runner.checkWrite?.(news.path, writeOptionsOf(want));
|
|
100
|
+
return diff;
|
|
101
|
+
};
|
|
102
|
+
// ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
|
|
103
|
+
if (news.path !== output.path) return writes({ action: 'replace' });
|
|
104
|
+
if (want.sha256 !== output.sha256) return writes({ action: 'update' });
|
|
90
105
|
const live = await readFileAttributes(runner, news.path);
|
|
91
|
-
return live !== undefined && matches(live, want)
|
|
106
|
+
return live !== undefined && matches(live, want)
|
|
107
|
+
? { action: 'noop' }
|
|
108
|
+
: writes({ action: 'update' });
|
|
92
109
|
};
|
|
93
110
|
|
|
111
|
+
/**
|
|
112
|
+
* `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
|
|
113
|
+
* own (ownership/adopt.ts adoptsAtApply). It lets such a generation take over a file already at the
|
|
114
|
+
* path — the takeover the plan's probe would have allowed, had it run. ⛔ A move onto an occupied
|
|
115
|
+
* path stays refused, both ways the engine drives one: an `update` across the move (`moved`,
|
|
116
|
+
* below), and a fresh replace's new generation, which the caller never marks adoptable (its
|
|
117
|
+
* `output` is undefined too, so this function cannot tell it from a create by itself).
|
|
118
|
+
*/
|
|
94
119
|
export const reconcileFile = async (
|
|
95
120
|
runner: HostRunner,
|
|
96
121
|
props: HostFileProps,
|
|
97
122
|
output?: HostFileAttributes,
|
|
123
|
+
adopt = false,
|
|
98
124
|
): Promise<HostFileAttributes> => {
|
|
99
125
|
const want = await desiredFile(runner, props);
|
|
100
126
|
// ⛔ NO SILENT SUDO: handing a file to another user is root's call (chown(2)), so say so up front.
|
|
@@ -120,11 +146,14 @@ export const reconcileFile = async (
|
|
|
120
146
|
const prior = moved ? undefined : output;
|
|
121
147
|
/**
|
|
122
148
|
* ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
|
|
123
|
-
* the engine's adoption probe has already refused anything it found — except
|
|
124
|
-
* a replace,
|
|
125
|
-
*
|
|
149
|
+
* the engine's adoption probe has already refused anything it found — except where it never
|
|
150
|
+
* looked: the new path of a replace, and a create whose props still held an Output at plan
|
|
151
|
+
* time. A path typo there would otherwise overwrite, say, a system file that a fresh
|
|
152
|
+
* declaration of the same path would have been refused as `Unowned`. ★ With adoption on, a
|
|
153
|
+
* create takes it over, as the probe would have let it (decided 2026-09-21).
|
|
126
154
|
*/
|
|
127
|
-
|
|
155
|
+
const takeOver = adopt && output === undefined;
|
|
156
|
+
if (prior === undefined && before !== undefined && !matches(before, want) && !takeOver) {
|
|
128
157
|
throw refuse(
|
|
129
158
|
props.path,
|
|
130
159
|
'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
|
|
@@ -132,11 +161,7 @@ export const reconcileFile = async (
|
|
|
132
161
|
);
|
|
133
162
|
}
|
|
134
163
|
if (before === undefined || !matches(before, want)) {
|
|
135
|
-
await runner.writeFileAtomic(props.path, want.bytes,
|
|
136
|
-
mode: want.mode,
|
|
137
|
-
...(want.uid === undefined ? {} : { uid: want.uid }),
|
|
138
|
-
...(want.gid === undefined ? {} : { gid: want.gid }),
|
|
139
|
-
});
|
|
164
|
+
await runner.writeFileAtomic(props.path, want.bytes, writeOptionsOf(want));
|
|
140
165
|
}
|
|
141
166
|
// ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
|
|
142
167
|
// up here as a refusal instead of as a forever-`update`.
|
package/src/launchd/host-file.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - delete — removes the file.
|
|
10
10
|
*
|
|
11
11
|
* ⛔ NEVER A SECRET — see host-file-form.ts. Secret files stay rendered by openbao-agent.
|
|
12
|
-
* ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`: a file already at the path reads as `Unowned
|
|
12
|
+
* ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`: a file already at the path reads as `Unowned`, and where
|
|
13
|
+
* the plan never asked, reconcile refuses it unless adoption is on (docs/ownership.md).
|
|
13
14
|
* ★ IT LIVES IN THE launchd SUBPATH because the two travel together: a Mac host stack declares a
|
|
14
15
|
* daemon's config file and the daemon that reads it, through the same HostRunner.
|
|
15
16
|
*/
|
|
@@ -18,6 +19,8 @@ import { Unowned } from 'alchemy/AdoptPolicy';
|
|
|
18
19
|
import { isResolved } from 'alchemy/Diff';
|
|
19
20
|
import * as Provider from 'alchemy/Provider';
|
|
20
21
|
import * as Effect from 'effect/Effect';
|
|
22
|
+
import { adoptsAtApply } from '../ownership/adopt.ts';
|
|
23
|
+
import { noteUnfinished } from '../ownership/resume.ts';
|
|
21
24
|
import { lift, resolvedString } from './host-effect.ts';
|
|
22
25
|
import type { HostFileAttributes, HostFileProps } from './host-file-form.ts';
|
|
23
26
|
import { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';
|
|
@@ -44,8 +47,9 @@ export const HostFileProvider = () =>
|
|
|
44
47
|
return output === undefined ? Unowned(found) : found;
|
|
45
48
|
}),
|
|
46
49
|
|
|
47
|
-
diff: ({ news, output }) => {
|
|
48
|
-
|
|
50
|
+
diff: ({ instanceId, news, output }) => {
|
|
51
|
+
// ★ An unfinished generation of our own: `--adopt` may resume it (ownership/resume.ts).
|
|
52
|
+
if (output === undefined) return noteUnfinished(instanceId);
|
|
49
53
|
if (isResolved(news)) return lift(() => diffFile(runner, news, output));
|
|
50
54
|
// ⛔ A new path is a replace even while `content` is unresolved (an Output templated in):
|
|
51
55
|
// the engine's default would be an update, which writes the new path and never
|
|
@@ -56,7 +60,12 @@ export const HostFileProvider = () =>
|
|
|
56
60
|
);
|
|
57
61
|
},
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
// ★ `--adopt` reaches the apply too: the probe never ran for a create with an Output prop.
|
|
64
|
+
// ⛔ A create or an unfinished generation — never a fresh replace's, never offered it.
|
|
65
|
+
reconcile: ({ fqn, instanceId, news, output }) =>
|
|
66
|
+
Effect.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) =>
|
|
67
|
+
lift(() => reconcileFile(runner, news, output, adopt)),
|
|
68
|
+
),
|
|
60
69
|
|
|
61
70
|
delete: ({ output }) => lift(() => deleteFile(runner, output)),
|
|
62
71
|
});
|
|
@@ -28,7 +28,9 @@ import {
|
|
|
28
28
|
assertReplaceable,
|
|
29
29
|
assertUnclaimed,
|
|
30
30
|
assertValid,
|
|
31
|
+
checkPlistWrite,
|
|
31
32
|
locate,
|
|
33
|
+
plistWriteOptions,
|
|
32
34
|
preflight,
|
|
33
35
|
refuse,
|
|
34
36
|
} from './job-preflight.ts';
|
|
@@ -94,19 +96,31 @@ export const diffJob = async (
|
|
|
94
96
|
}
|
|
95
97
|
assertValid(news);
|
|
96
98
|
const desired = renderJob(news).sha256;
|
|
99
|
+
// ★ A plan that will write the plist asks the runner first (checkPlistWrite, HostRunner.checkWrite).
|
|
100
|
+
const update = async (): Promise<Diff> => {
|
|
101
|
+
await checkPlistWrite(runner, await locate(runner, news));
|
|
102
|
+
return { action: 'update' };
|
|
103
|
+
};
|
|
97
104
|
// ⛔ BOTH digests: the stored one catches a bootstrap that failed after the write landed, the live
|
|
98
105
|
// one catches a hand edit. Either alone would report `noop` over a job that is not what we say.
|
|
99
|
-
if (desired !== output.plistSha256) return
|
|
106
|
+
if (desired !== output.plistSha256) return update();
|
|
100
107
|
const live = await readJob(runner, news);
|
|
101
|
-
if (live === undefined || !live.loaded || live.plistSha256 !== desired)
|
|
102
|
-
return { action: 'update' };
|
|
108
|
+
if (live === undefined || !live.loaded || live.plistSha256 !== desired) return update();
|
|
103
109
|
return { action: 'noop' };
|
|
104
110
|
};
|
|
105
111
|
|
|
112
|
+
/**
|
|
113
|
+
* `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
|
|
114
|
+
* own (ownership/adopt.ts adoptsAtApply): it lets such a generation take over a job already on the
|
|
115
|
+
* host, as the plan's probe would have. A rename onto an occupied label stays refused, as it is at
|
|
116
|
+
* plan time (assertReplaceable) — including a label taken between that plan and a fresh replace's
|
|
117
|
+
* new generation, which the caller never marks adoptable.
|
|
118
|
+
*/
|
|
106
119
|
export const reconcileJob = async (
|
|
107
120
|
runner: HostRunner,
|
|
108
121
|
props: LaunchdJobProps,
|
|
109
122
|
output: LaunchdJobAttributes | undefined,
|
|
123
|
+
adopt = false,
|
|
110
124
|
): Promise<LaunchdJobAttributes> => {
|
|
111
125
|
assertValid(props);
|
|
112
126
|
const { location, status } = await preflight(runner, props);
|
|
@@ -121,8 +135,9 @@ export const reconcileJob = async (
|
|
|
121
135
|
const renamed =
|
|
122
136
|
output !== undefined && (output.label !== props.label || output.domain !== props.domain);
|
|
123
137
|
const prior = renamed ? undefined : output;
|
|
124
|
-
// ⛔ No prior state for THIS identity: whatever is there already belongs to someone else
|
|
125
|
-
|
|
138
|
+
// ⛔ No prior state for THIS identity: whatever is there already belongs to someone else —
|
|
139
|
+
// unless adoption is on for a create, where the probe was skipped (a prop still an Output).
|
|
140
|
+
if (prior === undefined && !(adopt && output === undefined)) {
|
|
126
141
|
await assertUnclaimed(runner, props.label, location, status, rendered.sha256);
|
|
127
142
|
}
|
|
128
143
|
if (renamed) await deleteJob(runner, output);
|
|
@@ -138,18 +153,11 @@ export const reconcileJob = async (
|
|
|
138
153
|
) {
|
|
139
154
|
return attributesOf(props, location, rendered.sha256, status);
|
|
140
155
|
}
|
|
141
|
-
|
|
142
|
-
* ⚠️ launchd refuses a daemon plist that is not root:wheel or is group/world-writable ("bad
|
|
143
|
-
* ownership/permissions"), so the system domain always writes 0644 root:wheel. An agent's plist
|
|
144
|
-
* belongs to its user. REASONED from launchd's documented behaviour, not measured here (no
|
|
145
|
-
* bootstrap was run to write this); the nix-darwin daemons on the reference host are 0644 root.
|
|
146
|
-
*/
|
|
156
|
+
// ⚠️ Owner and mode are launchd's rule, not a choice: see plistWriteOptions (job-preflight.ts).
|
|
147
157
|
await runner.writeFileAtomic(
|
|
148
158
|
location.plistPath,
|
|
149
159
|
rendered.bytes,
|
|
150
|
-
location.domain
|
|
151
|
-
? { gid: 0, mode: 0o644, uid: 0 }
|
|
152
|
-
: { mode: 0o644, uid: location.domain.uid },
|
|
160
|
+
plistWriteOptions(location.domain),
|
|
153
161
|
);
|
|
154
162
|
await bootoutIfLoaded(runner, location.target);
|
|
155
163
|
const failed = async (detail: string): Promise<never> => {
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from './job-form.ts';
|
|
22
22
|
import { identityProblems, jobProblems } from './job-validate.ts';
|
|
23
23
|
import { type ServiceStatus, isDisabled, printService } from './launchctl.ts';
|
|
24
|
-
import { type HostRunner, canActAsRoot } from './runner.ts';
|
|
24
|
+
import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
|
|
25
25
|
|
|
26
26
|
export type Location = {
|
|
27
27
|
readonly domain: ParsedDomain;
|
|
@@ -31,6 +31,22 @@ export type Location = {
|
|
|
31
31
|
|
|
32
32
|
export type Identity = Pick<LaunchdJobProps, 'label' | 'domain'>;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* ⚠️ launchd refuses a daemon plist that is not root:wheel or is group/world-writable ("bad
|
|
36
|
+
* ownership/permissions"), so the system domain always writes 0644 root:wheel. An agent's plist
|
|
37
|
+
* belongs to its user. REASONED from launchd's documented behaviour, not measured here (no
|
|
38
|
+
* bootstrap was run to write this); the nix-darwin daemons on the reference host are 0644 root.
|
|
39
|
+
*/
|
|
40
|
+
export const plistWriteOptions = (domain: ParsedDomain): WriteOptions =>
|
|
41
|
+
domain.kind === 'system' ? { gid: 0, mode: 0o644, uid: 0 } : { mode: 0o644, uid: domain.uid };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* PLAN TIME, for a plan that will write the plist: the runner's own pre-write refusals
|
|
45
|
+
* (HostRunner.checkWrite — sudoRunner's directory and mode checks), reading only.
|
|
46
|
+
*/
|
|
47
|
+
export const checkPlistWrite = async (runner: HostRunner, location: Location): Promise<void> =>
|
|
48
|
+
runner.checkWrite?.(location.plistPath, plistWriteOptions(location.domain));
|
|
49
|
+
|
|
34
50
|
export const refuse = (label: string, message: string): Error =>
|
|
35
51
|
new Error(`Launchd.Job ${label}: ${message}`);
|
|
36
52
|
|
|
@@ -150,4 +166,5 @@ export const assertReplaceable = async (
|
|
|
150
166
|
assertMayWrite(runner, old.label, oldDomain);
|
|
151
167
|
const { location, status } = await preflight(runner, next);
|
|
152
168
|
await assertUnclaimed(runner, next.label, location, status, renderedSha256);
|
|
169
|
+
await checkPlistWrite(runner, location);
|
|
153
170
|
};
|
package/src/launchd/job.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* ⛔ ENVIRONMENT IS NON-SECRET ONLY — see the ⛔ on LaunchdJobProps.environment.
|
|
19
19
|
* ⛔ NO SILENT SUDO — see assertMayWrite in job-preflight.ts.
|
|
20
20
|
* ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`. A label already loaded, or a plist already on disk, reads
|
|
21
|
-
* as `Unowned`, so Alchemy refuses to take it over unless asked
|
|
21
|
+
* as `Unowned`, so Alchemy refuses to take it over unless asked — and reconcile, where the plan
|
|
22
|
+
* never asked, honours the same `--adopt` / `adopt(…)` (docs/ownership.md). Labels under `org.nixos.`,
|
|
22
23
|
* `com.apple.` and `homebrew.mxcl.` are refused outright (job-validate.ts) — declare a new label
|
|
23
24
|
* and cut over instead (docs/launchd.md).
|
|
24
25
|
*/
|
|
@@ -27,6 +28,8 @@ import { Unowned } from 'alchemy/AdoptPolicy';
|
|
|
27
28
|
import { isResolved } from 'alchemy/Diff';
|
|
28
29
|
import * as Provider from 'alchemy/Provider';
|
|
29
30
|
import * as Effect from 'effect/Effect';
|
|
31
|
+
import { adoptsAtApply } from '../ownership/adopt.ts';
|
|
32
|
+
import { noteUnfinished } from '../ownership/resume.ts';
|
|
30
33
|
import { lift, resolvedString } from './host-effect.ts';
|
|
31
34
|
import type { LaunchdJobAttributes, LaunchdJobProps } from './job-form.ts';
|
|
32
35
|
import { deleteJob, diffJob, readJob, reconcileJob, replaceDiff } from './job-lifecycle.ts';
|
|
@@ -67,8 +70,9 @@ export const LaunchdJobProvider = () =>
|
|
|
67
70
|
return output === undefined ? Unowned(found) : found;
|
|
68
71
|
}),
|
|
69
72
|
|
|
70
|
-
diff: ({ news, output }) => {
|
|
71
|
-
|
|
73
|
+
diff: ({ instanceId, news, output }) => {
|
|
74
|
+
// ★ An unfinished generation of our own: `--adopt` may resume it (ownership/resume.ts).
|
|
75
|
+
if (output === undefined) return noteUnfinished(instanceId);
|
|
72
76
|
if (isResolved(news)) return lift(() => diffJob(runner, news, output));
|
|
73
77
|
/**
|
|
74
78
|
* ⛔ A RENAME MUST BE SEEN EVEN WHILE OTHER PROPS ARE UNRESOLVED. Returning undefined
|
|
@@ -88,7 +92,12 @@ export const LaunchdJobProvider = () =>
|
|
|
88
92
|
: Effect.succeed(undefined);
|
|
89
93
|
},
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
// ★ `--adopt` reaches the apply too: the probe never ran for a create with an Output prop.
|
|
96
|
+
// ⛔ A create or an unfinished generation — never a fresh replace's, never offered it.
|
|
97
|
+
reconcile: ({ fqn, instanceId, news, output }) =>
|
|
98
|
+
Effect.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) =>
|
|
99
|
+
lift(() => reconcileJob(runner, news, output, adopt)),
|
|
100
|
+
),
|
|
92
101
|
|
|
93
102
|
delete: ({ output }) => lift(() => deleteJob(runner, output)),
|
|
94
103
|
});
|