@homeflare/alchemy 0.5.0 → 0.7.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 +68 -55
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +45 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +30 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +43 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +21 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +912 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +47 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +24 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/fake-engines.d.ts +30 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1518 -310
- package/dist/openbao/index.js.map +40 -17
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +106 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/caddy.md +180 -0
- package/docs/launchd.md +167 -0
- package/docs/mesh-node.md +188 -0
- package/package.json +12 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +218 -0
- package/src/caddy/config-lifecycle.ts +148 -0
- package/src/caddy/config.ts +116 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +160 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +25 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +244 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +62 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +2 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +163 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +62 -37
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/role-rename.test.ts +205 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchdJob's lifecycle against the fake host: no real launchctl, no real writes.
|
|
3
|
+
*
|
|
4
|
+
* ★ The assertions are on the SEQUENCE of host calls, because the ordering is the design — write
|
|
5
|
+
* before bootout before bootstrap, and nothing at all when the host is already converged.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { fakeRunner } from './fake-runner.ts';
|
|
9
|
+
import type { LaunchdJobProps } from './job-form.ts';
|
|
10
|
+
import { deleteJob, diffJob, readJob, reconcileJob } from './job-lifecycle.ts';
|
|
11
|
+
|
|
12
|
+
const DAEMON = '/Library/LaunchDaemons/com.example.job.plist';
|
|
13
|
+
const AGENT = '/Users/someone/Library/LaunchAgents/com.example.job.plist';
|
|
14
|
+
|
|
15
|
+
const job: LaunchdJobProps = {
|
|
16
|
+
domain: 'system',
|
|
17
|
+
keepAlive: true,
|
|
18
|
+
label: 'com.example.job',
|
|
19
|
+
programArguments: ['/usr/local/bin/job'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const host = (euid = 0, privileged = false) =>
|
|
23
|
+
fakeRunner({
|
|
24
|
+
dirs: { '/Library/LaunchDaemons': 0, '/Users/someone/Library/LaunchAgents': 501 },
|
|
25
|
+
euid,
|
|
26
|
+
privileged,
|
|
27
|
+
users: { someone: { gid: 20, home: '/Users/someone', uid: 501 } },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** `bootstrap`, `write`, … — the verbs, in order, skipping the read-only ones. */
|
|
31
|
+
const writes = (calls: string[][]) =>
|
|
32
|
+
calls
|
|
33
|
+
.map((call) => (call[0] === 'write' ? 'write' : call[1]))
|
|
34
|
+
.filter((verb) => verb !== 'print' && verb !== 'print-disabled');
|
|
35
|
+
|
|
36
|
+
describe('create', () => {
|
|
37
|
+
test('writes the plist root:wheel 0644, then bootstraps it', async () => {
|
|
38
|
+
const fake = host();
|
|
39
|
+
const attrs = await reconcileJob(fake.runner, job, undefined);
|
|
40
|
+
expect(writes(fake.calls)).toEqual(['write', 'bootstrap']);
|
|
41
|
+
expect(fake.files.get(DAEMON)).toMatchObject({ gid: 0, mode: 0o644, uid: 0 });
|
|
42
|
+
expect(attrs).toMatchObject({
|
|
43
|
+
loaded: true,
|
|
44
|
+
plistPath: DAEMON,
|
|
45
|
+
serviceTarget: 'system/com.example.job',
|
|
46
|
+
state: 'running',
|
|
47
|
+
});
|
|
48
|
+
expect(attrs.plistSha256).toMatch(/^[0-9a-f]{64}$/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("an agent is written as its user into that user's LaunchAgents", async () => {
|
|
52
|
+
const fake = host(501);
|
|
53
|
+
const attrs = await reconcileJob(fake.runner, { ...job, domain: 'gui/501' }, undefined);
|
|
54
|
+
expect(attrs.plistPath).toBe(AGENT);
|
|
55
|
+
expect(fake.files.get(AGENT)).toMatchObject({ mode: 0o644, uid: 501 });
|
|
56
|
+
expect(fake.loaded.has('gui/501/com.example.job')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('diff', () => {
|
|
61
|
+
test('noop when nothing changed, on disk or in launchd', async () => {
|
|
62
|
+
const fake = host();
|
|
63
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
64
|
+
expect(await diffJob(fake.runner, job, output)).toEqual({ action: 'noop' });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('update when a prop changes the rendered plist', async () => {
|
|
68
|
+
const fake = host();
|
|
69
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
70
|
+
expect(await diffJob(fake.runner, { ...job, runAtLoad: true }, output)).toEqual({
|
|
71
|
+
action: 'update',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('update when the file was hand-edited, or the job was unloaded behind our back', async () => {
|
|
76
|
+
const fake = host();
|
|
77
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
78
|
+
const entry = fake.files.get(DAEMON);
|
|
79
|
+
if (entry === undefined) throw new Error('no plist');
|
|
80
|
+
fake.files.set(DAEMON, { ...entry, bytes: new TextEncoder().encode('hand edit') });
|
|
81
|
+
expect(await diffJob(fake.runner, job, output)).toEqual({ action: 'update' });
|
|
82
|
+
await reconcileJob(fake.runner, job, output);
|
|
83
|
+
fake.loaded.clear();
|
|
84
|
+
expect(await diffJob(fake.runner, job, output)).toEqual({ action: 'update' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('replace, deleting first, only when label or domain changes', async () => {
|
|
88
|
+
const fake = host();
|
|
89
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
90
|
+
const replace = { action: 'replace', deleteFirst: true } as const;
|
|
91
|
+
expect(await diffJob(fake.runner, { ...job, label: 'com.example.renamed' }, output)).toEqual(
|
|
92
|
+
replace,
|
|
93
|
+
);
|
|
94
|
+
expect(await diffJob(fake.runner, { ...job, domain: 'gui/501' }, output)).toEqual(replace);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a bad declaration fails the plan, not the apply', async () => {
|
|
98
|
+
const fake = host();
|
|
99
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
100
|
+
await expect(diffJob(fake.runner, { ...job, startInterval: 0 }, output)).rejects.toThrow(
|
|
101
|
+
'startInterval',
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('update', () => {
|
|
107
|
+
test('write, then bootout, then bootstrap — a restart with a new pid', async () => {
|
|
108
|
+
const fake = host();
|
|
109
|
+
const first = await reconcileJob(fake.runner, job, undefined);
|
|
110
|
+
fake.calls.length = 0;
|
|
111
|
+
fake.state.lingerPrints = 2;
|
|
112
|
+
const second = await reconcileJob(fake.runner, { ...job, runAtLoad: true }, first);
|
|
113
|
+
expect(writes(fake.calls)).toEqual(['write', 'bootout', 'bootstrap']);
|
|
114
|
+
expect(second.pid).not.toBe(first.pid);
|
|
115
|
+
expect(second.plistSha256).not.toBe(first.plistSha256);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a converged job is not restarted', async () => {
|
|
119
|
+
const fake = host();
|
|
120
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
121
|
+
fake.calls.length = 0;
|
|
122
|
+
const again = await reconcileJob(fake.runner, job, output);
|
|
123
|
+
expect(writes(fake.calls)).toEqual([]);
|
|
124
|
+
expect(again.pid).toBe(output.pid);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a failed bootstrap leaves state behind, so the next diff retries', async () => {
|
|
128
|
+
const fake = host();
|
|
129
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
130
|
+
fake.state.bootstrapFailure = {
|
|
131
|
+
exitCode: 5,
|
|
132
|
+
stderr: 'Bootstrap failed: 5: Input/output error',
|
|
133
|
+
stdout: '',
|
|
134
|
+
};
|
|
135
|
+
const changed = { ...job, runAtLoad: true };
|
|
136
|
+
await expect(reconcileJob(fake.runner, changed, output)).rejects.toThrow('NOT RUNNING');
|
|
137
|
+
expect(fake.loaded.size).toBe(0);
|
|
138
|
+
expect(await diffJob(fake.runner, changed, output)).toEqual({ action: 'update' });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('refusals', () => {
|
|
143
|
+
test('the system domain as a non-root user: refused before anything is written', async () => {
|
|
144
|
+
const fake = host(501);
|
|
145
|
+
await expect(reconcileJob(fake.runner, job, undefined)).rejects.toThrow('never calls sudo');
|
|
146
|
+
expect(writes(fake.calls)).toEqual([]);
|
|
147
|
+
expect(fake.calls).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a runner that declares itself privileged may write the system domain', async () => {
|
|
151
|
+
const fake = host(501, true);
|
|
152
|
+
expect((await reconcileJob(fake.runner, job, undefined)).loaded).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("another user's gui domain needs root", async () => {
|
|
156
|
+
const fake = fakeRunner({
|
|
157
|
+
euid: 501,
|
|
158
|
+
users: { other: { gid: 20, home: '/Users/other', uid: 502 } },
|
|
159
|
+
});
|
|
160
|
+
await expect(
|
|
161
|
+
reconcileJob(fake.runner, { ...job, domain: 'gui/502' }, undefined),
|
|
162
|
+
).rejects.toThrow('another user');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a gui uid with no user on the host', async () => {
|
|
166
|
+
const fake = host();
|
|
167
|
+
await expect(
|
|
168
|
+
reconcileJob(fake.runner, { ...job, domain: 'gui/777' }, undefined),
|
|
169
|
+
).rejects.toThrow('no home');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('a disabled label is left disabled, with the command to change that', async () => {
|
|
173
|
+
const fake = host();
|
|
174
|
+
fake.disabled.add('system/com.example.job');
|
|
175
|
+
await expect(reconcileJob(fake.runner, job, undefined)).rejects.toThrow(
|
|
176
|
+
'launchctl enable system/com.example.job',
|
|
177
|
+
);
|
|
178
|
+
expect(fake.files.has(DAEMON)).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('a reserved label never reaches the host', async () => {
|
|
182
|
+
const fake = host();
|
|
183
|
+
await expect(
|
|
184
|
+
reconcileJob(fake.runner, { ...job, label: 'org.nixos.job' }, undefined),
|
|
185
|
+
).rejects.toThrow('another tool owns');
|
|
186
|
+
expect(fake.calls).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('read', () => {
|
|
191
|
+
test('nothing on disk and nothing loaded is undefined', async () => {
|
|
192
|
+
expect(await readJob(host().runner, job)).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a plist on disk that is not loaded reads as present, not loaded', async () => {
|
|
196
|
+
const fake = host();
|
|
197
|
+
await reconcileJob(fake.runner, job, undefined);
|
|
198
|
+
fake.loaded.clear();
|
|
199
|
+
expect(await readJob(fake.runner, job)).toMatchObject({ loaded: false, plistPath: DAEMON });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('delete', () => {
|
|
204
|
+
test('boots out, removes the plist, and is idempotent', async () => {
|
|
205
|
+
const fake = host();
|
|
206
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
207
|
+
fake.calls.length = 0;
|
|
208
|
+
await deleteJob(fake.runner, output);
|
|
209
|
+
expect(writes(fake.calls)).toEqual(['bootout']);
|
|
210
|
+
expect(fake.files.has(DAEMON)).toBe(false);
|
|
211
|
+
expect(fake.loaded.size).toBe(0);
|
|
212
|
+
await deleteJob(fake.runner, output);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('refused for a non-root user in the system domain', async () => {
|
|
216
|
+
const root = host();
|
|
217
|
+
const output = await reconcileJob(root.runner, job, undefined);
|
|
218
|
+
await expect(deleteJob(host(501).runner, output)).rejects.toThrow('needs root');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchdJob's read / diff / reconcile / delete as plain async functions over a HostRunner — split
|
|
3
|
+
* from job.ts so the whole lifecycle runs against fake-runner.ts in tests, with no real launchd.
|
|
4
|
+
* The checks made before any write live in job-preflight.ts.
|
|
5
|
+
*
|
|
6
|
+
* ★ THE ORDER IS WRITE, BOOTOUT, BOOTSTRAP — not bootout first. launchd reads the plist only at
|
|
7
|
+
* bootstrap, so writing the new file while the old job runs changes nothing yet, and a write that
|
|
8
|
+
* fails (disk full, EACCES) leaves the old job running instead of leaving nothing running.
|
|
9
|
+
* ⚠️ A FAILED BOOTSTRAP LEAVES THE JOB DOWN. On an UPDATE the new plist stays on disk, the state
|
|
10
|
+
* keeps the previous digest, and the next deploy retries. On a CREATE the plist this deploy wrote
|
|
11
|
+
* is removed again: left behind, the next plan's recovery `read` would find a plist with no
|
|
12
|
+
* state and report it `Unowned`, and every later deploy would demand `--adopt` for our own file.
|
|
13
|
+
* There is no rollback to an older plist: one that launchd refuses is a declaration to fix, and
|
|
14
|
+
* re-bootstrapping the old one could fail for the same host-side reason.
|
|
15
|
+
*/
|
|
16
|
+
import type { Diff } from 'alchemy/Diff';
|
|
17
|
+
import {
|
|
18
|
+
type LaunchdJobAttributes,
|
|
19
|
+
type LaunchdJobProps,
|
|
20
|
+
parseDomain,
|
|
21
|
+
renderJob,
|
|
22
|
+
sha256Hex,
|
|
23
|
+
} from './job-form.ts';
|
|
24
|
+
import {
|
|
25
|
+
type Identity,
|
|
26
|
+
type Location,
|
|
27
|
+
assertMayWrite,
|
|
28
|
+
assertReplaceable,
|
|
29
|
+
assertUnclaimed,
|
|
30
|
+
assertValid,
|
|
31
|
+
locate,
|
|
32
|
+
preflight,
|
|
33
|
+
refuse,
|
|
34
|
+
} from './job-preflight.ts';
|
|
35
|
+
import { type ServiceStatus, bootoutIfLoaded, bootstrap, printService } from './launchctl.ts';
|
|
36
|
+
import type { HostRunner } from './runner.ts';
|
|
37
|
+
|
|
38
|
+
const attributesOf = (
|
|
39
|
+
props: Identity,
|
|
40
|
+
location: Location,
|
|
41
|
+
plistSha256: string,
|
|
42
|
+
status: ServiceStatus,
|
|
43
|
+
): LaunchdJobAttributes => ({
|
|
44
|
+
domain: props.domain,
|
|
45
|
+
label: props.label,
|
|
46
|
+
loaded: status.loaded,
|
|
47
|
+
plistPath: location.plistPath,
|
|
48
|
+
plistSha256,
|
|
49
|
+
serviceTarget: location.target,
|
|
50
|
+
...(status.state === undefined ? {} : { state: status.state }),
|
|
51
|
+
...(status.pid === undefined ? {} : { pid: status.pid }),
|
|
52
|
+
...(status.lastExitCode === undefined ? {} : { lastExitCode: status.lastExitCode }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** What is on the host now: the plist's digest and launchd's view. `undefined` when neither exists. */
|
|
56
|
+
export const readJob = async (
|
|
57
|
+
runner: HostRunner,
|
|
58
|
+
props: Identity,
|
|
59
|
+
): Promise<LaunchdJobAttributes | undefined> => {
|
|
60
|
+
const location = await locate(runner, props);
|
|
61
|
+
const bytes = await runner.readFile(location.plistPath);
|
|
62
|
+
const status = await printService(runner, location.target);
|
|
63
|
+
if (bytes === undefined && !status.loaded) return undefined;
|
|
64
|
+
return attributesOf(props, location, bytes === undefined ? '' : sha256Hex(bytes), status);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ★ `deleteFirst`: labels are unique per domain, and the old and new job would otherwise run the
|
|
69
|
+
* same program side by side — same port, same files — until the old one is deleted. A brief
|
|
70
|
+
* outage beats two copies. ⛔ Which is why everything the new job's reconcile would refuse is
|
|
71
|
+
* refused HERE, at plan time (job-preflight.ts): after the delete it would be too late.
|
|
72
|
+
* `full` is the whole declaration when resolved; job.ts passes only the identity otherwise.
|
|
73
|
+
*/
|
|
74
|
+
export const replaceDiff = async (
|
|
75
|
+
runner: HostRunner,
|
|
76
|
+
next: Identity,
|
|
77
|
+
full: LaunchdJobProps | undefined,
|
|
78
|
+
output: LaunchdJobAttributes,
|
|
79
|
+
): Promise<Diff> => {
|
|
80
|
+
// ★ Validate before rendering, so a bad declaration shows its whole list, not a render error.
|
|
81
|
+
if (full !== undefined) assertValid(full);
|
|
82
|
+
const rendered = full === undefined ? undefined : renderJob(full).sha256;
|
|
83
|
+
await assertReplaceable(runner, next, full, output, rendered);
|
|
84
|
+
return { action: 'replace', deleteFirst: true };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const diffJob = async (
|
|
88
|
+
runner: HostRunner,
|
|
89
|
+
news: LaunchdJobProps,
|
|
90
|
+
output: LaunchdJobAttributes,
|
|
91
|
+
): Promise<Diff> => {
|
|
92
|
+
if (news.label !== output.label || news.domain !== output.domain) {
|
|
93
|
+
return replaceDiff(runner, news, news, output);
|
|
94
|
+
}
|
|
95
|
+
assertValid(news);
|
|
96
|
+
const desired = renderJob(news).sha256;
|
|
97
|
+
// ⛔ BOTH digests: the stored one catches a bootstrap that failed after the write landed, the live
|
|
98
|
+
// 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 { action: 'update' };
|
|
100
|
+
const live = await readJob(runner, news);
|
|
101
|
+
if (live === undefined || !live.loaded || live.plistSha256 !== desired)
|
|
102
|
+
return { action: 'update' };
|
|
103
|
+
return { action: 'noop' };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const reconcileJob = async (
|
|
107
|
+
runner: HostRunner,
|
|
108
|
+
props: LaunchdJobProps,
|
|
109
|
+
output: LaunchdJobAttributes | undefined,
|
|
110
|
+
): Promise<LaunchdJobAttributes> => {
|
|
111
|
+
assertValid(props);
|
|
112
|
+
const { location, status } = await preflight(runner, props);
|
|
113
|
+
const rendered = renderJob(props);
|
|
114
|
+
/**
|
|
115
|
+
* ⚠️ AN `output` UNDER ANOTHER LABEL OR DOMAIN means the engine planned an UPDATE across a rename —
|
|
116
|
+
* which it does whenever diff could not see the rename (a label that was itself an unresolved
|
|
117
|
+
* Output at plan time; job.ts). Bootstrapping the new label alone would orphan the old job,
|
|
118
|
+
* still running, with nothing left in state to ever remove it. So do what the replace would
|
|
119
|
+
* have done: every check above first, then delete the old job, then create the new one.
|
|
120
|
+
*/
|
|
121
|
+
const renamed =
|
|
122
|
+
output !== undefined && (output.label !== props.label || output.domain !== props.domain);
|
|
123
|
+
const prior = renamed ? undefined : output;
|
|
124
|
+
// ⛔ No prior state for THIS identity: whatever is there already belongs to someone else.
|
|
125
|
+
if (prior === undefined) {
|
|
126
|
+
await assertUnclaimed(runner, props.label, location, status, rendered.sha256);
|
|
127
|
+
}
|
|
128
|
+
if (renamed) await deleteJob(runner, output);
|
|
129
|
+
const before = await runner.readFile(location.plistPath);
|
|
130
|
+
// ★ Already converged (a retried deploy, or an adopted job that matches): do not restart it.
|
|
131
|
+
// ⛔ The STORED digest is part of the test: a write that landed before a failed bootout leaves
|
|
132
|
+
// the new plist on disk and the OLD job loaded — both look right, and only state knows better.
|
|
133
|
+
if (
|
|
134
|
+
status.loaded &&
|
|
135
|
+
before !== undefined &&
|
|
136
|
+
sha256Hex(before) === rendered.sha256 &&
|
|
137
|
+
prior?.plistSha256 === rendered.sha256
|
|
138
|
+
) {
|
|
139
|
+
return attributesOf(props, location, rendered.sha256, status);
|
|
140
|
+
}
|
|
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
|
+
*/
|
|
147
|
+
await runner.writeFileAtomic(
|
|
148
|
+
location.plistPath,
|
|
149
|
+
rendered.bytes,
|
|
150
|
+
location.domain.kind === 'system'
|
|
151
|
+
? { gid: 0, mode: 0o644, uid: 0 }
|
|
152
|
+
: { mode: 0o644, uid: location.domain.uid },
|
|
153
|
+
);
|
|
154
|
+
await bootoutIfLoaded(runner, location.target);
|
|
155
|
+
const failed = async (detail: string): Promise<never> => {
|
|
156
|
+
// ⚠️ A failed removal must not replace the bootstrap error, nor let the message claim it worked.
|
|
157
|
+
const removed =
|
|
158
|
+
before === undefined &&
|
|
159
|
+
(await runner.removeFile(location.plistPath).then(
|
|
160
|
+
() => true,
|
|
161
|
+
() => false,
|
|
162
|
+
));
|
|
163
|
+
throw refuse(
|
|
164
|
+
props.label,
|
|
165
|
+
removed
|
|
166
|
+
? `bootstrap failed, so the job is NOT RUNNING; the plist this deploy wrote was removed ` +
|
|
167
|
+
`again, so the next deploy starts clean. ${detail}`
|
|
168
|
+
: `bootstrap failed, so the job is NOT RUNNING; the new plist is at ${location.plistPath} ` +
|
|
169
|
+
`and the next deploy retries. ${detail}`,
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
await bootstrap(runner, props.domain, location.plistPath);
|
|
174
|
+
} catch (cause) {
|
|
175
|
+
return failed(cause instanceof Error ? cause.message : String(cause));
|
|
176
|
+
}
|
|
177
|
+
const after = await printService(runner, location.target);
|
|
178
|
+
if (!after.loaded) return failed('bootstrap exited 0 but launchctl print cannot find the job');
|
|
179
|
+
return attributesOf(props, location, rendered.sha256, after);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Boot out, then remove the plist. Idempotent: an absent job and an absent file are success. */
|
|
183
|
+
export const deleteJob = async (
|
|
184
|
+
runner: HostRunner,
|
|
185
|
+
output: LaunchdJobAttributes,
|
|
186
|
+
): Promise<void> => {
|
|
187
|
+
const domain = parseDomain(output.domain);
|
|
188
|
+
if (domain === undefined) throw refuse(output.label, `unknown domain ${output.domain}`);
|
|
189
|
+
assertMayWrite(runner, output.label, domain);
|
|
190
|
+
// ★ A gui domain with no login session reads as "not loaded" (launchctl.ts), so the plist is
|
|
191
|
+
// still removed — which is what stops launchd loading it again at that user's next login.
|
|
192
|
+
await bootoutIfLoaded(runner, output.serviceTarget);
|
|
193
|
+
await runner.removeFile(output.plistPath);
|
|
194
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything LaunchdJob checks before it touches the host — split from job-lifecycle.ts so that a
|
|
3
|
+
* reconcile and a plan-time REPLACE run the very same checks.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY A REPLACE IS CHECKED AT PLAN TIME. A label or domain change is a delete-first replace, and
|
|
6
|
+
* Alchemy runs `delete` on the old job BEFORE `reconcile` on the new one (Apply.ts,
|
|
7
|
+
* deleteOldGenerations under `node.deleteFirst`; read in alchemy 2.0.0-beta.79 on 2026-09-21).
|
|
8
|
+
* A refusal that fired only in reconcile would therefore arrive after the old job was already
|
|
9
|
+
* booted out: nothing running, and a plan that had promised a clean swap.
|
|
10
|
+
* ★ EVERY CHECK HERE IS READ-ONLY: validation, a user lookup, `launchctl print` and
|
|
11
|
+
* `print-disabled` — none of which needs root (measured, see launchctl.ts).
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
type LaunchdJobAttributes,
|
|
15
|
+
type LaunchdJobProps,
|
|
16
|
+
type ParsedDomain,
|
|
17
|
+
parseDomain,
|
|
18
|
+
plistPathFor,
|
|
19
|
+
serviceTarget,
|
|
20
|
+
sha256Hex,
|
|
21
|
+
} from './job-form.ts';
|
|
22
|
+
import { identityProblems, jobProblems } from './job-validate.ts';
|
|
23
|
+
import { type ServiceStatus, isDisabled, printService } from './launchctl.ts';
|
|
24
|
+
import { type HostRunner, canActAsRoot } from './runner.ts';
|
|
25
|
+
|
|
26
|
+
export type Location = {
|
|
27
|
+
readonly domain: ParsedDomain;
|
|
28
|
+
readonly plistPath: string;
|
|
29
|
+
readonly target: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type Identity = Pick<LaunchdJobProps, 'label' | 'domain'>;
|
|
33
|
+
|
|
34
|
+
export const refuse = (label: string, message: string): Error =>
|
|
35
|
+
new Error(`Launchd.Job ${label}: ${message}`);
|
|
36
|
+
|
|
37
|
+
/** Throw every refusal at once, so one plan shows the whole list. */
|
|
38
|
+
export const assertValid = (props: LaunchdJobProps): void => {
|
|
39
|
+
const found = jobProblems(props);
|
|
40
|
+
if (found.length > 0) throw refuse(props.label, found.join('; '));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const locate = async (runner: HostRunner, props: Identity): Promise<Location> => {
|
|
44
|
+
const domain = parseDomain(props.domain);
|
|
45
|
+
if (domain === undefined) throw refuse(props.label, `unknown domain ${props.domain}`);
|
|
46
|
+
const home =
|
|
47
|
+
domain.kind === 'gui' ? (await runner.lookupUser(String(domain.uid)))?.home : undefined;
|
|
48
|
+
return {
|
|
49
|
+
domain,
|
|
50
|
+
plistPath: plistPathFor(props.label, domain, home),
|
|
51
|
+
target: serviceTarget(props.label, props.domain),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* ⛔ NO SILENT SUDO. The system domain, and another user's gui domain, need root. Refuse up front
|
|
57
|
+
* with the two ways out, rather than let launchctl or rename(2) fail halfway through.
|
|
58
|
+
*/
|
|
59
|
+
export const assertMayWrite = (runner: HostRunner, label: string, domain: ParsedDomain): void => {
|
|
60
|
+
if (canActAsRoot(runner)) return;
|
|
61
|
+
if (domain.kind === 'system') {
|
|
62
|
+
throw refuse(
|
|
63
|
+
label,
|
|
64
|
+
'the system domain needs root. Run the deploy as root, or provide a HostRunner that is ' +
|
|
65
|
+
'deliberately privileged (privileged: true). This provider never calls sudo.',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (domain.uid !== runner.effectiveUid()) {
|
|
69
|
+
throw refuse(
|
|
70
|
+
label,
|
|
71
|
+
`gui/${String(domain.uid)} belongs to another user; only root may write it. ` +
|
|
72
|
+
'Deploy as that user, as root, or through a privileged HostRunner.',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Where the job goes, whether we may write it there, and launchd's current view of it. */
|
|
78
|
+
export const preflight = async (
|
|
79
|
+
runner: HostRunner,
|
|
80
|
+
props: Identity,
|
|
81
|
+
): Promise<{ location: Location; status: ServiceStatus }> => {
|
|
82
|
+
const location = await locate(runner, props);
|
|
83
|
+
assertMayWrite(runner, props.label, location.domain);
|
|
84
|
+
const status = await printService(runner, location.target);
|
|
85
|
+
if (status.domainMissing === true) {
|
|
86
|
+
throw refuse(
|
|
87
|
+
props.label,
|
|
88
|
+
`${props.domain} does not exist: that user has no login session, and launchd creates a gui ` +
|
|
89
|
+
'domain only at login. Log the user in (or declare a system-domain job with userName), ' +
|
|
90
|
+
'then redeploy. Nothing was written.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* ⛔ A DISABLED LABEL IS SOMEONE'S DECISION, NOT DRIFT. `launchctl disable` persists across boots
|
|
95
|
+
* and makes bootstrap fail; enabling it here would silently overrule whoever disabled it.
|
|
96
|
+
*/
|
|
97
|
+
if (await isDisabled(runner, props.domain, props.label)) {
|
|
98
|
+
throw refuse(
|
|
99
|
+
props.label,
|
|
100
|
+
`${location.target} is disabled (launchctl print-disabled ${props.domain}). ` +
|
|
101
|
+
`If that is stale, run \`launchctl enable ${location.target}\` deliberately, then redeploy.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return { location, status };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* ⛔ A JOB THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. Alchemy's adoption probe guards a
|
|
109
|
+
* create, but NOT the new label of a replace — the engine reads nothing there — so a rename onto
|
|
110
|
+
* a label someone already loaded would boot their job out and write ours over its plist.
|
|
111
|
+
* ★ A plist byte-identical to our render is exempt: it is this declaration's own leftover (a
|
|
112
|
+
* deploy that died between write and bootstrap), and rewriting it changes nothing.
|
|
113
|
+
*/
|
|
114
|
+
export const assertUnclaimed = async (
|
|
115
|
+
runner: HostRunner,
|
|
116
|
+
label: string,
|
|
117
|
+
location: Location,
|
|
118
|
+
status: ServiceStatus,
|
|
119
|
+
renderedSha256: string | undefined,
|
|
120
|
+
): Promise<void> => {
|
|
121
|
+
const bytes = await runner.readFile(location.plistPath);
|
|
122
|
+
const foreignFile = bytes !== undefined && sha256Hex(bytes) !== renderedSha256;
|
|
123
|
+
if (foreignFile || (bytes === undefined && status.loaded)) {
|
|
124
|
+
throw refuse(
|
|
125
|
+
label,
|
|
126
|
+
`${location.target} is already on this host (${foreignFile ? location.plistPath : 'loaded'})` +
|
|
127
|
+
' and is not this resource. Remove it, or declare it as a new resource and deploy with ' +
|
|
128
|
+
'--adopt.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A rename, checked at plan time: the new identity must be valid, writable, bootstrappable and
|
|
135
|
+
* free, and the old one deletable — before the plan may promise a delete-first replace.
|
|
136
|
+
* `full` is the whole declaration when it is already resolved; otherwise only the identity is.
|
|
137
|
+
*/
|
|
138
|
+
export const assertReplaceable = async (
|
|
139
|
+
runner: HostRunner,
|
|
140
|
+
next: Identity,
|
|
141
|
+
full: LaunchdJobProps | undefined,
|
|
142
|
+
old: LaunchdJobAttributes,
|
|
143
|
+
renderedSha256: string | undefined,
|
|
144
|
+
): Promise<void> => {
|
|
145
|
+
const found = full === undefined ? identityProblems(next.label, next.domain) : jobProblems(full);
|
|
146
|
+
if (found.length > 0) throw refuse(next.label, found.join('; '));
|
|
147
|
+
const oldDomain = parseDomain(old.domain);
|
|
148
|
+
if (oldDomain === undefined) throw refuse(old.label, `unknown domain ${old.domain}`);
|
|
149
|
+
assertMayWrite(runner, old.label, oldDomain);
|
|
150
|
+
const { location, status } = await preflight(runner, next);
|
|
151
|
+
await assertUnclaimed(runner, next.label, location, status, renderedSha256);
|
|
152
|
+
};
|