@homeflare/alchemy 0.4.1 → 0.6.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 +26 -0
- package/dist/index.d.ts +1 -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 +23 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/approle-login-form.d.ts +72 -0
- package/dist/openbao/approle-login-form.d.ts.map +1 -0
- package/dist/openbao/approle-login-result.d.ts +39 -0
- package/dist/openbao/approle-login-result.d.ts.map +1 -0
- package/dist/openbao/approle-login.d.ts +77 -0
- package/dist/openbao/approle-login.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-http.d.ts +2 -0
- package/dist/openbao/bao-http.d.ts.map +1 -1
- 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-roles-expand.d.ts.map +1 -1
- 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 +26 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1889 -477
- package/dist/openbao/index.js.map +46 -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 +18 -0
- package/dist/openbao/mount-path.d.ts.map +1 -0
- 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/plugin-form.d.ts +60 -0
- package/dist/openbao/plugin-form.d.ts.map +1 -0
- package/dist/openbao/plugin-reconcile.d.ts +18 -0
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -0
- package/dist/openbao/plugin-wire.d.ts +45 -0
- package/dist/openbao/plugin-wire.d.ts.map +1 -0
- package/dist/openbao/plugin.d.ts +31 -0
- package/dist/openbao/plugin.d.ts.map +1 -0
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.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/dist/openbao/ssh-role-form.d.ts +6 -5
- package/dist/openbao/ssh-role-form.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/launchd.md +167 -0
- package/package.json +7 -2
- package/src/index.ts +1 -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 +87 -0
- package/src/openbao/approle-login-form.test.ts +64 -0
- package/src/openbao/approle-login-form.ts +153 -0
- package/src/openbao/approle-login-result.ts +96 -0
- package/src/openbao/approle-login.test.ts +200 -0
- package/src/openbao/approle-login.ts +156 -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 +57 -0
- 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-http.ts +2 -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-roles-expand.ts +2 -5
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +32 -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 +33 -0
- package/src/openbao/mount-path.ts +31 -0
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount-wire.test.ts +13 -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/plugin-form.test.ts +86 -0
- package/src/openbao/plugin-form.ts +164 -0
- package/src/openbao/plugin-reconcile.ts +92 -0
- package/src/openbao/plugin-wire.test.ts +146 -0
- package/src/openbao/plugin-wire.ts +65 -0
- package/src/openbao/plugin.ts +100 -0
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/ssh-role-form.test.ts +105 -0
- package/src/openbao/ssh-role-form.ts +18 -16
- package/src/openbao/ssh-role.ts +3 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssh-role-form.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role-form.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ssh-role-form.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role-form.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,UAAU,IAAI,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAExD,MAAM,WAAW,eAAe;IAC9B,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,yEAAyE;IACzE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,sEAAsE;IACtE,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,qFAAqF;IACrF,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,4EAA4E;IAC5E,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrD,oFAAoF;IACpF,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C,0FAA0F;IAC1F,sBAAsB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1D,gFAAgF;IAChF,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,mFAAmF;IACnF,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,eAAe,CAAC,CAAC;AAEvD,sFAAsF;AACtF,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,KAAK,CAAC;IAClF;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,wFAAwF;IACxF,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,OAAO,EAAE,OAAO,EAAE,CAAC;AAEnB,eAAO,MAAM,QAAQ,UAAW,MAAM,GAAG,SAAS,QAAQ,MAAM,WAC7B,CAAC;AAMpC;;;;GAIG;AACH,eAAO,MAAM,OAAO,UAAW,OAAO,KAAG,SAAS,MAAM,EAOvD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,MAAM,UAAW,OAAO,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAM5D,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,OAAO,UAAW,eAAe,KAAG,cAiB/C,CAAC;AAEH,eAAO,MAAM,YAAY,SACjB,cAAc,QACd,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC5B,oBAqBF,CAAC;AAiBF;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,SAAU,cAAc,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAgBrE,CAAC;AAoBH;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,cAAc,KAAG,SAAS,MAAM,EACuB,CAAC;AAE3F;;;;;;GAMG;AACH,eAAO,MAAM,OAAO,eAAgB,oBAAoB,QAAQ,cAAc,YAe9B,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EAQrB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAyDH,eAAO,MAAM,kBAAkB,
|
|
1
|
+
{"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EAQrB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAyDH,eAAO,MAAM,kBAAkB,iGAqH5B,CAAC"}
|
package/docs/launchd.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# launchd — `@homeflare/alchemy/launchd`
|
|
2
|
+
|
|
3
|
+
Declare a Mac host with Alchemy: its launchd jobs (`LaunchdJob`) and the config files they read
|
|
4
|
+
(`HostFile`). launchd has no SDK, so the provider renders the plist itself and drives
|
|
5
|
+
`launchctl`. Every file read, file write and `launchctl` call goes through one injectable
|
|
6
|
+
`HostRunner`.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { HostFile, LaunchdJob, launchdProviders } from '@homeflare/alchemy/launchd';
|
|
11
|
+
|
|
12
|
+
export const exporter = Effect.gen(function* () {
|
|
13
|
+
const config = yield* HostFile('exporter-config', {
|
|
14
|
+
path: '/usr/local/etc/example-exporter.yml',
|
|
15
|
+
content: 'listen: 127.0.0.1:9100\n',
|
|
16
|
+
mode: 0o644,
|
|
17
|
+
owner: 'root',
|
|
18
|
+
group: 'wheel',
|
|
19
|
+
});
|
|
20
|
+
yield* LaunchdJob('exporter', {
|
|
21
|
+
label: 'com.example.exporter',
|
|
22
|
+
domain: 'system', // or 'gui/<uid>' for a LaunchAgent
|
|
23
|
+
// ★ config.path, not the literal: the Output orders the job after its file is written.
|
|
24
|
+
programArguments: ['/usr/local/bin/example-exporter', '--config', config.path],
|
|
25
|
+
environment: { TZ: 'UTC' }, // ⛔ non-secret values only
|
|
26
|
+
runAtLoad: true,
|
|
27
|
+
keepAlive: { successfulExit: false },
|
|
28
|
+
throttleInterval: 30,
|
|
29
|
+
standardOutPath: '/var/log/example-exporter.log',
|
|
30
|
+
standardErrorPath: '/var/log/example-exporter.log',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
// Provide launchdProviders() alongside the stack's other provider layers.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## LaunchdJob
|
|
37
|
+
|
|
38
|
+
| prop | plist key | notes |
|
|
39
|
+
| ----------------------------- | -------------------------------- | ------------------------------------------------------ |
|
|
40
|
+
| `label` | `Label` | also the file name; `[A-Za-z0-9._-]`, no `/` |
|
|
41
|
+
| `domain` | — | `system` (LaunchDaemons) or `gui/<uid>` (LaunchAgents) |
|
|
42
|
+
| `programArguments` | `ProgramArguments` | first element must be an absolute path |
|
|
43
|
+
| `environment` | `EnvironmentVariables` | ⛔ non-secret only (see below) |
|
|
44
|
+
| `runAtLoad` | `RunAtLoad` | |
|
|
45
|
+
| `keepAlive` | `KeepAlive` | `true`/`false` or `{ successfulExit, crashed }` |
|
|
46
|
+
| `startInterval` | `StartInterval` | seconds, > 0 |
|
|
47
|
+
| `startCalendarInterval` | `StartCalendarInterval` | one entry or a list; fields are range-checked |
|
|
48
|
+
| `throttleInterval` | `ThrottleInterval` | seconds, ≥ 0 |
|
|
49
|
+
| `standardOutPath` / `…Error…` | `StandardOutPath` / `…ErrorPath` | absolute |
|
|
50
|
+
| `workingDirectory` | `WorkingDirectory` | absolute |
|
|
51
|
+
| `userName` / `groupName` | `UserName` / `GroupName` | system domain only — launchd ignores them for agents |
|
|
52
|
+
| `extraKeys` | anything else, verbatim | may not repeat a key a typed prop owns |
|
|
53
|
+
|
|
54
|
+
The plist path is derived, never declared: `/Library/LaunchDaemons/<label>.plist` for `system`,
|
|
55
|
+
`<home of uid>/Library/LaunchAgents/<label>.plist` for `gui/<uid>`.
|
|
56
|
+
|
|
57
|
+
| step | what happens |
|
|
58
|
+
| ------- | --------------------------------------------------------------------------------------------------- |
|
|
59
|
+
| create | write the plist atomically (system: `root:wheel 0644`), then `launchctl bootstrap` |
|
|
60
|
+
| update | write, `bootout` the running job (and wait until launchd lets go), `bootstrap` — **a restart** |
|
|
61
|
+
| read | `launchctl print` (loaded? state, pid, last exit) plus the SHA-256 of the plist on disk |
|
|
62
|
+
| diff | the rendered plist's SHA-256 against the stored digest **and** the on-disk one, plus "is it loaded" |
|
|
63
|
+
| replace | only when `label` or `domain` changes — **delete first**, so two copies never run at once |
|
|
64
|
+
| delete | `bootout` if loaded, remove the plist; idempotent |
|
|
65
|
+
|
|
66
|
+
- A converged job is not restarted: a retried deploy that finds the right plist loaded does nothing.
|
|
67
|
+
- ⛔ **A rename is checked at plan time.** Alchemy deletes the old job before it creates the new
|
|
68
|
+
one, so everything the new one would be refused for (an invalid or reserved label, a domain you
|
|
69
|
+
may not write, a disabled label, a label another job already holds) fails the **plan**, with the
|
|
70
|
+
old job still running. A rename is seen even while other props are unresolved Outputs.
|
|
71
|
+
- ⚠️ A failed `bootstrap` leaves the job **down**. On an update the new plist stays on disk, the
|
|
72
|
+
state keeps the old digest, and the next deploy retries. On a first create the plist is removed
|
|
73
|
+
again, so the next plan does not find its own file and call it someone else's.
|
|
74
|
+
There is no rollback to an older plist.
|
|
75
|
+
- ⛔ A label disabled with `launchctl disable` is refused, not re-enabled: that was someone's
|
|
76
|
+
decision. The error names the `launchctl enable` command to run if it is stale.
|
|
77
|
+
- ⚠️ A `gui/<uid>` job needs that user logged in: without a login session the domain does not
|
|
78
|
+
exist (`launchctl print` exits 112, "Could not find domain"; measured). Deploying refuses before
|
|
79
|
+
it writes anything. Deleting still removes the plist, which stops launchd loading it at login.
|
|
80
|
+
- ⚠️ The digest covers the rendered bytes, so a kit upgrade that changes the renderer's output
|
|
81
|
+
restarts every declared job on the next deploy. Such a release says so in its changelog.
|
|
82
|
+
|
|
83
|
+
## HostFile
|
|
84
|
+
|
|
85
|
+
`path` (absolute, normalised), `content` (UTF-8 text), `mode` (default `0o644`), `owner` and
|
|
86
|
+
`group` (names or ids). Written as a temp file in the same directory — mode and owner set before
|
|
87
|
+
it becomes visible — then renamed over the path. Read back after every write. Diffed by SHA-256,
|
|
88
|
+
mode and owner. A new `path` is a create-before-delete replace. Delete removes the file.
|
|
89
|
+
|
|
90
|
+
- ⛔ A different file already at a new `path` is refused, not overwritten: the adoption probe
|
|
91
|
+
never looks at a replace's new path, so a typo there would otherwise overwrite a system file.
|
|
92
|
+
|
|
93
|
+
- ⛔ A symlink or directory at the path is refused, never replaced: rename over a symlink replaces
|
|
94
|
+
the link, which takes the path from whatever tool owns it (nix-darwin's `/etc` entries point
|
|
95
|
+
into `/nix/store`).
|
|
96
|
+
- ⚠️ The parent directory must exist. Creating it would invent an owner and mode nobody declared.
|
|
97
|
+
|
|
98
|
+
## ⛔ No secrets in props
|
|
99
|
+
|
|
100
|
+
Alchemy stores props and attributes **unencrypted** in its state store. A job's `environment`,
|
|
101
|
+
`programArguments` and `extraKeys`, and a file's `content`, land there, in every plan diff, and on
|
|
102
|
+
disk.
|
|
103
|
+
|
|
104
|
+
**Keep secret files rendered by a secret renderer** — `openbao-agent` templates on a HomeFlare
|
|
105
|
+
host — and declare only the path: `FOO_TOKEN_FILE=/path`, `--token-file /path`. A tripwire refuses
|
|
106
|
+
the obvious mistakes (a `*_TOKEN`/`*_PASSWORD`-style variable, `--token=…`, a PEM private key); it
|
|
107
|
+
is not a scanner, and passing it does not make a value safe to declare.
|
|
108
|
+
|
|
109
|
+
## ⛔ No silent sudo
|
|
110
|
+
|
|
111
|
+
The `system` domain, and another user's `gui/<uid>`, need root. The provider refuses up front
|
|
112
|
+
unless the runner is root (`effectiveUid() === 0`) or was deliberately built with
|
|
113
|
+
`privileged: true`. `localRunner()` — the default — is never privileged and never calls `sudo`, so:
|
|
114
|
+
|
|
115
|
+
- **system daemons:** start the whole deploy as root; or pass `launchdProviders(yourRunner)` where
|
|
116
|
+
`yourRunner` is a `HostRunner` you wrote that already holds root (a root helper, say).
|
|
117
|
+
- **your own agents** (`gui/<your uid>`): no root needed.
|
|
118
|
+
|
|
119
|
+
Reading (`launchctl print`, `print-disabled`) needs no root. ⚠️ A `HostFile` whose mode hides it
|
|
120
|
+
from the planning user (a root-owned `0600`, say) fails the plan with `EACCES`: plan as the user
|
|
121
|
+
you deploy as.
|
|
122
|
+
|
|
123
|
+
## Adoption: nothing is taken over silently
|
|
124
|
+
|
|
125
|
+
`read` with no prior state reports anything it finds — a loaded label, a plist, a file — as
|
|
126
|
+
`Unowned`, so Alchemy refuses to take it over without `--adopt`.
|
|
127
|
+
|
|
128
|
+
Labels under `org.nixos.`, `com.apple.` and `homebrew.mxcl.` are **refused outright**. nix-darwin
|
|
129
|
+
rewrites its plists on every activation and unloads any it no longer lists, macOS owns
|
|
130
|
+
`com.apple.*`, and `brew services` owns `homebrew.mxcl.*`. Adopting one would give a file two
|
|
131
|
+
owners. Move the job instead.
|
|
132
|
+
|
|
133
|
+
## Cutover from nix-darwin
|
|
134
|
+
|
|
135
|
+
Nix-managed `org.nixos.*` jobs are **not adopted**. The host stack declares a **new** label, and the
|
|
136
|
+
Nix job is booted out first. Per job:
|
|
137
|
+
|
|
138
|
+
1. **Declare** the replacement in the host stack under a new label (`com.example.<job>`), with its
|
|
139
|
+
config as `HostFile`s. ⚠️ Nothing it references may live in `/nix/store` — the next garbage
|
|
140
|
+
collection deletes it. Plan it: expect creates only.
|
|
141
|
+
2. **Remove the job from the nix-darwin config and activate.** Activation unloads and deletes every
|
|
142
|
+
daemon it no longer lists — checked 2026-09-21 in a nix-darwin activation script: it prints
|
|
143
|
+
`removing service <label>`, runs `launchctl unload`, then removes the plist. Confirm with
|
|
144
|
+
`launchctl print system/org.nixos.<job>` → exit 113 ("Could not find service").
|
|
145
|
+
3. **Deploy** the host stack. The new label bootstraps.
|
|
146
|
+
4. **Verify** `launchctl print system/com.example.<job>` shows `state = running`, then check the
|
|
147
|
+
service itself (port, metrics, logs).
|
|
148
|
+
|
|
149
|
+
- ★ **Why this order.** Both jobs usually bind the same port and write the same files; the old one
|
|
150
|
+
must be gone before the new one starts. Downtime is one deploy.
|
|
151
|
+
- ⚠️ **`sudo launchctl bootout system/org.nixos.<job>` is not a cutover.** The plist stays in
|
|
152
|
+
`/Library/LaunchDaemons`, so launchd loads it again at the next boot; and activation (checked in
|
|
153
|
+
the same script) copies back and `load -w`s any Nix plist that is missing or differs from the
|
|
154
|
+
store. Either way two copies end up fighting over the port.
|
|
155
|
+
- ⚠️ **Check the host's own activation for labels it removes.** An activation script can boot out,
|
|
156
|
+
`disable` and delete jobs by name on every run; a new label that collides with one is disabled
|
|
157
|
+
under you. `grep -n 'bootout\|disable' /run/current-system/activate` before choosing labels.
|
|
158
|
+
- **Rolling back** is the same three steps reversed: destroy (or remove) the resource, restore the
|
|
159
|
+
job in the Nix config, activate.
|
|
160
|
+
|
|
161
|
+
## The runner
|
|
162
|
+
|
|
163
|
+
`HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
|
|
164
|
+
`stat` (lstat), `writeFileAtomic`, `removeFile`, `lookupUser`, `lookupGroup`, `sleep`, plus
|
|
165
|
+
`privileged` and `effectiveUid()`. `localRunner()` implements it with `node:fs` and
|
|
166
|
+
`node:child_process`; tests use an in-memory fake, so nothing in the test suite runs `launchctl` or
|
|
167
|
+
writes outside a temp directory.
|
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 and
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos and launchd.",
|
|
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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
|
+
"docs",
|
|
16
17
|
"src",
|
|
17
18
|
"README.md",
|
|
18
19
|
"LICENSE"
|
|
@@ -44,6 +45,10 @@
|
|
|
44
45
|
"types": "./dist/proxmox/index.d.ts",
|
|
45
46
|
"default": "./dist/proxmox/index.js"
|
|
46
47
|
},
|
|
48
|
+
"./launchd": {
|
|
49
|
+
"types": "./dist/launchd/index.d.ts",
|
|
50
|
+
"default": "./dist/launchd/index.js"
|
|
51
|
+
},
|
|
47
52
|
"./package.json": "./package.json"
|
|
48
53
|
},
|
|
49
54
|
"publishConfig": {
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* import { BaoMount } from '@homeflare/alchemy/openbao';
|
|
10
10
|
* import { TalosCluster } from '@homeflare/alchemy/talos';
|
|
11
11
|
* import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
|
|
12
|
+
* import { LaunchdJob } from '@homeflare/alchemy/launchd';
|
|
12
13
|
*
|
|
13
14
|
* ★ WHY THESE EXIST AT ALL. When Alchemy has no resource for something, the alternative
|
|
14
15
|
* is a runbook step a human runs once — and a plan can never show a missing runbook
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory host for the tests beside it: a filesystem in a Map and a scripted launchd.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ TEST-ONLY. No provider imports this file, it is not on the barrel, and it never touches the
|
|
5
|
+
* real filesystem or runs a real program — which is the only way the lifecycle tests can prove
|
|
6
|
+
* "bootout before bootstrap" without writing /Library or loading a job.
|
|
7
|
+
* ★ IT MODELS THE BEHAVIOURS THE PROVIDER DEPENDS ON, as launchctl.ts documents them: `print` of an
|
|
8
|
+
* unknown target exits 113; bootstrap of a loaded or disabled label fails; bootout of an absent
|
|
9
|
+
* job exits non-zero; a job can linger after bootout (`lingerPrints`); a gui uid with no login
|
|
10
|
+
* session answers 112 (`loggedOut`); writes into a root-owned directory, or a chown to another
|
|
11
|
+
* uid, need root.
|
|
12
|
+
*/
|
|
13
|
+
import { LAUNCHCTL, NOT_FOUND, NO_DOMAIN } from './launchctl.ts';
|
|
14
|
+
import type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
|
|
15
|
+
|
|
16
|
+
type Entry = { bytes: Uint8Array; mode: number; uid: number; gid: number; kind: FileStat['kind'] };
|
|
17
|
+
|
|
18
|
+
export type FakeOptions = {
|
|
19
|
+
euid?: number;
|
|
20
|
+
privileged?: boolean;
|
|
21
|
+
/** Directories that exist, and their owner uid. */
|
|
22
|
+
dirs?: Record<string, number>;
|
|
23
|
+
users?: Record<string, HostUser>;
|
|
24
|
+
groups?: Record<string, number>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
|
|
28
|
+
const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
|
|
29
|
+
const errno = (code: string, path: string) =>
|
|
30
|
+
Object.assign(new Error(`${code}: ${path}`), { code });
|
|
31
|
+
const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
|
|
32
|
+
|
|
33
|
+
/** The shape `launchctl print` produced on macOS 27.2 (see launchctl.ts), trimmed. */
|
|
34
|
+
const printBlock = (target: string, pid: number | undefined, lastExit: string) =>
|
|
35
|
+
[
|
|
36
|
+
`${target} = {`,
|
|
37
|
+
'\tactive count = 1',
|
|
38
|
+
`\tstate = ${pid === undefined ? 'not running' : 'running'}`,
|
|
39
|
+
'\targuments = {',
|
|
40
|
+
'\t\t/bin/sh',
|
|
41
|
+
'}',
|
|
42
|
+
'\t}',
|
|
43
|
+
'\tresource coalition = {',
|
|
44
|
+
'\t\tstate = active',
|
|
45
|
+
'\t}',
|
|
46
|
+
...(pid === undefined ? [] : [`\tpid = ${String(pid)}`]),
|
|
47
|
+
`\tlast exit code = ${lastExit}`,
|
|
48
|
+
'}',
|
|
49
|
+
].join('\n');
|
|
50
|
+
|
|
51
|
+
export const fakeRunner = (options: FakeOptions = {}) => {
|
|
52
|
+
const euid = options.euid ?? 501;
|
|
53
|
+
const privileged = options.privileged ?? false;
|
|
54
|
+
const root = privileged || euid === 0;
|
|
55
|
+
const dirs = new Map(Object.entries(options.dirs ?? {}));
|
|
56
|
+
const files = new Map<string, Entry>();
|
|
57
|
+
const calls: string[][] = [];
|
|
58
|
+
/** target → pid (undefined = loaded, not running). */
|
|
59
|
+
const loaded = new Map<string, number | undefined>();
|
|
60
|
+
const disabled = new Set<string>();
|
|
61
|
+
const state = {
|
|
62
|
+
lingerPrints: 0,
|
|
63
|
+
nextPid: 4000,
|
|
64
|
+
bootstrapFailure: undefined as ExecResult | undefined,
|
|
65
|
+
/** Returned by bootout in place of success. The job still goes, unless `bootoutKeepsJob`. */
|
|
66
|
+
bootoutFailure: undefined as ExecResult | undefined,
|
|
67
|
+
bootoutKeepsJob: false,
|
|
68
|
+
/** gui uids with no login session: every launchctl call into `gui/<uid>` exits 112. */
|
|
69
|
+
loggedOut: new Set<number>(),
|
|
70
|
+
};
|
|
71
|
+
/** Booted-out targets launchd has not let go of yet → prints left before they vanish. */
|
|
72
|
+
const lingering = new Map<string, number>();
|
|
73
|
+
|
|
74
|
+
const labelIn = (path: string) =>
|
|
75
|
+
/<key>Label<\/key>\s*<string>([^<]*)<\/string>/.exec(
|
|
76
|
+
new TextDecoder().decode(files.get(path)?.bytes),
|
|
77
|
+
)?.[1];
|
|
78
|
+
|
|
79
|
+
const launchctl = (args: readonly string[]): ExecResult => {
|
|
80
|
+
const [sub, first, second] = args;
|
|
81
|
+
const gui = /^gui\/(\d+)(?:\/|$)/.exec(first ?? '')?.[1];
|
|
82
|
+
if (gui !== undefined && state.loggedOut.has(Number(gui))) {
|
|
83
|
+
// The measured shape (launchctl.ts): 112, "Bad request." then the domain line.
|
|
84
|
+
return fail(NO_DOMAIN, `Bad request.\nCould not find domain for user gui: ${gui}`);
|
|
85
|
+
}
|
|
86
|
+
if (sub === 'print' && first !== undefined) {
|
|
87
|
+
const left = lingering.get(first);
|
|
88
|
+
if (left !== undefined) {
|
|
89
|
+
if (left <= 0) {
|
|
90
|
+
lingering.delete(first);
|
|
91
|
+
loaded.delete(first);
|
|
92
|
+
} else lingering.set(first, left - 1);
|
|
93
|
+
}
|
|
94
|
+
if (!loaded.has(first)) return fail(NOT_FOUND, `Could not find service "${first}"`);
|
|
95
|
+
return ok(printBlock(first, loaded.get(first), '(never exited)'));
|
|
96
|
+
}
|
|
97
|
+
if (sub === 'print-disabled' && first !== undefined) {
|
|
98
|
+
const lines = [...disabled]
|
|
99
|
+
.filter((target) => target.startsWith(`${first}/`))
|
|
100
|
+
.map((target) => `\t\t"${target.slice(first.length + 1)}" => disabled`);
|
|
101
|
+
return ok(['\tdisabled services = {', ...lines, '\t}'].join('\n'));
|
|
102
|
+
}
|
|
103
|
+
if (sub === 'bootstrap' && first !== undefined && second !== undefined) {
|
|
104
|
+
if (state.bootstrapFailure !== undefined) return state.bootstrapFailure;
|
|
105
|
+
const label = labelIn(second);
|
|
106
|
+
if (label === undefined) return fail(5, 'Bootstrap failed: 5: Input/output error');
|
|
107
|
+
const target = `${first}/${label}`;
|
|
108
|
+
if (loaded.has(target) || disabled.has(target))
|
|
109
|
+
return fail(5, 'Bootstrap failed: 5: Input/output error');
|
|
110
|
+
state.nextPid += 1;
|
|
111
|
+
loaded.set(target, state.nextPid);
|
|
112
|
+
return ok();
|
|
113
|
+
}
|
|
114
|
+
if (sub === 'bootout' && first !== undefined) {
|
|
115
|
+
if (!loaded.has(first) || lingering.has(first))
|
|
116
|
+
return fail(3, 'Boot-out failed: 3: No such process');
|
|
117
|
+
if (state.bootoutKeepsJob) return state.bootoutFailure ?? fail(5, 'Boot-out failed: 5');
|
|
118
|
+
// ⚠️ Model launchd letting go late: the job stays visible for `lingerPrints` more prints.
|
|
119
|
+
if (state.lingerPrints === 0) loaded.delete(first);
|
|
120
|
+
else lingering.set(first, state.lingerPrints);
|
|
121
|
+
return state.bootoutFailure ?? ok();
|
|
122
|
+
}
|
|
123
|
+
return fail(64, `fake launchctl: unsupported ${args.join(' ')}`);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const runner: HostRunner = {
|
|
127
|
+
effectiveUid: () => euid,
|
|
128
|
+
exec: async (argv) => {
|
|
129
|
+
calls.push([...argv]);
|
|
130
|
+
if (argv[0] === LAUNCHCTL) return launchctl(argv.slice(1));
|
|
131
|
+
return fail(127, `fake: no program ${String(argv[0])}`);
|
|
132
|
+
},
|
|
133
|
+
lookupGroup: async (nameOrId) => options.groups?.[nameOrId],
|
|
134
|
+
lookupUser: async (nameOrId) =>
|
|
135
|
+
options.users?.[nameOrId] ??
|
|
136
|
+
Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
|
|
137
|
+
privileged,
|
|
138
|
+
readFile: async (path) => files.get(path)?.bytes,
|
|
139
|
+
removeFile: async (path) => {
|
|
140
|
+
if (files.has(path) && (dirs.get(parent(path)) ?? 0) !== euid && !root)
|
|
141
|
+
throw errno('EACCES', path);
|
|
142
|
+
files.delete(path);
|
|
143
|
+
},
|
|
144
|
+
// ★ No real waiting: a lingering job drains one `print` per poll instead.
|
|
145
|
+
sleep: async () => undefined,
|
|
146
|
+
stat: async (path) => {
|
|
147
|
+
const entry = files.get(path);
|
|
148
|
+
if (entry !== undefined)
|
|
149
|
+
return {
|
|
150
|
+
gid: entry.gid,
|
|
151
|
+
kind: entry.kind,
|
|
152
|
+
mode: entry.mode,
|
|
153
|
+
size: entry.bytes.length,
|
|
154
|
+
uid: entry.uid,
|
|
155
|
+
};
|
|
156
|
+
return dirs.has(path)
|
|
157
|
+
? { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 }
|
|
158
|
+
: undefined;
|
|
159
|
+
},
|
|
160
|
+
writeFileAtomic: async (path: string, bytes: Uint8Array, write: WriteOptions) => {
|
|
161
|
+
calls.push(['write', path]);
|
|
162
|
+
const owner = dirs.get(parent(path));
|
|
163
|
+
if (owner === undefined) throw errno('ENOENT', parent(path));
|
|
164
|
+
if (owner !== euid && !root) throw errno('EACCES', path);
|
|
165
|
+
if (write.uid !== undefined && write.uid !== euid && !root) throw errno('EPERM', path);
|
|
166
|
+
files.set(path, {
|
|
167
|
+
bytes,
|
|
168
|
+
gid: write.gid ?? 20,
|
|
169
|
+
kind: 'file',
|
|
170
|
+
mode: write.mode,
|
|
171
|
+
uid: write.uid ?? euid,
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
return { calls, disabled, dirs, files, loaded, runner, state };
|
|
176
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
|
|
3
|
+
* `news` both providers' diffs share.
|
|
4
|
+
*
|
|
5
|
+
* ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
|
|
6
|
+
* runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
|
|
7
|
+
* ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
|
|
8
|
+
* thrown is wrapped, so the failure channel is always an Error with a readable message.
|
|
9
|
+
*/
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
|
|
12
|
+
export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
|
|
13
|
+
Effect.tryPromise({
|
|
14
|
+
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
15
|
+
try: step,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A string prop of a diff's `news`, when that one prop is already resolved; `undefined` while it
|
|
20
|
+
* is still an Output (a function-typed proxy), an Effect or a Config.
|
|
21
|
+
* ★ For the identity props a diff must see even when the rest of `news` is unresolved — a plain
|
|
22
|
+
* string is resolved by definition, so `typeof` is the whole test.
|
|
23
|
+
*/
|
|
24
|
+
export const resolvedString = (news: unknown, key: string): string | undefined => {
|
|
25
|
+
if (typeof news !== 'object' || news === null) return undefined;
|
|
26
|
+
const value = (news as Record<string, unknown>)[key];
|
|
27
|
+
return typeof value === 'string' ? value : undefined;
|
|
28
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HostFile's props, attributes and validation — the pure half of the provider.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ `content` IS NEVER A SECRET. Alchemy stores props unencrypted, so the content of every HostFile
|
|
5
|
+
* sits in the state store and in every plan diff. A secret file stays RENDERED BY A SECRET
|
|
6
|
+
* RENDERER (openbao-agent templates, on this estate) that fetches it at run time; the stack
|
|
7
|
+
* declares only non-secret config, and at most the PATH the renderer writes to.
|
|
8
|
+
* secret-tripwire.ts refuses the obvious case (a PEM private key); nothing can catch all of them.
|
|
9
|
+
*/
|
|
10
|
+
import { fileSecretProblems } from './secret-tripwire.ts';
|
|
11
|
+
|
|
12
|
+
export interface HostFileProps {
|
|
13
|
+
/** Absolute path. The parent directory must already exist. */
|
|
14
|
+
path: string;
|
|
15
|
+
/** UTF-8 text. ⛔ Never a secret — see the file header. */
|
|
16
|
+
content: string;
|
|
17
|
+
/** Permission bits. @default 0o644 */
|
|
18
|
+
mode?: number;
|
|
19
|
+
/** User name or numeric uid. Omitted: whoever the runner writes as (a rewrite keeps no owner). */
|
|
20
|
+
owner?: string | number;
|
|
21
|
+
/** Group name or numeric gid. Omitted: the directory's default group. */
|
|
22
|
+
group?: string | number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HostFileAttributes {
|
|
26
|
+
path: string;
|
|
27
|
+
/** SHA-256 of the bytes on disk. */
|
|
28
|
+
sha256: string;
|
|
29
|
+
mode: number;
|
|
30
|
+
uid: number;
|
|
31
|
+
gid: number;
|
|
32
|
+
size: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_MODE = 0o644;
|
|
36
|
+
|
|
37
|
+
const identityProblems = (name: string, value: string | number | undefined): string[] => {
|
|
38
|
+
if (value === undefined) return [];
|
|
39
|
+
if (typeof value === 'number') {
|
|
40
|
+
return Number.isSafeInteger(value) && value >= 0
|
|
41
|
+
? []
|
|
42
|
+
: [`${name} must be a non-negative integer id`];
|
|
43
|
+
}
|
|
44
|
+
return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value) || /^\d+$/.test(value)
|
|
45
|
+
? []
|
|
46
|
+
: [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const fileProblems = (props: HostFileProps): string[] => {
|
|
50
|
+
const found: string[] = [];
|
|
51
|
+
const { path } = props;
|
|
52
|
+
if (!path.startsWith('/')) found.push('path must be absolute');
|
|
53
|
+
// ★ One spelling per file: `a/./b`, `a//b` and `a/../b` would each be a second resource for the
|
|
54
|
+
// same inode, and `..` is how a derived path walks out of the directory it was meant for.
|
|
55
|
+
if (
|
|
56
|
+
path.endsWith('/') ||
|
|
57
|
+
path
|
|
58
|
+
.split('/')
|
|
59
|
+
.slice(1)
|
|
60
|
+
.some((part) => part === '' || part === '.' || part === '..')
|
|
61
|
+
) {
|
|
62
|
+
found.push('path must be normalised (no trailing slash, no empty, "." or ".." segments)');
|
|
63
|
+
}
|
|
64
|
+
if (path.includes('\x00')) found.push('path contains NUL');
|
|
65
|
+
const mode = props.mode ?? DEFAULT_MODE;
|
|
66
|
+
if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) found.push('mode must be 0–0o7777');
|
|
67
|
+
found.push(...identityProblems('owner', props.owner));
|
|
68
|
+
found.push(...identityProblems('group', props.group));
|
|
69
|
+
found.push(...fileSecretProblems(props.content));
|
|
70
|
+
return found;
|
|
71
|
+
};
|