@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,114 @@
|
|
|
1
|
+
/** PBS 4.2.6-1 datastore lifecycle. Reads and writes use the distilled SDK. */
|
|
2
|
+
import { isResolved } from 'alchemy/Diff';
|
|
3
|
+
import type { Input } from 'alchemy/Input';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import type { PbsDatastoreProps } from './pbs-datastore.ts';
|
|
6
|
+
import { type PbsDatastoreAttributes, matches, object, updateForm } from './pbs-datastore-form.ts';
|
|
7
|
+
import { createOne, deleteOne, readOne, updateOne } from './pbs-datastore-wire.ts';
|
|
8
|
+
import { guardBackend, guardPath, settle } from './pbs-datastore-guard.ts';
|
|
9
|
+
import { guardDatastoreForms } from './pbs-datastore-endpoint.ts';
|
|
10
|
+
import { formToSend } from './update-guard.ts';
|
|
11
|
+
|
|
12
|
+
export const handlers = {
|
|
13
|
+
/**
|
|
14
|
+
* ⛔ EMPTY, AND HERE IT GUARDS THE ESTATE'S BACKUPS. `GET /config/datastore` returns every
|
|
15
|
+
* datastore on the host, the one PVE's `type: pbs` storage writes into included. See
|
|
16
|
+
* `pveHandlers` in resource.ts for why adoption stays an explicit act.
|
|
17
|
+
*/
|
|
18
|
+
list: () => Effect.succeed([]),
|
|
19
|
+
read: ({ olds }: { olds: PbsDatastoreProps }) => readOne(olds),
|
|
20
|
+
/**
|
|
21
|
+
* ⚠️ `isResolved` IS THE NARROWING, resource.ts's reason: at plan time a prop can still be an
|
|
22
|
+
* unresolved Output, and comparing a placeholder to a live value reports a phantom update.
|
|
23
|
+
* ⛔ `guardPath` IS A DELIBERATE DEPARTURE FROM storage.ts, which lets its create-only field
|
|
24
|
+
* plan as noop. Dying fails `alchemy plan` with both paths named — see the ⛔ on the prop.
|
|
25
|
+
*/
|
|
26
|
+
diff: ({
|
|
27
|
+
news,
|
|
28
|
+
output,
|
|
29
|
+
}: {
|
|
30
|
+
news: Input<PbsDatastoreProps>;
|
|
31
|
+
output: PbsDatastoreAttributes | undefined;
|
|
32
|
+
}) =>
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
if (!isResolved(news)) return undefined;
|
|
35
|
+
// ⛔ BEFORE the `output === undefined` return: that branch IS the create, which is the
|
|
36
|
+
// one this family has never checked. resource.ts carries the argument in full.
|
|
37
|
+
yield* guardDatastoreForms(news);
|
|
38
|
+
if (output === undefined) return undefined;
|
|
39
|
+
const live = yield* readOne(news);
|
|
40
|
+
// ⚠️ `update`, not `create` — Alchemy's Diff admits only noop/update/replace, and an
|
|
41
|
+
// object Alchemy has state for but PBS does not is drift for reconcile to repair.
|
|
42
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
43
|
+
yield* guardPath(live, news);
|
|
44
|
+
yield* guardBackend(live, news);
|
|
45
|
+
return matches(live, news) ? ({ action: 'noop' } as const) : ({ action: 'update' } as const);
|
|
46
|
+
}),
|
|
47
|
+
/**
|
|
48
|
+
* ⚠️ NOT THE FACTORY'S reconcile: it reads back ONCE, immediately — see the ⛔ on `settle`.
|
|
49
|
+
* ⛔ `guardPath` RUNS HERE TOO, AND THAT IS NOT BELT-AND-BRACES — see the ⛔ on it.
|
|
50
|
+
* ⛔ THE `matches` GUARD BEFORE THE PUT IS resource.ts's, AND IT IS WHY ADOPTION IS FREE.
|
|
51
|
+
*/
|
|
52
|
+
reconcile: Effect.fn(function* ({ news }: { news: PbsDatastoreProps }) {
|
|
53
|
+
// ⚠️ Again here: an ADOPTED row's diff answer is discarded by Alchemy and reconcile runs.
|
|
54
|
+
yield* guardDatastoreForms(news);
|
|
55
|
+
const live = yield* readOne(news);
|
|
56
|
+
if (live === undefined) {
|
|
57
|
+
yield* createOne(news);
|
|
58
|
+
} else {
|
|
59
|
+
yield* guardPath(live, news);
|
|
60
|
+
yield* guardBackend(live, news);
|
|
61
|
+
// ⚠️ An empty form is not a write, and a match is not one either — update-guard.ts.
|
|
62
|
+
const form = formToSend(matches, live, news, updateForm(news));
|
|
63
|
+
if (form !== undefined) {
|
|
64
|
+
yield* updateOne(news);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const after = yield* settle(readOne(news), (row) => row !== undefined && matches(row, news));
|
|
68
|
+
if (after === undefined || !matches(after, news)) {
|
|
69
|
+
/**
|
|
70
|
+
* ⛔ REFUSE RATHER THAN RETURN THE PROPS AS THOUGH THEY LANDED — resource.ts's rule.
|
|
71
|
+
* The pinned PBS 4.2.6-1 schema gives create a null return, so readback is its evidence.
|
|
72
|
+
*/
|
|
73
|
+
return yield* Effect.die(
|
|
74
|
+
new Error(
|
|
75
|
+
`${object(news)}: the write returned no error but the datastore still does not ` +
|
|
76
|
+
'match the declaration after 120s. Check the PBS task list ' +
|
|
77
|
+
'-- read it with `proxmox-backup-manager task log <upid>`. A slow disk is a ' +
|
|
78
|
+
'plausible cause (see `settle`); a create over an existing chunk store is not, ' +
|
|
79
|
+
'because PBS refuses that outright rather than timing out.',
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return after;
|
|
84
|
+
}),
|
|
85
|
+
/**
|
|
86
|
+
* ⛔ FULLY IMPLEMENTED, NEVER A STUB, and it only ever runs on an explicit
|
|
87
|
+
* `.pipe(RemovalPolicy.destroy())` because this family defaults to `retain`.
|
|
88
|
+
* ⛔ NO `destroy-data`. PBS's DELETE drops the section from `datastore.cfg` and leaves every
|
|
89
|
+
* chunk and snapshot on disk; `destroy-data=1` erases the contents. It is not a prop and
|
|
90
|
+
* not a flag here, so this provider can un-declare a datastore and can never erase one.
|
|
91
|
+
* ⚠️ IT IS STILL A ONE-WAY DOOR FOR A DECLARATION: re-adding a datastore over an existing
|
|
92
|
+
* chunk store needs `reuse-datastore`, which is deliberately not declarable — see the
|
|
93
|
+
* ⚠️ on `createForm`. Re-adopt with `proxmox-backup-manager datastore create …`.
|
|
94
|
+
* ⚠️ NO `keep-job-configs` EITHER, SO THE JOBS GO TOO: PBS defaults it false and removes the
|
|
95
|
+
* verify, sync and prune jobs referencing this datastore — objects nothing here declares.
|
|
96
|
+
* PBS's default stands, so the blast radius is what a human clicking Remove would get.
|
|
97
|
+
* ⚠️ AND THE REFUSAL CAN ARRIVE AFTER THE RESPONSE: the delete forks a worker too, so a store
|
|
98
|
+
* held open by a running backup can fail once the call has already returned 200.
|
|
99
|
+
*/
|
|
100
|
+
delete: Effect.fn(function* ({ olds }: { olds: PbsDatastoreProps }) {
|
|
101
|
+
yield* deleteOne(olds);
|
|
102
|
+
const left = yield* settle(readOne(olds), (row) => row === undefined);
|
|
103
|
+
if (left !== undefined) {
|
|
104
|
+
return yield* Effect.die(
|
|
105
|
+
new Error(
|
|
106
|
+
`${object(olds)}: the DELETE returned no error but the datastore is still in ` +
|
|
107
|
+
'datastore.cfg after 120s. Either a backup, verify or GC task still holds it, ' +
|
|
108
|
+
'or the provision role lacks Datastore.Allocate on /datastore/' +
|
|
109
|
+
`${olds.name}. Read the task log with \`proxmox-backup-manager task log <upid>\`.`,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** PBS 4.2.6-1 datastore SDK operations; no raw route-string transport. */
|
|
2
|
+
import * as config from '@distilled.cloud/proxmox-backup/config';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import { runPbs } from './distilled-pbs.ts';
|
|
5
|
+
import { attributes, createForm, updateForm } from './pbs-datastore-form.ts';
|
|
6
|
+
import type { PbsDatastoreProps } from './pbs-datastore.ts';
|
|
7
|
+
|
|
8
|
+
/** Keep the existing form's unmanaged-field decisions while using typed SDK member names. */
|
|
9
|
+
const members = (form: Record<string, string>) => ({
|
|
10
|
+
...(form['comment'] === undefined ? {} : { comment: form['comment'] }),
|
|
11
|
+
...(form['counter-reset-schedule'] === undefined
|
|
12
|
+
? {}
|
|
13
|
+
: { counter_reset_schedule: form['counter-reset-schedule'] }),
|
|
14
|
+
...(form['gc-schedule'] === undefined ? {} : { gc_schedule: form['gc-schedule'] }),
|
|
15
|
+
...(form['keep-daily'] === undefined ? {} : { keep_daily: form['keep-daily'] }),
|
|
16
|
+
...(form['keep-hourly'] === undefined ? {} : { keep_hourly: form['keep-hourly'] }),
|
|
17
|
+
...(form['keep-last'] === undefined ? {} : { keep_last: form['keep-last'] }),
|
|
18
|
+
...(form['keep-monthly'] === undefined ? {} : { keep_monthly: form['keep-monthly'] }),
|
|
19
|
+
...(form['keep-weekly'] === undefined ? {} : { keep_weekly: form['keep-weekly'] }),
|
|
20
|
+
...(form['keep-yearly'] === undefined ? {} : { keep_yearly: form['keep-yearly'] }),
|
|
21
|
+
...(form['maintenance-mode'] === undefined ? {} : { maintenance_mode: form['maintenance-mode'] }),
|
|
22
|
+
...(form['notification-thresholds'] === undefined
|
|
23
|
+
? {}
|
|
24
|
+
: { notification_thresholds: form['notification-thresholds'] }),
|
|
25
|
+
...(form['notify'] === undefined ? {} : { notify: form['notify'] }),
|
|
26
|
+
...(form['notify-user'] === undefined ? {} : { notify_user: form['notify-user'] }),
|
|
27
|
+
...(form['prune-schedule'] === undefined ? {} : { prune_schedule: form['prune-schedule'] }),
|
|
28
|
+
...(form['tuning'] === undefined ? {} : { tuning: form['tuning'] }),
|
|
29
|
+
...(form['verify-new'] === undefined ? {} : { verify_new: form['verify-new'] }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const createRequest = (props: PbsDatastoreProps): config.CreateConfigDatastoreRequest => ({
|
|
33
|
+
...members(createForm(props)),
|
|
34
|
+
name: props.name,
|
|
35
|
+
path: props.path,
|
|
36
|
+
...(props.backend === undefined ? {} : { backend: props.backend }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const updateRequest = (props: PbsDatastoreProps): config.PutConfigDatastoreRequest => ({
|
|
40
|
+
...members(updateForm(props)),
|
|
41
|
+
name: props.name,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const wire = (row: config.GetConfigDatastoreResponse): Record<string, unknown> => ({
|
|
45
|
+
...row,
|
|
46
|
+
'counter-reset-schedule': row.counter_reset_schedule,
|
|
47
|
+
'gc-schedule': row.gc_schedule,
|
|
48
|
+
'keep-daily': row.keep_daily,
|
|
49
|
+
'keep-hourly': row.keep_hourly,
|
|
50
|
+
'keep-last': row.keep_last,
|
|
51
|
+
'keep-monthly': row.keep_monthly,
|
|
52
|
+
'keep-weekly': row.keep_weekly,
|
|
53
|
+
'keep-yearly': row.keep_yearly,
|
|
54
|
+
'maintenance-mode': row.maintenance_mode,
|
|
55
|
+
'notification-thresholds': row.notification_thresholds,
|
|
56
|
+
'notify-user': row.notify_user,
|
|
57
|
+
'prune-schedule': row.prune_schedule,
|
|
58
|
+
'verify-new': row.verify_new,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* ⛔ THE RAW READ FOLDED EVERY ERROR TO ABSENCE. Its `name` presence check could not distinguish
|
|
63
|
+
* a missing section from a denied read: both suggested CREATE for a datastore holding backups.
|
|
64
|
+
* The resulting "already exists" failure was loud, but the same fold on DELETE was worse:
|
|
65
|
+
* the first denied read confirmed removal while the datastore still stood.
|
|
66
|
+
* ★ MEASURED 2026-09-24 against PBS 4.2.6-1: a missing section is plain-text HTTP400,
|
|
67
|
+
* `no such datastore '<name>'`. The distilled patch types that GET response as
|
|
68
|
+
* DatastoreNotFound. Only that tag means absent; no permission, transport or malformed-response
|
|
69
|
+
* error can settle deletion. This replaces the old recommendation to disambiguate by listing.
|
|
70
|
+
* ⚠️ The read role still requires Datastore.Audit, and a missing grant now fails visibly.
|
|
71
|
+
*/
|
|
72
|
+
export const readOne = (props: PbsDatastoreProps) =>
|
|
73
|
+
runPbs(props.target, 'read', config.getConfigDatastore({ name: props.name })).pipe(
|
|
74
|
+
Effect.map((live) => attributes(wire(live), props)),
|
|
75
|
+
Effect.catchTag('DatastoreNotFound', () => Effect.succeed(undefined)),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export const createOne = (props: PbsDatastoreProps) =>
|
|
79
|
+
runPbs(props.target, 'provision', config.createConfigDatastore(createRequest(props)));
|
|
80
|
+
|
|
81
|
+
export const updateOne = (props: PbsDatastoreProps) =>
|
|
82
|
+
runPbs(props.target, 'provision', config.putConfigDatastore(updateRequest(props)));
|
|
83
|
+
|
|
84
|
+
/** ⛔ No destroy-data or keep-job-configs flag: retain the original removal contract. */
|
|
85
|
+
export const deleteOne = (props: PbsDatastoreProps) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
// ⛔ Missing-item tags are measured for GET only. Do not guess the DELETE error shape.
|
|
88
|
+
if ((yield* readOne(props)) === undefined) return;
|
|
89
|
+
return yield* runPbs(
|
|
90
|
+
props.target,
|
|
91
|
+
'provision',
|
|
92
|
+
config.deleteConfigDatastore({ name: props.name }),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
@@ -13,12 +13,11 @@
|
|
|
13
13
|
* `/access/domains` lists the realms `pbs`, `pam` and an openid realm. So the
|
|
14
14
|
* prefix, the `{"data": …}` envelope and the strict-TLS story are PVE's exactly — and only the
|
|
15
15
|
* credential differs. There is no `pve` realm here; a PVE token cannot authenticate at all.
|
|
16
|
-
* The
|
|
17
|
-
* ⛔ on `pbsAuthorization` in pbs-datastore-form.ts.
|
|
16
|
+
* The distilled PBS SDK owns that distinct header; `distilled-pbs.ts` supplies its credentials.
|
|
18
17
|
*
|
|
19
|
-
*
|
|
18
|
+
* ⚠️ HISTORICAL BOOTSTRAP BLOCKER, 2026-09-13: THERE WAS NO PBS MOUNT. `PbsTarget.mount` is
|
|
20
19
|
* a parameter for the reason `PveTarget.mount` is — the provider must be able to leave this
|
|
21
|
-
* estate
|
|
20
|
+
* estate. A later PBS credential lane resolved the following incident. MEASURED: under the agent's approle,
|
|
22
21
|
* `bao token capabilities` answers `deny` for `proxmox-c1/creds/{read,provision}`,
|
|
23
22
|
* `proxmox-c2/creds/read` and every `…pbs…` path tried, and `sys/mounts` is 403 — so I could
|
|
24
23
|
* not enumerate the mounts to PROVE a PBS one is absent. That it is absent is the brief's
|
|
@@ -27,7 +26,7 @@
|
|
|
27
26
|
* <mount>/creds/<role>` answering `{"data":{"token_id":…,"secret":…}}`, where `token_id` is a
|
|
28
27
|
* PBS token id `user@realm!tokenname` — `hf-read@pbs!…` and `hf-provision@pbs!…`. Same short,
|
|
29
28
|
* non-renewable lease contract as `proxmox-c1`.
|
|
30
|
-
* ⚠️ PBS
|
|
29
|
+
* ⚠️ PBS HAD NO DYNAMIC-SECRETS PLUGIN OF ITS OWN — OpenBao's Proxmox support is a PVE thing — so
|
|
31
30
|
* the mount is a small custom vendor against `POST /access/users/{id}/token/{name}`. Say which
|
|
32
31
|
* mount and role you needed rather than reaching for the read-only `metrics@pbs` token on
|
|
33
32
|
* the kv shelf: widening that would delete the outer lock for every reader of the shelf.
|
|
@@ -50,23 +49,11 @@
|
|
|
50
49
|
* a state store Alchemy writes UNENCRYPTED and this estate copies to a backup guest nightly.
|
|
51
50
|
*/
|
|
52
51
|
import { Resource } from 'alchemy';
|
|
53
|
-
import { isResolved } from 'alchemy/Diff';
|
|
54
52
|
import * as Provider from 'alchemy/Provider';
|
|
55
53
|
import * as Effect from 'effect/Effect';
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
type PbsDatastoreAttributes,
|
|
59
|
-
type PbsTarget,
|
|
60
|
-
createForm,
|
|
61
|
-
matches,
|
|
62
|
-
object,
|
|
63
|
-
readOne,
|
|
64
|
-
updateForm,
|
|
65
|
-
} from './pbs-datastore-form.ts';
|
|
66
|
-
import { guardBackend, guardPath, settle } from './pbs-datastore-guard.ts';
|
|
67
|
-
import { guardDatastoreForms } from './pbs-datastore-endpoint.ts';
|
|
54
|
+
import type { PbsDatastoreAttributes, PbsTarget } from './pbs-datastore-form.ts';
|
|
55
|
+
import { handlers } from './pbs-datastore-lifecycle.ts';
|
|
68
56
|
import type { PveRequirements } from './resource.ts';
|
|
69
|
-
import { formToSend } from './update-guard.ts';
|
|
70
57
|
|
|
71
58
|
export type { PbsDatastoreAttributes, PbsTarget };
|
|
72
59
|
|
|
@@ -163,117 +150,4 @@ export const PbsDatastore = Resource<PbsDatastore>('Pbs.Datastore', {
|
|
|
163
150
|
});
|
|
164
151
|
|
|
165
152
|
export const PbsDatastoreProvider = () =>
|
|
166
|
-
Provider.effect(
|
|
167
|
-
PbsDatastore,
|
|
168
|
-
Effect.succeed(
|
|
169
|
-
PbsDatastore.Provider.of({
|
|
170
|
-
/**
|
|
171
|
-
* ⛔ EMPTY, AND HERE IT GUARDS THE ESTATE'S BACKUPS. `GET /config/datastore` returns every
|
|
172
|
-
* datastore on the host, the one PVE's `type: pbs` storage writes into included. See
|
|
173
|
-
* `pveHandlers` in resource.ts for why adoption stays an explicit act.
|
|
174
|
-
*/
|
|
175
|
-
list: () => Effect.succeed([]),
|
|
176
|
-
read: ({ olds }) => readOne(olds),
|
|
177
|
-
/**
|
|
178
|
-
* ⚠️ `isResolved` IS THE NARROWING, resource.ts's reason: at plan time a prop can still be an
|
|
179
|
-
* unresolved Output, and comparing a placeholder to a live value reports a phantom update.
|
|
180
|
-
* ⛔ `guardPath` IS A DELIBERATE DEPARTURE FROM storage.ts, which lets its create-only field
|
|
181
|
-
* plan as noop. Dying fails `alchemy plan` with both paths named — see the ⛔ on the prop.
|
|
182
|
-
*/
|
|
183
|
-
diff: ({ news, output }) =>
|
|
184
|
-
Effect.gen(function* () {
|
|
185
|
-
if (!isResolved(news)) return undefined;
|
|
186
|
-
// ⛔ BEFORE the `output === undefined` return: that branch IS the create, which is the
|
|
187
|
-
// one this family has never checked. resource.ts carries the argument in full.
|
|
188
|
-
yield* guardDatastoreForms(news);
|
|
189
|
-
if (output === undefined) return undefined;
|
|
190
|
-
const live = yield* readOne(news);
|
|
191
|
-
// ⚠️ `update`, not `create` — Alchemy's Diff admits only noop/update/replace, and an
|
|
192
|
-
// object Alchemy has state for but PBS does not is drift for reconcile to repair.
|
|
193
|
-
if (live === undefined) return { action: 'update' } as const;
|
|
194
|
-
yield* guardPath(live, news);
|
|
195
|
-
yield* guardBackend(live, news);
|
|
196
|
-
return matches(live, news)
|
|
197
|
-
? ({ action: 'noop' } as const)
|
|
198
|
-
: ({ action: 'update' } as const);
|
|
199
|
-
}),
|
|
200
|
-
/**
|
|
201
|
-
* ⚠️ NOT THE FACTORY'S reconcile: it reads back ONCE, immediately — see the ⛔ on `settle`.
|
|
202
|
-
* ⛔ `guardPath` RUNS HERE TOO, AND THAT IS NOT BELT-AND-BRACES — see the ⛔ on it.
|
|
203
|
-
* ⛔ THE `matches` GUARD BEFORE THE PUT IS resource.ts's, AND IT IS WHY ADOPTION IS FREE.
|
|
204
|
-
*/
|
|
205
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
206
|
-
// ⚠️ Again here: an ADOPTED row's diff answer is discarded by Alchemy and reconcile runs.
|
|
207
|
-
yield* guardDatastoreForms(news);
|
|
208
|
-
const live = yield* readOne(news);
|
|
209
|
-
let upid: string | undefined;
|
|
210
|
-
if (live === undefined) {
|
|
211
|
-
upid = yield* pve<string>(
|
|
212
|
-
news.target,
|
|
213
|
-
'provision',
|
|
214
|
-
'POST',
|
|
215
|
-
'config/datastore',
|
|
216
|
-
createForm(news),
|
|
217
|
-
);
|
|
218
|
-
} else {
|
|
219
|
-
yield* guardPath(live, news);
|
|
220
|
-
yield* guardBackend(live, news);
|
|
221
|
-
// ⚠️ An empty form is not a write, and a match is not one either — update-guard.ts.
|
|
222
|
-
const form = formToSend(matches, live, news, updateForm(news));
|
|
223
|
-
if (form !== undefined) {
|
|
224
|
-
yield* pve(news.target, 'provision', 'PUT', object(news), form);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
const after = yield* settle(
|
|
228
|
-
readOne(news),
|
|
229
|
-
(row) => row !== undefined && matches(row, news),
|
|
230
|
-
);
|
|
231
|
-
if (after === undefined || !matches(after, news)) {
|
|
232
|
-
/**
|
|
233
|
-
* ⛔ REFUSE RATHER THAN RETURN THE PROPS AS THOUGH THEY LANDED — resource.ts's rule.
|
|
234
|
-
* A create answers with a task id, so "no error" is not evidence of a datastore.
|
|
235
|
-
*/
|
|
236
|
-
return yield* Effect.die(
|
|
237
|
-
new Error(
|
|
238
|
-
`${object(news)}: the write returned no error but the datastore still does not ` +
|
|
239
|
-
`match the declaration after 120s. PBS answered with task ${upid ?? '(none)'} ` +
|
|
240
|
-
'-- read it with `proxmox-backup-manager task log <upid>`. A slow disk is a ' +
|
|
241
|
-
'plausible cause (see `settle`); a create over an existing chunk store is not, ' +
|
|
242
|
-
'because PBS refuses that outright rather than timing out.',
|
|
243
|
-
),
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
return after;
|
|
247
|
-
}),
|
|
248
|
-
/**
|
|
249
|
-
* ⛔ FULLY IMPLEMENTED, NEVER A STUB, and it only ever runs on an explicit
|
|
250
|
-
* `.pipe(RemovalPolicy.destroy())` because this family defaults to `retain`.
|
|
251
|
-
* ⛔ NO `destroy-data`. PBS's DELETE drops the section from `datastore.cfg` and leaves every
|
|
252
|
-
* chunk and snapshot on disk; `destroy-data=1` erases the contents. It is not a prop and
|
|
253
|
-
* not a flag here, so this provider can un-declare a datastore and can never erase one.
|
|
254
|
-
* ⚠️ IT IS STILL A ONE-WAY DOOR FOR A DECLARATION: re-adding a datastore over an existing
|
|
255
|
-
* chunk store needs `reuse-datastore`, which is deliberately not declarable — see the
|
|
256
|
-
* ⚠️ on `createForm`. Re-adopt with `proxmox-backup-manager datastore create …`.
|
|
257
|
-
* ⚠️ NO `keep-job-configs` EITHER, SO THE JOBS GO TOO: PBS defaults it false and removes the
|
|
258
|
-
* verify, sync and prune jobs referencing this datastore — objects nothing here declares.
|
|
259
|
-
* PBS's default stands, so the blast radius is what a human clicking Remove would get.
|
|
260
|
-
* ⚠️ AND THE REFUSAL CAN ARRIVE AFTER THE RESPONSE: the delete forks a worker too, so a store
|
|
261
|
-
* held open by a running backup can fail once the call has already returned 200.
|
|
262
|
-
*/
|
|
263
|
-
delete: Effect.fn(function* ({ olds }) {
|
|
264
|
-
yield* pve<string>(olds.target, 'provision', 'DELETE', object(olds));
|
|
265
|
-
const left = yield* settle(readOne(olds), (row) => row === undefined);
|
|
266
|
-
if (left !== undefined) {
|
|
267
|
-
return yield* Effect.die(
|
|
268
|
-
new Error(
|
|
269
|
-
`${object(olds)}: the DELETE returned no error but the datastore is still in ` +
|
|
270
|
-
'datastore.cfg after 120s. Either a backup, verify or GC task still holds it, ' +
|
|
271
|
-
'or the provision role lacks Datastore.Allocate on /datastore/' +
|
|
272
|
-
`${olds.name}. Read the task log with \`proxmox-backup-manager task log <upid>\`.`,
|
|
273
|
-
),
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
}),
|
|
277
|
-
}),
|
|
278
|
-
),
|
|
279
|
-
);
|
|
153
|
+
Provider.effect(PbsDatastore, Effect.succeed(PbsDatastore.Provider.of(handlers)));
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** A readable PBS section does not make a refused DELETE successful. No live calls. */
|
|
2
|
+
import { expect, test } from 'bun:test';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import { withFakeBao } from './fake-bao-env.ts';
|
|
5
|
+
import { PBS_JOBS_TARGET, fakePbsJobs } from './fake-pbs-jobs.ts';
|
|
6
|
+
import { deleteOne as deleteDatastore } from './pbs-datastore-wire.ts';
|
|
7
|
+
import { deleteOne as deletePrune } from './pbs-prune-job-wire.ts';
|
|
8
|
+
import { deleteOne as deleteSync } from './pbs-sync-job-wire.ts';
|
|
9
|
+
import { deleteOne as deleteVerify } from './pbs-verify-job-wire.ts';
|
|
10
|
+
|
|
11
|
+
test('all four PBS DELETE paths propagate a typed refusal after a successful pre-read', async () => {
|
|
12
|
+
const fake = fakePbsJobs();
|
|
13
|
+
const target = PBS_JOBS_TARGET;
|
|
14
|
+
fake.rows.set('config/datastore/store', { name: 'store', path: '/mnt/store' });
|
|
15
|
+
fake.rows.set('config/prune/prune', { id: 'prune', store: 'store', schedule: 'daily' });
|
|
16
|
+
fake.rows.set('config/sync/sync', { id: 'sync', store: 'store', 'remote-store': 'other' });
|
|
17
|
+
fake.rows.set('config/verify/verify', { id: 'verify', store: 'store' });
|
|
18
|
+
// ★ A permission refusal exercises propagation without inventing an absent-DELETE shape.
|
|
19
|
+
fake.failDelete(403, { message: 'permission denied' });
|
|
20
|
+
const deletions = [
|
|
21
|
+
deleteDatastore({ name: 'store', path: '/mnt/store', target }).pipe(Effect.asVoid),
|
|
22
|
+
deletePrune({ id: 'prune', store: 'store', schedule: 'daily', target }).pipe(Effect.asVoid),
|
|
23
|
+
deleteSync({ id: 'sync', store: 'store', 'remote-store': 'other', target }).pipe(Effect.asVoid),
|
|
24
|
+
deleteVerify({ id: 'verify', store: 'store', schedule: null, target }).pipe(Effect.asVoid),
|
|
25
|
+
];
|
|
26
|
+
await withFakeBao('http://bao.invalid', async () => {
|
|
27
|
+
for (const deletion of deletions) {
|
|
28
|
+
const error = await Effect.runPromise(deletion.pipe(Effect.flip, Effect.provide(fake.layer)));
|
|
29
|
+
expect(error).toMatchObject({ _tag: 'Forbidden' });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
expect(fake.calls.filter((call) => call.method === 'GET')).toHaveLength(4);
|
|
33
|
+
expect(fake.writes.map((write) => write.method)).toEqual([
|
|
34
|
+
'DELETE',
|
|
35
|
+
'DELETE',
|
|
36
|
+
'DELETE',
|
|
37
|
+
'DELETE',
|
|
38
|
+
]);
|
|
39
|
+
expect(fake.rows.size).toBe(4);
|
|
40
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/** Four PBS families through the real SDK and Alchemy's upstream test harness. */
|
|
2
|
+
import { afterEach, beforeEach, expect } from 'bun:test';
|
|
3
|
+
import * as Test from 'alchemy/Test/Bun';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import * as Layer from 'effect/Layer';
|
|
6
|
+
import { PBS_JOBS_TARGET, fakePbsJobs } from './fake-pbs-jobs.ts';
|
|
7
|
+
import { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
|
|
8
|
+
import { deleteOne as deleteDatastore } from './pbs-datastore-wire.ts';
|
|
9
|
+
import { PbsPruneJob, PbsPruneJobProvider } from './pbs-prune-job.ts';
|
|
10
|
+
import { PbsSyncJob, PbsSyncJobProvider } from './pbs-sync-job.ts';
|
|
11
|
+
import { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
|
|
12
|
+
|
|
13
|
+
const fake = fakePbsJobs();
|
|
14
|
+
// ⛔ Test harness hooks take Effects, so isolate the shell before its per-test runtime starts.
|
|
15
|
+
let saved: [string, string | undefined][] = [];
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
saved = Object.entries(process.env).filter(([key]) => /^(BAO|VAULT)_/.test(key));
|
|
18
|
+
for (const [key] of saved) delete process.env[key];
|
|
19
|
+
process.env['BAO_ADDR'] = 'http://bao.invalid';
|
|
20
|
+
fake.reset();
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
for (const key of Object.keys(process.env))
|
|
24
|
+
if (/^(BAO|VAULT)_/.test(key)) delete process.env[key];
|
|
25
|
+
for (const [key, value] of saved) process.env[key] = value;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { test } = Test.make({
|
|
29
|
+
adopt: true,
|
|
30
|
+
dev: false,
|
|
31
|
+
sidecar: false,
|
|
32
|
+
stage: 'test',
|
|
33
|
+
providers: Layer.mergeAll(
|
|
34
|
+
PbsDatastoreProvider(),
|
|
35
|
+
PbsPruneJobProvider(),
|
|
36
|
+
PbsSyncJobProvider(),
|
|
37
|
+
PbsVerifyJobProvider(),
|
|
38
|
+
).pipe(Layer.provideMerge(fake.layer)),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const stack = (comment = 'current') =>
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
yield* PbsDatastore('store', {
|
|
44
|
+
name: 'store',
|
|
45
|
+
path: '/mnt/store',
|
|
46
|
+
'gc-schedule': 'daily',
|
|
47
|
+
'verify-new': false,
|
|
48
|
+
comment,
|
|
49
|
+
target: PBS_JOBS_TARGET,
|
|
50
|
+
});
|
|
51
|
+
yield* PbsPruneJob('prune', {
|
|
52
|
+
id: 'prune',
|
|
53
|
+
store: 'store',
|
|
54
|
+
schedule: 'daily',
|
|
55
|
+
'keep-daily': 30,
|
|
56
|
+
'max-depth': 0,
|
|
57
|
+
comment,
|
|
58
|
+
target: PBS_JOBS_TARGET,
|
|
59
|
+
});
|
|
60
|
+
yield* PbsSyncJob('sync', {
|
|
61
|
+
id: 'sync',
|
|
62
|
+
store: 'store',
|
|
63
|
+
'remote-store': 'other',
|
|
64
|
+
'remote-ns': 'archive',
|
|
65
|
+
'remove-vanished': false,
|
|
66
|
+
'max-depth': 0,
|
|
67
|
+
'verified-only': true,
|
|
68
|
+
comment,
|
|
69
|
+
target: PBS_JOBS_TARGET,
|
|
70
|
+
});
|
|
71
|
+
yield* PbsVerifyJob('verify', {
|
|
72
|
+
id: 'verify',
|
|
73
|
+
store: 'store',
|
|
74
|
+
schedule: null,
|
|
75
|
+
'outdated-after': 30,
|
|
76
|
+
comment,
|
|
77
|
+
target: PBS_JOBS_TARGET,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const existing = () => {
|
|
82
|
+
fake.rows.set('config/datastore/store', {
|
|
83
|
+
name: 'store',
|
|
84
|
+
path: '/mnt/store',
|
|
85
|
+
'gc-schedule': 'daily',
|
|
86
|
+
'verify-new': false,
|
|
87
|
+
comment: 'current',
|
|
88
|
+
});
|
|
89
|
+
fake.rows.set('config/prune/prune', {
|
|
90
|
+
id: 'prune',
|
|
91
|
+
store: 'store',
|
|
92
|
+
schedule: 'daily',
|
|
93
|
+
'keep-daily': 30,
|
|
94
|
+
'max-depth': 0,
|
|
95
|
+
comment: 'current',
|
|
96
|
+
});
|
|
97
|
+
fake.rows.set('config/sync/sync', {
|
|
98
|
+
id: 'sync',
|
|
99
|
+
store: 'store',
|
|
100
|
+
'remote-store': 'other',
|
|
101
|
+
'remote-ns': 'archive',
|
|
102
|
+
'remove-vanished': false,
|
|
103
|
+
'max-depth': 0,
|
|
104
|
+
'verified-only': true,
|
|
105
|
+
comment: 'current',
|
|
106
|
+
});
|
|
107
|
+
fake.rows.set('config/verify/verify', {
|
|
108
|
+
id: 'verify',
|
|
109
|
+
store: 'store',
|
|
110
|
+
'ignore-verified': true,
|
|
111
|
+
'outdated-after': 30,
|
|
112
|
+
comment: 'current',
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const actions = (plan: { resources: Record<string, { action: string }> }) =>
|
|
117
|
+
Object.values(plan.resources)
|
|
118
|
+
.map((row) => row.action)
|
|
119
|
+
.sort();
|
|
120
|
+
|
|
121
|
+
test.provider(
|
|
122
|
+
'matching datastore and three job families adopt without writes, then plan noop',
|
|
123
|
+
(scratch) =>
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
existing();
|
|
126
|
+
yield* scratch.deploy(stack());
|
|
127
|
+
expect(fake.writes).toEqual([]);
|
|
128
|
+
expect(actions(yield* scratch.plan(stack()))).toEqual(['noop', 'noop', 'noop', 'noop']);
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
test.provider(
|
|
133
|
+
'measured missing sections create through typed SDK forms and settle to noop',
|
|
134
|
+
(scratch) =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
yield* scratch.deploy(stack());
|
|
137
|
+
expect(fake.writes.map((row) => `${row.method} ${row.path}`).sort()).toEqual([
|
|
138
|
+
'POST config/datastore',
|
|
139
|
+
'POST config/prune',
|
|
140
|
+
'POST config/sync',
|
|
141
|
+
'POST config/verify',
|
|
142
|
+
]);
|
|
143
|
+
expect(fake.writes.find((row) => row.path === 'config/prune')?.form).toMatchObject({
|
|
144
|
+
'keep-daily': '30',
|
|
145
|
+
'max-depth': '0',
|
|
146
|
+
});
|
|
147
|
+
expect(fake.writes.find((row) => row.path === 'config/sync')?.form).toMatchObject({
|
|
148
|
+
'remote-store': 'other',
|
|
149
|
+
'remote-ns': 'archive',
|
|
150
|
+
'remove-vanished': '0',
|
|
151
|
+
'verified-only': '1',
|
|
152
|
+
});
|
|
153
|
+
expect(fake.writes.find((row) => row.path === 'config/verify')?.form).not.toHaveProperty(
|
|
154
|
+
'schedule',
|
|
155
|
+
);
|
|
156
|
+
expect(actions(yield* scratch.plan(stack()))).toEqual(['noop', 'noop', 'noop', 'noop']);
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
test.provider(
|
|
161
|
+
'drift updates only declared fields through SDK forms and the next plan is noop',
|
|
162
|
+
(scratch) =>
|
|
163
|
+
Effect.gen(function* () {
|
|
164
|
+
existing();
|
|
165
|
+
yield* scratch.deploy(stack());
|
|
166
|
+
yield* scratch.deploy(stack('changed'));
|
|
167
|
+
expect(fake.writes.map((row) => `${row.method} ${row.path}`).sort()).toEqual([
|
|
168
|
+
'PUT config/datastore/store',
|
|
169
|
+
'PUT config/prune/prune',
|
|
170
|
+
'PUT config/sync/sync',
|
|
171
|
+
'PUT config/verify/verify',
|
|
172
|
+
]);
|
|
173
|
+
for (const write of fake.writes) expect(write.form).not.toHaveProperty('delete');
|
|
174
|
+
expect(actions(yield* scratch.plan(stack('changed')))).toEqual([
|
|
175
|
+
'noop',
|
|
176
|
+
'noop',
|
|
177
|
+
'noop',
|
|
178
|
+
'noop',
|
|
179
|
+
]);
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
for (const status of [200, 400, 401, 403, 500]) {
|
|
184
|
+
test.provider(
|
|
185
|
+
`HTTP ${status} malformed or refused read never becomes create or noop`,
|
|
186
|
+
(scratch) =>
|
|
187
|
+
Effect.gen(function* () {
|
|
188
|
+
existing();
|
|
189
|
+
yield* scratch.deploy(stack());
|
|
190
|
+
fake.failRead(status, { message: 'unrelated server refusal', data: null });
|
|
191
|
+
const result = yield* Effect.exit(scratch.plan(stack()));
|
|
192
|
+
expect(result._tag).toBe('Failure');
|
|
193
|
+
expect(fake.writes).toEqual([]);
|
|
194
|
+
// Clear the injected failure before Test.Bun's automatic fixture teardown.
|
|
195
|
+
fake.reset();
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
test.provider(
|
|
201
|
+
'a refused datastore delete read cannot confirm deletion; already absent needs no write',
|
|
202
|
+
(scratch) =>
|
|
203
|
+
Effect.gen(function* () {
|
|
204
|
+
existing();
|
|
205
|
+
yield* scratch.deploy(stack());
|
|
206
|
+
const props = { name: 'store', path: '/mnt/store', target: PBS_JOBS_TARGET };
|
|
207
|
+
fake.failRead(403, { message: 'permission denied', data: null });
|
|
208
|
+
expect((yield* Effect.exit(deleteDatastore(props)))._tag).toBe('Failure');
|
|
209
|
+
expect(fake.writes).toEqual([]);
|
|
210
|
+
fake.reset();
|
|
211
|
+
yield* deleteDatastore(props);
|
|
212
|
+
yield* scratch.destroy();
|
|
213
|
+
expect(fake.writes).toEqual([]);
|
|
214
|
+
}),
|
|
215
|
+
);
|