@homeflare/alchemy 0.37.2 → 0.37.3
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/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/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/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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Backup-job operations from distilled-proxmox, walked against pve-manager 9.2.11. */
|
|
2
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import * as Schema from 'effect/Schema';
|
|
5
|
+
import { ProxmoxParseError } from '@distilled.cloud/proxmox/Errors';
|
|
6
|
+
import { backupJobSpec } from './backup-job-config.ts';
|
|
7
|
+
import type { BackupJobProps } from './backup-job.ts';
|
|
8
|
+
import { runPve } from './distilled-pve.ts';
|
|
9
|
+
|
|
10
|
+
/** SDK member names differ from the vendor's hyphenated form keys; omission stays omission. */
|
|
11
|
+
export const backupJobRequest = (
|
|
12
|
+
props: BackupJobProps,
|
|
13
|
+
form = backupJobSpec.updateForm(props),
|
|
14
|
+
): cluster.PutClusterBackupRequest => {
|
|
15
|
+
const {
|
|
16
|
+
'notes-template': notes_template,
|
|
17
|
+
'notification-mode': notification_mode,
|
|
18
|
+
'prune-backups': prune_backups,
|
|
19
|
+
'repeat-missed': repeat_missed,
|
|
20
|
+
...fields
|
|
21
|
+
} = form;
|
|
22
|
+
return {
|
|
23
|
+
...fields,
|
|
24
|
+
id: props.id,
|
|
25
|
+
notification_mode,
|
|
26
|
+
repeat_missed,
|
|
27
|
+
...(notes_template === undefined ? {} : { notes_template }),
|
|
28
|
+
...(prune_backups === undefined ? {} : { prune_backups }),
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* SDK decode also renames NESTED retention keys. Restore the vendor's property-string
|
|
34
|
+
* spelling before the existing canonical comparison, or keep_daily becomes perpetual drift.
|
|
35
|
+
* A property string from an older server remains a property string.
|
|
36
|
+
*/
|
|
37
|
+
export const readBackupJob = (props: BackupJobProps) =>
|
|
38
|
+
runPve(props.target, 'read', false, cluster.getClusterBackup({ id: props.id })).pipe(
|
|
39
|
+
// Legacy PVE emits these two properties as strings; current PVE expands objects.
|
|
40
|
+
// Validate the generated response's remaining fields without narrowing that compatibility.
|
|
41
|
+
Effect.flatMap((live) =>
|
|
42
|
+
Schema.decodeUnknownEffect(Schema.toType(cluster.GetClusterBackupResponse))({
|
|
43
|
+
...live,
|
|
44
|
+
prune_backups: undefined,
|
|
45
|
+
fleecing: undefined,
|
|
46
|
+
}).pipe(
|
|
47
|
+
Effect.mapError(
|
|
48
|
+
() =>
|
|
49
|
+
new ProxmoxParseError({
|
|
50
|
+
body: undefined,
|
|
51
|
+
cause: 'Backup job response does not match its vendor schema',
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
Effect.as(live),
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
Effect.map((live) =>
|
|
58
|
+
backupJobSpec.attributes(
|
|
59
|
+
{
|
|
60
|
+
...live,
|
|
61
|
+
'next-run': live.next_run,
|
|
62
|
+
'notes-template': live.notes_template,
|
|
63
|
+
'notification-mode': live.notification_mode,
|
|
64
|
+
'prune-backups':
|
|
65
|
+
typeof live.prune_backups === 'object' && live.prune_backups !== null
|
|
66
|
+
? Object.fromEntries(
|
|
67
|
+
Object.entries(live.prune_backups).map(([key, value]) => [
|
|
68
|
+
key.replaceAll('_', '-'),
|
|
69
|
+
value,
|
|
70
|
+
]),
|
|
71
|
+
)
|
|
72
|
+
: live.prune_backups,
|
|
73
|
+
'repeat-missed': live.repeat_missed,
|
|
74
|
+
},
|
|
75
|
+
props,
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
Effect.catchTag('BackupJobNotFound', () => Effect.succeed(undefined)),
|
|
79
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
export const deleteBackupJob = (props: BackupJobProps) =>
|
|
83
|
+
runPve(props.target, 'provision', true, cluster.deleteClusterBackup({ id: props.id })).pipe(
|
|
84
|
+
Effect.catchTag('BackupJobNotFound', () => Effect.void),
|
|
85
|
+
Effect.catchTag('NotFound', () => Effect.void),
|
|
86
|
+
);
|
|
@@ -13,14 +13,13 @@
|
|
|
13
13
|
* COULD CARRY. PVE's own API schema says so in one parenthesis — `id`: "Job ID (will be
|
|
14
14
|
* autogenerated)." — and a generated id looks like `backup-f56d8ab7-d524`. A resource that
|
|
15
15
|
* POSTed without an id would create a SECOND job beside the first on EVERY reconcile, and the
|
|
16
|
-
* read-back
|
|
17
|
-
*
|
|
16
|
+
* read-back could still pass, because a job did appear: that check asks "is the object there",
|
|
17
|
+
* not "is it the one I meant". So `id` is a REQUIRED prop and is always sent
|
|
18
18
|
* on create. To adopt a job that already exists, declare its existing id; any other id is a new
|
|
19
19
|
* job beside it, not an adoption.
|
|
20
|
-
* ★
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* same swallowed error would have quietly doubled the cluster's backup jobs.
|
|
20
|
+
* ★ FAILED READS NOW PROPAGATE THROUGH DISTILLED. Only a typed missing-job response means
|
|
21
|
+
* absent. An expired lease, a 403 or an unrelated server error stops reconciliation before
|
|
22
|
+
* any POST. The explicit id remains a second defense against accidentally duplicating jobs.
|
|
24
23
|
*
|
|
25
24
|
* ⚠️ `Sys.Modify` ON `/` IS THE GATE AND IT IS NOT A SMALL GRANT. PVE has no Backup.* privilege:
|
|
26
25
|
* create, update and delete of a vzdump job are all checked against `Sys.Modify` at `/` — the
|
|
@@ -30,17 +29,21 @@
|
|
|
30
29
|
* reaction to a 403.
|
|
31
30
|
*/
|
|
32
31
|
import { Resource } from 'alchemy';
|
|
32
|
+
import { isResolved } from 'alchemy/Diff';
|
|
33
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
34
|
+
import { runPve } from './distilled-pve.ts';
|
|
35
|
+
import { specGuards } from './resource-guard.ts';
|
|
36
|
+
import { formToSend } from './update-guard.ts';
|
|
37
|
+
import { backupJobSpec } from './backup-job-config.ts';
|
|
38
|
+
import { backupJobRequest, deleteBackupJob, readBackupJob } from './backup-job-distilled.ts';
|
|
39
|
+
|
|
33
40
|
import * as Provider from 'alchemy/Provider';
|
|
34
41
|
import * as Effect from 'effect/Effect';
|
|
35
|
-
import {
|
|
36
|
-
import { bool, guestList, propertyString, text } from './values.ts';
|
|
42
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
37
43
|
|
|
38
44
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* and a translation table is one more place for a key to be renamed and silently never sent. A
|
|
42
|
-
* field that is never sent reads back as whatever it already was, which is indistinguishable
|
|
43
|
-
* from "PVE ignored me".
|
|
45
|
+
* ★ Public props retain PVE's own dashed field names. The distilled transport maps those
|
|
46
|
+
* existing forms to the SDK's generated identifiers; lifecycle tests check the actual wire.
|
|
44
47
|
*/
|
|
45
48
|
export interface BackupJobProps extends WithTarget {
|
|
46
49
|
/** ⛔ The primary key, REQUIRED. Never let PVE generate it — see the ⛔ above. */
|
|
@@ -107,109 +110,68 @@ export interface ProxmoxBackupJob extends Resource<
|
|
|
107
110
|
export const ProxmoxBackupJob = Resource<ProxmoxBackupJob>('Proxmox.BackupJob');
|
|
108
111
|
|
|
109
112
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* SET a key; clearing one needs `delete=<key>`, which this resource deliberately never sends.
|
|
114
|
-
* `updateForm` is handed the props and nothing else, so a `delete` list could only be "every
|
|
115
|
-
* field you did not declare" — and on a job adopted out of the UI that would silently strip
|
|
116
|
-
* settings this resource does not even model, `compress`, `mailto` and `bwlimit` among them, on
|
|
117
|
-
* the first deploy. Leaving undeclared fields alone is the smaller lie, and `matches` tells the
|
|
118
|
-
* same lie consistently: it does not compare what it cannot change.
|
|
119
|
-
*
|
|
120
|
-
* ⚠️ THE FIVE FIELDS PVE DOCUMENTS A DEFAULT FOR ARE ALWAYS SENT AND ALWAYS COMPARED — `enabled`
|
|
121
|
-
* (1), `all` (0), `mode` (snapshot), `notification-mode` (auto), `repeat-missed` (0). That is
|
|
122
|
-
* what makes a hand-flipped `enabled 0` show up as drift rather than as silence, and it is also
|
|
123
|
-
* what stops those five looping: a field compared against a default must be sent as that
|
|
124
|
-
* default, or every plan reports an update the PUT never performs. The cost is worth reading
|
|
125
|
-
* before the first deploy against an adopted job — an omitted `all` is a declaration that this
|
|
126
|
-
* job backs up NOTHING, and PVE accepts a job that selects nothing without a word. Read that
|
|
127
|
-
* first plan; do not deploy it unseen.
|
|
113
|
+
* ⛔ Adoption stays explicit: listing every backup job could grant Alchemy deletion rights over
|
|
114
|
+
* an undeclared schedule. Delete removes the schedule, not its stored archives; that still
|
|
115
|
+
* stops future backups, so the plan line is a consequential warning.
|
|
128
116
|
*/
|
|
129
|
-
const
|
|
130
|
-
all: props.all === true ? '1' : '0',
|
|
131
|
-
enabled: props.enabled === false ? '0' : '1',
|
|
132
|
-
mode: props.mode ?? 'snapshot',
|
|
133
|
-
'notification-mode': props['notification-mode'] ?? 'auto',
|
|
134
|
-
'repeat-missed': props['repeat-missed'] === true ? '1' : '0',
|
|
135
|
-
schedule: props.schedule,
|
|
136
|
-
...(props.comment === undefined ? {} : { comment: props.comment }),
|
|
137
|
-
...(props.exclude === undefined ? {} : { exclude: guestList(props.exclude) }),
|
|
138
|
-
...(props.fleecing === undefined ? {} : { fleecing: props.fleecing }),
|
|
139
|
-
...(props['notes-template'] === undefined ? {} : { 'notes-template': props['notes-template'] }),
|
|
140
|
-
...(props.pool === undefined ? {} : { pool: props.pool }),
|
|
141
|
-
...(props['prune-backups'] === undefined ? {} : { 'prune-backups': props['prune-backups'] }),
|
|
142
|
-
...(props.storage === undefined ? {} : { storage: props.storage }),
|
|
143
|
-
...(props.vmid === undefined ? {} : { vmid: guestList(props.vmid) }),
|
|
144
|
-
});
|
|
117
|
+
const { guardCreate, guardUpdate } = specGuards(backupJobSpec);
|
|
145
118
|
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* ⛔ NO "IS IT REALLY THERE" GUARD, ON PURPOSE. Returning undefined for a job whose JSON is
|
|
149
|
-
* missing some key would be a guess about which keys PVE echoes back, and a wrong guess here
|
|
150
|
-
* does not read as absent-and-harmless: `reconcile` would POST, and POST creates ANOTHER job.
|
|
151
|
-
* Absence is decided by the API declining to answer, which `pveOperations.read` already
|
|
152
|
-
* handles — not by a key being missing from an answer that did arrive.
|
|
153
|
-
*/
|
|
154
|
-
attributes: (live, props) => ({
|
|
155
|
-
all: bool(live['all'], false),
|
|
156
|
-
comment: text(live['comment'], ''),
|
|
157
|
-
enabled: bool(live['enabled'], true),
|
|
158
|
-
exclude: guestList(live['exclude']),
|
|
159
|
-
fleecing: propertyString(live['fleecing'], 'enabled'),
|
|
160
|
-
id: props.id,
|
|
161
|
-
mode: text(live['mode'], 'snapshot'),
|
|
162
|
-
'next-run': typeof live['next-run'] === 'number' ? live['next-run'] : 0,
|
|
163
|
-
'notes-template': text(live['notes-template'], ''),
|
|
164
|
-
'notification-mode': text(live['notification-mode'], 'auto'),
|
|
165
|
-
pool: text(live['pool'], ''),
|
|
166
|
-
'prune-backups': propertyString(live['prune-backups']),
|
|
167
|
-
'repeat-missed': bool(live['repeat-missed'], false),
|
|
168
|
-
schedule: text(live['schedule'], ''),
|
|
169
|
-
storage: text(live['storage'], ''),
|
|
170
|
-
vmid: guestList(live['vmid']),
|
|
171
|
-
}),
|
|
172
|
-
collection: () => 'cluster/backup',
|
|
173
|
-
/** ⛔ `id` IS SENT AND IS NOT OPTIONAL. Everything above depends on PVE not inventing one. */
|
|
174
|
-
createForm: (props) => ({ ...shape(props), id: props.id }),
|
|
175
|
-
/** Each line reads "not declared, or equal"; the five defaulted fields have no undeclared case. */
|
|
176
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
177
|
-
endpoint: { create: 'pve:POST /cluster/backup', update: 'pve:PUT /cluster/backup/{id}' },
|
|
178
|
-
matches: (attributes, props) =>
|
|
179
|
-
attributes.schedule === props.schedule &&
|
|
180
|
-
attributes.enabled === (props.enabled !== false) &&
|
|
181
|
-
attributes.all === (props.all === true) &&
|
|
182
|
-
attributes.mode === (props.mode ?? 'snapshot') &&
|
|
183
|
-
attributes['notification-mode'] === (props['notification-mode'] ?? 'auto') &&
|
|
184
|
-
attributes['repeat-missed'] === (props['repeat-missed'] === true) &&
|
|
185
|
-
(props.storage === undefined || attributes.storage === props.storage) &&
|
|
186
|
-
(props.pool === undefined || attributes.pool === props.pool) &&
|
|
187
|
-
(props.comment === undefined || attributes.comment === props.comment) &&
|
|
188
|
-
(props.vmid === undefined || attributes.vmid === guestList(props.vmid)) &&
|
|
189
|
-
(props.exclude === undefined || attributes.exclude === guestList(props.exclude)) &&
|
|
190
|
-
(props['notes-template'] === undefined ||
|
|
191
|
-
attributes['notes-template'] === props['notes-template']) &&
|
|
192
|
-
(props['prune-backups'] === undefined ||
|
|
193
|
-
attributes['prune-backups'] === propertyString(props['prune-backups'])) &&
|
|
194
|
-
(props.fleecing === undefined ||
|
|
195
|
-
attributes.fleecing === propertyString(props.fleecing, 'enabled')),
|
|
196
|
-
path: (props) => `cluster/backup/${props.id}`,
|
|
197
|
-
updateForm: shape,
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* ⛔ Empty for the same reason as every other resource here: adoption must be explicit.
|
|
202
|
-
* `GET /cluster/backup` would hand back the job somebody made by hand, and adopting it
|
|
203
|
-
* implicitly would give Alchemy the right to DELETE a cluster's only backup schedule the
|
|
204
|
-
* first time a line left a stack file.
|
|
205
|
-
|
|
206
|
-
*
|
|
207
|
-
* ⚠️ DELETING THE JOB DOES NOT DELETE THE BACKUPS, AND THAT CUTS BOTH WAYS. The archives
|
|
208
|
-
* stay on the storage, so this looks harmless today; it is destructive in six months,
|
|
209
|
-
* when the schedule that should have replaced them has not run since. PVE will not
|
|
210
|
-
* refuse this the way it refuses to delete a pool still holding guests — nothing depends
|
|
211
|
-
* on a backup job — so the plan line is the only warning anyone gets.
|
|
212
|
-
|
|
213
|
-
*/
|
|
119
|
+
/** SDK lifecycle walked against pve-manager 9.2.11; failed reads never imply create. */
|
|
214
120
|
export const ProxmoxBackupJobProvider = () =>
|
|
215
|
-
Provider.effect(
|
|
121
|
+
Provider.effect(
|
|
122
|
+
ProxmoxBackupJob,
|
|
123
|
+
Effect.succeed(
|
|
124
|
+
ProxmoxBackupJob.Provider.of({
|
|
125
|
+
list: () => Effect.succeed([]),
|
|
126
|
+
read: ({ olds }) => readBackupJob(olds),
|
|
127
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
128
|
+
if (!isResolved(news)) return undefined;
|
|
129
|
+
yield* guardCreate(news, output === undefined);
|
|
130
|
+
yield* guardUpdate(news);
|
|
131
|
+
if (output === undefined) return undefined;
|
|
132
|
+
const live = yield* readBackupJob(news);
|
|
133
|
+
if (live === undefined) {
|
|
134
|
+
yield* guardCreate(news, true);
|
|
135
|
+
return { action: 'update' } as const;
|
|
136
|
+
}
|
|
137
|
+
return { action: backupJobSpec.matches(live, news) ? 'noop' : 'update' } as const;
|
|
138
|
+
}),
|
|
139
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
140
|
+
const live = yield* readBackupJob(news);
|
|
141
|
+
yield* guardCreate(news, live === undefined);
|
|
142
|
+
yield* guardUpdate(news);
|
|
143
|
+
if (live === undefined) {
|
|
144
|
+
yield* runPve(
|
|
145
|
+
news.target,
|
|
146
|
+
'provision',
|
|
147
|
+
true,
|
|
148
|
+
cluster.createClusterBackup(backupJobRequest(news)),
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
const form = formToSend(
|
|
152
|
+
backupJobSpec.matches,
|
|
153
|
+
live,
|
|
154
|
+
news,
|
|
155
|
+
backupJobSpec.updateForm(news),
|
|
156
|
+
);
|
|
157
|
+
if (form !== undefined)
|
|
158
|
+
yield* runPve(
|
|
159
|
+
news.target,
|
|
160
|
+
'provision',
|
|
161
|
+
true,
|
|
162
|
+
cluster.putClusterBackup(backupJobRequest(news, form)),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const after = yield* readBackupJob(news);
|
|
166
|
+
if (after === undefined)
|
|
167
|
+
return yield* Effect.fail(
|
|
168
|
+
new Error(
|
|
169
|
+
`${backupJobSpec.path(news)}: write returned success but the resource is still absent`,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
return after;
|
|
173
|
+
}),
|
|
174
|
+
delete: ({ olds }) => deleteBackupJob(olds),
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
);
|
|
@@ -18,7 +18,8 @@ import { engineOver } from '../verify/fake-engine.ts';
|
|
|
18
18
|
import { constraintsFor, formViolations } from './constraint-guard.ts';
|
|
19
19
|
import { type EndpointConstraints, refusal, violations } from './constraints.ts';
|
|
20
20
|
import type { PbsTarget } from './credentials.ts';
|
|
21
|
-
import {
|
|
21
|
+
import { withoutBao } from './fake-pve.ts';
|
|
22
|
+
import { fakePbsJobs } from './fake-pbs-jobs.ts';
|
|
22
23
|
import { PROXMOX_CONSTRAINTS, PROXMOX_CONSTRAINTS_DIGEST } from './generated/constraints/index.ts';
|
|
23
24
|
import { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
|
|
24
25
|
|
|
@@ -46,12 +47,8 @@ describe('the v-r2-offsite failure, refused at plan instead of by the server', (
|
|
|
46
47
|
* skip entirely (`output === undefined` returned before any check). It remembers the POST so
|
|
47
48
|
* the read-back in `reconcile` can succeed, which is what the 128-character case needs.
|
|
48
49
|
*/
|
|
49
|
-
|
|
50
|
-
const fake =
|
|
51
|
-
if (call.method === 'GET') return live.get(call.path);
|
|
52
|
-
if (call.method === 'POST') live.set(`config/verify/${String(call.form['id'])}`, call.form);
|
|
53
|
-
return undefined;
|
|
54
|
-
});
|
|
50
|
+
// Missing PBS sections are measured plain-text HTTP400, not HTTP200 data:null.
|
|
51
|
+
const fake = fakePbsJobs();
|
|
55
52
|
const engine = engineOver(PbsVerifyJobProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
56
53
|
const outcome = await withoutBao(() =>
|
|
57
54
|
engine.deploy(job(comment)).then(
|
|
@@ -59,7 +56,11 @@ describe('the v-r2-offsite failure, refused at plan instead of by the server', (
|
|
|
59
56
|
(error: unknown) => ({ error }),
|
|
60
57
|
),
|
|
61
58
|
);
|
|
62
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
...outcome,
|
|
61
|
+
calls: fake.calls,
|
|
62
|
+
writes: fake.writes.map((row) => `${row.method} ${row.path}`),
|
|
63
|
+
};
|
|
63
64
|
};
|
|
64
65
|
|
|
65
66
|
test('129 characters is refused, and NOTHING is written', async () => {
|
|
@@ -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 });
|