@homeflare/alchemy 0.10.0 → 0.11.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/dist/caddy/index.js.map +2 -2
- package/dist/launchd/index.js.map +2 -2
- package/dist/openbao/index.js +10 -8
- package/dist/openbao/index.js.map +4 -4
- package/dist/ownership/adopting.d.ts +34 -0
- package/dist/ownership/adopting.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +15 -5
- package/dist/ownership/probe.d.ts.map +1 -1
- package/dist/ownership/rows.d.ts +12 -0
- package/dist/ownership/rows.d.ts.map +1 -1
- package/dist/ownership/store-fixture.d.ts +10 -0
- package/dist/ownership/store-fixture.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +8 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/dist/proxmox/index.js +147 -113
- package/dist/proxmox/index.js.map +8 -6
- package/dist/proxmox/lxc-adoption.d.ts +32 -0
- package/dist/proxmox/lxc-adoption.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +12 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -1
- package/dist/proxmox/lxc.d.ts +5 -5
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/verify/cli.js +79 -29
- package/dist/verify/cli.js.map +8 -6
- package/dist/verify/fake-engine.d.ts +9 -1
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/dist/verify/index.d.ts +1 -1
- package/dist/verify/index.d.ts.map +1 -1
- package/dist/verify/index.js +67 -17
- package/dist/verify/index.js.map +8 -6
- 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/rows.d.ts +13 -3
- package/dist/verify/rows.d.ts.map +1 -1
- package/dist/verify/spy.d.ts +12 -0
- package/dist/verify/spy.d.ts.map +1 -1
- package/dist/verify/verify.d.ts.map +1 -1
- package/docs/adopt-verify.md +13 -2
- package/docs/adopted-deploys.md +4 -2
- package/docs/ownership.md +1 -1
- package/docs/proxmox-lxc-adopt.md +83 -0
- package/docs/proxmox-lxc.md +7 -45
- package/docs/proxmox.md +2 -0
- package/package.json +1 -1
- package/src/ownership/adopting.test.ts +63 -0
- package/src/ownership/adopting.ts +46 -0
- package/src/ownership/ownership.test.ts +1 -34
- package/src/ownership/probe.ts +27 -14
- package/src/ownership/rows.ts +8 -2
- package/src/ownership/store-fixture.ts +50 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +32 -0
- package/src/proxmox/fake-pve-lxc.ts +13 -1
- package/src/proxmox/lxc-adopt.test.ts +13 -20
- package/src/proxmox/lxc-adoption.ts +50 -0
- package/src/proxmox/lxc-guards.test.ts +6 -13
- package/src/proxmox/lxc-harness.ts +25 -2
- package/src/proxmox/lxc-ownership.test.ts +19 -14
- package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
- package/src/proxmox/lxc.ts +16 -12
- package/src/verify/fake-engine.ts +24 -0
- package/src/verify/index.ts +1 -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/rows.ts +46 -5
- package/src/verify/spy.ts +14 -3
- package/src/verify/verify.test.ts +22 -0
- package/src/verify/verify.ts +38 -25
package/docs/ownership.md
CHANGED
|
@@ -108,6 +108,6 @@ a declaration, so Apply leaves its object in place with a note instead of deleti
|
|
|
108
108
|
- ⚠️ **`CaddyConfig` keeps its own probe.** A running config identical to the declaration reads as
|
|
109
109
|
ours, because adopting it changes nothing Caddy serves ([caddy.md](./caddy.md#adoption)).
|
|
110
110
|
`ProxmoxLxc` does not share that exception: its delete removes a guest and its volumes
|
|
111
|
-
([proxmox-lxc.md](./proxmox-lxc.md
|
|
111
|
+
([proxmox-lxc-adopt.md](./proxmox-lxc-adopt.md)).
|
|
112
112
|
- ⚠️ **The other `@homeflare/alchemy/proxmox` resources do not follow this rule yet.** Those built
|
|
113
113
|
on `pveHandlers` (`src/proxmox/resource.ts`) still read a live object with no state as ours.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Proxmox.Lxc — adopting a live guest
|
|
2
|
+
|
|
3
|
+
The adoption half of [proxmox-lxc.md](./proxmox-lxc.md). Two rules, both decided 2026-09-21:
|
|
4
|
+
|
|
5
|
+
1. ⛔ **No live guest is adopted without `adopt(true)` or `--adopt`, not even one that matches the
|
|
6
|
+
declaration** — the kit-wide rule in [ownership.md](./ownership.md). Without either, the plan
|
|
7
|
+
fails with "Cannot adopt" and nothing is written. Matching is not proof the guest is yours: a
|
|
8
|
+
pasted config or a mistyped vmid reads the same, and once state claims a guest,
|
|
9
|
+
`RemovalPolicy.destroy()` deletes it and its volumes. `.pipe(adopt(false))` stays refused
|
|
10
|
+
under `--adopt`.
|
|
11
|
+
2. ⛔ **An adoption never changes a guest.** It is taken over exactly as it runs, or not at all.
|
|
12
|
+
With adoption on, a declaration that differs from the live config in any key fails the plan:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
CT 100 on pve1: adopting it would change memory, net1. An adoption never changes a guest, so
|
|
16
|
+
nothing is written. Declare what is live for these keys (the plan names keys, never values) and
|
|
17
|
+
plan again; change them after the adoption, as an update.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **Keys are named, values never.** A live config can carry anything (`description` is where
|
|
21
|
+
people paste credentials), and plan output lands in CI logs.
|
|
22
|
+
- **The fix is the declaration.** Copy the live value for each named key and plan again. A plan
|
|
23
|
+
that says `adopted` and fails nothing is the zero-change check: the deploy writes nothing (two
|
|
24
|
+
GETs), and the next plan is `noop`.
|
|
25
|
+
- **To change the guest, adopt it first.** Once state holds it, a changed declaration is an
|
|
26
|
+
ordinary `update`, and its plan warns with each key the deploy writes:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Proxmox.Lxc pve1/100: live config differs from the declaration in memory -- a deploy writes these.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **The deploy asks again.** A key edited by hand between the plan and the deploy is refused at
|
|
33
|
+
apply, with nothing written. The adoption's state row stays, and every plan refuses the same way
|
|
34
|
+
until the declaration says what is live. Then it adopts with no write.
|
|
35
|
+
- **It comes before the other refusals.** A root@pam-only key, a smaller disk or an unprivileged
|
|
36
|
+
flip would print a declared value and a `pct set` to run. For an adoption the fix runs the other
|
|
37
|
+
way, so the adoption refusal is the one you see.
|
|
38
|
+
|
|
39
|
+
★ **Why it is strict.** Alchemy 2.0.0-beta.79 plans a cold adoption as `adopted` whatever it would
|
|
40
|
+
write, and prints no property diff for it. Until this rule, `diff` only logged the keys, and
|
|
41
|
+
`deploy --adopt --yes` then wrote them: a `net0` pasted without the live `tag=` took the guest off
|
|
42
|
+
its VLAN on the deploy that was meant to change nothing.
|
|
43
|
+
|
|
44
|
+
⚠️ **`alchemy plan` has no `--adopt` flag** (2.0.0-beta.79: only `deploy` declares it). Without
|
|
45
|
+
adoption on, the plan stops at "Cannot adopt" before the adoption check runs. To read the
|
|
46
|
+
adoption's plan, run `alchemy deploy --adopt --dry-run`, or declare `.pipe(adopt(true))` and run
|
|
47
|
+
`alchemy plan`.
|
|
48
|
+
|
|
49
|
+
## What counts as an adoption
|
|
50
|
+
|
|
51
|
+
State says, at plan and at apply alike (`src/ownership/adopting.ts`):
|
|
52
|
+
|
|
53
|
+
| the guest reached state… | adoption? |
|
|
54
|
+
| ------------------------------------------------------------------------------- | --------- |
|
|
55
|
+
| through the adoption probe: no state for it, found live | yes |
|
|
56
|
+
| on the deploy after that probe, until the adoption commits | yes |
|
|
57
|
+
| through an interrupted create the plan could not prove its own, under `--adopt` | yes |
|
|
58
|
+
| through an interrupted create whose guest still matches what it wrote | no |
|
|
59
|
+
| through this resource's own create, or an adoption that has committed | no |
|
|
60
|
+
|
|
61
|
+
An interrupted create proven its own is not an adoption. If its declaration changed meanwhile, the
|
|
62
|
+
deploy writes the change.
|
|
63
|
+
|
|
64
|
+
## Creates that find a guest
|
|
65
|
+
|
|
66
|
+
A deploy that planned a **create** never takes over a guest it then finds at that vmid (the plan
|
|
67
|
+
skips its adoption read while a prop is an unresolved Output, and a guest can appear after the
|
|
68
|
+
plan). Without adoption on, the deploy fails and forgets its `creating` row, so the next plan asks.
|
|
69
|
+
With it on, a matching guest is recorded with no write; any other fails until a plan can show it.
|
|
70
|
+
|
|
71
|
+
A create interrupted after its POST resumes on the next deploy **without** `--adopt` when the guest
|
|
72
|
+
still matches what it declared. If someone changed it meanwhile, the plan says "Cannot resume
|
|
73
|
+
creating". `--adopt` then adopts it as it now runs: declare what is live first.
|
|
74
|
+
|
|
75
|
+
A guest the cluster no longer lists at all, with state still held, plans `update` and warns that
|
|
76
|
+
the deploy **creates it again** from `ostemplate`, with new volumes. The plan fails instead when
|
|
77
|
+
there is no `ostemplate`, or when `rootfs`/`mpN` are declared by live volume id (as a paste is):
|
|
78
|
+
a create never reuses a volume.
|
|
79
|
+
|
|
80
|
+
## Leases
|
|
81
|
+
|
|
82
|
+
`GET …/config` needs `VM.Audit`, which the `read` lease has. A config PUT uses the cached
|
|
83
|
+
`provision` lease. A create, resize or delete mints a fresh one and polls its task with it.
|
package/docs/proxmox-lxc.md
CHANGED
|
@@ -90,7 +90,8 @@ Every prop is the key and spelling PVE uses in `/nodes/{node}/lxc/{vmid}/config`
|
|
|
90
90
|
|
|
91
91
|
The `digest` makes PVE refuse the write if the config changed since the deploy read it. ⚠️ That
|
|
92
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.
|
|
93
|
+
is written back to the declaration, even after a plan that warned of nothing. (An adoption
|
|
94
|
+
refuses it instead.)
|
|
94
95
|
|
|
95
96
|
On a running guest, PVE may keep a change as pending until the next restart. A read returns the
|
|
96
97
|
pending value, so the plan still converges.
|
|
@@ -141,51 +142,12 @@ guest that needs `/dev/net/tun` (a Mesh door) or `/dev/dri` is created in two st
|
|
|
141
142
|
- **Pool membership, HA, firewall rules, snapshots, replication** are not declared here. Use their
|
|
142
143
|
own resources, or manage them by hand.
|
|
143
144
|
|
|
144
|
-
## Adopting
|
|
145
|
+
## Adopting
|
|
145
146
|
|
|
146
|
-
⛔ **No live guest is adopted without `adopt(true)` or `--adopt`,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
147
|
+
⛔ **No live guest is adopted without `adopt(true)` or `--adopt`, and an adoption never changes
|
|
148
|
+
a guest.** It is taken over exactly as it runs, or the plan fails naming each key that differs.
|
|
149
|
+
What the plan says, what counts as an adoption, and the leases each step uses:
|
|
150
|
+
[proxmox-lxc-adopt.md](./proxmox-lxc-adopt.md).
|
|
189
151
|
|
|
190
152
|
## Removing
|
|
191
153
|
|
package/docs/proxmox.md
CHANGED
|
@@ -8,5 +8,7 @@ or creates one from a template.
|
|
|
8
8
|
flip or another template fails the plan. Destroy retains unless `RemovalPolicy.destroy()`.
|
|
9
9
|
- ⛔ **It adopts nothing without `--adopt` or `adopt(true)`**, a matching guest included
|
|
10
10
|
([ownership.md](./ownership.md)): under `destroy`, a claimed guest's disks go with it.
|
|
11
|
+
- ⛔ **An adoption never changes a guest.** Any key that differs from the live config fails the
|
|
12
|
+
plan, named without its value ([proxmox-lxc-adopt.md](./proxmox-lxc-adopt.md)).
|
|
11
13
|
- ⛔ **root@pam-only keys** (`devN`, bind mounts, features beyond `nesting`) are refused at plan,
|
|
12
14
|
with the `pct set` to run instead. Guide: [proxmox-lxc.md](./proxmox-lxc.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
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",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `adopting` answer by answer, against a store fixture: which rows make a call with attributes an
|
|
3
|
+
* adoption. `Proxmox.Lxc` pins the same answers end to end through the engine
|
|
4
|
+
* (proxmox/lxc-strict-adopt.test.ts, proxmox/lxc-ownership.test.ts).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import type { ResourceState } from 'alchemy/State/ResourceState';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { adopting } from './adopting.ts';
|
|
10
|
+
import { row, withStore } from './store-fixture.ts';
|
|
11
|
+
|
|
12
|
+
const matches = () => Effect.succeed(true);
|
|
13
|
+
const differs = () => Effect.succeed(false);
|
|
14
|
+
const held = { name: 'a' };
|
|
15
|
+
const owner = (instanceId: string, output: unknown = held) => ({ fqn: 'A', instanceId, output });
|
|
16
|
+
const creating = { fqn: 'A', instanceId: 'probe', output: undefined };
|
|
17
|
+
|
|
18
|
+
const recorded = (status: string, extra: Record<string, unknown> = {}): ResourceState =>
|
|
19
|
+
({ ...row('A', 'mine'), attr: held, status, ...extra }) as unknown as ResourceState;
|
|
20
|
+
|
|
21
|
+
describe('adopting', () => {
|
|
22
|
+
test("the probe's instance, which no row holds, is an adoption", async () => {
|
|
23
|
+
expect(await withStore({ A: recorded('created') }, adopting(owner('probe'), matches))).toBe(
|
|
24
|
+
true,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("Apply's adoption row, and an interrupted one, stay adoptions until they commit", async () => {
|
|
29
|
+
const rows = { A: recorded('updating', { adopting: true }) };
|
|
30
|
+
expect(await withStore(rows, adopting(owner('mine'), matches))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a row with attributes and no flag is state vouching for the object', async () => {
|
|
34
|
+
for (const status of ['created', 'updated', 'updating']) {
|
|
35
|
+
expect(await withStore({ A: recorded(status) }, adopting(owner('mine'), differs))).toBe(
|
|
36
|
+
false,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a recovered create is ours only when its whole row matches live', async () => {
|
|
42
|
+
const rows = { A: row('A', 'mine') };
|
|
43
|
+
expect(await withStore(rows, adopting(owner('mine'), matches))).toBe(false);
|
|
44
|
+
expect(await withStore(rows, adopting(owner('mine'), differs))).toBe(true);
|
|
45
|
+
const holed = { A: row('A', 'mine', undefined, {}) };
|
|
46
|
+
expect(await withStore(holed, adopting(owner('mine'), matches))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('`settled` is asked with the props of the row that recorded the create', async () => {
|
|
50
|
+
const seen: unknown[] = [];
|
|
51
|
+
const spy = (props: unknown) => Effect.sync(() => (seen.push(props), true));
|
|
52
|
+
await withStore({ A: row('A', 'mine') }, adopting(owner('mine'), spy));
|
|
53
|
+
expect(seen).toEqual([{ name: 'a' }]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a create (no attributes held) is never one; reconcile has its own rule', async () => {
|
|
57
|
+
expect(await withStore({}, adopting(creating, differs))).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('outside a stack every call with attributes is an adoption — the strict answer', async () => {
|
|
61
|
+
expect(await Effect.runPromise(adopting(owner('mine'), matches))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a `diff` or `reconcile` that holds attributes is looking at an ADOPTION — a live object
|
|
3
|
+
* that reached this stack's state by being FOUND, not by this resource's own create — so a family
|
|
4
|
+
* whose adoption must never change what it takes over (`Proxmox.Lxc`) can refuse any difference
|
|
5
|
+
* instead of writing it. adopt.ts and probe.ts decide WHETHER an object may be claimed; this says,
|
|
6
|
+
* once it is claimed, whether the claim is an adoption.
|
|
7
|
+
*
|
|
8
|
+
* ★ STATE ALREADY SAYS WHICH, AT BOTH HALVES OF A DEPLOY (alchemy 2.0.0-beta.79):
|
|
9
|
+
* · PLAN, A COLD ADOPTION. The probe mints an instance id no row can hold (Plan.ts
|
|
10
|
+
* `generateInstanceId()`), and `diff` then runs with that id and the attributes the probe
|
|
11
|
+
* read. So no row holds the id.
|
|
12
|
+
* · APPLY, THE SAME ADOPTION. Apply commits an `updating` row with `adopting: true` under that id
|
|
13
|
+
* BEFORE it calls reconcile, and the flag stays until the update commits (Apply.ts). An
|
|
14
|
+
* adoption whose apply was interrupted keeps it, so the next plan's `diff` sees it too.
|
|
15
|
+
* · A RECOVERED CREATE. A `creating` row with no attributes, whose live object the recovery read
|
|
16
|
+
* found. It is this resource's own create when the row proves it (probe.ts `provenOurs`, the
|
|
17
|
+
* same proof the read asked). Otherwise the plan went on only because `--adopt` said so — an
|
|
18
|
+
* adoption like any other.
|
|
19
|
+
* · Any other row with attributes is state vouching for the object: not an adoption.
|
|
20
|
+
* ⚠️ OUTSIDE A STACK — no Stack or State in context, or a store that errors (rows.ts) — no row
|
|
21
|
+
* holds anything, so every call is an adoption: the strict answer, never the permissive one. A
|
|
22
|
+
* family then refuses an ordinary update's write as if it were an adoption's. `alchemy deploy`
|
|
23
|
+
* and the Alchemist session provide both services.
|
|
24
|
+
*/
|
|
25
|
+
import * as Effect from 'effect/Effect';
|
|
26
|
+
import type { Owner } from './adopt.ts';
|
|
27
|
+
import { provenOurs } from './probe.ts';
|
|
28
|
+
import { recordedGeneration } from './rows.ts';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether `owner`'s call is an adoption (header). `settled` is the family's "live matches these
|
|
32
|
+
* props", asked only for a recovered create, with the props of the row that recorded it.
|
|
33
|
+
* ⛔ A CREATE IS NEVER ONE HERE. `output` undefined is reconcile's create path, which has its own
|
|
34
|
+
* rule for a live object it finds (adopt.ts `refuseTakeover`).
|
|
35
|
+
*/
|
|
36
|
+
export const adopting = <E, R>(
|
|
37
|
+
owner: Owner,
|
|
38
|
+
settled: (recorded: unknown) => Effect.Effect<boolean, E, R>,
|
|
39
|
+
): Effect.Effect<boolean, never, R> =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
if (owner.output === undefined) return false;
|
|
42
|
+
const generation = yield* recordedGeneration(owner.fqn, owner.instanceId);
|
|
43
|
+
if (generation === undefined || generation.adopting === true) return true;
|
|
44
|
+
if (generation.attr !== undefined && generation.attr !== null) return false;
|
|
45
|
+
return !(yield* provenOurs(owner, settled(generation.props)));
|
|
46
|
+
});
|
|
@@ -7,46 +7,13 @@
|
|
|
7
7
|
import { describe, expect, test } from 'bun:test';
|
|
8
8
|
import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
|
|
9
9
|
import { Artifacts, makeScopedArtifacts } from 'alchemy/Artifacts';
|
|
10
|
-
import { InMemoryService } from 'alchemy/State/InMemoryState';
|
|
11
10
|
import type { ResourceState } from 'alchemy/State/ResourceState';
|
|
12
|
-
import { State } from 'alchemy/State/State';
|
|
13
|
-
import { Stack } from 'alchemy/Stack';
|
|
14
11
|
import * as Effect from 'effect/Effect';
|
|
15
12
|
import { refuseTakeover } from './adopt.ts';
|
|
16
13
|
import { ownedRead } from './probe.ts';
|
|
17
14
|
import { noteResume, noteUnfinished, resumes } from './resume.ts';
|
|
18
15
|
import { forgetRefusedCreate, isCreate, recordedInstance } from './rows.ts';
|
|
19
|
-
|
|
20
|
-
const row = (fqn: string, instanceId: string, old?: unknown, props: unknown = { name: 'a' }) =>
|
|
21
|
-
({
|
|
22
|
-
fqn,
|
|
23
|
-
instanceId,
|
|
24
|
-
old,
|
|
25
|
-
props,
|
|
26
|
-
status: old === undefined ? 'creating' : 'replacing',
|
|
27
|
-
}) as unknown as ResourceState;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* A stack `s` at stage `test` whose store holds `rows`: `A` and `B` declared with a `name`, and `Z`
|
|
31
|
-
* declared `renamedFrom('X')`.
|
|
32
|
-
*/
|
|
33
|
-
const withStore = <A>(rows: Record<string, ResourceState>, effect: Effect.Effect<A>): Promise<A> =>
|
|
34
|
-
Effect.runPromise(
|
|
35
|
-
effect.pipe(
|
|
36
|
-
Effect.provideService(State, InMemoryService({ s: { test: rows } })),
|
|
37
|
-
Effect.provideService(Stack, {
|
|
38
|
-
actions: {},
|
|
39
|
-
bindings: {},
|
|
40
|
-
name: 's',
|
|
41
|
-
resources: {
|
|
42
|
-
A: { Props: { name: 'a' } },
|
|
43
|
-
B: { Props: { name: 'b' } },
|
|
44
|
-
Z: { FormerFqns: ['X'], Props: { name: 'a' } },
|
|
45
|
-
},
|
|
46
|
-
stage: 'test',
|
|
47
|
-
} as never),
|
|
48
|
-
),
|
|
49
|
-
);
|
|
16
|
+
import { row, withStore } from './store-fixture.ts';
|
|
50
17
|
|
|
51
18
|
describe('recordedInstance', () => {
|
|
52
19
|
test('is the row at the FQN, any generation of its `old` chain, or a renamedFrom row', async () => {
|
package/src/ownership/probe.ts
CHANGED
|
@@ -28,29 +28,28 @@ import type { Owner } from './adopt.ts';
|
|
|
28
28
|
import { recordedInstance } from './rows.ts';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* Whether the state store proves the live object this instance's own: a row records `ask.instanceId`
|
|
32
|
+
* with its whole declaration (whole.ts) AND `settled` — the family's own "live matches these props"
|
|
33
|
+
* — says so. The one proof ownership/ accepts for a live object state holds no attributes for; the
|
|
34
|
+
* recovery read (`ownedRead`) and an adoption check (adopting.ts) both ask it.
|
|
35
|
+
* ⛔ `settled` NEVER FAILS THE CALLER. The recovery read runs with the INTERRUPTED deploy's props, so
|
|
35
36
|
* a declaration that cannot be evaluated any more (a fragments directory since moved, a group
|
|
36
37
|
* name since renamed) would fail every later plan, the fix included. It reads as "not proven
|
|
37
|
-
* ours" instead
|
|
38
|
+
* ours" instead, with the reason logged.
|
|
38
39
|
*/
|
|
39
|
-
export const
|
|
40
|
-
ask:
|
|
41
|
-
found: A | undefined,
|
|
40
|
+
export const provenOurs = <E, R>(
|
|
41
|
+
ask: { readonly fqn: string; readonly instanceId: string },
|
|
42
42
|
settled: Effect.Effect<boolean, E, R>,
|
|
43
|
-
): Effect.Effect<
|
|
43
|
+
): Effect.Effect<boolean, never, R> =>
|
|
44
44
|
Effect.gen(function* () {
|
|
45
|
-
if (found === undefined || ask.output !== undefined) return found;
|
|
46
45
|
const recorded = yield* recordedInstance(ask.fqn, ask.instanceId);
|
|
47
|
-
if (recorded === 'absent') return
|
|
46
|
+
if (recorded === 'absent') return false;
|
|
48
47
|
if (recorded === 'partial') {
|
|
49
48
|
yield* Effect.logWarning(
|
|
50
49
|
`${ask.fqn}: the interrupted create's state row lacks part of the declaration (a prop ` +
|
|
51
50
|
'was still an Output when it was written), so the live object is not proven ours',
|
|
52
51
|
);
|
|
53
|
-
return
|
|
52
|
+
return false;
|
|
54
53
|
}
|
|
55
54
|
const unproven = (reason: unknown) =>
|
|
56
55
|
Effect.as(
|
|
@@ -60,6 +59,20 @@ export const ownedRead = <A extends object, E, R>(
|
|
|
60
59
|
),
|
|
61
60
|
false,
|
|
62
61
|
);
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
return yield* settled.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A family's `read` answer. With attributes in state it is `found` as is. Without, it is the probe
|
|
67
|
+
* or the recovery read (header): `found` is ours only when `provenOurs` says so, else `Unowned`,
|
|
68
|
+
* which `--adopt` resolves.
|
|
69
|
+
*/
|
|
70
|
+
export const ownedRead = <A extends object, E, R>(
|
|
71
|
+
ask: Owner,
|
|
72
|
+
found: A | undefined,
|
|
73
|
+
settled: Effect.Effect<boolean, E, R>,
|
|
74
|
+
): Effect.Effect<A | undefined, never, R> =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
if (found === undefined || ask.output !== undefined) return found;
|
|
77
|
+
return (yield* provenOurs(ask, settled)) ? found : Unowned(found);
|
|
65
78
|
});
|
package/src/ownership/rows.ts
CHANGED
|
@@ -31,12 +31,15 @@ const storeOf = Effect.gen(function* () {
|
|
|
31
31
|
const rowAt = (store: StateService, where: Where, fqn: string) =>
|
|
32
32
|
store.get({ ...where, fqn }).pipe(Effect.orElseSucceed(() => undefined));
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
/** One generation of a persisted row, as far as ownership/ reads it. */
|
|
35
|
+
export type Generation = {
|
|
35
36
|
readonly instanceId?: unknown;
|
|
36
37
|
readonly status?: unknown;
|
|
37
38
|
readonly props?: unknown;
|
|
38
39
|
readonly attr?: unknown;
|
|
39
40
|
readonly old?: unknown;
|
|
41
|
+
/** Set by Apply on the `updating` row of an adoption, until that update commits (Apply.ts). */
|
|
42
|
+
readonly adopting?: unknown;
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
/** The generation of a persisted row that carries `instanceId`: the row itself, or one in `old`. */
|
|
@@ -51,7 +54,10 @@ const generationOf = (row: unknown, instanceId: string): Generation | undefined
|
|
|
51
54
|
};
|
|
52
55
|
|
|
53
56
|
/** The generation `instanceId` names — at the FQN, or at a former FQN `renamedFrom` names. */
|
|
54
|
-
const recordedGeneration = (
|
|
57
|
+
export const recordedGeneration = (
|
|
58
|
+
fqn: string,
|
|
59
|
+
instanceId: string,
|
|
60
|
+
): Effect.Effect<Generation | undefined> =>
|
|
55
61
|
Effect.gen(function* () {
|
|
56
62
|
const found = yield* storeOf;
|
|
57
63
|
if (found === undefined) return undefined;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A state store and a Stack for the ownership/ unit tests — the two services rows.ts reads.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ TEST-ONLY. No provider imports this file and it is not on any barrel.
|
|
5
|
+
*/
|
|
6
|
+
import { InMemoryService } from 'alchemy/State/InMemoryState';
|
|
7
|
+
import type { ResourceState } from 'alchemy/State/ResourceState';
|
|
8
|
+
import { State } from 'alchemy/State/State';
|
|
9
|
+
import { Stack } from 'alchemy/Stack';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
|
|
12
|
+
/** A row with no attributes: `creating`, or `replacing` when it carries an `old` generation. */
|
|
13
|
+
export const row = (
|
|
14
|
+
fqn: string,
|
|
15
|
+
instanceId: string,
|
|
16
|
+
old?: unknown,
|
|
17
|
+
props: unknown = { name: 'a' },
|
|
18
|
+
): ResourceState =>
|
|
19
|
+
({
|
|
20
|
+
fqn,
|
|
21
|
+
instanceId,
|
|
22
|
+
old,
|
|
23
|
+
props,
|
|
24
|
+
status: old === undefined ? 'creating' : 'replacing',
|
|
25
|
+
}) as unknown as ResourceState;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A stack `s` at stage `test` whose store holds `rows`: `A` and `B` declared with a `name`, and `Z`
|
|
29
|
+
* declared `renamedFrom('X')`.
|
|
30
|
+
*/
|
|
31
|
+
export const withStore = <A>(
|
|
32
|
+
rows: Record<string, ResourceState>,
|
|
33
|
+
effect: Effect.Effect<A>,
|
|
34
|
+
): Promise<A> =>
|
|
35
|
+
Effect.runPromise(
|
|
36
|
+
effect.pipe(
|
|
37
|
+
Effect.provideService(State, InMemoryService({ s: { test: rows } })),
|
|
38
|
+
Effect.provideService(Stack, {
|
|
39
|
+
actions: {},
|
|
40
|
+
bindings: {},
|
|
41
|
+
name: 's',
|
|
42
|
+
resources: {
|
|
43
|
+
A: { Props: { name: 'a' } },
|
|
44
|
+
B: { Props: { name: 'b' } },
|
|
45
|
+
Z: { FormerFqns: ['X'], Props: { name: 'a' } },
|
|
46
|
+
},
|
|
47
|
+
stage: 'test',
|
|
48
|
+
} as never),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
@@ -73,6 +73,38 @@ describe('adopting a Ceph pool', () => {
|
|
|
73
73
|
expect(fake.calls.every((call) => call.path === STATUS)).toBe(true);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* ★ THE RECHECK MUST NOT FAIL A FIELD THE FAMILY DOES NOT MANAGE. `pg_num` is create-time only and
|
|
78
|
+
* the autoscaler owns it, so a declared birth size differs from the live count and shows under
|
|
79
|
+
* `changed`. recheck.ts asks the diff again with the live value as recorded props; this diff
|
|
80
|
+
* re-reads the cluster and ignores `olds`, so it says `noop` again, and the deploy only reads.
|
|
81
|
+
*/
|
|
82
|
+
test('declaring a field it does not manage: rechecked, still noop, and the deploy only reads', async () => {
|
|
83
|
+
const fake = cluster(3);
|
|
84
|
+
const birth = () =>
|
|
85
|
+
ProxmoxCephPool('cephtb4', {
|
|
86
|
+
min_size: 2,
|
|
87
|
+
name: 'cephtb4',
|
|
88
|
+
node: 'n2',
|
|
89
|
+
pg_num: 32,
|
|
90
|
+
size: 3,
|
|
91
|
+
target: FAKE_TARGET,
|
|
92
|
+
});
|
|
93
|
+
await withoutBao(async () => {
|
|
94
|
+
const engine = engineFor(fake);
|
|
95
|
+
const report = await engine.verify(birth());
|
|
96
|
+
expect(report.rows[0]).toMatchObject({
|
|
97
|
+
changed: ['pg_num'],
|
|
98
|
+
diff: 'noop',
|
|
99
|
+
ok: true,
|
|
100
|
+
recheck: 'noop',
|
|
101
|
+
});
|
|
102
|
+
expect(report.rows[0]?.why).toContain('does not manage');
|
|
103
|
+
expect(await engine.deploy(birth())).toEqual({ cephtb4: 'adopted' });
|
|
104
|
+
});
|
|
105
|
+
expect(fake.writes()).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
76
108
|
test('that drifted: the verifier names the field, and the deploy PUTs once', async () => {
|
|
77
109
|
const fake = cluster(2);
|
|
78
110
|
await withoutBao(async () => {
|
|
@@ -34,6 +34,11 @@ export type FakePve = {
|
|
|
34
34
|
* keyed `node/vmid` — a guest that appears between a plan and its deploy.
|
|
35
35
|
*/
|
|
36
36
|
readonly vanish: Map<string, number>;
|
|
37
|
+
/**
|
|
38
|
+
* Hand edits that land mid-deploy, keyed `node/vmid`: once that guest's config has been read
|
|
39
|
+
* `after` more times, `set` is merged into it — a change made between a plan and its apply.
|
|
40
|
+
*/
|
|
41
|
+
readonly edits: Map<string, { after: number; readonly set: Record<string, unknown> }>;
|
|
37
42
|
/** Config keys a `PUT …/config` accepts with 200 and then does not store. */
|
|
38
43
|
readonly ignore: Set<string>;
|
|
39
44
|
/** How every task ends, and whether a task-starting call answers with no UPID at all. */
|
|
@@ -90,6 +95,7 @@ export const fakePve = (): FakePve => {
|
|
|
90
95
|
const others: { vmid: number | string; node: string; type: string }[] = [];
|
|
91
96
|
const configErrors = new Map<string, { status: number; body: unknown }>();
|
|
92
97
|
const vanish = new Map<string, number>();
|
|
98
|
+
const edits: FakePve['edits'] = new Map();
|
|
93
99
|
const ignore = new Set<string>();
|
|
94
100
|
const task = { exit: 'OK', noUpid: false };
|
|
95
101
|
const seen: Seen[] = [];
|
|
@@ -148,7 +154,12 @@ export const fakePve = (): FakePve => {
|
|
|
148
154
|
const message = `Configuration file 'nodes/${node}/lxc/${vmid}.conf' does not exist\n`;
|
|
149
155
|
return json({ data: null, message }, 500);
|
|
150
156
|
}
|
|
151
|
-
if (tail === '/config' && request.method === 'GET')
|
|
157
|
+
if (tail === '/config' && request.method === 'GET') {
|
|
158
|
+
const answer = json({ data: config });
|
|
159
|
+
const edit = edits.get(`${node}/${vmid}`);
|
|
160
|
+
if (edit !== undefined && --edit.after === 0) Object.assign(config, edit.set);
|
|
161
|
+
return answer;
|
|
162
|
+
}
|
|
152
163
|
if (tail === '/config' && request.method === 'PUT') {
|
|
153
164
|
const digest = form.get('digest');
|
|
154
165
|
if (digest !== null && digest !== config['digest']) {
|
|
@@ -192,6 +203,7 @@ export const fakePve = (): FakePve => {
|
|
|
192
203
|
};
|
|
193
204
|
return {
|
|
194
205
|
configErrors,
|
|
206
|
+
edits,
|
|
195
207
|
fetch: Object.assign(remapped, { preconnect: globalThis.fetch.preconnect }),
|
|
196
208
|
guests,
|
|
197
209
|
ignore,
|
|
@@ -4,28 +4,20 @@
|
|
|
4
4
|
*
|
|
5
5
|
* ⛔ WHAT THESE PIN: a declaration pasted from the live config plans `adopted` with no warning,
|
|
6
6
|
* its deploy writes NOTHING, and the next plan is `noop`; the create spellings (`storage:size`,
|
|
7
|
-
* a NIC without its MAC) adopt as cleanly; drift is named at plan and
|
|
8
|
-
* keeps the live MAC and carries the digest; and every change PVE cannot
|
|
9
|
-
* plan with nothing written. Deleting both empty-change guards
|
|
10
|
-
* `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in
|
|
11
|
-
* each MEASURED by making that edit and running this file,
|
|
7
|
+
* a NIC without its MAC) adopt as cleanly; after that adoption, drift is named at plan and
|
|
8
|
+
* written as one PUT that keeps the live MAC and carries the digest; and every change PVE cannot
|
|
9
|
+
* make in place fails the plan with nothing written. Deleting both empty-change guards
|
|
10
|
+
* (reconcile's in lxc.ts and `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in
|
|
11
|
+
* lxc-wire.ts, fails a test here — each MEASURED by making that edit and running this file,
|
|
12
|
+
* 2026-09-21. An adoption that WOULD write fails instead: lxc-strict-adopt.test.ts.
|
|
12
13
|
*/
|
|
13
14
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
14
15
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
15
16
|
import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
|
|
16
|
-
import {
|
|
17
|
+
import { NODE, TARGET, VMID, adoptedAsIs, lxcEngine, pasted, seed } from './lxc-harness.ts';
|
|
17
18
|
import { ProxmoxLxc } from './lxc.ts';
|
|
18
19
|
import type { LxcProps } from './lxc-props.ts';
|
|
19
20
|
|
|
20
|
-
const NODE = 'pve1';
|
|
21
|
-
const VMID = 100;
|
|
22
|
-
|
|
23
|
-
/** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
|
|
24
|
-
const pasted = (): LxcProps => {
|
|
25
|
-
const { lxc: _raw, ...config } = LIVE;
|
|
26
|
-
return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID };
|
|
27
|
-
};
|
|
28
|
-
|
|
29
21
|
const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
|
|
30
22
|
|
|
31
23
|
let fake: FakePve | undefined;
|
|
@@ -84,17 +76,17 @@ describe('adopting a live container', () => {
|
|
|
84
76
|
});
|
|
85
77
|
});
|
|
86
78
|
|
|
87
|
-
describe('drift
|
|
79
|
+
describe('drift after the adoption is named at plan and written once', () => {
|
|
88
80
|
test('memory and an MTU change: one PUT, the live MAC kept, the digest carried', async () => {
|
|
89
81
|
const pve = live();
|
|
90
|
-
const stack =
|
|
82
|
+
const stack = await adoptedAsIs(pve);
|
|
91
83
|
const props: LxcProps = {
|
|
92
84
|
...pasted(),
|
|
93
85
|
memory: 4096,
|
|
94
86
|
net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=9000',
|
|
95
87
|
};
|
|
96
88
|
const planned = await stack.plan(ct(props));
|
|
97
|
-
expect(planned.actions).toEqual({ ct: '
|
|
89
|
+
expect(planned.actions).toEqual({ ct: 'update' });
|
|
98
90
|
expect(planned.warnings.join('\n')).toContain('differs from the declaration in memory, net1');
|
|
99
91
|
expect((await stack.deploy(ct(props))).failure).toBe('');
|
|
100
92
|
expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/config`]);
|
|
@@ -107,7 +99,7 @@ describe('drift at adoption is named at plan and written once', () => {
|
|
|
107
99
|
|
|
108
100
|
test('a larger rootfs is a resize after the config, never a new volume', async () => {
|
|
109
101
|
const pve = live();
|
|
110
|
-
const stack =
|
|
102
|
+
const stack = await adoptedAsIs(pve);
|
|
111
103
|
const props: LxcProps = { ...pasted(), rootfs: 'local-zfs:subvol-100-disk-0,size=64G' };
|
|
112
104
|
expect((await stack.deploy(ct(props))).failure).toBe('');
|
|
113
105
|
expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/resize`]);
|
|
@@ -125,7 +117,8 @@ describe('changes PVE cannot make in place fail the plan and write nothing', ()
|
|
|
125
117
|
['detaching a mount point', { mp0: '' }, /unusedN/],
|
|
126
118
|
])('%s', async (_, change, reason) => {
|
|
127
119
|
const pve = live();
|
|
128
|
-
const
|
|
120
|
+
const stack = await adoptedAsIs(pve);
|
|
121
|
+
const run = await stack.deploy(ct({ ...pasted(), ...change } as LxcProps));
|
|
129
122
|
expect(run.failure).toMatch(reason);
|
|
130
123
|
expect(writesOf(pve)).toEqual([]);
|
|
131
124
|
});
|