@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,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launchd providers for Alchemy — declare a Mac host's jobs and their config files.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. The
|
|
5
|
+
* lifecycle files, the launchctl parsers and the fake runner are internals; an `export *` would
|
|
6
|
+
* publish them as API and make the next refactor a breaking change.
|
|
7
|
+
* ⛔ macOS HOSTS ONLY for LaunchdJob. HostFile and the runner work on any POSIX host, but the
|
|
8
|
+
* subpath exists for the Mac: see docs/launchd.md for the cutover from nix-darwin.
|
|
9
|
+
*/
|
|
10
|
+
export type { HostFileAttributes, HostFileProps } from './host-file.ts';
|
|
11
|
+
export { HostFile, HostFileProvider } from './host-file.ts';
|
|
12
|
+
export type {
|
|
13
|
+
CalendarInterval,
|
|
14
|
+
KeepAlive,
|
|
15
|
+
LaunchdDomain,
|
|
16
|
+
LaunchdJobAttributes,
|
|
17
|
+
LaunchdJobProps,
|
|
18
|
+
} from './job.ts';
|
|
19
|
+
export { LaunchdJob, LaunchdJobProvider } from './job.ts';
|
|
20
|
+
export type { LocalRunnerOptions } from './local-runner.ts';
|
|
21
|
+
export { localRunner } from './local-runner.ts';
|
|
22
|
+
export type { PlistDict, PlistValue } from './plist.ts';
|
|
23
|
+
export { PlistError, renderPlist } from './plist.ts';
|
|
24
|
+
export { launchdProviders } from './providers.ts';
|
|
25
|
+
export type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
|
|
26
|
+
export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchdJob's pure half: domain parsing, the derived plist path, the props → plist mapping, and
|
|
3
|
+
* every refusal in job-validate.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
type LaunchdJobProps,
|
|
8
|
+
jobPlistDict,
|
|
9
|
+
parseDomain,
|
|
10
|
+
plistPathFor,
|
|
11
|
+
renderJob,
|
|
12
|
+
serviceTarget,
|
|
13
|
+
} from './job-form.ts';
|
|
14
|
+
import { RESERVED_LABEL_PREFIXES, jobProblems } from './job-validate.ts';
|
|
15
|
+
|
|
16
|
+
const base: LaunchdJobProps = {
|
|
17
|
+
domain: 'system',
|
|
18
|
+
label: 'com.example.job',
|
|
19
|
+
programArguments: ['/usr/local/bin/job', '--flag'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('domains and paths', () => {
|
|
23
|
+
test('system and gui/<uid> parse; anything else does not', () => {
|
|
24
|
+
expect(parseDomain('system')).toEqual({ kind: 'system' });
|
|
25
|
+
expect(parseDomain('gui/501')).toEqual({ kind: 'gui', uid: 501 });
|
|
26
|
+
for (const bad of ['gui/', 'gui/abc', 'user/501', 'System', 'gui/501/x']) {
|
|
27
|
+
expect(parseDomain(bad)).toBeUndefined();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('the plist path is derived from label and domain', () => {
|
|
32
|
+
expect(plistPathFor('com.example.job', { kind: 'system' }, undefined)).toBe(
|
|
33
|
+
'/Library/LaunchDaemons/com.example.job.plist',
|
|
34
|
+
);
|
|
35
|
+
expect(plistPathFor('com.example.job', { kind: 'gui', uid: 501 }, '/Users/someone')).toBe(
|
|
36
|
+
'/Users/someone/Library/LaunchAgents/com.example.job.plist',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a gui domain with no home refuses rather than guessing `~`', () => {
|
|
41
|
+
expect(() => plistPathFor('x', { kind: 'gui', uid: 9 }, undefined)).toThrow('no home');
|
|
42
|
+
expect(() => plistPathFor('x', { kind: 'gui', uid: 9 }, 'relative')).toThrow('no home');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('service target', () => {
|
|
46
|
+
expect(serviceTarget('com.example.job', 'gui/501')).toBe('gui/501/com.example.job');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('jobPlistDict', () => {
|
|
51
|
+
test('the minimum is Label and ProgramArguments — no invented defaults', () => {
|
|
52
|
+
expect(jobPlistDict(base)).toEqual({
|
|
53
|
+
Label: 'com.example.job',
|
|
54
|
+
ProgramArguments: ['/usr/local/bin/job', '--flag'],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('every typed prop maps to its launchd.plist(5) key', () => {
|
|
59
|
+
expect(
|
|
60
|
+
jobPlistDict({
|
|
61
|
+
...base,
|
|
62
|
+
environment: { SSL_CERT_FILE: '/etc/ssl/cert.pem' },
|
|
63
|
+
groupName: 'staff',
|
|
64
|
+
keepAlive: { crashed: true, successfulExit: false },
|
|
65
|
+
runAtLoad: true,
|
|
66
|
+
standardErrorPath: '/var/log/job.err',
|
|
67
|
+
standardOutPath: '/var/log/job.out',
|
|
68
|
+
startCalendarInterval: [{ hour: 3, minute: 30 }, { weekday: 0 }],
|
|
69
|
+
startInterval: 300,
|
|
70
|
+
throttleInterval: 30,
|
|
71
|
+
userName: 'nobody',
|
|
72
|
+
workingDirectory: '/var/empty',
|
|
73
|
+
extraKeys: { ProcessType: 'Background' },
|
|
74
|
+
}),
|
|
75
|
+
).toEqual({
|
|
76
|
+
EnvironmentVariables: { SSL_CERT_FILE: '/etc/ssl/cert.pem' },
|
|
77
|
+
GroupName: 'staff',
|
|
78
|
+
KeepAlive: { Crashed: true, SuccessfulExit: false },
|
|
79
|
+
Label: 'com.example.job',
|
|
80
|
+
ProcessType: 'Background',
|
|
81
|
+
ProgramArguments: ['/usr/local/bin/job', '--flag'],
|
|
82
|
+
RunAtLoad: true,
|
|
83
|
+
StandardErrorPath: '/var/log/job.err',
|
|
84
|
+
StandardOutPath: '/var/log/job.out',
|
|
85
|
+
StartCalendarInterval: [{ Hour: 3, Minute: 30 }, { Weekday: 0 }],
|
|
86
|
+
StartInterval: 300,
|
|
87
|
+
ThrottleInterval: 30,
|
|
88
|
+
UserName: 'nobody',
|
|
89
|
+
WorkingDirectory: '/var/empty',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a single calendar entry stays a dict; keepAlive true stays a boolean; empty env is omitted', () => {
|
|
94
|
+
const dict = jobPlistDict({
|
|
95
|
+
...base,
|
|
96
|
+
environment: {},
|
|
97
|
+
keepAlive: true,
|
|
98
|
+
startCalendarInterval: { minute: 0 },
|
|
99
|
+
});
|
|
100
|
+
expect(dict['StartCalendarInterval']).toEqual({ Minute: 0 });
|
|
101
|
+
expect(dict['KeepAlive']).toBe(true);
|
|
102
|
+
expect('EnvironmentVariables' in dict).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the digest is stable across key order and changes with any value', () => {
|
|
106
|
+
const one = renderJob({ ...base, environment: { A: '1', B: '2' } });
|
|
107
|
+
const two = renderJob({ ...base, environment: { B: '2', A: '1' } });
|
|
108
|
+
expect(one.sha256).toBe(two.sha256);
|
|
109
|
+
expect(one.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
110
|
+
expect(renderJob({ ...base, runAtLoad: true }).sha256).not.toBe(one.sha256);
|
|
111
|
+
expect(new TextDecoder().decode(one.bytes)).toBe(one.text);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('jobProblems', () => {
|
|
116
|
+
test('a well-formed job has none', () => {
|
|
117
|
+
expect(jobProblems(base)).toEqual([]);
|
|
118
|
+
expect(
|
|
119
|
+
jobProblems({ ...base, domain: 'gui/501', startCalendarInterval: { weekday: 7 } }),
|
|
120
|
+
).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test.each([
|
|
124
|
+
[
|
|
125
|
+
'a slash in the label (it becomes a file name)',
|
|
126
|
+
{ label: 'com.example/../evil' },
|
|
127
|
+
'label must be',
|
|
128
|
+
],
|
|
129
|
+
['a leading dot', { label: '.hidden' }, 'label must be'],
|
|
130
|
+
['an unknown domain', { domain: 'user/501' as never }, 'domain must be'],
|
|
131
|
+
['no program', { programArguments: [] }, 'must name a program'],
|
|
132
|
+
['a relative program', { programArguments: ['job'] }, 'absolute path'],
|
|
133
|
+
['a bad env name', { environment: { 'A-B': 'x' } }, 'not a valid variable name'],
|
|
134
|
+
['a zero startInterval', { startInterval: 0 }, 'startInterval'],
|
|
135
|
+
['a fractional throttleInterval', { throttleInterval: 1.5 }, 'throttleInterval'],
|
|
136
|
+
['an hour of 24', { startCalendarInterval: { hour: 24 } }, 'hour must be 0–23'],
|
|
137
|
+
['a day of 0', { startCalendarInterval: [{ day: 0 }] }, 'day must be 1–31'],
|
|
138
|
+
['an empty calendar list', { startCalendarInterval: [] }, 'empty list'],
|
|
139
|
+
['keepAlive with no condition', { keepAlive: {} }, 'names no condition'],
|
|
140
|
+
[
|
|
141
|
+
'a relative log path',
|
|
142
|
+
{ standardOutPath: 'log.txt' },
|
|
143
|
+
'standardOutPath must be an absolute path',
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
'userName on an agent',
|
|
147
|
+
{ domain: 'gui/501' as const, userName: 'nobody' },
|
|
148
|
+
'only to the system domain',
|
|
149
|
+
],
|
|
150
|
+
['extraKeys naming a typed key', { extraKeys: { KeepAlive: true } }, 'extraKeys.KeepAlive'],
|
|
151
|
+
['extraKeys naming Program', { extraKeys: { Program: '/bin/x' } }, 'extraKeys.Program'],
|
|
152
|
+
['a secret-looking env var', { environment: { API_TOKEN: 'x' } }, 'unencrypted'],
|
|
153
|
+
])('refuses %s', (_name, patch, message) => {
|
|
154
|
+
expect(jobProblems({ ...base, ...patch } as LaunchdJobProps).join('\n')).toContain(message);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test.each(RESERVED_LABEL_PREFIXES.map((prefix) => [prefix]))(
|
|
158
|
+
'refuses labels under %s',
|
|
159
|
+
(prefix) => {
|
|
160
|
+
expect(jobProblems({ ...base, label: `${prefix}job` }).join('\n')).toContain(
|
|
161
|
+
'another tool owns',
|
|
162
|
+
);
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
test('reports every problem at once, so one plan shows the whole list', () => {
|
|
167
|
+
expect(
|
|
168
|
+
jobProblems({ ...base, label: '/x', programArguments: ['rel'], startInterval: -1 }).length,
|
|
169
|
+
).toBe(3);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchdJob's props, attributes and the pure half of the provider: domain parsing, the plist path,
|
|
3
|
+
* and the props → plist mapping whose SHA-256 is the whole diff.
|
|
4
|
+
*
|
|
5
|
+
* ★ KEY NAMES AND MEANINGS FROM launchd.plist(5), checked against the 148 nix-darwin daemons on a
|
|
6
|
+
* macOS 27.2 host on 2026-09-21 (read-only, from /nix/store): Label, ProgramArguments, RunAtLoad,
|
|
7
|
+
* KeepAlive (boolean or {SuccessfulExit}), ThrottleInterval, StandardOutPath/StandardErrorPath,
|
|
8
|
+
* EnvironmentVariables, UserName, StartInterval and StartCalendarInterval are the keys in real
|
|
9
|
+
* use. Everything rarer goes through `extraKeys`.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { type PlistDict, type PlistValue, renderPlist } from './plist.ts';
|
|
13
|
+
|
|
14
|
+
/** `system` (LaunchDaemons, root) or one user's login session, `gui/<uid>` (LaunchAgents). */
|
|
15
|
+
export type LaunchdDomain = 'system' | `gui/${number}`;
|
|
16
|
+
|
|
17
|
+
/** One StartCalendarInterval entry. An omitted field is a wildcard, as in cron. */
|
|
18
|
+
export type CalendarInterval = {
|
|
19
|
+
readonly minute?: number;
|
|
20
|
+
readonly hour?: number;
|
|
21
|
+
readonly day?: number;
|
|
22
|
+
/** 0 and 7 are both Sunday. */
|
|
23
|
+
readonly weekday?: number;
|
|
24
|
+
readonly month?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** `true` = always restart; the object form restarts only on the named conditions. */
|
|
28
|
+
export type KeepAlive = boolean | { readonly successfulExit?: boolean; readonly crashed?: boolean };
|
|
29
|
+
|
|
30
|
+
export interface LaunchdJobProps {
|
|
31
|
+
/** Reverse-DNS label. Unique per domain; also the plist's file name. */
|
|
32
|
+
label: string;
|
|
33
|
+
domain: LaunchdDomain;
|
|
34
|
+
/** argv; the first element must be an absolute path. */
|
|
35
|
+
programArguments: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* ⛔ NON-SECRET VALUES ONLY. Props are stored unencrypted in Alchemy state, and these also land in
|
|
38
|
+
* the plist on disk and in `launchctl print`. Pass a secret as a FILE PATH that a secret renderer
|
|
39
|
+
* (openbao-agent) maintains — secret-tripwire.ts refuses the obvious mistakes.
|
|
40
|
+
*/
|
|
41
|
+
environment?: Readonly<Record<string, string>>;
|
|
42
|
+
runAtLoad?: boolean;
|
|
43
|
+
keepAlive?: KeepAlive;
|
|
44
|
+
/** Seconds between starts. */
|
|
45
|
+
startInterval?: number;
|
|
46
|
+
startCalendarInterval?: CalendarInterval | readonly CalendarInterval[];
|
|
47
|
+
/** Minimum seconds between (re)starts; launchd's default is 10. */
|
|
48
|
+
throttleInterval?: number;
|
|
49
|
+
standardOutPath?: string;
|
|
50
|
+
standardErrorPath?: string;
|
|
51
|
+
workingDirectory?: string;
|
|
52
|
+
/** Run as this user. ⚠️ System domain only — launchd ignores it for agents, so it is refused. */
|
|
53
|
+
userName?: string;
|
|
54
|
+
/** Run as this group. ⚠️ System domain only, like `userName`. */
|
|
55
|
+
groupName?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Any other launchd.plist(5) key, verbatim (`ProcessType`, `SoftResourceLimits`, …).
|
|
58
|
+
* ⛔ A key the typed props own is refused, so one job can never say two things.
|
|
59
|
+
*/
|
|
60
|
+
extraKeys?: PlistDict;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LaunchdJobAttributes {
|
|
64
|
+
label: string;
|
|
65
|
+
domain: LaunchdDomain;
|
|
66
|
+
/** `<domain>/<label>` — what `launchctl print` / `bootout` take. */
|
|
67
|
+
serviceTarget: string;
|
|
68
|
+
plistPath: string;
|
|
69
|
+
/** SHA-256 of the plist bytes last bootstrapped (reconcile) or found on disk (read). */
|
|
70
|
+
plistSha256: string;
|
|
71
|
+
/** Bootstrapped into the domain — not necessarily running. */
|
|
72
|
+
loaded: boolean;
|
|
73
|
+
/** launchd's own word: `running`, `not running`, … */
|
|
74
|
+
state?: string;
|
|
75
|
+
pid?: number;
|
|
76
|
+
lastExitCode?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ParsedDomain =
|
|
80
|
+
| { readonly kind: 'system' }
|
|
81
|
+
| { readonly kind: 'gui'; readonly uid: number };
|
|
82
|
+
|
|
83
|
+
export const parseDomain = (domain: string): ParsedDomain | undefined => {
|
|
84
|
+
if (domain === 'system') return { kind: 'system' };
|
|
85
|
+
const match = /^gui\/(\d+)$/.exec(domain);
|
|
86
|
+
return match?.[1] === undefined ? undefined : { kind: 'gui', uid: Number(match[1]) };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const serviceTarget = (label: string, domain: LaunchdDomain): string => `${domain}/${label}`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where launchd expects the job's plist. ★ Derived, never declared: a daemon's plist outside
|
|
93
|
+
* /Library/LaunchDaemons is not bootstrapped at boot, so a free-form path would be a job that
|
|
94
|
+
* silently stops existing after the next restart.
|
|
95
|
+
*/
|
|
96
|
+
export const plistPathFor = (
|
|
97
|
+
label: string,
|
|
98
|
+
domain: ParsedDomain,
|
|
99
|
+
home: string | undefined,
|
|
100
|
+
): string => {
|
|
101
|
+
if (domain.kind === 'system') return `/Library/LaunchDaemons/${label}.plist`;
|
|
102
|
+
// ⛔ No `~` fallback: the provider may run as root, whose `~` is not the agent owner's home.
|
|
103
|
+
if (home === undefined || !home.startsWith('/')) {
|
|
104
|
+
throw new Error(`gui/${String(domain.uid)}: no home directory for that uid on this host`);
|
|
105
|
+
}
|
|
106
|
+
return `${home}/Library/LaunchAgents/${label}.plist`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Plist keys the typed props own — `extraKeys` may not name them. `Program` is refused too. */
|
|
110
|
+
export const OWNED_KEYS: readonly string[] = [
|
|
111
|
+
'EnvironmentVariables',
|
|
112
|
+
'GroupName',
|
|
113
|
+
'KeepAlive',
|
|
114
|
+
'Label',
|
|
115
|
+
'Program',
|
|
116
|
+
'ProgramArguments',
|
|
117
|
+
'RunAtLoad',
|
|
118
|
+
'StandardErrorPath',
|
|
119
|
+
'StandardOutPath',
|
|
120
|
+
'StartCalendarInterval',
|
|
121
|
+
'StartInterval',
|
|
122
|
+
'ThrottleInterval',
|
|
123
|
+
'UserName',
|
|
124
|
+
'WorkingDirectory',
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const calendarDict = (entry: CalendarInterval): PlistDict => {
|
|
128
|
+
const dict: Record<string, PlistValue> = {};
|
|
129
|
+
if (entry.minute !== undefined) dict['Minute'] = entry.minute;
|
|
130
|
+
if (entry.hour !== undefined) dict['Hour'] = entry.hour;
|
|
131
|
+
if (entry.day !== undefined) dict['Day'] = entry.day;
|
|
132
|
+
if (entry.weekday !== undefined) dict['Weekday'] = entry.weekday;
|
|
133
|
+
if (entry.month !== undefined) dict['Month'] = entry.month;
|
|
134
|
+
return dict;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const keepAliveValue = (keepAlive: KeepAlive): PlistValue => {
|
|
138
|
+
if (typeof keepAlive === 'boolean') return keepAlive;
|
|
139
|
+
const dict: Record<string, PlistValue> = {};
|
|
140
|
+
if (keepAlive.successfulExit !== undefined) dict['SuccessfulExit'] = keepAlive.successfulExit;
|
|
141
|
+
if (keepAlive.crashed !== undefined) dict['Crashed'] = keepAlive.crashed;
|
|
142
|
+
return dict;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The plist dict for a job. ⚠️ Assumes jobProblems() came back empty. */
|
|
146
|
+
export const jobPlistDict = (props: LaunchdJobProps): PlistDict => {
|
|
147
|
+
const dict: Record<string, PlistValue> = { ...props.extraKeys };
|
|
148
|
+
dict['Label'] = props.label;
|
|
149
|
+
dict['ProgramArguments'] = [...props.programArguments];
|
|
150
|
+
const env = props.environment ?? {};
|
|
151
|
+
if (Object.keys(env).length > 0) dict['EnvironmentVariables'] = { ...env };
|
|
152
|
+
if (props.runAtLoad !== undefined) dict['RunAtLoad'] = props.runAtLoad;
|
|
153
|
+
if (props.keepAlive !== undefined) dict['KeepAlive'] = keepAliveValue(props.keepAlive);
|
|
154
|
+
if (props.startInterval !== undefined) dict['StartInterval'] = props.startInterval;
|
|
155
|
+
const calendar = props.startCalendarInterval;
|
|
156
|
+
if (calendar !== undefined) {
|
|
157
|
+
dict['StartCalendarInterval'] = Array.isArray(calendar)
|
|
158
|
+
? (calendar as readonly CalendarInterval[]).map(calendarDict)
|
|
159
|
+
: calendarDict(calendar as CalendarInterval);
|
|
160
|
+
}
|
|
161
|
+
if (props.throttleInterval !== undefined) dict['ThrottleInterval'] = props.throttleInterval;
|
|
162
|
+
if (props.standardOutPath !== undefined) dict['StandardOutPath'] = props.standardOutPath;
|
|
163
|
+
if (props.standardErrorPath !== undefined) dict['StandardErrorPath'] = props.standardErrorPath;
|
|
164
|
+
if (props.workingDirectory !== undefined) dict['WorkingDirectory'] = props.workingDirectory;
|
|
165
|
+
if (props.userName !== undefined) dict['UserName'] = props.userName;
|
|
166
|
+
if (props.groupName !== undefined) dict['GroupName'] = props.groupName;
|
|
167
|
+
return dict;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const sha256Hex = (bytes: Uint8Array | string): string =>
|
|
171
|
+
createHash('sha256').update(bytes).digest('hex');
|
|
172
|
+
|
|
173
|
+
/** The rendered plist and its digest — the only thing LaunchdJob compares. */
|
|
174
|
+
export const renderJob = (
|
|
175
|
+
props: LaunchdJobProps,
|
|
176
|
+
): { text: string; bytes: Uint8Array; sha256: string } => {
|
|
177
|
+
const text = renderPlist(jobPlistDict(props));
|
|
178
|
+
const bytes = new TextEncoder().encode(text);
|
|
179
|
+
return { bytes, sha256: sha256Hex(bytes), text };
|
|
180
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchdJob's identity edges against the fake host: renames (a delete-first replace), jobs this
|
|
3
|
+
* resource does not own, a gui domain with no login session, and what a failure leaves behind.
|
|
4
|
+
*
|
|
5
|
+
* ★ Split from job-lifecycle.test.ts, which covers the ordinary create / update / delete path.
|
|
6
|
+
* Every test here is a way to end up with NOTHING running, or TWO copies running.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { fakeRunner } from './fake-runner.ts';
|
|
10
|
+
import type { LaunchdJobProps } from './job-form.ts';
|
|
11
|
+
import { deleteJob, diffJob, readJob, reconcileJob, replaceDiff } from './job-lifecycle.ts';
|
|
12
|
+
|
|
13
|
+
const DAEMON = '/Library/LaunchDaemons/com.example.job.plist';
|
|
14
|
+
const RENAMED = '/Library/LaunchDaemons/com.example.renamed.plist';
|
|
15
|
+
const AGENT = '/Users/someone/Library/LaunchAgents/com.example.job.plist';
|
|
16
|
+
|
|
17
|
+
const job: LaunchdJobProps = {
|
|
18
|
+
domain: 'system',
|
|
19
|
+
label: 'com.example.job',
|
|
20
|
+
programArguments: ['/usr/local/bin/job'],
|
|
21
|
+
};
|
|
22
|
+
const renamed: LaunchdJobProps = { ...job, label: 'com.example.renamed' };
|
|
23
|
+
|
|
24
|
+
const host = (euid = 0) =>
|
|
25
|
+
fakeRunner({
|
|
26
|
+
dirs: { '/Library/LaunchDaemons': 0, '/Users/someone/Library/LaunchAgents': 501 },
|
|
27
|
+
euid,
|
|
28
|
+
users: { someone: { gid: 20, home: '/Users/someone', uid: 501 } },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const verbs = (calls: string[][]) =>
|
|
32
|
+
calls
|
|
33
|
+
.map((call) => (call[0] === 'write' ? 'write' : call[1]))
|
|
34
|
+
.filter((verb) => verb !== 'print' && verb !== 'print-disabled');
|
|
35
|
+
|
|
36
|
+
describe('a rename is refused at PLAN time for anything its reconcile would refuse', () => {
|
|
37
|
+
// ★ The engine deletes the old job before reconciling the new one (deleteFirst), so each of
|
|
38
|
+
// these, caught only at reconcile, would leave nothing running.
|
|
39
|
+
test('an invalid or reserved new label', async () => {
|
|
40
|
+
const fake = host();
|
|
41
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
42
|
+
const bad = { ...job, label: 'org.nixos.job' };
|
|
43
|
+
await expect(diffJob(fake.runner, bad, output)).rejects.toThrow('another tool owns');
|
|
44
|
+
await expect(replaceDiff(fake.runner, bad, undefined, output)).rejects.toThrow('another tool');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a new domain the deployer may not write', async () => {
|
|
48
|
+
const fake = host(501);
|
|
49
|
+
const agent = { ...job, domain: 'gui/501' } as const;
|
|
50
|
+
const output = await reconcileJob(fake.runner, agent, undefined);
|
|
51
|
+
await expect(diffJob(fake.runner, job, output)).rejects.toThrow('needs root');
|
|
52
|
+
expect(fake.loaded.has('gui/501/com.example.job')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a disabled new label', async () => {
|
|
56
|
+
const fake = host();
|
|
57
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
58
|
+
fake.disabled.add('system/com.example.renamed');
|
|
59
|
+
await expect(diffJob(fake.runner, renamed, output)).rejects.toThrow('launchctl enable');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a new label someone else already loaded', async () => {
|
|
63
|
+
const fake = host();
|
|
64
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
65
|
+
fake.loaded.set('system/com.example.renamed', 77);
|
|
66
|
+
await expect(diffJob(fake.runner, renamed, output)).rejects.toThrow('not this resource');
|
|
67
|
+
expect(fake.loaded.get('system/com.example.renamed')).toBe(77);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a clean rename plans a delete-first replace', async () => {
|
|
71
|
+
const fake = host();
|
|
72
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
73
|
+
expect(await diffJob(fake.runner, renamed, output)).toEqual({
|
|
74
|
+
action: 'replace',
|
|
75
|
+
deleteFirst: true,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('a rename that reaches reconcile as an update', () => {
|
|
81
|
+
test('boots out and removes the old job BEFORE bootstrapping the new one', async () => {
|
|
82
|
+
const fake = host();
|
|
83
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
84
|
+
fake.calls.length = 0;
|
|
85
|
+
const next = await reconcileJob(fake.runner, renamed, output);
|
|
86
|
+
expect(verbs(fake.calls)).toEqual(['bootout', 'write', 'bootstrap']);
|
|
87
|
+
expect(fake.files.has(DAEMON)).toBe(false);
|
|
88
|
+
expect(fake.loaded.has('system/com.example.job')).toBe(false);
|
|
89
|
+
expect(next).toMatchObject({ loaded: true, plistPath: RENAMED });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('never boots out a job it does not own under the new label', async () => {
|
|
93
|
+
const fake = host();
|
|
94
|
+
const output = await reconcileJob(fake.runner, job, undefined);
|
|
95
|
+
fake.loaded.set('system/com.example.renamed', 77);
|
|
96
|
+
await expect(reconcileJob(fake.runner, renamed, output)).rejects.toThrow('not this resource');
|
|
97
|
+
expect(fake.loaded.has('system/com.example.job')).toBe(true);
|
|
98
|
+
expect(fake.loaded.get('system/com.example.renamed')).toBe(77);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('a job still running its OLD config is not mistaken for converged', () => {
|
|
103
|
+
test('write landed, bootout failed: the next deploy restarts it', async () => {
|
|
104
|
+
const fake = host();
|
|
105
|
+
const v1 = await reconcileJob(fake.runner, job, undefined);
|
|
106
|
+
fake.state.bootoutKeepsJob = true;
|
|
107
|
+
const v2 = { ...job, runAtLoad: true };
|
|
108
|
+
await expect(reconcileJob(fake.runner, v2, v1)).rejects.toThrow('bootout');
|
|
109
|
+
// The v2 plist is on disk and the v1 job is loaded: both look converged. Only state knows.
|
|
110
|
+
fake.state.bootoutKeepsJob = false;
|
|
111
|
+
fake.calls.length = 0;
|
|
112
|
+
const after = await reconcileJob(fake.runner, v2, v1);
|
|
113
|
+
expect(verbs(fake.calls)).toEqual(['write', 'bootout', 'bootstrap']);
|
|
114
|
+
expect(after.pid).not.toBe(v1.pid);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('a failed first bootstrap leaves nothing behind', () => {
|
|
119
|
+
test('the plist it wrote is removed, so the next plan does not find it Unowned', async () => {
|
|
120
|
+
const fake = host();
|
|
121
|
+
fake.state.bootstrapFailure = { exitCode: 5, stderr: 'Bootstrap failed: 5', stdout: '' };
|
|
122
|
+
await expect(reconcileJob(fake.runner, job, undefined)).rejects.toThrow('removed again');
|
|
123
|
+
expect(fake.files.has(DAEMON)).toBe(false);
|
|
124
|
+
expect(await readJob(fake.runner, job)).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('a gui domain with no login session', () => {
|
|
129
|
+
const agent = { ...job, domain: 'gui/501' } as const;
|
|
130
|
+
|
|
131
|
+
test('reconcile refuses before writing anything', async () => {
|
|
132
|
+
const fake = host(501);
|
|
133
|
+
fake.state.loggedOut.add(501);
|
|
134
|
+
await expect(reconcileJob(fake.runner, agent, undefined)).rejects.toThrow('no login session');
|
|
135
|
+
expect(fake.files.has(AGENT)).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('delete still removes the plist, so launchd cannot load it at the next login', async () => {
|
|
139
|
+
const fake = host(501);
|
|
140
|
+
const output = await reconcileJob(fake.runner, agent, undefined);
|
|
141
|
+
fake.loaded.clear();
|
|
142
|
+
fake.state.loggedOut.add(501);
|
|
143
|
+
await deleteJob(fake.runner, output);
|
|
144
|
+
expect(fake.files.has(AGENT)).toBe(false);
|
|
145
|
+
expect(await readJob(fake.runner, agent)).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
});
|