@homeflare/alchemy 0.37.1 → 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/ceph-fs-distilled.d.ts +17 -10
- package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-wire.d.ts +5 -22
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts +3 -3
- 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/group-wire.d.ts +2 -2
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +1382 -506
- package/dist/proxmox/index.js.map +51 -27
- 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/node-network-wire.d.ts +1 -1
- package/dist/proxmox/node-network-wire.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/proxmox/storage-wire.d.ts +2 -2
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/user-wire.d.ts +2 -2
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-wire.d.ts +1 -1
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -1
- package/docs/changelog/README.md +22 -0
- package/docs/changelog/archive-01.md +176 -0
- package/docs/changelog/archive-02.md +180 -0
- package/docs/changelog/archive-03.md +179 -0
- package/docs/changelog/archive-04.md +178 -0
- package/docs/changelog/archive-05.md +177 -0
- package/docs/changelog/archive-06.md +180 -0
- package/docs/changelog/archive-07.md +180 -0
- package/docs/changelog/archive-08.md +178 -0
- package/docs/changelog/archive-09.md +181 -0
- package/docs/changelog/archive-10.md +182 -0
- package/docs/changelog/archive-11.md +182 -0
- package/docs/changelog/archive-12.md +183 -0
- package/docs/changelog/archive-13.md +180 -0
- package/docs/changelog/archive-14.md +178 -0
- package/docs/changelog/archive-15.md +181 -0
- package/docs/changelog/archive-16.md +182 -0
- package/docs/changelog/archive-17.md +183 -0
- package/docs/changelog/archive-18.md +55 -0
- 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/proxmox.md +9 -3
- package/docs/upstream-conformance-families.md +6 -2
- package/package.json +3 -3
- 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/ceph-fs-delete.test.ts +5 -8
- package/src/proxmox/ceph-fs-destroy-sdk.test.ts +98 -0
- package/src/proxmox/ceph-fs-distilled.ts +41 -13
- package/src/proxmox/ceph-fs-wire.ts +5 -93
- package/src/proxmox/ceph-fs.ts +7 -7
- 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
|
+
);
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* by ceph-fs-write.test.ts and ceph-adopt.test.ts, `destroyFs` alone is unchanged from main, but
|
|
7
|
-
* the two composed together — on a destructive, hard-to-reverse `RemovalPolicy.destroy()` — had
|
|
8
|
-
* no coverage of the seam itself.
|
|
2
|
+
* The CephFS destroy lifecycle through the real SDK protocol and Alchemy engine.
|
|
3
|
+
* The original transport walk left DELETE on the hand client; SDK 0.3.0's query
|
|
4
|
+
* binding now permits the whole sequence to move. These tests keep the destructive
|
|
5
|
+
* read/delete/poll/read-back boundary covered without touching a real filesystem.
|
|
9
6
|
*/
|
|
10
7
|
import { describe, expect, test } from 'bun:test';
|
|
11
8
|
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
@@ -27,7 +24,7 @@ const declared = () => ProxmoxCephFs(NAME, { name: NAME, node: NODE, target: FAK
|
|
|
27
24
|
describe('destroying a CephFS', () => {
|
|
28
25
|
test(
|
|
29
26
|
'RemovalPolicy.destroy() then undeclaring: readFs (distilled) finds it, destroyFs ' +
|
|
30
|
-
'(
|
|
27
|
+
'(distilled) removes it, and the distilled read-back confirms it is gone',
|
|
31
28
|
async () => {
|
|
32
29
|
let destroyed = false;
|
|
33
30
|
const fake = fakePve((call: PveCall) => {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** The real SDK must send destructive flags in the query and fold only its missing-FS tag. */
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import * as Layer from 'effect/Layer';
|
|
5
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
6
|
+
import { destroyFs } from './ceph-fs-distilled.ts';
|
|
7
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
8
|
+
|
|
9
|
+
const props = { name: 'oldfs', node: 'node-b', target: FAKE_TARGET };
|
|
10
|
+
const UPID = 'UPID:node-b:fake:destroyfs';
|
|
11
|
+
const fixture = (answer: () => Response) => {
|
|
12
|
+
const calls: { method: string; path: string; body: string }[] = [];
|
|
13
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
14
|
+
const request =
|
|
15
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
if (url.pathname.includes('/creds/')) {
|
|
18
|
+
return Response.json({
|
|
19
|
+
data: { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' },
|
|
20
|
+
lease_duration: 0,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
calls.push({
|
|
24
|
+
method: request.method,
|
|
25
|
+
path: url.pathname + url.search,
|
|
26
|
+
body: await request.text(),
|
|
27
|
+
});
|
|
28
|
+
return request.method === 'DELETE'
|
|
29
|
+
? answer()
|
|
30
|
+
: Response.json({ data: { status: 'stopped', exitstatus: 'OK' } });
|
|
31
|
+
};
|
|
32
|
+
const layer = FetchHttpClient.layer.pipe(
|
|
33
|
+
Layer.provideMerge(
|
|
34
|
+
Layer.succeed(
|
|
35
|
+
FetchHttpClient.Fetch,
|
|
36
|
+
Object.assign(stub, { preconnect: globalThis.fetch.preconnect }) as typeof fetch,
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
return { calls, layer };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('CephFS distilled delete', () => {
|
|
44
|
+
test('destructive flags reach the query, never the DELETE body, and the worker is polled', async () => {
|
|
45
|
+
const fake = fixture(() => Response.json({ data: UPID }));
|
|
46
|
+
await withoutBao(() =>
|
|
47
|
+
Effect.runPromise(
|
|
48
|
+
destroyFs({ ...props, 'remove-pools': true, 'remove-storages': true }).pipe(
|
|
49
|
+
Effect.provide(fake.layer),
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
expect(fake.calls[0]).toEqual({
|
|
54
|
+
method: 'DELETE',
|
|
55
|
+
path: '/api2/json/nodes/node-b/ceph/fs/oldfs?remove-pools=1&remove-storages=1',
|
|
56
|
+
body: '',
|
|
57
|
+
});
|
|
58
|
+
expect(fake.calls[1]?.path).toBe(
|
|
59
|
+
`/api2/json/nodes/node-b/tasks/${encodeURIComponent(UPID)}/status`,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('omitted or false flags preserve the safe defaults', async () => {
|
|
64
|
+
const fake = fixture(() => Response.json({ data: UPID }));
|
|
65
|
+
await withoutBao(() =>
|
|
66
|
+
Effect.runPromise(
|
|
67
|
+
destroyFs({ ...props, 'remove-pools': false }).pipe(Effect.provide(fake.layer)),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
expect(fake.calls[0]).toEqual({
|
|
71
|
+
method: 'DELETE',
|
|
72
|
+
path: '/api2/json/nodes/node-b/ceph/fs/oldfs',
|
|
73
|
+
body: '',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a race that already removed the filesystem is idempotent without a task poll', async () => {
|
|
78
|
+
const fake = fixture(() =>
|
|
79
|
+
Response.json({ data: null, message: "no such cephfs 'oldfs'\n" }, { status: 500 }),
|
|
80
|
+
);
|
|
81
|
+
await withoutBao(() => Effect.runPromise(destroyFs(props).pipe(Effect.provide(fake.layer))));
|
|
82
|
+
expect(fake.calls).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
for (const status of [401, 403, 500]) {
|
|
86
|
+
test(`an unrelated HTTP ${String(status)} fails instead of claiming deletion`, async () => {
|
|
87
|
+
const fake = fixture(() =>
|
|
88
|
+
Response.json({ data: null, message: 'refused or unavailable' }, { status }),
|
|
89
|
+
);
|
|
90
|
+
await withoutBao(async () => {
|
|
91
|
+
await expect(
|
|
92
|
+
Effect.runPromise(destroyFs(props).pipe(Effect.provide(fake.layer))),
|
|
93
|
+
).rejects.toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
expect(fake.calls).toHaveLength(1);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Proxmox.CephFs`'s distilled
|
|
3
|
-
*
|
|
2
|
+
* `Proxmox.CephFs`'s distilled transport: index, create, delete and task status.
|
|
3
|
+
* SDK 0.3.0 fixes DELETE query flags and supplies the measured CephFsNotFound tag.
|
|
4
4
|
*
|
|
5
5
|
* ⛔ `nodes.updateNodeCephFs` IS distilled's GENERATOR MISNAMING A CREATE, NOT A SECOND ENDPOINT.
|
|
6
6
|
* Checked directly against its schema (`T.Http({ method: "POST", uri:
|
|
7
7
|
* "/nodes/{node}/ceph/fs/{name}" })`) rather than trusted from the function name — it is the
|
|
8
8
|
* SAME single write PVE registers, `createfs`. Harmless once named here for what it is.
|
|
9
9
|
*
|
|
10
|
-
* ★
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* settled yet, not a failure" tolerance. That tolerance is why this calls the RAW distilled
|
|
15
|
-
* operation directly. The former SDK `Task.awaitTask` propagated poll failures; SDK PR #265
|
|
16
|
-
* removed that helper because polling policy belongs in the provider. This bounded wait
|
|
17
|
-
* still tolerates a missing `Sys.Audit` grant or a node mid-restart until its deadline.
|
|
10
|
+
* ★ CREATE AND DELETE SHARE THE SAME BOUNDED POLL POLICY. The raw getNodeTaskStatus
|
|
11
|
+
* operation preserves this family's tolerance of a missing Sys.Audit grant or a node
|
|
12
|
+
* mid-restart until the deadline. The former SDK Task.awaitTask propagated poll failures;
|
|
13
|
+
* SDK PR #265 removed it because polling policy belongs in the provider.
|
|
18
14
|
*/
|
|
19
15
|
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
20
16
|
import * as Effect from 'effect/Effect';
|
|
@@ -43,8 +39,11 @@ const toDistilledCreate = (props: CephFsProps): nodes.UpdateNodeCephFsRequest =>
|
|
|
43
39
|
node: props.node,
|
|
44
40
|
}) as unknown as nodes.UpdateNodeCephFsRequest;
|
|
45
41
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Wait for a forked task. `exitstatus` is absent while running and exactly `OK` on success.
|
|
44
|
+
* ⚠️ T.Label encodes the colon-filled UPID as one path segment, as the hand client did.
|
|
45
|
+
*/
|
|
46
|
+
const settleTask = (props: CephFsProps, upid: string) =>
|
|
48
47
|
Effect.gen(function* () {
|
|
49
48
|
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
|
|
50
49
|
const status = yield* runPve(
|
|
@@ -96,5 +95,34 @@ export const createFs = (props: CephFsProps) =>
|
|
|
96
95
|
),
|
|
97
96
|
);
|
|
98
97
|
}
|
|
99
|
-
yield*
|
|
98
|
+
yield* settleTask(props, upid);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* DELETE the filesystem, then wait for the worker.
|
|
103
|
+
*
|
|
104
|
+
* ⛔ WHAT THIS TAKES AWAY DEPENDS ENTIRELY ON `remove-pools`, AND THE DEFAULT IS THE SAFE ONE. With
|
|
105
|
+
* it unset PVE removes the filesystem from the MDS map and LEAVES the two pools on disk: every
|
|
106
|
+
* mount breaks, but the bytes are still there for an operator to recover. With it set the pools
|
|
107
|
+
* go too and nothing is recoverable. Leaving it unset also means a later create of the SAME name
|
|
108
|
+
* fails with "ceph pools '<name>_data' and/or '<name>_metadata' already exist" -- that refusal is
|
|
109
|
+
* a BRAKE, not a bug, and it is the last thing standing between a mis-typed rename and the data.
|
|
110
|
+
*/
|
|
111
|
+
export const destroyFs = (props: CephFsProps) =>
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const upid = yield* runPve(
|
|
114
|
+
props.target,
|
|
115
|
+
'provision',
|
|
116
|
+
true,
|
|
117
|
+
nodes.deleteNodeCephFs({
|
|
118
|
+
node: props.node,
|
|
119
|
+
name: props.name,
|
|
120
|
+
// ⛔ Query fields, never a DELETE body. Omitted/false retains PVE's safe default.
|
|
121
|
+
...(props['remove-pools'] === true ? { remove_pools: '1' } : {}),
|
|
122
|
+
...(props['remove-storages'] === true ? { remove_storages: '1' } : {}),
|
|
123
|
+
}),
|
|
124
|
+
).pipe(Effect.catchTag('CephFsNotFound', () => Effect.succeed('')));
|
|
125
|
+
// ⚠️ Missing already is idempotent; every other SDK error propagates. The caller's
|
|
126
|
+
// successful index read must still confirm absence after the DELETE or task.
|
|
127
|
+
if (upid !== '') yield* settleTask(props, upid);
|
|
100
128
|
});
|
|
@@ -49,21 +49,13 @@
|
|
|
49
49
|
* Grant it on the `read` role, or every create burns the full cap below and reports the wrong
|
|
50
50
|
* cause.
|
|
51
51
|
*
|
|
52
|
-
* ★
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* (network-apply-read.ts's own header has the fuller reasoning). `destroyFs` stays HERE,
|
|
58
|
-
* unmigrated: distilled types `remove_pools`/`remove_storages` as `T.Body` on a DELETE, and the
|
|
59
|
-
* ⛔ two paragraphs up is the MEASURED reason PVE would silently ignore both flags if sent that
|
|
60
|
-
* way — a real protocol gap, not a judgement call. `settle` below is still shared where it can
|
|
61
|
-
* be: `createForm`/`objectPath`/`destroyPath` are pure and reused by the distilled file too.
|
|
52
|
+
* ★ THE ORIGINAL DISTILLED WALK MOVED READ AND CREATE FIRST, leaving DELETE here because
|
|
53
|
+
* SDK flags were annotated as body fields. Distilled Proxmox 0.3.0 fixes their query binding
|
|
54
|
+
* and adds the measured CephFsNotFound tag. All calls now live in ceph-fs-distilled.ts;
|
|
55
|
+
* this file keeps the pure forms, attributes and read-back failure messages. The protocol
|
|
56
|
+
* history above remains load-bearing: moving a DELETE flag back into a body loses it silently.
|
|
62
57
|
*/
|
|
63
|
-
import * as Effect from 'effect/Effect';
|
|
64
58
|
import type { CephFsAttributes, CephFsProps } from './ceph-fs.ts';
|
|
65
|
-
import { pve } from './client.ts';
|
|
66
|
-
import type { PveTarget } from './credentials.ts';
|
|
67
59
|
import { csv, flag, int, text } from './values.ts';
|
|
68
60
|
|
|
69
61
|
/**
|
|
@@ -100,9 +92,6 @@ export const readRow = (
|
|
|
100
92
|
};
|
|
101
93
|
};
|
|
102
94
|
|
|
103
|
-
/** ⚠️ `exitstatus` is ABSENT while a task runs, and is the exact string `OK` only on success. */
|
|
104
|
-
type TaskStatus = { readonly status?: string; readonly exitstatus?: string };
|
|
105
|
-
|
|
106
95
|
/**
|
|
107
96
|
* ⚠️ SHORT POLLS, LOW CAP. Ninety seconds covers two pool creates plus the ten seconds `createfs`
|
|
108
97
|
* itself spends waiting for an MDS to go active, and is short enough that a stuck task fails the
|
|
@@ -131,83 +120,6 @@ export const createForm = (props: CephFsProps): Record<string, string> => ({
|
|
|
131
120
|
...field('pg_num', props.pg_num === undefined ? undefined : String(props.pg_num)),
|
|
132
121
|
});
|
|
133
122
|
|
|
134
|
-
/** The delete target with its flags in the query string — see the ⛔ in the header. */
|
|
135
|
-
const destroyPath = (props: CephFsProps) => {
|
|
136
|
-
const query = [
|
|
137
|
-
props['remove-pools'] === true ? 'remove-pools=1' : undefined,
|
|
138
|
-
props['remove-storages'] === true ? 'remove-storages=1' : undefined,
|
|
139
|
-
].filter((part) => part !== undefined);
|
|
140
|
-
return query.length === 0 ? objectPath(props) : `${objectPath(props)}?${query.join('&')}`;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Wait for a forked PVE task, and fail loudly rather than quietly.
|
|
145
|
-
*
|
|
146
|
-
* ⚠️ THE UPID IS PERCENT-ENCODED BECAUSE IT IS FULL OF COLONS — `UPID:node-b:00396D3A:…:root@pam:`
|
|
147
|
-
* (MEASURED shape, from `GET /nodes/node-b/tasks`) is ONE path segment, not seven. Encoding is what
|
|
148
|
-
* guarantees it arrives as one; PVE's router decodes each segment before matching. REASONED from
|
|
149
|
-
* the URI handling rather than measured — no task of this provider's has been polled yet.
|
|
150
|
-
*/
|
|
151
|
-
const settle = (target: PveTarget, node: string, upid: string, what: string) =>
|
|
152
|
-
Effect.gen(function* () {
|
|
153
|
-
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
|
|
154
|
-
const status = yield* pve<TaskStatus>(
|
|
155
|
-
target,
|
|
156
|
-
'read',
|
|
157
|
-
'GET',
|
|
158
|
-
`nodes/${node}/tasks/${encodeURIComponent(upid)}/status`,
|
|
159
|
-
).pipe(
|
|
160
|
-
// ⚠️ AN UNREADABLE STATUS IS "NOT SETTLED YET", NOT A FAILURE. It is a missing Sys.Audit
|
|
161
|
-
// (see the header) or a node mid-restart; either way the task is still the cluster's
|
|
162
|
-
// business, and the cap is what ends the wait rather than one unlucky GET.
|
|
163
|
-
Effect.orElseSucceed(() => undefined),
|
|
164
|
-
);
|
|
165
|
-
if (status?.status === 'stopped') {
|
|
166
|
-
const exit = text(status.exitstatus);
|
|
167
|
-
if (exit === 'OK') return;
|
|
168
|
-
return yield* Effect.die(
|
|
169
|
-
new Error(
|
|
170
|
-
`${what}: PVE task ${upid} finished with "${exit === '' ? 'no exit status' : exit}". ` +
|
|
171
|
-
'The write returned HTTP 200 because it only forked the worker -- the real error is ' +
|
|
172
|
-
`in \`pvesh get /nodes/${node}/tasks/${upid}/log\`.`,
|
|
173
|
-
),
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
// ⚠️ A BARE NUMBER IS MILLISECONDS to Effect's Duration, not seconds. The cap in the
|
|
177
|
-
// message below is derived from the same constant so the two cannot drift apart.
|
|
178
|
-
yield* Effect.sleep(POLL_SECONDS * 1000);
|
|
179
|
-
}
|
|
180
|
-
return yield* Effect.die(
|
|
181
|
-
new Error(
|
|
182
|
-
`${what}: PVE task ${upid} was still running after ` +
|
|
183
|
-
`${String(POLL_ATTEMPTS * POLL_SECONDS)}s, or its ` +
|
|
184
|
-
'status could not be read. Check the task log, and check that the `read` role holds ' +
|
|
185
|
-
`Sys.Audit on /nodes/${node} -- each call mints a new token, so the poller is never the ` +
|
|
186
|
-
'task owner and is answered 403 rather than told to wait.',
|
|
187
|
-
),
|
|
188
|
-
);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* DELETE the filesystem, then wait for the worker.
|
|
193
|
-
*
|
|
194
|
-
* ⛔ WHAT THIS TAKES AWAY DEPENDS ENTIRELY ON `remove-pools`, AND THE DEFAULT IS THE SAFE ONE. With
|
|
195
|
-
* it unset PVE removes the filesystem from the MDS map and LEAVES the two pools on disk: every
|
|
196
|
-
* mount breaks, but the bytes are still there for an operator to recover. With it set the pools
|
|
197
|
-
* go too and nothing is recoverable. Leaving it unset also means a later create of the SAME name
|
|
198
|
-
* fails with "ceph pools '<name>_data' and/or '<name>_metadata' already exist" -- that refusal is
|
|
199
|
-
* a BRAKE, not a bug, and it is the last thing standing between a mis-typed rename and the data.
|
|
200
|
-
*/
|
|
201
|
-
export const destroyFs = (props: CephFsProps) =>
|
|
202
|
-
Effect.gen(function* () {
|
|
203
|
-
const upid = text(yield* pve<string>(props.target, 'provision', 'DELETE', destroyPath(props)));
|
|
204
|
-
// ⚠️ A DESTROY THAT ANSWERS WITHOUT A UPID IS NOT REFUSED HERE. `destroyfs` dies synchronously
|
|
205
|
-
// with "no such cephfs" when the filesystem is already gone, which surfaces as a failed
|
|
206
|
-
// DELETE; an empty answer that is not an error is best treated as already-absent, and the
|
|
207
|
-
// caller's read-back is what proves it either way.
|
|
208
|
-
if (upid !== '') yield* settle(props.target, props.node, upid, destroyPath(props));
|
|
209
|
-
});
|
|
210
|
-
|
|
211
123
|
/**
|
|
212
124
|
* The two read-back failures, as messages rather than as guesses.
|
|
213
125
|
*
|