@homeflare/alchemy 0.27.3 → 0.27.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloudflare/mesh-node-token.d.ts +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +4 -4
- package/dist/forgejo/org-actions-secrets.d.ts +4 -4
- package/dist/forgejo/org-label.d.ts +4 -4
- package/dist/forgejo/org-team.d.ts +4 -4
- package/dist/forgejo/repo-webhook.d.ts +4 -4
- package/dist/forgejo/repository.d.ts +4 -4
- package/dist/forgejo/team-member.d.ts +4 -4
- package/dist/netbox/fake-netbox.d.ts +43 -0
- package/dist/netbox/fake-netbox.d.ts.map +1 -0
- package/dist/netbox/index.d.ts +1 -2
- package/dist/netbox/index.d.ts.map +1 -1
- package/dist/netbox/index.js +71 -119
- package/dist/netbox/index.js.map +5 -6
- package/dist/netbox/prefix.d.ts +33 -2
- package/dist/netbox/prefix.d.ts.map +1 -1
- package/dist/netbox/resource.d.ts +73 -48
- package/dist/netbox/resource.d.ts.map +1 -1
- package/docs/credentials.md +10 -0
- package/docs/peers.md +11 -0
- package/docs/upstream-conformance.md +17 -2
- package/package.json +2 -1
- package/src/netbox/fake-netbox.ts +56 -0
- package/src/netbox/index.ts +8 -2
- package/src/netbox/prefix.test.ts +223 -0
- package/src/netbox/prefix.ts +68 -32
- package/src/netbox/resource.test.ts +67 -0
- package/src/netbox/resource.ts +133 -112
- package/dist/netbox/client.d.ts +0 -52
- package/dist/netbox/client.d.ts.map +0 -1
- package/src/netbox/client.test.ts +0 -67
- package/src/netbox/client.ts +0 -146
package/src/netbox/prefix.ts
CHANGED
|
@@ -28,13 +28,22 @@
|
|
|
28
28
|
* IP assignments; the record of why it existed survives only in the change log.
|
|
29
29
|
*
|
|
30
30
|
* ⛔ NO CREDENTIAL IS A PROP. `NETBOX_URL` and `NETBOX_TOKEN` are read from the environment at
|
|
31
|
-
* call time
|
|
31
|
+
* call time, through `@distilled.cloud/netbox`'s `CredentialsFromEnv` (resource.ts) — the house
|
|
32
|
+
* convention, and the only shape that keeps a token out of Alchemy state. Unchanged by this
|
|
33
|
+
* migration off `client.ts`'s hand-rolled `HttpClient` calls onto distilled's typed operations.
|
|
32
34
|
*/
|
|
33
35
|
import { Resource } from 'alchemy';
|
|
34
36
|
import * as Provider from 'alchemy/Provider';
|
|
37
|
+
import * as ipam from '@distilled.cloud/netbox/ipam';
|
|
35
38
|
import * as Effect from 'effect/Effect';
|
|
36
39
|
import { prefixBody, prefixMatches } from './prefix-form.ts';
|
|
37
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
LOCATE_PAGE,
|
|
42
|
+
type NetboxRequirements,
|
|
43
|
+
type NetboxSpec,
|
|
44
|
+
locateOne,
|
|
45
|
+
netboxHandlers,
|
|
46
|
+
} from './resource.ts';
|
|
38
47
|
import { choice, fk, text } from './values.ts';
|
|
39
48
|
|
|
40
49
|
/**
|
|
@@ -92,45 +101,72 @@ export const NetboxPrefix = Resource<NetboxPrefix>('Netbox.Prefix', {
|
|
|
92
101
|
defaultRemovalPolicy: 'retain',
|
|
93
102
|
});
|
|
94
103
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
104
|
+
const attributesOf = (live: ipam.Prefix, props: PrefixProps): PrefixAttributes => ({
|
|
105
|
+
comments: text(live.comments),
|
|
106
|
+
description: text(live.description),
|
|
107
|
+
isPool: live.is_pool === true,
|
|
108
|
+
markUtilized: live.mark_utilized === true,
|
|
109
|
+
prefix: live.prefix || props.prefix,
|
|
110
|
+
prefixId: live.id,
|
|
111
|
+
status: choice(live.status),
|
|
112
|
+
tenant: fk(live.tenant),
|
|
113
|
+
vlan: fk(live.vlan),
|
|
114
|
+
vrf: fk(live.vrf),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see
|
|
119
|
+
* `fake-netbox.ts`/`prefix.test.ts`, the same seam `../forgejo/repository.ts` uses.
|
|
120
|
+
*/
|
|
121
|
+
export const spec: NetboxSpec<
|
|
122
|
+
PrefixProps,
|
|
123
|
+
ipam.Prefix,
|
|
124
|
+
PrefixAttributes,
|
|
125
|
+
| ipam.CreateIpamPrefixError
|
|
126
|
+
| ipam.ListIpamPrefixesError
|
|
127
|
+
| ipam.UpdateIpamPrefixesPartialError
|
|
128
|
+
| ipam.IpamPrefixesDestroyError
|
|
129
|
+
> = {
|
|
130
|
+
attributes: attributesOf,
|
|
131
|
+
create: (_props, body) =>
|
|
132
|
+
ipam.createIpamPrefix({ body: body as unknown as ipam.WritablePrefixRequest }),
|
|
113
133
|
createBody: prefixBody,
|
|
134
|
+
destroy: (_props, live) => ipam.ipamPrefixesDestroy({ id: live.id }),
|
|
114
135
|
describe: (props) => `ipam/prefixes ${props.prefix}`,
|
|
115
136
|
endpoint: {
|
|
116
137
|
create: 'netbox:POST /api/ipam/prefixes/',
|
|
117
138
|
update: 'netbox:PATCH /api/ipam/prefixes/{id}/',
|
|
118
139
|
},
|
|
119
140
|
/**
|
|
120
|
-
* 🔴 THE VRF IS **NOT** IN THIS FILTER, AND THE REASON IS A GUESS THAT WAS CAUGHT.
|
|
121
|
-
* `vrf_id=null` for the global table — NetBox does define
|
|
122
|
-
* and does honour it on filters that opt in. ⛔ MEASURED
|
|
123
|
-
* v4.7.0: `PrefixFilterSet.vrf_id` is a plain
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
141
|
+
* 🔴 THE VRF IS **NOT** IN THIS SERVER-SIDE FILTER, AND THE REASON IS A GUESS THAT WAS CAUGHT.
|
|
142
|
+
* An earlier version filtered `vrf_id=null` for the global table — NetBox does define
|
|
143
|
+
* `FILTERS_NULL_CHOICE_VALUE = 'null'` and does honour it on filters that opt in. ⛔ MEASURED
|
|
144
|
+
* IN THE VENDOR'S OWN SOURCE at v4.7.0: `PrefixFilterSet.vrf_id` is a plain
|
|
145
|
+
* `django_filters.ModelMultipleChoiceFilter` with NO `null_value`, so `'null'` is validated
|
|
146
|
+
* against the VRF queryset, fails, and NetBox answers 400 under strict filtering. The
|
|
147
|
+
* sentinel is real but it is opt-in per filter, and this one did not opt in.
|
|
148
|
+
* ★ SO THE DISCRIMINATOR STAYS IN THIS PROCESS, VIA `locateOne`'s `identifies` — one extra page
|
|
149
|
+
* of candidates, no vendor semantics guessed. `listIpamPrefixes` never declares `NotFound`
|
|
150
|
+
* (a list call 200s with an empty page, it does not 404), so there is no status to fold here.
|
|
128
151
|
*/
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
fetchLive: (props) =>
|
|
153
|
+
locateOne(
|
|
154
|
+
ipam.listIpamPrefixes({ limit: LOCATE_PAGE, prefix: [props.prefix] }),
|
|
155
|
+
`ipam/prefixes ${props.prefix}`,
|
|
156
|
+
(row) => fk(row.vrf) === props.vrf,
|
|
157
|
+
),
|
|
131
158
|
matches: prefixMatches,
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
update: {
|
|
160
|
+
body: prefixBody,
|
|
161
|
+
call: (_props, live, body) =>
|
|
162
|
+
ipam.updateIpamPrefixesPartial({
|
|
163
|
+
...(body as unknown as Omit<ipam.UpdateIpamPrefixesPartialRequest, 'id'>),
|
|
164
|
+
id: live.id,
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const handlers = netboxHandlers(spec);
|
|
134
170
|
|
|
135
171
|
export const NetboxPrefixProvider = () =>
|
|
136
172
|
Provider.effect(NetboxPrefix, Effect.succeed(NetboxPrefix.Provider.of(handlers)));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `soleMatch` and `locateOne` — identity resolution, and the two ways it must refuse. Pure/Effect
|
|
3
|
+
* only (no HTTP): `locateOne` takes an already-built list `Effect`, so these run against
|
|
4
|
+
* `Effect.succeed` fixtures rather than a fake server — `prefix.test.ts` exercises the real
|
|
5
|
+
* distilled wire path this feeds from.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ THE FAILURE THIS GUARDS IS SILENT AND SELF-RENEWING. If an ambiguous match took the first
|
|
8
|
+
* row, adopt would bind to it, the next plan would bind to the other, and every plan after that
|
|
9
|
+
* would report drift that is not there — while "fixing" it PATCHed one object with the other's
|
|
10
|
+
* declaration. Failing loudly is the only outcome an operator can act on. Ported verbatim from
|
|
11
|
+
* the pre-migration `client.test.ts`, which tested the same `soleMatch` before it moved here.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import { LOCATE_PAGE, locateOne, soleMatch } from './resource.ts';
|
|
16
|
+
|
|
17
|
+
describe('soleMatch', () => {
|
|
18
|
+
test('no candidate is absent, not an error — that is what plans a create', () => {
|
|
19
|
+
expect(soleMatch([], 'ipam/prefixes 10.0.0.0/24')).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('exactly one candidate is the object', () => {
|
|
23
|
+
expect(soleMatch([{ id: 7 }], 'ipam/prefixes 10.0.0.0/24')).toEqual({ id: 7 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** ⛔ Two rows means the identity rule is wrong, and the message says which object it was. */
|
|
27
|
+
test('more than one candidate throws and names the object', () => {
|
|
28
|
+
expect(() => soleMatch([{ id: 7 }, { id: 8 }], 'ipam/prefixes 10.0.0.0/24')).toThrow(
|
|
29
|
+
/ipam\/prefixes 10\.0\.0\.0\/24 matched 2 NetBox objects/,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('locateOne', () => {
|
|
35
|
+
const page = (count: number, results: readonly { id: number; vrf?: number }[]) =>
|
|
36
|
+
Effect.succeed({ count, results });
|
|
37
|
+
|
|
38
|
+
test('an empty page is absent', async () => {
|
|
39
|
+
const result = await Effect.runPromise(locateOne(page(0, []), 'ipam/prefixes 10.0.0.0/24'));
|
|
40
|
+
expect(result).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('one row with no `identifies` narrowing is the object', async () => {
|
|
44
|
+
const result = await Effect.runPromise(
|
|
45
|
+
locateOne(page(1, [{ id: 7 }]), 'ipam/prefixes 10.0.0.0/24'),
|
|
46
|
+
);
|
|
47
|
+
expect(result).toEqual({ id: 7 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('`identifies` narrows several server-side matches to the declared one', async () => {
|
|
51
|
+
const rows = [
|
|
52
|
+
{ id: 7, vrf: 1 },
|
|
53
|
+
{ id: 8, vrf: 2 },
|
|
54
|
+
];
|
|
55
|
+
const result = await Effect.runPromise(
|
|
56
|
+
locateOne(page(2, rows), 'ipam/prefixes 10.0.0.0/24', (row) => row.vrf === 2),
|
|
57
|
+
);
|
|
58
|
+
expect(result).toEqual({ id: 8, vrf: 2 });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('more rows than the page width is a defect, not a truncation', async () => {
|
|
62
|
+
const rows = Array.from({ length: LOCATE_PAGE + 1 }, (_, i) => ({ id: i }));
|
|
63
|
+
await expect(
|
|
64
|
+
Effect.runPromise(locateOne(page(LOCATE_PAGE + 1, rows), 'ipam/prefixes 10.0.0.0/24')),
|
|
65
|
+
).rejects.toThrow(/matched 21 rows, more than the 20-row page/);
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/netbox/resource.ts
CHANGED
|
@@ -1,134 +1,146 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One shape for every NetBox object — the same four operations, different
|
|
2
|
+
* One shape for every NetBox object — the same four operations, different distilled call.
|
|
3
3
|
*
|
|
4
4
|
* ⛔ THE ALTERNATIVE IS COPYING THE SAME HANDLERS PER RESOURCE. Prefixes, VLANs, devices, tenants
|
|
5
|
-
* and interfaces are all create / read / update / delete
|
|
6
|
-
*
|
|
5
|
+
* and interfaces are all create / read / update / delete against `@distilled.cloud/netbox`'s
|
|
6
|
+
* typed operations. Hand-rolling the fourth copy is where someone drops the read-back or the
|
|
7
7
|
* constraint guard and the plan lies.
|
|
8
8
|
*
|
|
9
|
+
* ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE — mirroring `../forgejo/resource.ts`'s engine exactly,
|
|
10
|
+
* plus the constraint-guard wiring every NetBox write already had (guardBody, unchanged).
|
|
11
|
+
*
|
|
12
|
+
* 🔴 WHY THIS REPLACED `client.ts`'s HAND-ROLLED `HttpClient` CALLS. Measured against Tim's
|
|
13
|
+
* distilled research page 2026-09-23: upstream Alchemy only accepts providers that call
|
|
14
|
+
* `@distilled.cloud/<vendor>` operations and `catchTag` their typed errors — not a raw
|
|
15
|
+
* `fetch`/`HttpClient` client over a hand-maintained path table. `@distilled.cloud/netbox`
|
|
16
|
+
* (published as `@homeflare/distilled-netbox@0.2.0`, aliased in — see
|
|
17
|
+
* `docs/distilled-interim.md`) covers every operation this family calls.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
|
|
20
|
+
* direct successor to the old, centralized `absentOn404`, and the same split `../forgejo/resource.ts`
|
|
21
|
+
* uses. `Effect.catchTag`'s tag-literal inference needs a concrete error union to resolve
|
|
22
|
+
* `"NotFound"` against; a spec's `E` is only concrete at each resource file's own call site. A
|
|
23
|
+
* NetBox *list* call (what `locateOne` below wraps) never 404s — narrowing happens by an empty
|
|
24
|
+
* `results` page, not a status code — so nothing here folds a status at all.
|
|
25
|
+
*
|
|
9
26
|
* ★ ADOPT IS THE DEFAULT POSTURE, NOT A FLAG. `reconcile` LOCATES before it writes: an object the
|
|
10
27
|
* estate already has is bound, not duplicated. NetBox is a record that predates this code by
|
|
11
28
|
* years; a provider that created a second `10.20.10.0/24` on first deploy would be worse than
|
|
12
29
|
* no provider.
|
|
13
30
|
*
|
|
14
|
-
* ★ `defaultRemovalPolicy: 'retain'` ON EVERY FAMILY
|
|
15
|
-
*
|
|
16
|
-
* IP assignments detach, and the change log is the only trace left. `retain` means the engine
|
|
17
|
-
* skips `delete` when a resource is orphaned unless the caller opts into
|
|
18
|
-
* `.pipe(RemovalPolicy.destroy())`. The `delete` handler is FULLY IMPLEMENTED anyway: a stub
|
|
19
|
-
* that silently does nothing lies to whoever reads the plan.
|
|
31
|
+
* ★ `defaultRemovalPolicy: 'retain'` ON EVERY FAMILY — declared per resource file (prefix.ts),
|
|
32
|
+
* same as before this migration.
|
|
20
33
|
*/
|
|
21
34
|
import { isResolved } from 'alchemy/Diff';
|
|
22
35
|
import type { Input } from 'alchemy/Input';
|
|
36
|
+
import { CredentialsFromEnv } from '@distilled.cloud/netbox/Credentials';
|
|
37
|
+
import type { NetboxOpContext } from '@distilled.cloud/netbox/Protocol';
|
|
23
38
|
import * as Effect from 'effect/Effect';
|
|
24
39
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
25
|
-
import { NetboxError, type NetboxList, type NetboxRow, netbox, soleMatch } from './client.ts';
|
|
26
40
|
import { guardBody } from './constraint-guard.ts';
|
|
27
41
|
import type { EndpointKey, NetboxBody } from './constraints.ts';
|
|
28
42
|
|
|
29
43
|
/**
|
|
30
|
-
* ★ WHAT EVERY
|
|
31
|
-
* `
|
|
32
|
-
*
|
|
33
|
-
*
|
|
44
|
+
* ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
|
|
45
|
+
* `Netbox.NetboxOpContext` is `Credentials | HttpClient.HttpClient`; `netboxHandlers` closes
|
|
46
|
+
* over `CredentialsFromEnv` itself (`NETBOX_URL` / `NETBOX_TOKEN`, the names client.ts already
|
|
47
|
+
* read — see the SDK's own credentials.ts note), so a consuming stack only has to provide
|
|
48
|
+
* `HttpClient.HttpClient`, exactly as it did before this migration.
|
|
34
49
|
*/
|
|
35
50
|
export type NetboxRequirements = HttpClient.HttpClient;
|
|
36
51
|
|
|
37
|
-
export interface NetboxSpec<Props extends object, Attributes> {
|
|
38
|
-
/** `ipam/prefixes` — where a new one is POSTed and where `locate` filters. */
|
|
39
|
-
readonly collection: string;
|
|
40
|
-
/**
|
|
41
|
-
* The query that narrows the candidates, as `URLSearchParams` pairs.
|
|
42
|
-
*
|
|
43
|
-
* ⛔ ONLY FILTERS THE VENDOR DOCUMENT DECLARES, AND ONLY IN THE FORM IT DECLARES THEM. A
|
|
44
|
-
* filter NetBox rejects answers 400, which `absentOn404` correctly does NOT fold to "absent" —
|
|
45
|
-
* so a guessed filter shape is a hard failure on every plan, not a soft one.
|
|
46
|
-
* ★ IT DOES NOT HAVE TO BE UNIQUE. `identifies` finishes the job in this process, where the
|
|
47
|
-
* rule is readable and testable offline.
|
|
48
|
-
*/
|
|
49
|
-
readonly locate: (props: Props) => readonly (readonly [string, string])[];
|
|
50
|
-
/**
|
|
51
|
-
* Which of the returned rows IS this object.
|
|
52
|
-
*
|
|
53
|
-
* 🔴 THIS EXISTS BECAUSE THE OBVIOUS ALTERNATIVE WAS A GUESS. `Netbox.Prefix` first narrowed
|
|
54
|
-
* server-side with `vrf_id=null`, the sentinel NetBox uses elsewhere for "no foreign key".
|
|
55
|
-
* ⛔ MEASURED IN THE VENDOR'S OWN SOURCE at v4.7.0: `PrefixFilterSet.vrf_id` is a plain
|
|
56
|
-
* `django_filters.ModelMultipleChoiceFilter` with NO `null_value`, so `'null'` is validated
|
|
57
|
-
* against the VRF queryset, fails, and NetBox answers 400 under strict filtering. The
|
|
58
|
-
* sentinel is real (`FILTERS_NULL_CHOICE_VALUE = 'null'`) but it is opt-in per filter, and
|
|
59
|
-
* this one did not opt in.
|
|
60
|
-
* ★ SO THE DISCRIMINATOR MOVED INTO THIS PROCESS. It costs one page of candidates and buys a
|
|
61
|
-
* rule that can be read, tested without a server, and cannot be wrong about a vendor's
|
|
62
|
-
* filter semantics. Omit it when `locate` really is unique on its own.
|
|
63
|
-
*/
|
|
64
|
-
readonly identifies?: (live: NetboxRow, props: Props) => boolean;
|
|
65
|
-
readonly attributes: (live: NetboxRow, props: Props) => Attributes | undefined;
|
|
66
|
-
readonly createBody: (props: Props) => NetboxBody;
|
|
67
|
-
/** ⚠️ PARTIAL BY DESIGN: NetBox updates are PATCH, so presence is not checked on this body. */
|
|
68
|
-
readonly updateBody?: (props: Props) => NetboxBody;
|
|
69
|
-
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
70
|
-
/** The generated constraint table to check each body against, by endpoint key. */
|
|
71
|
-
readonly endpoint: { readonly create: EndpointKey; readonly update?: EndpointKey };
|
|
72
|
-
/** For the message `soleMatch` raises and for read-back failures. */
|
|
73
|
-
readonly describe: (props: Props) => string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** ⚠️ Only 404 folds to "absent". A 403 or a 502 is a real failure — folding it would plan a create over an existing object. */
|
|
77
|
-
const absentOn404 = <A>(io: Effect.Effect<A, NetboxError, NetboxRequirements>) =>
|
|
78
|
-
io.pipe(
|
|
79
|
-
Effect.catchIf(
|
|
80
|
-
(cause): cause is NetboxError => cause instanceof NetboxError && cause.status === 404,
|
|
81
|
-
() => Effect.succeed(undefined as A),
|
|
82
|
-
),
|
|
83
|
-
);
|
|
84
|
-
|
|
85
52
|
/**
|
|
86
53
|
* ⚠️ NOT `limit=1`, AND NOT `limit=2` EITHER. One row is all a caller wants, but the page has to
|
|
87
|
-
* be wide enough for `identifies` to see every candidate
|
|
88
|
-
* exists in several VRFs comes back several times, and a page of two
|
|
54
|
+
* be wide enough for an `identifies` narrowing to see every candidate a server-side filter
|
|
55
|
+
* matched — a prefix that exists in several VRFs comes back several times, and a page of two
|
|
56
|
+
* would hide the third.
|
|
89
57
|
* ★ TWENTY, AND THE NUMBER IS AN ASSERTION: a natural key matching more than twenty rows is not
|
|
90
58
|
* a natural key. `count` is checked against it, so the failure is "your filter is too broad"
|
|
91
|
-
* rather than a silent truncation.
|
|
59
|
+
* rather than a silent truncation. Unchanged from client.ts's original value.
|
|
92
60
|
*/
|
|
93
|
-
const LOCATE_PAGE = 20;
|
|
61
|
+
export const LOCATE_PAGE = 20;
|
|
94
62
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The single row a filtered list returns, or `undefined`.
|
|
65
|
+
*
|
|
66
|
+
* ⛔ MORE THAN ONE MATCH IS A DEFECT, NOT A REASON TO TAKE THE FIRST. A `locate` filter that is
|
|
67
|
+
* not unique would let adopt bind to whichever row NetBox happened to order first, and the next
|
|
68
|
+
* plan would bind to the other one and report drift that is not there.
|
|
69
|
+
*/
|
|
70
|
+
export const soleMatch = <T>(rows: readonly T[], describe: string): T | undefined => {
|
|
71
|
+
if (rows.length > 1) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`${describe} matched ${String(rows.length)} NetBox objects. An object's identity must be ` +
|
|
74
|
+
'unique; narrow it (the VRF, the site, the tenant) rather than taking the first row.',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return rows[0];
|
|
78
|
+
};
|
|
100
79
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
80
|
+
/**
|
|
81
|
+
* List, then narrow to at most one candidate — the same "which of these rows IS this object"
|
|
82
|
+
* problem a server-side filter cannot always answer (see prefix.ts's own note on `vrf_id`). Any
|
|
83
|
+
* NetBox resource whose locate is "filter server-side, then disambiguate in this process" reaches
|
|
84
|
+
* this once rather than reimplementing the page-width assertion and the ambiguity defect per
|
|
85
|
+
* resource — the direct successor to client.ts's `fetchLive` helper, generalized over the SDK's
|
|
86
|
+
* own paginated-list shape instead of a hand-decoded `NetboxList<NetboxRow>`.
|
|
87
|
+
*/
|
|
88
|
+
export const locateOne = <Row, E, R>(
|
|
89
|
+
list: Effect.Effect<{ readonly count: number; readonly results: readonly Row[] }, E, R>,
|
|
90
|
+
describe: string,
|
|
91
|
+
identifies?: (row: Row) => boolean,
|
|
92
|
+
): Effect.Effect<Row | undefined, E, R> =>
|
|
93
|
+
list.pipe(
|
|
94
|
+
Effect.map((page) => {
|
|
95
|
+
if (page.count > LOCATE_PAGE) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${describe}: the locate filter matched ${String(page.count)} rows, more than the ` +
|
|
98
|
+
`${String(LOCATE_PAGE)}-row page this reads. That is not a natural key — narrow the ` +
|
|
99
|
+
'filter rather than paging through candidates.',
|
|
121
100
|
);
|
|
122
|
-
}
|
|
123
|
-
|
|
101
|
+
}
|
|
102
|
+
const rows = identifies === undefined ? page.results : page.results.filter(identifies);
|
|
103
|
+
return soleMatch(rows, describe);
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
124
106
|
|
|
125
|
-
|
|
126
|
-
|
|
107
|
+
/**
|
|
108
|
+
* One NetBox object's distilled calls. `Live` is whatever the SDK decodes (a `Prefix`, a `VLAN`,
|
|
109
|
+
* …) — no more untyped `NetboxRow`. `E` is left to each resource file to declare (the union of the
|
|
110
|
+
* distilled operations it actually calls), so a call site's precise per-operation error union
|
|
111
|
+
* flows straight through instead of being widened by hand here.
|
|
112
|
+
*/
|
|
113
|
+
export type NetboxSpec<Props extends object, Live, Attributes, E> = {
|
|
114
|
+
/** Already folds any real "absent" outcome (an empty list, or a `catchTag('NotFound', ...)`). */
|
|
115
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, NetboxOpContext>;
|
|
116
|
+
readonly attributes: (live: Live, props: Props) => Attributes | undefined;
|
|
117
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
118
|
+
readonly createBody: (props: Props) => NetboxBody;
|
|
119
|
+
readonly create: (props: Props, body: NetboxBody) => Effect.Effect<unknown, E, NetboxOpContext>;
|
|
120
|
+
/** Absent for a create-only family — reconcile then never attempts a write against an adopted row. */
|
|
121
|
+
readonly update?: {
|
|
122
|
+
/** ⚠️ PARTIAL BY DESIGN: NetBox updates are PATCH, so presence is not checked on this body. */
|
|
123
|
+
readonly body: (props: Props) => NetboxBody;
|
|
124
|
+
readonly call: (
|
|
125
|
+
props: Props,
|
|
126
|
+
live: Live,
|
|
127
|
+
body: NetboxBody,
|
|
128
|
+
) => Effect.Effect<unknown, E, NetboxOpContext>;
|
|
129
|
+
};
|
|
130
|
+
readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, NetboxOpContext>;
|
|
131
|
+
/** The generated constraint table to check each body against, by endpoint key. */
|
|
132
|
+
readonly endpoint: { readonly create: EndpointKey; readonly update?: EndpointKey };
|
|
133
|
+
/** For the message `soleMatch` raises and for read-back failures. */
|
|
134
|
+
readonly describe: (props: Props) => string;
|
|
135
|
+
};
|
|
127
136
|
|
|
137
|
+
export const netboxOperations = <Props extends object, Live, Attributes, E>(
|
|
138
|
+
spec: NetboxSpec<Props, Live, Attributes, E>,
|
|
139
|
+
) => {
|
|
128
140
|
const read = (props: Props) =>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
141
|
+
spec
|
|
142
|
+
.fetchLive(props)
|
|
143
|
+
.pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
|
|
132
144
|
|
|
133
145
|
return {
|
|
134
146
|
read,
|
|
@@ -139,29 +151,29 @@ export const netboxOperations = <Props extends object, Attributes>(
|
|
|
139
151
|
const live = yield* read(news);
|
|
140
152
|
if (live === undefined) return { action: 'update' } as const;
|
|
141
153
|
if (spec.matches(live, news)) return { action: 'noop' } as const;
|
|
142
|
-
return spec.
|
|
154
|
+
return spec.update === undefined
|
|
143
155
|
? ({ action: 'replace' } as const)
|
|
144
156
|
: ({ action: 'update' } as const);
|
|
145
157
|
}),
|
|
146
158
|
|
|
147
159
|
reconcile: (news: Props) =>
|
|
148
160
|
Effect.gen(function* () {
|
|
149
|
-
const before = yield* fetchLive(news);
|
|
161
|
+
const before = yield* spec.fetchLive(news);
|
|
150
162
|
if (before === undefined) {
|
|
151
163
|
const body = spec.createBody(news);
|
|
152
164
|
// ⛔ GUARD BEFORE THE REQUEST, WITH PRESENCE ON. This is the only place a missing
|
|
153
165
|
// required property can still be caught locally.
|
|
154
166
|
yield* guardBody(spec.endpoint.create, body, true);
|
|
155
|
-
yield*
|
|
156
|
-
} else if (spec.
|
|
167
|
+
yield* spec.create(news, body);
|
|
168
|
+
} else if (spec.update !== undefined) {
|
|
157
169
|
const mapped = spec.attributes(before, news);
|
|
158
170
|
// ⚠️ ADOPTION IS THE `mapped === undefined` CASE TOO: a row this spec cannot map is not
|
|
159
171
|
// a row to PATCH blindly.
|
|
160
172
|
if (mapped !== undefined && !spec.matches(mapped, news)) {
|
|
161
|
-
const body = spec.
|
|
173
|
+
const body = spec.update.body(news);
|
|
162
174
|
yield* guardBody(spec.endpoint.update, body, false);
|
|
163
175
|
if (Object.keys(body).length > 0) {
|
|
164
|
-
yield*
|
|
176
|
+
yield* spec.update.call(news, before, body);
|
|
165
177
|
}
|
|
166
178
|
}
|
|
167
179
|
}
|
|
@@ -180,9 +192,9 @@ export const netboxOperations = <Props extends object, Attributes>(
|
|
|
180
192
|
|
|
181
193
|
destroy: (olds: Props) =>
|
|
182
194
|
Effect.gen(function* () {
|
|
183
|
-
const live = yield* fetchLive(olds);
|
|
195
|
+
const live = yield* spec.fetchLive(olds);
|
|
184
196
|
if (live === undefined) return;
|
|
185
|
-
yield*
|
|
197
|
+
yield* spec.destroy(olds, live);
|
|
186
198
|
}),
|
|
187
199
|
};
|
|
188
200
|
};
|
|
@@ -190,19 +202,28 @@ export const netboxOperations = <Props extends object, Attributes>(
|
|
|
190
202
|
/**
|
|
191
203
|
* The five provider handlers for a spec'd NetBox object, wired once.
|
|
192
204
|
*
|
|
205
|
+
* ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
|
|
206
|
+
* `NETBOX_URL` / `NETBOX_TOKEN` on the calling fiber per request, so this closure keeps that
|
|
207
|
+
* laziness — nothing is captured at module load. ⛔ THE TOKEN IS NEVER A PROP: Alchemy persists
|
|
208
|
+
* attributes unencrypted, so nothing stored in a stack file may be a credential.
|
|
209
|
+
*
|
|
210
|
+
* ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as `forgejoHandlers`.
|
|
211
|
+
*
|
|
193
212
|
* ⚠️ `list` answers empty. NetBox index endpoints return the whole estate — tens of thousands of
|
|
194
213
|
* rows on a real instance — so adoption stays explicit rather than sweeping.
|
|
195
214
|
*/
|
|
196
|
-
export const netboxHandlers = <Props extends object, Attributes extends object>(
|
|
197
|
-
spec: NetboxSpec<Props, Attributes>,
|
|
215
|
+
export const netboxHandlers = <Props extends object, Live, Attributes extends object, E>(
|
|
216
|
+
spec: NetboxSpec<Props, Live, Attributes, E>,
|
|
198
217
|
) => {
|
|
199
218
|
const ops = netboxOperations(spec);
|
|
219
|
+
const withCredentials = <A>(effect: Effect.Effect<A, E, NetboxOpContext>) =>
|
|
220
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
200
221
|
return {
|
|
201
222
|
list: () => Effect.succeed([]),
|
|
202
|
-
read: ({ olds }: { olds: Props }) => ops.read(olds),
|
|
223
|
+
read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
|
|
203
224
|
diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
204
|
-
ops.diff(news, output),
|
|
205
|
-
reconcile: ({ news }: { news: Props }) => ops.reconcile(news),
|
|
206
|
-
delete: ({ olds }: { olds: Props }) => ops.destroy(olds),
|
|
225
|
+
withCredentials(ops.diff(news, output)),
|
|
226
|
+
reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
|
|
227
|
+
delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
|
|
207
228
|
};
|
|
208
229
|
};
|
package/dist/netbox/client.d.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One authenticated call to the NetBox REST API at `/api`.
|
|
3
|
-
*
|
|
4
|
-
* ⚠️ `Authorization: Token <value>`, NOT `Bearer`. NetBox is Django REST Framework using DRF's own
|
|
5
|
-
* `TokenAuthentication`. MEASURED on this estate's own instance while it was up: `Bearer`
|
|
6
|
-
* returns 401 and `Token` returns 200. ⛔ A wrong scheme and a wrong credential are
|
|
7
|
-
* indistinguishable in the response, which is why this is written down rather than inferred.
|
|
8
|
-
*
|
|
9
|
-
* ⚠️ NETBOX DOES NOT WRAP A DETAIL RESPONSE THE WAY PVE DOES — a GET of one object returns the
|
|
10
|
-
* object. A LIST, however, IS wrapped: `{count, next, previous, results: [...]}`. A caller that
|
|
11
|
-
* treats a list like a detail sees `undefined` and calls a healthy object absent, which plans a
|
|
12
|
-
* create over something that already exists.
|
|
13
|
-
*
|
|
14
|
-
* ⛔ THE TOKEN IS NEVER A PROP. It is read from `NETBOX_TOKEN` at call time only. Alchemy persists
|
|
15
|
-
* attributes unencrypted; nothing stored here may be a credential.
|
|
16
|
-
*/
|
|
17
|
-
import * as Effect from 'effect/Effect';
|
|
18
|
-
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
19
|
-
/**
|
|
20
|
-
* The instance, from `NETBOX_URL`. Trailing slash stripped.
|
|
21
|
-
*
|
|
22
|
-
* ⛔ NO DEFAULT HOSTNAME AND NO DEFAULT PORT. A published package that fell back to a loopback
|
|
23
|
-
* address would let a consumer who forgot the variable watch every call fail against a machine
|
|
24
|
-
* that is not theirs, instead of being told which variable is missing.
|
|
25
|
-
* ⚠️ Read at CALL time, never at module scope — a value captured at import is pinned to whatever
|
|
26
|
-
* the first isolate saw.
|
|
27
|
-
*/
|
|
28
|
-
export declare const apiBase: () => string;
|
|
29
|
-
export declare class NetboxError extends Error {
|
|
30
|
-
readonly status: number;
|
|
31
|
-
readonly method: string;
|
|
32
|
-
readonly path: string;
|
|
33
|
-
constructor(status: number, method: string, path: string, detail: string);
|
|
34
|
-
}
|
|
35
|
-
/** A NetBox list response. ⚠️ Paginated: `next` is a URL, not a cursor to guess at. */
|
|
36
|
-
export interface NetboxList<T> {
|
|
37
|
-
readonly count: number;
|
|
38
|
-
readonly next: string | null;
|
|
39
|
-
readonly results: readonly T[];
|
|
40
|
-
}
|
|
41
|
-
export type NetboxRow = Record<string, unknown>;
|
|
42
|
-
/** One JSON call. Returns the parsed body, or `undefined` when the response is empty (DELETE). */
|
|
43
|
-
export declare const netbox: <T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: Record<string, unknown>) => Effect.Effect<T, NetboxError, HttpClient.HttpClient>;
|
|
44
|
-
/**
|
|
45
|
-
* The single row a filtered list returns, or `undefined`.
|
|
46
|
-
*
|
|
47
|
-
* ⛔ MORE THAN ONE MATCH IS A DEFECT, NOT A REASON TO TAKE THE FIRST. A `locate` filter that is
|
|
48
|
-
* not unique would let adopt bind to whichever row NetBox happened to order first, and the next
|
|
49
|
-
* plan would bind to the other one and report drift that is not there.
|
|
50
|
-
*/
|
|
51
|
-
export declare const soleMatch: <T>(rows: readonly T[], describe: string) => T | undefined;
|
|
52
|
-
//# sourceMappingURL=client.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/netbox/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAK9D;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,QAAO,MAQ1B,CAAC;AAEF,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;IAHvB,YACW,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,MAAM,EAAE,MAAM,EAIf;CACF;AAkBD,uFAAuF;AACvF,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC;CAChC;AAED,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEhD,kGAAkG;AAClG,eAAO,MAAM,MAAM,GAAI,CAAC,UACd,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,QAC7C,MAAM,SACL,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,yDA8C5B,CAAC;AAEL;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,GAAI,CAAC,QAAQ,SAAS,CAAC,EAAE,YAAY,MAAM,KAAG,CAAC,GAAG,SAQvE,CAAC"}
|