@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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one refusal every `Unifi.*` write handler returns — never an SDK write call.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY FOR NOW. This family has no
|
|
5
|
+
* `create`, no `update` body and no delete call anywhere in its source — `reconcile` and
|
|
6
|
+
* `delete` both fail with this typed error instead, and `resource.test.ts` proves the point
|
|
7
|
+
* with a fake `HttpClient` that fails the test on any request that is not a `GET`. Lifting the
|
|
8
|
+
* policy is a kit change (a new PR that adds a write path, reviewed as one), not a flag a stack
|
|
9
|
+
* can pass.
|
|
10
|
+
*
|
|
11
|
+
* ★ A TYPED TAG, NOT A THROWN STRING (S21). `Effect.catchTag('UnifiWriteRefused', …)` lets a
|
|
12
|
+
* caller (a future import script, a test) branch on the refusal without parsing a message.
|
|
13
|
+
*/
|
|
14
|
+
import * as Data from 'effect/Data';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
|
|
17
|
+
export const UNIFI_READ_ONLY_POLICY =
|
|
18
|
+
"read-only by Tim's rule, 2026-09-24; lifting it is a kit change";
|
|
19
|
+
|
|
20
|
+
/** What kind of write was asked for and refused — for the message only, never acted on. */
|
|
21
|
+
export type UnifiWriteAction = 'create' | 'update' | 'delete';
|
|
22
|
+
|
|
23
|
+
export class UnifiWriteRefused extends Data.TaggedError('UnifiWriteRefused')<{
|
|
24
|
+
/** The vendor type string, e.g. `Unifi.Network`. */
|
|
25
|
+
readonly type: string;
|
|
26
|
+
/** `spec.describe(props)` — the object the write would have touched. */
|
|
27
|
+
readonly identity: string;
|
|
28
|
+
readonly action: UnifiWriteAction;
|
|
29
|
+
}> {
|
|
30
|
+
override get message(): string {
|
|
31
|
+
return `${this.type} ${this.identity}: refusing to ${this.action} -- ${UNIFI_READ_ONLY_POLICY}.`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The only thing `reconcile`/`delete` ever return for a write this family cannot make. */
|
|
36
|
+
export const refuseWrite = (type: string, identity: string, action: UnifiWriteAction) =>
|
|
37
|
+
Effect.fail(new UnifiWriteRefused({ type, identity, action }));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `Unifi.*` provider as one layer for a stack.
|
|
3
|
+
*
|
|
4
|
+
* Layer.mergeAll(unifiProviders(), …the stack's other providers)
|
|
5
|
+
* .pipe(Layer.provide(FetchHttpClient.layer))
|
|
6
|
+
*
|
|
7
|
+
* S16: a subpath's `providers()` plays the role upstream's own `Providers.ts` does — one minimal
|
|
8
|
+
* insertion per family. `FetchHttpClient.layer` is the stack's own responsibility to provide,
|
|
9
|
+
* mirroring `../discord/providers.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { UnifiFirewallZoneProvider } from './firewall-zone.ts';
|
|
13
|
+
import { UnifiNetworkProvider } from './network.ts';
|
|
14
|
+
|
|
15
|
+
export const providers = () => Layer.mergeAll(UnifiNetworkProvider(), UnifiFirewallZoneProvider());
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unifiOperations` in isolation, against a synthetic spec — no real UniFi SDK, no HTTP. Proves
|
|
3
|
+
* the read-only engine's own contract: `Unowned` on a cold read (H1), `noop`/`update` diff,
|
|
4
|
+
* `reconcile` succeeding with ZERO write calls on an exact match (the H6 forced post-adoption
|
|
5
|
+
* call this engine has to survive without writing), and a typed `UnifiWriteRefused` — never a
|
|
6
|
+
* silent success, never a raw SDK write — every other time `reconcile` or `delete` runs.
|
|
7
|
+
*
|
|
8
|
+
* `network.test.ts`/`firewall-zone.test.ts` prove the same contract again against the REAL SDK
|
|
9
|
+
* and a fake HTTP server, and additionally prove that no non-`GET` request is ever sent.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import { fakeUnifiLayer } from './fake-unifi.ts';
|
|
16
|
+
import { UnifiWriteRefused } from './policy.ts';
|
|
17
|
+
import { type UnifiSpec, unifiOperations } from './resource.ts';
|
|
18
|
+
|
|
19
|
+
interface WidgetProps {
|
|
20
|
+
id: string;
|
|
21
|
+
value: number;
|
|
22
|
+
}
|
|
23
|
+
type WidgetLive = WidgetProps;
|
|
24
|
+
type WidgetAttrs = WidgetProps;
|
|
25
|
+
|
|
26
|
+
/** `live: undefined` models a declared object nothing on the site matches. */
|
|
27
|
+
const specFor = (
|
|
28
|
+
live: WidgetLive | undefined,
|
|
29
|
+
): UnifiSpec<WidgetProps, WidgetLive, WidgetAttrs, never> => ({
|
|
30
|
+
type: 'Test.Widget',
|
|
31
|
+
describe: (props) => `widgets/${props.id}`,
|
|
32
|
+
fetchLive: () => Effect.succeed(live),
|
|
33
|
+
attributes: (l) => l,
|
|
34
|
+
matches: (attrs, props) => attrs.value === props.value,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const MATCH: WidgetProps = { id: 'w1', value: 1 };
|
|
38
|
+
const DRIFTED_LIVE: WidgetLive = { id: 'w1', value: 99 };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `UnifiSpec`'s `fetchLive` is typed against the real `UnifiNetworkOpContext` (`resource.ts`'s
|
|
42
|
+
* own header explains why the engine is not generic over it) even though `specFor` above never
|
|
43
|
+
* touches HTTP — so every run here still needs A layer satisfying that context. This one fails
|
|
44
|
+
* the test outright if anything ever calls it, which is itself part of the write-refusal proof:
|
|
45
|
+
* the synthetic spec has no operation that could, but if one ever did, this is not a silent pass.
|
|
46
|
+
*/
|
|
47
|
+
const unreachableFetch = (() => {
|
|
48
|
+
throw new Error('resource.test.ts: unexpected HTTP call from a spec with no HTTP operations');
|
|
49
|
+
}) as unknown as typeof globalThis.fetch;
|
|
50
|
+
|
|
51
|
+
const run = <A, E>(effect: Effect.Effect<A, E, UnifiNetworkOpContext>) =>
|
|
52
|
+
Effect.runPromise(effect.pipe(Effect.provide(fakeUnifiLayer(unreachableFetch))));
|
|
53
|
+
|
|
54
|
+
const refusal = async (
|
|
55
|
+
effect: Effect.Effect<unknown, UnifiWriteRefused, UnifiNetworkOpContext>,
|
|
56
|
+
) => {
|
|
57
|
+
const failure = await run(Effect.flip(effect));
|
|
58
|
+
expect(failure).toBeInstanceOf(UnifiWriteRefused);
|
|
59
|
+
return failure;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe('unifiOperations.readHandler', () => {
|
|
63
|
+
test('cold read (no persisted output) of a match answers Unowned', async () => {
|
|
64
|
+
const attrs = await run(
|
|
65
|
+
unifiOperations(specFor(MATCH)).readHandler({ olds: MATCH, output: undefined }),
|
|
66
|
+
);
|
|
67
|
+
expect(Unowned.is(attrs)).toBe(true);
|
|
68
|
+
expect(attrs).toEqual(MATCH);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('warm read (persisted output says ours already) answers plain attributes', async () => {
|
|
72
|
+
const attrs = await run(
|
|
73
|
+
unifiOperations(specFor(MATCH)).readHandler({ olds: MATCH, output: MATCH }),
|
|
74
|
+
);
|
|
75
|
+
expect(Unowned.is(attrs)).toBe(false);
|
|
76
|
+
expect(attrs).toEqual(MATCH);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a genuinely absent object answers undefined either way', async () => {
|
|
80
|
+
const attrs = await run(
|
|
81
|
+
unifiOperations(specFor(undefined)).readHandler({ olds: MATCH, output: MATCH }),
|
|
82
|
+
);
|
|
83
|
+
expect(attrs).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('unifiOperations.diff', () => {
|
|
88
|
+
test('no persisted output defers to the engine default (undefined)', async () => {
|
|
89
|
+
const diff = await run(unifiOperations(specFor(MATCH)).diff(MATCH, undefined));
|
|
90
|
+
expect(diff).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('declared props match live -- noop', async () => {
|
|
94
|
+
const diff = await run(unifiOperations(specFor(MATCH)).diff(MATCH, MATCH));
|
|
95
|
+
expect(diff).toEqual({ action: 'noop' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('live object drifted from the declaration -- update, never silently absorbed', async () => {
|
|
99
|
+
const diff = await run(unifiOperations(specFor(DRIFTED_LIVE)).diff(MATCH, MATCH));
|
|
100
|
+
expect(diff).toEqual({ action: 'update' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('live object vanished since it was adopted -- update, never a quiet noop', async () => {
|
|
104
|
+
const diff = await run(unifiOperations(specFor(undefined)).diff(MATCH, MATCH));
|
|
105
|
+
expect(diff).toEqual({ action: 'update' });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('unifiOperations.reconcile', () => {
|
|
110
|
+
test('exact match: zero write calls (the spec has none), returns live attributes -- H6', async () => {
|
|
111
|
+
const attrs = await run(unifiOperations(specFor(MATCH)).reconcile(MATCH));
|
|
112
|
+
expect(attrs).toEqual(MATCH);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('missing live object refuses "create", never fabricates one', async () => {
|
|
116
|
+
const failure = await refusal(unifiOperations(specFor(undefined)).reconcile(MATCH));
|
|
117
|
+
expect(failure.action).toBe('create');
|
|
118
|
+
expect(failure.type).toBe('Test.Widget');
|
|
119
|
+
expect(failure.identity).toBe('widgets/w1');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('drifted live object refuses "update", never converges by writing', async () => {
|
|
123
|
+
const failure = await refusal(unifiOperations(specFor(DRIFTED_LIVE)).reconcile(MATCH));
|
|
124
|
+
expect(failure.action).toBe('update');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('unifiOperations.destroy', () => {
|
|
129
|
+
test('always refuses "delete" -- the spec has no destroy operation at all', async () => {
|
|
130
|
+
const failure = await refusal(unifiOperations(specFor(MATCH)).destroy(MATCH));
|
|
131
|
+
expect(failure.action).toBe('delete');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shape for every UniFi Network read-only object — `Unifi.Network`, `Unifi.FirewallZone`,
|
|
3
|
+
* and whatever import adds next.
|
|
4
|
+
*
|
|
5
|
+
* ★ MIRRORS `../discord/resource.ts` (S7-S10, H1, H5), NOT `../netbox/resource.ts`. Both are
|
|
6
|
+
* "marker-less API" families (S8): UniFi Network objects carry no tag or metadata field this
|
|
7
|
+
* stack could stamp as ownership, so a cold read of a match is `Unowned(attrs)` exactly like
|
|
8
|
+
* `Cloudflare/Snippets/Snippet.ts@v2.0.0-beta.79#read`, the reference implementation S8 and H1
|
|
9
|
+
* both cite. `adopt(true)`, piped on by each resource's convenience constructor (H5), turns
|
|
10
|
+
* that into a silent one-time bind instead of an `OwnedBySomeoneElse` refusal.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ THE DIFFERENCE FROM EVERY OTHER FAMILY HERE: THERE IS NO `create`, `update` OR `destroy` IN
|
|
13
|
+
* THIS SPEC AT ALL. Tim's rule, 2026-09-24 (`policy.ts`): UniFi and OPNsense are read-only. So
|
|
14
|
+
* `reconcile` below can only ever refuse or report an exact match — it has no write path to
|
|
15
|
+
* fall into by accident, and no resource file wired through this engine can reach one either.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ RECONCILE STILL HAS TO SUCCEED ON AN EXACT-MATCH ADOPTION, NOT JUST REFUSE UNCONDITIONALLY.
|
|
18
|
+
* Measured against `Plan.ts@v2.0.0-beta.79` (`forceUpdateAfterAdoption`, H6): beta.79 forces
|
|
19
|
+
* ONE reconcile call after every cold adoption, whether or not the diff said noop — so a
|
|
20
|
+
* resource whose `reconcile` always failed would break the ordinary "adopt an unchanged
|
|
21
|
+
* object" deploy, not just a real write. The fix, exactly like `discordOperations.reconcile`:
|
|
22
|
+
* read the live object again, and refuse ONLY when it is missing (would need a create) or
|
|
23
|
+
* drifted (would need a write to converge). An exact match returns the live attributes and
|
|
24
|
+
* calls nothing — S10's "zero write calls when there is no drift" holds even under the forced
|
|
25
|
+
* post-adoption call.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE —
|
|
28
|
+
* same split netbox's `resource.ts` uses and for the same reason: `Effect.catchTag`'s
|
|
29
|
+
* tag-literal inference needs a concrete error union to resolve `"NotFound"` against, and a
|
|
30
|
+
* spec's `E` is only concrete at each resource file's own call site. Every OTHER failure (a
|
|
31
|
+
* real 5xx, a network error, `Forbidden`) is NOT folded anywhere — it fails the plan loudly,
|
|
32
|
+
* per the task's own rule: only a genuine not-found may mean absent.
|
|
33
|
+
*/
|
|
34
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
35
|
+
import { isResolved } from 'alchemy/Diff';
|
|
36
|
+
import type { Input } from 'alchemy/Input';
|
|
37
|
+
import { CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
|
|
38
|
+
import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
|
|
39
|
+
import * as Effect from 'effect/Effect';
|
|
40
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
41
|
+
import { refuseWrite } from './policy.ts';
|
|
42
|
+
|
|
43
|
+
/** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
|
|
44
|
+
export type UnifiRequirements = HttpClient.HttpClient;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One UniFi Network object's read. `Live` is whatever the SDK decodes (a `NetworkDetails`, a
|
|
48
|
+
* `FirewallZone`, …); `E` is left to each resource file to declare (its own `fetchLive`'s error
|
|
49
|
+
* union, with `NotFound` already folded away) so the precise per-operation error union flows
|
|
50
|
+
* through instead of being widened by hand here.
|
|
51
|
+
*/
|
|
52
|
+
export type UnifiSpec<Props extends object, Live, Attributes extends object, E> = {
|
|
53
|
+
/** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
|
|
54
|
+
readonly type: string;
|
|
55
|
+
/** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
|
|
56
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, UnifiNetworkOpContext>;
|
|
57
|
+
readonly attributes: (live: Live, props: Props) => Attributes;
|
|
58
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
59
|
+
/** For the refusal message and read-back failures. */
|
|
60
|
+
readonly describe: (props: Props) => string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const unifiOperations = <Props extends object, Live, Attributes extends object, E>(
|
|
64
|
+
spec: UnifiSpec<Props, Live, Attributes, E>,
|
|
65
|
+
) => {
|
|
66
|
+
const read = (props: Props) =>
|
|
67
|
+
spec
|
|
68
|
+
.fetchLive(props)
|
|
69
|
+
.pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
read,
|
|
73
|
+
|
|
74
|
+
readHandler: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const attrs = yield* read(olds);
|
|
77
|
+
if (attrs === undefined) return undefined;
|
|
78
|
+
// Warm read (persisted `output` says a prior apply already adopted this exact object):
|
|
79
|
+
// plain attributes. Cold read (fresh store, or state lost): no ownership marker on this
|
|
80
|
+
// vendor exists to tell "ours" from "someone else's" apart, so Unowned until adopt(true).
|
|
81
|
+
return output !== undefined ? attrs : Unowned(attrs);
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
diff: (news: Input<Props>, output: Attributes | undefined) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
87
|
+
const live = yield* read(news);
|
|
88
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
89
|
+
return spec.matches(live, news)
|
|
90
|
+
? ({ action: 'noop' } as const)
|
|
91
|
+
: ({ action: 'update' } as const);
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
reconcile: (news: Props) =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const live = yield* read(news);
|
|
97
|
+
if (live === undefined) return yield* refuseWrite(spec.type, spec.describe(news), 'create');
|
|
98
|
+
if (!spec.matches(live, news)) {
|
|
99
|
+
return yield* refuseWrite(spec.type, spec.describe(news), 'update');
|
|
100
|
+
}
|
|
101
|
+
// Exact match: the object already is what is declared. Zero write calls (S10), correct
|
|
102
|
+
// whether this is a routine deploy or beta.79's forced post-adoption reconcile (H6).
|
|
103
|
+
return live;
|
|
104
|
+
}),
|
|
105
|
+
|
|
106
|
+
destroy: (olds: Props) => refuseWrite(spec.type, spec.describe(olds), 'delete'),
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The four provider handlers for a spec'd read-only UniFi object, wired once.
|
|
112
|
+
*
|
|
113
|
+
* ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
|
|
114
|
+
* adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
|
|
115
|
+
*/
|
|
116
|
+
export const unifiHandlers = <Props extends object, Live, Attributes extends object, E>(
|
|
117
|
+
spec: UnifiSpec<Props, Live, Attributes, E>,
|
|
118
|
+
) => {
|
|
119
|
+
const ops = unifiOperations(spec);
|
|
120
|
+
const withCredentials = <A, Err>(effect: Effect.Effect<A, Err, UnifiNetworkOpContext>) =>
|
|
121
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
122
|
+
return {
|
|
123
|
+
list: () => Effect.succeed([]),
|
|
124
|
+
read: (args: { olds: Props; output: Attributes | undefined }) =>
|
|
125
|
+
withCredentials(ops.readHandler(args)),
|
|
126
|
+
diff: (args: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
127
|
+
withCredentials(ops.diff(args.news, args.output)),
|
|
128
|
+
reconcile: (args: { news: Props }) => withCredentials(ops.reconcile(args.news)),
|
|
129
|
+
delete: (args: { olds: Props }) => withCredentials(ops.destroy(args.olds)),
|
|
130
|
+
};
|
|
131
|
+
};
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `PveSpec` for `Proxmox.ZfsPool`: where a pool lives, what its GET reports, and the vendor
|
|
3
|
-
* endpoint its create form is checked against.
|
|
4
|
-
*
|
|
5
|
-
* ★ SPLIT OUT OF zfs-pool.ts TO KEEP THAT FILE UNDER THE 250-LINE CAP, and the seam is the same
|
|
6
|
-
* one metric-server-form.ts and zfs-pool-write.ts already cut on: zfs-pool.ts answers "what is
|
|
7
|
-
* a zpool and when has it changed", this file answers "what PVE's own GET/{node}/disks/zfs
|
|
8
|
-
* collection and item say, read into that shape". `leafDevices` moved with `spec` because it
|
|
9
|
-
* exists for exactly one field of `spec.attributes` and nothing else in this package calls it.
|
|
10
|
-
*
|
|
11
|
-
* ⚠️ THE `import type` BACK TO zfs-pool.ts IS A CYCLE ON PAPER ONLY — type-only, erased before
|
|
12
|
-
* anything runs, so the resource's public shape stays in the file that declares the resource.
|
|
13
|
-
* Same reasoning as zfs-pool-write.ts.
|
|
14
|
-
*/
|
|
15
|
-
import type { PveSpec } from './resource.ts';
|
|
16
|
-
import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
|
|
17
|
-
/**
|
|
18
|
-
* The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
|
|
19
|
-
*
|
|
20
|
-
* ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
|
|
21
|
-
* which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
|
|
22
|
-
* equally well; recursing on `children` needs no second field to be present and is what makes
|
|
23
|
-
* nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
|
|
24
|
-
*/
|
|
25
|
-
export declare const leafDevices: (children: unknown) => string[];
|
|
26
|
-
export declare const spec: PveSpec<ZfsPoolProps, ZfsPoolAttributes>;
|
|
27
|
-
//# sourceMappingURL=zfs-pool-spec.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"zfs-pool-spec.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAG7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,aAAc,OAAO,KAAG,MAAM,EAO9C,CAAC;AAET,eAAO,MAAM,IAAI,EAAE,OAAO,CAAC,YAAY,EAAE,iBAAiB,CAuCzD,CAAC"}
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The create lane for `Proxmox.ZfsPool`: the form PVE wants, and the wait for the worker it forks.
|
|
3
|
-
*
|
|
4
|
-
* ★ SPLIT OUT OF zfs-pool.ts TO KEEP BOTH FILES UNDER THE 250-LINE CAP, and the seam is a real one
|
|
5
|
-
* rather than a convenient line number. This file answers "how does a declaration become a pool
|
|
6
|
-
* on disks, and how do we know it landed". zfs-pool.ts answers "what is a zpool, what can be
|
|
7
|
-
* said about a live one, and when has it changed" — and on this family the answer to the last of
|
|
8
|
-
* those is "never", which is why the two halves have so little to say to each other.
|
|
9
|
-
*
|
|
10
|
-
* ⚠️ THE `import type` BACK TO zfs-pool.ts IS A CYCLE ON PAPER ONLY — it is type-only, so it is
|
|
11
|
-
* erased before anything runs, and the resource's public shape stays in the file that declares
|
|
12
|
-
* the resource rather than being moved somewhere odd to dodge the arrow. Same as
|
|
13
|
-
* metric-server-form.ts.
|
|
14
|
-
*
|
|
15
|
-
* ⚠️ `createPool` TAKES THE READ AS A PARAMETER, GENERIC IN ITS ERROR AND REQUIREMENT CHANNELS,
|
|
16
|
-
* rather than importing `pveOperations` and rebuilding the spec here. Two `pveOperations` over
|
|
17
|
-
* one spec would be two closures nobody can prove are the same; passing the one the resource
|
|
18
|
-
* already built means this file cannot accidentally read the cluster differently from `diff`.
|
|
19
|
-
*/
|
|
20
|
-
import * as Effect from 'effect/Effect';
|
|
21
|
-
import type { EndpointPair } from './resource-spec.ts';
|
|
22
|
-
import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
|
|
23
|
-
/**
|
|
24
|
-
* The POST body. Every field in it is write-only — nothing here is ever read back.
|
|
25
|
-
*
|
|
26
|
-
* ⚠️ `node` IS NOT IN THE BODY: it is already the `nodes/{node}` segment of the URL being POSTed
|
|
27
|
-
* to, and a second copy can only disagree with it. Same reasoning as `id` in metric-server.ts.
|
|
28
|
-
* ⚠️ `devices` IS JOINED IN DECLARED ORDER AND NOT PUT THROUGH `csv()` FROM values.ts. `csv` sorts,
|
|
29
|
-
* and PVE walks this list two at a time to pair `raid10` mirrors — MEASURED in the node's own
|
|
30
|
-
* `PVE::API2::Disks::ZFS` — so sorting it builds a different pool from the one declared. The
|
|
31
|
-
* list is never compared against anything, so it needs no normalisation, only faithful order.
|
|
32
|
-
* ⚠️ AN OMITTED OPTIONAL IS NOT SENT. PVE's own defaults (ashift 12, compression on) are then
|
|
33
|
-
* applied by the node. Sending a guessed default would be indistinguishable here — the values
|
|
34
|
-
* are unreadable afterwards either way — but it would put a number in the request that no
|
|
35
|
-
* declaration asked for, and this is the one call that writes to physical disks.
|
|
36
|
-
*/
|
|
37
|
-
/**
|
|
38
|
-
* The vendor rules a FULL declaration's create form is checked against at plan time.
|
|
39
|
-
*
|
|
40
|
-
* ⛔ HERE RATHER THAN INLINE IN zfs-pool.ts BECAUSE THAT FILE IS ALREADY OVER THE 250-LINE CAP,
|
|
41
|
-
* and a `pve:POST …` literal has to live in a non-test source file for the generator to table
|
|
42
|
-
* it at all. zfs-pool-spec.ts imports it on the line it already imports `createForm` from.
|
|
43
|
-
* ⚠️ NO UPDATE KEY: PVE registers no PUT under `/nodes/{node}/disks/zfs`, which is exactly why
|
|
44
|
-
* the spec declares no `updateForm` and a changed prop is a REPLACE.
|
|
45
|
-
* ⚠️ `spec.endpoint` DOES NOT RETURN THIS CONSTANT DIRECTLY — see the ★ on `zfsPoolEndpoint`
|
|
46
|
-
* below, and the ⛔ on `devices`/`raidlevel` in zfs-pool.ts (decision 9, 2026-09-23).
|
|
47
|
-
*/
|
|
48
|
-
export declare const ZFS_POOL_ENDPOINT: EndpointPair;
|
|
49
|
-
/**
|
|
50
|
-
* ★ `PveSpec['endpoint']` AS A FUNCTION OF PROPS, THE WAY notification-target.ts AND
|
|
51
|
-
* ceph-daemon.ts ALREADY DO IT FOR A DIFFERENT REASON (resource-spec.ts's ⚠️ on that field).
|
|
52
|
-
* Here the reason is decision 9's adopt-only mode: `guardCreate` runs on the FIRST plan of
|
|
53
|
-
* ANY declaration (resource.ts `ops.diff`, `output === undefined` branch) and checks the
|
|
54
|
-
* create form's REQUIRED fields whenever `endpoint.create` resolves to a key — and PVE marks
|
|
55
|
-
* both `devices` and `raidlevel` required (measured, codegen/constraints's `pve-nodes-disks`
|
|
56
|
-
* row). An adopt-only declaration deliberately omits both, so returning the real key there
|
|
57
|
-
* would refuse a MATCHING adoption at plan time, before `read` ever ran — exactly the gap
|
|
58
|
-
* `guardCreate`/`guardUpdate` being exported (resource.ts's ★) says this family must not fall
|
|
59
|
-
* through. Returning `{}` (no `create` key) makes `guardCreate` a no-op (`constraint-guard.ts`:
|
|
60
|
-
* `create === undefined ? Effect.void : …`) for an adopt-only declaration, while a FULL one
|
|
61
|
-
* (`devices` and `raidlevel` both present) is checked exactly as before.
|
|
62
|
-
* ⚠️ THE RUNTIME BACKSTOP IS `createPool` BELOW, NOT THIS FUNCTION. Skipping the plan-time guard
|
|
63
|
-
* for an adopt-only declaration does not skip the check that the pool is actually there —
|
|
64
|
-
* `createPool` refuses before any POST when the read comes back absent and either field is
|
|
65
|
-
* undefined, which is what a genuinely absent adopt-only declaration hits at apply time.
|
|
66
|
-
*/
|
|
67
|
-
export declare const zfsPoolEndpoint: (props: ZfsPoolProps) => EndpointPair;
|
|
68
|
-
/**
|
|
69
|
-
* ⚠️ EVERY KEY IS OMITTED WHEN ABSENT, `devices` AND `raidlevel` INCLUDED — not just the
|
|
70
|
-
* optional ones. An adopt-only declaration (both undefined, decision 9) must produce a form
|
|
71
|
-
* `createPool`'s own guard can inspect for their absence, and one this function itself does
|
|
72
|
-
* not throw building: `props.devices.join(',')` on an undefined `devices` is exactly the
|
|
73
|
-
* `TypeError` the plan-time guard hit before this change, because `guardCreate` evaluates
|
|
74
|
-
* `spec.createForm(props)` eagerly (resource-guard.ts) — before it even looks at `presence`.
|
|
75
|
-
*/
|
|
76
|
-
export declare const createForm: (props: ZfsPoolProps) => Record<string, string>;
|
|
77
|
-
/**
|
|
78
|
-
* Create the pool if it is absent, and otherwise leave it completely alone.
|
|
79
|
-
*
|
|
80
|
-
* ⛔ THE POST ONLY FORKS A WORKER. MEASURED in `/usr/share/perl5/PVE/API2/Disks/ZFS.pm` on node-b: the
|
|
81
|
-
* create handler ends in `$rpcenv->fork_worker('zfscreate', ...)` and its HTTP answer is a UPID
|
|
82
|
-
* returned the instant the worker is forked — before `zpool create` has run, let alone finished.
|
|
83
|
-
* A read-back taken immediately, as `pveOperations.reconcile` takes it, finds nothing and would
|
|
84
|
-
* report a successful create as the failure "the write returned no error but the object is still
|
|
85
|
-
* absent". THAT is why this function exists instead of the factory's reconcile.
|
|
86
|
-
*
|
|
87
|
-
* ⚠️ THERE IS NO UPDATE BRANCH BECAUSE PVE HAS NO PUT HERE. An existing pool is returned exactly as
|
|
88
|
-
* read, unwritten: the only write this resource ever makes is the create of a pool that is not
|
|
89
|
-
* there. Do not add a "repair" write; there is no field it could set.
|
|
90
|
-
*
|
|
91
|
-
* ⛔ AND IF THE READ IS WRONG, THE POST IS STILL SAFE — measured, not hoped for. `read` folds a 403
|
|
92
|
-
* or an unreachable node into "absent", so a too-narrow lease would send this down the create
|
|
93
|
-
* path over a live pool. PVE's handler calls `get_pool_data()` before it forks anything and dies
|
|
94
|
-
* with "pool '<name>' already exists on node '<node>'", and `assert_disk_unused` refuses every
|
|
95
|
-
* device that pool is holding. A create aimed at a pool that is really there fails loudly rather
|
|
96
|
-
* than wiping it.
|
|
97
|
-
*
|
|
98
|
-
* ⚠️ SHORT POLLS WITH A HARD CAP, NOT ONE LONG WAIT. `read` cannot tell "not yet" from "forbidden"
|
|
99
|
-
* — both are `undefined` — so the loop gives up with the UPID rather than retrying forever, and
|
|
100
|
-
* the UPID is the only thing that leads to the worker's real error.
|
|
101
|
-
*
|
|
102
|
-
* ⛔ THE ADOPT-ONLY GUARD, RIGHT AFTER THE READ AND BEFORE ANYTHING ELSE. Decision 9 (2026-09-23):
|
|
103
|
-
* omitting `devices` and `raidlevel` declares a pool this resource may only ADOPT, for a pool
|
|
104
|
-
* PVE's own schema cannot fully describe — n1's `speed` is a stripe, and `raidlevel`'s enum has
|
|
105
|
-
* no stripe (zfs-pool.ts). `zfsPoolEndpoint` above already keeps the vendor constraint guard
|
|
106
|
-
* from refusing such a declaration at PLAN time; this is the matching refusal at APPLY time, for
|
|
107
|
-
* the one case that guard cannot see — the pool genuinely is not there. MEASURED without it: a
|
|
108
|
-
* tolerant `createForm` sends `POST … {name: "rpool"}` with no `devices`/`raidlevel`, which PVE's
|
|
109
|
-
* own schema would 400 on eventually, but only after the request left this process; refusing
|
|
110
|
-
* here is the same `Effect.die` idiom as every other refusal in this package, and it costs
|
|
111
|
-
* nothing this pool did not already lack.
|
|
112
|
-
*/
|
|
113
|
-
export declare const createPool: <E, R>(props: ZfsPoolProps, collection: string, path: string, read: (props: ZfsPoolProps) => Effect.Effect<ZfsPoolAttributes | undefined, E, R>) => Effect.Effect<ZfsPoolAttributes, E | Error | import("./credential-errors.ts").PveCredentialDenied, R | import("effect/unstable/http/HttpClient").HttpClient>;
|
|
114
|
-
/**
|
|
115
|
-
* ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT. Per
|
|
116
|
-
* S14 (alchemy-provider-standard), it is implemented rather than stubbed — a `delete` that
|
|
117
|
-
* silently does nothing lies to whoever reads the plan — and what keeps an ordinary removed
|
|
118
|
-
* declaration from reaching it is the resource's `defaultRemovalPolicy: 'retain'` (zfs-pool.ts):
|
|
119
|
-
* an orphaned pool is forgotten, not destroyed, unless a caller opts in with
|
|
120
|
-
* `.pipe(RemovalPolicy.destroy())`. See the corrected header in zfs-pool.ts.
|
|
121
|
-
*
|
|
122
|
-
* ⚠️ `cleanup-config` IS NOT SENT. It would additionally remove the PVE storage entry pointing
|
|
123
|
-
* at this pool, which is a different object with its own resource (`Proxmox.Storage`); one
|
|
124
|
-
* resource reaching over to delete another's is how a stack ends up with state describing
|
|
125
|
-
* something that is gone.
|
|
126
|
-
*/
|
|
127
|
-
export declare const destroyPool: (olds: ZfsPoolProps, path: string) => Effect.Effect<unknown, Error | import("./credential-errors.ts").PveCredentialDenied, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
128
|
-
//# sourceMappingURL=zfs-pool-write.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"zfs-pool-write.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,EAAE,YAA6D,CAAC;AAE9F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,eAAe,UAAW,YAAY,KAAG,YACiC,CAAC;AAExF;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,UAAW,YAAY,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOpE,CAAC;AAKH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,SACtB,YAAY,cACP,MAAM,QACZ,MAAM,QACN,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,iKA8C/E,CAAC;AAEL;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,SAAU,YAAY,QAAQ,MAAM,+IACb,CAAC"}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `PveSpec` for `Proxmox.ZfsPool`: where a pool lives, what its GET reports, and the vendor
|
|
3
|
-
* endpoint its create form is checked against.
|
|
4
|
-
*
|
|
5
|
-
* ★ SPLIT OUT OF zfs-pool.ts TO KEEP THAT FILE UNDER THE 250-LINE CAP, and the seam is the same
|
|
6
|
-
* one metric-server-form.ts and zfs-pool-write.ts already cut on: zfs-pool.ts answers "what is
|
|
7
|
-
* a zpool and when has it changed", this file answers "what PVE's own GET/{node}/disks/zfs
|
|
8
|
-
* collection and item say, read into that shape". `leafDevices` moved with `spec` because it
|
|
9
|
-
* exists for exactly one field of `spec.attributes` and nothing else in this package calls it.
|
|
10
|
-
*
|
|
11
|
-
* ⚠️ THE `import type` BACK TO zfs-pool.ts IS A CYCLE ON PAPER ONLY — type-only, erased before
|
|
12
|
-
* anything runs, so the resource's public shape stays in the file that declares the resource.
|
|
13
|
-
* Same reasoning as zfs-pool-write.ts.
|
|
14
|
-
*/
|
|
15
|
-
import type { PveSpec } from './resource.ts';
|
|
16
|
-
import { text } from './values.ts';
|
|
17
|
-
import { createForm, zfsPoolEndpoint } from './zfs-pool-write.ts';
|
|
18
|
-
import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
|
|
22
|
-
*
|
|
23
|
-
* ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
|
|
24
|
-
* which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
|
|
25
|
-
* equally well; recursing on `children` needs no second field to be present and is what makes
|
|
26
|
-
* nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
|
|
27
|
-
*/
|
|
28
|
-
export const leafDevices = (children: unknown): string[] =>
|
|
29
|
-
Array.isArray(children)
|
|
30
|
-
? children.flatMap((entry: unknown) => {
|
|
31
|
-
const vdev = entry as { children?: unknown; name?: unknown };
|
|
32
|
-
const nested = leafDevices(vdev.children);
|
|
33
|
-
return nested.length > 0 ? nested : [text(vdev.name)].filter((name) => name !== '');
|
|
34
|
-
})
|
|
35
|
-
: [];
|
|
36
|
-
|
|
37
|
-
export const spec: PveSpec<ZfsPoolProps, ZfsPoolAttributes> = {
|
|
38
|
-
/**
|
|
39
|
-
* ⚠️ `scan`, `status` AND `action` ARE DELIBERATELY NOT ATTRIBUTES. All three are advisory
|
|
40
|
-
* strings ZFS rewrites on its own — `scan` on every scrub, the other two as feature flags and
|
|
41
|
-
* faults come and go — so keeping them would rewrite this resource's state row for reasons no
|
|
42
|
-
* declaration caused. Same reasoning as `digest` in storage.ts.
|
|
43
|
-
*/
|
|
44
|
-
attributes: (live, props) => {
|
|
45
|
-
// ⚠️ A detail answer with no `name` is not a pool. `name` is non-optional in PVE's schema, so
|
|
46
|
-
// this is a shape check rather than a default — and "absent" is the honest reading of a
|
|
47
|
-
// reply that does not describe the object that was asked for.
|
|
48
|
-
if (text(live['name']) === '') return undefined;
|
|
49
|
-
return {
|
|
50
|
-
devices: leafDevices(live['children']).join(','),
|
|
51
|
-
errors: text(live['errors'], 'unknown'),
|
|
52
|
-
name: props.name,
|
|
53
|
-
node: props.node,
|
|
54
|
-
state: text(live['state'], 'unknown'),
|
|
55
|
-
};
|
|
56
|
-
},
|
|
57
|
-
collection: (props) => `nodes/${props.node}/disks/zfs`,
|
|
58
|
-
createForm,
|
|
59
|
-
/** ★ A function of props, not the constant — see the ★ on `zfsPoolEndpoint` (zfs-pool-write.ts). */
|
|
60
|
-
endpoint: zfsPoolEndpoint,
|
|
61
|
-
/**
|
|
62
|
-
* ⛔ IT ALWAYS ANSWERS TRUE, AND THAT IS THE POINT OF THIS FILE. A pool that is there under the
|
|
63
|
-
* declared name on the declared node IS the declaration, because there is nothing else the two
|
|
64
|
-
* can be compared on: every create parameter is write-only (see the ⛔ in zfs-pool.ts's header)
|
|
65
|
-
* and everything the read does return is telemetry that moves by itself. So declaring what is
|
|
66
|
-
* live plans as `noop` — the only honest answer available, and the one the live cluster needs:
|
|
67
|
-
* `rpool` exists on node-b, node-c and node-d with nothing about its construction readable.
|
|
68
|
-
* ⛔ ANYTHING ADDED HERE IS A REPLACE, NOT AN UPDATE. `updateForm` is omitted because PVE has
|
|
69
|
-
* no PUT, so `resource.ts` turns a false into `{action:'replace'}` — delete then create — on
|
|
70
|
-
* a family whose delete is `zpool destroy`. A comparison added here would be one plan away
|
|
71
|
-
* from destroying a pool over a field it cannot even read back. If a future PVE grows a PUT,
|
|
72
|
-
* add `updateForm` FIRST and only then consider comparing what that PUT accepts.
|
|
73
|
-
*/
|
|
74
|
-
matches: () => true,
|
|
75
|
-
path: (props) => `nodes/${props.node}/disks/zfs/${props.name}`,
|
|
76
|
-
};
|