@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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adopting an object that already matches must be read-only — measured per reconcile SHAPE, through
|
|
3
|
+
* Alchemy's own Plan and Apply over a fake cluster (fake-pve.ts, ../verify/fake-engine.ts).
|
|
4
|
+
*
|
|
5
|
+
* ★ ONE ROW PER WAY A FAMILY REACHES ITS WRITE, not one per family. Every family built on
|
|
6
|
+
* `pveHandlers` shares resource.ts's reconcile, so `Proxmox.Pool` stands for all of them; `Acl`
|
|
7
|
+
* wraps that reconcile; `PbsDatastore` and `SdnApply` write their own. CephPool, the one that got
|
|
8
|
+
* it wrong, has its own file. docs/adopt-verify.md carries the full per-family table.
|
|
9
|
+
* ⚠️ A FIXTURE THAT DOES NOT MATCH TURNS A ROW INTO A DRIFT TEST, and the assertion on the
|
|
10
|
+
* verifier's `noop` is what catches that: a wrong fixture fails loudly instead of passing for
|
|
11
|
+
* the wrong reason. The Pool drift row proves the harness can see a write at all.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
16
|
+
import { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
|
|
17
|
+
import type { PbsTarget } from './credentials.ts';
|
|
18
|
+
import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
|
|
19
|
+
import { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
|
|
20
|
+
import { ProxmoxPool, ProxmoxPoolProvider } from './pool.ts';
|
|
21
|
+
import { ProxmoxSdnApply, ProxmoxSdnApplyProvider } from './sdn-apply.ts';
|
|
22
|
+
|
|
23
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
24
|
+
|
|
25
|
+
type Case = {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly provider: () => Layer.Layer<never, never, never>;
|
|
28
|
+
readonly declare: () => ReturnType<typeof ProxmoxPool>;
|
|
29
|
+
/** GET path → the `data` PVE/PBS answers; anything unlisted answers `null`. */
|
|
30
|
+
readonly live: Readonly<Record<string, unknown>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const cases: readonly Case[] = [
|
|
34
|
+
{
|
|
35
|
+
declare: () => ProxmoxPool('house', { comment: 'x', poolid: 'house', target: FAKE_TARGET }),
|
|
36
|
+
live: { 'pools/house': { comment: 'x', members: [] } },
|
|
37
|
+
name: 'Proxmox.Pool (pveHandlers: every factory family)',
|
|
38
|
+
provider: ProxmoxPoolProvider as never,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
declare: () =>
|
|
42
|
+
ProxmoxAcl('auditor', {
|
|
43
|
+
path: '/',
|
|
44
|
+
roleid: 'PVEAuditor',
|
|
45
|
+
target: FAKE_TARGET,
|
|
46
|
+
type: 'user',
|
|
47
|
+
ugid: 'u@pve',
|
|
48
|
+
}) as never,
|
|
49
|
+
live: {
|
|
50
|
+
'access/acl': [
|
|
51
|
+
{ path: '/', propagate: 1, roleid: 'PVEAuditor', type: 'user', ugid: 'u@pve' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
name: 'Proxmox.Acl (wraps the factory reconcile)',
|
|
55
|
+
provider: ProxmoxAclProvider as never,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
declare: () =>
|
|
59
|
+
PbsDatastore('store1', {
|
|
60
|
+
comment: 'c',
|
|
61
|
+
'gc-schedule': 'daily',
|
|
62
|
+
name: 'store1',
|
|
63
|
+
path: '/mnt/store1',
|
|
64
|
+
target: PBS,
|
|
65
|
+
}) as never,
|
|
66
|
+
live: {
|
|
67
|
+
'config/datastore/store1': {
|
|
68
|
+
comment: 'c',
|
|
69
|
+
'gc-schedule': 'daily',
|
|
70
|
+
name: 'store1',
|
|
71
|
+
path: '/mnt/store1',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
name: 'Pbs.Datastore (its own reconcile)',
|
|
75
|
+
provider: PbsDatastoreProvider as never,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
declare: () => ProxmoxSdnApply('sdn', { target: FAKE_TARGET }) as never,
|
|
79
|
+
live: {},
|
|
80
|
+
name: 'Proxmox.SdnApply (its own reconcile; nothing staged)',
|
|
81
|
+
provider: ProxmoxSdnApplyProvider as never,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const clusterWith = (live: Readonly<Record<string, unknown>>) =>
|
|
86
|
+
fakePve((call) => (call.method === 'GET' ? live[call.path] : 'UPID:fake'));
|
|
87
|
+
|
|
88
|
+
describe('adopting a matching object only reads', () => {
|
|
89
|
+
test.each(cases.map((c) => [c.name, c] as const))('%s', async (_, c) => {
|
|
90
|
+
const fake = clusterWith(c.live);
|
|
91
|
+
await withoutBao(async () => {
|
|
92
|
+
const engine = engineOver(c.provider().pipe(Layer.provideMerge(fake.layer)));
|
|
93
|
+
const report = await engine.verify(c.declare());
|
|
94
|
+
expect(report.rows).toEqual([expect.objectContaining({ diff: 'noop', ok: true })]);
|
|
95
|
+
expect(Object.values(await engine.deploy(c.declare()))).toEqual(['adopted']);
|
|
96
|
+
});
|
|
97
|
+
expect(fake.writes()).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('and the harness does see a write: a drifted Pool is PUT once', async () => {
|
|
101
|
+
const fake = clusterWith({ 'pools/house': { comment: 'old', members: [] } });
|
|
102
|
+
await withoutBao(async () => {
|
|
103
|
+
const engine = engineOver(ProxmoxPoolProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
104
|
+
const declared = () =>
|
|
105
|
+
ProxmoxPool('house', { comment: 'x', poolid: 'house', target: FAKE_TARGET });
|
|
106
|
+
const report = await engine.verify(declared());
|
|
107
|
+
expect(report.rows[0]).toMatchObject({ changed: ['comment'], diff: 'update', ok: false });
|
|
108
|
+
expect(await engine.deploy(declared())).toEqual({ house: 'adopted' });
|
|
109
|
+
});
|
|
110
|
+
expect(fake.writes()).toEqual(['PUT pools/house']);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephPool` adopted through Alchemy's own Plan and Apply, over a fake cluster.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 THE BUG THIS PINS (found 2026-09-21, update-guard.ts). The family's hand-written reconcile
|
|
5
|
+
* PUT the declared set whenever the pool existed. Plan.ts forces reconcile after the adoption
|
|
6
|
+
* probe even when the diff says `noop`, so adopting a pool that already matched sent `setpool`
|
|
7
|
+
* and forked a worker on the live cluster. The first test fails with that PUT on the old code.
|
|
8
|
+
* ★ THE VERIFIER RUNS OVER THE SAME ENGINE, so the same test also shows what it reports before
|
|
9
|
+
* the deploy: `adopted` from the engine, `noop` from the provider.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
14
|
+
import { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
|
|
15
|
+
import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
|
|
16
|
+
|
|
17
|
+
const STATUS = 'nodes/n2/ceph/pool/cephtb4/status?verbose=1';
|
|
18
|
+
const OBJECT = 'nodes/n2/ceph/pool/cephtb4';
|
|
19
|
+
|
|
20
|
+
/** TB4's rbd pool as `getpool` answers it — the fields ceph-pool.ts reads. */
|
|
21
|
+
const livePool = (size: number): Record<string, unknown> => ({
|
|
22
|
+
application_list: ['rbd'],
|
|
23
|
+
crush_rule: 'replicated_rule',
|
|
24
|
+
id: 2,
|
|
25
|
+
min_size: 2,
|
|
26
|
+
name: 'cephtb4',
|
|
27
|
+
pg_autoscale_mode: 'on',
|
|
28
|
+
pg_num: 128,
|
|
29
|
+
size,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** A cluster holding one pool; a PUT to it lands, as `setpool` would. */
|
|
33
|
+
const cluster = (size: number) => {
|
|
34
|
+
const pool = livePool(size);
|
|
35
|
+
return fakePve((call: PveCall) => {
|
|
36
|
+
if (call.method === 'GET' && call.path === STATUS) return pool;
|
|
37
|
+
if (call.method === 'PUT' && call.path === OBJECT) {
|
|
38
|
+
for (const [key, value] of Object.entries(call.form)) {
|
|
39
|
+
pool[key] = /^\d+$/.test(value) ? Number(value) : value;
|
|
40
|
+
}
|
|
41
|
+
return 'UPID:n2:fake:setpool';
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const declared = () =>
|
|
48
|
+
ProxmoxCephPool('cephtb4', {
|
|
49
|
+
crush_rule: 'replicated_rule',
|
|
50
|
+
min_size: 2,
|
|
51
|
+
name: 'cephtb4',
|
|
52
|
+
node: 'n2',
|
|
53
|
+
pg_autoscale_mode: 'on',
|
|
54
|
+
size: 3,
|
|
55
|
+
target: FAKE_TARGET,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const engineFor = (fake: ReturnType<typeof cluster>) =>
|
|
59
|
+
engineOver(ProxmoxCephPoolProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
60
|
+
|
|
61
|
+
describe('adopting a Ceph pool', () => {
|
|
62
|
+
test('that already matches: the verifier says noop, and the deploy only reads', async () => {
|
|
63
|
+
const fake = cluster(3);
|
|
64
|
+
await withoutBao(async () => {
|
|
65
|
+
const engine = engineFor(fake);
|
|
66
|
+
const report = await engine.verify(declared());
|
|
67
|
+
expect(report.rows).toEqual([
|
|
68
|
+
expect.objectContaining({ diff: 'noop', ok: true, planned: 'adopted', read: 'found' }),
|
|
69
|
+
]);
|
|
70
|
+
expect(await engine.deploy(declared())).toEqual({ cephtb4: 'adopted' });
|
|
71
|
+
});
|
|
72
|
+
expect(fake.writes()).toEqual([]);
|
|
73
|
+
expect(fake.calls.every((call) => call.path === STATUS)).toBe(true);
|
|
74
|
+
});
|
|
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
|
+
|
|
108
|
+
test('that drifted: the verifier names the field, and the deploy PUTs once', async () => {
|
|
109
|
+
const fake = cluster(2);
|
|
110
|
+
await withoutBao(async () => {
|
|
111
|
+
const engine = engineFor(fake);
|
|
112
|
+
const report = await engine.verify(declared());
|
|
113
|
+
expect(report.rows[0]).toMatchObject({ changed: ['size'], diff: 'update', ok: false });
|
|
114
|
+
expect(fake.writes()).toEqual([]);
|
|
115
|
+
expect(await engine.deploy(declared())).toEqual({ cephtb4: 'adopted' });
|
|
116
|
+
});
|
|
117
|
+
expect(fake.writes()).toEqual([`PUT ${OBJECT}`]);
|
|
118
|
+
expect(fake.calls.find((call) => call.method === 'PUT')?.form['size']).toBe('3');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -192,9 +192,9 @@ const numeric = (value: number | undefined) => (value === undefined ? undefined
|
|
|
192
192
|
* the way storage.ts's `mutable` serves both of its forms. `name` is the path's last segment on an
|
|
193
193
|
* update and is not repeated here.
|
|
194
194
|
*
|
|
195
|
-
* ⚠️ SAFE TO RE-APPLY TO A POOL THAT ALREADY MATCHES
|
|
196
|
-
* PVE::Ceph::Tools::set_pool (Tools.pm:293-300): it reads `osd pool get all` first
|
|
197
|
-
*
|
|
195
|
+
* ⚠️ SAFE TO RE-APPLY TO A POOL THAT ALREADY MATCHES (reconcile leaned on it until update-guard.ts).
|
|
196
|
+
* MEASURED in PVE::Ceph::Tools::set_pool (Tools.pm:293-300): it reads `osd pool get all` first
|
|
197
|
+
* and SKIPS every unchanged setting, so the undrifted fields that ride along cost one mon read.
|
|
198
198
|
* ⛔ BUT A SETTING THAT DID CHANGE AND CANNOT BE APPLIED KILLS THE WHOLE PUT. Tools.pm:310 dies
|
|
199
199
|
* with "Could not set: <fields>" if any parameter is left unapplied, so a pool carrying Ceph's
|
|
200
200
|
* `nosizechange` fails an entire update over one field. That is why the `no*` flags are reported
|
package/src/proxmox/ceph-pool.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
import { confirmAbsent, settle } from './ceph-pool-settle.ts';
|
|
54
54
|
import { pve } from './client.ts';
|
|
55
55
|
import { type PveRequirements, type PveSpec, pveOperations } from './resource.ts';
|
|
56
|
+
import { formToSend } from './update-guard.ts';
|
|
56
57
|
import { bool, int, num, text } from './values.ts';
|
|
57
58
|
|
|
58
59
|
export type { CephPoolAttributes, CephPoolProps };
|
|
@@ -160,9 +161,15 @@ export const ProxmoxCephPoolProvider = () =>
|
|
|
160
161
|
createBody(news),
|
|
161
162
|
);
|
|
162
163
|
} else {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
/**
|
|
165
|
+
* ⛔ ONLY WHEN `matches` IS FALSE. This PUT used to fire whenever the pool existed, so
|
|
166
|
+
* adopting a pool that already matched — Plan.ts forces reconcile after the probe —
|
|
167
|
+
* sent `setpool` and forked a worker on the live cluster. PVE skipping the unchanged
|
|
168
|
+
* settings (ceph-pool-form.ts) made it harmless to Ceph, not free: it is still a
|
|
169
|
+
* provision-lease write and a task in the cluster's list. update-guard.ts has the rest.
|
|
170
|
+
*/
|
|
171
|
+
const form = formToSend(spec.matches, live, news, updateBody(news));
|
|
172
|
+
if (form !== undefined) {
|
|
166
173
|
upid = yield* pve<string>(news.target, 'provision', 'PUT', object(news), form);
|
|
167
174
|
}
|
|
168
175
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell's OpenBao variables, replaced for the life of one test run and put back afterwards.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ WHY A TEST CANNOT LEAVE THEM ALONE. `leased` mints through credentials.ts `mint`, which reads
|
|
5
|
+
* `BAO_AGENT_ADDR` before `BAO_ADDR` (and the `VAULT_*` spellings), and sends `BAO_TOKEN` as a
|
|
6
|
+
* header. A developer shell with any of them set would send its real token to a fake, or a mint
|
|
7
|
+
* to a real agent. credentials.test.ts avoids it by passing an explicit environment; a test that
|
|
8
|
+
* drives a provider cannot, because the lease cache reads `process.env`.
|
|
9
|
+
* ★ ONE HELPER FOR EVERY FAKE. lxc-harness.ts and fake-pve.ts each carried their own copy; the
|
|
10
|
+
* first covered three names and the second every `BAO_*` / `VAULT_*`, so they now share the
|
|
11
|
+
* wider one. ⛔ TEST-ONLY. No provider imports this file.
|
|
12
|
+
*/
|
|
13
|
+
const BAO_VARIABLES = /^(BAO|VAULT)_/;
|
|
14
|
+
|
|
15
|
+
/** Run `body` with every BAO_* / VAULT_* unset except `BAO_ADDR=address`, then restore them. */
|
|
16
|
+
export const withFakeBao = async <A>(address: string, body: () => Promise<A>): Promise<A> => {
|
|
17
|
+
const saved = Object.entries(process.env).filter(([name]) => BAO_VARIABLES.test(name));
|
|
18
|
+
for (const [name] of saved) delete process.env[name];
|
|
19
|
+
process.env['BAO_ADDR'] = address;
|
|
20
|
+
try {
|
|
21
|
+
return await body();
|
|
22
|
+
} finally {
|
|
23
|
+
for (const name of Object.keys(process.env)) {
|
|
24
|
+
if (BAO_VARIABLES.test(name)) delete process.env[name];
|
|
25
|
+
}
|
|
26
|
+
for (const [name, value] of saved) process.env[name] = value;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake PVE cluster for `Proxmox.Lxc`: containers in memory, tasks that finish at once, and the
|
|
3
|
+
* OpenBao creds endpoint the mint reads — one Bun server, reached through `FetchHttpClient.Fetch`.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ TEST-ONLY, AND NEVER A REAL HOST. No provider imports this file and it is not on the barrel.
|
|
6
|
+
* Hostnames are `.test` (RFC 2606) and are remapped to the loopback server; any other host is
|
|
7
|
+
* refused with ENOTFOUND rather than resolved (client.test.ts has the incident behind that).
|
|
8
|
+
* ★ IT ANSWERS THE WAY PVE 9 DOES WHERE THE PROVIDER DEPENDS ON IT, and each shape is a measured
|
|
9
|
+
* one: a missing config is HTTP 500 with `message` in the JSON body (pve-http-server
|
|
10
|
+
* `Formatter/Standard.pm`); a PUT with a stale `digest` is refused; a create allocates
|
|
11
|
+
* `<storage>:subvol-<vmid>-disk-<n>,size=<n>G` and generates `hwaddr` and `type=veth`, as a live
|
|
12
|
+
* guest's own config shows; tags come back sorted and lower-cased (`get_unique_tags`).
|
|
13
|
+
* ⚠️ EVERY MAC HERE IS FROM THE RFC 7042 DOCUMENTATION BLOCK (`00:00:5E:00:53:xx`), as every
|
|
14
|
+
* address is TEST-NET: PVE's own prefix is `BC:24:11`, but no value in this package may be one a
|
|
15
|
+
* real NIC could carry.
|
|
16
|
+
*/
|
|
17
|
+
import { pairs } from './lxc-wire.ts';
|
|
18
|
+
|
|
19
|
+
export type Seen = {
|
|
20
|
+
readonly method: string;
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly form: URLSearchParams;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FakePve = {
|
|
26
|
+
/** Live configs, keyed `node/vmid`. Mutate freely between deploys to simulate hand edits. */
|
|
27
|
+
readonly guests: Map<string, Record<string, unknown>>;
|
|
28
|
+
/** Extra `GET /cluster/resources` rows — a QEMU VM, or a guest on another node. */
|
|
29
|
+
readonly others: { vmid: number | string; node: string; type: string }[];
|
|
30
|
+
/** Forced answers for `GET …/config`, keyed `node/vmid`: an HTTP status and a JSON body. */
|
|
31
|
+
readonly configErrors: Map<string, { status: number; body: unknown }>;
|
|
32
|
+
/**
|
|
33
|
+
* Guests that read as ABSENT for the next N cluster listings (their config GET 500s meanwhile),
|
|
34
|
+
* keyed `node/vmid` — a guest that appears between a plan and its deploy.
|
|
35
|
+
*/
|
|
36
|
+
readonly vanish: Map<string, number>;
|
|
37
|
+
/** Config keys a `PUT …/config` accepts with 200 and then does not store. */
|
|
38
|
+
readonly ignore: Set<string>;
|
|
39
|
+
/** How every task ends, and whether a task-starting call answers with no UPID at all. */
|
|
40
|
+
readonly task: { exit: string; noUpid: boolean };
|
|
41
|
+
readonly seen: Seen[];
|
|
42
|
+
readonly fetch: typeof fetch;
|
|
43
|
+
readonly stop: () => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The member hostname a test target names, and the OpenBao address the mint is pointed at. */
|
|
47
|
+
export const FAKE_MEMBER = 'pve-a.test';
|
|
48
|
+
export const FAKE_BAO = 'http://bao.test:8200';
|
|
49
|
+
|
|
50
|
+
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
|
51
|
+
|
|
52
|
+
let digestCounter = 0;
|
|
53
|
+
const nextDigest = () => {
|
|
54
|
+
digestCounter += 1;
|
|
55
|
+
return `digest-${String(digestCounter)}`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** What PVE's create does to the declared form: allocate volumes, fill NIC defaults, sort tags. */
|
|
59
|
+
const created = (vmid: string, form: URLSearchParams): Record<string, unknown> => {
|
|
60
|
+
const config: Record<string, unknown> = { arch: 'amd64', ostype: 'debian', unprivileged: 1 };
|
|
61
|
+
let disk = 0;
|
|
62
|
+
for (const [key, value] of form) {
|
|
63
|
+
if (['ostemplate', 'start', 'vmid'].includes(key)) continue;
|
|
64
|
+
if (key === 'rootfs' || /^mp\d+$/.test(key)) {
|
|
65
|
+
const [first, ...rest] = value.split(',');
|
|
66
|
+
const fresh = /^([^:]+):(\d+)$/.exec(first ?? '');
|
|
67
|
+
config[key] =
|
|
68
|
+
fresh === null
|
|
69
|
+
? value
|
|
70
|
+
: [
|
|
71
|
+
`${fresh[1] ?? ''}:subvol-${vmid}-disk-${String(disk++)}`,
|
|
72
|
+
...rest,
|
|
73
|
+
`size=${fresh[2] ?? '0'}G`,
|
|
74
|
+
].join(',');
|
|
75
|
+
} else if (/^net\d+$/.test(key)) {
|
|
76
|
+
const map = new Map(pairs(value));
|
|
77
|
+
if (!map.has('hwaddr'))
|
|
78
|
+
map.set('hwaddr', `00:00:5E:00:53:${vmid.slice(-2).padStart(2, '0')}`);
|
|
79
|
+
if (!map.has('type')) map.set('type', 'veth');
|
|
80
|
+
config[key] = [...map].map(([k, v]) => `${k}=${v}`).join(',');
|
|
81
|
+
} else if (key === 'tags') {
|
|
82
|
+
config[key] = [...new Set(value.toLowerCase().split(/[;,\s]+/))].sort().join(';');
|
|
83
|
+
} else config[key] = /^\d+$/.test(value) ? Number(value) : value;
|
|
84
|
+
}
|
|
85
|
+
return config;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const fakePve = (): FakePve => {
|
|
89
|
+
const guests = new Map<string, Record<string, unknown>>();
|
|
90
|
+
const others: { vmid: number | string; node: string; type: string }[] = [];
|
|
91
|
+
const configErrors = new Map<string, { status: number; body: unknown }>();
|
|
92
|
+
const vanish = new Map<string, number>();
|
|
93
|
+
const ignore = new Set<string>();
|
|
94
|
+
const task = { exit: 'OK', noUpid: false };
|
|
95
|
+
const seen: Seen[] = [];
|
|
96
|
+
let upids = 0;
|
|
97
|
+
const upid = (node: string, kind: string, vmid: string) => {
|
|
98
|
+
upids += 1;
|
|
99
|
+
if (task.noUpid) return null;
|
|
100
|
+
return `UPID:${node}:${String(upids).padStart(8, '0')}:0:0:${kind}:${vmid}:hf-test@pve!fake:`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handle = async (request: Request): Promise<Response> => {
|
|
104
|
+
const url = new URL(request.url);
|
|
105
|
+
// ⚠️ QUERY AND BODY BOTH: a DELETE carries its parameters in the query, and a `force=1` there
|
|
106
|
+
// must be visible to the test that says there is none.
|
|
107
|
+
const form = new URLSearchParams(url.search);
|
|
108
|
+
if (request.method !== 'GET')
|
|
109
|
+
for (const pair of new URLSearchParams(await request.text())) form.append(...pair);
|
|
110
|
+
seen.push({ form, method: request.method, path: url.pathname });
|
|
111
|
+
if (url.pathname.startsWith('/v1/') && url.pathname.includes('/creds/')) {
|
|
112
|
+
return json({
|
|
113
|
+
data: { secret: 'not-a-secret', token_id: 'hf-test@pve!fake' },
|
|
114
|
+
lease_duration: 300,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const path = url.pathname.replace(/^\/api2\/json\//, '');
|
|
118
|
+
if (path === 'cluster/resources') {
|
|
119
|
+
const rows = [...guests.keys()]
|
|
120
|
+
.filter((key) => (vanish.get(key) ?? 0) <= 0)
|
|
121
|
+
.map((key) => {
|
|
122
|
+
const [node, vmid] = key.split('/');
|
|
123
|
+
return { node, type: 'lxc', vmid: Number(vmid) };
|
|
124
|
+
});
|
|
125
|
+
for (const [key, left] of vanish) vanish.set(key, left - 1);
|
|
126
|
+
return json({ data: [...rows, ...others] });
|
|
127
|
+
}
|
|
128
|
+
if (/^nodes\/[^/]+\/tasks\/.+\/status$/.test(path)) {
|
|
129
|
+
return json({ data: { exitstatus: task.exit, status: 'stopped' } });
|
|
130
|
+
}
|
|
131
|
+
const create = /^nodes\/([^/]+)\/lxc$/.exec(path);
|
|
132
|
+
if (create !== null && request.method === 'POST') {
|
|
133
|
+
const node = create[1] ?? '';
|
|
134
|
+
const vmid = form.get('vmid') ?? '';
|
|
135
|
+
guests.set(`${node}/${vmid}`, { ...created(vmid, form), digest: nextDigest() });
|
|
136
|
+
return json({ data: upid(node, 'vzcreate', vmid) });
|
|
137
|
+
}
|
|
138
|
+
const guest = /^nodes\/([^/]+)\/lxc\/(\d+)(\/config|\/resize)?$/.exec(path);
|
|
139
|
+
if (guest === null) return json({ data: null, message: `no such path ${path}\n` }, 501);
|
|
140
|
+
const [, node = '', vmid = '', tail] = guest;
|
|
141
|
+
const forced = configErrors.get(`${node}/${vmid}`);
|
|
142
|
+
if (forced !== undefined && tail === '/config' && request.method === 'GET') {
|
|
143
|
+
return json(forced.body, forced.status);
|
|
144
|
+
}
|
|
145
|
+
const config =
|
|
146
|
+
(vanish.get(`${node}/${vmid}`) ?? 0) > 0 ? undefined : guests.get(`${node}/${vmid}`);
|
|
147
|
+
if (config === undefined) {
|
|
148
|
+
const message = `Configuration file 'nodes/${node}/lxc/${vmid}.conf' does not exist\n`;
|
|
149
|
+
return json({ data: null, message }, 500);
|
|
150
|
+
}
|
|
151
|
+
if (tail === '/config' && request.method === 'GET') return json({ data: config });
|
|
152
|
+
if (tail === '/config' && request.method === 'PUT') {
|
|
153
|
+
const digest = form.get('digest');
|
|
154
|
+
if (digest !== null && digest !== config['digest']) {
|
|
155
|
+
return json({ data: null, message: 'detected modified configuration\n' }, 500);
|
|
156
|
+
}
|
|
157
|
+
for (const [key, value] of form)
|
|
158
|
+
if (key !== 'digest' && key !== 'delete' && !ignore.has(key)) config[key] = value;
|
|
159
|
+
for (const key of (form.get('delete') ?? '').split(',')) if (key !== '') delete config[key];
|
|
160
|
+
config['digest'] = nextDigest();
|
|
161
|
+
return json({ data: null });
|
|
162
|
+
}
|
|
163
|
+
if (tail === '/resize' && request.method === 'PUT') {
|
|
164
|
+
const disk = form.get('disk') ?? '';
|
|
165
|
+
const current = String(config[disk] ?? '');
|
|
166
|
+
config[disk] = current.replace(/size=[^,]+/, `size=${form.get('size') ?? ''}`);
|
|
167
|
+
config['digest'] = nextDigest();
|
|
168
|
+
return json({ data: upid(node, 'resize', vmid) });
|
|
169
|
+
}
|
|
170
|
+
if (tail === undefined && request.method === 'DELETE') {
|
|
171
|
+
guests.delete(`${node}/${vmid}`);
|
|
172
|
+
return json({ data: upid(node, 'vzdestroy', vmid) });
|
|
173
|
+
}
|
|
174
|
+
return json({ data: null, message: 'unhandled\n' }, 501);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const server = Bun.serve({ fetch: handle, port: 0 });
|
|
178
|
+
const port = String(server.port);
|
|
179
|
+
const remapped = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
180
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
181
|
+
if (url.hostname !== FAKE_MEMBER && url.hostname !== new URL(FAKE_BAO).hostname) {
|
|
182
|
+
return Promise.reject(
|
|
183
|
+
Object.assign(new Error(`getaddrinfo ENOTFOUND ${url.hostname}`), {
|
|
184
|
+
code: 'ENOTFOUND',
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
url.protocol = 'http:';
|
|
189
|
+
url.hostname = '127.0.0.1';
|
|
190
|
+
url.port = port;
|
|
191
|
+
return globalThis.fetch(url, init);
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
configErrors,
|
|
195
|
+
fetch: Object.assign(remapped, { preconnect: globalThis.fetch.preconnect }),
|
|
196
|
+
guests,
|
|
197
|
+
ignore,
|
|
198
|
+
others,
|
|
199
|
+
seen,
|
|
200
|
+
stop: () => server.stop(true),
|
|
201
|
+
task,
|
|
202
|
+
vanish,
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Every call that changed something, as `METHOD path`, in order — OpenBao mints excluded. */
|
|
207
|
+
export const writesOf = (fake: FakePve) =>
|
|
208
|
+
fake.seen
|
|
209
|
+
.filter((call) => call.method !== 'GET')
|
|
210
|
+
.map((call) => `${call.method} ${call.path.replace(/^\/api2\/json\//, '')}`);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PVE cluster and the OpenBao that mints for it, both answered in-process by one stub `fetch` —
|
|
3
|
+
* so a family's REAL provider can run through Alchemy's engine with every call recorded.
|
|
4
|
+
*
|
|
5
|
+
* ★ A STUB `fetch`, NOT A SERVER. client.test.ts remaps hosts onto Bun.serve to test failover;
|
|
6
|
+
* here the question is only which calls a lifecycle makes, and a stub answers every URL without
|
|
7
|
+
* a socket, so nothing can reach a real host whatever the environment says.
|
|
8
|
+
* ⛔ AND THE ENVIRONMENT IS EMPTIED ANYWAY (`withoutBao`, fake-bao-env.ts), so a token in the
|
|
9
|
+
* shell running the tests is never read.
|
|
10
|
+
* ⚠️ `lease_duration: 0` KEEPS THE LEASE CACHE COLD (lease-cache.ts `timeToLive`), so no fake
|
|
11
|
+
* credential outlives the test that minted it.
|
|
12
|
+
* ⛔ TEST-ONLY. No provider imports this file.
|
|
13
|
+
*/
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
16
|
+
import type { PveTarget } from './credentials.ts';
|
|
17
|
+
import { withFakeBao } from './fake-bao-env.ts';
|
|
18
|
+
import { resetLastGoodForTest } from './members.ts';
|
|
19
|
+
|
|
20
|
+
export const FAKE_TARGET: PveTarget = {
|
|
21
|
+
members: ['pve.test'],
|
|
22
|
+
mount: 'proxmox-test',
|
|
23
|
+
scheme: 'pve',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface PveCall {
|
|
27
|
+
readonly method: string;
|
|
28
|
+
/** Relative to `/api2/json/`, query included — the string a family passes to `pve()`. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly form: Readonly<Record<string, string>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Answers one call with its `data`; `undefined` is PVE's `{"data": null}`. */
|
|
34
|
+
export type PveAnswer = (call: PveCall) => unknown;
|
|
35
|
+
|
|
36
|
+
export interface FakePve {
|
|
37
|
+
readonly calls: PveCall[];
|
|
38
|
+
/** Every call that is not a GET, as `METHOD path`. */
|
|
39
|
+
readonly writes: () => string[];
|
|
40
|
+
/** Provide over a provider layer: `Provider().pipe(Layer.provideMerge(fake.layer))`. */
|
|
41
|
+
readonly layer: ReturnType<typeof transport>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Effect's own fetch client over a stub — client.test.ts wires failover the same way. */
|
|
45
|
+
const transport = (stub: typeof fetch) =>
|
|
46
|
+
FetchHttpClient.layer.pipe(Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, stub)));
|
|
47
|
+
|
|
48
|
+
export const fakePve = (answer: PveAnswer): FakePve => {
|
|
49
|
+
const calls: PveCall[] = [];
|
|
50
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
51
|
+
const request =
|
|
52
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
53
|
+
const url = new URL(request.url);
|
|
54
|
+
if (url.pathname.includes('/creds/')) {
|
|
55
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
56
|
+
return Response.json({ data, lease_duration: 0 });
|
|
57
|
+
}
|
|
58
|
+
const path = `${url.pathname.replace(/^\/api2\/json\//, '')}${url.search}`;
|
|
59
|
+
const form = Object.fromEntries(new URLSearchParams(await request.text()));
|
|
60
|
+
const call = { form, method: request.method, path };
|
|
61
|
+
calls.push(call);
|
|
62
|
+
return Response.json({ data: answer(call) ?? null });
|
|
63
|
+
};
|
|
64
|
+
// ⚠️ `typeof fetch` carries `preconnect` on bun's lib — client.test.ts has the note.
|
|
65
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
66
|
+
return {
|
|
67
|
+
calls,
|
|
68
|
+
layer: transport(fetchStub as typeof fetch),
|
|
69
|
+
writes: () => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path}`),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Run `body` against the stub's OpenBao (fake-bao-env.ts), then forget the member failover state. */
|
|
74
|
+
export const withoutBao = <A>(body: () => Promise<A>): Promise<A> =>
|
|
75
|
+
withFakeBao('http://bao.invalid', body).finally(resetLastGoodForTest);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public barrel: control-plane Resource constructors
|
|
2
|
+
* Public barrel: control-plane Resource constructors and `ProxmoxLxc`, not QEMU or NIC apply.
|
|
3
3
|
*
|
|
4
|
-
* ⛔
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* ⛔ Vm stays Provider-only. NodeNetwork/NetworkApply stay off the barrel — they stage
|
|
5
|
+
* interfaces.new. ★ Lxc is a Resource since 2026-09-21: adopt-first, refuse-not-replace,
|
|
6
|
+
* retain on destroy (lxc.ts).
|
|
7
7
|
*/
|
|
8
8
|
import { expect, test } from 'bun:test';
|
|
9
9
|
import { PbsDatastore, ProxmoxStorage } from './index.ts';
|
|
@@ -20,6 +20,7 @@ const resourceExports = [
|
|
|
20
20
|
'ProxmoxGroup',
|
|
21
21
|
'ProxmoxHaResource',
|
|
22
22
|
'ProxmoxHaRule',
|
|
23
|
+
'ProxmoxLxc',
|
|
23
24
|
'ProxmoxMetricServer',
|
|
24
25
|
'ProxmoxNotificationTarget',
|
|
25
26
|
'ProxmoxRole',
|
|
@@ -39,8 +40,7 @@ test('control-plane Resource constructors are on the public barrel', () => {
|
|
|
39
40
|
}
|
|
40
41
|
});
|
|
41
42
|
|
|
42
|
-
test('
|
|
43
|
-
expect(src).not.toMatch(/export \{ ProxmoxLxc[, }]/);
|
|
43
|
+
test('QEMU and NIC apply stay Provider-only', () => {
|
|
44
44
|
expect(src).not.toMatch(/export \{ ProxmoxVm[, }]/);
|
|
45
45
|
expect(src).not.toMatch(/export \{ ProxmoxNodeNetwork[, }]/);
|
|
46
46
|
expect(src).not.toMatch(/export \{ ProxmoxNetworkApply[, }]/);
|
package/src/proxmox/index.ts
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* provider needs but a consumer should not depend on. An `export *` here would publish
|
|
7
7
|
* every helper as API and make the next refactor a breaking change.
|
|
8
8
|
* ★ Control-plane Resource constructors (Storage, SDN, access, HA, backup,
|
|
9
|
-
* metrics, PBS) sit next to their Providers.
|
|
9
|
+
* metrics, PBS) sit next to their Providers. NIC apply and QEMU stay
|
|
10
10
|
* Provider-only so a stack cannot declare them by accident.
|
|
11
|
+
* ★ `ProxmoxLxc` JOINED THEM 2026-09-21, AND IT WAS KEPT OFF UNTIL IT COULD NOT HURT A GUEST.
|
|
12
|
+
* The provider-only version diffed four fields and would have planned a create over any read
|
|
13
|
+
* failure. The Resource now adopts from the live config, refuses every change PVE cannot make
|
|
14
|
+
* in place, and retains on destroy (lxc.ts).
|
|
11
15
|
* ★ Anything else unlisted is still reachable by path if you genuinely need
|
|
12
16
|
* it — that is a deliberate, visible act rather than an accident of barrelling.
|
|
13
17
|
*/
|
|
@@ -23,7 +27,7 @@ export { ProxmoxFirewallAliasProvider } from './firewall-alias.ts';
|
|
|
23
27
|
export { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
|
|
24
28
|
export { ProxmoxHaResource, ProxmoxHaResourceProvider } from './ha-resource.ts';
|
|
25
29
|
export { ProxmoxHaRule, ProxmoxHaRuleProvider } from './ha-rule.ts';
|
|
26
|
-
export { ProxmoxLxcProvider } from './lxc.ts';
|
|
30
|
+
export { type LxcAttributes, type LxcProps, ProxmoxLxc, ProxmoxLxcProvider } from './lxc.ts';
|
|
27
31
|
export { ProxmoxMetricServer, ProxmoxMetricServerProvider } from './metric-server.ts';
|
|
28
32
|
export { ProxmoxNetworkApplyProvider } from './network-apply.ts';
|
|
29
33
|
export { ProxmoxNodeNetworkProvider } from './node-network.ts';
|