@homeflare/alchemy 0.8.0 → 0.10.1

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.
Files changed (247) hide show
  1. package/README.md +22 -11
  2. package/dist/caddy/config-lifecycle.d.ts +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +221 -53
  5. package/dist/caddy/index.js.map +13 -9
  6. package/dist/launchd/fake-runner.d.ts +1 -0
  7. package/dist/launchd/fake-runner.d.ts.map +1 -1
  8. package/dist/launchd/fake-sudo.d.ts +1 -0
  9. package/dist/launchd/fake-sudo.d.ts.map +1 -1
  10. package/dist/launchd/host-effect.d.ts +9 -18
  11. package/dist/launchd/host-effect.d.ts.map +1 -1
  12. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  13. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  14. package/dist/launchd/host-file.d.ts +2 -1
  15. package/dist/launchd/host-file.d.ts.map +1 -1
  16. package/dist/launchd/index.js +345 -86
  17. package/dist/launchd/index.js.map +18 -12
  18. package/dist/launchd/job-lifecycle.d.ts +8 -1
  19. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  20. package/dist/launchd/job-preflight.d.ts +13 -1
  21. package/dist/launchd/job-preflight.d.ts.map +1 -1
  22. package/dist/launchd/job.d.ts +2 -1
  23. package/dist/launchd/job.d.ts.map +1 -1
  24. package/dist/launchd/runner.d.ts +7 -0
  25. package/dist/launchd/runner.d.ts.map +1 -1
  26. package/dist/launchd/sudo-acl.d.ts +25 -0
  27. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  28. package/dist/launchd/sudo-guard.d.ts +31 -4
  29. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  30. package/dist/launchd/sudo-runner.d.ts +3 -3
  31. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-said.d.ts +10 -0
  33. package/dist/launchd/sudo-said.d.ts.map +1 -0
  34. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  35. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  36. package/dist/openbao/auth-method.d.ts.map +1 -1
  37. package/dist/openbao/auth-role.d.ts.map +1 -1
  38. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  39. package/dist/openbao/fake-bao.d.ts +18 -0
  40. package/dist/openbao/fake-bao.d.ts.map +1 -1
  41. package/dist/openbao/fake-core.d.ts +39 -0
  42. package/dist/openbao/fake-core.d.ts.map +1 -0
  43. package/dist/openbao/fake-families.d.ts +6 -3
  44. package/dist/openbao/fake-families.d.ts.map +1 -1
  45. package/dist/openbao/fake-mounts.d.ts +16 -0
  46. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  47. package/dist/openbao/fake-stack.d.ts +5 -1
  48. package/dist/openbao/fake-stack.d.ts.map +1 -1
  49. package/dist/openbao/index.js +541 -270
  50. package/dist/openbao/index.js.map +26 -21
  51. package/dist/openbao/jwt-config.d.ts.map +1 -1
  52. package/dist/openbao/jwt-role.d.ts.map +1 -1
  53. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  54. package/dist/openbao/mfa-enforcement.d.ts +6 -2
  55. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  56. package/dist/openbao/mfa-totp.d.ts +6 -2
  57. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  58. package/dist/openbao/mount-reconcile.d.ts +5 -1
  59. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  60. package/dist/openbao/mount.d.ts.map +1 -1
  61. package/dist/openbao/pki-role.d.ts.map +1 -1
  62. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  63. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  64. package/dist/openbao/plugin.d.ts.map +1 -1
  65. package/dist/openbao/policy.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  67. package/dist/openbao/role-reconcile.d.ts +11 -1
  68. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  69. package/dist/openbao/ssh-role.d.ts.map +1 -1
  70. package/dist/ownership/adopt.d.ts +52 -0
  71. package/dist/ownership/adopt.d.ts.map +1 -0
  72. package/dist/ownership/probe.d.ts +13 -0
  73. package/dist/ownership/probe.d.ts.map +1 -0
  74. package/dist/ownership/resume.d.ts +40 -0
  75. package/dist/ownership/resume.d.ts.map +1 -0
  76. package/dist/ownership/rows.d.ts +33 -0
  77. package/dist/ownership/rows.d.ts.map +1 -0
  78. package/dist/ownership/whole.d.ts +14 -0
  79. package/dist/ownership/whole.d.ts.map +1 -0
  80. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  81. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  82. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  83. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  84. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  85. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  86. package/dist/proxmox/fake-pve.d.ts +38 -0
  87. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  88. package/dist/proxmox/index.d.ts +6 -2
  89. package/dist/proxmox/index.d.ts.map +1 -1
  90. package/dist/proxmox/index.js +970 -186
  91. package/dist/proxmox/index.js.map +22 -9
  92. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  93. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  94. package/dist/proxmox/lxc-harness.d.ts +34 -0
  95. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  96. package/dist/proxmox/lxc-identity.d.ts +18 -0
  97. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  98. package/dist/proxmox/lxc-judge.d.ts +46 -0
  99. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  100. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  101. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  102. package/dist/proxmox/lxc-props.d.ts +139 -0
  103. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  104. package/dist/proxmox/lxc-volume.d.ts +29 -0
  105. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  106. package/dist/proxmox/lxc-wire.d.ts +51 -0
  107. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  108. package/dist/proxmox/lxc.d.ts +27 -47
  109. package/dist/proxmox/lxc.d.ts.map +1 -1
  110. package/dist/proxmox/network-apply-read.d.ts +1 -1
  111. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  112. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  113. package/dist/proxmox/resource.d.ts.map +1 -1
  114. package/dist/proxmox/update-guard.d.ts +27 -0
  115. package/dist/proxmox/update-guard.d.ts.map +1 -0
  116. package/dist/verify/args.d.ts +16 -0
  117. package/dist/verify/args.d.ts.map +1 -0
  118. package/dist/verify/cli.d.ts +3 -0
  119. package/dist/verify/cli.d.ts.map +1 -0
  120. package/dist/verify/cli.js +370 -0
  121. package/dist/verify/cli.js.map +17 -0
  122. package/dist/verify/fake-engine.d.ts +65 -0
  123. package/dist/verify/fake-engine.d.ts.map +1 -0
  124. package/dist/verify/index.d.ts +16 -0
  125. package/dist/verify/index.d.ts.map +1 -0
  126. package/dist/verify/index.js +347 -0
  127. package/dist/verify/index.js.map +16 -0
  128. package/dist/verify/plan-scope.d.ts +14 -0
  129. package/dist/verify/plan-scope.d.ts.map +1 -0
  130. package/dist/verify/recheck.d.ts +42 -0
  131. package/dist/verify/recheck.d.ts.map +1 -0
  132. package/dist/verify/report.d.ts +6 -0
  133. package/dist/verify/report.d.ts.map +1 -0
  134. package/dist/verify/rows.d.ts +67 -0
  135. package/dist/verify/rows.d.ts.map +1 -0
  136. package/dist/verify/spy.d.ts +43 -0
  137. package/dist/verify/spy.d.ts.map +1 -0
  138. package/dist/verify/verify.d.ts +43 -0
  139. package/dist/verify/verify.d.ts.map +1 -0
  140. package/docs/adopt-verify.md +119 -0
  141. package/docs/adopted-deploys.md +85 -0
  142. package/docs/credentials.md +15 -0
  143. package/docs/launchd-sudo.md +31 -6
  144. package/docs/launchd.md +7 -3
  145. package/docs/ownership.md +113 -0
  146. package/docs/proxmox-lxc.md +196 -0
  147. package/docs/proxmox.md +12 -0
  148. package/package.json +9 -2
  149. package/src/caddy/adopt-scope.test.ts +1 -1
  150. package/src/caddy/config-lifecycle.ts +1 -1
  151. package/src/caddy/config.ts +2 -1
  152. package/src/launchd/adopt-apply.test.ts +166 -0
  153. package/src/launchd/fake-runner.ts +26 -4
  154. package/src/launchd/host-effect.ts +1 -33
  155. package/src/launchd/host-file-lifecycle.ts +39 -14
  156. package/src/launchd/host-file.ts +13 -4
  157. package/src/launchd/job-lifecycle.ts +22 -14
  158. package/src/launchd/job-preflight.ts +18 -1
  159. package/src/launchd/job.ts +13 -4
  160. package/src/launchd/runner.ts +7 -0
  161. package/src/launchd/sudo-acl.ts +68 -0
  162. package/src/launchd/sudo-chain.test.ts +121 -0
  163. package/src/launchd/sudo-guard.ts +89 -10
  164. package/src/launchd/sudo-modes.test.ts +135 -0
  165. package/src/launchd/sudo-runner.ts +40 -59
  166. package/src/launchd/sudo-said.ts +58 -0
  167. package/src/openbao/README.md +5 -2
  168. package/src/openbao/REPLACE.md +13 -11
  169. package/src/openbao/adopt-core.test.ts +119 -0
  170. package/src/openbao/adopt-families.test.ts +97 -0
  171. package/src/openbao/adopt-holes.test.ts +168 -0
  172. package/src/openbao/adopt-recovery.test.ts +71 -0
  173. package/src/openbao/auth-method-reconcile.ts +15 -3
  174. package/src/openbao/auth-method.ts +15 -6
  175. package/src/openbao/auth-role.ts +14 -4
  176. package/src/openbao/cloudflare-role.ts +17 -4
  177. package/src/openbao/fake-bao.ts +52 -0
  178. package/src/openbao/fake-core.ts +141 -0
  179. package/src/openbao/fake-families.ts +11 -5
  180. package/src/openbao/fake-mounts.ts +74 -0
  181. package/src/openbao/fake-stack.ts +17 -4
  182. package/src/openbao/jwt-config.ts +10 -7
  183. package/src/openbao/jwt-role.ts +10 -7
  184. package/src/openbao/kubernetes-role.ts +13 -7
  185. package/src/openbao/mfa-enforcement.ts +20 -6
  186. package/src/openbao/mfa-totp.ts +22 -7
  187. package/src/openbao/mount-move.test.ts +2 -35
  188. package/src/openbao/mount-reconcile.ts +13 -2
  189. package/src/openbao/mount.ts +15 -6
  190. package/src/openbao/pki-role.ts +15 -4
  191. package/src/openbao/plugin-reconcile.ts +16 -5
  192. package/src/openbao/plugin.ts +17 -6
  193. package/src/openbao/policy.ts +17 -4
  194. package/src/openbao/proxmox-role.ts +13 -4
  195. package/src/openbao/rename-adoption.test.ts +32 -19
  196. package/src/openbao/role-reconcile.ts +23 -0
  197. package/src/openbao/ssh-role.ts +14 -4
  198. package/src/ownership/adopt.ts +122 -0
  199. package/src/ownership/ownership.test.ts +200 -0
  200. package/src/ownership/probe.ts +65 -0
  201. package/src/ownership/resume.ts +118 -0
  202. package/src/ownership/rows.ts +117 -0
  203. package/src/ownership/whole.test.ts +39 -0
  204. package/src/ownership/whole.ts +60 -0
  205. package/src/proxmox/adopt-noop.test.ts +112 -0
  206. package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
  207. package/src/proxmox/ceph-pool-form.ts +3 -3
  208. package/src/proxmox/ceph-pool.ts +10 -3
  209. package/src/proxmox/fake-bao-env.ts +28 -0
  210. package/src/proxmox/fake-pve-lxc.ts +210 -0
  211. package/src/proxmox/fake-pve.ts +75 -0
  212. package/src/proxmox/index.test.ts +6 -6
  213. package/src/proxmox/index.ts +6 -2
  214. package/src/proxmox/lxc-adopt.test.ts +170 -0
  215. package/src/proxmox/lxc-create-form.ts +76 -0
  216. package/src/proxmox/lxc-create.test.ts +126 -0
  217. package/src/proxmox/lxc-guards.test.ts +158 -0
  218. package/src/proxmox/lxc-harness.ts +150 -0
  219. package/src/proxmox/lxc-identity.ts +43 -0
  220. package/src/proxmox/lxc-judge.test.ts +175 -0
  221. package/src/proxmox/lxc-judge.ts +196 -0
  222. package/src/proxmox/lxc-lifecycle.ts +200 -0
  223. package/src/proxmox/lxc-ownership.test.ts +159 -0
  224. package/src/proxmox/lxc-props.ts +215 -0
  225. package/src/proxmox/lxc-read.test.ts +60 -0
  226. package/src/proxmox/lxc-volume.test.ts +96 -0
  227. package/src/proxmox/lxc-volume.ts +153 -0
  228. package/src/proxmox/lxc-wire.ts +210 -0
  229. package/src/proxmox/lxc.ts +216 -93
  230. package/src/proxmox/network-apply-read.ts +3 -1
  231. package/src/proxmox/pbs-datastore.ts +4 -3
  232. package/src/proxmox/resource.ts +7 -7
  233. package/src/proxmox/update-guard.ts +31 -0
  234. package/src/verify/args.ts +82 -0
  235. package/src/verify/cli.test.ts +86 -0
  236. package/src/verify/cli.ts +49 -0
  237. package/src/verify/fake-engine.ts +182 -0
  238. package/src/verify/index.ts +37 -0
  239. package/src/verify/plan-scope.ts +34 -0
  240. package/src/verify/recheck.test.ts +56 -0
  241. package/src/verify/recheck.ts +71 -0
  242. package/src/verify/report.ts +52 -0
  243. package/src/verify/rows.ts +210 -0
  244. package/src/verify/spy.test.ts +141 -0
  245. package/src/verify/spy.ts +160 -0
  246. package/src/verify/verify.test.ts +146 -0
  247. package/src/verify/verify.ts +185 -0
@@ -0,0 +1,196 @@
1
+ # Proxmox.Lxc
2
+
3
+ A Proxmox VE container as an Alchemy resource: adopt one that runs today, or create one from a
4
+ template. It uses the same PVE client and OpenBao-minted tokens as every other
5
+ `@homeflare/alchemy/proxmox` resource. Nothing reads a stored credential.
6
+
7
+ ```ts
8
+ import { adopt } from 'alchemy/AdoptPolicy';
9
+ import * as Effect from 'effect/Effect';
10
+ import { ProxmoxLxc } from '@homeflare/alchemy/proxmox';
11
+
12
+ export const guests = Effect.gen(function* () {
13
+ // Adopt: paste `pvesh get /nodes/<node>/lxc/<vmid>/config --output-format json`,
14
+ // minus `digest`, `lxc`, `lock`, `parent` and `unusedN`, and add where it lives.
15
+ // adopt(true) (or one deploy with --adopt) says the claim is meant; without it, nothing is.
16
+ const ct = yield* ProxmoxLxc('ct', {
17
+ target,
18
+ node: 'pve1',
19
+ vmid: 100,
20
+ hostname: 'ct-example',
21
+ cores: 4,
22
+ memory: 8192,
23
+ swap: 0,
24
+ onboot: 1,
25
+ unprivileged: 1,
26
+ features: 'nesting=1',
27
+ rootfs: 'local-zfs:subvol-100-disk-0,size=40G',
28
+ mp0: 'tank:subvol-100-disk-0,mp=/data,backup=0,size=200G',
29
+ net0: 'name=eth0,bridge=vmbr0,hwaddr=00:00:5E:00:53:01,ip=192.0.2.10/24,type=veth',
30
+ dev0: '/dev/dri/renderD128,uid=0,gid=44,mode=0660',
31
+ }).pipe(adopt(true));
32
+
33
+ // Create: a template, a new volume as `storage:GiB`, and `start` for the first boot only.
34
+ const door = yield* ProxmoxLxc('door', {
35
+ target,
36
+ node: 'pve1',
37
+ vmid: 150,
38
+ ostemplate: 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst',
39
+ rootfs: 'local-zfs:8',
40
+ net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
41
+ features: 'nesting=1',
42
+ onboot: 1,
43
+ start: 1,
44
+ });
45
+ return { ct, door };
46
+ });
47
+ ```
48
+
49
+ Provide `ProxmoxLxcProvider()` with the other providers, plus `FetchHttpClient.layer`.
50
+
51
+ ## The props are PVE's keys
52
+
53
+ Every prop is the key and spelling PVE uses in `/nodes/{node}/lxc/{vmid}/config`: `mp0`, `net1`,
54
+ `dev0` and `rootfs` are property strings, and booleans may be `1`/`0` or `true`/`false`.
55
+
56
+ - ⛔ **An undeclared key is unmanaged.** It is never sent, compared or deleted, including the keys
57
+ PVE fills in itself (`arch`, `ostype`, a NIC's `hwaddr`).
58
+ - **To remove a key, declare it as `''`.** This works for `description`, `tags`, `startup`,
59
+ `features`, `nameserver`, `searchdomain`, `timezone` and `netN`. It is refused for a mount point.
60
+ - ⛔ `password`, `ssh-public-keys` and `env` are typed `never`, because state is stored
61
+ unencrypted. A container created here has no root password. Use `pct enter` on its node.
62
+ - ⛔ A key the resource does not manage (`hookscript`, `lock`, `force`, …) fails the plan, and
63
+ the create too: it is never sent.
64
+ - ⛔ The `config` attribute keeps only keys this resource manages (an allowlist, so a new PVE key
65
+ such as `entrypoint` is not stored). Never `description` (the UI's Notes panel, where people
66
+ paste credentials), `env`, `digest` or raw `lxc.*` values. A declared description is in props.
67
+
68
+ **Values are compared as PVE stores them, not as strings:**
69
+
70
+ - A property string is compared as a map. Key order does not matter. A default written out
71
+ (`backup=0`, `firewall=0`, `type=veth`, `deny-write=0`, `keyctl=0`) equals the key being absent.
72
+ - An undeclared `hwaddr` is ignored, and a NIC write keeps the live MAC. A new MAC would mean a
73
+ new DHCP lease.
74
+ - `rootfs: 'local-zfs:40'` equals `local-zfs:subvol-…,size=40G`: the storage and the size are
75
+ compared. An existing volume is always written back with its live volume id, never with the
76
+ new-disk spelling, which would allocate a new volume and detach the old one.
77
+ - Tags are compared as a lower-cased set, as PVE stores them by default. ⚠️ If your cluster sets
78
+ `tag-style case-sensitive=1`, a change of case is not seen.
79
+ - `nameserver` and `searchdomain` are compared in order. `description` ignores the trailing
80
+ newline PVE adds to it.
81
+
82
+ ## What a change does
83
+
84
+ | Change | What happens |
85
+ | ------------------------------------------------------------------ | ------------------------------------------------------------------- |
86
+ | cores, memory, swap, hostname, onboot, tags, NIC, mount options, … | `PUT …/config`, carrying the config `digest` |
87
+ | A larger `rootfs` / `mpN` | `PUT …/resize` after the config write, then wait for the task |
88
+ | A new `mpN` (`storage:GiB`) or `netN` | `PUT …/config` |
89
+ | Nothing differs | Nothing is written, including on the first deploy after an adoption |
90
+
91
+ The `digest` makes PVE refuse the write if the config changed since the deploy read it. ⚠️ That
92
+ read is the deploy's, not the plan's: a declared key edited by hand between `plan` and `deploy`
93
+ is written back to the declaration, even after a plan that warned of nothing.
94
+
95
+ On a running guest, PVE may keep a change as pending until the next restart. A read returns the
96
+ pending value, so the plan still converges.
97
+
98
+ ## What is refused, and why
99
+
100
+ The plan fails with a sentence, and nothing is written. Nothing here plans a replace, because a
101
+ replace of a guest deletes a running machine and its disks.
102
+
103
+ | Change | Why |
104
+ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
105
+ | `vmid` | A new vmid is a different guest. |
106
+ | `node`, while the guest is on another node | This resource never migrates. |
107
+ | `ostemplate` | The template is used up at create. Another template means a new guest. |
108
+ | `unprivileged` | PVE: "unable to modify read-only option" |
109
+ | A smaller `rootfs` / `mpN` | PVE: "unable to shrink disk size" |
110
+ | Another storage | Use `pct move-volume`, which copies. It is not a config edit. |
111
+ | Another volume, or `mpN: ''` | The live volume would be detached to `unusedN`. |
112
+ | A create naming an existing volume id (not `storage:GiB`) | PVE would unpack the template onto that volume. |
113
+ | `devN`, a bind or device mount, a feature other than `nesting`, any feature on a privileged guest | Only root@pam can write these (see below) |
114
+
115
+ A vmid that belongs to another node, or to a QEMU VM, fails the plan and says where it is. It
116
+ does not plan a create. After HA or `pct migrate` has moved a guest, set `node` to where it is
117
+ now: the plan is an update that writes nothing and records the new node.
118
+
119
+ ### root@pam only: device passthrough, bind mounts, most features
120
+
121
+ PVE's `check_ct_modify_config_perm` (pve-container 6.1.14) lets only `root@pam` write these. An
122
+ API token's user is always `user@realm!token`, never `root@pam`, so no minted token can write them.
123
+
124
+ For these, the plan refuses and prints the exact `pct set <vmid> --<key> '<value>'` to run on the
125
+ node. After you run it, declare the value, and the next plan compares it like any other key. A
126
+ guest that needs `/dev/net/tun` (a Mesh door) or `/dev/dri` is created in two steps:
127
+
128
+ 1. Declare the guest without the device, and deploy.
129
+ 2. Run the `pct set` on the node, then add `dev0` to the declaration.
130
+
131
+ `nesting` on an unprivileged guest is allowed. It needs `VM.Allocate`, which `LXCProvisioner` has.
132
+
133
+ ### Gaps
134
+
135
+ - **Raw `lxc.*` lines** (`lxc.prlimit.memlock`, `lxc.cgroup2.devices.allow`, `lxc.mount.entry`)
136
+ can be read through the API but not set. They are edited in `/etc/pve/lxc/<vmid>.conf` on the
137
+ node. The attribute `rawKeys` lists their names only. Values are never stored, because
138
+ `lxc.environment` can hold anything.
139
+ - **Power state is not declared.** `start: 1` boots the guest once, after its create. After
140
+ that, `onboot` is the setting, and starting or stopping is an operator's job.
141
+ - **Pool membership, HA, firewall rules, snapshots, replication** are not declared here. Use their
142
+ own resources, or manage them by hand.
143
+
144
+ ## Adopting: what the plan says
145
+
146
+ ⛔ **No live guest is adopted without `adopt(true)` or `--adopt`, not even one that matches the
147
+ declaration** — the kit-wide rule in [ownership.md](./ownership.md). Without either, the plan fails
148
+ with "Cannot adopt" and nothing is written. Matching is not proof the guest is yours: a pasted
149
+ config or a mistyped vmid reads the same, and once state claims a guest, `RemovalPolicy.destroy()`
150
+ deletes it and its volumes. `.pipe(adopt(false))` stays refused under `--adopt`.
151
+
152
+ With adoption on, a cold adoption plans **`adopted`** even when the deploy would write. Alchemy
153
+ 2.0.0-beta.79 forces an update after it adopts, and prints no property diff for that update. So
154
+ `diff` logs a warning naming each key a deploy would write:
155
+
156
+ ```
157
+ Proxmox.Lxc pve1/100: live config differs from the declaration in memory, net1 -- a deploy writes these.
158
+ ```
159
+
160
+ - **No warning** means the declaration is the live config. The deploy writes nothing (two GETs),
161
+ and the next plan is `noop`. ★ That is the zero-change adoption check: read the plan first.
162
+ - **A warning** means fix the declaration, or accept the named writes. Keys are named, values are
163
+ not.
164
+
165
+ ⛔ **A warning does not stop the deploy.** `deploy --adopt --yes` writes each named key onto the
166
+ live guest. A `net0` declared without the live `tag=` is written without it, and the guest leaves
167
+ its VLAN. Only the plan you read makes an adoption zero-change.
168
+
169
+ ⚠️ **`alchemy plan` has no `--adopt` flag** (2.0.0-beta.79: only `deploy` declares it). Without
170
+ adoption on, the plan stops at "Cannot adopt" before `diff` can warn. To read the adoption's plan,
171
+ run `alchemy deploy --adopt --dry-run`, or declare `.pipe(adopt(true))` and run `alchemy plan`.
172
+
173
+ A deploy that planned a **create** never takes over a guest it then finds at that vmid (the plan
174
+ skips its adoption read while a prop is an unresolved Output, and a guest can appear after the
175
+ plan). Without adoption on, the deploy fails and forgets its `creating` row, so the next plan asks.
176
+ With it on, a matching guest is recorded with no write; any other fails until a plan can show it.
177
+
178
+ A create interrupted after its POST resumes on the next deploy **without** `--adopt` when the guest
179
+ still matches what it declared. If someone changed it meanwhile, the plan says "Cannot resume
180
+ creating", and `--adopt` resumes it (the warning names the keys it writes back).
181
+
182
+ A guest the cluster no longer lists at all, with state still held, plans `update` and warns that
183
+ the deploy **creates it again** from `ostemplate`, with new volumes. The plan fails instead when
184
+ there is no `ostemplate`, or when `rootfs`/`mpN` are declared by live volume id (as a paste is):
185
+ a create never reuses a volume.
186
+
187
+ `GET …/config` needs `VM.Audit`, which the `read` lease has. A config PUT uses the cached
188
+ `provision` lease. A create, resize or delete mints a fresh one and polls its task with it.
189
+
190
+ ## Removing
191
+
192
+ `defaultRemovalPolicy: 'retain'`. If you drop the declaration, the state row is dropped and the
193
+ guest keeps running. Only `.pipe(RemovalPolicy.destroy())` reaches the delete. That delete never
194
+ forces, purges or stops the guest first: PVE refuses to delete a running or protected guest, and
195
+ that refusal is kept. It also deletes only a guest that still matches its last declaration: a
196
+ vmid is reusable, so one that differs may be somebody else's, and the delete is refused.
@@ -0,0 +1,12 @@
1
+ # Proxmox — `@homeflare/alchemy/proxmox`
2
+
3
+ PVE and PBS objects over the PVE API, each call on a short-lived token minted from an OpenBao
4
+ mount. `ProxmoxLxc` adopts a live container from its own config (a paste of `pvesh get …/config`)
5
+ or creates one from a template.
6
+
7
+ - ⛔ **It never replaces or destroys a guest by default.** A move, a smaller disk, an unprivileged
8
+ flip or another template fails the plan. Destroy retains unless `RemovalPolicy.destroy()`.
9
+ - ⛔ **It adopts nothing without `--adopt` or `adopt(true)`**, a matching guest included
10
+ ([ownership.md](./ownership.md)): under `destroy`, a claimed guest's disks go with it.
11
+ - ⛔ **root@pam-only keys** (`devN`, bind mounts, features beyond `nesting`) are refused at plan,
12
+ with the `pct set` to run instead. Guide: [proxmox-lxc.md](./proxmox-lxc.md).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.8.0",
4
- "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy.",
3
+ "version": "0.10.1",
4
+ "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
7
7
  "license": "MIT",
@@ -11,6 +11,9 @@
11
11
  "url": "git+https://github.com/taslabs-net/homeflare-kit.git",
12
12
  "directory": "packages/alchemy"
13
13
  },
14
+ "bin": {
15
+ "hf-adopt-verify": "./dist/verify/cli.js"
16
+ },
14
17
  "files": [
15
18
  "dist",
16
19
  "docs",
@@ -53,6 +56,10 @@
53
56
  "types": "./dist/caddy/index.d.ts",
54
57
  "default": "./dist/caddy/index.js"
55
58
  },
59
+ "./verify": {
60
+ "types": "./dist/verify/index.d.ts",
61
+ "default": "./dist/verify/index.js"
62
+ },
56
63
  "./package.json": "./package.json"
57
64
  },
58
65
  "publishConfig": {
@@ -5,7 +5,7 @@
5
5
  * Caddy the stack declared `.pipe(adopt(false))`.
6
6
  * ★ Registered through the REAL `Resource` (caddyWithFile under a Stack), not a hand-built map: the
7
7
  * lookup reads what Alchemy records at registration, so an upgrade that stops recording `Adopt`
8
- * in `Stack.resources` fails here (host-effect.ts adoptEnabled).
8
+ * in `Stack.resources` fails here (ownership/adopt.ts adoptEnabled).
9
9
  */
10
10
  import { afterEach, expect, test } from 'bun:test';
11
11
  import { push } from 'alchemy';
@@ -172,7 +172,7 @@ export type Applied = {
172
172
  * What this apply may load over — config.ts decides from the state and the adopt setting:
173
173
  * · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
174
174
  * correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
175
- * resource's own `adopt(true)` — host-effect.ts adoptEnabled).
175
+ * resource's own `adopt(true)` — ownership/adopt.ts adoptEnabled).
176
176
  * · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
177
177
  * Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
178
178
  */
@@ -27,7 +27,8 @@ import { Unowned } from 'alchemy/AdoptPolicy';
27
27
  import { isResolved } from 'alchemy/Diff';
28
28
  import * as Provider from 'alchemy/Provider';
29
29
  import * as Effect from 'effect/Effect';
30
- import { adoptEnabled, lift, resolvedString } from '../launchd/host-effect.ts';
30
+ import { lift, resolvedString } from '../launchd/host-effect.ts';
31
+ import { adoptEnabled } from '../ownership/adopt.ts';
31
32
  import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
32
33
  import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
33
34
  import { diffConfig, probeLive, readLive, reconcileConfig } from './config-lifecycle.ts';
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `--adopt` and `adopt(…)` at APPLY, for HostFile and LaunchdJob — the create Alchemy never probed
3
+ * (a prop still an Output at plan time). Before 2026-09-21 these reconciles refused a foreign file
4
+ * or job whatever the deploy said, so the flag the refusal told you to use could not work there.
5
+ * They now resolve adoption the way the planner does (ownership/adopt.ts adoptEnabled), BOTH ways —
6
+ * for a CREATE only: the row Apply committed for this instance is `creating`. ⛔ A replace's new
7
+ * generation (a `replacing` row, `output` undefined all the same) is never adoptable at apply.
8
+ * ★ Registered through the REAL `Resource` under a Stack, as caddy/adopt-scope.test.ts is, over a
9
+ * state store holding the row Apply commits before it calls reconcile.
10
+ */
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
13
+ import { Artifacts, makeScopedArtifacts } from 'alchemy/Artifacts';
14
+ import { InMemoryService } from 'alchemy/State/InMemoryState';
15
+ import type { ResourceState } from 'alchemy/State/ResourceState';
16
+ import { State } from 'alchemy/State/State';
17
+ import { Stack } from 'alchemy/Stack';
18
+ import * as Effect from 'effect/Effect';
19
+ import * as Layer from 'effect/Layer';
20
+ import { noteUnfinished } from '../ownership/resume.ts';
21
+ import { fakeRunner } from './fake-runner.ts';
22
+ import { HostFile, HostFileProvider } from './host-file.ts';
23
+ import { LaunchdJob, LaunchdJobProvider } from './job.ts';
24
+ import type { LaunchdJobAttributes } from './job-form.ts';
25
+ import { hostRunnerLayer } from './runner.ts';
26
+
27
+ type Scope = 'adopt(true)' | 'adopt(false)' | 'none';
28
+
29
+ const FOREIGN = new TextEncoder().encode('someone else wrote this\n');
30
+ const PATH = '/etc/example/a.conf';
31
+ const PLIST = '/Library/LaunchDaemons/com.example.job.plist';
32
+ const JOB = { domain: 'system', label: 'com.example.job', programArguments: ['/bin/job'] } as const;
33
+
34
+ const host = () => {
35
+ const fake = fakeRunner({ dirs: { '/Library/LaunchDaemons': 0, '/etc/example': 0 }, euid: 0 });
36
+ for (const path of [PATH, PLIST]) {
37
+ fake.files.set(path, { bytes: FOREIGN, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
38
+ }
39
+ return fake;
40
+ };
41
+
42
+ const scoped = <A extends Effect.Effect<unknown, unknown, unknown>>(declared: A, scope: Scope) =>
43
+ scope === 'none' ? declared : (declared.pipe(adopt(scope === 'adopt(true)')) as A);
44
+
45
+ /** The row Apply commits before reconcile: a create's, or a replace's new generation's. */
46
+ type Generation = 'creating' | 'replacing';
47
+
48
+ /** Register `family` with `scope` under a deploy whose `--adopt` is `flag`; reconcile it statelessly. */
49
+ const firstApply = (
50
+ family: 'file' | 'job',
51
+ scope: Scope,
52
+ flag: boolean,
53
+ output?: unknown,
54
+ generation: Generation = 'creating',
55
+ /** The plan's diff saw this generation's row with no attributes (ownership/resume.ts). */
56
+ unfinished = false,
57
+ ) => {
58
+ const fake = host();
59
+ const fqn = family === 'file' ? 'conf' : 'job';
60
+ const row = { fqn, instanceId: 'i-1', status: generation } as unknown as ResourceState;
61
+ const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
62
+ const call = {
63
+ bindings: [] as never,
64
+ instanceId: 'i-1',
65
+ olds: undefined,
66
+ session: undefined as never,
67
+ };
68
+ const program: Effect.Effect<unknown, unknown, AdoptPolicy | Stack> =
69
+ family === 'file'
70
+ ? Effect.gen(function* () {
71
+ yield* scoped(HostFile('conf', { content: 'x', path: PATH }), scope);
72
+ const provider = yield* HostFile.Provider;
73
+ const news = { content: 'x', path: PATH };
74
+ return yield* provider.reconcile({
75
+ ...call,
76
+ fqn: 'conf',
77
+ id: 'conf',
78
+ news,
79
+ output: undefined,
80
+ });
81
+ }).pipe(
82
+ Effect.provide(HostFileProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner)))),
83
+ )
84
+ : Effect.gen(function* () {
85
+ yield* scoped(LaunchdJob('job', JOB), scope);
86
+ const provider = yield* LaunchdJob.Provider;
87
+ const prior = output as LaunchdJobAttributes | undefined;
88
+ return yield* provider.reconcile({
89
+ ...call,
90
+ fqn: 'job',
91
+ id: 'job',
92
+ news: JOB,
93
+ output: prior,
94
+ });
95
+ }).pipe(
96
+ Effect.provide(LaunchdJobProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner)))),
97
+ );
98
+ const bag = makeScopedArtifacts(new Map(), fqn);
99
+ const noted = unfinished ? noteUnfinished('i-1') : Effect.void;
100
+ const applying = Effect.runPromise(
101
+ Effect.andThen(noted, program).pipe(
102
+ Effect.provideService(Artifacts, bag),
103
+ Effect.provideService(AdoptPolicy, flag),
104
+ Effect.provideService(State, InMemoryService({ test: { test: { [fqn]: row } } })),
105
+ Effect.provideService(Stack, spec as never),
106
+ ),
107
+ );
108
+ return { applying, fake };
109
+ };
110
+
111
+ const REFUSED = /already exists and is not this resource|is already on this host/;
112
+
113
+ describe.each(['file', 'job'] as const)('%s: a first apply over a foreign object', (family) => {
114
+ test.each([
115
+ ['adopt(false) under --adopt', 'adopt(false)', true, false],
116
+ ['adopt(true) without --adopt', 'adopt(true)', false, true],
117
+ ['no scope under --adopt', 'none', true, true],
118
+ ['no scope without --adopt', 'none', false, false],
119
+ ] as const)('with %s', async (_, scope, flag, takesOver) => {
120
+ const { applying, fake } = firstApply(family, scope, flag);
121
+ const path = family === 'file' ? PATH : PLIST;
122
+ if (takesOver) {
123
+ await applying;
124
+ expect(fake.files.get(path)?.bytes).not.toEqual(FOREIGN);
125
+ } else {
126
+ await expect(applying).rejects.toThrow(REFUSED);
127
+ expect(fake.files.get(path)?.bytes).toEqual(FOREIGN);
128
+ }
129
+ });
130
+ });
131
+
132
+ test('a LaunchdJob rename onto a foreign job stays refused, --adopt or not', async () => {
133
+ const old = {
134
+ domain: 'system',
135
+ label: 'com.example.old',
136
+ loaded: false,
137
+ plistPath: '/Library/LaunchDaemons/com.example.old.plist',
138
+ plistSha256: '',
139
+ serviceTarget: 'system/com.example.old',
140
+ };
141
+ const { applying, fake } = firstApply('job', 'adopt(true)', true, old);
142
+ await expect(applying).rejects.toThrow(REFUSED);
143
+ expect(fake.files.get(PLIST)?.bytes).toEqual(FOREIGN);
144
+ });
145
+
146
+ describe.each(['file', 'job'] as const)('%s: a replace onto a foreign object', (family) => {
147
+ test.each([
148
+ ['--adopt', 'none', true],
149
+ ['adopt(true)', 'adopt(true)', false],
150
+ ] as const)(
151
+ 'stays refused under %s: the plan never offered its new generation adoption',
152
+ async (_, scope, flag) => {
153
+ const { applying, fake } = firstApply(family, scope, flag, undefined, 'replacing');
154
+ await expect(applying).rejects.toThrow(REFUSED);
155
+ expect(fake.files.get(family === 'file' ? PATH : PLIST)?.bytes).toEqual(FOREIGN);
156
+ },
157
+ );
158
+
159
+ test('an UNFINISHED replace (its row seen by the plan) resumes under --adopt, as a create does', async () => {
160
+ const refused = firstApply(family, 'none', false, undefined, 'replacing', true);
161
+ await expect(refused.applying).rejects.toThrow(REFUSED);
162
+ const { applying, fake } = firstApply(family, 'none', true, undefined, 'replacing', true);
163
+ await applying;
164
+ expect(fake.files.get(family === 'file' ? PATH : PLIST)?.bytes).not.toEqual(FOREIGN);
165
+ });
166
+ });
@@ -9,6 +9,8 @@
9
9
  * job exits non-zero; a job can linger after bootout (`lingerPrints`); a gui uid with no login
10
10
  * session answers 112 (`loggedOut`); writes into a root-owned directory, or a chown to another
11
11
  * uid, need root.
12
+ * ★ Every directory ABOVE a declared one exists, root's and `0755`, as `/`, `/Library` and `/opt`
13
+ * are on a Mac; `acls` holds the `ls -lden` entry lines a directory prints (sudo-acl.ts).
12
14
  */
13
15
  import { LAUNCHCTL, NOT_FOUND, NO_DOMAIN } from './launchctl.ts';
14
16
  import type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
@@ -54,6 +56,16 @@ export const fakeRunner = (options: FakeOptions = {}) => {
54
56
  const root = privileged || euid === 0;
55
57
  const dirs = new Map(Object.entries(options.dirs ?? {}));
56
58
  const files = new Map<string, Entry>();
59
+ /** Directory → the ACL entry lines `ls -lden` prints under it. */
60
+ const acls = new Map<string, string[]>();
61
+ /** The directories above the declared ones, as the host starts: root's, `0755`. */
62
+ const above = new Set<string>(['/']);
63
+ for (const dir of dirs.keys()) {
64
+ const parts = dir.split('/').slice(1, -1);
65
+ for (const index of parts.keys()) above.add(`/${parts.slice(0, index + 1).join('/')}`);
66
+ }
67
+ for (const dir of dirs.keys()) above.delete(dir);
68
+ const implied = (path: string) => above.has(path);
57
69
  const calls: string[][] = [];
58
70
  /** target → pid (undefined = loaded, not running). */
59
71
  const loaded = new Map<string, number | undefined>();
@@ -128,6 +140,16 @@ export const fakeRunner = (options: FakeOptions = {}) => {
128
140
  exec: async (argv) => {
129
141
  calls.push([...argv]);
130
142
  if (argv[0] === LAUNCHCTL) return launchctl(argv.slice(1));
143
+ if (argv[0] === '/bin/ls' && argv[1] === '-lden' && argv[2] === '--') {
144
+ const paths = argv.slice(3);
145
+ if (paths.some((path) => !dirs.has(path) && !files.has(path) && !implied(path)))
146
+ return fail(1, 'ls: No such file or directory');
147
+ const lines = paths.flatMap((path) => [
148
+ `drwxr-xr-x 2 0 0 64 Sep 21 10:00 ${path}`,
149
+ ...(acls.get(path) ?? []),
150
+ ]);
151
+ return ok(lines.join('\n'));
152
+ }
131
153
  return fail(127, `fake: no program ${String(argv[0])}`);
132
154
  },
133
155
  lookupGroup: async (nameOrId) => options.groups?.[nameOrId],
@@ -153,9 +175,9 @@ export const fakeRunner = (options: FakeOptions = {}) => {
153
175
  size: entry.bytes.length,
154
176
  uid: entry.uid,
155
177
  };
156
- return dirs.has(path)
157
- ? { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 }
158
- : undefined;
178
+ if (dirs.has(path) || implied(path))
179
+ return { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 };
180
+ return undefined;
159
181
  },
160
182
  writeFileAtomic: async (path: string, bytes: Uint8Array, write: WriteOptions) => {
161
183
  calls.push(['write', path]);
@@ -172,5 +194,5 @@ export const fakeRunner = (options: FakeOptions = {}) => {
172
194
  });
173
195
  },
174
196
  };
175
- return { calls, disabled, dirs, files, loaded, runner, state };
197
+ return { acls, calls, disabled, dirs, files, loaded, runner, state };
176
198
  };
@@ -1,17 +1,13 @@
1
1
  /**
2
2
  * The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
3
- * `news` both providers' diffs share, and the engine's adopt setting for a check at apply.
3
+ * `news` both providers' diffs share. (The engine's adopt setting lives in ownership/adopt.ts.)
4
4
  *
5
5
  * ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
6
6
  * runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
7
7
  * ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
8
8
  * thrown is wrapped, so the failure channel is always an Error with a readable message.
9
9
  */
10
- import { AdoptPolicy } from 'alchemy/AdoptPolicy';
11
- import { AlchemyContext } from 'alchemy/AlchemyContext';
12
- import { Stack } from 'alchemy/Stack';
13
10
  import * as Effect from 'effect/Effect';
14
- import * as Option from 'effect/Option';
15
11
 
16
12
  export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
17
13
  Effect.tryPromise({
@@ -30,31 +26,3 @@ export const resolvedString = (news: unknown, key: string): string | undefined =
30
26
  const value = (news as Record<string, unknown>)[key];
31
27
  return typeof value === 'string' ? value : undefined;
32
28
  };
33
-
34
- /**
35
- * Whether the resource at `fqn` may be adopted, resolved exactly as the planner resolves it (alchemy
36
- * beta.79 Plan.ts: `resource.Adopt ?? shouldAdopt`): a resource-scoped `adopt(…)`, else the
37
- * `AdoptPolicy` service, else `AlchemyContext.adopt`, else off.
38
- * ★ FOR A CREATE THE ENGINE NEVER PROBED. Alchemy skips the adoption probe while `news` holds an
39
- * Output, so an ownership check that must still honour `--adopt` can only run at apply. All three
40
- * reach the apply: Alchemist runs plan AND apply under the session context, which provides the
41
- * services from the CLI flag and the Stack the resources registered on (Alchemist/Session.ts,
42
- * routes/stack.ts; Apply.ts reads the same Stack).
43
- * ⛔ THE RESOURCE-SCOPED SETTING WINS, BOTH WAYS. The engine captures `adopt(…)` on the resource at
44
- * registration (Resource.ts `Adopt`, into `Stack.resources[fqn]`) and never hands it to a
45
- * provider. Reading only the deploy-wide flag let `--adopt` take over a resource declared
46
- * `.pipe(adopt(false))` — which the planner refuses — and refused one declared `adopt(true)`.
47
- * ⚠️ `resources` carries an `@internal` comment in Alchemy's source, but it is in the published types
48
- * and the planner reads `Adopt` from it. caddy/adopt-scope.test.ts registers through the real
49
- * `Resource`, so an upgrade that stops recording it there fails the suite.
50
- */
51
- export const adoptEnabled = (fqn: string): Effect.Effect<boolean> =>
52
- Effect.gen(function* () {
53
- const stack = yield* Effect.serviceOption(Stack);
54
- const scoped = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
55
- if (scoped !== undefined) return scoped;
56
- const policy = yield* Effect.serviceOption(AdoptPolicy);
57
- if (Option.isSome(policy)) return policy.value;
58
- const context = yield* Effect.serviceOption(AlchemyContext);
59
- return Option.isSome(context) ? context.value.adopt : false;
60
- });
@@ -15,7 +15,7 @@ import {
15
15
  fileProblems,
16
16
  } from './host-file-form.ts';
17
17
  import { sha256Hex } from './job-form.ts';
18
- import { type HostRunner, canActAsRoot } from './runner.ts';
18
+ import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
19
19
 
20
20
  type Desired = {
21
21
  readonly bytes: Uint8Array;
@@ -72,29 +72,55 @@ export const readFileAttributes = async (
72
72
  };
73
73
  };
74
74
 
75
+ const writeOptionsOf = (want: Desired): WriteOptions => ({
76
+ mode: want.mode,
77
+ ...(want.uid === undefined ? {} : { uid: want.uid }),
78
+ ...(want.gid === undefined ? {} : { gid: want.gid }),
79
+ });
80
+
75
81
  const matches = (live: HostFileAttributes, want: Desired): boolean =>
76
82
  live.sha256 === want.sha256 &&
77
83
  live.mode === want.mode &&
78
84
  (want.uid === undefined || live.uid === want.uid) &&
79
85
  (want.gid === undefined || live.gid === want.gid);
80
86
 
87
+ /**
88
+ * ★ A PLAN THAT WILL WRITE ASKS THE RUNNER FIRST (`checkWrite`, when it has one): sudoRunner's
89
+ * refusals — a setuid or group-writable root file, a directory under a prefix another user may
90
+ * change — then fail the plan, before any resource is applied, instead of halfway through it.
91
+ */
81
92
  export const diffFile = async (
82
93
  runner: HostRunner,
83
94
  news: HostFileProps,
84
95
  output: HostFileAttributes,
85
96
  ): Promise<Diff> => {
86
- // ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
87
- if (news.path !== output.path) return { action: 'replace' };
88
97
  const want = await desiredFile(runner, news);
89
- if (want.sha256 !== output.sha256) return { action: 'update' };
98
+ const writes = async (diff: Diff): Promise<Diff> => {
99
+ await runner.checkWrite?.(news.path, writeOptionsOf(want));
100
+ return diff;
101
+ };
102
+ // ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
103
+ if (news.path !== output.path) return writes({ action: 'replace' });
104
+ if (want.sha256 !== output.sha256) return writes({ action: 'update' });
90
105
  const live = await readFileAttributes(runner, news.path);
91
- return live !== undefined && matches(live, want) ? { action: 'noop' } : { action: 'update' };
106
+ return live !== undefined && matches(live, want)
107
+ ? { action: 'noop' }
108
+ : writes({ action: 'update' });
92
109
  };
93
110
 
111
+ /**
112
+ * `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
113
+ * own (ownership/adopt.ts adoptsAtApply). It lets such a generation take over a file already at the
114
+ * path — the takeover the plan's probe would have allowed, had it run. ⛔ A move onto an occupied
115
+ * path stays refused, both ways the engine drives one: an `update` across the move (`moved`,
116
+ * below), and a fresh replace's new generation, which the caller never marks adoptable (its
117
+ * `output` is undefined too, so this function cannot tell it from a create by itself).
118
+ */
94
119
  export const reconcileFile = async (
95
120
  runner: HostRunner,
96
121
  props: HostFileProps,
97
122
  output?: HostFileAttributes,
123
+ adopt = false,
98
124
  ): Promise<HostFileAttributes> => {
99
125
  const want = await desiredFile(runner, props);
100
126
  // ⛔ NO SILENT SUDO: handing a file to another user is root's call (chown(2)), so say so up front.
@@ -120,11 +146,14 @@ export const reconcileFile = async (
120
146
  const prior = moved ? undefined : output;
121
147
  /**
122
148
  * ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
123
- * the engine's adoption probe has already refused anything it found — except at the new path of
124
- * a replace, where it reads nothing. A path typo there would otherwise overwrite, say, a system
125
- * file that a fresh declaration of the same path would have been refused as `Unowned`.
149
+ * the engine's adoption probe has already refused anything it found — except where it never
150
+ * looked: the new path of a replace, and a create whose props still held an Output at plan
151
+ * time. A path typo there would otherwise overwrite, say, a system file that a fresh
152
+ * declaration of the same path would have been refused as `Unowned`. ★ With adoption on, a
153
+ * create takes it over, as the probe would have let it (decided 2026-09-21).
126
154
  */
127
- if (prior === undefined && before !== undefined && !matches(before, want)) {
155
+ const takeOver = adopt && output === undefined;
156
+ if (prior === undefined && before !== undefined && !matches(before, want) && !takeOver) {
128
157
  throw refuse(
129
158
  props.path,
130
159
  'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
@@ -132,11 +161,7 @@ export const reconcileFile = async (
132
161
  );
133
162
  }
134
163
  if (before === undefined || !matches(before, want)) {
135
- await runner.writeFileAtomic(props.path, want.bytes, {
136
- mode: want.mode,
137
- ...(want.uid === undefined ? {} : { uid: want.uid }),
138
- ...(want.gid === undefined ? {} : { gid: want.gid }),
139
- });
164
+ await runner.writeFileAtomic(props.path, want.bytes, writeOptionsOf(want));
140
165
  }
141
166
  // ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
142
167
  // up here as a refusal instead of as a forever-`update`.