@homeflare/alchemy 0.7.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -45
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +270 -44
- package/dist/caddy/index.js.map +15 -11
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- 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 +51 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.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.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +642 -49
- package/dist/launchd/index.js.map +24 -14
- 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-validate.d.ts +2 -0
- package/dist/launchd/job-validate.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/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +14 -5
- 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-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +83 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.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-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- 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-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +52 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- 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/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +709 -359
- package/dist/openbao/index.js.map +32 -25
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +12 -5
- 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/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.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-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- 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 +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -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 +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -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 +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +147 -0
- package/docs/launchd.md +14 -7
- package/docs/ownership.md +113 -0
- package/docs/peers.md +39 -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 +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -10
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +1 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +41 -15
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +21 -3
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +14 -5
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +230 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +216 -0
- package/src/launchd/sudo-said.ts +58 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +11 -2
- package/src/openbao/REPLACE.md +67 -37
- 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-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +48 -17
- package/src/openbao/cloudflare-role.ts +23 -18
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +188 -0
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +35 -11
- package/src/openbao/jwt-role.ts +33 -14
- package/src/openbao/kubernetes-role.ts +32 -14
- package/src/openbao/mfa-enforcement.ts +42 -13
- package/src/openbao/mfa-totp.ts +42 -14
- 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 +33 -10
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +57 -20
- package/src/openbao/policy.ts +24 -11
- package/src/openbao/proxmox-role.ts +20 -20
- package/src/openbao/rename-adoption.test.ts +95 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename.ts +19 -15
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +29 -57
- 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 +88 -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 +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Ownership — nothing is adopted without `--adopt`
|
|
2
|
+
|
|
3
|
+
A live object this stack holds **no state for** is never taken over silently. That holds for every
|
|
4
|
+
`Bao.*` family, `HostFile`, `LaunchdJob`, `CaddyConfig` and `ProxmoxLxc`, and it holds even when the
|
|
5
|
+
live object is **identical** to the declaration: identical is not ours. Another stack, a person or an old
|
|
6
|
+
script put it there, and once state claims it, a delete under `RemovalPolicy.destroy()` removes it
|
|
7
|
+
from its real owner. (Decided 2026-09-21; the Bao families adopted silently until 0.9.0.)
|
|
8
|
+
|
|
9
|
+
To take an object over, say so:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
alchemy deploy --adopt --dry-run # read it first: `alchemy plan` has no --adopt (beta.79)
|
|
13
|
+
alchemy deploy --adopt # every resource in this deploy
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
⚠️ Only `deploy` declares `--adopt` in alchemy 2.0.0-beta.79, so `alchemy plan` fails "Cannot
|
|
17
|
+
adopt" before any resource can say what taking the object over would write. `--dry-run` shows it.
|
|
18
|
+
★ Even then every adoption prints `adopted`, drift or not. `hf-adopt-verify` plans with adoption on,
|
|
19
|
+
never writes, and prints each row's own diff and an exit code: [adopt-verify.md](./adopt-verify.md).
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
23
|
+
|
|
24
|
+
// this resource only
|
|
25
|
+
export const app = BaoPolicy('app', { fragments: './policies/app', name: 'app' }).pipe(adopt(true));
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
A resource-scoped `adopt(…)` wins over the flag, both ways: `.pipe(adopt(false))` stays refused
|
|
29
|
+
under `--adopt`.
|
|
30
|
+
|
|
31
|
+
## Where it is checked
|
|
32
|
+
|
|
33
|
+
| when | what happens to a live object with no state |
|
|
34
|
+
| ----------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
35
|
+
| **plan** — Alchemy's adoption probe | `read` answers `Unowned`; the plan fails with `OwnedBySomeoneElse` unless adoption is on |
|
|
36
|
+
| **apply** — the probe never ran | `reconcile` refuses before any write unless adoption is on |
|
|
37
|
+
|
|
38
|
+
Alchemy skips the probe while a prop is still an `Output` (it comes from a resource created in the
|
|
39
|
+
same deploy), and it never probes the new generation of a replace. An object can also appear
|
|
40
|
+
between plan and apply. So each family checks again in `reconcile`, reading the object before it
|
|
41
|
+
writes, and resolves `--adopt` / `adopt(…)` exactly as the planner does
|
|
42
|
+
(`src/ownership/adopt.ts`).
|
|
43
|
+
|
|
44
|
+
- A refused create also **forgets the `creating` row** Apply wrote for it, so the next plan probes
|
|
45
|
+
again and refuses again. Left behind, that row's recovery read could have adopted the very object
|
|
46
|
+
the apply refused (measured: `Bao.Mount` and `Bao.AuthMethod`, whose omitted props are not managed).
|
|
47
|
+
- `Bao.Mount` / `Bao.AuthMethod`: a create with `remountFrom` moves a live mount that this stack
|
|
48
|
+
holds no state for. That move is a takeover too, and it is refused the same way.
|
|
49
|
+
- **Adoption at apply is for a create, or an unfinished generation.** The planner offers `--adopt`
|
|
50
|
+
to a resource with no state, and Alchemy offers it to resume an interrupted create; so at apply
|
|
51
|
+
it lets through a `creating` row, or a generation whose row the plan's `diff` saw with no
|
|
52
|
+
attributes (an interrupted create or replace). A **fresh** replace's new generation that finds
|
|
53
|
+
its new identity taken is refused, `--adopt` or not: the plan never asked about that identity
|
|
54
|
+
(measured 2026-09-21: a `HostFile` whose path changed onto a file it did not own overwrote it
|
|
55
|
+
under a deploy-wide `--adopt`). For the Bao families a rename onto a taken identity already fails
|
|
56
|
+
the plan; this keeps the apply to the same answer.
|
|
57
|
+
|
|
58
|
+
## Crash recovery still works — when the row can prove it
|
|
59
|
+
|
|
60
|
+
A deploy that dies after its write and before its commit leaves a `creating` (or `replacing`) row
|
|
61
|
+
and a live object. The next deploy finishes it **without** `--adopt`:
|
|
62
|
+
|
|
63
|
+
- **An interrupted create.** Alchemy's recovery read passes the row's own instance id, which the
|
|
64
|
+
state store records (a probe's id is freshly minted, so no row holds it). The object is ours when
|
|
65
|
+
it also matches that row's props. If it does not, it may have lost a race to someone else, so it
|
|
66
|
+
is `Unowned`: the plan says `Cannot resume creating`, and `--adopt` resumes it.
|
|
67
|
+
- **An interrupted replace.** Alchemy never probes a `replacing` row; it calls `diff`. For a row
|
|
68
|
+
with no attributes, every Bao family's and `ProxmoxLxc`'s `diff` first asks its own `read` the
|
|
69
|
+
recovery question —
|
|
70
|
+
recorded instance, whole row, matching object (`provingResumes`, `src/ownership/resume.ts`) — and
|
|
71
|
+
only a "yes" leaves a note in Alchemy's per-deploy `Artifacts` bag that lets the apply's own
|
|
72
|
+
object through.
|
|
73
|
+
|
|
74
|
+
⛔ **A row proves nothing by itself.** Apply commits it **before** reconcile runs, so a deploy killed
|
|
75
|
+
before the claim (an upstream that failed, a Ctrl-C) leaves a row whose create never asked whose
|
|
76
|
+
object sat at its identity. Found by the red team on 2026-09-21 (`openbao/adopt-holes.test.ts`):
|
|
77
|
+
|
|
78
|
+
- **A row missing part of its declaration proves nothing.** Apply strips a prop that was still an
|
|
79
|
+
`Output` from the row, and most families read an absent prop as "not managed". So the row must
|
|
80
|
+
carry every value the declaration names (`src/ownership/whole.ts`); one with a hole is `Unowned`.
|
|
81
|
+
Measured: a `Bao.Mount` whose `defaultLeaseTtl` was an Output adopted, and tuned, another owner's
|
|
82
|
+
mount; with the name an Output, every role family resumed onto another owner's role.
|
|
83
|
+
- **So a create or replace interrupted while a prop was still an Output resumes with `--adopt`.**
|
|
84
|
+
Its row cannot say what it wrote, and that deploy's plan never asked whose object was there.
|
|
85
|
+
|
|
86
|
+
⚠️ **A multi-step create interrupted halfway is not proven ours.** `Bao.Mount` enables, then tunes.
|
|
87
|
+
If the deploy dies between the two, the mount does not match its props, and the resume needs
|
|
88
|
+
`--adopt`. That is the safe direction.
|
|
89
|
+
|
|
90
|
+
⚠️ **It fails closed.** If plan and apply do not share an `Artifacts` bag, or the store cannot be
|
|
91
|
+
read, a resume reads as a fresh create and asks for `--adopt`. `alchemy deploy` and the Alchemist
|
|
92
|
+
session both share one. An orphan's interrupted create (no longer declared) cannot be compared with
|
|
93
|
+
a declaration, so Apply leaves its object in place with a note instead of deleting it.
|
|
94
|
+
|
|
95
|
+
## Limits
|
|
96
|
+
|
|
97
|
+
- ⚠️ **A check, then a write: not a lock.** Two resources that declare the same name in **one**
|
|
98
|
+
deploy create concurrently. Both read nothing, and both write (measured with the fakes). Declare
|
|
99
|
+
each name once.
|
|
100
|
+
- ⚠️ **Proven at plan, written at apply.** A resume is proven against the object the plan read.
|
|
101
|
+
One swapped for another between that read and the apply is written over. That takes a race.
|
|
102
|
+
- ⚠️ **Identical is still ours to a recovery read.** A foreign object identical to a whole row's
|
|
103
|
+
props, at the identity an interrupted create or replace was going to write, reads as ours — the
|
|
104
|
+
rule Plan.ts itself applies to a recovery read.
|
|
105
|
+
- ⚠️ **`HostFile` and `LaunchdJob` answer `Unowned` to the recovery read too.** A create of theirs
|
|
106
|
+
interrupted after its write needs `--adopt` on the next deploy. Both remove what a failed create
|
|
107
|
+
wrote, so this needs a kill between the write and the commit.
|
|
108
|
+
- ⚠️ **`CaddyConfig` keeps its own probe.** A running config identical to the declaration reads as
|
|
109
|
+
ours, because adopting it changes nothing Caddy serves ([caddy.md](./caddy.md#adoption)).
|
|
110
|
+
`ProxmoxLxc` does not share that exception: its delete removes a guest and its volumes
|
|
111
|
+
([proxmox-lxc.md](./proxmox-lxc.md#adopting-what-the-plan-says)).
|
|
112
|
+
- ⚠️ **The other `@homeflare/alchemy/proxmox` resources do not follow this rule yet.** Those built
|
|
113
|
+
on `pveHandlers` (`src/proxmox/resource.ts`) still read a live object with no state as ours.
|
package/docs/peers.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Peers — and why each pin exists
|
|
2
|
+
|
|
3
|
+
The install line for `@homeflare/alchemy`, and why every peer on it and every entry in the
|
|
4
|
+
`overrides` block is required. The block itself, which every consumer copies, is in the
|
|
5
|
+
[README](../README.md#peers--and-one-override-you-need).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @homeflare/alchemy alchemy@2.0.0-beta.79 effect@4.0.0-rc.115 \
|
|
9
|
+
@effect/platform-node@4.0.0-rc.115 cloudflare@4.5.0 mime@4.1.0 \
|
|
10
|
+
@distilled.cloud/cloudflare@1.0.0-rc.12
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
🔴 **Why, measured 2026-09-16 on 0.1.0 and re-checked 2026-09-17 against Alchemy 78.**
|
|
14
|
+
Effect's `rc` line is not semver-compatible with itself. Alchemy 78's peer is
|
|
15
|
+
`effect >= 4.0.0-rc.115`; an unlocked `@effect/platform-node-shared` still floats to the
|
|
16
|
+
next rc and breaks at import. Pin the whole set.
|
|
17
|
+
|
|
18
|
+
| what resolves | what happens |
|
|
19
|
+
| ------------------------------------------------------------- | ------------------------------------------------------ |
|
|
20
|
+
| Alchemy 77 + `effect` → rc.115 (measured 2026-09-16) | `TypeError: Config.string is not a function` at import |
|
|
21
|
+
| `@effect/platform-node-shared` newer than the pinned `effect` | `Cannot find module 'effect/ByteSize'` |
|
|
22
|
+
| `rolldown` → 1.2.9 via vite's `~1.2.6` (measured 2026-09-16) | `GET …/rolldown-1.2.9.tgz - 404` at `bun add` |
|
|
23
|
+
| no `mime` (measured 2026-09-17 against Alchemy 78) | `Cannot find package 'mime'` from cloudflare-runtime |
|
|
24
|
+
|
|
25
|
+
Alchemy 78 adapted to rc.115 — that first row is why we used to pin 112, not a reason to
|
|
26
|
+
stay there. The override is still the only thing that holds the set together.
|
|
27
|
+
|
|
28
|
+
⚠️ `@effect/platform-node` is **required, not optional**: Alchemy's module graph reaches
|
|
29
|
+
`Cloudflare/Workers/WorkerBridge → @effect/platform-node/NodeServices` even when you only
|
|
30
|
+
import the Proxmox subpath.
|
|
31
|
+
|
|
32
|
+
⚠️ So is `mime`. Alchemy 78's `@alchemy.run/cloudflare-runtime` imported it without
|
|
33
|
+
declaring it (measured 2026-09-17). 79 declares it; the peer stays so a consumer
|
|
34
|
+
that followed the 78 README does not drop a required line.
|
|
35
|
+
|
|
36
|
+
⚠️ So is `cloudflare`. It was marked optional in 0.1.1, which claimed the `/cloudflare`
|
|
37
|
+
subpath would degrade without it — measured 2026-09-16, the subpath does not load at all:
|
|
38
|
+
`Cannot find package 'cloudflare'`. An optional peer should mean a feature is absent, not
|
|
39
|
+
that an import fails.
|
|
@@ -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.0",
|
|
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": {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A resource-scoped `adopt(…)` at apply — the create Alchemy never probed (caddyWithFile's first
|
|
3
|
+
* deploy). The planner resolves adoption as `resource.Adopt ?? --adopt` (alchemy beta.79 Plan.ts);
|
|
4
|
+
* the apply-time check must resolve it the same way, BOTH ways, or `--adopt` would take over a
|
|
5
|
+
* Caddy the stack declared `.pipe(adopt(false))`.
|
|
6
|
+
* ★ Registered through the REAL `Resource` (caddyWithFile under a Stack), not a hand-built map: the
|
|
7
|
+
* lookup reads what Alchemy records at registration, so an upgrade that stops recording `Adopt`
|
|
8
|
+
* in `Stack.resources` fails here (ownership/adopt.ts adoptEnabled).
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, expect, test } from 'bun:test';
|
|
11
|
+
import { push } from 'alchemy';
|
|
12
|
+
import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import { Stack } from 'alchemy/Stack';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import * as Layer from 'effect/Layer';
|
|
16
|
+
import { caddyAdminLayer } from './admin.ts';
|
|
17
|
+
import { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
18
|
+
import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
|
|
19
|
+
import { caddyWithFile } from './with-file.ts';
|
|
20
|
+
|
|
21
|
+
const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
|
|
22
|
+
/** A config someone else loaded: it serves a site, and it is not what the stack declares. */
|
|
23
|
+
const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
|
|
24
|
+
|
|
25
|
+
let fake: FakeCaddy | undefined;
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fake?.stop();
|
|
28
|
+
fake = undefined;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
type Scope = 'adopt(true)' | 'adopt(false)' | 'none';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Declare caddyWithFile with `scope` under a deploy whose `--adopt` is `flag` — the session provides
|
|
35
|
+
* AdoptPolicy to registration AND apply, as Alchemist does — then apply the CaddyConfig with no state.
|
|
36
|
+
*/
|
|
37
|
+
const applyFirstDeploy = (scope: Scope, flag: boolean) => {
|
|
38
|
+
const made = fakeDefaultCaddy(FOREIGN);
|
|
39
|
+
fake = made.caddy;
|
|
40
|
+
const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
|
|
41
|
+
const declared = caddyWithFile('caddy', { caddyfile: SITE, path: '/usr/local/etc/Caddyfile' });
|
|
42
|
+
const scoped =
|
|
43
|
+
scope === 'none'
|
|
44
|
+
? declared
|
|
45
|
+
: (declared.pipe(adopt(scope === 'adopt(true)')) as typeof declared);
|
|
46
|
+
const program = Effect.gen(function* () {
|
|
47
|
+
// ★ Under a namespace, so the FQN (`edge/caddy`) differs from the logical id (`caddy`).
|
|
48
|
+
yield* push('edge', scoped);
|
|
49
|
+
const provider = yield* CaddyConfig.Provider;
|
|
50
|
+
return yield* provider.reconcile({
|
|
51
|
+
bindings: [] as never,
|
|
52
|
+
fqn: 'edge/caddy',
|
|
53
|
+
id: 'caddy',
|
|
54
|
+
instanceId: 'i-1',
|
|
55
|
+
news: { caddyfile: SITE, sourceFile: '/usr/local/etc/Caddyfile' },
|
|
56
|
+
olds: undefined,
|
|
57
|
+
output: undefined,
|
|
58
|
+
session: undefined as never,
|
|
59
|
+
});
|
|
60
|
+
}).pipe(
|
|
61
|
+
Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(made.admin)))),
|
|
62
|
+
Effect.provideService(AdoptPolicy, flag),
|
|
63
|
+
Effect.provideService(Stack, spec as never),
|
|
64
|
+
);
|
|
65
|
+
return { applying: Effect.runPromise(program as Effect.Effect<unknown, unknown>), spec };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const loads = () => fake?.seen.filter((call) => call.path === '/load') ?? [];
|
|
69
|
+
|
|
70
|
+
test.each([
|
|
71
|
+
['adopt(false) under --adopt', 'adopt(false)', true, false],
|
|
72
|
+
['adopt(true) without --adopt', 'adopt(true)', false, true],
|
|
73
|
+
['no scope under --adopt', 'none', true, true],
|
|
74
|
+
['no scope without --adopt', 'none', false, false],
|
|
75
|
+
] as const)('a first deploy with %s', async (_, scope, flag, loaded) => {
|
|
76
|
+
const { applying, spec } = applyFirstDeploy(scope, flag);
|
|
77
|
+
if (loaded) await applying;
|
|
78
|
+
else await expect(applying).rejects.toThrow(/which this stack did not load.*--adopt/);
|
|
79
|
+
// ★ The lookup key is the FQN the engine hands reconcile — the one registration used.
|
|
80
|
+
expect(Object.keys(spec.resources)).toContain('edge/caddy');
|
|
81
|
+
expect(loads()).toHaveLength(loaded ? 1 : 0);
|
|
82
|
+
});
|