@homeflare/alchemy 0.37.2 → 0.37.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/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/directory.d.ts +5 -3
- package/dist/linux/directory.d.ts.map +1 -1
- package/dist/linux/index.js +4 -3
- package/dist/linux/index.js.map +4 -4
- package/dist/litellm/fake-stack.d.ts +7 -7
- package/dist/litellm/fake-stack.d.ts.map +1 -1
- package/dist/litellm/index.js +2 -2
- package/dist/litellm/index.js.map +4 -4
- package/dist/litellm/pass-through-endpoint.d.ts +9 -8
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +8 -2
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/proxmox/backup-job-config.d.ts +95 -0
- package/dist/proxmox/backup-job-config.d.ts.map +1 -0
- package/dist/proxmox/backup-job-distilled.d.ts +46 -0
- package/dist/proxmox/backup-job-distilled.d.ts.map +1 -0
- package/dist/proxmox/backup-job.d.ts +9 -26
- package/dist/proxmox/backup-job.d.ts.map +1 -1
- package/dist/proxmox/distilled-pbs.d.ts +12 -0
- package/dist/proxmox/distilled-pbs.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-jobs.d.ts +23 -0
- package/dist/proxmox/fake-pbs-jobs.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -1
- package/dist/proxmox/fake-pve.d.ts +1 -1
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/index.js +1501 -607
- package/dist/proxmox/index.js.map +48 -24
- package/dist/proxmox/live-core-fixtures.d.ts +40 -0
- package/dist/proxmox/live-core-fixtures.d.ts.map +1 -0
- package/dist/proxmox/metric-server-config.d.ts +49 -0
- package/dist/proxmox/metric-server-config.d.ts.map +1 -0
- package/dist/proxmox/metric-server-distilled.d.ts +43 -0
- package/dist/proxmox/metric-server-distilled.d.ts.map +1 -0
- package/dist/proxmox/metric-server.d.ts +2 -10
- package/dist/proxmox/metric-server.d.ts.map +1 -1
- package/dist/proxmox/notification-matcher-distilled.d.ts +9 -0
- package/dist/proxmox/notification-matcher-distilled.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher-lifecycle.d.ts +27 -0
- package/dist/proxmox/notification-matcher-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +5 -5
- package/dist/proxmox/notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/notification-sdk-input.d.ts +22 -0
- package/dist/proxmox/notification-sdk-input.d.ts.map +1 -0
- package/dist/proxmox/notification-target-distilled.d.ts +12 -0
- package/dist/proxmox/notification-target-distilled.d.ts.map +1 -0
- package/dist/proxmox/notification-target-form.d.ts +2 -0
- package/dist/proxmox/notification-target-form.d.ts.map +1 -1
- package/dist/proxmox/notification-target-lifecycle.d.ts +29 -0
- package/dist/proxmox/notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/notification-target-spec.d.ts +5 -0
- package/dist/proxmox/notification-target-spec.d.ts.map +1 -0
- package/dist/proxmox/notification-target.d.ts +4 -5
- package/dist/proxmox/notification-target.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-endpoint.d.ts +1 -1
- package/dist/proxmox/pbs-datastore-form.d.ts +18 -42
- package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-guard.d.ts +7 -4
- package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-lifecycle.d.ts +56 -0
- package/dist/proxmox/pbs-datastore-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore-wire.d.ts +21 -0
- package/dist/proxmox/pbs-datastore-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore.d.ts +5 -6
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-input.d.ts +18 -0
- package/dist/proxmox/pbs-notification-input.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher-distilled.d.ts +11 -0
- package/dist/proxmox/pbs-notification-matcher-distilled.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts +4 -3
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target-distilled.d.ts +20 -0
- package/dist/proxmox/pbs-notification-target-distilled.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +4 -4
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts +1 -1
- package/dist/proxmox/pbs-prune-job-lifecycle.d.ts +22 -0
- package/dist/proxmox/pbs-prune-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job-spec.d.ts +5 -0
- package/dist/proxmox/pbs-prune-job-spec.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job-wire.d.ts +11 -0
- package/dist/proxmox/pbs-prune-job-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job.d.ts +9 -8
- package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
- package/dist/proxmox/pbs-sync-job-attributes.d.ts +34 -0
- package/dist/proxmox/pbs-sync-job-attributes.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job-form.d.ts +21 -22
- package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-sync-job-lifecycle.d.ts +22 -0
- package/dist/proxmox/pbs-sync-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job-wire.d.ts +11 -0
- package/dist/proxmox/pbs-sync-job-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job.d.ts +9 -39
- package/dist/proxmox/pbs-sync-job.d.ts.map +1 -1
- package/dist/proxmox/pbs-verify-job-lifecycle.d.ts +22 -0
- package/dist/proxmox/pbs-verify-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job-spec.d.ts +16 -0
- package/dist/proxmox/pbs-verify-job-spec.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job-wire.d.ts +11 -0
- package/dist/proxmox/pbs-verify-job-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job.d.ts +12 -12
- package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
- package/dist/proxmox/pool.d.ts +10 -1
- package/dist/proxmox/pool.d.ts.map +1 -1
- package/dist/release/archive.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/docs/pbs-distilled-jobs.md +31 -0
- package/docs/pbs-notifications.md +8 -2
- package/docs/proxmox-notification-sdk.md +28 -0
- package/docs/proxmox-sdk-migration.md +60 -0
- package/docs/upstream-conformance-families.md +6 -2
- package/package.json +1 -1
- package/src/linux/directory-lifecycle.ts +14 -2
- package/src/linux/directory.test.ts +24 -0
- package/src/linux/directory.ts +5 -3
- package/src/litellm/fake-stack.ts +13 -10
- package/src/litellm/pass-through-endpoint.ts +5 -6
- package/src/litellm/pass-through-stack.test.ts +54 -0
- package/src/litellm/providers.ts +8 -2
- package/src/proxmox/adopt-noop.test.ts +4 -5
- package/src/proxmox/backup-job-config.ts +95 -0
- package/src/proxmox/backup-job-distilled.ts +86 -0
- package/src/proxmox/backup-job.ts +78 -116
- package/src/proxmox/constraints.test.ts +9 -8
- package/src/proxmox/distilled-pbs.test.ts +87 -0
- package/src/proxmox/distilled-pbs.ts +50 -0
- package/src/proxmox/fake-pbs-jobs.ts +99 -0
- package/src/proxmox/fake-pbs-notify.ts +4 -1
- package/src/proxmox/fake-pve.ts +3 -2
- package/src/proxmox/live-core-fixtures.ts +126 -0
- package/src/proxmox/live-core-lifecycle.test.ts +115 -0
- package/src/proxmox/live-core-wire.test.ts +190 -0
- package/src/proxmox/metric-server-config.ts +87 -0
- package/src/proxmox/metric-server-distilled.ts +81 -0
- package/src/proxmox/metric-server.ts +77 -88
- package/src/proxmox/notification-distilled.test.ts +162 -0
- package/src/proxmox/notification-matcher-distilled.ts +60 -0
- package/src/proxmox/notification-matcher-lifecycle.ts +71 -0
- package/src/proxmox/notification-matcher.ts +9 -26
- package/src/proxmox/notification-replacement.test.ts +190 -0
- package/src/proxmox/notification-sdk-input.ts +39 -0
- package/src/proxmox/notification-target-distilled.ts +160 -0
- package/src/proxmox/notification-target-form.ts +2 -0
- package/src/proxmox/notification-target-lifecycle.ts +75 -0
- package/src/proxmox/notification-target-spec.ts +68 -0
- package/src/proxmox/notification-target.ts +6 -68
- package/src/proxmox/pbs-datastore-endpoint.ts +1 -1
- package/src/proxmox/pbs-datastore-form.ts +18 -49
- package/src/proxmox/pbs-datastore-guard.ts +7 -4
- package/src/proxmox/pbs-datastore-lifecycle.ts +114 -0
- package/src/proxmox/pbs-datastore-wire.ts +94 -0
- package/src/proxmox/pbs-datastore.ts +7 -133
- package/src/proxmox/pbs-jobs-delete.test.ts +40 -0
- package/src/proxmox/pbs-jobs-sdk.test.ts +215 -0
- package/src/proxmox/pbs-notification-errors.test.ts +93 -0
- package/src/proxmox/pbs-notification-input.ts +25 -0
- package/src/proxmox/pbs-notification-mail.test.ts +64 -0
- package/src/proxmox/pbs-notification-matcher-distilled.ts +65 -0
- package/src/proxmox/pbs-notification-matcher.ts +70 -17
- package/src/proxmox/pbs-notification-target-distilled.ts +124 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +26 -17
- package/src/proxmox/pbs-notification-target.ts +1 -1
- package/src/proxmox/pbs-notifications-stack.test.ts +25 -0
- package/src/proxmox/pbs-prune-job-lifecycle.ts +52 -0
- package/src/proxmox/pbs-prune-job-spec.ts +53 -0
- package/src/proxmox/pbs-prune-job-wire.ts +68 -0
- package/src/proxmox/pbs-prune-job.ts +10 -58
- package/src/proxmox/pbs-sync-job-attributes.ts +33 -0
- package/src/proxmox/pbs-sync-job-form.ts +7 -6
- package/src/proxmox/pbs-sync-job-lifecycle.ts +46 -0
- package/src/proxmox/pbs-sync-job-wire.ts +71 -0
- package/src/proxmox/pbs-sync-job.ts +10 -41
- package/src/proxmox/pbs-verify-job-lifecycle.ts +52 -0
- package/src/proxmox/pbs-verify-job-spec.ts +76 -0
- package/src/proxmox/pbs-verify-job-wire.ts +58 -0
- package/src/proxmox/pbs-verify-job.ts +13 -85
- package/src/proxmox/pool.ts +92 -10
- package/src/release/archive.ts +5 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** The real PBS SDK protocol: correct credentials, bounded attempts, and no implicit re-send. */
|
|
2
|
+
import { expect, test } from 'bun:test';
|
|
3
|
+
import * as config from '@distilled.cloud/proxmox-backup/config';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import * as Layer from 'effect/Layer';
|
|
6
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
7
|
+
import type { PbsTarget, PveCredential } from './credentials.ts';
|
|
8
|
+
import { runPbsWith } from './distilled-pbs.ts';
|
|
9
|
+
|
|
10
|
+
const TARGET: PbsTarget = { api: 'https://pbs.invalid:8007', mount: 'pbs-test', scheme: 'pbs' };
|
|
11
|
+
const CREDENTIAL: PveCredential = {
|
|
12
|
+
leaseSeconds: 300,
|
|
13
|
+
secret: 'fake-secret-not-real',
|
|
14
|
+
tokenId: 'test@pbs!fake',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const transport = (answer: (request: Request) => Response | Promise<Response>) => {
|
|
18
|
+
const stub = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
|
|
19
|
+
Promise.resolve(
|
|
20
|
+
answer(
|
|
21
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
return FetchHttpClient.layer.pipe(
|
|
25
|
+
Layer.provideMerge(
|
|
26
|
+
Layer.succeed(
|
|
27
|
+
FetchHttpClient.Fetch,
|
|
28
|
+
Object.assign(stub, { preconnect: globalThis.fetch.preconnect }),
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
test('the PBS SDK builds its own colon-separated authorization and normalizes the API root', async () => {
|
|
35
|
+
let called = false;
|
|
36
|
+
const layer = transport((request) => {
|
|
37
|
+
called = true;
|
|
38
|
+
expect(request.url).toBe(
|
|
39
|
+
'https://pbs.invalid:8007/api2/json/config/notifications/matchers/pager',
|
|
40
|
+
);
|
|
41
|
+
expect(request.headers.get('authorization')).toBe(
|
|
42
|
+
'PBSAPIToken=test@pbs!fake:fake-secret-not-real',
|
|
43
|
+
);
|
|
44
|
+
return Response.json({ data: { name: 'pager', target: ['mail-to-root'] } });
|
|
45
|
+
});
|
|
46
|
+
const row = await Effect.runPromise(
|
|
47
|
+
runPbsWith(TARGET, CREDENTIAL, config.getConfigNotificationMatcher({ name: 'pager' })).pipe(
|
|
48
|
+
Effect.provide(layer),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
expect(called).toBe(true);
|
|
52
|
+
expect(row.target).toEqual(['mail-to-root']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a typed HTTP failure is returned once, without the SDK default retry', async () => {
|
|
56
|
+
let calls = 0;
|
|
57
|
+
const layer = transport(() => {
|
|
58
|
+
calls += 1;
|
|
59
|
+
return Response.json({ message: 'unavailable' }, { status: 503 });
|
|
60
|
+
});
|
|
61
|
+
const error = await Effect.runPromise(
|
|
62
|
+
runPbsWith(TARGET, CREDENTIAL, config.deleteConfigNotificationMatcher({ name: 'pager' })).pipe(
|
|
63
|
+
Effect.flip,
|
|
64
|
+
Effect.provide(layer),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
expect(error).toMatchObject({ _tag: 'ServiceUnavailable' });
|
|
68
|
+
expect(calls).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a sent write that never answers is bounded and never repeated', async () => {
|
|
72
|
+
let calls = 0;
|
|
73
|
+
const layer = transport(() => {
|
|
74
|
+
calls += 1;
|
|
75
|
+
return new Promise<Response>(() => {});
|
|
76
|
+
});
|
|
77
|
+
const error = await Effect.runPromise(
|
|
78
|
+
runPbsWith(
|
|
79
|
+
TARGET,
|
|
80
|
+
CREDENTIAL,
|
|
81
|
+
config.deleteConfigNotificationMatcher({ name: 'pager' }),
|
|
82
|
+
'20 millis',
|
|
83
|
+
).pipe(Effect.flip, Effect.provide(layer)),
|
|
84
|
+
);
|
|
85
|
+
expect(error).toMatchObject({ _tag: 'TimeoutError' });
|
|
86
|
+
expect(calls).toBe(1);
|
|
87
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PBS 4.2.6-1 operations through the distilled SDK, with the existing OpenBao leases.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ PBS IS A SINGLE HOST, NOT A PVE CLUSTER. Its Credentials layer supplies the
|
|
5
|
+
* `PBSAPIToken=id:secret` scheme; running a PBS operation through runPve would send PVE's
|
|
6
|
+
* different header. Nothing here invents another host or retries a possibly completed write.
|
|
7
|
+
* ★ Retry.none also disables distilled's default transient-error retries within that host.
|
|
8
|
+
* MEMBER_TIMEOUT bounds the one attempt, just as it bounds each PVE member attempt.
|
|
9
|
+
*/
|
|
10
|
+
import { credentials } from '@distilled.cloud/proxmox-backup/Credentials';
|
|
11
|
+
import type { ProxmoxBackupOpContext } from '@distilled.cloud/proxmox-backup/Protocol';
|
|
12
|
+
import * as Retry from '@distilled.cloud/proxmox-backup/Retry';
|
|
13
|
+
import type * as Cause from 'effect/Cause';
|
|
14
|
+
import type * as Duration from 'effect/Duration';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
17
|
+
import type { PveCredentialDenied } from './credential-errors.ts';
|
|
18
|
+
import type { PbsTarget, PveCredential, PveRole } from './credentials.ts';
|
|
19
|
+
import { leased } from './lease-cache.ts';
|
|
20
|
+
import { MEMBER_TIMEOUT } from './members.ts';
|
|
21
|
+
|
|
22
|
+
/** An already-held lease, also used by offline protocol tests. Production uses the default bound. */
|
|
23
|
+
export const runPbsWith = <A, E>(
|
|
24
|
+
target: PbsTarget,
|
|
25
|
+
credential: PveCredential,
|
|
26
|
+
op: Effect.Effect<A, E, ProxmoxBackupOpContext>,
|
|
27
|
+
timeout: Duration.Input = MEMBER_TIMEOUT,
|
|
28
|
+
): Effect.Effect<A, E | Cause.TimeoutError, HttpClient.HttpClient> =>
|
|
29
|
+
op.pipe(
|
|
30
|
+
Retry.none,
|
|
31
|
+
Effect.provide(
|
|
32
|
+
credentials({
|
|
33
|
+
baseUrl: target.api,
|
|
34
|
+
secret: credential.secret,
|
|
35
|
+
tokenId: credential.tokenId,
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
Effect.timeout(timeout),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** Preserve lease sharing and expose credential refusal and SDK error tags unchanged. */
|
|
42
|
+
export const runPbs = <A, E>(
|
|
43
|
+
target: PbsTarget,
|
|
44
|
+
role: PveRole,
|
|
45
|
+
op: Effect.Effect<A, E, ProxmoxBackupOpContext>,
|
|
46
|
+
): Effect.Effect<A, E | Cause.TimeoutError | PveCredentialDenied | Error, HttpClient.HttpClient> =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const credential = yield* leased(target, role);
|
|
49
|
+
return yield* runPbsWith(target, credential, op);
|
|
50
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Test-only PBS 4.2.6-1 wire fixture. Every HTTP request is intercepted; no live credentials. */
|
|
2
|
+
import * as Layer from 'effect/Layer';
|
|
3
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
4
|
+
import type { PbsTarget } from './credentials.ts';
|
|
5
|
+
|
|
6
|
+
export const PBS_JOBS_TARGET: PbsTarget = {
|
|
7
|
+
api: 'https://pbs.test:8007/api2/json',
|
|
8
|
+
mount: 'pbs-test',
|
|
9
|
+
scheme: 'pbs',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sections: Record<string, string> = {
|
|
13
|
+
datastore: 'datastore',
|
|
14
|
+
prune: 'prune',
|
|
15
|
+
sync: 'sync',
|
|
16
|
+
verify: 'verification',
|
|
17
|
+
};
|
|
18
|
+
const numbers =
|
|
19
|
+
/^(keep-(daily|hourly|last|monthly|weekly|yearly)|max-depth|outdated-after|transfer-last)$/;
|
|
20
|
+
const booleans = /^(disable|ignore-verified|remove-vanished|verified-only|verify-new)$/;
|
|
21
|
+
|
|
22
|
+
type Write = { method: string; path: string; form: Record<string, string> };
|
|
23
|
+
|
|
24
|
+
export const fakePbsJobs = () => {
|
|
25
|
+
const rows = new Map<string, Record<string, unknown>>();
|
|
26
|
+
const writes: Write[] = [];
|
|
27
|
+
const calls: { method: string; path: string }[] = [];
|
|
28
|
+
let failure: { status: number; body: unknown } | undefined;
|
|
29
|
+
let deleteFailure: { status: number; body: unknown } | undefined;
|
|
30
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
31
|
+
const request =
|
|
32
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
if (url.pathname.includes('/creds/')) {
|
|
35
|
+
return Response.json({
|
|
36
|
+
data: { secret: 'fake-secret-not-real', token_id: 'test@pbs!fake' },
|
|
37
|
+
lease_duration: 0,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const path = decodeURIComponent(url.pathname.replace('/api2/json/', ''));
|
|
41
|
+
calls.push({ method: request.method, path });
|
|
42
|
+
if (request.method === 'GET' && failure !== undefined) {
|
|
43
|
+
return Response.json(failure.body, { status: failure.status });
|
|
44
|
+
}
|
|
45
|
+
const parts = path.split('/');
|
|
46
|
+
const family = parts[1] ?? '';
|
|
47
|
+
const id = parts[2] ?? '';
|
|
48
|
+
const row = rows.get(path);
|
|
49
|
+
if (request.method === 'GET') {
|
|
50
|
+
// ★ Measured live 2026-09-24: these four missing sections are plain-text HTTP400.
|
|
51
|
+
return row === undefined
|
|
52
|
+
? new Response(`no such ${sections[family]} '${id}'`, { status: 400 })
|
|
53
|
+
: Response.json({ data: row });
|
|
54
|
+
}
|
|
55
|
+
const form = Object.fromEntries(new URLSearchParams(await request.text()));
|
|
56
|
+
writes.push({ method: request.method, path, form });
|
|
57
|
+
if (request.method === 'DELETE') {
|
|
58
|
+
if (deleteFailure !== undefined) {
|
|
59
|
+
return Response.json(deleteFailure.body, { status: deleteFailure.status });
|
|
60
|
+
}
|
|
61
|
+
rows.delete(path);
|
|
62
|
+
return Response.json({ data: family === 'datastore' ? 'UPID:fake:delete' : null });
|
|
63
|
+
}
|
|
64
|
+
const key = request.method === 'POST' ? `${path}/${form['id'] ?? form['name']}` : path;
|
|
65
|
+
const after = { ...rows.get(key) };
|
|
66
|
+
for (const [name, value] of Object.entries(form)) {
|
|
67
|
+
after[name] = numbers.test(name)
|
|
68
|
+
? Number(value)
|
|
69
|
+
: booleans.test(name)
|
|
70
|
+
? value === '1'
|
|
71
|
+
: value;
|
|
72
|
+
}
|
|
73
|
+
rows.set(key, after);
|
|
74
|
+
// ★ Pinned schema: create datastore is Unit/null; DELETE alone yields a UPID.
|
|
75
|
+
return Response.json({ data: null });
|
|
76
|
+
};
|
|
77
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
78
|
+
return {
|
|
79
|
+
rows,
|
|
80
|
+
writes,
|
|
81
|
+
calls,
|
|
82
|
+
failRead: (status: number, body: unknown) => {
|
|
83
|
+
failure = { status, body };
|
|
84
|
+
},
|
|
85
|
+
failDelete: (status: number, body: unknown) => {
|
|
86
|
+
deleteFailure = { status, body };
|
|
87
|
+
},
|
|
88
|
+
reset: () => {
|
|
89
|
+
rows.clear();
|
|
90
|
+
calls.length = 0;
|
|
91
|
+
writes.length = 0;
|
|
92
|
+
failure = undefined;
|
|
93
|
+
deleteFailure = undefined;
|
|
94
|
+
},
|
|
95
|
+
layer: FetchHttpClient.layer.pipe(
|
|
96
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
@@ -83,7 +83,10 @@ export const fakeNotify = (
|
|
|
83
83
|
const { deletes, fields } = body(call);
|
|
84
84
|
if (call.method === 'GET') {
|
|
85
85
|
const object = objects.get(path);
|
|
86
|
-
|
|
86
|
+
// SDK reads require the resource shape: data:null is malformed, not evidence of absence.
|
|
87
|
+
return object === undefined
|
|
88
|
+
? Response.json({ message: 'not found' }, { status: 404 })
|
|
89
|
+
: withSecretsBlanked(path, object, secrets);
|
|
87
90
|
}
|
|
88
91
|
if (call.method === 'DELETE') {
|
|
89
92
|
objects.delete(path);
|
package/src/proxmox/fake-pve.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface PveCall {
|
|
|
35
35
|
readonly pairs: readonly (readonly [string, string])[];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** Answers
|
|
38
|
+
/** Answers with `data`, or an explicit Response for SDK error fixtures; undefined is data:null. */
|
|
39
39
|
export type PveAnswer = (call: PveCall) => unknown;
|
|
40
40
|
|
|
41
41
|
export interface FakePve {
|
|
@@ -69,7 +69,8 @@ export const fakePve = (answer: PveAnswer): FakePve => {
|
|
|
69
69
|
path,
|
|
70
70
|
};
|
|
71
71
|
calls.push(call);
|
|
72
|
-
|
|
72
|
+
const value = answer(call);
|
|
73
|
+
return value instanceof Response ? value : Response.json({ data: value ?? null });
|
|
73
74
|
};
|
|
74
75
|
// ⚠️ `typeof fetch` carries `preconnect` on bun's lib — client.test.ts has the note.
|
|
75
76
|
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Test-only declarations and vendor-shaped responses for the three migrated PVE families. */
|
|
2
|
+
import type * as Effect from 'effect/Effect';
|
|
3
|
+
import * as Layer from 'effect/Layer';
|
|
4
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
5
|
+
import { type FakeEngine, engineOver } from '../verify/fake-engine.ts';
|
|
6
|
+
import { ProxmoxBackupJob, ProxmoxBackupJobProvider } from './backup-job.ts';
|
|
7
|
+
import { deleteBackupJob, readBackupJob } from './backup-job-distilled.ts';
|
|
8
|
+
import { FAKE_TARGET, type FakePve } from './fake-pve.ts';
|
|
9
|
+
import { ProxmoxMetricServer, ProxmoxMetricServerProvider } from './metric-server.ts';
|
|
10
|
+
import { deleteMetricServer, readMetricServer } from './metric-server-distilled.ts';
|
|
11
|
+
import { ProxmoxPool, ProxmoxPoolProvider, deletePool, readPool } from './pool.ts';
|
|
12
|
+
|
|
13
|
+
export const poolProps = { poolid: 'lab', comment: 'managed', target: FAKE_TARGET };
|
|
14
|
+
export const backupProps = {
|
|
15
|
+
id: 'nightly',
|
|
16
|
+
schedule: '02:00',
|
|
17
|
+
vmid: [103, 101],
|
|
18
|
+
'prune-backups': 'keep-weekly=4,keep-daily=7',
|
|
19
|
+
target: FAKE_TARGET,
|
|
20
|
+
};
|
|
21
|
+
export const metricProps = {
|
|
22
|
+
id: 'metrics',
|
|
23
|
+
type: 'influxdb' as const,
|
|
24
|
+
server: 'metrics.test',
|
|
25
|
+
port: 8086,
|
|
26
|
+
target: FAKE_TARGET,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface CoreCase {
|
|
30
|
+
name: string;
|
|
31
|
+
path: string;
|
|
32
|
+
declare: () => Effect.Effect<unknown, never, unknown>;
|
|
33
|
+
engine: (fake: FakePve) => FakeEngine;
|
|
34
|
+
read: () => Effect.Effect<unknown, unknown, HttpClient.HttpClient>;
|
|
35
|
+
remove: () => Effect.Effect<unknown, unknown, HttpClient.HttpClient>;
|
|
36
|
+
live: Record<string, unknown>;
|
|
37
|
+
drift: Record<string, unknown>;
|
|
38
|
+
missing: () => Response;
|
|
39
|
+
createPath: string;
|
|
40
|
+
/** Actual form fields, including defaults, without SDK identifier spelling leaking onto wire. */
|
|
41
|
+
createForm: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
export const coreCases: CoreCase[] = [
|
|
44
|
+
{
|
|
45
|
+
name: 'Pool',
|
|
46
|
+
path: 'pools/lab',
|
|
47
|
+
createPath: 'pools',
|
|
48
|
+
declare: () => ProxmoxPool('row', poolProps),
|
|
49
|
+
engine: (fake) => engineOver(ProxmoxPoolProvider().pipe(Layer.provideMerge(fake.layer))),
|
|
50
|
+
read: () => readPool(poolProps),
|
|
51
|
+
remove: () => deletePool(poolProps),
|
|
52
|
+
live: { comment: 'managed', members: [] },
|
|
53
|
+
drift: { comment: 'old', members: [] },
|
|
54
|
+
missing: () =>
|
|
55
|
+
Response.json({ message: "pool 'lab' does not exist\n", data: null }, { status: 500 }),
|
|
56
|
+
createForm: { poolid: 'lab', comment: 'managed' },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'BackupJob',
|
|
60
|
+
path: 'cluster/backup/nightly',
|
|
61
|
+
createPath: 'cluster/backup',
|
|
62
|
+
declare: () => ProxmoxBackupJob('row', backupProps),
|
|
63
|
+
engine: (fake) => engineOver(ProxmoxBackupJobProvider().pipe(Layer.provideMerge(fake.layer))),
|
|
64
|
+
read: () => readBackupJob(backupProps),
|
|
65
|
+
remove: () => deleteBackupJob(backupProps),
|
|
66
|
+
live: {
|
|
67
|
+
id: 'nightly',
|
|
68
|
+
schedule: '02:00',
|
|
69
|
+
vmid: '101,103',
|
|
70
|
+
'next-run': 987654,
|
|
71
|
+
'prune-backups': { 'keep-daily': 7, 'keep-weekly': 4 },
|
|
72
|
+
compress: 'zstd',
|
|
73
|
+
mailto: 'operator.invalid',
|
|
74
|
+
},
|
|
75
|
+
drift: {
|
|
76
|
+
id: 'nightly',
|
|
77
|
+
schedule: '03:00',
|
|
78
|
+
vmid: '101,103',
|
|
79
|
+
'prune-backups': { 'keep-daily': 7, 'keep-weekly': 4 },
|
|
80
|
+
},
|
|
81
|
+
missing: () =>
|
|
82
|
+
Response.json(
|
|
83
|
+
{
|
|
84
|
+
message: 'Parameter verification failed.\n',
|
|
85
|
+
errors: { id: "No such job 'nightly'" },
|
|
86
|
+
data: null,
|
|
87
|
+
},
|
|
88
|
+
{ status: 400 },
|
|
89
|
+
),
|
|
90
|
+
createForm: {
|
|
91
|
+
id: 'nightly',
|
|
92
|
+
schedule: '02:00',
|
|
93
|
+
vmid: '101,103',
|
|
94
|
+
all: '0',
|
|
95
|
+
enabled: '1',
|
|
96
|
+
mode: 'snapshot',
|
|
97
|
+
'notification-mode': 'auto',
|
|
98
|
+
'repeat-missed': '0',
|
|
99
|
+
'prune-backups': 'keep-weekly=4,keep-daily=7',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'MetricServer',
|
|
104
|
+
path: 'cluster/metrics/server/metrics',
|
|
105
|
+
createPath: 'cluster/metrics/server/metrics',
|
|
106
|
+
declare: () => ProxmoxMetricServer('row', metricProps),
|
|
107
|
+
engine: (fake) =>
|
|
108
|
+
engineOver(ProxmoxMetricServerProvider().pipe(Layer.provideMerge(fake.layer))),
|
|
109
|
+
read: () => readMetricServer(metricProps),
|
|
110
|
+
remove: () => deleteMetricServer(metricProps),
|
|
111
|
+
live: { type: 'influxdb', server: 'metrics.test', port: 8086, token: 'fake-not-a-secret' },
|
|
112
|
+
drift: { type: 'influxdb', server: 'old.test', port: 8086, token: 'fake-not-a-secret' },
|
|
113
|
+
missing: () =>
|
|
114
|
+
Response.json(
|
|
115
|
+
{ message: "status server entry 'metrics' does not exist\n", data: null },
|
|
116
|
+
{ status: 500 },
|
|
117
|
+
),
|
|
118
|
+
createForm: {
|
|
119
|
+
type: 'influxdb',
|
|
120
|
+
server: 'metrics.test',
|
|
121
|
+
port: '8086',
|
|
122
|
+
disable: '0',
|
|
123
|
+
'verify-certificate': '1',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Real Alchemy Plan/Apply and distilled requests; fixtures never reach a real host. */
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
5
|
+
import { fakePve, withoutBao } from './fake-pve.ts';
|
|
6
|
+
import { coreCases } from './live-core-fixtures.ts';
|
|
7
|
+
|
|
8
|
+
for (const entry of coreCases) {
|
|
9
|
+
describe(`distilled ${entry.name}`, () => {
|
|
10
|
+
test('adoption, subsequent plan and apply have zero writes', async () => {
|
|
11
|
+
const fake = fakePve(() => entry.live);
|
|
12
|
+
await withoutBao(async () => {
|
|
13
|
+
const engine = entry.engine(fake);
|
|
14
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows).toEqual([
|
|
15
|
+
expect.objectContaining({ diff: 'noop', ok: true }),
|
|
16
|
+
]);
|
|
17
|
+
expect(await engine.deploy(entry.declare())).toEqual({ row: 'adopted' });
|
|
18
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows).toEqual([
|
|
19
|
+
expect.objectContaining({ diff: 'noop', ok: true }),
|
|
20
|
+
]);
|
|
21
|
+
await engine.deploy(entry.declare());
|
|
22
|
+
expect(engine.stored()).not.toContain('fake-not-a-secret');
|
|
23
|
+
});
|
|
24
|
+
expect(fake.writes()).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('genuine absence creates once, then reads and converges', async () => {
|
|
28
|
+
let exists = false;
|
|
29
|
+
const fake = fakePve((call) => {
|
|
30
|
+
if (call.method === 'GET') return exists ? entry.live : entry.missing();
|
|
31
|
+
exists = true;
|
|
32
|
+
return null;
|
|
33
|
+
});
|
|
34
|
+
await withoutBao(async () => {
|
|
35
|
+
const engine = entry.engine(fake);
|
|
36
|
+
await engine.deploy(entry.declare());
|
|
37
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows[0]).toMatchObject({
|
|
38
|
+
diff: 'noop',
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
expect(fake.writes()).toEqual([`POST ${entry.createPath}`]);
|
|
42
|
+
expect(fake.calls.find((call) => call.method === 'POST')?.form).toEqual(entry.createForm);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('live drift produces exactly one PUT and preserves omitted fields', async () => {
|
|
46
|
+
let live = entry.drift;
|
|
47
|
+
const fake = fakePve((call) => {
|
|
48
|
+
if (call.method === 'GET') return live;
|
|
49
|
+
live = entry.live;
|
|
50
|
+
return null;
|
|
51
|
+
});
|
|
52
|
+
await withoutBao(async () => {
|
|
53
|
+
const engine = entry.engine(fake);
|
|
54
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows[0]).toMatchObject({
|
|
55
|
+
diff: 'update',
|
|
56
|
+
});
|
|
57
|
+
await engine.deploy(entry.declare());
|
|
58
|
+
expect((await engine.verify(entry.declare(), { all: true })).rows[0]).toMatchObject({
|
|
59
|
+
diff: 'noop',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
expect(fake.writes()).toEqual([`PUT ${entry.path}`]);
|
|
63
|
+
const form = fake.calls.find((call) => call.method === 'PUT')?.form;
|
|
64
|
+
expect(form).not.toHaveProperty('token');
|
|
65
|
+
expect(form).not.toHaveProperty('otel-headers');
|
|
66
|
+
expect(form).not.toHaveProperty('compress');
|
|
67
|
+
expect(form).not.toHaveProperty('mailto');
|
|
68
|
+
if (entry.name === 'BackupJob') expect(form).not.toHaveProperty('delete');
|
|
69
|
+
if (entry.name === 'MetricServer') {
|
|
70
|
+
expect(form?.['delete']).toBe(
|
|
71
|
+
'api-path-prefix,bucket,influxdbproto,max-body-size,mtu,organization,timeout',
|
|
72
|
+
);
|
|
73
|
+
expect(form).not.toHaveProperty('id');
|
|
74
|
+
expect(form).not.toHaveProperty('type');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
for (const status of [401, 403, 500]) {
|
|
79
|
+
test(`${status} read failure stops cold adoption and confirmed drift before writes`, async () => {
|
|
80
|
+
let fail = true;
|
|
81
|
+
const fake = fakePve(() =>
|
|
82
|
+
fail
|
|
83
|
+
? Response.json({ data: null, message: 'unrelated failure' }, { status })
|
|
84
|
+
: entry.live,
|
|
85
|
+
);
|
|
86
|
+
await withoutBao(async () => {
|
|
87
|
+
const engine = entry.engine(fake);
|
|
88
|
+
await expect(engine.deploy(entry.declare())).rejects.toBeDefined();
|
|
89
|
+
fail = false;
|
|
90
|
+
await engine.deploy(entry.declare());
|
|
91
|
+
fail = true;
|
|
92
|
+
await expect(engine.drift(entry.declare())).rejects.toBeDefined();
|
|
93
|
+
await expect(engine.deploy(entry.declare())).rejects.toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
expect(fake.writes()).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('delete failure retains the engine state and propagates', async () => {
|
|
100
|
+
let failing = false;
|
|
101
|
+
const fake = fakePve(() =>
|
|
102
|
+
failing
|
|
103
|
+
? Response.json({ data: null, message: 'permission denied' }, { status: 403 })
|
|
104
|
+
: entry.live,
|
|
105
|
+
);
|
|
106
|
+
await withoutBao(async () => {
|
|
107
|
+
const engine = entry.engine(fake);
|
|
108
|
+
await engine.deploy(entry.declare().pipe(RemovalPolicy.destroy()));
|
|
109
|
+
failing = true;
|
|
110
|
+
await expect(engine.deploy(Effect.void)).rejects.toBeDefined();
|
|
111
|
+
expect(engine.stored()).toContain('row');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|