@homeflare/alchemy 0.27.2 → 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 +5 -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/src/proxmox/distilled-task-await.test.ts +124 -0
- 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/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"}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Identity resolution, and the two ways it must refuse.
|
|
3
|
-
*
|
|
4
|
-
* ⛔ THE FAILURE THIS GUARDS IS SILENT AND SELF-RENEWING. If an ambiguous match took the first
|
|
5
|
-
* row, adopt would bind to it, the next plan would bind to the other, and every plan after that
|
|
6
|
-
* would report drift that is not there — while "fixing" it PATCHed one object with the other's
|
|
7
|
-
* declaration. Failing loudly is the only outcome an operator can act on.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, expect, test } from 'bun:test';
|
|
10
|
-
import { NetboxError, apiBase, soleMatch } from './client.ts';
|
|
11
|
-
|
|
12
|
-
describe('soleMatch', () => {
|
|
13
|
-
test('no candidate is absent, not an error — that is what plans a create', () => {
|
|
14
|
-
expect(soleMatch([], 'ipam/prefixes 10.0.0.0/24')).toBeUndefined();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test('exactly one candidate is the object', () => {
|
|
18
|
-
expect(soleMatch([{ id: 7 }], 'ipam/prefixes 10.0.0.0/24')).toEqual({ id: 7 });
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
/** ⛔ Two rows means the identity rule is wrong, and the message says which object it was. */
|
|
22
|
-
test('more than one candidate throws and names the object', () => {
|
|
23
|
-
expect(() => soleMatch([{ id: 7 }, { id: 8 }], 'ipam/prefixes 10.0.0.0/24')).toThrow(
|
|
24
|
-
/ipam\/prefixes 10\.0\.0\.0\/24 matched 2 NetBox objects/,
|
|
25
|
-
);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('the instance origin', () => {
|
|
30
|
-
/**
|
|
31
|
-
* ⛔ NO DEFAULT HOSTNAME. A published package that fell back to a loopback address would let a
|
|
32
|
-
* consumer who forgot the variable watch every call fail against a machine that is not
|
|
33
|
-
* theirs, rather than being told which variable is missing.
|
|
34
|
-
*/
|
|
35
|
-
test('an unset or blank NETBOX_URL names the variable', () => {
|
|
36
|
-
const before = process.env['NETBOX_URL'];
|
|
37
|
-
try {
|
|
38
|
-
delete process.env['NETBOX_URL'];
|
|
39
|
-
expect(() => apiBase()).toThrow(/NETBOX_URL is not set/);
|
|
40
|
-
process.env['NETBOX_URL'] = ' ';
|
|
41
|
-
expect(() => apiBase()).toThrow(/NETBOX_URL is not set/);
|
|
42
|
-
} finally {
|
|
43
|
-
if (before === undefined) delete process.env['NETBOX_URL'];
|
|
44
|
-
else process.env['NETBOX_URL'] = before;
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('a trailing slash is stripped so paths do not double up', () => {
|
|
49
|
-
const before = process.env['NETBOX_URL'];
|
|
50
|
-
try {
|
|
51
|
-
process.env['NETBOX_URL'] = 'https://netbox.example.com/';
|
|
52
|
-
expect(apiBase()).toBe('https://netbox.example.com');
|
|
53
|
-
} finally {
|
|
54
|
-
if (before === undefined) delete process.env['NETBOX_URL'];
|
|
55
|
-
else process.env['NETBOX_URL'] = before;
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe('NetboxError', () => {
|
|
61
|
-
/** ⚠️ The status is on the instance, because `absentOn404` folds ONLY 404 and nothing else. */
|
|
62
|
-
test('it carries the status a caller has to branch on', () => {
|
|
63
|
-
const error = new NetboxError(404, 'GET', 'ipam/prefixes/', 'Not found.');
|
|
64
|
-
expect(error.status).toBe(404);
|
|
65
|
-
expect(error.message).toContain('NetBox GET ipam/prefixes/ -> 404');
|
|
66
|
-
});
|
|
67
|
-
});
|
package/src/netbox/client.ts
DELETED
|
@@ -1,146 +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
|
-
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
20
|
-
|
|
21
|
-
const PATH_PREFIX = '/api';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The instance, from `NETBOX_URL`. Trailing slash stripped.
|
|
25
|
-
*
|
|
26
|
-
* ⛔ NO DEFAULT HOSTNAME AND NO DEFAULT PORT. A published package that fell back to a loopback
|
|
27
|
-
* address would let a consumer who forgot the variable watch every call fail against a machine
|
|
28
|
-
* that is not theirs, instead of being told which variable is missing.
|
|
29
|
-
* ⚠️ Read at CALL time, never at module scope — a value captured at import is pinned to whatever
|
|
30
|
-
* the first isolate saw.
|
|
31
|
-
*/
|
|
32
|
-
export const apiBase = (): string => {
|
|
33
|
-
const raw = process.env['NETBOX_URL'];
|
|
34
|
-
if (raw === undefined || raw.trim() === '') {
|
|
35
|
-
throw new Error(
|
|
36
|
-
'NETBOX_URL is not set. Point it at your NetBox instance, e.g. https://netbox.example.com',
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
return raw.trim().replace(/\/$/, '');
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export class NetboxError extends Error {
|
|
43
|
-
constructor(
|
|
44
|
-
readonly status: number,
|
|
45
|
-
readonly method: string,
|
|
46
|
-
readonly path: string,
|
|
47
|
-
detail: string,
|
|
48
|
-
) {
|
|
49
|
-
super(`NetBox ${method} ${path} -> ${String(status)}: ${detail}`);
|
|
50
|
-
this.name = 'NetboxError';
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const token = (): Effect.Effect<string, NetboxError> =>
|
|
55
|
-
Effect.gen(function* () {
|
|
56
|
-
const raw = process.env['NETBOX_TOKEN'];
|
|
57
|
-
if (raw === undefined || raw.trim() === '') {
|
|
58
|
-
return yield* Effect.fail(
|
|
59
|
-
new NetboxError(
|
|
60
|
-
0,
|
|
61
|
-
'GET',
|
|
62
|
-
'',
|
|
63
|
-
'NETBOX_TOKEN is unset. Mint a scoped token and export it — never store it in Alchemy props.',
|
|
64
|
-
),
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
return raw.trim();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
/** A NetBox list response. ⚠️ Paginated: `next` is a URL, not a cursor to guess at. */
|
|
71
|
-
export interface NetboxList<T> {
|
|
72
|
-
readonly count: number;
|
|
73
|
-
readonly next: string | null;
|
|
74
|
-
readonly results: readonly T[];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export type NetboxRow = Record<string, unknown>;
|
|
78
|
-
|
|
79
|
-
/** One JSON call. Returns the parsed body, or `undefined` when the response is empty (DELETE). */
|
|
80
|
-
export const netbox = <T>(
|
|
81
|
-
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
82
|
-
path: string,
|
|
83
|
-
body?: Record<string, unknown>,
|
|
84
|
-
) =>
|
|
85
|
-
Effect.gen(function* () {
|
|
86
|
-
const credential = yield* token();
|
|
87
|
-
const url = `${apiBase()}${PATH_PREFIX}/${path.replace(/^\//, '')}`;
|
|
88
|
-
/**
|
|
89
|
-
* ★ THROUGH `HttpClient`, NOT THE GLOBAL `fetch` — the idiom Alchemy's own providers follow.
|
|
90
|
-
* It buys a typed error channel, interruption when a plan is cancelled, the runtime's
|
|
91
|
-
* tracing, and a client a test can replace without monkey-patching a global.
|
|
92
|
-
*/
|
|
93
|
-
const client = yield* HttpClient.HttpClient;
|
|
94
|
-
/**
|
|
95
|
-
* ⛔ THE CONTENT TYPE GOES ON `bodyText`, NOT IN THE HEADER MAP. `bodyText(s)` with no second
|
|
96
|
-
* argument sets `text/plain` and OVERWRITES a `Content-Type` set by `setHeaders`, because it
|
|
97
|
-
* is piped afterwards. That shipped `@homeflare/alchemy/forgejo` unable to write at all —
|
|
98
|
-
* every create and update in the package, broken identically, for as long as the only
|
|
99
|
-
* credential on the shelf was read-only and so never reached a request body.
|
|
100
|
-
*/
|
|
101
|
-
const request = HttpClientRequest.make(method)(url).pipe(
|
|
102
|
-
HttpClientRequest.setHeaders({
|
|
103
|
-
Accept: 'application/json',
|
|
104
|
-
Authorization: `Token ${credential}`,
|
|
105
|
-
}),
|
|
106
|
-
body === undefined
|
|
107
|
-
? (self) => self
|
|
108
|
-
: HttpClientRequest.bodyText(JSON.stringify(body), 'application/json'),
|
|
109
|
-
);
|
|
110
|
-
const response = yield* client
|
|
111
|
-
.execute(request)
|
|
112
|
-
.pipe(Effect.mapError((cause) => new NetboxError(0, method, path, String(cause))));
|
|
113
|
-
// ⛔ Status first, body once — a NetBox error body is JSON but a reverse proxy's 502 is not.
|
|
114
|
-
// ⚠️ AND A 502 IS THE EXPECTED SYMPTOM WHEN NETBOX IS DOWN BUT ITS FRONT DOOR IS UP, which
|
|
115
|
-
// is the state the reference instance has been in since 2026-09-14. Folding it to "absent"
|
|
116
|
-
// would plan a create for every object that already exists.
|
|
117
|
-
if (response.status < 200 || response.status >= 300) {
|
|
118
|
-
const detail = yield* response.text.pipe(Effect.orElseSucceed(() => ''));
|
|
119
|
-
return yield* Effect.fail(
|
|
120
|
-
new NetboxError(response.status, method, path, detail.slice(0, 300)),
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
// ⚠️ 204 AND AN EMPTY BODY ARE REAL: a DELETE answers no content, and parsing an empty body
|
|
124
|
-
// throws. Read the text once and parse only when there is something to parse.
|
|
125
|
-
const text = yield* response.text.pipe(
|
|
126
|
-
Effect.mapError((cause) => new NetboxError(0, method, path, String(cause))),
|
|
127
|
-
);
|
|
128
|
-
return (text === '' ? undefined : JSON.parse(text)) as T;
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* The single row a filtered list returns, or `undefined`.
|
|
133
|
-
*
|
|
134
|
-
* ⛔ MORE THAN ONE MATCH IS A DEFECT, NOT A REASON TO TAKE THE FIRST. A `locate` filter that is
|
|
135
|
-
* not unique would let adopt bind to whichever row NetBox happened to order first, and the next
|
|
136
|
-
* plan would bind to the other one and report drift that is not there.
|
|
137
|
-
*/
|
|
138
|
-
export const soleMatch = <T>(rows: readonly T[], describe: string): T | undefined => {
|
|
139
|
-
if (rows.length > 1) {
|
|
140
|
-
throw new Error(
|
|
141
|
-
`${describe} matched ${String(rows.length)} NetBox objects. An object's identity must be ` +
|
|
142
|
-
'unique; narrow it (the VRF, the site, the tenant) rather than taking the first row.',
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
return rows[0];
|
|
146
|
-
};
|