@homeflare/alchemy 0.31.1 → 0.32.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 +44 -7
- package/dist/grafana/index.d.ts +6 -5
- package/dist/grafana/index.d.ts.map +1 -1
- 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/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +44 -5
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +326 -264
- package/dist/proxmox/index.js.map +13 -11
- package/dist/proxmox/role.d.ts.map +1 -1
- 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/user-wire.d.ts +34 -5
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +6 -6
- package/dist/proxmox/user.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/dist/verify/fake-engine.d.ts +14 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/grafana.md +47 -17
- 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 +14 -4
- package/package.json +13 -3
- package/src/grafana/index.ts +6 -5
- 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/acl.ts +10 -4
- package/src/proxmox/api-token.ts +20 -12
- package/src/proxmox/group-wire.ts +62 -8
- package/src/proxmox/group.ts +23 -35
- package/src/proxmox/read-failure-propagation.test.ts +183 -0
- package/src/proxmox/role.ts +9 -2
- 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/user-wire.ts +44 -8
- package/src/proxmox/user.ts +29 -27
- 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/src/verify/fake-engine.ts +23 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shape for every OPNsense object this family declares — `read`/`diff` mirroring
|
|
3
|
+
* `../discord/resource.ts`'s marker-less-API posture exactly (S7–S9, H1), and `reconcile`/
|
|
4
|
+
* `delete` replaced end to end with the read-only refusal `policy.ts` states.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHY ONE ENGINE FOR THREE RESOURCES. `alias.ts`, `category.ts` and `group.ts` differ only in
|
|
7
|
+
* which `firewall_*` module they call and how a live item maps to attributes — the read/diff/
|
|
8
|
+
* reconcile/delete flow is identical, so it lives here once, the way `netbox/resource.ts` and
|
|
9
|
+
* `discord/resource.ts` already do for their own families.
|
|
10
|
+
*
|
|
11
|
+
* ★ `Unowned(attrs)` ON A COLD READ, PLAIN ATTRIBUTES ON A WARM ONE (S7, S8). None of Alias,
|
|
12
|
+
* Category or Group carries an ownership marker OPNsense itself understands — no tag, no
|
|
13
|
+
* metadata field a stack could stamp — so this is the same "marker-less API" S8 describes, and
|
|
14
|
+
* the same posture `../discord/resource.ts` documents against `Snippet.ts`'s reference read.
|
|
15
|
+
* `adopt(true)` (H5), piped on by each resource file's convenience constructor, is what turns
|
|
16
|
+
* an `Unowned` first read into a silent one-time takeover instead of an `OwnedBySomeoneElse`
|
|
17
|
+
* refusal — exactly what an import script generating `alchemy.run.ts` rows from live reads
|
|
18
|
+
* needs: declare what is live, adopt it, change nothing.
|
|
19
|
+
*
|
|
20
|
+
* ⛔ `reconcile` AND `delete` NEVER CALL AN SDK OPERATION. `reconcile` still OBSERVES first (one
|
|
21
|
+
* GET, via `read`) so the refusal it raises can say whether it would have created or updated —
|
|
22
|
+
* S9's "observe before you write" doctrine, kept for the error message even though there is no
|
|
23
|
+
* write to guard. `delete` observes nothing: it refuses unconditionally, because under this
|
|
24
|
+
* policy a delete never succeeds regardless of whether the object is still live. See policy.ts.
|
|
25
|
+
*/
|
|
26
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
27
|
+
import { isResolved } from 'alchemy/Diff';
|
|
28
|
+
import type { Input } from 'alchemy/Input';
|
|
29
|
+
import type { OpnsenseOpContext } from '@distilled.cloud/opnsense/Protocol';
|
|
30
|
+
import * as Effect from 'effect/Effect';
|
|
31
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
32
|
+
import { CredentialsFromEnv } from './credentials.ts';
|
|
33
|
+
import { refuseWrite } from './policy.ts';
|
|
34
|
+
|
|
35
|
+
/** What every stack must still provide after `opnsenseHandlers` bakes in `CredentialsFromEnv`. */
|
|
36
|
+
export type OpnsenseRequirements = HttpClient.HttpClient;
|
|
37
|
+
|
|
38
|
+
/** Every resource in this family identifies its live object by OPNsense's own assigned uuid. */
|
|
39
|
+
export interface OpnsenseIdentity {
|
|
40
|
+
readonly uuid: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One OPNsense object's read path. `Live` is whatever the generated `get()` response decodes to
|
|
45
|
+
* for one item (an `AliasItem`, `CategoryItem`, `GroupItem`); `E` is left to each resource file
|
|
46
|
+
* to declare — in practice always `OpnsenseOpError`, since every generated operation shares one
|
|
47
|
+
* error channel (protocol.ts).
|
|
48
|
+
*/
|
|
49
|
+
export type OpnsenseSpec<Props extends OpnsenseIdentity, Live, Attributes extends object, E> = {
|
|
50
|
+
readonly resourceType: string;
|
|
51
|
+
/** Absent when `props.uuid` is not a key in the live model's map — see each resource file. */
|
|
52
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, OpnsenseOpContext>;
|
|
53
|
+
/** Also the declaration renderer (S.T. propsFromLive re-export) — see each `*-form.ts`. */
|
|
54
|
+
readonly attributes: (uuid: string, live: Live) => Attributes;
|
|
55
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const opnsenseOperations = <
|
|
59
|
+
Props extends OpnsenseIdentity,
|
|
60
|
+
Live,
|
|
61
|
+
Attributes extends object,
|
|
62
|
+
E,
|
|
63
|
+
>(
|
|
64
|
+
spec: OpnsenseSpec<Props, Live, Attributes, E>,
|
|
65
|
+
) => {
|
|
66
|
+
const read = (props: Props) =>
|
|
67
|
+
spec
|
|
68
|
+
.fetchLive(props)
|
|
69
|
+
.pipe(
|
|
70
|
+
Effect.map((live) => (live === undefined ? undefined : spec.attributes(props.uuid, live))),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
read: ({ 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: a prior apply already adopted this exact uuid — ours.
|
|
79
|
+
if (output !== undefined) return attrs;
|
|
80
|
+
// Cold read (fresh store, or state lost): no ownership marker exists on this vendor.
|
|
81
|
+
return 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
|
+
return yield* refuseWrite(
|
|
98
|
+
spec.resourceType,
|
|
99
|
+
news.uuid,
|
|
100
|
+
live === undefined ? 'create' : 'update',
|
|
101
|
+
);
|
|
102
|
+
}),
|
|
103
|
+
|
|
104
|
+
destroy: (olds: Props) => refuseWrite(spec.resourceType, olds.uuid, 'delete'),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The provider handlers for a spec'd OPNsense object, wired once. `list` answers empty — every
|
|
110
|
+
* `get()` in this family returns the WHOLE model tree with no per-account/per-scope narrowing,
|
|
111
|
+
* so a `list` that enumerated it would sweep the estate's entire alias/category/group set into
|
|
112
|
+
* one plan; adoption stays explicit, one uuid at a time, the same reasoning netbox's and
|
|
113
|
+
* discord's own `list` give for a shared or marker-less object space (S12).
|
|
114
|
+
*
|
|
115
|
+
* `nuke: { skip: true }` — `delete` refuses unconditionally (policy.ts), so `alchemy unsafe
|
|
116
|
+
* nuke` could never do anything here but fail loudly on every object it found; skipping keeps
|
|
117
|
+
* that refusal out of a nuke census entirely, matching `postgres/database.ts` and
|
|
118
|
+
* `github/repository-ruleset.ts`'s own `nuke: { skip: true }` for the same reason.
|
|
119
|
+
*/
|
|
120
|
+
export const opnsenseHandlers = <
|
|
121
|
+
Props extends OpnsenseIdentity,
|
|
122
|
+
Live,
|
|
123
|
+
Attributes extends object,
|
|
124
|
+
E,
|
|
125
|
+
>(
|
|
126
|
+
spec: OpnsenseSpec<Props, Live, Attributes, E>,
|
|
127
|
+
) => {
|
|
128
|
+
const ops = opnsenseOperations(spec);
|
|
129
|
+
// ⚠️ GENERIC OVER ITS OWN ERROR AND REQUIREMENT CHANNELS, NOT PINNED TO `E`/`OpnsenseOpContext`.
|
|
130
|
+
// `read`/`diff` fail with `E` and need `OpnsenseOpContext`; `reconcile` fails with
|
|
131
|
+
// `E | OpnsenseWriteRefused` (it observes, then refuses) and still needs `OpnsenseOpContext`
|
|
132
|
+
// for that observe; `delete` fails with `OpnsenseWriteRefused` alone and needs NOTHING (it
|
|
133
|
+
// never observes) — three different signatures sharing one credentials-providing wrapper.
|
|
134
|
+
const withCredentials = <A, Err, R>(effect: Effect.Effect<A, Err, R>) =>
|
|
135
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
136
|
+
return {
|
|
137
|
+
list: () => Effect.succeed([]),
|
|
138
|
+
nuke: { skip: true },
|
|
139
|
+
read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
|
|
140
|
+
withCredentials(ops.read({ olds, output })),
|
|
141
|
+
diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
142
|
+
withCredentials(ops.diff(news, output)),
|
|
143
|
+
reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
|
|
144
|
+
delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
|
|
145
|
+
};
|
|
146
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How OPNsense's MVC model spells a value on the wire, and how to get it back to something
|
|
3
|
+
* comparable — the `../proxmox/values.ts` idea, scoped to this vendor's own spellings.
|
|
4
|
+
*
|
|
5
|
+
* ★ OPNSENSE HAS EXACTLY TWO WIRE SHAPES THIS FAMILY NEEDS. Every `BooleanField` is the string
|
|
6
|
+
* `"1"` or `"0"` (never a JSON boolean — `AliasItem.enabled: string`, `GroupItem.nogroup?:
|
|
7
|
+
* string`, see `firewall_alias.ts`/`firewall_group.ts`). Every multi-select `OptionField`/
|
|
8
|
+
* `ModelRelationField` is a SINGLE comma-joined string, not a JSON array (`AliasItem.categories`,
|
|
9
|
+
* `GroupItem.members` — both carry the doc comment "wire value is a single comma-joined string,
|
|
10
|
+
* not a JSON array" in the generated SDK). Nothing here is specific to one resource.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ COMMA-LIST ORDER ROUND-TRIP IS NOT ASSUMED STABLE. OPNsense builds these from a PHP
|
|
13
|
+
* associative array (`ModelRelationField`/`OptionField::getNodeData()`), and nothing in the
|
|
14
|
+
* model XML promises the join order survives a save-then-read unchanged. Sorting and
|
|
15
|
+
* deduplicating both sides before comparing is what makes `matches` (alias-form.ts,
|
|
16
|
+
* group-form.ts) actually settle instead of reporting drift on a re-ordered list that changed
|
|
17
|
+
* nothing — the same reasoning `../proxmox/user-wire.ts`'s `groupSet` documents for PVE's
|
|
18
|
+
* `groups` field.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** OPNsense's own boolean spelling. Absent takes the caller's stated default — OPNsense's own
|
|
22
|
+
* default for a `BooleanField` is form-specific, so there is no house-wide fallback here. */
|
|
23
|
+
export const bool01 = (value: string | undefined, fallback: boolean): boolean =>
|
|
24
|
+
value === undefined ? fallback : value === '1';
|
|
25
|
+
|
|
26
|
+
/** The write direction, for symmetry — unused today (this family never writes), kept so a form
|
|
27
|
+
* helper never has to guess the spelling if lifting the read-only policy adds one later. */
|
|
28
|
+
export const toBool01 = (value: boolean): string => (value ? '1' : '0');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A comma-joined multi-select column, normalised to a sorted, deduplicated set — see the module
|
|
32
|
+
* header's ⚠️ for why normalising (not just splitting) is what makes a diff settle.
|
|
33
|
+
*/
|
|
34
|
+
export const csvSet = (value: string | undefined): readonly string[] => {
|
|
35
|
+
if (value === undefined || value.trim() === '') return [];
|
|
36
|
+
const items = value
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((item) => item.trim())
|
|
39
|
+
.filter((item) => item !== '');
|
|
40
|
+
return [...new Set(items)].sort();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A declared array, normalised the SAME way `csvSet` normalises the wire, so `matches` compares
|
|
44
|
+
* two sets built by one function rather than trusting the caller's array to already be sorted. */
|
|
45
|
+
export const csvSetOf = (value: readonly string[] | undefined): readonly string[] =>
|
|
46
|
+
csvSet(value === undefined ? undefined : value.join(','));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* OPNsense's numeric-as-string fields (`"60"`, `"0"`), tolerant of an already-numeric input the
|
|
50
|
+
* same way `../proxmox/user-wire.ts`'s `int` is tolerant of PVE's mixed number/string releases.
|
|
51
|
+
*/
|
|
52
|
+
export const numField = (value: string | undefined, fallback: number): number => {
|
|
53
|
+
if (value === undefined) return fallback;
|
|
54
|
+
const parsed = Number(value);
|
|
55
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
56
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PROOF: no SDK write operation — `addItem`, `setItem`, `delItem`, `toggleItem`, `set`, or
|
|
3
|
+
* OPNsense's own POST-shaped search reads — is reachable from ANY handler of ANY resource in this
|
|
4
|
+
* family, for ANY input. `getOnlyOrFail` (fake-opnsense.ts) fails the test itself the instant a
|
|
5
|
+
* non-GET request is made, so this does not rely on inspecting what each handler happens to call
|
|
6
|
+
* — it fails loudly the moment anything but a `get()` reaches the wire.
|
|
7
|
+
*
|
|
8
|
+
* `alias.test.ts`, `category.test.ts` and `group.test.ts` already run every scenario through this
|
|
9
|
+
* same fake and would catch a write there too; this file exists so ONE place in this family
|
|
10
|
+
* states the guarantee plainly, exercising the public `handlers` object each resource file wires
|
|
11
|
+
* into its `Provider` (not just the lower-level `opnsenseOperations`), across every lifecycle
|
|
12
|
+
* method `Provider.of` calls and against both an absent and a drifted live object.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ `handlers.*` BAKES IN `CredentialsFromEnv` (resource.ts's `withCredentials`), unlike
|
|
15
|
+
* `opnsenseOperations(spec)` — see fake-opnsense.ts's warning on `fakeOpnsenseLayer`. Providing
|
|
16
|
+
* `fakeOpnsenseLayer`'s own `Credentials` layer from here does nothing: the inner `Effect.provide`
|
|
17
|
+
* already closed over that tag, so it wins. Left unaddressed, `CredentialsFromEnv` reads real
|
|
18
|
+
* `OPNSENSE_*` env vars via `EffectConfig`, finds none in CI, and `Effect.orDie`s before any
|
|
19
|
+
* request is built — `fake.seen` stays empty and `.every(...)` on an empty array is vacuously
|
|
20
|
+
* `true`. `fakeCredentialsConfigProvider` below fixes this WITHOUT touching real `process.env`
|
|
21
|
+
* (which a parallel test file could race on): it overrides the `effect/ConfigProvider` the
|
|
22
|
+
* `EffectConfig.String("OPNSENSE_*")` reads inside `CredentialsFromEnv` resolve against, which is
|
|
23
|
+
* a different tag CredentialsFromEnv never shadows — proved against this exact fake/real pattern
|
|
24
|
+
* before landing here. Every "only GETs" test below also asserts `fake.seen` has exactly the
|
|
25
|
+
* one GET `fetchLive` issues, so a run that silently makes zero requests (or a future
|
|
26
|
+
* double-GET regression) fails loudly instead of passing on an under-checked `.every()`.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ THIS FIX HAS ONE INVARIANT: `CredentialsFromEnv` (distilled-opnsense's `credentials.ts`) must
|
|
29
|
+
* keep resolving `OPNSENSE_*` through the AMBIENT `ConfigProvider` rather than one it pins
|
|
30
|
+
* itself. If it ever starts providing its own `ConfigProvider` — the way it already closes over
|
|
31
|
+
* the `Credentials` tag via `withCredentials` — this override would be shadowed the same way
|
|
32
|
+
* `fakeOpnsenseLayer`'s `Credentials` layer is today, and the vacuous-pass bug this file exists
|
|
33
|
+
* to prevent would return silently.
|
|
34
|
+
*/
|
|
35
|
+
import { describe, expect, test } from 'bun:test';
|
|
36
|
+
import * as Effect from 'effect/Effect';
|
|
37
|
+
import * as Layer from 'effect/Layer';
|
|
38
|
+
import * as ConfigProvider from 'effect/ConfigProvider';
|
|
39
|
+
import { handlers as aliasHandlers, spec as aliasSpec } from './alias.ts';
|
|
40
|
+
import { handlers as categoryHandlers, spec as categorySpec } from './category.ts';
|
|
41
|
+
import {
|
|
42
|
+
FAKE_BASE,
|
|
43
|
+
FAKE_KEY,
|
|
44
|
+
FAKE_SECRET,
|
|
45
|
+
fakeOpnsenseLayer,
|
|
46
|
+
getOnlyOrFail,
|
|
47
|
+
} from './fake-opnsense.ts';
|
|
48
|
+
import { handlers as groupHandlers, spec as groupSpec } from './group.ts';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Overrides the `ConfigProvider` `CredentialsFromEnv`'s `EffectConfig.String("OPNSENSE_*")`
|
|
52
|
+
* resolves against, so `handlers.*` (which bakes in real env-var credentials resolution) gets
|
|
53
|
+
* the same placeholder values `fakeOpnsenseLayer`'s default `Credentials` layer uses — without
|
|
54
|
+
* ever reading or writing real `process.env`.
|
|
55
|
+
*/
|
|
56
|
+
const fakeCredentialsConfigProvider = ConfigProvider.layer(
|
|
57
|
+
ConfigProvider.fromUnknown({
|
|
58
|
+
OPNSENSE_API_KEY: FAKE_KEY,
|
|
59
|
+
OPNSENSE_API_SECRET: FAKE_SECRET,
|
|
60
|
+
OPNSENSE_URL: FAKE_BASE,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/** `fakeOpnsenseLayer` plus the env override `handlers.*` needs — see the file header. */
|
|
65
|
+
const fakeHandlersLayer = (fetchFn: typeof globalThis.fetch) =>
|
|
66
|
+
Layer.mergeAll(fakeOpnsenseLayer(fetchFn), fakeCredentialsConfigProvider);
|
|
67
|
+
|
|
68
|
+
const families = [
|
|
69
|
+
{
|
|
70
|
+
empty: Response.json({ alias: {} }),
|
|
71
|
+
handlers: aliasHandlers,
|
|
72
|
+
live: Response.json({
|
|
73
|
+
alias: { aliases: { alias: { u: { content: 'x', enabled: '1', name: 'n', type: 'host' } } } },
|
|
74
|
+
}),
|
|
75
|
+
props: { content: 'x', name: 'n', type: 'host', uuid: 'u' },
|
|
76
|
+
resourceType: aliasSpec.resourceType,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
empty: Response.json({ category: {} }),
|
|
80
|
+
handlers: categoryHandlers,
|
|
81
|
+
live: Response.json({ category: { categories: { category: { u: { name: 'n' } } } } }),
|
|
82
|
+
props: { name: 'n', uuid: 'u' },
|
|
83
|
+
resourceType: categorySpec.resourceType,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
empty: Response.json({ group: {} }),
|
|
87
|
+
handlers: groupHandlers,
|
|
88
|
+
live: Response.json({
|
|
89
|
+
group: { ifgroupentry: { u: { ifname: 'n', members: '', sequence: '0' } } },
|
|
90
|
+
}),
|
|
91
|
+
props: { ifname: 'n', members: [], uuid: 'u' },
|
|
92
|
+
resourceType: groupSpec.resourceType,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
describe.each(families)(
|
|
97
|
+
'$resourceType — write-refusal proof',
|
|
98
|
+
({ empty, handlers, live, props }) => {
|
|
99
|
+
test('list() is empty and issues no request', async () => {
|
|
100
|
+
const fake = getOnlyOrFail(() => {
|
|
101
|
+
throw new Error('list() must never call the wire at all');
|
|
102
|
+
});
|
|
103
|
+
const result = await Effect.runPromise(
|
|
104
|
+
Effect.provide(handlers.list(), fakeOpnsenseLayer(fake.fetch)),
|
|
105
|
+
);
|
|
106
|
+
expect(result).toEqual([]);
|
|
107
|
+
expect(fake.seen).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('reconcile against a LIVE object still only GETs, then refuses', async () => {
|
|
111
|
+
const fake = getOnlyOrFail(() => live);
|
|
112
|
+
const outcome = await Effect.runPromiseExit(
|
|
113
|
+
Effect.provide(handlers.reconcile({ news: props as never }), fakeHandlersLayer(fake.fetch)),
|
|
114
|
+
);
|
|
115
|
+
expect(outcome._tag).toBe('Failure');
|
|
116
|
+
// ⚠️ Not sufficient alone — `.every()` on an empty array is vacuously true. See the file
|
|
117
|
+
// header: without `fakeHandlersLayer`'s ConfigProvider override this assertion never ran
|
|
118
|
+
// a single request, because `CredentialsFromEnv` died first. Exactly 1, not just > 0:
|
|
119
|
+
// `fetchLive` (alias.ts/category.ts/group.ts) issues one `get()` — matches
|
|
120
|
+
// `alias.test.ts`'s own `toHaveLength(1)` and also catches a future double-GET regression.
|
|
121
|
+
expect(fake.seen).toHaveLength(1);
|
|
122
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('reconcile against an ABSENT object still only GETs, then refuses', async () => {
|
|
126
|
+
const fake = getOnlyOrFail(() => empty);
|
|
127
|
+
const outcome = await Effect.runPromiseExit(
|
|
128
|
+
Effect.provide(handlers.reconcile({ news: props as never }), fakeHandlersLayer(fake.fetch)),
|
|
129
|
+
);
|
|
130
|
+
expect(outcome._tag).toBe('Failure');
|
|
131
|
+
expect(fake.seen).toHaveLength(1);
|
|
132
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('delete makes ZERO requests and refuses', async () => {
|
|
136
|
+
const fake = getOnlyOrFail(() => live);
|
|
137
|
+
const outcome = await Effect.runPromiseExit(
|
|
138
|
+
Effect.provide(handlers.delete({ olds: props as never }), fakeOpnsenseLayer(fake.fetch)),
|
|
139
|
+
);
|
|
140
|
+
expect(outcome._tag).toBe('Failure');
|
|
141
|
+
expect(fake.seen).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('nuke is skipped, so account-wide teardown never even lists this type', () => {
|
|
145
|
+
expect(handlers.nuke).toEqual({ skip: true });
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
);
|
package/src/proxmox/acl.ts
CHANGED
|
@@ -99,15 +99,21 @@ export const ProxmoxAcl = Resource<ProxmoxAcl>('Proxmox.Acl');
|
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
101
|
* ⛔ `readRole: 'provision'`, WITHOUT WHICH EVERY GRANT READS BACK AS ABSENT — see the header's
|
|
102
|
-
* ⛔ on filtering.
|
|
102
|
+
* ⛔ on filtering.
|
|
103
103
|
* ★ `readOrUnreadable` (unreadable-read.ts) runs first: a REFUSED mint of `provision` — the
|
|
104
|
-
* "cries wolf" bug — comes back `UNREADABLE` instead of the
|
|
105
|
-
*
|
|
104
|
+
* "cries wolf" bug — comes back `UNREADABLE` instead of the `undefined` a genuine, SUCCESSFUL
|
|
105
|
+
* read (the row simply not being in the list) produces, so `diff` below can tell them apart.
|
|
106
|
+
* ⛔ NO `orElseSucceed` HERE ANY MORE — found on 2026-09-24, the SAME bug class as the credential
|
|
107
|
+
* denial: it used to fold EVERY OTHER failure (a transport error, `PveClusterExhausted`, a
|
|
108
|
+
* decode failure) into `undefined` too, which then read exactly like a genuine absence and
|
|
109
|
+
* forced the same false `update`. Only a read that actually SUCCEEDS (and the grant is not in
|
|
110
|
+
* the list it returns) may mean absent now; every other failure propagates and fails the whole
|
|
111
|
+
* plan loudly — the engine's own contract for a failed `read`/`diff` (verify.ts's `readWithState`
|
|
112
|
+
* already expects and reports it as `'failed'`).
|
|
106
113
|
*/
|
|
107
114
|
const readAttributes = (props: AclProps) =>
|
|
108
115
|
readOrUnreadable(runPve(props.target, 'provision', false, access.listAccessAcl({}))).pipe(
|
|
109
116
|
Effect.map((rows) => (rows === UNREADABLE ? UNREADABLE : attributesOf(rows, props))),
|
|
110
|
-
Effect.orElseSucceed(() => undefined),
|
|
111
117
|
);
|
|
112
118
|
|
|
113
119
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
package/src/proxmox/api-token.ts
CHANGED
|
@@ -141,12 +141,22 @@ export const ProxmoxApiToken = Resource<ProxmoxApiToken>('Proxmox.ApiToken', {
|
|
|
141
141
|
defaultRemovalPolicy: 'retain',
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
/**
|
|
145
|
+
* ⛔ `readRole: 'provision'` — see the header's ⚠️ on privileges.
|
|
146
|
+
* ⛔ `{ userid, tokenid }`, NEVER THE WHOLE `props` — group.ts's readGroup has the full measured
|
|
147
|
+
* mechanism (kit 0.31.1's regression): an extra key beyond the schema's own path labels gets
|
|
148
|
+
* encoded onto this GET as a body, which a stricter fetch client refuses outright.
|
|
149
|
+
* ⛔ NO `orElseSucceed` — a MISSING token's absence signal is a SUCCESSFUL read with `expire`/
|
|
150
|
+
* `privsep` both absent (`attributesOf`/api-token-form.ts, MEASURED pre-migration), unlike
|
|
151
|
+
* user.ts/group.ts's missing-object 500 — so this family never needed their folding split. A
|
|
152
|
+
* THROWN failure here is genuinely unexpected and must propagate and fail the plan loudly.
|
|
153
|
+
*/
|
|
154
|
+
const readToken = (props: ApiTokenProps) => {
|
|
155
|
+
const label = { tokenid: props.tokenid, userid: props.userid };
|
|
156
|
+
return readOrUnreadable(
|
|
157
|
+
runPve(props.target, 'provision', false, access.getAccessUserToken(label)),
|
|
158
|
+
).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
|
|
159
|
+
};
|
|
150
160
|
|
|
151
161
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
152
162
|
const dropUnreadable = (live: ApiTokenAttributes | Unreadable | undefined) =>
|
|
@@ -193,12 +203,10 @@ export const ProxmoxApiTokenProvider = () =>
|
|
|
193
203
|
}),
|
|
194
204
|
reconcile: Effect.fn(function* ({ news }) {
|
|
195
205
|
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
196
|
-
// read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// second is denied) and not silently wrong — the operator still sees a die() and goes
|
|
201
|
-
// looking — but named here rather than left for the next reader to assume away.
|
|
206
|
+
// read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race, not
|
|
207
|
+
// the routine case `diff` handles. Any OTHER read failure now propagates on its own
|
|
208
|
+
// (readToken no longer folds it), so the die() message below is reached only on a
|
|
209
|
+
// genuinely absent token, never on a transient failure wearing that message.
|
|
202
210
|
const before = dropUnreadable(yield* readToken(news));
|
|
203
211
|
if (before === undefined) {
|
|
204
212
|
return yield* Effect.die(
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Proxmox.Group`'s wire shape: PVE's create/update forms
|
|
3
|
-
*
|
|
4
|
-
* the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
|
|
2
|
+
* `Proxmox.Group`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
|
|
3
|
+
* and the read itself. Split out of group.ts (2026-09-24) to keep that file under the 250-line
|
|
4
|
+
* cap — the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
|
|
5
5
|
*/
|
|
6
|
-
import
|
|
6
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { runPve } from './distilled-pve.ts';
|
|
7
9
|
import type { GroupAttributes, GroupProps } from './group.ts';
|
|
10
|
+
import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
|
|
8
11
|
import { csv, text } from './values.ts';
|
|
9
12
|
|
|
10
13
|
export const GROUP_CREATE = 'pve:POST /access/groups';
|
|
@@ -41,10 +44,7 @@ export const updateForm = (props: GroupProps): access.PutAccessGroupRequest => (
|
|
|
41
44
|
groupid: props.groupid,
|
|
42
45
|
});
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
live: access.GetAccessGroupResponse,
|
|
46
|
-
props: GroupProps,
|
|
47
|
-
): GroupAttributes => ({
|
|
47
|
+
const attributesOf = (live: access.GetAccessGroupResponse, props: GroupProps): GroupAttributes => ({
|
|
48
48
|
/**
|
|
49
49
|
* ⚠️ ABSENT IS EMPTY, NOT MISSING — `read_group` sets `comment` only `if defined(…)`. distilled
|
|
50
50
|
* types it `comment?: string`, so `text()`'s `''` fallback meets the same normalisation.
|
|
@@ -61,3 +61,57 @@ export const attributesOf = (
|
|
|
61
61
|
*/
|
|
62
62
|
export const matches = (attributes: GroupAttributes, props: GroupProps) =>
|
|
63
63
|
attributes.comment === storedComment(props.comment);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ★ Default `read` role — nothing widened for this family, see group.ts's header privileges note.
|
|
67
|
+
* ⛔ `{ groupid: props.groupid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression
|
|
68
|
+
* in kit 0.31.1. `GetAccessGroupRequest`'s schema declares only `groupid` (a path label); any
|
|
69
|
+
* OTHER key on the object passed to `access.getAccessGroup` — `target`, `comment`, every field
|
|
70
|
+
* this family's own props carry — is treated by distilled's `buildRequest` as an "unknown key"
|
|
71
|
+
* and JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
|
|
72
|
+
* fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
|
|
73
|
+
* (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
|
|
74
|
+
* which `runPveWith` retries across every member and exhausts identically —
|
|
75
|
+
* `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
|
|
76
|
+
* false `update` on every group. `role.ts`'s list call and acl.ts's `listAccessAcl({})` were
|
|
77
|
+
* never at risk: both are always called with a literal `{}`.
|
|
78
|
+
* ⛔ `live.members === undefined` IS DEFENSIVE, NOT THE ABSENCE SIGNAL — CORRECTED 2026-09-24.
|
|
79
|
+
* This file previously claimed a missing group answers 200 with `{"data":null}`, unwrapped to
|
|
80
|
+
* `{}`. MEASURED against the live cluster: `GET /access/groups/{missing}` answers a genuine 500
|
|
81
|
+
* ("group '…' does not exist"), the SAME shape as user.ts's own measured 500 — there is no
|
|
82
|
+
* success-path absence signal here at all. The check stays as insurance against a future PVE
|
|
83
|
+
* release that does answer `{}`, but this function is what actually carries the "not found"
|
|
84
|
+
* case, as a thrown error — `readGroup` below is the one that folds it.
|
|
85
|
+
*/
|
|
86
|
+
export const readGroupOrFail = (props: GroupProps) =>
|
|
87
|
+
readOrUnreadable(
|
|
88
|
+
runPve(props.target, 'read', false, access.getAccessGroup({ groupid: props.groupid })),
|
|
89
|
+
).pipe(
|
|
90
|
+
Effect.map((live) =>
|
|
91
|
+
live === UNREADABLE
|
|
92
|
+
? UNREADABLE
|
|
93
|
+
: live.members === undefined
|
|
94
|
+
? undefined
|
|
95
|
+
: attributesOf(live, props),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT.
|
|
101
|
+
* PVE's OWN missing-group answer is a thrown 500 (the ⛔ above), so `reconcile`'s create branch
|
|
102
|
+
* NEEDS "absent" from a failure to ever run at all — without this fold, a brand-new group could
|
|
103
|
+
* never be created, because its very-first read would always fail loudly. Safe here because a
|
|
104
|
+
* wrongful fold costs at most a redundant `createAccessGroup` POST, which PVE refuses loudly
|
|
105
|
+
* ("group already exists") rather than silently corrupting anything.
|
|
106
|
+
* ⛔ `group.ts`'s `diff` does **NOT** use this — found 2026-09-24, the SAME bug class as the
|
|
107
|
+
* credential denial fix: folding a TRANSIENT failure (a `PveClusterExhausted`, the encoding bug
|
|
108
|
+
* above) into "absent" at PLAN TIME is exactly what forced a false `update` with nothing
|
|
109
|
+
* compared. `diff` calls `readGroupOrFail` directly instead, so a genuine failure there
|
|
110
|
+
* propagates and fails the whole plan loudly — never silently misreports one row.
|
|
111
|
+
*/
|
|
112
|
+
export const readGroup = (props: GroupProps) =>
|
|
113
|
+
readGroupOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
|
|
114
|
+
|
|
115
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
116
|
+
export const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
|
|
117
|
+
live === UNREADABLE ? undefined : live;
|
package/src/proxmox/group.ts
CHANGED
|
@@ -76,19 +76,16 @@ import { guardWrite } from './distilled-guard.ts';
|
|
|
76
76
|
import {
|
|
77
77
|
GROUP_CREATE,
|
|
78
78
|
GROUP_UPDATE,
|
|
79
|
-
attributesOf,
|
|
80
79
|
createForm,
|
|
80
|
+
dropUnreadable,
|
|
81
81
|
matches,
|
|
82
|
+
readGroup,
|
|
83
|
+
readGroupOrFail,
|
|
82
84
|
updateForm,
|
|
83
85
|
} from './group-wire.ts';
|
|
84
86
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
85
87
|
import { runPve } from './distilled-pve.ts';
|
|
86
|
-
import {
|
|
87
|
-
UNREADABLE,
|
|
88
|
-
type Unreadable,
|
|
89
|
-
readOrUnreadable,
|
|
90
|
-
unreadableWarning,
|
|
91
|
-
} from './unreadable-read.ts';
|
|
88
|
+
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
92
89
|
|
|
93
90
|
export interface GroupProps extends WithTarget {
|
|
94
91
|
/**
|
|
@@ -127,31 +124,6 @@ export const ProxmoxGroup = Resource<ProxmoxGroup>('Proxmox.Group', {
|
|
|
127
124
|
defaultRemovalPolicy: 'retain',
|
|
128
125
|
});
|
|
129
126
|
|
|
130
|
-
/**
|
|
131
|
-
* ★ Default `read` role — nothing widened for this family, see the header's privileges note.
|
|
132
|
-
* ⛔ `live.members === undefined` MEANS ABSENT, NOT AN EMPTY GROUP. `@distilled.cloud/core`'s
|
|
133
|
-
* REST protocol unwraps PVE's `{"data": null}` (a genuinely missing object — MEASURED in
|
|
134
|
-
* `protocol.ts`: "a Unit-output endpoint... `data` is null/absent") to `{}` BEFORE the
|
|
135
|
-
* schema-driven decode, not to a decode failure. `members` is the one REQUIRED field on this
|
|
136
|
-
* response, so its absence is the only signal that the group was never there — the same trap
|
|
137
|
-
* api-token-form.ts's `attributes` guards with its own required-field check.
|
|
138
|
-
*/
|
|
139
|
-
const readGroup = (props: GroupProps) =>
|
|
140
|
-
readOrUnreadable(runPve(props.target, 'read', false, access.getAccessGroup(props))).pipe(
|
|
141
|
-
Effect.map((live) =>
|
|
142
|
-
live === UNREADABLE
|
|
143
|
-
? UNREADABLE
|
|
144
|
-
: live.members === undefined
|
|
145
|
-
? undefined
|
|
146
|
-
: attributesOf(live, props),
|
|
147
|
-
),
|
|
148
|
-
Effect.orElseSucceed(() => undefined),
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
152
|
-
const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
|
|
153
|
-
live === UNREADABLE ? undefined : live;
|
|
154
|
-
|
|
155
127
|
export const ProxmoxGroupProvider = () =>
|
|
156
128
|
Provider.effect(
|
|
157
129
|
ProxmoxGroup,
|
|
@@ -162,15 +134,31 @@ export const ProxmoxGroupProvider = () =>
|
|
|
162
134
|
* cluster, `admins` (grants `Administrator` on `/`) included. Adoption stays explicit.
|
|
163
135
|
*/
|
|
164
136
|
list: () => Effect.succeed([]),
|
|
165
|
-
|
|
166
|
-
|
|
137
|
+
// ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — MEASURED 2026-09-24, an adversarial review
|
|
138
|
+
// of this very fix caught the gap. The engine calls this ONE hook from FOUR places
|
|
139
|
+
// (alchemy/src/{Plan,Apply,Drift}.ts), and only `output` tells them apart: Plan.ts's
|
|
140
|
+
// cold-start adoption probe and interrupted-create recovery, and Apply.ts's
|
|
141
|
+
// delete-recovery, all pass `output: undefined` — nothing is confirmed to exist yet, so
|
|
142
|
+
// `readGroup`'s fold is still needed there or a brand-new group could never be adopted
|
|
143
|
+
// or created. `Drift.ts` (`alchemy drift`/`sync`/`deploy --detect-drift`) is different:
|
|
144
|
+
// it calls this on an ALREADY-CONFIRMED row (`output: old.attr`, defined), exactly
|
|
145
|
+
// `diff`'s own situation below — a transient failure folded to `undefined` there is
|
|
146
|
+
// reported as `{action: 'missing'}` with NO error at all, the cries-wolf bug again, one
|
|
147
|
+
// command over. `readGroupOrFail` on that branch propagates it instead.
|
|
148
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
149
|
+
return dropUnreadable(
|
|
150
|
+
yield* output === undefined ? readGroup(olds) : readGroupOrFail(olds),
|
|
151
|
+
);
|
|
167
152
|
}),
|
|
168
153
|
diff: Effect.fn(function* ({ news, output }) {
|
|
169
154
|
if (!isResolved(news)) return undefined;
|
|
170
155
|
yield* guardWrite(GROUP_CREATE, createForm(news), output === undefined);
|
|
171
156
|
yield* guardWrite(GROUP_UPDATE, updateForm(news), false);
|
|
172
157
|
if (output === undefined) return undefined;
|
|
173
|
-
|
|
158
|
+
// ⚠️ `readGroupOrFail`, NOT `readGroup` — see that function's own ⛔. A genuine
|
|
159
|
+
// TRANSIENT failure here propagates and fails the whole plan loudly instead of
|
|
160
|
+
// folding to "absent" and forcing a false update, the cries-wolf class of bug.
|
|
161
|
+
const live = yield* readGroupOrFail(news);
|
|
174
162
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
175
163
|
// `update` on a group that was plainly there — see unreadable-read.ts.
|
|
176
164
|
if (live === UNREADABLE) {
|