@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
|
@@ -1,48 +1,66 @@
|
|
|
1
1
|
import type { Input } from 'alchemy/Input';
|
|
2
|
+
import type { NetboxOpContext } from '@distilled.cloud/netbox/Protocol';
|
|
2
3
|
import * as Effect from 'effect/Effect';
|
|
3
4
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
4
|
-
import { NetboxError, type NetboxRow } from './client.ts';
|
|
5
5
|
import type { EndpointKey, NetboxBody } from './constraints.ts';
|
|
6
6
|
/**
|
|
7
|
-
* ★ WHAT EVERY
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
|
|
8
|
+
* `Netbox.NetboxOpContext` is `Credentials | HttpClient.HttpClient`; `netboxHandlers` closes
|
|
9
|
+
* over `CredentialsFromEnv` itself (`NETBOX_URL` / `NETBOX_TOKEN`, the names client.ts already
|
|
10
|
+
* read — see the SDK's own credentials.ts note), so a consuming stack only has to provide
|
|
11
|
+
* `HttpClient.HttpClient`, exactly as it did before this migration.
|
|
11
12
|
*/
|
|
12
13
|
export type NetboxRequirements = HttpClient.HttpClient;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
readonly
|
|
41
|
-
readonly
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
14
|
+
/**
|
|
15
|
+
* ⚠️ NOT `limit=1`, AND NOT `limit=2` EITHER. One row is all a caller wants, but the page has to
|
|
16
|
+
* be wide enough for an `identifies` narrowing to see every candidate a server-side filter
|
|
17
|
+
* matched — a prefix that exists in several VRFs comes back several times, and a page of two
|
|
18
|
+
* would hide the third.
|
|
19
|
+
* ★ TWENTY, AND THE NUMBER IS AN ASSERTION: a natural key matching more than twenty rows is not
|
|
20
|
+
* a natural key. `count` is checked against it, so the failure is "your filter is too broad"
|
|
21
|
+
* rather than a silent truncation. Unchanged from client.ts's original value.
|
|
22
|
+
*/
|
|
23
|
+
export declare const LOCATE_PAGE = 20;
|
|
24
|
+
/**
|
|
25
|
+
* The single row a filtered list returns, or `undefined`.
|
|
26
|
+
*
|
|
27
|
+
* ⛔ MORE THAN ONE MATCH IS A DEFECT, NOT A REASON TO TAKE THE FIRST. A `locate` filter that is
|
|
28
|
+
* not unique would let adopt bind to whichever row NetBox happened to order first, and the next
|
|
29
|
+
* plan would bind to the other one and report drift that is not there.
|
|
30
|
+
*/
|
|
31
|
+
export declare const soleMatch: <T>(rows: readonly T[], describe: string) => T | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* List, then narrow to at most one candidate — the same "which of these rows IS this object"
|
|
34
|
+
* problem a server-side filter cannot always answer (see prefix.ts's own note on `vrf_id`). Any
|
|
35
|
+
* NetBox resource whose locate is "filter server-side, then disambiguate in this process" reaches
|
|
36
|
+
* this once rather than reimplementing the page-width assertion and the ambiguity defect per
|
|
37
|
+
* resource — the direct successor to client.ts's `fetchLive` helper, generalized over the SDK's
|
|
38
|
+
* own paginated-list shape instead of a hand-decoded `NetboxList<NetboxRow>`.
|
|
39
|
+
*/
|
|
40
|
+
export declare const locateOne: <Row, E, R>(list: Effect.Effect<{
|
|
41
|
+
readonly count: number;
|
|
42
|
+
readonly results: readonly Row[];
|
|
43
|
+
}, E, R>, describe: string, identifies?: (row: Row) => boolean) => Effect.Effect<Row | undefined, E, R>;
|
|
44
|
+
/**
|
|
45
|
+
* One NetBox object's distilled calls. `Live` is whatever the SDK decodes (a `Prefix`, a `VLAN`,
|
|
46
|
+
* …) — no more untyped `NetboxRow`. `E` is left to each resource file to declare (the union of the
|
|
47
|
+
* distilled operations it actually calls), so a call site's precise per-operation error union
|
|
48
|
+
* flows straight through instead of being widened by hand here.
|
|
49
|
+
*/
|
|
50
|
+
export type NetboxSpec<Props extends object, Live, Attributes, E> = {
|
|
51
|
+
/** Already folds any real "absent" outcome (an empty list, or a `catchTag('NotFound', ...)`). */
|
|
52
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, NetboxOpContext>;
|
|
53
|
+
readonly attributes: (live: Live, props: Props) => Attributes | undefined;
|
|
45
54
|
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
55
|
+
readonly createBody: (props: Props) => NetboxBody;
|
|
56
|
+
readonly create: (props: Props, body: NetboxBody) => Effect.Effect<unknown, E, NetboxOpContext>;
|
|
57
|
+
/** Absent for a create-only family — reconcile then never attempts a write against an adopted row. */
|
|
58
|
+
readonly update?: {
|
|
59
|
+
/** ⚠️ PARTIAL BY DESIGN: NetBox updates are PATCH, so presence is not checked on this body. */
|
|
60
|
+
readonly body: (props: Props) => NetboxBody;
|
|
61
|
+
readonly call: (props: Props, live: Live, body: NetboxBody) => Effect.Effect<unknown, E, NetboxOpContext>;
|
|
62
|
+
};
|
|
63
|
+
readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, NetboxOpContext>;
|
|
46
64
|
/** The generated constraint table to check each body against, by endpoint key. */
|
|
47
65
|
readonly endpoint: {
|
|
48
66
|
readonly create: EndpointKey;
|
|
@@ -50,30 +68,37 @@ export interface NetboxSpec<Props extends object, Attributes> {
|
|
|
50
68
|
};
|
|
51
69
|
/** For the message `soleMatch` raises and for read-back failures. */
|
|
52
70
|
readonly describe: (props: Props) => string;
|
|
53
|
-
}
|
|
54
|
-
export declare const netboxOperations: <Props extends object, Attributes>(spec: NetboxSpec<Props, Attributes>) => {
|
|
55
|
-
read: (props: Props) => Effect.Effect<Attributes | undefined,
|
|
71
|
+
};
|
|
72
|
+
export declare const netboxOperations: <Props extends object, Live, Attributes, E>(spec: NetboxSpec<Props, Live, Attributes, E>) => {
|
|
73
|
+
read: (props: Props) => Effect.Effect<Attributes | undefined, E, NetboxOpContext>;
|
|
56
74
|
diff: (news: Input<Props>, output: Attributes | undefined) => Effect.Effect<{
|
|
57
75
|
readonly action: 'update';
|
|
58
76
|
} | {
|
|
59
77
|
readonly action: 'noop';
|
|
60
78
|
} | {
|
|
61
79
|
readonly action: 'replace';
|
|
62
|
-
} | undefined,
|
|
63
|
-
reconcile: (news: Props) => Effect.Effect<Attributes & ({} | null),
|
|
64
|
-
destroy: (olds: Props) => Effect.Effect<void,
|
|
80
|
+
} | undefined, E, NetboxOpContext>;
|
|
81
|
+
reconcile: (news: Props) => Effect.Effect<Attributes & ({} | null), E, NetboxOpContext>;
|
|
82
|
+
destroy: (olds: Props) => Effect.Effect<void, E, NetboxOpContext>;
|
|
65
83
|
};
|
|
66
84
|
/**
|
|
67
85
|
* The five provider handlers for a spec'd NetBox object, wired once.
|
|
68
86
|
*
|
|
87
|
+
* ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
|
|
88
|
+
* `NETBOX_URL` / `NETBOX_TOKEN` on the calling fiber per request, so this closure keeps that
|
|
89
|
+
* laziness — nothing is captured at module load. ⛔ THE TOKEN IS NEVER A PROP: Alchemy persists
|
|
90
|
+
* attributes unencrypted, so nothing stored in a stack file may be a credential.
|
|
91
|
+
*
|
|
92
|
+
* ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as `forgejoHandlers`.
|
|
93
|
+
*
|
|
69
94
|
* ⚠️ `list` answers empty. NetBox index endpoints return the whole estate — tens of thousands of
|
|
70
95
|
* rows on a real instance — so adoption stays explicit rather than sweeping.
|
|
71
96
|
*/
|
|
72
|
-
export declare const netboxHandlers: <Props extends object, Attributes extends object>(spec: NetboxSpec<Props, Attributes>) => {
|
|
97
|
+
export declare const netboxHandlers: <Props extends object, Live, Attributes extends object, E>(spec: NetboxSpec<Props, Live, Attributes, E>) => {
|
|
73
98
|
list: () => Effect.Effect<never[], never, never>;
|
|
74
99
|
read: ({ olds }: {
|
|
75
100
|
olds: Props;
|
|
76
|
-
}) => Effect.Effect<Attributes | undefined,
|
|
101
|
+
}) => Effect.Effect<Attributes | undefined, E, HttpClient.HttpClient>;
|
|
77
102
|
diff: ({ news, output }: {
|
|
78
103
|
news: Input<Props>;
|
|
79
104
|
output: Attributes | undefined;
|
|
@@ -83,12 +108,12 @@ export declare const netboxHandlers: <Props extends object, Attributes extends o
|
|
|
83
108
|
readonly action: 'noop';
|
|
84
109
|
} | {
|
|
85
110
|
readonly action: 'replace';
|
|
86
|
-
} | undefined,
|
|
111
|
+
} | undefined, E, HttpClient.HttpClient>;
|
|
87
112
|
reconcile: ({ news }: {
|
|
88
113
|
news: Props;
|
|
89
|
-
}) => Effect.Effect<Attributes,
|
|
114
|
+
}) => Effect.Effect<Attributes, E, HttpClient.HttpClient>;
|
|
90
115
|
delete: ({ olds }: {
|
|
91
116
|
olds: Props;
|
|
92
|
-
}) => Effect.Effect<void,
|
|
117
|
+
}) => Effect.Effect<void, E, HttpClient.HttpClient>;
|
|
93
118
|
};
|
|
94
119
|
//# sourceMappingURL=resource.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/netbox/resource.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/netbox/resource.ts"],"names":[],"mappings":"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"}
|
package/docs/credentials.md
CHANGED
|
@@ -19,3 +19,13 @@ through the package's own `CredentialsFromEnv` layer, which reads `FORGEJO_URL`
|
|
|
19
19
|
— the same two variable names the retired hand-rolled client read, still resolved on the calling
|
|
20
20
|
fiber per request, never captured at module scope. `Authorization: token <value>` (Forgejo's
|
|
21
21
|
access-token scheme, not `Bearer`) is built by the package's protocol layer, not by this package.
|
|
22
|
+
|
|
23
|
+
The NetBox subpath (2026-09-23, moved onto `@distilled.cloud/netbox` — see
|
|
24
|
+
[distilled-interim.md](./distilled-interim.md)) resolves credentials the same way, through the
|
|
25
|
+
SDK's own `CredentialsFromEnv` layer, which reads `NETBOX_URL` / `NETBOX_TOKEN` — the same two
|
|
26
|
+
variable names the retired hand-rolled `client.ts` read, still resolved on the calling fiber per
|
|
27
|
+
request, never captured at module scope. `Authorization: Token <value>` (NetBox's Django REST
|
|
28
|
+
Framework `TokenAuthentication` scheme — NOT `Bearer`, measured against this estate's own
|
|
29
|
+
instance) is built by the package's protocol layer, not by this package. ⛔ The token is never a
|
|
30
|
+
prop: Alchemy persists attributes unencrypted, so nothing stored in a stack file may be a
|
|
31
|
+
credential.
|
package/docs/peers.md
CHANGED
|
@@ -53,3 +53,14 @@ whole install, not one per subpath — `peers.test.ts`'s "no peer is marked opti
|
|
|
53
53
|
enforces it. `@effect/sql-pg` is optional on `alchemy`'s own manifest (its module graph
|
|
54
54
|
does not reach `SQL/Postgres` unless a stack imports it); here it stays a plain peer like
|
|
55
55
|
every other one.
|
|
56
|
+
|
|
57
|
+
★ **`@distilled.cloud/netbox` (added 2026-09-23, moving `/netbox` off a hand-rolled
|
|
58
|
+
`HttpClient` client the same way `/forgejo` did) is NOT on this install line, and that is
|
|
59
|
+
deliberate — see [distilled-interim.md](./distilled-interim.md).** The real
|
|
60
|
+
`@distilled.cloud/netbox` is not published upstream yet, so this package aliases it onto
|
|
61
|
+
`@homeflare/distilled-netbox@0.2.0`, a copy built the distilled way and shipped from this
|
|
62
|
+
monorepo, as a plain **`dependencies`** entry (not a peer). A `dependencies` entry resolves
|
|
63
|
+
automatically for every consumer that installs `@homeflare/alchemy` — nothing to add to a
|
|
64
|
+
peer install line, and nothing for `peers.test.ts` to check. When the real package ships
|
|
65
|
+
upstream, the alias's target changes (or the entry becomes a normal peer, matching
|
|
66
|
+
`@distilled.cloud/forgejo` above) and this line still does not change for it.
|
|
@@ -104,8 +104,23 @@ then tidiness.
|
|
|
104
104
|
kubeconfig it writes is meant to be consumed through upstream's
|
|
105
105
|
`Kubernetes.KubeConfig({ path })`.
|
|
106
106
|
9. **`netbox/*` conforms mostly.** Its constraints are generated from NetBox 4.7.0's
|
|
107
|
-
OpenAPI
|
|
108
|
-
|
|
107
|
+
OpenAPI. Adopting first is documented as deliberate.
|
|
108
|
+
- ✅ **S23 fixed 2026-09-23** (branch `claude2/distilled-netbox-family`, mirroring the
|
|
109
|
+
Forgejo migration above, decision 42). `Netbox.Prefix` now calls
|
|
110
|
+
`@distilled.cloud/netbox`'s typed `ipam` operations instead of a hand-rolled `Effect
|
|
111
|
+
HttpClient` client. The old status-carrying `NetboxError` and its `cause.status ===
|
|
112
|
+
404` check are gone entirely — `Netbox.Prefix` locates by a server-side list filter,
|
|
113
|
+
which never 404s (an empty page is a normal 200), so nothing here checks a status
|
|
114
|
+
code at all, a stronger form of the same rule `catchTag('NotFound', …)` enforces for
|
|
115
|
+
a family that reads by direct key. `client.ts` is deleted. Not published upstream
|
|
116
|
+
yet, so aliased onto `@homeflare/distilled-netbox@0.2.0` as a plain `dependencies`
|
|
117
|
+
entry, not a peer — [distilled-interim.md](./distilled-interim.md). State did not
|
|
118
|
+
move: props/attributes stay byte-identical, proven by the family's unchanged
|
|
119
|
+
existing tests plus new tests against a fake NetBox exercising the real distilled
|
|
120
|
+
protocol.
|
|
121
|
+
- ⛔ **Still diverges on credentials (S24):** `NETBOX_TOKEN` / `NETBOX_URL` are read at
|
|
122
|
+
call time, now through the SDK's `CredentialsFromEnv` rather than a hand-rolled
|
|
123
|
+
`token()` — same divergence, unchanged by the transport swap.
|
|
109
124
|
10. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
|
|
110
125
|
`R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
|
|
111
126
|
(S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.4",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"access": "public"
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
|
+
"@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
|
|
97
98
|
"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.2.0"
|
|
98
99
|
},
|
|
99
100
|
"peerDependencies": {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal in-memory NetBox REST API for this family's tests — an in-memory `fetch` handed to
|
|
3
|
+
* Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
|
|
4
|
+
* `../forgejo/fake-forgejo.ts` (itself mirroring `cloudflare/fake-mesh.ts`). Tests exercise
|
|
5
|
+
* distilled's REAL path assembly, JSON encode/decode and status→typed-error matching (`404` →
|
|
6
|
+
* `NotFound`, `403` → `Forbidden`, …) — nothing about that matching is re-implemented here, only
|
|
7
|
+
* the wire responses a scenario needs.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
|
|
10
|
+
* `netbox.example.com`, and the token is a placeholder.
|
|
11
|
+
*/
|
|
12
|
+
import { type Credentials, credentials } from '@distilled.cloud/netbox/Credentials';
|
|
13
|
+
import * as Layer from 'effect/Layer';
|
|
14
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
15
|
+
|
|
16
|
+
export const FAKE_BASE = 'https://netbox.example.com';
|
|
17
|
+
export const FAKE_TOKEN = 'placeholder-netbox-token';
|
|
18
|
+
|
|
19
|
+
export type Seen = { readonly method: string; readonly path: string };
|
|
20
|
+
|
|
21
|
+
/** NetBox's `{ detail }` failure envelope (403/404) — matching protocol.ts's `errorEnvelope`. */
|
|
22
|
+
export const fakeFailure = (status: number, detail: string) =>
|
|
23
|
+
Response.json({ detail }, { status });
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `route` decides every response; this wrapper only records what was sent and adapts to
|
|
27
|
+
* `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
|
|
28
|
+
*/
|
|
29
|
+
export const fakeNetbox = (route: (method: string, url: URL) => Response) => {
|
|
30
|
+
const seen: Seen[] = [];
|
|
31
|
+
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
32
|
+
const request =
|
|
33
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
|
|
36
|
+
return route(request.method, url);
|
|
37
|
+
}) as typeof globalThis.fetch;
|
|
38
|
+
return { fetch, seen };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* distilled Credentials + the real FetchHttpClient over the fake — `NetboxOpContext`, what every
|
|
43
|
+
* resource file's exported `spec.fetchLive` needs.
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ NOT PAIRED WITH `handlers` (each resource file's `netboxHandlers(spec)` export). `handlers`
|
|
46
|
+
* bakes in `CredentialsFromEnv`, which resolves `NETBOX_URL` / `NETBOX_TOKEN` through Effect's
|
|
47
|
+
* `Config` — whose default provider snapshots `process.env` once and never re-reads it (measured
|
|
48
|
+
* in the forgejo migration, kit PR 180: mutating `process.env` mid-test-run had no effect). So a
|
|
49
|
+
* test cannot point `handlers` at a fake server; it calls `spec.fetchLive`/`spec.attributes`
|
|
50
|
+
* directly instead, with this explicit layer, exercising exactly the same production code
|
|
51
|
+
* without that env seam.
|
|
52
|
+
*/
|
|
53
|
+
export const fakeNetboxLayer = (
|
|
54
|
+
fetchFn: typeof globalThis.fetch,
|
|
55
|
+
creds: Layer.Layer<Credentials> = credentials({ baseUrl: FAKE_BASE, token: FAKE_TOKEN }),
|
|
56
|
+
) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
|
package/src/netbox/index.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* ★ The constraint reader IS exported, because a consumer writing its own family needs the same
|
|
9
9
|
* guard — and because a table nobody can reach is a table nobody uses.
|
|
10
10
|
*/
|
|
11
|
-
export { NetboxError, apiBase } from './client.ts';
|
|
12
11
|
export {
|
|
13
12
|
NETBOX_CONSTRAINTS,
|
|
14
13
|
bodyViolations,
|
|
@@ -31,4 +30,11 @@ export {
|
|
|
31
30
|
type PrefixProps,
|
|
32
31
|
type PrefixStatus,
|
|
33
32
|
} from './prefix.ts';
|
|
34
|
-
export {
|
|
33
|
+
export {
|
|
34
|
+
LOCATE_PAGE,
|
|
35
|
+
locateOne,
|
|
36
|
+
type NetboxRequirements,
|
|
37
|
+
type NetboxSpec,
|
|
38
|
+
netboxHandlers,
|
|
39
|
+
soleMatch,
|
|
40
|
+
} from './resource.ts';
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Netbox.Prefix`'s `spec` against a fake NetBox, proving the real distilled wire path that
|
|
3
|
+
* replaced `client.ts`'s hand-rolled `HttpClient` calls — path assembly, JSON encode/decode and
|
|
4
|
+
* status→typed-error matching, exercised through `@distilled.cloud/netbox/ipam`'s real operations,
|
|
5
|
+
* never re-implemented here. Mirrors `../forgejo/org-label.test.ts` and `repository.test.ts`'s
|
|
6
|
+
* "call the exported production functions" shape (kit PR 180).
|
|
7
|
+
*
|
|
8
|
+
* ⛔ THE POINT OF THIS FILE. `resource.test.ts` proves `locateOne`/`soleMatch` in isolation with a
|
|
9
|
+
* fixture list; this file proves prefix.ts's own `spec.fetchLive`, `spec.attributes`,
|
|
10
|
+
* `spec.create`, `spec.update.call` and `spec.destroy` — the exact object `netboxHandlers(spec)`
|
|
11
|
+
* wraps into `NetboxPrefixProvider` — against a real HTTP round trip.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import type * as ipam from '@distilled.cloud/netbox/ipam';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import { FAKE_BASE, fakeFailure, fakeNetbox, fakeNetboxLayer } from './fake-netbox.ts';
|
|
17
|
+
import type { PrefixProps } from './prefix.ts';
|
|
18
|
+
import { spec } from './prefix.ts';
|
|
19
|
+
|
|
20
|
+
const PREFIXES_PATH = '/api/ipam/prefixes/';
|
|
21
|
+
const PREFIX_ID_PATH = '/api/ipam/prefixes/7/';
|
|
22
|
+
|
|
23
|
+
/** A decodable `Prefix` — every field the SDK's schema requires present, per ipam.ts's interface. */
|
|
24
|
+
const livePrefix = (overrides: Record<string, unknown> = {}): ipam.Prefix =>
|
|
25
|
+
({
|
|
26
|
+
_depth: 0,
|
|
27
|
+
children: 0,
|
|
28
|
+
comments: '',
|
|
29
|
+
created: '2026-01-01T00:00:00Z',
|
|
30
|
+
description: '',
|
|
31
|
+
display: '10.0.0.0/24',
|
|
32
|
+
display_url: `${FAKE_BASE}${PREFIX_ID_PATH}`,
|
|
33
|
+
family: { label: 'IPv4', value: 4 },
|
|
34
|
+
id: 7,
|
|
35
|
+
is_pool: false,
|
|
36
|
+
last_updated: '2026-01-01T00:00:00Z',
|
|
37
|
+
mark_utilized: false,
|
|
38
|
+
prefix: '10.0.0.0/24',
|
|
39
|
+
scope: null,
|
|
40
|
+
status: { label: 'Active', value: 'active' },
|
|
41
|
+
url: `${FAKE_BASE}${PREFIX_ID_PATH}`,
|
|
42
|
+
vrf: null,
|
|
43
|
+
...overrides,
|
|
44
|
+
}) as unknown as ipam.Prefix;
|
|
45
|
+
|
|
46
|
+
/** `BriefVRF`/`BriefRIR`/`BriefVLAN` all require `id`, `url`, `display`, `name` — no `S.optional`. */
|
|
47
|
+
const briefVrf = (id: number, name: string) => ({
|
|
48
|
+
display: name,
|
|
49
|
+
id,
|
|
50
|
+
name,
|
|
51
|
+
url: `${FAKE_BASE}/api/ipam/vrfs/${String(id)}/`,
|
|
52
|
+
});
|
|
53
|
+
const briefTenant = (id: number, name: string) => ({
|
|
54
|
+
display: name,
|
|
55
|
+
id,
|
|
56
|
+
name,
|
|
57
|
+
slug: name,
|
|
58
|
+
url: `${FAKE_BASE}/api/tenancy/tenants/${String(id)}/`,
|
|
59
|
+
});
|
|
60
|
+
const briefVlan = (id: number, name: string, vid: number) => ({
|
|
61
|
+
display: name,
|
|
62
|
+
id,
|
|
63
|
+
name,
|
|
64
|
+
url: `${FAKE_BASE}/api/ipam/vlans/${String(id)}/`,
|
|
65
|
+
vid,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const readThrough = (fetchFn: typeof globalThis.fetch, props: PrefixProps) =>
|
|
69
|
+
Effect.runPromise(
|
|
70
|
+
spec.fetchLive(props).pipe(
|
|
71
|
+
Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
|
|
72
|
+
Effect.provide(fakeNetboxLayer(fetchFn)),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
describe('Netbox.Prefix spec.fetchLive', () => {
|
|
77
|
+
test('one server-side match, no VRF declared, decodes into the typed row', async () => {
|
|
78
|
+
const fake = fakeNetbox((method, url) =>
|
|
79
|
+
method === 'GET' && url.pathname === PREFIXES_PATH
|
|
80
|
+
? Response.json({ count: 1, next: null, previous: null, results: [livePrefix()] })
|
|
81
|
+
: fakeFailure(400, 'unexpected request'),
|
|
82
|
+
);
|
|
83
|
+
const live = await Effect.runPromise(
|
|
84
|
+
spec.fetchLive({ prefix: '10.0.0.0/24' }).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
|
|
85
|
+
);
|
|
86
|
+
expect(live?.id).toBe(7);
|
|
87
|
+
expect(fake.seen[0]?.method).toBe('GET');
|
|
88
|
+
// ⚠️ Not just the path — the server-side filter this resource relies on to narrow
|
|
89
|
+
// candidates before `identifies` runs. A future distilled bump that changed how an
|
|
90
|
+
// array param serializes would silently stop narrowing on the wire; asserting only
|
|
91
|
+
// `pathname` would never catch that.
|
|
92
|
+
const sent = new URL(`${FAKE_BASE}${fake.seen[0]?.path ?? ''}`);
|
|
93
|
+
expect(sent.searchParams.getAll('prefix')).toEqual(['10.0.0.0/24']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 🔴 THE CASE `identifies` EXISTS FOR — the same VRF disambiguation prefix.ts's own docstring
|
|
98
|
+
* explains. `prefix` alone matched two rows (the same CIDR in two VRFs); the declared `vrf`
|
|
99
|
+
* picks the right one in this process, never by re-filtering server-side.
|
|
100
|
+
*/
|
|
101
|
+
test('several rows for the same CIDR narrow to the declared VRF', async () => {
|
|
102
|
+
const rows = [
|
|
103
|
+
livePrefix({ id: 7, vrf: briefVrf(1, 'blue') }),
|
|
104
|
+
livePrefix({ id: 8, vrf: briefVrf(2, 'red') }),
|
|
105
|
+
];
|
|
106
|
+
const fake = fakeNetbox((method, url) =>
|
|
107
|
+
method === 'GET' && url.pathname === PREFIXES_PATH
|
|
108
|
+
? Response.json({ count: 2, next: null, previous: null, results: rows })
|
|
109
|
+
: fakeFailure(400, 'unexpected request'),
|
|
110
|
+
);
|
|
111
|
+
const live = await Effect.runPromise(
|
|
112
|
+
spec
|
|
113
|
+
.fetchLive({ prefix: '10.0.0.0/24', vrf: 2 })
|
|
114
|
+
.pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
|
|
115
|
+
);
|
|
116
|
+
expect(live?.id).toBe(8);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('no candidate rows is absent, not an error', async () => {
|
|
120
|
+
const fake = fakeNetbox(() =>
|
|
121
|
+
Response.json({ count: 0, next: null, previous: null, results: [] }),
|
|
122
|
+
);
|
|
123
|
+
const live = await Effect.runPromise(
|
|
124
|
+
spec.fetchLive({ prefix: '10.20.30.0/24' }).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
|
|
125
|
+
);
|
|
126
|
+
expect(live).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a 400 on the list propagates — never folded to absent', async () => {
|
|
130
|
+
const fake = fakeNetbox(() => fakeFailure(400, 'prefix: invalid CIDR'));
|
|
131
|
+
const failure = await Effect.runPromise(
|
|
132
|
+
Effect.flip(
|
|
133
|
+
spec.fetchLive({ prefix: 'not-a-cidr' }).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
expect(failure._tag).toBe('BadRequest');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('Netbox.Prefix spec.attributes — the actual exported production mapping', () => {
|
|
141
|
+
test('status, foreign keys and free text all decode the way client.ts used to read them', async () => {
|
|
142
|
+
const fake = fakeNetbox((method, url) =>
|
|
143
|
+
method === 'GET' && url.pathname === PREFIXES_PATH
|
|
144
|
+
? Response.json({
|
|
145
|
+
count: 1,
|
|
146
|
+
next: null,
|
|
147
|
+
previous: null,
|
|
148
|
+
results: [
|
|
149
|
+
livePrefix({
|
|
150
|
+
comments: 'management range',
|
|
151
|
+
description: 'core VLAN',
|
|
152
|
+
is_pool: true,
|
|
153
|
+
mark_utilized: true,
|
|
154
|
+
status: { label: 'Deprecated', value: 'deprecated' },
|
|
155
|
+
tenant: briefTenant(3, 'ops'),
|
|
156
|
+
vlan: briefVlan(5, 'core', 10),
|
|
157
|
+
vrf: briefVrf(1, 'blue'),
|
|
158
|
+
}),
|
|
159
|
+
],
|
|
160
|
+
})
|
|
161
|
+
: fakeFailure(400, 'unexpected request'),
|
|
162
|
+
);
|
|
163
|
+
expect(await readThrough(fake.fetch, { prefix: '10.0.0.0/24', vrf: 1 })).toEqual({
|
|
164
|
+
comments: 'management range',
|
|
165
|
+
description: 'core VLAN',
|
|
166
|
+
isPool: true,
|
|
167
|
+
markUtilized: true,
|
|
168
|
+
prefix: '10.0.0.0/24',
|
|
169
|
+
prefixId: 7,
|
|
170
|
+
status: 'deprecated',
|
|
171
|
+
tenant: 3,
|
|
172
|
+
vlan: 5,
|
|
173
|
+
vrf: 1,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('Netbox.Prefix spec.create / spec.update.call / spec.destroy — the real wire calls', () => {
|
|
179
|
+
const props: PrefixProps = { description: 'core VLAN', prefix: '10.0.0.0/24', status: 'active' };
|
|
180
|
+
const update = spec.update;
|
|
181
|
+
if (update === undefined) throw new Error('Netbox.Prefix must declare spec.update');
|
|
182
|
+
|
|
183
|
+
test('create POSTs the constraint-checked body to /api/ipam/prefixes/', async () => {
|
|
184
|
+
const fake = fakeNetbox((method, url) =>
|
|
185
|
+
method === 'POST' && url.pathname === PREFIXES_PATH
|
|
186
|
+
? Response.json(livePrefix({ description: 'core VLAN' }), { status: 201 })
|
|
187
|
+
: fakeFailure(400, 'unexpected request'),
|
|
188
|
+
);
|
|
189
|
+
const body = spec.createBody(props);
|
|
190
|
+
const live = await Effect.runPromise(
|
|
191
|
+
spec.create(props, body).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
|
|
192
|
+
);
|
|
193
|
+
expect((live as ipam.Prefix).id).toBe(7);
|
|
194
|
+
expect(fake.seen).toEqual([{ method: 'POST', path: PREFIXES_PATH }]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('update PATCHes /api/ipam/prefixes/{id}/, addressed by the live row numeric id', async () => {
|
|
198
|
+
const fake = fakeNetbox((method, url) =>
|
|
199
|
+
method === 'PATCH' && url.pathname === PREFIX_ID_PATH
|
|
200
|
+
? Response.json(livePrefix({ status: { label: 'Deprecated', value: 'deprecated' } }))
|
|
201
|
+
: fakeFailure(400, 'unexpected request'),
|
|
202
|
+
);
|
|
203
|
+
const declared = { ...props, status: 'deprecated' as const };
|
|
204
|
+
const body = update.body(declared);
|
|
205
|
+
const after = await Effect.runPromise(
|
|
206
|
+
update.call(declared, livePrefix(), body).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
|
|
207
|
+
);
|
|
208
|
+
expect((after as ipam.Prefix).status?.value).toBe('deprecated');
|
|
209
|
+
expect(fake.seen).toEqual([{ method: 'PATCH', path: PREFIX_ID_PATH }]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('destroy DELETEs /api/ipam/prefixes/{id}/', async () => {
|
|
213
|
+
const fake = fakeNetbox((method, url) =>
|
|
214
|
+
method === 'DELETE' && url.pathname === PREFIX_ID_PATH
|
|
215
|
+
? new Response(null, { status: 204 })
|
|
216
|
+
: fakeFailure(400, 'unexpected request'),
|
|
217
|
+
);
|
|
218
|
+
await Effect.runPromise(
|
|
219
|
+
spec.destroy(props, livePrefix()).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
|
|
220
|
+
);
|
|
221
|
+
expect(fake.seen).toEqual([{ method: 'DELETE', path: PREFIX_ID_PATH }]);
|
|
222
|
+
});
|
|
223
|
+
});
|