@homeflare/alchemy 0.41.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/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- 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/credential-errors.d.ts +18 -4
- package/dist/proxmox/credential-errors.d.ts.map +1 -1
- package/dist/proxmox/credentials.d.ts +35 -1
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +805 -757
- package/dist/proxmox/index.js.map +21 -19
- package/dist/proxmox/lease-cache.d.ts +67 -5
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +4 -2
- package/dist/proxmox/mint.d.ts.map +1 -1
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +8 -1
- package/dist/proxmox/unreadable-read.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/docs/credentials.md +14 -1
- 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/acl.ts +7 -1
- package/src/proxmox/api-token.ts +7 -1
- 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
- package/src/proxmox/credential-errors.ts +19 -5
- package/src/proxmox/credentials.test.ts +73 -1
- package/src/proxmox/credentials.ts +72 -1
- package/src/proxmox/group.ts +7 -1
- package/src/proxmox/lease-cache-mint.test.ts +70 -0
- package/src/proxmox/lease-cache.test.ts +83 -7
- package/src/proxmox/lease-cache.ts +74 -10
- package/src/proxmox/mint.ts +12 -5
- package/src/proxmox/node-network.ts +7 -1
- package/src/proxmox/role.ts +7 -1
- package/src/proxmox/storage.ts +7 -1
- package/src/proxmox/unreadable-read.ts +16 -4
- package/src/proxmox/user.ts +7 -1
- package/src/proxmox/zfs-pool.ts +7 -1
|
@@ -9,8 +9,21 @@
|
|
|
9
9
|
* the results under whatever logical grouping it wants.
|
|
10
10
|
*/
|
|
11
11
|
import * as Layer from 'effect/Layer';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
type PostgresConnectionConfig,
|
|
14
|
+
type PostgresRunnerConfig,
|
|
15
|
+
postgresConnection,
|
|
16
|
+
postgresRunnerConnection,
|
|
17
|
+
} from './connection.ts';
|
|
13
18
|
import { PostgresDatabaseProvider } from './database.ts';
|
|
14
19
|
|
|
20
|
+
// ⚠️ `Layer.provideMerge`, NEVER plain `Layer.provide`: the handlers' `withPg` reads
|
|
21
|
+
// `PostgresConnection` when the ENGINE later calls `read`/`reconcile`, and plain `provide`
|
|
22
|
+
// seals it away ("Service not found: Postgres.Connection" — measured for `caddyProviders`,
|
|
23
|
+
// see `caddy/providers.ts`).
|
|
15
24
|
export const postgresProviders = (config: PostgresConnectionConfig) =>
|
|
16
|
-
PostgresDatabaseProvider().pipe(Layer.
|
|
25
|
+
PostgresDatabaseProvider().pipe(Layer.provideMerge(postgresConnection(config)));
|
|
26
|
+
|
|
27
|
+
/** The same provider over a command runner (`psql-executor.ts`) — for a loopback-only cluster. */
|
|
28
|
+
export const postgresRunnerProviders = (config: PostgresRunnerConfig) =>
|
|
29
|
+
PostgresDatabaseProvider().pipe(Layer.provideMerge(postgresRunnerConnection(config)));
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import * as Effect from 'effect/Effect';
|
|
3
|
+
import { postgresRunnerProviders } from './providers.ts';
|
|
4
|
+
import { postgresRunnerConnection, withPg } from './connection.ts';
|
|
5
|
+
import { reconcileWithClient } from './database.ts';
|
|
6
|
+
import { buildCreateDatabaseSql, isDuplicateDatabaseRace } from './database-sql.ts';
|
|
7
|
+
import { type PsqlRunner, inlineParams, makePsqlExecutor } from './psql-executor.ts';
|
|
8
|
+
|
|
9
|
+
const target = { database: 'postgres', username: 'postgres' };
|
|
10
|
+
const ok = (stdout: string) => Promise.resolve({ code: 0, stdout, stderr: '' });
|
|
11
|
+
|
|
12
|
+
describe('psql executor', () => {
|
|
13
|
+
test('inlines string params as literals and refuses non-strings', () => {
|
|
14
|
+
expect(inlineParams('SELECT $1, $2', ["it's", 'b'])).toBe("SELECT 'it''s', 'b'");
|
|
15
|
+
expect(() => inlineParams('SELECT $1', [5])).toThrow('must be a string');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('a SELECT is wrapped in json_agg and parsed to rows', async () => {
|
|
19
|
+
const calls: { argv: readonly string[]; stdin: string }[] = [];
|
|
20
|
+
const run: PsqlRunner = (call) => {
|
|
21
|
+
calls.push(call);
|
|
22
|
+
return ok('[{"oid":"16400","name":"x"}]\n');
|
|
23
|
+
};
|
|
24
|
+
const rows = await Effect.runPromise(
|
|
25
|
+
makePsqlExecutor(run, target).unsafe('SELECT 1 FROM pg_database WHERE datname = $1', ['x']),
|
|
26
|
+
);
|
|
27
|
+
expect(rows).toEqual([{ oid: 16400, name: 'x' }]);
|
|
28
|
+
expect(calls[0]?.argv.slice(0, 1)).toEqual(['psql']);
|
|
29
|
+
expect(calls[0]?.stdin).toContain('json_agg');
|
|
30
|
+
expect(calls[0]?.stdin).toContain("datname = 'x'");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a duplicate-database failure stays recognisable as the 42P04 race', async () => {
|
|
34
|
+
const run: PsqlRunner = () =>
|
|
35
|
+
Promise.resolve({
|
|
36
|
+
code: 3,
|
|
37
|
+
stdout: '',
|
|
38
|
+
stderr: 'ERROR: 42P04: database "x" already exists\n',
|
|
39
|
+
});
|
|
40
|
+
const error = await Effect.runPromise(
|
|
41
|
+
Effect.flip(makePsqlExecutor(run, target).unsafe('CREATE DATABASE "x"')),
|
|
42
|
+
);
|
|
43
|
+
expect(isDuplicateDatabaseRace(error)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a runner that never reached psql is a ConnectionError', async () => {
|
|
47
|
+
const run: PsqlRunner = () =>
|
|
48
|
+
Promise.resolve({ code: 255, stdout: '', stderr: 'ssh: refused' });
|
|
49
|
+
const error = await Effect.runPromise(
|
|
50
|
+
Effect.flip(makePsqlExecutor(run, target).unsafe('SELECT 1')),
|
|
51
|
+
);
|
|
52
|
+
expect(error.reason._tag).toBe('ConnectionError');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('runner transport through withPg', () => {
|
|
57
|
+
test('create uses template0 by default and the same adopt path on an existing row', async () => {
|
|
58
|
+
const stdins: string[] = [];
|
|
59
|
+
const row = {
|
|
60
|
+
oid: 1,
|
|
61
|
+
name: 'lite',
|
|
62
|
+
owner: 'litellm',
|
|
63
|
+
encoding: 'UTF8',
|
|
64
|
+
localeProvider: 'libc',
|
|
65
|
+
collate: 'C',
|
|
66
|
+
ctype: 'C',
|
|
67
|
+
allowConnections: true,
|
|
68
|
+
connectionLimit: -1,
|
|
69
|
+
isTemplate: false,
|
|
70
|
+
tablespace: 'pg_default',
|
|
71
|
+
};
|
|
72
|
+
let created = false;
|
|
73
|
+
const run: PsqlRunner = ({ stdin }) => {
|
|
74
|
+
stdins.push(stdin);
|
|
75
|
+
if (stdin.includes('pg_roles')) return ok('[{"present":1}]');
|
|
76
|
+
if (stdin.includes('FROM pg_database')) return ok(created ? JSON.stringify([row]) : '[]');
|
|
77
|
+
if (stdin.startsWith('CREATE DATABASE')) created = true;
|
|
78
|
+
return ok('');
|
|
79
|
+
};
|
|
80
|
+
const layer = postgresRunnerConnection({ run, ...target });
|
|
81
|
+
const props = { name: 'lite', owner: 'litellm' };
|
|
82
|
+
const attrs = await Effect.runPromise(
|
|
83
|
+
withPg((pg, ctx) => reconcileWithClient(pg, props, ctx.template)).pipe(Effect.provide(layer)),
|
|
84
|
+
);
|
|
85
|
+
expect(attrs.name).toBe('lite');
|
|
86
|
+
expect(stdins.find((s) => s.startsWith('CREATE DATABASE'))).toBe(
|
|
87
|
+
`${buildCreateDatabaseSql(props, 'template0')};`,
|
|
88
|
+
);
|
|
89
|
+
expect(stdins.find((s) => s.startsWith('CREATE DATABASE'))).toContain('TEMPLATE "template0"');
|
|
90
|
+
const before = stdins.length;
|
|
91
|
+
await Effect.runPromise(
|
|
92
|
+
withPg((pg, ctx) => reconcileWithClient(pg, props, ctx.template)).pipe(Effect.provide(layer)),
|
|
93
|
+
);
|
|
94
|
+
expect(stdins.slice(before).some((s) => s.startsWith('CREATE DATABASE'))).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("postgresRunnerProviders keeps the connection live for the engine's later handler calls", async () => {
|
|
98
|
+
const run: PsqlRunner = () => ok('[]');
|
|
99
|
+
const rows = await Effect.runPromise(
|
|
100
|
+
withPg((pg) => pg.unsafe('SELECT 1')).pipe(
|
|
101
|
+
Effect.provide(postgresRunnerProviders({ run, ...target })),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
expect(rows).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -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
|
+
});
|
package/src/proxmox/acl.ts
CHANGED
|
@@ -50,6 +50,7 @@ import * as Effect from 'effect/Effect';
|
|
|
50
50
|
import { attributesOf, bind, guardWrite, identity, matches, tuple } from './acl-wire.ts';
|
|
51
51
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
52
52
|
import { runPve } from './distilled-pve.ts';
|
|
53
|
+
import { mintTier } from './credentials.ts';
|
|
53
54
|
import {
|
|
54
55
|
UNREADABLE,
|
|
55
56
|
type Unreadable,
|
|
@@ -148,7 +149,12 @@ export const ProxmoxAclProvider = () =>
|
|
|
148
149
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and
|
|
149
150
|
// force `update` on a grant that was plainly there — see unreadable-read.ts.
|
|
150
151
|
if (live === UNREADABLE) {
|
|
151
|
-
yield* unreadableWarning(
|
|
152
|
+
yield* unreadableWarning(
|
|
153
|
+
'Proxmox.Acl',
|
|
154
|
+
identity(news),
|
|
155
|
+
news.target.mount,
|
|
156
|
+
mintTier(news.target, 'provision'),
|
|
157
|
+
);
|
|
152
158
|
return { action: 'noop' } as const;
|
|
153
159
|
}
|
|
154
160
|
return live !== undefined && matches(live, news)
|
package/src/proxmox/api-token.ts
CHANGED
|
@@ -52,6 +52,7 @@ import * as Provider from 'alchemy/Provider';
|
|
|
52
52
|
import * as access from '@distilled.cloud/proxmox/access';
|
|
53
53
|
import * as Effect from 'effect/Effect';
|
|
54
54
|
import { API_TOKEN_UPDATE, attributesOf, matches, shape } from './api-token-form.ts';
|
|
55
|
+
import { mintTier } from './credentials.ts';
|
|
55
56
|
import { guardWrite } from './distilled-guard.ts';
|
|
56
57
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
57
58
|
import { runPve } from './distilled-pve.ts';
|
|
@@ -190,7 +191,12 @@ export const ProxmoxApiTokenProvider = () =>
|
|
|
190
191
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
191
192
|
// `update` on a token that was plainly there — see unreadable-read.ts.
|
|
192
193
|
if (live === UNREADABLE) {
|
|
193
|
-
yield* unreadableWarning(
|
|
194
|
+
yield* unreadableWarning(
|
|
195
|
+
'Proxmox.ApiToken',
|
|
196
|
+
`${news.userid}!${news.tokenid}`,
|
|
197
|
+
news.target.mount,
|
|
198
|
+
mintTier(news.target, 'provision'),
|
|
199
|
+
);
|
|
194
200
|
return { action: 'noop' } as const;
|
|
195
201
|
}
|
|
196
202
|
// ⚠️ NO GUARDED-CREATE BRANCH HERE, UNLIKE EVERY OTHER FAMILY IN THIS SUB-AREA. There
|
|
@@ -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
|
+
);
|