@homeflare/alchemy 0.31.2 → 0.33.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 +39 -3
- package/dist/grafana/dashboard-model.d.ts +77 -0
- package/dist/grafana/dashboard-model.d.ts.map +1 -0
- package/dist/grafana/dashboard.d.ts +98 -0
- package/dist/grafana/dashboard.d.ts.map +1 -0
- package/dist/grafana/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +9 -8
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +158 -6
- package/dist/grafana/index.js.map +8 -4
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +11 -0
- package/dist/grafana/provisioned.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/index.js +170 -80
- package/dist/proxmox/index.js.map +9 -7
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-form.d.ts +124 -0
- package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +27 -52
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +11 -10
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +9 -4
- package/package.json +12 -2
- package/src/grafana/dashboard-model.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +111 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +21 -8
- package/src/grafana/providers.ts +7 -1
- package/src/grafana/provisioned.ts +49 -0
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/constraints-host-forms.test.ts +1 -1
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
- package/src/proxmox/zfs-pool-form.ts +236 -0
- package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
- package/src/proxmox/zfs-pool-wire.ts +132 -0
- package/src/proxmox/zfs-pool.ts +106 -76
- package/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
- package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
- package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
- package/dist/proxmox/zfs-pool-write.d.ts +0 -128
- package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
- package/src/proxmox/zfs-pool-spec.ts +0 -76
- package/src/proxmox/zfs-pool-write.ts +0 -196
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.ZfsPool`'s own cries-wolf fix, PLUS the request-shape and transient-failure fixes
|
|
3
|
+
* kit 0.31.2/0.31.3 (PR 239/243) found for User/Group/Storage — all apply here too, this time on
|
|
4
|
+
* a `read`-role read (this family never needed `provision` for GETs). Real-shaped fixtures (live
|
|
5
|
+
* TB4 `n2` response bodies, measured 2026-09-24, nothing secret): `GET /nodes/n2/disks/zfs/rpool`
|
|
6
|
+
* -> `{"data":{"name":"rpool","state":"ONLINE","errors":"No known data errors","children":[...]}}`,
|
|
7
|
+
* and a missing pool -> a generic shelled-out `zpool status` failure at HTTP 500, the same class
|
|
8
|
+
* of measured 500 as every other family this package has migrated.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
13
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
14
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
15
|
+
import { ProxmoxZfsPool, ProxmoxZfsPoolProvider } from './zfs-pool.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One pool, an OpenBao mint the test can deny for `read` mid-run (the cries-wolf case), and a
|
|
19
|
+
* cluster answer the test can break into a transient 500 mid-run (the request-shape regression's
|
|
20
|
+
* own case) — never both at once, so one fixture serves every test below.
|
|
21
|
+
*/
|
|
22
|
+
const clusterOverPool = (node: string, name: string, live: Record<string, unknown>) => {
|
|
23
|
+
let denyRead = false;
|
|
24
|
+
let transient = false;
|
|
25
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
26
|
+
const request =
|
|
27
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
28
|
+
const url = new URL(request.url);
|
|
29
|
+
if (url.pathname.includes('/creds/')) {
|
|
30
|
+
if (denyRead && url.pathname.endsWith('/creds/read')) {
|
|
31
|
+
return Response.json({ errors: ['permission denied'] }, { status: 403 });
|
|
32
|
+
}
|
|
33
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
34
|
+
return Response.json({ data, lease_duration: 0 });
|
|
35
|
+
}
|
|
36
|
+
if (request.method === 'GET') {
|
|
37
|
+
// ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE (user.ts/group.ts/storage.ts): a real
|
|
38
|
+
// fetch client refuses a GET carrying a body outright.
|
|
39
|
+
expect(await request.text()).toBe('');
|
|
40
|
+
}
|
|
41
|
+
const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
42
|
+
if (wire !== `nodes/${node}/disks/zfs/${name}`) return Response.json({ data: null });
|
|
43
|
+
if (transient) {
|
|
44
|
+
// MEASURED 2026-09-24, live TB4: a missing pool answers exactly this shape.
|
|
45
|
+
return Response.json(
|
|
46
|
+
{ data: null, message: `command '/sbin/zpool status -P ${name}' failed: exit code 1\n` },
|
|
47
|
+
{ status: 500 },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return Response.json({ data: live });
|
|
51
|
+
};
|
|
52
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
53
|
+
return {
|
|
54
|
+
breakReads: () => {
|
|
55
|
+
transient = true;
|
|
56
|
+
},
|
|
57
|
+
denyRead: () => {
|
|
58
|
+
denyRead = true;
|
|
59
|
+
},
|
|
60
|
+
layer: FetchHttpClient.layer.pipe(
|
|
61
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// MEASURED 2026-09-24, live TB4 `GET /nodes/n2/disks/zfs/rpool`.
|
|
67
|
+
const live = {
|
|
68
|
+
children: [
|
|
69
|
+
{ children: [{ name: 'nvme-part3', state: 'ONLINE' }], name: 'rpool', state: 'ONLINE' },
|
|
70
|
+
],
|
|
71
|
+
errors: 'No known data errors',
|
|
72
|
+
name: 'rpool',
|
|
73
|
+
state: 'ONLINE',
|
|
74
|
+
};
|
|
75
|
+
// ⚠️ ADOPT-ONLY (no `devices`/`raidlevel`, decision 9) — the pool is not one this test creates.
|
|
76
|
+
const declare = () =>
|
|
77
|
+
ProxmoxZfsPool('n2-rpool', { name: 'rpool', node: 'n2', target: FAKE_TARGET });
|
|
78
|
+
|
|
79
|
+
describe("Proxmox.ZfsPool's own cries-wolf fix: a refused read mint reports noop, never update", () => {
|
|
80
|
+
test('an already-adopted pool plans noop once the credential is denied, and nothing is written', async () => {
|
|
81
|
+
const fake = clusterOverPool('n2', 'rpool', live);
|
|
82
|
+
await withoutBao(async () => {
|
|
83
|
+
const engine = engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
84
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'adopted' });
|
|
85
|
+
fake.denyRead();
|
|
86
|
+
const report = await engine.verify(declare(), { all: true });
|
|
87
|
+
// ⛔ THE BUG THIS PROVES FIXED — same class as storage.ts's, measured for that family
|
|
88
|
+
// 2026-09-24: before the dual-path fix this row read `update` with nothing compared.
|
|
89
|
+
expect(report.rows[0]).toMatchObject({ diff: 'noop' });
|
|
90
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'noop' });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('the same request-shape and transient-failure fixes, once for Proxmox.ZfsPool', () => {
|
|
96
|
+
test('diff() propagates a transient read failure once a state row exists', async () => {
|
|
97
|
+
const fake = clusterOverPool('n2', 'rpool', live);
|
|
98
|
+
await withoutBao(async () => {
|
|
99
|
+
const engine = engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
100
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'adopted' });
|
|
101
|
+
fake.breakReads();
|
|
102
|
+
// ⛔ THE FIX: this must REJECT, never resolve with a false 'update'.
|
|
103
|
+
await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('alchemy drift never reports a transient failure as missing', async () => {
|
|
108
|
+
const fake = clusterOverPool('n2', 'rpool', live);
|
|
109
|
+
await withoutBao(async () => {
|
|
110
|
+
const engine = engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
111
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'adopted' });
|
|
112
|
+
fake.breakReads();
|
|
113
|
+
// ⛔ THE FIX: this must REJECT. Without `read`'s `output` branch it resolved with
|
|
114
|
+
// `{ 'n2-rpool': { action: 'missing' } }` and no error anywhere.
|
|
115
|
+
await expect(engine.drift(declare())).rejects.toBeDefined();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.ZfsPool`'s READ side: the live-to-attributes shape, the read itself, and `matches`.
|
|
3
|
+
* Split out of zfs-pool.ts (2026-09-24, the distilled migration) — the storage.ts/storage-wire.ts
|
|
4
|
+
* seam; the write side is zfs-pool-form.ts.
|
|
5
|
+
*/
|
|
6
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { runPve } from './distilled-pve.ts';
|
|
9
|
+
import type { ZfsPoolProps } from './zfs-pool.ts';
|
|
10
|
+
import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
|
|
11
|
+
import { text } from './values.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ⚠️ EVERYTHING HERE IS REPORTED, NOTHING HERE IS COMPARED — see the ⛔ on `matches` below. These
|
|
15
|
+
* exist so a plan and the state store can say what the pool actually is, on a family where the
|
|
16
|
+
* declaration and the live object share no comparable field at all.
|
|
17
|
+
*/
|
|
18
|
+
export interface ZfsPoolAttributes {
|
|
19
|
+
/** ★ The value a `Proxmox.Storage` should read to order itself after this pool. */
|
|
20
|
+
name: string;
|
|
21
|
+
node: string;
|
|
22
|
+
/** `ONLINE` | `DEGRADED` | `FAULTED` | … Health, not configuration. */
|
|
23
|
+
state: string;
|
|
24
|
+
/** ZFS's own error summary, e.g. `No known data errors`. */
|
|
25
|
+
errors: string;
|
|
26
|
+
/**
|
|
27
|
+
* The leaf devices ZFS reports, in vdev order, comma-joined.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ THIS IS WHAT ZFS RESOLVED, NOT WHAT WAS DECLARED, and the two normally differ: PVE rewrites
|
|
30
|
+
* a `/dev/sdb` into a by-id link before creating, and ZFS reports partition paths (`…-part3`)
|
|
31
|
+
* for a pool built on partitions — MEASURED, that is exactly what node-b's `rpool` reports.
|
|
32
|
+
* Never compare it with `props.devices`.
|
|
33
|
+
*/
|
|
34
|
+
devices: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
|
|
41
|
+
* which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
|
|
42
|
+
* equally well; recursing on `children` needs no second field to be present and is what makes
|
|
43
|
+
* nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
|
|
44
|
+
*/
|
|
45
|
+
export const leafDevices = (children: unknown): string[] =>
|
|
46
|
+
Array.isArray(children)
|
|
47
|
+
? children.flatMap((entry: unknown) => {
|
|
48
|
+
const vdev = entry as { children?: unknown; name?: unknown };
|
|
49
|
+
const nested = leafDevices(vdev.children);
|
|
50
|
+
return nested.length > 0 ? nested : [text(vdev.name)].filter((name) => name !== '');
|
|
51
|
+
})
|
|
52
|
+
: [];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ⚠️ `scan`, `status` AND `action` ARE DELIBERATELY NOT ATTRIBUTES. All three are advisory strings
|
|
56
|
+
* ZFS rewrites on its own — `scan` on every scrub, the other two as feature flags and faults come
|
|
57
|
+
* and go — so keeping them would rewrite this resource's state row for reasons no declaration
|
|
58
|
+
* caused. Same reasoning as `digest` in storage.ts.
|
|
59
|
+
*/
|
|
60
|
+
const attributesOf = (
|
|
61
|
+
live: nodes.GetNodeDiskZfsResponse,
|
|
62
|
+
props: ZfsPoolProps,
|
|
63
|
+
): ZfsPoolAttributes => ({
|
|
64
|
+
devices: leafDevices(live.children).join(','),
|
|
65
|
+
errors: text(live.errors, 'unknown'),
|
|
66
|
+
name: props.name,
|
|
67
|
+
node: props.node,
|
|
68
|
+
state: text(live.state, 'unknown'),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ★ Default `read` role — `Sys.Audit` on `/` is all this GET checks (zfs-pool.ts's header).
|
|
73
|
+
* ⛔ `{ node: props.node, name: props.name }`, NEVER THE WHOLE `props` — storage.ts's/user.ts's own
|
|
74
|
+
* ⛔, the kit 0.31.1 regression: `GetNodeDiskZfsRequest`'s schema declares only `node` and `name`
|
|
75
|
+
* (both path labels); any OTHER key on the object passed to `nodes.getNodeDiskZfs` is treated by
|
|
76
|
+
* distilled's `buildRequest` as an "unknown key" and JSON-encoded onto this bodyless GET, which a
|
|
77
|
+
* stricter fetch client refuses outright.
|
|
78
|
+
* ⛔ A MISSING POOL IS A 500, NOT A 404 — MEASURED against the live cluster (TB4 n2, 2026-09-24,
|
|
79
|
+
* read-role, read-only probe): `GET /nodes/n2/disks/zfs/hf-measure-nonexistent-pool` answers
|
|
80
|
+
* `{"message":"command '/sbin/zpool status -P hf-measure-nonexistent-pool' failed: exit code
|
|
81
|
+
* 1\n","data":null}` at HTTP 500 — PVE shells out to `zpool status` and reports its exit code
|
|
82
|
+
* as a generic command failure, not a typed "not found". The same shape as user.ts's/group.ts's/
|
|
83
|
+
* storage.ts's own measured 500s. `readPool` below (used by `createPool`'s settle-poll and by
|
|
84
|
+
* `reconcile`/`read`) still folds it, exactly as `readUser`/`readGroup`/`readStorage` do — the
|
|
85
|
+
* whole reason `createPool` can tell "not built yet" from "forbidden" only by giving up after
|
|
86
|
+
* `SETTLE_ATTEMPTS` (zfs-pool-form.ts's own ⚠️ on that); `readPoolOrFail` (used by `diff`) does
|
|
87
|
+
* not fold it, so a genuine transient failure there propagates and fails the plan loudly instead.
|
|
88
|
+
*/
|
|
89
|
+
export const readPoolOrFail = (props: ZfsPoolProps) =>
|
|
90
|
+
readOrUnreadable(
|
|
91
|
+
runPve(
|
|
92
|
+
props.target,
|
|
93
|
+
'read',
|
|
94
|
+
false,
|
|
95
|
+
nodes.getNodeDiskZfs({ name: props.name, node: props.node }),
|
|
96
|
+
),
|
|
97
|
+
).pipe(
|
|
98
|
+
Effect.map((live) => {
|
|
99
|
+
if (live === UNREADABLE) return UNREADABLE;
|
|
100
|
+
// ⚠️ A detail answer with no `name` is not a pool — same shape check zfs-pool-spec.ts made,
|
|
101
|
+
// kept as a defensive fallback now that the 500 above is the measured absence signal.
|
|
102
|
+
return text(live.name) === '' ? undefined : attributesOf(live, props);
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `createPool`'s settle-poll AND
|
|
108
|
+
* `read`/`reconcile` MAY USE IT — user.ts's `readUser` has the full reasoning. Safe here because
|
|
109
|
+
* a wrongful fold during create costs at most a redundant `createNodeDiskZfs` POST, which PVE
|
|
110
|
+
* refuses loudly ("pool already exists") rather than silently corrupting anything — zfs-pool.ts's
|
|
111
|
+
* own header, measured.
|
|
112
|
+
* ⛔ `zfs-pool.ts`'s `diff` does **NOT** use this — the same bug class as the credential denial fix:
|
|
113
|
+
* folding a TRANSIENT failure into "absent" at PLAN TIME would force a false `update` with
|
|
114
|
+
* nothing compared. `diff` calls `readPoolOrFail` directly, so a genuine failure there propagates
|
|
115
|
+
* and fails the whole plan loudly instead.
|
|
116
|
+
*/
|
|
117
|
+
export const readPool = (props: ZfsPoolProps) =>
|
|
118
|
+
readPoolOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
|
|
119
|
+
|
|
120
|
+
/** `read`/`reconcile`/`createPool` return `Attributes | undefined`; only `diff` sees `UNREADABLE`. */
|
|
121
|
+
export const dropUnreadable = (live: ZfsPoolAttributes | Unreadable | undefined) =>
|
|
122
|
+
live === UNREADABLE ? undefined : live;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* ⛔ THERE IS NO `matches` FUNCTION HERE — UNLIKE EVERY OTHER MIGRATED FAMILY, AND DELIBERATELY.
|
|
126
|
+
* Every create parameter on this family is write-only (this file's own `attributesOf`), so
|
|
127
|
+
* there is nothing a declaration and a live pool can ever be compared on — a `matches` that
|
|
128
|
+
* invented one would plan a replace (`zpool destroy`, since this family has no PUT) over a
|
|
129
|
+
* difference it could never verify. `zfs-pool.ts`'s `diff` returns `noop` unconditionally once a
|
|
130
|
+
* pool is found, rather than calling a function that would only ever return `true` — found by
|
|
131
|
+
* adversarial review that an earlier draft exported one here anyway, unused by anything.
|
|
132
|
+
*/
|
package/src/proxmox/zfs-pool.ts
CHANGED
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
* ⛔ THERE IS NO PUT ON THIS FAMILY AT ALL. MEASURED from the cluster's own published schema
|
|
27
27
|
* (`/usr/share/pve-docs/api-viewer/apidoc.js`, read on node-b 2026-09-13): `/nodes/{node}/disks/zfs`
|
|
28
28
|
* has GET and POST, `/nodes/{node}/disks/zfs/{name}` has GET and DELETE, and that is the whole
|
|
29
|
-
* surface
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* surface — CONFIRMED again in `@distilled.cloud/proxmox`'s generated `nodes.ts`, which declares
|
|
30
|
+
* no `putNodeDiskZfs` at all. So `matches` (zfs-pool-wire.ts) always answers `true`: there is
|
|
31
|
+
* nothing to plan a replace over, and the family's one write is the create below.
|
|
32
32
|
*
|
|
33
33
|
* ⛔ NOT ONE CREATE PARAMETER COMES BACK ON READ, SO `matches` COMPARES NOTHING. MEASURED against
|
|
34
|
-
* the live cluster: `GET /nodes/
|
|
35
|
-
* `{action
|
|
36
|
-
*
|
|
34
|
+
* the live cluster: `GET /nodes/n2/disks/zfs/rpool` answers exactly
|
|
35
|
+
* `{action?, children, errors, name, scan?, state, status?}` — no `ashift`, no `compression`, no
|
|
36
|
+
* `raidlevel`, no `devices`, no `add_storage`. Every create parameter is write-only. There is
|
|
37
37
|
* therefore no field a declaration and a live pool can both be asked about, and a `matches` that
|
|
38
38
|
* invented one would plan a replace — i.e. a `zpool destroy` — over a difference it could never
|
|
39
39
|
* verify in the first place.
|
|
@@ -46,11 +46,20 @@
|
|
|
46
46
|
* is compared, and the three churning strings are kept out of the state store entirely rather
|
|
47
47
|
* than rewriting this resource's row every time somebody writes a file.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
49
|
+
* ⛔ A MISSING POOL IS A 500, NOT A 404 — MEASURED against the live cluster (TB4 n2, 2026-09-24,
|
|
50
|
+
* read-role, read-only probe): `GET /nodes/n2/disks/zfs/hf-measure-nonexistent-pool` answers a
|
|
51
|
+
* generic shelled-out command failure at HTTP 500, the same shape as every other family this
|
|
52
|
+
* package has migrated. `zfs-pool-wire.ts` carries the same dual-path read PR 239/243
|
|
53
|
+
* established: `readPoolOrFail` (non-folding, used by `diff`) and `readPool` (folding, used by
|
|
54
|
+
* `createPool`'s settle-poll and by `read`/`reconcile`).
|
|
55
|
+
*
|
|
56
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveOperations` ONTO `@distilled.cloud/proxmox`'s
|
|
57
|
+
* typed `nodes.getNodeDiskZfs`/`createNodeDiskZfs`/`deleteNodeDiskZfs` (2026-09-24, decision
|
|
58
|
+
* 43's proxmox walk-down, nodes/storage sub-area). `distilled-pve.ts`'s `runPve` replaces
|
|
59
|
+
* `pve()`; the cries-wolf fix is wired the same way as every other migrated family. `reconcile`
|
|
60
|
+
* and `delete` are still hand-written (never the factory) for the SAME two reasons as before the
|
|
61
|
+
* migration — the forked-worker settle-poll, and a DELETE that destroys data — now calling
|
|
62
|
+
* `zfs-pool-form.ts`'s `createPool`/`destroyPool` directly instead of through `pveHandlers`.
|
|
54
63
|
*
|
|
55
64
|
* ⚠️ PRIVILEGES, FROM THE SCHEMA. Read and diff need `Sys.Audit` on `/` — both GETs check it.
|
|
56
65
|
* Reconcile needs `Sys.Modify` on `/` for the POST. `Datastore.Allocate` on `/storage` is NOT
|
|
@@ -60,31 +69,23 @@
|
|
|
60
69
|
* family checks it, not only the POST. MEASURED the same day as the rest of this paragraph:
|
|
61
70
|
* `ceph-osd.ts`'s own privilege comparison names `disks/zfs` as one of the sibling families
|
|
62
71
|
* whose write verbs DO carry a `Sys.Modify` check, unlike `ceph/osd`'s create and delete, which
|
|
63
|
-
* carry no permissions block at all and 403 for any identity but `root@pam`.
|
|
64
|
-
* previously said delete "needs nothing at all, since it calls nothing", which repeated the same
|
|
65
|
-
* mistake the header above corrects: `delete` calls the DELETE endpoint, so it needs whatever
|
|
66
|
-
* that endpoint checks.
|
|
72
|
+
* carry no permissions block at all and 403 for any identity but `root@pam`.
|
|
67
73
|
*/
|
|
68
74
|
import { Resource } from 'alchemy';
|
|
75
|
+
import { isResolved } from 'alchemy/Diff';
|
|
69
76
|
import * as Provider from 'alchemy/Provider';
|
|
70
77
|
import * as Effect from 'effect/Effect';
|
|
71
|
-
import
|
|
72
|
-
import {
|
|
73
|
-
import {
|
|
74
|
-
import {
|
|
78
|
+
import { asForm } from './distilled-guard.ts';
|
|
79
|
+
import { guardForm } from './constraint-guard.ts';
|
|
80
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
81
|
+
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
82
|
+
import { dropUnreadable, readPool, readPoolOrFail } from './zfs-pool-wire.ts';
|
|
83
|
+
import type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
|
|
84
|
+
import { createForm, createPool, destroyPool, zfsPoolCreateEndpoint } from './zfs-pool-form.ts';
|
|
85
|
+
import type { ZfsCompression, ZfsRaidLevel } from './zfs-pool-form.ts';
|
|
75
86
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
* (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
|
|
79
|
-
* hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
|
|
80
|
-
* even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
|
|
81
|
-
* as-is): `ZfsPoolProps` below is why a stripe pool is declarable at all — as an ADOPT-ONLY
|
|
82
|
-
* declaration with no `raidlevel`, never as a full one.
|
|
83
|
-
*/
|
|
84
|
-
export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
|
|
85
|
-
|
|
86
|
-
/** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
|
|
87
|
-
export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
|
|
87
|
+
export type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
|
|
88
|
+
export type { ZfsCompression, ZfsRaidLevel } from './zfs-pool-form.ts';
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
91
|
* ⛔ THERE IS NO `add_storage`, `cleanup-config` OR `cleanup-disks` PROP, AND THAT IS NOT AN
|
|
@@ -122,7 +123,7 @@ export interface ZfsPoolProps extends WithTarget {
|
|
|
122
123
|
* both this and `raidlevel` undeclared declares an ADOPT-ONLY pool — one this resource may
|
|
123
124
|
* read and report but never build. That is the only shape available for a pool PVE's own POST
|
|
124
125
|
* schema cannot fully describe, such as n1's `speed`, whose layout is a stripe and whose
|
|
125
|
-
* `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-
|
|
126
|
+
* `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-form.ts) omits
|
|
126
127
|
* both keys when they are absent, and `createPool` refuses before any POST if the pool it
|
|
127
128
|
* would need to build is not already there — an adopt-only declaration never creates. Declare
|
|
128
129
|
* both fields together for a pool this resource should build from scratch; declaring only one
|
|
@@ -152,30 +153,6 @@ export interface ZfsPoolProps extends WithTarget {
|
|
|
152
153
|
'draid-config'?: string;
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
/**
|
|
156
|
-
* ⚠️ EVERYTHING HERE IS REPORTED, NOTHING HERE IS COMPARED — see the ⛔ on `matches`. These exist
|
|
157
|
-
* so a plan and the state store can say what the pool actually is, on a family where the
|
|
158
|
-
* declaration and the live object share no comparable field at all.
|
|
159
|
-
*/
|
|
160
|
-
export interface ZfsPoolAttributes {
|
|
161
|
-
/** ★ The value a `Proxmox.Storage` should read to order itself after this pool. */
|
|
162
|
-
name: string;
|
|
163
|
-
node: string;
|
|
164
|
-
/** `ONLINE` | `DEGRADED` | `FAULTED` | … Health, not configuration. */
|
|
165
|
-
state: string;
|
|
166
|
-
/** ZFS's own error summary, e.g. `No known data errors`. */
|
|
167
|
-
errors: string;
|
|
168
|
-
/**
|
|
169
|
-
* The leaf devices ZFS reports, in vdev order, comma-joined.
|
|
170
|
-
*
|
|
171
|
-
* ⚠️ THIS IS WHAT ZFS RESOLVED, NOT WHAT WAS DECLARED, and the two normally differ: PVE rewrites
|
|
172
|
-
* a `/dev/sdb` into a by-id link before creating, and ZFS reports partition paths (`…-part3`)
|
|
173
|
-
* for a pool built on partitions — MEASURED, that is exactly what node-b's `rpool` reports. Never
|
|
174
|
-
* compare it with `props.devices`.
|
|
175
|
-
*/
|
|
176
|
-
devices: string;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
156
|
export interface ProxmoxZfsPool extends Resource<
|
|
180
157
|
'Proxmox.ZfsPool',
|
|
181
158
|
ZfsPoolProps,
|
|
@@ -189,24 +166,77 @@ export const ProxmoxZfsPool = Resource<ProxmoxZfsPool>('Proxmox.ZfsPool', {
|
|
|
189
166
|
defaultRemovalPolicy: 'retain',
|
|
190
167
|
});
|
|
191
168
|
|
|
192
|
-
const ops = pveOperations(spec);
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* ★ THREE HANDLERS COME FROM THE FACTORY AND TWO DO NOT, SO THE TWO ARE THE ONLY ONES WRITTEN OUT.
|
|
196
|
-
* `list`, `read` and `diff` are the factory's, unchanged — the spread is what proves it, rather
|
|
197
|
-
* than three re-typed delegations nobody rereads. `reconcile` and `delete` are the two the header
|
|
198
|
-
* explains, and they are the only places this family departs from every other resource here.
|
|
199
|
-
* This is the acl.ts precedent, for a different reason: there the factory's delete hits an
|
|
200
|
-
* endpoint PVE does not implement, here it hits one that works and destroys the data.
|
|
201
|
-
*/
|
|
202
|
-
const handlers = {
|
|
203
|
-
...pveHandlers(spec),
|
|
204
|
-
delete: Effect.fn(function* ({ olds }: { olds: ZfsPoolProps }) {
|
|
205
|
-
yield* destroyPool(olds, spec.path(olds));
|
|
206
|
-
}),
|
|
207
|
-
reconcile: ({ news }: { news: ZfsPoolProps }) =>
|
|
208
|
-
createPool(news, spec.collection(news), spec.path(news), ops.read),
|
|
209
|
-
};
|
|
210
|
-
|
|
211
169
|
export const ProxmoxZfsPoolProvider = () =>
|
|
212
|
-
Provider.effect(
|
|
170
|
+
Provider.effect(
|
|
171
|
+
ProxmoxZfsPool,
|
|
172
|
+
Effect.succeed(
|
|
173
|
+
ProxmoxZfsPool.Provider.of({
|
|
174
|
+
/** ⛔ EMPTY — `GET /nodes/{node}/disks/zfs` returns telemetry for every pool on the node,
|
|
175
|
+
* `rpool` included. Adoption stays explicit. */
|
|
176
|
+
list: () => Effect.succeed([]),
|
|
177
|
+
// ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — storage.ts's own ⚠️, applied here from the
|
|
178
|
+
// start: `output === undefined` (Plan.ts's adoption probe, Apply.ts's delete recovery)
|
|
179
|
+
// keeps `readPool`'s fold; `output !== undefined` (Drift.ts's already-confirmed row)
|
|
180
|
+
// uses `readPoolOrFail` instead, so `alchemy drift` never reports a transient failure as
|
|
181
|
+
// a silent `{action: 'missing'}`.
|
|
182
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
183
|
+
return dropUnreadable(
|
|
184
|
+
yield* output === undefined ? readPool(olds) : readPoolOrFail(olds),
|
|
185
|
+
);
|
|
186
|
+
}),
|
|
187
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
188
|
+
if (!isResolved(news)) return undefined;
|
|
189
|
+
// ⚠️ `guardForm`, NOT `guardWrite` — the only migrated family whose create-guard is
|
|
190
|
+
// CONDITIONAL. `guardWrite`'s own signature requires a real `EndpointKey`, but decision
|
|
191
|
+
// 9's adopt-only mode needs `undefined` some of the time (`zfsPoolCreateEndpoint`'s own
|
|
192
|
+
// header) — `guardForm` (constraint-guard.ts) is the one `distilled-guard.ts`'s
|
|
193
|
+
// `guardWrite` itself calls, and it already accepts `EndpointKey | undefined`.
|
|
194
|
+
yield* guardForm(
|
|
195
|
+
zfsPoolCreateEndpoint(news),
|
|
196
|
+
asForm(createForm(news)),
|
|
197
|
+
output === undefined,
|
|
198
|
+
);
|
|
199
|
+
if (output === undefined) return undefined;
|
|
200
|
+
// ⚠️ `readPoolOrFail`, NOT `readPool` — a genuine TRANSIENT failure here propagates and
|
|
201
|
+
// fails the whole plan loudly instead of folding to "absent" and forcing a false
|
|
202
|
+
// update, the cries-wolf class of bug.
|
|
203
|
+
const live = yield* readPoolOrFail(news);
|
|
204
|
+
if (live === UNREADABLE) {
|
|
205
|
+
yield* unreadableWarning('Proxmox.ZfsPool', `${news.node}/${news.name}`);
|
|
206
|
+
return { action: 'noop' } as const;
|
|
207
|
+
}
|
|
208
|
+
// ⛔ FOUND WRITING THIS FILE, 2026-09-24 — an earlier edit that removed the dead
|
|
209
|
+
// `matches()` call (zfs-pool-wire.ts's own ⛔) took this whole branch out with it,
|
|
210
|
+
// leaving a genuinely-absent pool planning `noop` unconditionally instead of `update`
|
|
211
|
+
// — a silent no-op on a state row Alchemy still holds, caught by
|
|
212
|
+
// zfs-pool-adopt.test.ts's own "an adopted pool that vanishes" case rejecting the plan
|
|
213
|
+
// loudly rather than resolving. Restored before this PR opened.
|
|
214
|
+
if (live === undefined) {
|
|
215
|
+
yield* guardForm(zfsPoolCreateEndpoint(news), asForm(createForm(news)), true);
|
|
216
|
+
return { action: 'update' } as const;
|
|
217
|
+
}
|
|
218
|
+
// A live pool means `noop`, unconditionally — `matches` (zfs-pool-wire.ts's own ⛔)
|
|
219
|
+
// always answers `true`, since every create parameter is write-only: there is nothing a
|
|
220
|
+
// declaration and a live pool can be compared on, so nothing here can ever be a replace.
|
|
221
|
+
return { action: 'noop' } as const;
|
|
222
|
+
}),
|
|
223
|
+
/**
|
|
224
|
+
* ⛔ THE ONLY WRITE THIS FAMILY EVER MAKES IS `createPool`'s create — see zfs-pool-form.ts.
|
|
225
|
+
* An existing pool is returned exactly as read, unwritten.
|
|
226
|
+
* ⚠️ `readPool` PIPED THROUGH `dropUnreadable` — `createPool`'s settle-poll treats a
|
|
227
|
+
* refused mid-poll credential exactly like "not found yet" (its own header), so its own
|
|
228
|
+
* `read` parameter is typed `Attributes | undefined`, never `Unreadable`; folding here is
|
|
229
|
+
* what keeps that true rather than leaking the sentinel into `reconcile`'s own return
|
|
230
|
+
* value, which the engine expects to be real attributes or nothing.
|
|
231
|
+
*/
|
|
232
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
233
|
+
return yield* createPool(news, (props) =>
|
|
234
|
+
readPool(props).pipe(Effect.map(dropUnreadable)),
|
|
235
|
+
);
|
|
236
|
+
}),
|
|
237
|
+
delete: Effect.fn(function* ({ olds }) {
|
|
238
|
+
yield* destroyPool(olds);
|
|
239
|
+
}),
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal in-memory UniFi Network API for this family's tests — an in-memory `fetch` handed to
|
|
3
|
+
* Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring `../netbox/fake-netbox.ts`
|
|
4
|
+
* (itself mirroring `../forgejo/fake-forgejo.ts`). Tests exercise distilled's REAL path assembly,
|
|
5
|
+
* JSON encode/decode and status→typed-error matching — nothing about that matching is
|
|
6
|
+
* re-implemented here, only the wire responses a scenario needs.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
|
|
9
|
+
* `unifi.example.com` and the key is a placeholder — never a real console address: a cloud
|
|
10
|
+
* connector's base URL embeds the account's Console ID, which must never appear in this repo
|
|
11
|
+
* (`docs/unifi.md`).
|
|
12
|
+
*/
|
|
13
|
+
import { type Credentials, credentials } from '@distilled.cloud/unifi-network/Credentials';
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
16
|
+
|
|
17
|
+
export const FAKE_BASE = 'https://unifi.example.com/proxy/network/integration';
|
|
18
|
+
export const FAKE_KEY = 'placeholder-unifi-key';
|
|
19
|
+
|
|
20
|
+
export type Seen = { readonly method: string; readonly path: string };
|
|
21
|
+
|
|
22
|
+
/** No documented failure envelope (see `protocol.ts`) — plain text is all a failure needs. */
|
|
23
|
+
export const fakeFailure = (status: number, detail: string) => new Response(detail, { status });
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `route` decides every response; this wrapper only records what was sent and adapts to
|
|
27
|
+
* `globalThis.fetch`'s signature.
|
|
28
|
+
*
|
|
29
|
+
* ★ THE WRITE-REFUSAL PROOF USES `seen` DIRECTLY, NOT A THROWING ROUTE. `resource.test.ts`'s
|
|
30
|
+
* "no SDK write operation is reachable" test asserts `seen.every(s => s.method === 'GET')`
|
|
31
|
+
* after driving every handler — a request this family's engine was never supposed to send
|
|
32
|
+
* shows up in `seen` as a fact, rather than depending on an exception surviving Effect's own
|
|
33
|
+
* error handling on its way back out of a fake `fetch`.
|
|
34
|
+
*/
|
|
35
|
+
export const fakeUnifi = (route: (method: string, url: URL) => Response) => {
|
|
36
|
+
const seen: Seen[] = [];
|
|
37
|
+
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
38
|
+
const request =
|
|
39
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
40
|
+
const url = new URL(request.url);
|
|
41
|
+
seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
|
|
42
|
+
return route(request.method, url);
|
|
43
|
+
}) as typeof globalThis.fetch;
|
|
44
|
+
return { fetch, seen };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* distilled Credentials + the real FetchHttpClient over the fake — `UnifiNetworkOpContext`, what
|
|
49
|
+
* every resource file's exported `spec.fetchLive` needs.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ NOT PAIRED WITH `handlers`. `handlers` (each resource file's `unifiHandlers(spec)` export)
|
|
52
|
+
* bakes in `CredentialsFromEnv`, which resolves `UNIFI_NETWORK_API_KEY`/`_API_BASE_URL` through
|
|
53
|
+
* Effect's `Config` — whose default provider snapshots `process.env` once and never re-reads it
|
|
54
|
+
* (the same seam `netbox/fake-netbox.ts` documents). Tests call `spec.fetchLive`/`spec.attributes`
|
|
55
|
+
* directly instead, with this explicit layer.
|
|
56
|
+
*/
|
|
57
|
+
export const fakeUnifiLayer = (
|
|
58
|
+
fetchFn: typeof globalThis.fetch,
|
|
59
|
+
creds: Layer.Layer<Credentials> = credentials({ apiBaseUrl: FAKE_BASE, apiKey: FAKE_KEY }),
|
|
60
|
+
) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.FirewallZone`'s wire shape — the declarable subset of `FirewallZone`, and how a live
|
|
3
|
+
* read becomes both plan attributes and a rendered declaration.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ ONLY `name` AND `networkIds` ARE DECLARABLE. `CreateFirewallZoneRequest`
|
|
6
|
+
* (`firewall.ts`) accepts exactly those two beyond `siteId`; `metadata` (origin/source) and
|
|
7
|
+
* `id` are server-derived, so — same split as `network-form.ts`'s `default`/`metadata` —
|
|
8
|
+
* they are attributes-only, reported but never compared or declared.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ `networkIds` ORDER IS NOT PART OF IDENTITY. The vendor document gives no indication the
|
|
11
|
+
* list is ordered (it is a set of attached networks, not a sequence like the ordering
|
|
12
|
+
* endpoints' rule lists), so `matches` compares it as a set — sorted before comparing —
|
|
13
|
+
* the same reasoning `proxmox/user-wire.ts`'s `groupSet` gives for PVE's `groups`.
|
|
14
|
+
*/
|
|
15
|
+
import type * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
16
|
+
|
|
17
|
+
export interface FirewallZoneProps {
|
|
18
|
+
siteId: string;
|
|
19
|
+
firewallZoneId: string;
|
|
20
|
+
/** Name of a firewall zone. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Network IDs attached to this zone — compared as a set, see the header. */
|
|
23
|
+
networkIds: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FirewallZoneAttributes {
|
|
27
|
+
siteId: string;
|
|
28
|
+
firewallZoneId: string;
|
|
29
|
+
name: string;
|
|
30
|
+
networkIds: string[];
|
|
31
|
+
/** `origin`/`source` (e.g. `SDWAN`) — server-derived, never declared. */
|
|
32
|
+
metadataOrigin: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();
|
|
36
|
+
|
|
37
|
+
export const attributesOf = (
|
|
38
|
+
live: firewall.FirewallZone,
|
|
39
|
+
props: FirewallZoneProps,
|
|
40
|
+
): FirewallZoneAttributes => ({
|
|
41
|
+
siteId: props.siteId,
|
|
42
|
+
firewallZoneId: live.id,
|
|
43
|
+
name: live.name,
|
|
44
|
+
networkIds: sortedSet(live.networkIds),
|
|
45
|
+
metadataOrigin: live.metadata.origin,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const matches = (attributes: FirewallZoneAttributes, props: FirewallZoneProps): boolean =>
|
|
49
|
+
attributes.name === props.name &&
|
|
50
|
+
attributes.networkIds.join(',') === sortedSet(props.networkIds).join(',');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The declaration renderer (task spec: "given one live object, return the props a declaration
|
|
54
|
+
* needs so its plan is noop"). `matches(attributesOf(live, props), declareFirewallZone(live,
|
|
55
|
+
* siteId))` is `true` by construction (`firewall-zone.test.ts` proves it).
|
|
56
|
+
*/
|
|
57
|
+
export const declareFirewallZone = (
|
|
58
|
+
live: firewall.FirewallZone,
|
|
59
|
+
siteId: string,
|
|
60
|
+
): FirewallZoneProps => ({
|
|
61
|
+
siteId,
|
|
62
|
+
firewallZoneId: live.id,
|
|
63
|
+
name: live.name,
|
|
64
|
+
networkIds: sortedSet(live.networkIds),
|
|
65
|
+
});
|