@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.
- package/README.md +22 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +370 -0
- package/dist/verify/cli.js.map +17 -0
- package/dist/verify/fake-engine.d.ts +65 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +347 -0
- package/dist/verify/index.js.map +16 -0
- package/dist/verify/plan-scope.d.ts +14 -0
- package/dist/verify/plan-scope.d.ts.map +1 -0
- package/dist/verify/recheck.d.ts +42 -0
- package/dist/verify/recheck.d.ts.map +1 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +67 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +43 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +119 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +182 -0
- package/src/verify/index.ts +37 -0
- package/src/verify/plan-scope.ts +34 -0
- package/src/verify/recheck.test.ts +56 -0
- package/src/verify/recheck.ts +71 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +210 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +160 -0
- package/src/verify/verify.test.ts +146 -0
- 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.
|
package/docs/proxmox.md
ADDED
|
@@ -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.
|
|
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 (
|
|
8
|
+
* in `Stack.resources` fails here (ownership/adopt.ts adoptEnabled).
|
|
9
9
|
*/
|
|
10
10
|
import { afterEach, expect, test } from 'bun:test';
|
|
11
11
|
import { push } from 'alchemy';
|
|
@@ -172,7 +172,7 @@ export type Applied = {
|
|
|
172
172
|
* What this apply may load over — config.ts decides from the state and the adopt setting:
|
|
173
173
|
* · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
|
|
174
174
|
* correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
|
|
175
|
-
* resource's own `adopt(true)` —
|
|
175
|
+
* resource's own `adopt(true)` — ownership/adopt.ts adoptEnabled).
|
|
176
176
|
* · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
|
|
177
177
|
* Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
|
|
178
178
|
*/
|
package/src/caddy/config.ts
CHANGED
|
@@ -27,7 +27,8 @@ import { Unowned } from 'alchemy/AdoptPolicy';
|
|
|
27
27
|
import { isResolved } from 'alchemy/Diff';
|
|
28
28
|
import * as Provider from 'alchemy/Provider';
|
|
29
29
|
import * as Effect from 'effect/Effect';
|
|
30
|
-
import {
|
|
30
|
+
import { lift, resolvedString } from '../launchd/host-effect.ts';
|
|
31
|
+
import { adoptEnabled } from '../ownership/adopt.ts';
|
|
31
32
|
import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
|
|
32
33
|
import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
33
34
|
import { diffConfig, probeLive, readLive, reconcileConfig } from './config-lifecycle.ts';
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--adopt` and `adopt(…)` at APPLY, for HostFile and LaunchdJob — the create Alchemy never probed
|
|
3
|
+
* (a prop still an Output at plan time). Before 2026-09-21 these reconciles refused a foreign file
|
|
4
|
+
* or job whatever the deploy said, so the flag the refusal told you to use could not work there.
|
|
5
|
+
* They now resolve adoption the way the planner does (ownership/adopt.ts adoptEnabled), BOTH ways —
|
|
6
|
+
* for a CREATE only: the row Apply committed for this instance is `creating`. ⛔ A replace's new
|
|
7
|
+
* generation (a `replacing` row, `output` undefined all the same) is never adoptable at apply.
|
|
8
|
+
* ★ Registered through the REAL `Resource` under a Stack, as caddy/adopt-scope.test.ts is, over a
|
|
9
|
+
* state store holding the row Apply commits before it calls reconcile.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import { Artifacts, makeScopedArtifacts } from 'alchemy/Artifacts';
|
|
14
|
+
import { InMemoryService } from 'alchemy/State/InMemoryState';
|
|
15
|
+
import type { ResourceState } from 'alchemy/State/ResourceState';
|
|
16
|
+
import { State } from 'alchemy/State/State';
|
|
17
|
+
import { Stack } from 'alchemy/Stack';
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import * as Layer from 'effect/Layer';
|
|
20
|
+
import { noteUnfinished } from '../ownership/resume.ts';
|
|
21
|
+
import { fakeRunner } from './fake-runner.ts';
|
|
22
|
+
import { HostFile, HostFileProvider } from './host-file.ts';
|
|
23
|
+
import { LaunchdJob, LaunchdJobProvider } from './job.ts';
|
|
24
|
+
import type { LaunchdJobAttributes } from './job-form.ts';
|
|
25
|
+
import { hostRunnerLayer } from './runner.ts';
|
|
26
|
+
|
|
27
|
+
type Scope = 'adopt(true)' | 'adopt(false)' | 'none';
|
|
28
|
+
|
|
29
|
+
const FOREIGN = new TextEncoder().encode('someone else wrote this\n');
|
|
30
|
+
const PATH = '/etc/example/a.conf';
|
|
31
|
+
const PLIST = '/Library/LaunchDaemons/com.example.job.plist';
|
|
32
|
+
const JOB = { domain: 'system', label: 'com.example.job', programArguments: ['/bin/job'] } as const;
|
|
33
|
+
|
|
34
|
+
const host = () => {
|
|
35
|
+
const fake = fakeRunner({ dirs: { '/Library/LaunchDaemons': 0, '/etc/example': 0 }, euid: 0 });
|
|
36
|
+
for (const path of [PATH, PLIST]) {
|
|
37
|
+
fake.files.set(path, { bytes: FOREIGN, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
38
|
+
}
|
|
39
|
+
return fake;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const scoped = <A extends Effect.Effect<unknown, unknown, unknown>>(declared: A, scope: Scope) =>
|
|
43
|
+
scope === 'none' ? declared : (declared.pipe(adopt(scope === 'adopt(true)')) as A);
|
|
44
|
+
|
|
45
|
+
/** The row Apply commits before reconcile: a create's, or a replace's new generation's. */
|
|
46
|
+
type Generation = 'creating' | 'replacing';
|
|
47
|
+
|
|
48
|
+
/** Register `family` with `scope` under a deploy whose `--adopt` is `flag`; reconcile it statelessly. */
|
|
49
|
+
const firstApply = (
|
|
50
|
+
family: 'file' | 'job',
|
|
51
|
+
scope: Scope,
|
|
52
|
+
flag: boolean,
|
|
53
|
+
output?: unknown,
|
|
54
|
+
generation: Generation = 'creating',
|
|
55
|
+
/** The plan's diff saw this generation's row with no attributes (ownership/resume.ts). */
|
|
56
|
+
unfinished = false,
|
|
57
|
+
) => {
|
|
58
|
+
const fake = host();
|
|
59
|
+
const fqn = family === 'file' ? 'conf' : 'job';
|
|
60
|
+
const row = { fqn, instanceId: 'i-1', status: generation } as unknown as ResourceState;
|
|
61
|
+
const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
|
|
62
|
+
const call = {
|
|
63
|
+
bindings: [] as never,
|
|
64
|
+
instanceId: 'i-1',
|
|
65
|
+
olds: undefined,
|
|
66
|
+
session: undefined as never,
|
|
67
|
+
};
|
|
68
|
+
const program: Effect.Effect<unknown, unknown, AdoptPolicy | Stack> =
|
|
69
|
+
family === 'file'
|
|
70
|
+
? Effect.gen(function* () {
|
|
71
|
+
yield* scoped(HostFile('conf', { content: 'x', path: PATH }), scope);
|
|
72
|
+
const provider = yield* HostFile.Provider;
|
|
73
|
+
const news = { content: 'x', path: PATH };
|
|
74
|
+
return yield* provider.reconcile({
|
|
75
|
+
...call,
|
|
76
|
+
fqn: 'conf',
|
|
77
|
+
id: 'conf',
|
|
78
|
+
news,
|
|
79
|
+
output: undefined,
|
|
80
|
+
});
|
|
81
|
+
}).pipe(
|
|
82
|
+
Effect.provide(HostFileProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner)))),
|
|
83
|
+
)
|
|
84
|
+
: Effect.gen(function* () {
|
|
85
|
+
yield* scoped(LaunchdJob('job', JOB), scope);
|
|
86
|
+
const provider = yield* LaunchdJob.Provider;
|
|
87
|
+
const prior = output as LaunchdJobAttributes | undefined;
|
|
88
|
+
return yield* provider.reconcile({
|
|
89
|
+
...call,
|
|
90
|
+
fqn: 'job',
|
|
91
|
+
id: 'job',
|
|
92
|
+
news: JOB,
|
|
93
|
+
output: prior,
|
|
94
|
+
});
|
|
95
|
+
}).pipe(
|
|
96
|
+
Effect.provide(LaunchdJobProvider().pipe(Layer.provide(hostRunnerLayer(fake.runner)))),
|
|
97
|
+
);
|
|
98
|
+
const bag = makeScopedArtifacts(new Map(), fqn);
|
|
99
|
+
const noted = unfinished ? noteUnfinished('i-1') : Effect.void;
|
|
100
|
+
const applying = Effect.runPromise(
|
|
101
|
+
Effect.andThen(noted, program).pipe(
|
|
102
|
+
Effect.provideService(Artifacts, bag),
|
|
103
|
+
Effect.provideService(AdoptPolicy, flag),
|
|
104
|
+
Effect.provideService(State, InMemoryService({ test: { test: { [fqn]: row } } })),
|
|
105
|
+
Effect.provideService(Stack, spec as never),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
return { applying, fake };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const REFUSED = /already exists and is not this resource|is already on this host/;
|
|
112
|
+
|
|
113
|
+
describe.each(['file', 'job'] as const)('%s: a first apply over a foreign object', (family) => {
|
|
114
|
+
test.each([
|
|
115
|
+
['adopt(false) under --adopt', 'adopt(false)', true, false],
|
|
116
|
+
['adopt(true) without --adopt', 'adopt(true)', false, true],
|
|
117
|
+
['no scope under --adopt', 'none', true, true],
|
|
118
|
+
['no scope without --adopt', 'none', false, false],
|
|
119
|
+
] as const)('with %s', async (_, scope, flag, takesOver) => {
|
|
120
|
+
const { applying, fake } = firstApply(family, scope, flag);
|
|
121
|
+
const path = family === 'file' ? PATH : PLIST;
|
|
122
|
+
if (takesOver) {
|
|
123
|
+
await applying;
|
|
124
|
+
expect(fake.files.get(path)?.bytes).not.toEqual(FOREIGN);
|
|
125
|
+
} else {
|
|
126
|
+
await expect(applying).rejects.toThrow(REFUSED);
|
|
127
|
+
expect(fake.files.get(path)?.bytes).toEqual(FOREIGN);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a LaunchdJob rename onto a foreign job stays refused, --adopt or not', async () => {
|
|
133
|
+
const old = {
|
|
134
|
+
domain: 'system',
|
|
135
|
+
label: 'com.example.old',
|
|
136
|
+
loaded: false,
|
|
137
|
+
plistPath: '/Library/LaunchDaemons/com.example.old.plist',
|
|
138
|
+
plistSha256: '',
|
|
139
|
+
serviceTarget: 'system/com.example.old',
|
|
140
|
+
};
|
|
141
|
+
const { applying, fake } = firstApply('job', 'adopt(true)', true, old);
|
|
142
|
+
await expect(applying).rejects.toThrow(REFUSED);
|
|
143
|
+
expect(fake.files.get(PLIST)?.bytes).toEqual(FOREIGN);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe.each(['file', 'job'] as const)('%s: a replace onto a foreign object', (family) => {
|
|
147
|
+
test.each([
|
|
148
|
+
['--adopt', 'none', true],
|
|
149
|
+
['adopt(true)', 'adopt(true)', false],
|
|
150
|
+
] as const)(
|
|
151
|
+
'stays refused under %s: the plan never offered its new generation adoption',
|
|
152
|
+
async (_, scope, flag) => {
|
|
153
|
+
const { applying, fake } = firstApply(family, scope, flag, undefined, 'replacing');
|
|
154
|
+
await expect(applying).rejects.toThrow(REFUSED);
|
|
155
|
+
expect(fake.files.get(family === 'file' ? PATH : PLIST)?.bytes).toEqual(FOREIGN);
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
test('an UNFINISHED replace (its row seen by the plan) resumes under --adopt, as a create does', async () => {
|
|
160
|
+
const refused = firstApply(family, 'none', false, undefined, 'replacing', true);
|
|
161
|
+
await expect(refused.applying).rejects.toThrow(REFUSED);
|
|
162
|
+
const { applying, fake } = firstApply(family, 'none', true, undefined, 'replacing', true);
|
|
163
|
+
await applying;
|
|
164
|
+
expect(fake.files.get(family === 'file' ? PATH : PLIST)?.bytes).not.toEqual(FOREIGN);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* job exits non-zero; a job can linger after bootout (`lingerPrints`); a gui uid with no login
|
|
10
10
|
* session answers 112 (`loggedOut`); writes into a root-owned directory, or a chown to another
|
|
11
11
|
* uid, need root.
|
|
12
|
+
* ★ Every directory ABOVE a declared one exists, root's and `0755`, as `/`, `/Library` and `/opt`
|
|
13
|
+
* are on a Mac; `acls` holds the `ls -lden` entry lines a directory prints (sudo-acl.ts).
|
|
12
14
|
*/
|
|
13
15
|
import { LAUNCHCTL, NOT_FOUND, NO_DOMAIN } from './launchctl.ts';
|
|
14
16
|
import type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
|
|
@@ -54,6 +56,16 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
54
56
|
const root = privileged || euid === 0;
|
|
55
57
|
const dirs = new Map(Object.entries(options.dirs ?? {}));
|
|
56
58
|
const files = new Map<string, Entry>();
|
|
59
|
+
/** Directory → the ACL entry lines `ls -lden` prints under it. */
|
|
60
|
+
const acls = new Map<string, string[]>();
|
|
61
|
+
/** The directories above the declared ones, as the host starts: root's, `0755`. */
|
|
62
|
+
const above = new Set<string>(['/']);
|
|
63
|
+
for (const dir of dirs.keys()) {
|
|
64
|
+
const parts = dir.split('/').slice(1, -1);
|
|
65
|
+
for (const index of parts.keys()) above.add(`/${parts.slice(0, index + 1).join('/')}`);
|
|
66
|
+
}
|
|
67
|
+
for (const dir of dirs.keys()) above.delete(dir);
|
|
68
|
+
const implied = (path: string) => above.has(path);
|
|
57
69
|
const calls: string[][] = [];
|
|
58
70
|
/** target → pid (undefined = loaded, not running). */
|
|
59
71
|
const loaded = new Map<string, number | undefined>();
|
|
@@ -128,6 +140,16 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
128
140
|
exec: async (argv) => {
|
|
129
141
|
calls.push([...argv]);
|
|
130
142
|
if (argv[0] === LAUNCHCTL) return launchctl(argv.slice(1));
|
|
143
|
+
if (argv[0] === '/bin/ls' && argv[1] === '-lden' && argv[2] === '--') {
|
|
144
|
+
const paths = argv.slice(3);
|
|
145
|
+
if (paths.some((path) => !dirs.has(path) && !files.has(path) && !implied(path)))
|
|
146
|
+
return fail(1, 'ls: No such file or directory');
|
|
147
|
+
const lines = paths.flatMap((path) => [
|
|
148
|
+
`drwxr-xr-x 2 0 0 64 Sep 21 10:00 ${path}`,
|
|
149
|
+
...(acls.get(path) ?? []),
|
|
150
|
+
]);
|
|
151
|
+
return ok(lines.join('\n'));
|
|
152
|
+
}
|
|
131
153
|
return fail(127, `fake: no program ${String(argv[0])}`);
|
|
132
154
|
},
|
|
133
155
|
lookupGroup: async (nameOrId) => options.groups?.[nameOrId],
|
|
@@ -153,9 +175,9 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
153
175
|
size: entry.bytes.length,
|
|
154
176
|
uid: entry.uid,
|
|
155
177
|
};
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
if (dirs.has(path) || implied(path))
|
|
179
|
+
return { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 };
|
|
180
|
+
return undefined;
|
|
159
181
|
},
|
|
160
182
|
writeFileAtomic: async (path: string, bytes: Uint8Array, write: WriteOptions) => {
|
|
161
183
|
calls.push(['write', path]);
|
|
@@ -172,5 +194,5 @@ export const fakeRunner = (options: FakeOptions = {}) => {
|
|
|
172
194
|
});
|
|
173
195
|
},
|
|
174
196
|
};
|
|
175
|
-
return { calls, disabled, dirs, files, loaded, runner, state };
|
|
197
|
+
return { acls, calls, disabled, dirs, files, loaded, runner, state };
|
|
176
198
|
};
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
|
|
3
|
-
* `news` both providers' diffs share
|
|
3
|
+
* `news` both providers' diffs share. (The engine's adopt setting lives in ownership/adopt.ts.)
|
|
4
4
|
*
|
|
5
5
|
* ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
|
|
6
6
|
* runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
|
|
7
7
|
* ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
|
|
8
8
|
* thrown is wrapped, so the failure channel is always an Error with a readable message.
|
|
9
9
|
*/
|
|
10
|
-
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
11
|
-
import { AlchemyContext } from 'alchemy/AlchemyContext';
|
|
12
|
-
import { Stack } from 'alchemy/Stack';
|
|
13
10
|
import * as Effect from 'effect/Effect';
|
|
14
|
-
import * as Option from 'effect/Option';
|
|
15
11
|
|
|
16
12
|
export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
|
|
17
13
|
Effect.tryPromise({
|
|
@@ -30,31 +26,3 @@ export const resolvedString = (news: unknown, key: string): string | undefined =
|
|
|
30
26
|
const value = (news as Record<string, unknown>)[key];
|
|
31
27
|
return typeof value === 'string' ? value : undefined;
|
|
32
28
|
};
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Whether the resource at `fqn` may be adopted, resolved exactly as the planner resolves it (alchemy
|
|
36
|
-
* beta.79 Plan.ts: `resource.Adopt ?? shouldAdopt`): a resource-scoped `adopt(…)`, else the
|
|
37
|
-
* `AdoptPolicy` service, else `AlchemyContext.adopt`, else off.
|
|
38
|
-
* ★ FOR A CREATE THE ENGINE NEVER PROBED. Alchemy skips the adoption probe while `news` holds an
|
|
39
|
-
* Output, so an ownership check that must still honour `--adopt` can only run at apply. All three
|
|
40
|
-
* reach the apply: Alchemist runs plan AND apply under the session context, which provides the
|
|
41
|
-
* services from the CLI flag and the Stack the resources registered on (Alchemist/Session.ts,
|
|
42
|
-
* routes/stack.ts; Apply.ts reads the same Stack).
|
|
43
|
-
* ⛔ THE RESOURCE-SCOPED SETTING WINS, BOTH WAYS. The engine captures `adopt(…)` on the resource at
|
|
44
|
-
* registration (Resource.ts `Adopt`, into `Stack.resources[fqn]`) and never hands it to a
|
|
45
|
-
* provider. Reading only the deploy-wide flag let `--adopt` take over a resource declared
|
|
46
|
-
* `.pipe(adopt(false))` — which the planner refuses — and refused one declared `adopt(true)`.
|
|
47
|
-
* ⚠️ `resources` carries an `@internal` comment in Alchemy's source, but it is in the published types
|
|
48
|
-
* and the planner reads `Adopt` from it. caddy/adopt-scope.test.ts registers through the real
|
|
49
|
-
* `Resource`, so an upgrade that stops recording it there fails the suite.
|
|
50
|
-
*/
|
|
51
|
-
export const adoptEnabled = (fqn: string): Effect.Effect<boolean> =>
|
|
52
|
-
Effect.gen(function* () {
|
|
53
|
-
const stack = yield* Effect.serviceOption(Stack);
|
|
54
|
-
const scoped = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
|
|
55
|
-
if (scoped !== undefined) return scoped;
|
|
56
|
-
const policy = yield* Effect.serviceOption(AdoptPolicy);
|
|
57
|
-
if (Option.isSome(policy)) return policy.value;
|
|
58
|
-
const context = yield* Effect.serviceOption(AlchemyContext);
|
|
59
|
-
return Option.isSome(context) ? context.value.adopt : false;
|
|
60
|
-
});
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
fileProblems,
|
|
16
16
|
} from './host-file-form.ts';
|
|
17
17
|
import { sha256Hex } from './job-form.ts';
|
|
18
|
-
import { type HostRunner, canActAsRoot } from './runner.ts';
|
|
18
|
+
import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
|
|
19
19
|
|
|
20
20
|
type Desired = {
|
|
21
21
|
readonly bytes: Uint8Array;
|
|
@@ -72,29 +72,55 @@ export const readFileAttributes = async (
|
|
|
72
72
|
};
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
const writeOptionsOf = (want: Desired): WriteOptions => ({
|
|
76
|
+
mode: want.mode,
|
|
77
|
+
...(want.uid === undefined ? {} : { uid: want.uid }),
|
|
78
|
+
...(want.gid === undefined ? {} : { gid: want.gid }),
|
|
79
|
+
});
|
|
80
|
+
|
|
75
81
|
const matches = (live: HostFileAttributes, want: Desired): boolean =>
|
|
76
82
|
live.sha256 === want.sha256 &&
|
|
77
83
|
live.mode === want.mode &&
|
|
78
84
|
(want.uid === undefined || live.uid === want.uid) &&
|
|
79
85
|
(want.gid === undefined || live.gid === want.gid);
|
|
80
86
|
|
|
87
|
+
/**
|
|
88
|
+
* ★ A PLAN THAT WILL WRITE ASKS THE RUNNER FIRST (`checkWrite`, when it has one): sudoRunner's
|
|
89
|
+
* refusals — a setuid or group-writable root file, a directory under a prefix another user may
|
|
90
|
+
* change — then fail the plan, before any resource is applied, instead of halfway through it.
|
|
91
|
+
*/
|
|
81
92
|
export const diffFile = async (
|
|
82
93
|
runner: HostRunner,
|
|
83
94
|
news: HostFileProps,
|
|
84
95
|
output: HostFileAttributes,
|
|
85
96
|
): Promise<Diff> => {
|
|
86
|
-
// ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
|
|
87
|
-
if (news.path !== output.path) return { action: 'replace' };
|
|
88
97
|
const want = await desiredFile(runner, news);
|
|
89
|
-
|
|
98
|
+
const writes = async (diff: Diff): Promise<Diff> => {
|
|
99
|
+
await runner.checkWrite?.(news.path, writeOptionsOf(want));
|
|
100
|
+
return diff;
|
|
101
|
+
};
|
|
102
|
+
// ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
|
|
103
|
+
if (news.path !== output.path) return writes({ action: 'replace' });
|
|
104
|
+
if (want.sha256 !== output.sha256) return writes({ action: 'update' });
|
|
90
105
|
const live = await readFileAttributes(runner, news.path);
|
|
91
|
-
return live !== undefined && matches(live, want)
|
|
106
|
+
return live !== undefined && matches(live, want)
|
|
107
|
+
? { action: 'noop' }
|
|
108
|
+
: writes({ action: 'update' });
|
|
92
109
|
};
|
|
93
110
|
|
|
111
|
+
/**
|
|
112
|
+
* `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
|
|
113
|
+
* own (ownership/adopt.ts adoptsAtApply). It lets such a generation take over a file already at the
|
|
114
|
+
* path — the takeover the plan's probe would have allowed, had it run. ⛔ A move onto an occupied
|
|
115
|
+
* path stays refused, both ways the engine drives one: an `update` across the move (`moved`,
|
|
116
|
+
* below), and a fresh replace's new generation, which the caller never marks adoptable (its
|
|
117
|
+
* `output` is undefined too, so this function cannot tell it from a create by itself).
|
|
118
|
+
*/
|
|
94
119
|
export const reconcileFile = async (
|
|
95
120
|
runner: HostRunner,
|
|
96
121
|
props: HostFileProps,
|
|
97
122
|
output?: HostFileAttributes,
|
|
123
|
+
adopt = false,
|
|
98
124
|
): Promise<HostFileAttributes> => {
|
|
99
125
|
const want = await desiredFile(runner, props);
|
|
100
126
|
// ⛔ NO SILENT SUDO: handing a file to another user is root's call (chown(2)), so say so up front.
|
|
@@ -120,11 +146,14 @@ export const reconcileFile = async (
|
|
|
120
146
|
const prior = moved ? undefined : output;
|
|
121
147
|
/**
|
|
122
148
|
* ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
|
|
123
|
-
* the engine's adoption probe has already refused anything it found — except
|
|
124
|
-
* a replace,
|
|
125
|
-
*
|
|
149
|
+
* the engine's adoption probe has already refused anything it found — except where it never
|
|
150
|
+
* looked: the new path of a replace, and a create whose props still held an Output at plan
|
|
151
|
+
* time. A path typo there would otherwise overwrite, say, a system file that a fresh
|
|
152
|
+
* declaration of the same path would have been refused as `Unowned`. ★ With adoption on, a
|
|
153
|
+
* create takes it over, as the probe would have let it (decided 2026-09-21).
|
|
126
154
|
*/
|
|
127
|
-
|
|
155
|
+
const takeOver = adopt && output === undefined;
|
|
156
|
+
if (prior === undefined && before !== undefined && !matches(before, want) && !takeOver) {
|
|
128
157
|
throw refuse(
|
|
129
158
|
props.path,
|
|
130
159
|
'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
|
|
@@ -132,11 +161,7 @@ export const reconcileFile = async (
|
|
|
132
161
|
);
|
|
133
162
|
}
|
|
134
163
|
if (before === undefined || !matches(before, want)) {
|
|
135
|
-
await runner.writeFileAtomic(props.path, want.bytes,
|
|
136
|
-
mode: want.mode,
|
|
137
|
-
...(want.uid === undefined ? {} : { uid: want.uid }),
|
|
138
|
-
...(want.gid === undefined ? {} : { gid: want.gid }),
|
|
139
|
-
});
|
|
164
|
+
await runner.writeFileAtomic(props.path, want.bytes, writeOptionsOf(want));
|
|
140
165
|
}
|
|
141
166
|
// ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
|
|
142
167
|
// up here as a refusal instead of as a forever-`update`.
|