@homeflare/alchemy 0.42.0 → 0.43.0
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/litellm/budget-errors.d.ts +36 -0
- package/dist/litellm/budget-errors.d.ts.map +1 -0
- package/dist/litellm/budget-form.d.ts +50 -0
- package/dist/litellm/budget-form.d.ts.map +1 -0
- package/dist/litellm/budget-operations.d.ts +20 -0
- package/dist/litellm/budget-operations.d.ts.map +1 -0
- package/dist/litellm/budget.d.ts +61 -0
- package/dist/litellm/budget.d.ts.map +1 -0
- package/dist/litellm/fake-litellm.d.ts +5 -0
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +2 -0
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +125 -2
- package/dist/litellm/index.js.map +9 -5
- package/dist/litellm/operations.d.ts +22 -0
- package/dist/litellm/operations.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +1 -1
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role-wire.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
- package/dist/openbao/index.js +9 -6
- package/dist/openbao/index.js.map +4 -4
- package/dist/postgres/connection.d.ts +20 -2
- package/dist/postgres/connection.d.ts.map +1 -1
- package/dist/postgres/database-sql.d.ts +1 -1
- package/dist/postgres/database-sql.d.ts.map +1 -1
- package/dist/postgres/database.d.ts +1 -1
- package/dist/postgres/database.d.ts.map +1 -1
- package/dist/postgres/index.d.ts +5 -3
- package/dist/postgres/index.d.ts.map +1 -1
- package/dist/postgres/index.js +124 -38
- package/dist/postgres/index.js.map +8 -7
- package/dist/postgres/providers.d.ts +4 -2
- package/dist/postgres/providers.d.ts.map +1 -1
- package/dist/postgres/psql-executor.d.ts +21 -0
- package/dist/postgres/psql-executor.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag-types.d.ts +57 -0
- package/dist/proxmox/ceph-flag-types.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag-wire.d.ts +77 -0
- package/dist/proxmox/ceph-flag-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag.d.ts +15 -50
- package/dist/proxmox/ceph-flag.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +10 -13
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/index.js +772 -740
- package/dist/proxmox/index.js.map +8 -7
- package/docs/litellm-budget.md +55 -0
- package/docs/litellm.md +7 -6
- package/docs/postgres.md +22 -0
- package/docs/proxmox-sdk-migration.md +45 -0
- package/package.json +3 -3
- package/src/litellm/budget-errors.ts +38 -0
- package/src/litellm/budget-form.ts +94 -0
- package/src/litellm/budget-operations.ts +51 -0
- package/src/litellm/budget.test.ts +105 -0
- package/src/litellm/budget.ts +127 -0
- package/src/litellm/fake-litellm.ts +50 -1
- package/src/litellm/index.ts +13 -0
- package/src/litellm/operations.ts +1 -1
- package/src/litellm/providers.ts +4 -1
- package/src/openbao/auth-role-form.test.ts +37 -0
- package/src/openbao/auth-role-form.ts +12 -1
- package/src/openbao/auth-role-wire.test.ts +1 -0
- package/src/openbao/auth-role-wire.ts +13 -1
- package/src/openbao/distilled-retry.test.ts +1 -0
- package/src/openbao/fake-engines-roles.ts +7 -1
- package/src/postgres/connection.ts +37 -3
- package/src/postgres/database-sql.ts +4 -3
- package/src/postgres/database.ts +3 -2
- package/src/postgres/index.ts +10 -3
- package/src/postgres/providers.ts +15 -2
- package/src/postgres/psql-executor.test.ts +106 -0
- package/src/postgres/psql-executor.ts +130 -0
- package/src/proxmox/ceph-flag-types.ts +70 -0
- package/src/proxmox/ceph-flag-wire.test.ts +72 -0
- package/src/proxmox/ceph-flag-wire.ts +123 -0
- package/src/proxmox/ceph-flag.ts +77 -130
- package/src/proxmox/ceph-osd-write.ts +10 -13
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `PgExecutor` that runs SQL through a caller-supplied command runner instead of a socket.
|
|
3
|
+
*
|
|
4
|
+
* For a host whose Postgres listens on loopback only inside a container (CT100: rootful podman
|
|
5
|
+
* container `postgres`, local socket, user `postgres`, no password): the consumer's runner is
|
|
6
|
+
* `ssh <host> sudo -n podman exec -i postgres <argv…>`, and this file only builds the `psql`
|
|
7
|
+
* argv and stdin and parses the answer.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ NO ESTATE VALUE LIVES HERE (public kit). The ssh destination, container name and sudo
|
|
10
|
+
* allowlist are the runner's, in the consuming stack.
|
|
11
|
+
* ⛔ `psql` HAS NO BIND PARAMETERS OVER STDIN. Every `$n` is replaced by a `quoteStringLiteral`
|
|
12
|
+
* literal, and ONLY string params are accepted — this family binds a database or role name,
|
|
13
|
+
* nothing else — so a number or object is a refusal, never silently stringified.
|
|
14
|
+
* ★ ROWS COME BACK AS JSON: a `SELECT` is wrapped in `json_agg`, so `oid` and `datconnlimit`
|
|
15
|
+
* arrive as JS numbers exactly as the socket client decodes them (database-sql.ts, no bigint).
|
|
16
|
+
* ★ `VERBOSITY=verbose` puts the SQLSTATE in the error line, so `42P04` (duplicate database)
|
|
17
|
+
* stays recognisable to `isDuplicateDatabaseRace`.
|
|
18
|
+
*/
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import {
|
|
21
|
+
ConnectionError,
|
|
22
|
+
SqlError,
|
|
23
|
+
SqlSyntaxError,
|
|
24
|
+
UnknownError,
|
|
25
|
+
} from 'effect/unstable/sql/SqlError';
|
|
26
|
+
import { type PgExecutor, quoteStringLiteral } from './database-sql.ts';
|
|
27
|
+
|
|
28
|
+
export interface PsqlResult {
|
|
29
|
+
readonly code: number;
|
|
30
|
+
readonly stdout: string;
|
|
31
|
+
readonly stderr: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Runs one `psql` argv (starting with `psql`) with `stdin` and answers its exit. The consumer's
|
|
35
|
+
* implementation prepends the transport (`ssh … sudo -n podman exec -i <container>`) and owns
|
|
36
|
+
* the timeout and output bound: the kit sets neither. */
|
|
37
|
+
export type PsqlRunner = (call: {
|
|
38
|
+
readonly argv: readonly string[];
|
|
39
|
+
readonly stdin: string;
|
|
40
|
+
}) => Promise<PsqlResult>;
|
|
41
|
+
|
|
42
|
+
export interface PsqlTarget {
|
|
43
|
+
readonly database: string;
|
|
44
|
+
readonly username: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Replace `$1…$n` with string literals; refuses a non-string param or a missing one. */
|
|
48
|
+
export const inlineParams = (sql: string, params: ReadonlyArray<unknown>): string =>
|
|
49
|
+
params.length === 0
|
|
50
|
+
? sql
|
|
51
|
+
: sql.replace(/\$(\d+)/g, (_match, index: string) => {
|
|
52
|
+
const value = params[Number(index) - 1];
|
|
53
|
+
if (typeof value !== 'string') {
|
|
54
|
+
throw new Error(`psql executor: param $${index} must be a string, got ${typeof value}`);
|
|
55
|
+
}
|
|
56
|
+
return quoteStringLiteral(value);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Postgres `to_json` emits the `oid` type as a string; the socket driver returns a number. */
|
|
60
|
+
const normalizeRow = (row: unknown): unknown =>
|
|
61
|
+
typeof row === 'object' && row !== null && typeof (row as { oid?: unknown }).oid === 'string'
|
|
62
|
+
? { ...row, oid: Number((row as { oid: string }).oid) }
|
|
63
|
+
: row;
|
|
64
|
+
|
|
65
|
+
const isSelect = (sql: string): boolean => /^\s*SELECT\b/i.test(sql);
|
|
66
|
+
|
|
67
|
+
const wrapRows = (sql: string): string =>
|
|
68
|
+
`SELECT coalesce(json_agg(t), '[]'::json)::text FROM (${sql}) t;`;
|
|
69
|
+
|
|
70
|
+
const failure = (operation: string, result: PsqlResult): SqlError => {
|
|
71
|
+
const state = /ERROR:\s+([0-9A-Z]{5}):/.exec(result.stderr)?.[1];
|
|
72
|
+
const fields = {
|
|
73
|
+
cause: Object.assign(
|
|
74
|
+
new Error(result.stderr.trim()),
|
|
75
|
+
state === undefined ? {} : { code: state },
|
|
76
|
+
),
|
|
77
|
+
message: result.stderr.trim(),
|
|
78
|
+
operation,
|
|
79
|
+
};
|
|
80
|
+
const reason =
|
|
81
|
+
state === undefined
|
|
82
|
+
? new ConnectionError(fields)
|
|
83
|
+
: state.startsWith('42')
|
|
84
|
+
? new SqlSyntaxError(fields)
|
|
85
|
+
: new UnknownError(fields);
|
|
86
|
+
return new SqlError({ reason });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const makePsqlExecutor = (run: PsqlRunner, target: PsqlTarget): PgExecutor => ({
|
|
90
|
+
unsafe: <A extends object>(sql: string, params: ReadonlyArray<unknown> = []) =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const inlined = yield* Effect.try({
|
|
93
|
+
try: () => inlineParams(sql, params),
|
|
94
|
+
catch: (cause) =>
|
|
95
|
+
new SqlError({ reason: new UnknownError({ cause, operation: 'psql inline params' }) }),
|
|
96
|
+
});
|
|
97
|
+
const rows = isSelect(inlined);
|
|
98
|
+
const result = yield* Effect.tryPromise({
|
|
99
|
+
try: () =>
|
|
100
|
+
run({
|
|
101
|
+
argv: [
|
|
102
|
+
'psql',
|
|
103
|
+
'-X',
|
|
104
|
+
'-q',
|
|
105
|
+
'-A',
|
|
106
|
+
'-t',
|
|
107
|
+
'-v',
|
|
108
|
+
'ON_ERROR_STOP=1',
|
|
109
|
+
'-v',
|
|
110
|
+
'VERBOSITY=verbose',
|
|
111
|
+
'-U',
|
|
112
|
+
target.username,
|
|
113
|
+
'-d',
|
|
114
|
+
target.database,
|
|
115
|
+
],
|
|
116
|
+
stdin: rows ? wrapRows(inlined) : `${inlined};`,
|
|
117
|
+
}),
|
|
118
|
+
catch: (cause) =>
|
|
119
|
+
new SqlError({ reason: new ConnectionError({ cause, operation: 'psql exec' }) }),
|
|
120
|
+
});
|
|
121
|
+
if (result.code !== 0) return yield* Effect.fail(failure(inlined.slice(0, 40), result));
|
|
122
|
+
if (!rows) return [] as ReadonlyArray<A>;
|
|
123
|
+
const parsed = yield* Effect.try({
|
|
124
|
+
try: () => (JSON.parse(result.stdout.trim() || '[]') as unknown[]).map(normalizeRow),
|
|
125
|
+
catch: (cause) =>
|
|
126
|
+
new SqlError({ reason: new UnknownError({ cause, operation: 'psql parse output' }) }),
|
|
127
|
+
});
|
|
128
|
+
return parsed as ReadonlyArray<A>;
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flag names, props and attributes for `Proxmox.CephFlag`.
|
|
3
|
+
*
|
|
4
|
+
* ★ SPLIT OUT OF ceph-flag.ts FOR THE 250-LINE CAP. The comments stay with the types they
|
|
5
|
+
* explain; nothing here talks to the cluster.
|
|
6
|
+
*/
|
|
7
|
+
import type { WithTarget } from './resource-spec.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PVE's eleven flags, spelled as `PVE::Ceph::Tools::get_possible_osd_flags` spells them.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ THE ENUM IS CLOSED AND A TYPO IS A 400, WHICH IS WHY THIS IS A UNION AND NOT `string`. Both
|
|
13
|
+
* the GET and the PUT declare `additionalProperties => 0` over exactly this list.
|
|
14
|
+
* ⚠️ CEPH HAS FLAGS PVE DOES NOT MODEL, and four of them are always on. MEASURED on C1:
|
|
15
|
+
* `ceph osd dump` reports `flags sortbitwise,recovery_deletes,purged_snapdirs,pglog_hardlimit`.
|
|
16
|
+
* PVE reads that same string and answers only about its own eleven, so the others can neither
|
|
17
|
+
* leak in here nor be set from here — `noautoscale` and `nosnaptrim` included.
|
|
18
|
+
* ⛔ IF SOMEBODY LATER "IMPROVES" THE READ BY PARSING `osd dump` DIRECTLY, those four become
|
|
19
|
+
* permanently-set flags that nothing declares: a forever-diff on a brand new cluster.
|
|
20
|
+
*/
|
|
21
|
+
export type CephFlagName =
|
|
22
|
+
| 'nobackfill'
|
|
23
|
+
| 'nodeep-scrub'
|
|
24
|
+
| 'nodown'
|
|
25
|
+
| 'noin'
|
|
26
|
+
| 'noout'
|
|
27
|
+
| 'norebalance'
|
|
28
|
+
| 'norecover'
|
|
29
|
+
| 'noscrub'
|
|
30
|
+
| 'notieragent'
|
|
31
|
+
| 'noup'
|
|
32
|
+
| 'pause';
|
|
33
|
+
|
|
34
|
+
export interface CephFlagProps extends WithTarget {
|
|
35
|
+
/**
|
|
36
|
+
* Which flag. PVE's primary key here, and the last segment of the path.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ EDITING IT IN PLACE ORPHANS THE OLD FLAG RATHER THAN MOVING ANYTHING. `diff` reads the NEW
|
|
39
|
+
* path and reconcile writes it, while the old flag keeps whatever this stack last put there.
|
|
40
|
+
* There is deliberately no replace override for it — acl.ts needs one because its `delete`
|
|
41
|
+
* removes a real grant, and `delete` here is inert by design, so a replace would do exactly
|
|
42
|
+
* what an update already does. Declare a second resource and set the old one to `value: false`
|
|
43
|
+
* rather than renaming this one.
|
|
44
|
+
* ⛔ TWO RESOURCES DECLARING THE SAME FLAG ARE ONE CLUSTER OBJECT, and Alchemy sees two ids
|
|
45
|
+
* rather than a collision — the acl.ts hazard exactly. Disagreeing, they take turns winning
|
|
46
|
+
* and BOTH plan `update` for ever; agreeing, deleting either leaves the flag where the other
|
|
47
|
+
* put it. One declaration per flag per cluster.
|
|
48
|
+
*/
|
|
49
|
+
flag: CephFlagName;
|
|
50
|
+
/**
|
|
51
|
+
* Set the flag (`true`) or clear it (`false`).
|
|
52
|
+
*
|
|
53
|
+
* ⛔ REQUIRED, WITH NO DEFAULT — see the second ⛔ in the header, which is the whole safety
|
|
54
|
+
* argument for this family. It is also the only field PVE accepts on this endpoint, and it is
|
|
55
|
+
* not optional in the schema either: omitting it from the form is a 400, not an untouched
|
|
56
|
+
* flag. (The BULK endpoint is the one where omission means "leave it alone"; this is not it.)
|
|
57
|
+
*/
|
|
58
|
+
value: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CephFlagAttributes {
|
|
62
|
+
/**
|
|
63
|
+
* ⚠️ REPORTED, NEVER COMPARED. It is the path key the read was made WITH, copied back out of
|
|
64
|
+
* props, so comparing it against props would be true by construction — the same reasoning
|
|
65
|
+
* acl.ts gives for the four identity fields it also declines to diff.
|
|
66
|
+
*/
|
|
67
|
+
flag: CephFlagName;
|
|
68
|
+
/** Whether ceph has the flag set right now. The only field `matches` looks at. */
|
|
69
|
+
value: boolean;
|
|
70
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the two Opus findings on the CephFlag transport (kit PR 319, 2026-09-28).
|
|
3
|
+
*
|
|
4
|
+
* 1. A `{"data":null}` body becomes `{}` inside distilled's `transformResponse` before the
|
|
5
|
+
* provider sees it. Guarding only `null`/`undefined` lets `bool({})` read a missing answer
|
|
6
|
+
* as "flag clear".
|
|
7
|
+
* 2. A refused mint must not fail `diff`. Alchemy fails the whole plan if one resource's
|
|
8
|
+
* `diff` fails (unreadable-read.ts).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
14
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
15
|
+
import { scalarFlagOrDie } from './ceph-flag-wire.ts';
|
|
16
|
+
import { ProxmoxCephFlag, ProxmoxCephFlagProvider } from './ceph-flag.ts';
|
|
17
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
18
|
+
|
|
19
|
+
const flagProps = { flag: 'noout' as const, target: FAKE_TARGET, value: true };
|
|
20
|
+
|
|
21
|
+
describe('CephFlag GET of no data dies instead of reading clear', () => {
|
|
22
|
+
test('the post-transform empty object dies', async () => {
|
|
23
|
+
// distilled protocol.ts maps `{"data":null}` to `{}` before a RawResponseRoot decode.
|
|
24
|
+
await expect(Effect.runPromise(scalarFlagOrDie({}, flagProps))).rejects.toThrow(
|
|
25
|
+
/not a bare boolean/,
|
|
26
|
+
);
|
|
27
|
+
await expect(Effect.runPromise(scalarFlagOrDie(null, flagProps))).rejects.toThrow(
|
|
28
|
+
/not a bare boolean/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a real scalar still decodes', async () => {
|
|
33
|
+
expect(await Effect.runPromise(scalarFlagOrDie(1, flagProps))).toBe(1);
|
|
34
|
+
expect(await Effect.runPromise(scalarFlagOrDie(false, flagProps))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('a refused CephFlag read does not abort the plan', () => {
|
|
39
|
+
test('verify after a 403 mint reports noop, not a failed plan', async () => {
|
|
40
|
+
let refuse = false;
|
|
41
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
42
|
+
const request =
|
|
43
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
44
|
+
const url = new URL(request.url);
|
|
45
|
+
if (url.pathname.includes('/creds/')) {
|
|
46
|
+
if (refuse) {
|
|
47
|
+
return Response.json({ errors: ['status 403'] }, { status: 403 });
|
|
48
|
+
}
|
|
49
|
+
return Response.json({
|
|
50
|
+
data: { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' },
|
|
51
|
+
lease_duration: 0,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return Response.json({ data: 1 });
|
|
55
|
+
};
|
|
56
|
+
const layer = FetchHttpClient.layer.pipe(
|
|
57
|
+
Layer.provideMerge(
|
|
58
|
+
Layer.succeed(
|
|
59
|
+
FetchHttpClient.Fetch,
|
|
60
|
+
Object.assign(stub, { preconnect: globalThis.fetch.preconnect }) as typeof fetch,
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
const declare = () => ProxmoxCephFlag('noout', flagProps);
|
|
65
|
+
await withoutBao(async () => {
|
|
66
|
+
const engine = engineOver(ProxmoxCephFlagProvider().pipe(Layer.provideMerge(layer)));
|
|
67
|
+
expect(await engine.deploy(declare())).toEqual({ noout: 'adopted' });
|
|
68
|
+
refuse = true;
|
|
69
|
+
await expect(engine.verify(declare(), { all: true })).resolves.toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.CephFlag`'s distilled wire lane: the read and the single-flag write.
|
|
3
|
+
*
|
|
4
|
+
* ★ SPLIT OUT OF ceph-flag.ts FOR THE 250-LINE CAP, mirroring ceph-pool-wire.ts's split by "touches
|
|
5
|
+
* the cluster or not" — ceph-flag.ts keeps the header, the props/attributes shapes, `matches` and
|
|
6
|
+
* the provider wiring; this file is the only place that calls `runPve` for this family.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ NEITHER OPERATION HERE DECLARES A TYPED ABSENCE (`errors: []` on both `getClusterCephFlag` and
|
|
9
|
+
* `putClusterCephFlag`, MEASURED in distilled-proxmox/src/services/cluster.ts) — UNLIKE
|
|
10
|
+
* ceph-pool-wire.ts's `CephPoolNotFound`. All eleven flags always exist (ceph-flag.ts's own ⛔ on
|
|
11
|
+
* `CephFlagName` and on the old unreachable create branch), so there is no absence case to fold
|
|
12
|
+
* here. `readClusterCephFlag` therefore has no `catchTag` at all and lets every failure — a
|
|
13
|
+
* refused credential, a timeout, cluster exhaustion — propagate to the caller exactly as it
|
|
14
|
+
* arrived, rather than inventing an "absent" this family does not have. Simpler than
|
|
15
|
+
* ceph-pool-wire.ts's case, not harder.
|
|
16
|
+
* ⚠️ THIS IS A BEHAVIOR CHANGE FROM THE PRE-MIGRATION FACTORY, AND A DELIBERATE ONE. The old
|
|
17
|
+
* `pveOperations.read` folded EVERY failure into `undefined` via `Effect.orElseSucceed`, so an
|
|
18
|
+
* expired lease or node-b being down looked identical to "the flag is absent" (which cannot
|
|
19
|
+
* happen — see ceph-flag.ts). Now such a failure propagates as itself: a caller sees the real
|
|
20
|
+
* error (an expired 300s lease, node-b down, ceph not configured) rather than a misleading
|
|
21
|
+
* "absent" that would have sent `diff`/`reconcile` down a create path this endpoint has never
|
|
22
|
+
* implemented. Same advice as the old comment gave, sharper because the failure is no longer
|
|
23
|
+
* disguised.
|
|
24
|
+
*
|
|
25
|
+
* ⛔ THE PER-FLAG PUT IS SYNCHRONOUS (`$rados->mon_command` inline, MEASURED in Ceph.pm — see
|
|
26
|
+
* ceph-flag.ts's header), which is what makes reading back immediately after
|
|
27
|
+
* `writeClusterCephFlag` fair rather than a race against a worker task, unlike ceph-pool.ts's
|
|
28
|
+
* forked-worker writes.
|
|
29
|
+
*/
|
|
30
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
31
|
+
import * as Effect from 'effect/Effect';
|
|
32
|
+
import type { CephFlagAttributes, CephFlagProps } from './ceph-flag.ts';
|
|
33
|
+
import { runPve } from './distilled-pve.ts';
|
|
34
|
+
import { readOrUnreadable } from './unreadable-read.ts';
|
|
35
|
+
import { bool, flag } from './values.ts';
|
|
36
|
+
|
|
37
|
+
/** The one endpoint this family writes, as `guardWrite` (distilled-guard.ts) keys it. */
|
|
38
|
+
export const CEPH_FLAG_UPDATE = 'pve:PUT /cluster/ceph/flags/{flag}';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ⚠️ `live` IS A BARE BOOLEAN HERE, NOT AN OBJECT, AND IT IS HANDED TO `bool` WHOLE ON PURPOSE.
|
|
42
|
+
* `get_flag` returns perl `1` or `0` under a `type => 'boolean'` return schema, so the wire is
|
|
43
|
+
* `{"data":0}` or `{"data":false}` depending on how the REST layer renders it — matching
|
|
44
|
+
* distilled's `GetClusterCephFlagResponse = unknown` (`S.Unknown.pipe(T.RawResponseRoot())`).
|
|
45
|
+
* `bool` accepts every one of those spellings, which is exactly why it is a shared coercion.
|
|
46
|
+
* ⚠️ THE SCALAR IS A MEASURED PROPERTY OF THIS PVE, NOT A PROMISE. A later version wrapping the
|
|
47
|
+
* answer in an object would make `bool` read a SET flag as false, and `value: false` would then
|
|
48
|
+
* plan `noop` over a live flag — silently. Re-measure `GET /cluster/ceph/flags/noout` after a
|
|
49
|
+
* major upgrade; cheaper than a second parse path.
|
|
50
|
+
*/
|
|
51
|
+
const attributesOf = (live: unknown, props: CephFlagProps): CephFlagAttributes => ({
|
|
52
|
+
flag: props.flag,
|
|
53
|
+
value: bool(live),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ⛔ DIE ON THE POST-TRANSFORM SHAPE, NOT THE PRE-TRANSFORM ONE. `bool` treats a missing value as
|
|
58
|
+
* `false` because most callers hand it an optional PVE form field where that is correct — but
|
|
59
|
+
* here it would read an unmeasured "no answer" as "flag is clear". distilled's
|
|
60
|
+
* `transformResponse` maps a `{"data":null}` body to `{}` BEFORE this function sees it (a shape
|
|
61
|
+
* `get_flag`'s `type => 'boolean'` schema does not document and this PVE has never produced).
|
|
62
|
+
* Guarding only `null`/`undefined` lets that `{}` through, and `bool({})` is `false` — a plan
|
|
63
|
+
* of `noop` over a set brake, with no read-back guard, because `reconcile`'s guard only fires
|
|
64
|
+
* on a write. A success here is a scalar (`true`/`false`/`0`/`1`/`'0'`/`'1'`). Anything else,
|
|
65
|
+
* including the empty object the protocol substitutes for null, is the unmeasured shape.
|
|
66
|
+
*/
|
|
67
|
+
export const scalarFlagOrDie = (live: unknown, props: CephFlagProps) => {
|
|
68
|
+
const scalar =
|
|
69
|
+
typeof live === 'boolean' || live === 0 || live === 1 || live === '0' || live === '1';
|
|
70
|
+
return scalar
|
|
71
|
+
? Effect.succeed(live)
|
|
72
|
+
: Effect.die(
|
|
73
|
+
new Error(
|
|
74
|
+
`cluster/ceph/flags/${props.flag}: GET answered ${JSON.stringify(live)}, not a bare ` +
|
|
75
|
+
"boolean. This endpoint's success is a scalar; an empty object is what the protocol " +
|
|
76
|
+
'substitutes for `{"data":null}`. Check `ceph osd dump | head -1` and the cluster ' +
|
|
77
|
+
'log rather than trust a coerced `false`.',
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The live flag, as attributes.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ NO ABSENCE TAG, ON PURPOSE. Both operations declare `errors: []` (see the file header), so
|
|
86
|
+
* there is nothing to `catchTag` except `PveCredentialDenied`, and that one is caught by the
|
|
87
|
+
* caller that plans (`readClusterCephFlagForDiff`) — not here. `read` and `reconcile` must
|
|
88
|
+
* still see a refused mint as itself.
|
|
89
|
+
*/
|
|
90
|
+
export const readClusterCephFlag = (props: CephFlagProps) =>
|
|
91
|
+
runPve(props.target, 'read', false, cluster.getClusterCephFlag({ flag: props.flag })).pipe(
|
|
92
|
+
Effect.flatMap((live) => scalarFlagOrDie(live, props)),
|
|
93
|
+
Effect.map((live) => attributesOf(live, props)),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The same read, with a refused mint turned into `UNREADABLE`.
|
|
98
|
+
*
|
|
99
|
+
* ⛔ `diff` MUST NOT FAIL ON A REFUSED READ. Alchemy runs every resource's `diff` under
|
|
100
|
+
* `Effect.exit` and fails the whole plan if one fails (unreadable-read.ts). A lane that cannot
|
|
101
|
+
* mint `read` would otherwise abort every other row, not just this flag. `read` and
|
|
102
|
+
* `reconcile` keep the propagating form above.
|
|
103
|
+
*/
|
|
104
|
+
export const readClusterCephFlagForDiff = (props: CephFlagProps) =>
|
|
105
|
+
readOrUnreadable(readClusterCephFlag(props));
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* ⚠️ ONLY `value` GOES IN THE BODY. `flag` is already bound by the path and the PUT declares
|
|
109
|
+
* `additionalProperties => 0`, so a second copy in the form could only disagree with the URL.
|
|
110
|
+
* ⚠️ THE `?? '0'` IS UNREACHABLE, AND IS WRITTEN RATHER THAN CAST AWAY. `flag()` types its answer
|
|
111
|
+
* `string | undefined` because it serves OPTIONAL props, and `value` here is required, so the
|
|
112
|
+
* branch cannot be taken; a cast would claim a proof the type system has not made.
|
|
113
|
+
*/
|
|
114
|
+
export const updateForm = (props: CephFlagProps) => ({ value: flag(props.value) ?? '0' });
|
|
115
|
+
|
|
116
|
+
/** The actual `putClusterCephFlag` call — `updateForm`'s own `value`, so the two cannot disagree. */
|
|
117
|
+
export const writeClusterCephFlag = (props: CephFlagProps) =>
|
|
118
|
+
runPve(
|
|
119
|
+
props.target,
|
|
120
|
+
'provision',
|
|
121
|
+
true,
|
|
122
|
+
cluster.putClusterCephFlag({ flag: props.flag, value: updateForm(props).value }),
|
|
123
|
+
);
|
package/src/proxmox/ceph-flag.ts
CHANGED
|
@@ -73,73 +73,24 @@
|
|
|
73
73
|
* "Permission check failed (/, Sys.Modify)" on the first deploy, and widen deliberately.
|
|
74
74
|
*/
|
|
75
75
|
import { Resource } from 'alchemy';
|
|
76
|
+
import { isResolved } from 'alchemy/Diff';
|
|
76
77
|
import * as Provider from 'alchemy/Provider';
|
|
77
78
|
import * as Effect from 'effect/Effect';
|
|
78
|
-
import {
|
|
79
|
-
|
|
79
|
+
import {
|
|
80
|
+
CEPH_FLAG_UPDATE,
|
|
81
|
+
readClusterCephFlag,
|
|
82
|
+
readClusterCephFlagForDiff,
|
|
83
|
+
updateForm,
|
|
84
|
+
writeClusterCephFlag,
|
|
85
|
+
} from './ceph-flag-wire.ts';
|
|
86
|
+
import { mintTier } from './credentials.ts';
|
|
87
|
+
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
88
|
+
import { guardWrite } from './distilled-guard.ts';
|
|
89
|
+
import type { CephFlagAttributes, CephFlagProps } from './ceph-flag-types.ts';
|
|
90
|
+
import type { PveRequirements } from './resource-spec.ts';
|
|
91
|
+
import { formToSend } from './update-guard.ts';
|
|
80
92
|
|
|
81
|
-
|
|
82
|
-
* PVE's eleven flags, spelled as `PVE::Ceph::Tools::get_possible_osd_flags` spells them.
|
|
83
|
-
*
|
|
84
|
-
* ⚠️ THE ENUM IS CLOSED AND A TYPO IS A 400, WHICH IS WHY THIS IS A UNION AND NOT `string`. Both
|
|
85
|
-
* the GET and the PUT declare `additionalProperties => 0` over exactly this list.
|
|
86
|
-
* ⚠️ CEPH HAS FLAGS PVE DOES NOT MODEL, and four of them are always on. MEASURED on C1:
|
|
87
|
-
* `ceph osd dump` reports `flags sortbitwise,recovery_deletes,purged_snapdirs,pglog_hardlimit`.
|
|
88
|
-
* PVE reads that same string and answers only about its own eleven, so the others can neither
|
|
89
|
-
* leak in here nor be set from here — `noautoscale` and `nosnaptrim` included.
|
|
90
|
-
* ⛔ IF SOMEBODY LATER "IMPROVES" THE READ BY PARSING `osd dump` DIRECTLY, those four become
|
|
91
|
-
* permanently-set flags that nothing declares: a forever-diff on a brand new cluster.
|
|
92
|
-
*/
|
|
93
|
-
export type CephFlagName =
|
|
94
|
-
| 'nobackfill'
|
|
95
|
-
| 'nodeep-scrub'
|
|
96
|
-
| 'nodown'
|
|
97
|
-
| 'noin'
|
|
98
|
-
| 'noout'
|
|
99
|
-
| 'norebalance'
|
|
100
|
-
| 'norecover'
|
|
101
|
-
| 'noscrub'
|
|
102
|
-
| 'notieragent'
|
|
103
|
-
| 'noup'
|
|
104
|
-
| 'pause';
|
|
105
|
-
|
|
106
|
-
export interface CephFlagProps extends WithTarget {
|
|
107
|
-
/**
|
|
108
|
-
* Which flag. PVE's primary key here, and the last segment of the path.
|
|
109
|
-
*
|
|
110
|
-
* ⚠️ EDITING IT IN PLACE ORPHANS THE OLD FLAG RATHER THAN MOVING ANYTHING. `diff` reads the NEW
|
|
111
|
-
* path and reconcile writes it, while the old flag keeps whatever this stack last put there.
|
|
112
|
-
* There is deliberately no replace override for it — acl.ts needs one because its `delete`
|
|
113
|
-
* removes a real grant, and `delete` here is inert by design, so a replace would do exactly
|
|
114
|
-
* what an update already does. Declare a second resource and set the old one to `value: false`
|
|
115
|
-
* rather than renaming this one.
|
|
116
|
-
* ⛔ TWO RESOURCES DECLARING THE SAME FLAG ARE ONE CLUSTER OBJECT, and Alchemy sees two ids
|
|
117
|
-
* rather than a collision — the acl.ts hazard exactly. Disagreeing, they take turns winning
|
|
118
|
-
* and BOTH plan `update` for ever; agreeing, deleting either leaves the flag where the other
|
|
119
|
-
* put it. One declaration per flag per cluster.
|
|
120
|
-
*/
|
|
121
|
-
flag: CephFlagName;
|
|
122
|
-
/**
|
|
123
|
-
* Set the flag (`true`) or clear it (`false`).
|
|
124
|
-
*
|
|
125
|
-
* ⛔ REQUIRED, WITH NO DEFAULT — see the second ⛔ in the header, which is the whole safety
|
|
126
|
-
* argument for this family. It is also the only field PVE accepts on this endpoint, and it is
|
|
127
|
-
* not optional in the schema either: omitting it from the form is a 400, not an untouched
|
|
128
|
-
* flag. (The BULK endpoint is the one where omission means "leave it alone"; this is not it.)
|
|
129
|
-
*/
|
|
130
|
-
value: boolean;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export interface CephFlagAttributes {
|
|
134
|
-
/**
|
|
135
|
-
* ⚠️ REPORTED, NEVER COMPARED. It is the path key the read was made WITH, copied back out of
|
|
136
|
-
* props, so comparing it against props would be true by construction — the same reasoning
|
|
137
|
-
* acl.ts gives for the four identity fields it also declines to diff.
|
|
138
|
-
*/
|
|
139
|
-
flag: CephFlagName;
|
|
140
|
-
/** Whether ceph has the flag set right now. The only field `matches` looks at. */
|
|
141
|
-
value: boolean;
|
|
142
|
-
}
|
|
93
|
+
export type { CephFlagAttributes, CephFlagName, CephFlagProps } from './ceph-flag-types.ts';
|
|
143
94
|
|
|
144
95
|
export interface ProxmoxCephFlag extends Resource<
|
|
145
96
|
'Proxmox.CephFlag',
|
|
@@ -151,86 +102,82 @@ export interface ProxmoxCephFlag extends Resource<
|
|
|
151
102
|
|
|
152
103
|
export const ProxmoxCephFlag = Resource<ProxmoxCephFlag>('Proxmox.CephFlag');
|
|
153
104
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
* would then plan `noop` over a live flag — silently. Re-measure
|
|
166
|
-
* `GET /cluster/ceph/flags/noout` after a major upgrade; cheaper than a second parse path.
|
|
167
|
-
*
|
|
168
|
-
* ⛔ IT NEVER ANSWERS undefined, WHICH MAKES THE CREATE BRANCH UNREACHABLE, AND ITS ERROR WILL
|
|
169
|
-
* MISLEAD YOU. All eleven flags always exist; there is nothing for "absent" to mean. So a POST
|
|
170
|
-
* to `collection` means the READ failed — an expired 300s lease, node-b down, or ceph simply not
|
|
171
|
-
* configured on the cluster (every one of these four handlers opens with
|
|
172
|
-
* `check_ceph_configured()`, which dies). Read the resulting "Method 'POST /cluster/ceph/flags'
|
|
173
|
-
* not implemented" as "the read failed" and go and look at the credential, not at ceph. Same
|
|
174
|
-
* shape as the unreachable POST in acl.ts, same advice.
|
|
175
|
-
*/
|
|
176
|
-
attributes: (live, props) => ({ flag: props.flag, value: bool(live) }),
|
|
177
|
-
collection: () => 'cluster/ceph/flags',
|
|
178
|
-
/**
|
|
179
|
-
* ⛔ UNREACHABLE — see the second ⛔ in `attributes`. It is written in the BULK endpoint's shape
|
|
180
|
-
* (the flag name is the parameter, not `value`) rather than left empty, because that is the
|
|
181
|
-
* only body `cluster/ceph/flags` has ever documented. Nothing sends it today.
|
|
182
|
-
*/
|
|
183
|
-
createForm: (props) => ({ [props.flag]: flag(props.value) ?? '0' }),
|
|
184
|
-
endpoint: { update: 'pve:PUT /cluster/ceph/flags/{flag}' }, // ⛔ No create: see above.
|
|
185
|
-
/**
|
|
186
|
-
* ⚠️ `value` IS THE ONLY THING COMPARED, AND THE LIST OF THINGS DELIBERATELY NOT COMPARED IS THE
|
|
187
|
-
* point of this resource. `flag` is props-derived (see `CephFlagAttributes`). `description`
|
|
188
|
-
* and `name` exist only on the bulk GET, which this file does not read, so neither can be
|
|
189
|
-
* compared by accident. Nothing PVE returns here is rewritten, re-ordered or re-typed by the
|
|
190
|
-
* cluster, because all PVE returns here is one boolean.
|
|
191
|
-
* ★ MEASURED: on C1, `GET /cluster/ceph/flags` answers value 0 for all eleven flags, so a
|
|
192
|
-
* declaration of `value: false` reads back false and plans `noop`.
|
|
193
|
-
*/
|
|
194
|
-
matches: (attributes, props) => attributes.value === props.value,
|
|
195
|
-
path: (props) => `cluster/ceph/flags/${props.flag}`,
|
|
196
|
-
/**
|
|
197
|
-
* ⚠️ ONLY `value` GOES IN THE BODY. `flag` is already bound by the path and the PUT declares
|
|
198
|
-
* `additionalProperties => 0`, so a second copy in the form could only disagree with the URL.
|
|
199
|
-
* ⚠️ THE `?? '0'` IS UNREACHABLE, AND IS WRITTEN RATHER THAN CAST AWAY. `flag()` types its
|
|
200
|
-
* answer `string | undefined` because it serves OPTIONAL props, and `value` here is required,
|
|
201
|
-
* so the branch cannot be taken; a cast would claim a proof the type system has not made.
|
|
202
|
-
*/
|
|
203
|
-
updateForm: (props) => ({ value: flag(props.value) ?? '0' }),
|
|
204
|
-
});
|
|
105
|
+
/**
|
|
106
|
+
* ⚠️ `value` IS THE ONLY THING COMPARED, AND THE LIST OF THINGS DELIBERATELY NOT COMPARED IS THE
|
|
107
|
+
* point of this resource. `flag` is props-derived (see `CephFlagAttributes`). `description`
|
|
108
|
+
* and `name` exist only on the bulk GET, which this file does not read, so neither can be
|
|
109
|
+
* compared by accident. Nothing PVE returns here is rewritten, re-ordered or re-typed by the
|
|
110
|
+
* cluster, because all PVE returns here is one boolean.
|
|
111
|
+
* ★ MEASURED: on C1, `GET /cluster/ceph/flags` answers value 0 for all eleven flags, so a
|
|
112
|
+
* declaration of `value: false` reads back false and plans `noop`.
|
|
113
|
+
*/
|
|
114
|
+
const matches = (attributes: CephFlagAttributes, props: CephFlagProps) =>
|
|
115
|
+
attributes.value === props.value;
|
|
205
116
|
|
|
117
|
+
/**
|
|
118
|
+
* ★ MIGRATED OFF `pveHandlers`/`client.ts`'s generic `pve()` ONTO `@distilled.cloud/proxmox`'s
|
|
119
|
+
* typed `cluster.getClusterCephFlag`/`cluster.putClusterCephFlag` (ceph-flag-wire.ts).
|
|
120
|
+
* `pveHandlers` cannot run a distilled operation, so `diff`/`reconcile` are hand-written here,
|
|
121
|
+
* the same way ceph-pool.ts and backup-job.ts hand-write theirs for the same reason.
|
|
122
|
+
*
|
|
123
|
+
* ⛔ THIS FAMILY HAS NO CREATE KEY, AND THAT IS THE VENDOR'S DOING, NOT AN OMISSION HERE. PVE
|
|
124
|
+
* registers only `PUT /cluster/ceph/flags/{flag}`; `POST /cluster/ceph/flags` does not exist
|
|
125
|
+
* (constraints-forms.test.ts's own regression test), which is why the second ⛔ in the header
|
|
126
|
+
* above calls the create branch unreachable — there is no create form and no create endpoint to
|
|
127
|
+
* guard, only `CEPH_FLAG_UPDATE`.
|
|
128
|
+
*/
|
|
206
129
|
export const ProxmoxCephFlagProvider = () =>
|
|
207
130
|
Provider.effect(
|
|
208
131
|
ProxmoxCephFlag,
|
|
209
132
|
Effect.succeed(
|
|
210
133
|
ProxmoxCephFlag.Provider.of({
|
|
211
|
-
/**
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
134
|
+
/** ⛔ Empty, like every resource in this package — see resource.ts's own ⚠️ on why. */
|
|
135
|
+
list: () => Effect.succeed([]),
|
|
136
|
+
read: ({ olds }) => readClusterCephFlag(olds),
|
|
137
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
138
|
+
if (!isResolved(news)) return undefined;
|
|
139
|
+
yield* guardWrite(CEPH_FLAG_UPDATE, updateForm(news), false);
|
|
140
|
+
if (output === undefined) return undefined;
|
|
141
|
+
// ⛔ Never absent — see the second ⛔ above. A failed read other than a refused mint
|
|
142
|
+
// propagates instead of reaching this point. A refused mint must NOT fail `diff`:
|
|
143
|
+
// that aborts the whole plan (unreadable-read.ts). `read` still propagates.
|
|
144
|
+
const live = yield* readClusterCephFlagForDiff(news);
|
|
145
|
+
if (live === UNREADABLE) {
|
|
146
|
+
yield* unreadableWarning(
|
|
147
|
+
'Proxmox.CephFlag',
|
|
148
|
+
news.flag,
|
|
149
|
+
news.target.mount,
|
|
150
|
+
mintTier(news.target, 'read'),
|
|
151
|
+
);
|
|
152
|
+
return { action: 'noop' } as const;
|
|
153
|
+
}
|
|
154
|
+
return { action: matches(live, news) ? 'noop' : 'update' } as const;
|
|
155
|
+
}),
|
|
220
156
|
/** ⛔ Inert, deliberately. The third ⛔ in the header is the whole argument; read it. */
|
|
221
157
|
delete: () => Effect.void,
|
|
222
158
|
/**
|
|
223
|
-
* ⚠️ THE READ-BACK GUARD, RESTORED FOR A FAMILY WHOSE "ABSENT" DOES NOT EXIST.
|
|
224
|
-
* `
|
|
225
|
-
* never missing, so that check
|
|
226
|
-
*
|
|
227
|
-
*
|
|
159
|
+
* ⚠️ THE READ-BACK GUARD, RESTORED FOR A FAMILY WHOSE "ABSENT" DOES NOT EXIST. A generic
|
|
160
|
+
* `reconcile` (resource.ts) refuses when the object is still missing after a write, and
|
|
161
|
+
* a flag is never missing, so that check could never fire here. PVE answers 200 with
|
|
162
|
+
* `{"data":null}` on calls that did nothing, and this PUT's documented return IS null,
|
|
163
|
+
* so the status code carries no evidence at all — only a read-back does.
|
|
228
164
|
* ⚠️ THE PER-FLAG PUT IS SYNCHRONOUS (`$rados->mon_command` inline, MEASURED in Ceph.pm),
|
|
229
165
|
* which is what makes this check fair rather than a race against a worker task. If it
|
|
230
166
|
* ever fires spuriously the honest fix is to look at the mon, not to delete the guard.
|
|
167
|
+
* ⛔ `formToSend` IS WHAT MAKES ADOPTING AN ALREADY-MATCHING FLAG FREE. Alchemy's
|
|
168
|
+
* `adopted` action reaches `reconcile` exactly like `update` does (resource.ts's own ⛔
|
|
169
|
+
* on this), so without this guard adopting a flag that already reads correctly would
|
|
170
|
+
* still fire a PUT — pointless against ceph, and update-guard.ts's header has the
|
|
171
|
+
* incident where the equivalent gap on CephPool forked an unnecessary worker.
|
|
231
172
|
*/
|
|
232
173
|
reconcile: Effect.fn(function* ({ news }) {
|
|
233
|
-
const
|
|
174
|
+
const live = yield* readClusterCephFlag(news);
|
|
175
|
+
const form = formToSend(matches, live, news, updateForm(news));
|
|
176
|
+
if (form !== undefined) {
|
|
177
|
+
yield* guardWrite(CEPH_FLAG_UPDATE, updateForm(news), false);
|
|
178
|
+
yield* writeClusterCephFlag(news);
|
|
179
|
+
}
|
|
180
|
+
const after = yield* readClusterCephFlag(news);
|
|
234
181
|
if (after.value !== news.value) {
|
|
235
182
|
return yield* Effect.die(
|
|
236
183
|
new Error(
|