@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,52 @@
|
|
|
1
|
+
/** PBS 4.2.6-1 prune lifecycle; the SDK owns serialization and typed API errors. */
|
|
2
|
+
import { isResolved } from 'alchemy/Diff';
|
|
3
|
+
import type { Input } from 'alchemy/Input';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import { specGuards } from './resource-guard.ts';
|
|
6
|
+
import type { PruneJobAttributes, PruneJobProps } from './pbs-prune-job.ts';
|
|
7
|
+
import { spec } from './pbs-prune-job-spec.ts';
|
|
8
|
+
import { createOne, deleteOne, readOne, updateOne } from './pbs-prune-job-wire.ts';
|
|
9
|
+
|
|
10
|
+
const { guardCreate, guardUpdate } = specGuards(spec);
|
|
11
|
+
|
|
12
|
+
export const handlers = {
|
|
13
|
+
// ⛔ Explicit adoption only: a listed job may be somebody else's only backup policy.
|
|
14
|
+
list: () => Effect.succeed([]),
|
|
15
|
+
read: ({ olds }: { olds: PruneJobProps }) => readOne(olds),
|
|
16
|
+
diff: ({
|
|
17
|
+
news,
|
|
18
|
+
output,
|
|
19
|
+
}: {
|
|
20
|
+
news: Input<PruneJobProps>;
|
|
21
|
+
output: PruneJobAttributes | undefined;
|
|
22
|
+
}) =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
if (!isResolved(news)) return undefined;
|
|
25
|
+
yield* guardCreate(news, output === undefined);
|
|
26
|
+
yield* guardUpdate(news);
|
|
27
|
+
if (output === undefined) return undefined;
|
|
28
|
+
const live = yield* readOne(news);
|
|
29
|
+
if (live === undefined) {
|
|
30
|
+
yield* guardCreate(news, true);
|
|
31
|
+
return { action: 'update' } as const;
|
|
32
|
+
}
|
|
33
|
+
return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
|
|
34
|
+
}),
|
|
35
|
+
reconcile: ({ news }: { news: PruneJobProps }) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const live = yield* readOne(news);
|
|
38
|
+
yield* guardCreate(news, live === undefined);
|
|
39
|
+
yield* guardUpdate(news);
|
|
40
|
+
if (live === undefined) yield* createOne(news);
|
|
41
|
+
else if (!spec.matches(live, news)) yield* updateOne(news);
|
|
42
|
+
// ⛔ A successful write is not evidence of a job; read it back through the SDK.
|
|
43
|
+
const after = yield* readOne(news);
|
|
44
|
+
if (after === undefined) {
|
|
45
|
+
return yield* Effect.fail(
|
|
46
|
+
new Error(`${spec.path(news)}: write succeeded but job is absent.`),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return after;
|
|
50
|
+
}),
|
|
51
|
+
delete: ({ olds }: { olds: PruneJobProps }) => deleteOne(olds),
|
|
52
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** PBS 4.2.6-1 values and vendor form guards; SDK transport is in pbs-prune-job-wire.ts. */
|
|
2
|
+
import type { PruneJobAttributes, PruneJobProps } from './pbs-prune-job.ts';
|
|
3
|
+
import type { PveSpec } from './resource-spec.ts';
|
|
4
|
+
import { createBody, keepAttributes, keepsMatch, updateBody } from './pbs-prune-job-form.ts';
|
|
5
|
+
import { bool, int, text } from './values.ts';
|
|
6
|
+
|
|
7
|
+
/** "Not declared, or equal" — the single shape every unmanaged field is compared with. */
|
|
8
|
+
const same = <T>(declared: T | undefined, live: T) => declared === undefined || declared === live;
|
|
9
|
+
|
|
10
|
+
export const spec: PveSpec<PruneJobProps, PruneJobAttributes> = {
|
|
11
|
+
/**
|
|
12
|
+
* ⚠️ EVERY OPTIONAL FIELD FALLS BACK TO ITS ABSENT SENTINEL RATHER THAN TO PBS'S DEFAULT, because
|
|
13
|
+
* for this family the two are different questions. `keep-daily` absent does not mean "keep zero
|
|
14
|
+
* dailies", it means "this window is not part of the policy", and `matches` must not compare a
|
|
15
|
+
* window the declaration never mentioned against an invented value.
|
|
16
|
+
* ⛔ NO "IS IT REALLY THERE" GUARD, for backup-job.ts's reason: returning undefined for a job
|
|
17
|
+
* whose JSON is missing a key would be a guess about which keys PBS echoes, and a wrong guess
|
|
18
|
+
* sends reconcile down the POST branch — which creates ANOTHER prune job rather than failing.
|
|
19
|
+
*/
|
|
20
|
+
attributes: (live, props) => ({
|
|
21
|
+
comment: text(live['comment'], ''),
|
|
22
|
+
disable: bool(live['disable'], false),
|
|
23
|
+
id: props.id,
|
|
24
|
+
...keepAttributes(live),
|
|
25
|
+
'max-depth': int(live['max-depth'], -1),
|
|
26
|
+
ns: text(live['ns'], ''),
|
|
27
|
+
schedule: text(live['schedule'], ''),
|
|
28
|
+
store: text(live['store'], props.store),
|
|
29
|
+
}),
|
|
30
|
+
collection: () => 'config/prune',
|
|
31
|
+
createForm: createBody,
|
|
32
|
+
/**
|
|
33
|
+
* ⚠️ THE UNDECLARED HALF IS NOT COMPARED, AND THAT IS THE WHOLE SAFETY MODEL. `same` reads "not
|
|
34
|
+
* declared, or equal", so a field set by hand on an adopted job survives adoption untouched and
|
|
35
|
+
* never appears as drift. `keepsMatch` applies the identical rule to the six windows, from the
|
|
36
|
+
* file that also writes and reads them — see the ★ at the top of pbs-prune-job-form.ts.
|
|
37
|
+
* ⚠️ `store` AND `schedule` HAVE NO UNDECLARED CASE because both are required props, so both are
|
|
38
|
+
* compared unconditionally. A changed `store` is an update, not a replace: PBS accepts `store`
|
|
39
|
+
* on PUT, so the job is re-pointed in place.
|
|
40
|
+
*/
|
|
41
|
+
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
42
|
+
endpoint: { create: 'pbs:POST /config/prune', update: 'pbs:PUT /config/prune/{id}' },
|
|
43
|
+
matches: (attributes, props) =>
|
|
44
|
+
attributes.schedule === props.schedule &&
|
|
45
|
+
attributes.store === props.store &&
|
|
46
|
+
same(props.ns, attributes.ns) &&
|
|
47
|
+
same(props['max-depth'], attributes['max-depth']) &&
|
|
48
|
+
same(props.disable, attributes.disable) &&
|
|
49
|
+
same(props.comment, attributes.comment) &&
|
|
50
|
+
keepsMatch(attributes, props),
|
|
51
|
+
path: (props) => `config/prune/${props.id}`,
|
|
52
|
+
updateForm: updateBody,
|
|
53
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** PBS 4.2.6-1 operations through @distilled.cloud/proxmox-backup. */
|
|
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 type { PruneJobProps } from './pbs-prune-job.ts';
|
|
6
|
+
import { createBody, updateBody } from './pbs-prune-job-form.ts';
|
|
7
|
+
import { spec } from './pbs-prune-job-spec.ts';
|
|
8
|
+
|
|
9
|
+
/** SDK member names differ from the vendor form names; omission stays unmanaged. */
|
|
10
|
+
const members = (form: Record<string, string>) => ({
|
|
11
|
+
...(form['comment'] === undefined ? {} : { comment: form['comment'] }),
|
|
12
|
+
...(form['disable'] === undefined ? {} : { disable: form['disable'] }),
|
|
13
|
+
...(form['keep-daily'] === undefined ? {} : { keep_daily: form['keep-daily'] }),
|
|
14
|
+
...(form['keep-hourly'] === undefined ? {} : { keep_hourly: form['keep-hourly'] }),
|
|
15
|
+
...(form['keep-last'] === undefined ? {} : { keep_last: form['keep-last'] }),
|
|
16
|
+
...(form['keep-monthly'] === undefined ? {} : { keep_monthly: form['keep-monthly'] }),
|
|
17
|
+
...(form['keep-weekly'] === undefined ? {} : { keep_weekly: form['keep-weekly'] }),
|
|
18
|
+
...(form['keep-yearly'] === undefined ? {} : { keep_yearly: form['keep-yearly'] }),
|
|
19
|
+
...(form['max-depth'] === undefined ? {} : { max_depth: form['max-depth'] }),
|
|
20
|
+
...(form['ns'] === undefined ? {} : { ns: form['ns'] }),
|
|
21
|
+
...(form['schedule'] === undefined ? {} : { schedule: form['schedule'] }),
|
|
22
|
+
...(form['store'] === undefined ? {} : { store: form['store'] }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const createRequest = (props: PruneJobProps): config.CreateConfigPruneRequest => ({
|
|
26
|
+
...members(createBody(props)),
|
|
27
|
+
id: props.id,
|
|
28
|
+
store: props.store,
|
|
29
|
+
schedule: props.schedule,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const updateRequest = (props: PruneJobProps): config.PutConfigPruneRequest => ({
|
|
33
|
+
...members(updateBody(props)),
|
|
34
|
+
id: props.id,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Preserve the state attribute shape while the SDK decodes its typed field names. */
|
|
38
|
+
const wire = (row: config.GetConfigPruneResponse): Record<string, unknown> => ({
|
|
39
|
+
...row,
|
|
40
|
+
'keep-daily': row.keep_daily,
|
|
41
|
+
'keep-hourly': row.keep_hourly,
|
|
42
|
+
'keep-last': row.keep_last,
|
|
43
|
+
'keep-monthly': row.keep_monthly,
|
|
44
|
+
'keep-weekly': row.keep_weekly,
|
|
45
|
+
'keep-yearly': row.keep_yearly,
|
|
46
|
+
'max-depth': row.max_depth,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** ⛔ Only the SDK PruneJobNotFound is absence; permission, transport and malformed replies propagate. */
|
|
50
|
+
export const readOne = (props: PruneJobProps) =>
|
|
51
|
+
runPbs(props.target, 'read', config.getConfigPrune({ id: props.id })).pipe(
|
|
52
|
+
Effect.map((row) => spec.attributes(wire(row), props)),
|
|
53
|
+
Effect.catchTag('PruneJobNotFound', () => Effect.succeed(undefined)),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export const createOne = (props: PruneJobProps) =>
|
|
57
|
+
runPbs(props.target, 'provision', config.createConfigPrune(createRequest(props)));
|
|
58
|
+
|
|
59
|
+
export const updateOne = (props: PruneJobProps) =>
|
|
60
|
+
runPbs(props.target, 'provision', config.putConfigPrune(updateRequest(props)));
|
|
61
|
+
|
|
62
|
+
/** Deleting the schedule never deletes backups; already missing is a successful deletion. */
|
|
63
|
+
export const deleteOne = (props: PruneJobProps) =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
// ⛔ Missing-item tags are measured for GET only. Do not guess the DELETE error shape.
|
|
66
|
+
if ((yield* readOne(props)) === undefined) return;
|
|
67
|
+
return yield* runPbs(props.target, 'provision', config.deleteConfigPrune({ id: props.id }));
|
|
68
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Pbs.PruneJob` — a Proxmox Backup Server retention schedule, declared. PBS is a SIBLING of PVE,
|
|
3
|
-
*
|
|
3
|
+
* and this family uses its own distilled SDK's typed config operations (PBS 4.2.6-1).
|
|
4
4
|
*
|
|
5
5
|
* ⛔ THIS IS THE ONE RESOURCE IN THIS PACKAGE THAT DESTROYS DATA ON AN ORDINARY *UPDATE*. CephPool,
|
|
6
6
|
* ZfsPool and Storage can destroy data too — which is exactly why they default to `retain` and
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
* `-1` in the attributes means "absent from prune.cfg", the sentinel storage.ts uses for
|
|
27
27
|
* `maxfiles`, because `0` here is a real and very different setting.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
* host and
|
|
29
|
+
* ⚠️ HISTORICAL BOOTSTRAP BLOCKER, 2026-09-13: PBS was a different
|
|
30
|
+
* host and auth realm from PVE. The later PBS credential lane and SDK resolved this blocker.
|
|
31
|
+
* MEASURED 2026-09-13: pbs.example.com:8007 answers
|
|
31
32
|
* HTTP 401 under STRICT TLS behind a real Let's Encrypt certificate (CN=pbs.example.com, issuer
|
|
32
33
|
* YR1, valid 2026-09-02 to 2026-12-01) — so there is no `-k` to reach for and `client.ts` needs
|
|
33
34
|
* no TLS escape hatch, exactly as on PVE. What it does need is a header it cannot build today.
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
* ★ WHAT A `PbsTarget` NEEDS, AND WHY IT IS ALMOST `PveTarget`. Structurally `PveTarget` already
|
|
48
49
|
* fits a PBS host: a credential mount name and an `/api2/json` base are the whole of it, and PBS
|
|
49
50
|
* shares the `{"data": …}` envelope, the four-operation shape and the read-back discipline —
|
|
50
|
-
* which is
|
|
51
|
+
* which is why the pure spec survives the SDK transport migration. The single thing `PveTarget` cannot
|
|
51
52
|
* carry is WHICH AUTHORIZATION SCHEME the host at the other end speaks, so `PbsTarget` below adds
|
|
52
53
|
* that and nothing else rather than bending the PVE type in silence.
|
|
53
54
|
*
|
|
@@ -55,9 +56,9 @@
|
|
|
55
56
|
* below are MEASURED from the PBS API as published at pbs.proxmox.com/docs/api-viewer, fetched
|
|
56
57
|
* 2026-09-13 — current PBS 4.x. Before PBS 2.2 there was no `/config/prune` at all: retention
|
|
57
58
|
* lived on the datastore section itself, as `keep-*` plus `prune-schedule` under
|
|
58
|
-
* `/config/datastore/{name}`.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
59
|
+
* `/config/datastore/{name}`. The old `pveOperations.read` folded those 404s into "absent",
|
|
60
|
+
* producing a create forever. The SDK path now propagates an unknown route's 404; only the
|
|
61
|
+
* vendor's typed missing-prune response means absent. Check the PBS version before planning.
|
|
61
62
|
*
|
|
62
63
|
* ★ THE READ LANE NEEDS NOTHING EXTRA, UNLIKE THREE PVE FAMILIES — so there is no `readRole:
|
|
63
64
|
* 'provision'` below and there should not be one. MEASURED: `GET /config/prune/{id}` requires
|
|
@@ -79,10 +80,9 @@
|
|
|
79
80
|
import { Resource } from 'alchemy';
|
|
80
81
|
import * as Provider from 'alchemy/Provider';
|
|
81
82
|
import * as Effect from 'effect/Effect';
|
|
83
|
+
import { handlers } from './pbs-prune-job-lifecycle.ts';
|
|
82
84
|
import type { PbsTarget } from './credentials.ts';
|
|
83
|
-
import {
|
|
84
|
-
import { type PveRequirements, type WithPbsTarget, pveHandlers } from './resource.ts';
|
|
85
|
-
import { bool, int, text } from './values.ts';
|
|
85
|
+
import type { PveRequirements, WithPbsTarget } from './resource-spec.ts';
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* ⚠️ THE PROPS ARE PBS'S OWN KEY NAMES, HYPHENS INCLUDED, for backup-job.ts's reason: the form
|
|
@@ -160,54 +160,6 @@ export interface PbsPruneJob extends Resource<
|
|
|
160
160
|
*/
|
|
161
161
|
export const PbsPruneJob = Resource<PbsPruneJob>('Pbs.PruneJob');
|
|
162
162
|
|
|
163
|
-
/** "Not declared, or equal" — the single shape every unmanaged field is compared with. */
|
|
164
|
-
const same = <T>(declared: T | undefined, live: T) => declared === undefined || declared === live;
|
|
165
|
-
|
|
166
|
-
const handlers = pveHandlers<PruneJobProps, PruneJobAttributes>({
|
|
167
|
-
/**
|
|
168
|
-
* ⚠️ EVERY OPTIONAL FIELD FALLS BACK TO ITS ABSENT SENTINEL RATHER THAN TO PBS'S DEFAULT, because
|
|
169
|
-
* for this family the two are different questions. `keep-daily` absent does not mean "keep zero
|
|
170
|
-
* dailies", it means "this window is not part of the policy", and `matches` must not compare a
|
|
171
|
-
* window the declaration never mentioned against an invented value.
|
|
172
|
-
* ⛔ NO "IS IT REALLY THERE" GUARD, for backup-job.ts's reason: returning undefined for a job
|
|
173
|
-
* whose JSON is missing a key would be a guess about which keys PBS echoes, and a wrong guess
|
|
174
|
-
* sends reconcile down the POST branch — which creates ANOTHER prune job rather than failing.
|
|
175
|
-
*/
|
|
176
|
-
attributes: (live, props) => ({
|
|
177
|
-
comment: text(live['comment'], ''),
|
|
178
|
-
disable: bool(live['disable'], false),
|
|
179
|
-
id: props.id,
|
|
180
|
-
...keepAttributes(live),
|
|
181
|
-
'max-depth': int(live['max-depth'], -1),
|
|
182
|
-
ns: text(live['ns'], ''),
|
|
183
|
-
schedule: text(live['schedule'], ''),
|
|
184
|
-
store: text(live['store'], props.store),
|
|
185
|
-
}),
|
|
186
|
-
collection: () => 'config/prune',
|
|
187
|
-
createForm: createBody,
|
|
188
|
-
/**
|
|
189
|
-
* ⚠️ THE UNDECLARED HALF IS NOT COMPARED, AND THAT IS THE WHOLE SAFETY MODEL. `same` reads "not
|
|
190
|
-
* declared, or equal", so a field set by hand on an adopted job survives adoption untouched and
|
|
191
|
-
* never appears as drift. `keepsMatch` applies the identical rule to the six windows, from the
|
|
192
|
-
* file that also writes and reads them — see the ★ at the top of pbs-prune-job-form.ts.
|
|
193
|
-
* ⚠️ `store` AND `schedule` HAVE NO UNDECLARED CASE because both are required props, so both are
|
|
194
|
-
* compared unconditionally. A changed `store` is an update, not a replace: PBS accepts `store`
|
|
195
|
-
* on PUT, so the job is re-pointed in place.
|
|
196
|
-
*/
|
|
197
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
198
|
-
endpoint: { create: 'pbs:POST /config/prune', update: 'pbs:PUT /config/prune/{id}' },
|
|
199
|
-
matches: (attributes, props) =>
|
|
200
|
-
attributes.schedule === props.schedule &&
|
|
201
|
-
attributes.store === props.store &&
|
|
202
|
-
same(props.ns, attributes.ns) &&
|
|
203
|
-
same(props['max-depth'], attributes['max-depth']) &&
|
|
204
|
-
same(props.disable, attributes.disable) &&
|
|
205
|
-
same(props.comment, attributes.comment) &&
|
|
206
|
-
keepsMatch(attributes, props),
|
|
207
|
-
path: (props) => `config/prune/${props.id}`,
|
|
208
|
-
updateForm: updateBody,
|
|
209
|
-
});
|
|
210
|
-
|
|
211
163
|
/**
|
|
212
164
|
* ⛔ `list` IS EMPTY like every other family here, and the stakes are the usual ones read backwards.
|
|
213
165
|
* `GET /config/prune` hands back every prune job on the server, the ones an operator tuned by
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Stable PBS sync-job state; only vendor configuration, never runtime task status. */
|
|
2
|
+
export interface SyncJobAttributes {
|
|
3
|
+
/** From the props — it is the path this object was read by. */
|
|
4
|
+
id: string;
|
|
5
|
+
store: string;
|
|
6
|
+
/** `''` is the root namespace, which is also how PBS spells it. */
|
|
7
|
+
ns: string;
|
|
8
|
+
/** `''` means a LOCAL sync — datastore to datastore on this PBS. */
|
|
9
|
+
remote: string;
|
|
10
|
+
'remote-store': string;
|
|
11
|
+
'remote-ns': string;
|
|
12
|
+
/** `''` means the job runs only when somebody starts it. */
|
|
13
|
+
schedule: string;
|
|
14
|
+
comment: string;
|
|
15
|
+
'remove-vanished': boolean;
|
|
16
|
+
/** ⚠️ Absent on the wire means `false`, PBS's own default. */
|
|
17
|
+
'verified-only': boolean;
|
|
18
|
+
/** `''` means no owner in the config, i.e. `root@pam` at runtime. An identifier, never a secret. */
|
|
19
|
+
owner: string;
|
|
20
|
+
/** Bytes per second, or `UNSET` (-1). See `bytes()`. */
|
|
21
|
+
'rate-in': number;
|
|
22
|
+
/** `UNSET` (-1) means unset, which means full recursion. `0` is a real value meaning none. */
|
|
23
|
+
'max-depth': number;
|
|
24
|
+
/** `UNSET` (-1) means all snapshots. */
|
|
25
|
+
'transfer-last': number;
|
|
26
|
+
/**
|
|
27
|
+
* ⚠️ REPORTED, NEVER SENT, NEVER COMPARED — the live filter list joined for display only. See the
|
|
28
|
+
* ⛔ on the prop for why it is not declarable.
|
|
29
|
+
*/
|
|
30
|
+
'group-filter': string;
|
|
31
|
+
/** `pull` or `push`. Reported, never compared — create-only here. */
|
|
32
|
+
'sync-direction': string;
|
|
33
|
+
}
|
|
@@ -7,17 +7,18 @@ import type { SyncJobAttributes, SyncJobProps } from './pbs-sync-job.ts';
|
|
|
7
7
|
* with the wire instead of staying with the props. The `import type` back is a cycle on paper
|
|
8
8
|
* only — erased before anything runs, so the runtime arrow points one way, resource to wire.
|
|
9
9
|
*
|
|
10
|
-
* ★ IT
|
|
10
|
+
* ★ HISTORICALLY IT USED THE SHARED PVE CLIENT, NOT A PBS ONE. An earlier draft of this file was written
|
|
11
11
|
* against a `./pbs-client.ts` that never existed, and spelled out what it would have to export.
|
|
12
12
|
* That module is not needed: PBS speaks the same `/api2/json` paths and the same `{"data": …}`
|
|
13
13
|
* envelope, its OpenBao mount vends the same `{token_id, secret}`, and the only difference is
|
|
14
14
|
* the authorization header — so `PveTarget` carries a `scheme` and `authorization()` spells
|
|
15
|
-
* `PBSAPIToken=<id>:<secret>` for `'pbs'`.
|
|
15
|
+
* `PBSAPIToken=<id>:<secret>` for `'pbs'`. The SDK now owns that header; the pure spec below
|
|
16
|
+
* preserves the existing field and retention semantics.
|
|
16
17
|
* The one PBS-specific thing that survived is the FORM TYPE: PBS decodes a multi-valued field
|
|
17
|
-
* from repeated keys, and
|
|
18
|
+
* from repeated keys, and the distilled PBS protocol must preserve that encoding.
|
|
18
19
|
|
|
19
20
|
*/
|
|
20
|
-
import {
|
|
21
|
+
import type { PveSpec } from './resource-spec.ts';
|
|
21
22
|
import { bool, int, text } from './values.ts';
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -150,7 +151,7 @@ export const createBody = (props: SyncJobProps): Record<string, string> => ({
|
|
|
150
151
|
*/
|
|
151
152
|
export const updateBody = (props: SyncJobProps): Record<string, string> => shape(props);
|
|
152
153
|
|
|
153
|
-
export const
|
|
154
|
+
export const spec: PveSpec<SyncJobProps, SyncJobAttributes> = {
|
|
154
155
|
/**
|
|
155
156
|
* ⛔ NO "IS IT REALLY THERE" GUARD, for backup-job.ts's reason: absence is the API declining to
|
|
156
157
|
* answer, which the factory's `read` already handles, not a key missing from an answer that did
|
|
@@ -233,4 +234,4 @@ export const handlers = pveHandlers<SyncJobProps, SyncJobAttributes>({
|
|
|
233
234
|
/** ⚠️ A Proxmox safe id has no `/` or `:` in it, so there is nothing here to encode. */
|
|
234
235
|
path: (props) => `config/sync/${props.id}`,
|
|
235
236
|
updateForm: updateBody,
|
|
236
|
-
}
|
|
237
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** PBS 4.2.6-1 sync lifecycle; the SDK owns serialization and typed API errors. */
|
|
2
|
+
import { isResolved } from 'alchemy/Diff';
|
|
3
|
+
import type { Input } from 'alchemy/Input';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import { specGuards } from './resource-guard.ts';
|
|
6
|
+
import type { SyncJobAttributes, SyncJobProps } from './pbs-sync-job.ts';
|
|
7
|
+
import { spec } from './pbs-sync-job-form.ts';
|
|
8
|
+
import { createOne, deleteOne, readOne, updateOne } from './pbs-sync-job-wire.ts';
|
|
9
|
+
|
|
10
|
+
const { guardCreate, guardUpdate } = specGuards(spec);
|
|
11
|
+
|
|
12
|
+
export const handlers = {
|
|
13
|
+
// ⛔ Explicit adoption only: a listed job may be somebody else's only backup policy.
|
|
14
|
+
list: () => Effect.succeed([]),
|
|
15
|
+
read: ({ olds }: { olds: SyncJobProps }) => readOne(olds),
|
|
16
|
+
diff: ({ news, output }: { news: Input<SyncJobProps>; output: SyncJobAttributes | undefined }) =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
if (!isResolved(news)) return undefined;
|
|
19
|
+
yield* guardCreate(news, output === undefined);
|
|
20
|
+
yield* guardUpdate(news);
|
|
21
|
+
if (output === undefined) return undefined;
|
|
22
|
+
const live = yield* readOne(news);
|
|
23
|
+
if (live === undefined) {
|
|
24
|
+
yield* guardCreate(news, true);
|
|
25
|
+
return { action: 'update' } as const;
|
|
26
|
+
}
|
|
27
|
+
return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
|
|
28
|
+
}),
|
|
29
|
+
reconcile: ({ news }: { news: SyncJobProps }) =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const live = yield* readOne(news);
|
|
32
|
+
yield* guardCreate(news, live === undefined);
|
|
33
|
+
yield* guardUpdate(news);
|
|
34
|
+
if (live === undefined) yield* createOne(news);
|
|
35
|
+
else if (!spec.matches(live, news)) yield* updateOne(news);
|
|
36
|
+
// ⛔ A successful write is not evidence of a job; read it back through the SDK.
|
|
37
|
+
const after = yield* readOne(news);
|
|
38
|
+
if (after === undefined) {
|
|
39
|
+
return yield* Effect.fail(
|
|
40
|
+
new Error(`${spec.path(news)}: write succeeded but job is absent.`),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return after;
|
|
44
|
+
}),
|
|
45
|
+
delete: ({ olds }: { olds: SyncJobProps }) => deleteOne(olds),
|
|
46
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** PBS 4.2.6-1 operations through @distilled.cloud/proxmox-backup. */
|
|
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 type { SyncJobProps } from './pbs-sync-job.ts';
|
|
6
|
+
import { createBody, spec, updateBody } from './pbs-sync-job-form.ts';
|
|
7
|
+
|
|
8
|
+
/** SDK member names differ from the vendor form names; omission stays unmanaged. */
|
|
9
|
+
const members = (form: Record<string, string>) => ({
|
|
10
|
+
...(form['remote-store'] === undefined ? {} : { remote_store: form['remote-store'] }),
|
|
11
|
+
...(form['remove-vanished'] === undefined ? {} : { remove_vanished: form['remove-vanished'] }),
|
|
12
|
+
...(form['store'] === undefined ? {} : { store: form['store'] }),
|
|
13
|
+
...(form['comment'] === undefined ? {} : { comment: form['comment'] }),
|
|
14
|
+
...(form['max-depth'] === undefined ? {} : { max_depth: form['max-depth'] }),
|
|
15
|
+
...(form['ns'] === undefined ? {} : { ns: form['ns'] }),
|
|
16
|
+
...(form['owner'] === undefined ? {} : { owner: form['owner'] }),
|
|
17
|
+
...(form['rate-in'] === undefined ? {} : { rate_in: form['rate-in'] }),
|
|
18
|
+
...(form['remote'] === undefined ? {} : { remote: form['remote'] }),
|
|
19
|
+
...(form['remote-ns'] === undefined ? {} : { remote_ns: form['remote-ns'] }),
|
|
20
|
+
...(form['schedule'] === undefined ? {} : { schedule: form['schedule'] }),
|
|
21
|
+
...(form['verified-only'] === undefined ? {} : { verified_only: form['verified-only'] }),
|
|
22
|
+
...(form['transfer-last'] === undefined ? {} : { transfer_last: form['transfer-last'] }),
|
|
23
|
+
...(form['sync-direction'] === undefined ? {} : { sync_direction: form['sync-direction'] }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const createRequest = (props: SyncJobProps): config.SyncConfigRequest => ({
|
|
27
|
+
...members(createBody(props)),
|
|
28
|
+
id: props.id,
|
|
29
|
+
store: props.store,
|
|
30
|
+
remote_store: props['remote-store'],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const updateRequest = (props: SyncJobProps): config.PutConfigSyncRequest => ({
|
|
34
|
+
...members(updateBody(props)),
|
|
35
|
+
id: props.id,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Preserve the state attribute shape while the SDK decodes its typed field names. */
|
|
39
|
+
const wire = (row: config.GetConfigSyncResponse): Record<string, unknown> => ({
|
|
40
|
+
...row,
|
|
41
|
+
'remote-store': row.remote_store,
|
|
42
|
+
'remove-vanished': row.remove_vanished,
|
|
43
|
+
'max-depth': row.max_depth,
|
|
44
|
+
'rate-in': row.rate_in,
|
|
45
|
+
'remote-ns': row.remote_ns,
|
|
46
|
+
'verified-only': row.verified_only,
|
|
47
|
+
'transfer-last': row.transfer_last,
|
|
48
|
+
'sync-direction': row.sync_direction,
|
|
49
|
+
'group-filter': row.group_filter,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** ⛔ Only the SDK SyncJobNotFound is absence; permission, transport and malformed replies propagate. */
|
|
53
|
+
export const readOne = (props: SyncJobProps) =>
|
|
54
|
+
runPbs(props.target, 'read', config.getConfigSync({ id: props.id })).pipe(
|
|
55
|
+
Effect.map((row) => spec.attributes(wire(row), props)),
|
|
56
|
+
Effect.catchTag('SyncJobNotFound', () => Effect.succeed(undefined)),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export const createOne = (props: SyncJobProps) =>
|
|
60
|
+
runPbs(props.target, 'provision', config.syncConfig(createRequest(props)));
|
|
61
|
+
|
|
62
|
+
export const updateOne = (props: SyncJobProps) =>
|
|
63
|
+
runPbs(props.target, 'provision', config.putConfigSync(updateRequest(props)));
|
|
64
|
+
|
|
65
|
+
/** Deleting the schedule never deletes backups; already missing is a successful deletion. */
|
|
66
|
+
export const deleteOne = (props: SyncJobProps) =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
// ⛔ Missing-item tags are measured for GET only. Do not guess the DELETE error shape.
|
|
69
|
+
if ((yield* readOne(props)) === undefined) return;
|
|
70
|
+
return yield* runPbs(props.target, 'provision', config.deleteConfigSync({ id: props.id }));
|
|
71
|
+
});
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
* `/api2/json/config/sync` is not even evidence that the endpoint exists. THE FIRST PLAN
|
|
36
36
|
* AGAINST A LIVE PBS IS THE MEASUREMENT. Read it; do not deploy it unseen.
|
|
37
37
|
* ⚠️ AN ERROR BODY IS NOT WRAPPED: the 401 body is the bare text `authentication failed`, not
|
|
38
|
-
* JSON. `pve()`
|
|
39
|
-
*
|
|
38
|
+
* JSON. The original `pve()` checked status before decoding success data. The distilled PBS
|
|
39
|
+
* protocol preserves this distinction and classifies error text through SDK patches.
|
|
40
40
|
*
|
|
41
41
|
* ⚠️ `Proxmox.BackupJob` AND `Pbs.SyncJob` ARE NOT THE SAME FAMILY AND THE NAMES NEARLY COLLIDE.
|
|
42
42
|
* The first is a PVE vzdump schedule; this is a Proxmox Backup SERVER sync job, on a different
|
|
@@ -55,7 +55,9 @@
|
|
|
55
55
|
import { Resource } from 'alchemy';
|
|
56
56
|
import * as Provider from 'alchemy/Provider';
|
|
57
57
|
import * as Effect from 'effect/Effect';
|
|
58
|
-
import { handlers } from './pbs-sync-job-
|
|
58
|
+
import { handlers } from './pbs-sync-job-lifecycle.ts';
|
|
59
|
+
import type { SyncJobAttributes } from './pbs-sync-job-attributes.ts';
|
|
60
|
+
export type { SyncJobAttributes } from './pbs-sync-job-attributes.ts';
|
|
59
61
|
import type { PveRequirements } from './resource.ts';
|
|
60
62
|
import type { WithPbsTarget } from './resource.ts';
|
|
61
63
|
|
|
@@ -158,10 +160,10 @@ export interface SyncJobProps extends WithPbsTarget {
|
|
|
158
160
|
'transfer-last'?: number;
|
|
159
161
|
/**
|
|
160
162
|
* ⛔ NOT DECLARABLE, AND A COMPILE ERROR RATHER THAN A PROP THAT HALF WORKS — the storage.ts
|
|
161
|
-
* `password` idiom
|
|
163
|
+
* `password` idiom. Two barriers originally applied; the second remains unresolved.
|
|
162
164
|
* 1. THE WIRE FORM IS AN ARRAY. PBS takes `group-filter` as a repeated parameter
|
|
163
|
-
* (`group-filter=type:ct&group-filter=regex:^web`)
|
|
164
|
-
*
|
|
165
|
+
* (`group-filter=type:ct&group-filter=regex:^web`). The old single-value form could not
|
|
166
|
+
* express it; the distilled PBS protocol now supports repeated keys.
|
|
165
167
|
* 2. NARROWING A FILTER MAY BE A DELETE. Whether `remove-vanished` skips the groups a filter
|
|
166
168
|
* excludes is NOT something I could measure without a credential. If it does not, then
|
|
167
169
|
* editing this field on a job with `remove-vanished` set removes every local group the new
|
|
@@ -169,8 +171,8 @@ export interface SyncJobProps extends WithPbsTarget {
|
|
|
169
171
|
* half-working prop.
|
|
170
172
|
* ★ IT IS STILL REPORTED in the attributes, so a plan shows the filters a hand-made job carries
|
|
171
173
|
* and this resource never strips them: undeclared is unmanaged, and PBS's update assigns only
|
|
172
|
-
* what it was sent.
|
|
173
|
-
*
|
|
174
|
+
* what it was sent. Making it declarable still needs a MEASURED answer to (2), even though
|
|
175
|
+
* the SDK now supplies the necessary repeated-key transport.
|
|
174
176
|
*/
|
|
175
177
|
'group-filter'?: never;
|
|
176
178
|
/**
|
|
@@ -196,39 +198,6 @@ export interface SyncJobProps extends WithPbsTarget {
|
|
|
196
198
|
'verified-only'?: boolean;
|
|
197
199
|
}
|
|
198
200
|
|
|
199
|
-
export interface SyncJobAttributes {
|
|
200
|
-
/** From the props — it is the path this object was read by. */
|
|
201
|
-
id: string;
|
|
202
|
-
store: string;
|
|
203
|
-
/** `''` is the root namespace, which is also how PBS spells it. */
|
|
204
|
-
ns: string;
|
|
205
|
-
/** `''` means a LOCAL sync — datastore to datastore on this PBS. */
|
|
206
|
-
remote: string;
|
|
207
|
-
'remote-store': string;
|
|
208
|
-
'remote-ns': string;
|
|
209
|
-
/** `''` means the job runs only when somebody starts it. */
|
|
210
|
-
schedule: string;
|
|
211
|
-
comment: string;
|
|
212
|
-
'remove-vanished': boolean;
|
|
213
|
-
/** ⚠️ Absent on the wire means `false`, PBS's own default. */
|
|
214
|
-
'verified-only': boolean;
|
|
215
|
-
/** `''` means no owner in the config, i.e. `root@pam` at runtime. An identifier, never a secret. */
|
|
216
|
-
owner: string;
|
|
217
|
-
/** Bytes per second, or `UNSET` (-1). See `bytes()`. */
|
|
218
|
-
'rate-in': number;
|
|
219
|
-
/** `UNSET` (-1) means unset, which means full recursion. `0` is a real value meaning none. */
|
|
220
|
-
'max-depth': number;
|
|
221
|
-
/** `UNSET` (-1) means all snapshots. */
|
|
222
|
-
'transfer-last': number;
|
|
223
|
-
/**
|
|
224
|
-
* ⚠️ REPORTED, NEVER SENT, NEVER COMPARED — the live filter list joined for display only. See the
|
|
225
|
-
* ⛔ on the prop for why it is not declarable.
|
|
226
|
-
*/
|
|
227
|
-
'group-filter': string;
|
|
228
|
-
/** `pull` or `push`. Reported, never compared — create-only here. */
|
|
229
|
-
'sync-direction': string;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
201
|
export interface PbsSyncJob extends Resource<
|
|
233
202
|
'Pbs.SyncJob',
|
|
234
203
|
SyncJobProps,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** PBS 4.2.6-1 verify lifecycle; the SDK owns serialization and typed API errors. */
|
|
2
|
+
import { isResolved } from 'alchemy/Diff';
|
|
3
|
+
import type { Input } from 'alchemy/Input';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import { specGuards } from './resource-guard.ts';
|
|
6
|
+
import type { PbsVerifyJobAttributes, PbsVerifyJobProps } from './pbs-verify-job.ts';
|
|
7
|
+
import { spec } from './pbs-verify-job-spec.ts';
|
|
8
|
+
import { createOne, deleteOne, readOne, updateOne } from './pbs-verify-job-wire.ts';
|
|
9
|
+
|
|
10
|
+
const { guardCreate, guardUpdate } = specGuards(spec);
|
|
11
|
+
|
|
12
|
+
export const handlers = {
|
|
13
|
+
// ⛔ Explicit adoption only: a listed job may be somebody else's only backup policy.
|
|
14
|
+
list: () => Effect.succeed([]),
|
|
15
|
+
read: ({ olds }: { olds: PbsVerifyJobProps }) => readOne(olds),
|
|
16
|
+
diff: ({
|
|
17
|
+
news,
|
|
18
|
+
output,
|
|
19
|
+
}: {
|
|
20
|
+
news: Input<PbsVerifyJobProps>;
|
|
21
|
+
output: PbsVerifyJobAttributes | undefined;
|
|
22
|
+
}) =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
if (!isResolved(news)) return undefined;
|
|
25
|
+
yield* guardCreate(news, output === undefined);
|
|
26
|
+
yield* guardUpdate(news);
|
|
27
|
+
if (output === undefined) return undefined;
|
|
28
|
+
const live = yield* readOne(news);
|
|
29
|
+
if (live === undefined) {
|
|
30
|
+
yield* guardCreate(news, true);
|
|
31
|
+
return { action: 'update' } as const;
|
|
32
|
+
}
|
|
33
|
+
return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
|
|
34
|
+
}),
|
|
35
|
+
reconcile: ({ news }: { news: PbsVerifyJobProps }) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const live = yield* readOne(news);
|
|
38
|
+
yield* guardCreate(news, live === undefined);
|
|
39
|
+
yield* guardUpdate(news);
|
|
40
|
+
if (live === undefined) yield* createOne(news);
|
|
41
|
+
else if (!spec.matches(live, news)) yield* updateOne(news);
|
|
42
|
+
// ⛔ A successful write is not evidence of a job; read it back through the SDK.
|
|
43
|
+
const after = yield* readOne(news);
|
|
44
|
+
if (after === undefined) {
|
|
45
|
+
return yield* Effect.fail(
|
|
46
|
+
new Error(`${spec.path(news)}: write succeeded but job is absent.`),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return after;
|
|
50
|
+
}),
|
|
51
|
+
delete: ({ olds }: { olds: PbsVerifyJobProps }) => deleteOne(olds),
|
|
52
|
+
};
|