@homeflare/alchemy 0.14.0 → 0.15.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/openbao/index.d.ts +10 -3
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/backup-job.d.ts.map +1 -1
- package/dist/proxmox/constraint-guard.d.ts +28 -0
- package/dist/proxmox/constraint-guard.d.ts.map +1 -0
- package/dist/proxmox/constraints.d.ts +71 -0
- package/dist/proxmox/constraints.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +19 -0
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pbs-config.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pbs-config.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-access.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-cluster.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +18 -0
- package/dist/proxmox/generated/constraints/pve-storage.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/ha-resource.d.ts.map +1 -1
- package/dist/proxmox/ha-rule.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +6 -1
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +816 -257
- package/dist/proxmox/index.js.map +33 -24
- package/dist/proxmox/notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-endpoint.d.ts +22 -0
- package/dist/proxmox/pbs-datastore-endpoint.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
- package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
- package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
- package/dist/proxmox/pool.d.ts.map +1 -1
- package/dist/proxmox/provision-baseline.d.ts +30 -5
- package/dist/proxmox/provision-baseline.d.ts.map +1 -1
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/proxmox/resource-spec.d.ts +120 -0
- package/dist/proxmox/resource-spec.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +10 -91
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
- package/dist/proxmox/sdn-zone.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts.map +1 -1
- package/docs/provision-baseline.md +34 -7
- package/docs/proxmox-lxc.md +40 -0
- package/package.json +1 -1
- package/src/openbao/index-types.test.ts +87 -0
- package/src/openbao/index.ts +15 -3
- package/src/proxmox/acl.ts +2 -0
- package/src/proxmox/backup-job.ts +2 -0
- package/src/proxmox/constraint-guard.ts +53 -0
- package/src/proxmox/constraints.test.ts +260 -0
- package/src/proxmox/constraints.ts +135 -0
- package/src/proxmox/firewall-alias.ts +5 -0
- package/src/proxmox/generated/constraints/index.ts +31 -0
- package/src/proxmox/generated/constraints/pbs-config.ts +141 -0
- package/src/proxmox/generated/constraints/pve-access.ts +57 -0
- package/src/proxmox/generated/constraints/pve-cluster.ts +174 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +26 -0
- package/src/proxmox/generated/constraints/pve-storage.ts +75 -0
- package/src/proxmox/group.ts +2 -0
- package/src/proxmox/ha-resource.ts +5 -0
- package/src/proxmox/ha-rule.ts +2 -0
- package/src/proxmox/index.ts +12 -0
- package/src/proxmox/lxc-interior.test.ts +61 -0
- package/src/proxmox/notification-matcher.ts +5 -0
- package/src/proxmox/pbs-datastore-endpoint.ts +29 -0
- package/src/proxmox/pbs-datastore.ts +8 -1
- package/src/proxmox/pbs-notification-matcher.ts +5 -0
- package/src/proxmox/pbs-prune-job.ts +2 -0
- package/src/proxmox/pbs-sync-job-form.ts +2 -0
- package/src/proxmox/pbs-verify-job.ts +2 -0
- package/src/proxmox/pool.ts +2 -0
- package/src/proxmox/provision-baseline.test.ts +38 -0
- package/src/proxmox/provision-baseline.ts +73 -14
- package/src/proxmox/provision-bootstrap.test.ts +51 -0
- package/src/proxmox/replication-job.ts +5 -0
- package/src/proxmox/resource-spec.ts +117 -0
- package/src/proxmox/resource.ts +50 -91
- package/src/proxmox/role.ts +2 -0
- package/src/proxmox/sdn-vnet.ts +2 -0
- package/src/proxmox/sdn-zone.ts +2 -0
- package/src/proxmox/storage.ts +2 -0
- package/src/proxmox/user.ts +2 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The barrel's TYPE surface, pinned.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS IS THE TEST FOR A BUG THAT ONLY A CONSUMER COULD SEE. Every `Bao.*` family already
|
|
5
|
+
* re-exported its `…Props` and `…Attributes` from its own module, but `index.ts` forwarded only
|
|
6
|
+
* the VALUES for seven of them. A consumer that may not deep-import — which is every consumer,
|
|
7
|
+
* by the estate's adoption rules — therefore could not name the props of the very Resource the
|
|
8
|
+
* barrel handed it, and had to write the shape out and hope it stayed in step.
|
|
9
|
+
* MEASURED 2026-09-22 in homeflare-openbao, which did exactly that for `BaoProxmoxRole`.
|
|
10
|
+
*
|
|
11
|
+
* ★ A TYPE-ONLY TEST, ON PURPOSE. Nothing here runs; `tsc --noEmit` is the assertion. If a family's
|
|
12
|
+
* props stop being reachable from the package entry, this file stops compiling, which is the
|
|
13
|
+
* only moment anyone would otherwise notice — at the next consumer, in another repo.
|
|
14
|
+
*/
|
|
15
|
+
import type {
|
|
16
|
+
BaoAuthMethodAttributes,
|
|
17
|
+
BaoAuthMethodProps,
|
|
18
|
+
BaoAuthRoleAttributes,
|
|
19
|
+
BaoAuthRoleProps,
|
|
20
|
+
BaoCloudflareRoleAttributes,
|
|
21
|
+
BaoCloudflareRoleProps,
|
|
22
|
+
BaoJwtRoleAttributes,
|
|
23
|
+
BaoMountAttributes,
|
|
24
|
+
BaoMountProps,
|
|
25
|
+
BaoPkiRoleAttributes,
|
|
26
|
+
BaoPkiRoleProps,
|
|
27
|
+
BaoPluginAttributes,
|
|
28
|
+
BaoPluginProps,
|
|
29
|
+
BaoPolicyAttributes,
|
|
30
|
+
BaoPolicyProps,
|
|
31
|
+
BaoProxmoxRoleAttributes,
|
|
32
|
+
BaoProxmoxRoleProps,
|
|
33
|
+
BaoSshRoleAttributes,
|
|
34
|
+
BaoSshRoleProps,
|
|
35
|
+
} from './index.ts';
|
|
36
|
+
import { expect, test } from 'bun:test';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ⛔ The one a consumer was already missing. `mount` + `name` + `mintUser` + `ttl` + `maxTtl` IS the
|
|
40
|
+
* whole role, so a consumer that writes this shape out by hand is writing the entire
|
|
41
|
+
* server-side state of the family — including `mintUser`, which is its security boundary.
|
|
42
|
+
*/
|
|
43
|
+
const proxmoxRole: BaoProxmoxRoleProps = {
|
|
44
|
+
maxTtl: '30m',
|
|
45
|
+
mintUser: 'hf-provision@pve',
|
|
46
|
+
mount: 'proxmox-ops',
|
|
47
|
+
name: 'provision',
|
|
48
|
+
ttl: '5m',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const mount: BaoMountProps = { path: 'proxmox-ops', type: 'openbao-plugin-secrets-proxmox' };
|
|
52
|
+
|
|
53
|
+
/** ⛔ The plugin catalog key — a consumer needs this to declare a registration at all. */
|
|
54
|
+
const plugin: BaoPluginProps = {
|
|
55
|
+
command: 'openbao-plugin-secrets-proxmox',
|
|
56
|
+
name: 'openbao-plugin-secrets-proxmox',
|
|
57
|
+
sha256: 'a'.repeat(64),
|
|
58
|
+
type: 'secret',
|
|
59
|
+
version: 'v0.0.0-dev',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** ★ Named so the attribute types are reachable too: a consumer reads outputs as well as writing props. */
|
|
63
|
+
type Reachable =
|
|
64
|
+
| BaoAuthMethodAttributes
|
|
65
|
+
| BaoAuthMethodProps
|
|
66
|
+
| BaoAuthRoleAttributes
|
|
67
|
+
| BaoAuthRoleProps
|
|
68
|
+
| BaoCloudflareRoleAttributes
|
|
69
|
+
| BaoCloudflareRoleProps
|
|
70
|
+
| BaoJwtRoleAttributes
|
|
71
|
+
| BaoMountAttributes
|
|
72
|
+
| BaoPkiRoleAttributes
|
|
73
|
+
| BaoPkiRoleProps
|
|
74
|
+
| BaoProxmoxRoleAttributes
|
|
75
|
+
| BaoPluginAttributes
|
|
76
|
+
| BaoPolicyAttributes
|
|
77
|
+
| BaoPolicyProps
|
|
78
|
+
| BaoSshRoleAttributes
|
|
79
|
+
| BaoSshRoleProps;
|
|
80
|
+
|
|
81
|
+
test("every openbao family's props are reachable from the package entry", () => {
|
|
82
|
+
const reachable: Reachable[] = [];
|
|
83
|
+
expect(reachable).toEqual([]);
|
|
84
|
+
expect(proxmoxRole.mintUser).toBe('hf-provision@pve');
|
|
85
|
+
expect(mount.type).toBe('openbao-plugin-secrets-proxmox');
|
|
86
|
+
expect(plugin.version).toBe('v0.0.0-dev');
|
|
87
|
+
});
|
package/src/openbao/index.ts
CHANGED
|
@@ -23,11 +23,13 @@ export {
|
|
|
23
23
|
revokeSelf,
|
|
24
24
|
revokeSelfEffect,
|
|
25
25
|
} from './approle-login.ts';
|
|
26
|
+
export type { BaoAuthMethodAttributes, BaoAuthMethodProps } from './auth-method.ts';
|
|
26
27
|
export { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
|
|
27
|
-
export type { BaoAuthRoleProps } from './auth-role.ts';
|
|
28
|
+
export type { BaoAuthRoleAttributes, BaoAuthRoleProps } from './auth-role.ts';
|
|
28
29
|
export { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
|
|
29
30
|
export type { BaoIdentity, BaoIdentityExpected, BaoIdentityFailure } from './bao-identity.ts';
|
|
30
31
|
export { BaoIdentityError, assertBaoIdentity, assertBaoIdentityEffect } from './bao-identity.ts';
|
|
32
|
+
export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps } from './cloudflare-role.ts';
|
|
31
33
|
export { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
|
|
32
34
|
export {
|
|
33
35
|
CloudflarePermissionGroups,
|
|
@@ -42,7 +44,12 @@ export type { HostAppRolesInput, HostRoleClass, HostRoleHost } from './host-appr
|
|
|
42
44
|
export { HOST_ROLE_SEPARATOR, hostAppRoles, hostRoleName } from './host-approles.ts';
|
|
43
45
|
export type { BaoJwtAuthConfigProps } from './jwt-config.ts';
|
|
44
46
|
export { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
|
|
45
|
-
export type {
|
|
47
|
+
export type {
|
|
48
|
+
BaoJwtCallbackMode,
|
|
49
|
+
BaoJwtRoleAttributes,
|
|
50
|
+
BaoJwtRoleProps,
|
|
51
|
+
BaoJwtRoleType,
|
|
52
|
+
} from './jwt-role.ts';
|
|
46
53
|
export { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
|
|
47
54
|
export type { BaoKubernetesAliasSource, BaoKubernetesRoleProps } from './kubernetes-role.ts';
|
|
48
55
|
export { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
|
|
@@ -50,10 +57,15 @@ export type { BaoMfaLoginEnforcementProps } from './mfa-enforcement.ts';
|
|
|
50
57
|
export { BaoMfaLoginEnforcement, BaoMfaLoginEnforcementProvider } from './mfa-enforcement.ts';
|
|
51
58
|
export type { BaoMfaTotpMethodProps, BaoTotpAlgorithm } from './mfa-totp.ts';
|
|
52
59
|
export { BaoMfaTotpMethod, BaoMfaTotpMethodProvider } from './mfa-totp.ts';
|
|
60
|
+
export type { BaoMountAttributes, BaoMountProps } from './mount.ts';
|
|
53
61
|
export { BaoMount, BaoMountProvider } from './mount.ts';
|
|
62
|
+
export type { BaoPkiRoleAttributes, BaoPkiRoleProps } from './pki-role.ts';
|
|
54
63
|
export { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
|
|
55
|
-
export type { BaoPluginProps, BaoPluginType } from './plugin.ts';
|
|
64
|
+
export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin.ts';
|
|
56
65
|
export { BaoPlugin, BaoPluginProvider } from './plugin.ts';
|
|
66
|
+
export type { BaoPolicyAttributes, BaoPolicyProps } from './policy.ts';
|
|
57
67
|
export { BaoPolicy, BaoPolicyProvider } from './policy.ts';
|
|
68
|
+
export type { BaoProxmoxRoleAttributes, BaoProxmoxRoleProps } from './proxmox-role.ts';
|
|
58
69
|
export { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
|
|
70
|
+
export type { BaoSshRoleAttributes, BaoSshRoleProps } from './ssh-role.ts';
|
|
59
71
|
export { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
|
package/src/proxmox/acl.ts
CHANGED
|
@@ -189,6 +189,8 @@ const ops = pveOperations<AclProps, AclAttributes>({
|
|
|
189
189
|
* the props they came from is true by construction. An identity change is a DIFFERENT grant,
|
|
190
190
|
* which `diff` in the provider handles.
|
|
191
191
|
*/
|
|
192
|
+
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
193
|
+
endpoint: { create: 'pve:PUT /access/acl', update: 'pve:PUT /access/acl' },
|
|
192
194
|
matches: (attributes, props) =>
|
|
193
195
|
attributes.bound && attributes.propagate === (props.propagate !== false),
|
|
194
196
|
path: () => 'access/acl',
|
|
@@ -173,6 +173,8 @@ const handlers = pveHandlers<BackupJobProps, BackupJobAttributes>({
|
|
|
173
173
|
/** ⛔ `id` IS SENT AND IS NOT OPTIONAL. Everything above depends on PVE not inventing one. */
|
|
174
174
|
createForm: (props) => ({ ...shape(props), id: props.id }),
|
|
175
175
|
/** Each line reads "not declared, or equal"; the five defaulted fields have no undeclared case. */
|
|
176
|
+
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
177
|
+
endpoint: { create: 'pve:POST /cluster/backup', update: 'pve:PUT /cluster/backup/{id}' },
|
|
176
178
|
matches: (attributes, props) =>
|
|
177
179
|
attributes.schedule === props.schedule &&
|
|
178
180
|
attributes.enabled === (props.enabled !== false) &&
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated tables, looked up by endpoint, and the refusal that stops a plan.
|
|
3
|
+
*
|
|
4
|
+
* ★ ONE FILE BETWEEN THE PROVIDERS AND THE GENERATED DATA, so `resource.ts` gains three lines
|
|
5
|
+
* rather than an import of two generated modules and a merge, and so a family that writes its
|
|
6
|
+
* own handlers (CephPool, PbsDatastore) reaches the same check by the same name.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ AN UNKNOWN KEY IS A DEFECT, NOT A REASON TO SKIP THE CHECK. Silently passing would put us back
|
|
9
|
+
* where this started: a declaration nothing checked, and a plan that says so in no way at all.
|
|
10
|
+
* The generator resolves every key in its source scan against the vendor schema, so a key that
|
|
11
|
+
* is absent here means the tables were not regenerated — which the message says.
|
|
12
|
+
*/
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
import type { PveForm } from './client.ts';
|
|
15
|
+
import { type EndpointConstraints, type EndpointKey, refusal, violations } from './constraints.ts';
|
|
16
|
+
import { PROXMOX_CONSTRAINTS } from './generated/constraints/index.ts';
|
|
17
|
+
|
|
18
|
+
export { PROXMOX_CONSTRAINTS };
|
|
19
|
+
|
|
20
|
+
export const constraintsFor = (key: EndpointKey): EndpointConstraints => {
|
|
21
|
+
const table = PROXMOX_CONSTRAINTS[key];
|
|
22
|
+
if (table === undefined) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`no vendor constraint table for '${key}'. It is named in this package but not generated: ` +
|
|
25
|
+
'run `bun codegen/constraints.ts` (see codegen/README.md).',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return table;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Every vendor rule this form breaks. Pure — a test calls it with a literal form. */
|
|
32
|
+
export const formViolations = (
|
|
33
|
+
key: EndpointKey,
|
|
34
|
+
form: PveForm,
|
|
35
|
+
presence: boolean,
|
|
36
|
+
): readonly string[] => violations(constraintsFor(key), form, { presence });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ⛔ `Effect.die`, NOT `Effect.fail`. A declaration the vendor would reject is a DEFECT in the
|
|
40
|
+
* stack file, not a recoverable condition: there is no retry and no fallback that makes a
|
|
41
|
+
* 129-character comment legal. The same call that refuses a write-back with no read-back
|
|
42
|
+
* (resource.ts) dies for the same reason.
|
|
43
|
+
*/
|
|
44
|
+
export const guardForm = (
|
|
45
|
+
key: EndpointKey | undefined,
|
|
46
|
+
form: PveForm,
|
|
47
|
+
presence: boolean,
|
|
48
|
+
): Effect.Effect<void> =>
|
|
49
|
+
Effect.suspend(() => {
|
|
50
|
+
if (key === undefined) return Effect.void;
|
|
51
|
+
const found = formViolations(key, form, presence);
|
|
52
|
+
return found.length === 0 ? Effect.void : Effect.die(new Error(refusal(key, found)));
|
|
53
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vendor's own limits, enforced at plan time — built from the failure that made them necessary.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 2026-09-22, `homeflare-proxmox` `deploy:pbs`: ten objects adopted, then
|
|
5
|
+
* `POST config/verify -> 400: parameter verification failed - comment: value may only be 128
|
|
6
|
+
* characters long`. The job was `v-r2-offsite`, the monthly read-back of the R2 copy. Plan,
|
|
7
|
+
* `bun run check`, the family's own tests and `hf-adopt-verify` were all green, because the only
|
|
8
|
+
* place the number 128 existed was PBS's published schema.
|
|
9
|
+
*
|
|
10
|
+
* ★ SO THE FIRST TEST IS THAT JOB, WITH A 129-CHARACTER COMMENT, THROUGH ALCHEMY'S OWN ENGINE, and
|
|
11
|
+
* the assertion that matters is not only the message: it is that `fake.writes()` is EMPTY. The
|
|
12
|
+
* incident was half a deploy landing before the refusal.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import * as Layer from 'effect/Layer';
|
|
16
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
17
|
+
import { constraintsFor, formViolations } from './constraint-guard.ts';
|
|
18
|
+
import { type EndpointConstraints, refusal, violations } from './constraints.ts';
|
|
19
|
+
import type { PbsTarget } from './credentials.ts';
|
|
20
|
+
import { fakePve, withoutBao } from './fake-pve.ts';
|
|
21
|
+
import { createForm as datastoreCreateForm } from './pbs-datastore-form.ts';
|
|
22
|
+
import { createBody as pruneCreateBody } from './pbs-prune-job-form.ts';
|
|
23
|
+
import { createBody as syncCreateBody } from './pbs-sync-job-form.ts';
|
|
24
|
+
import { PROXMOX_CONSTRAINTS, PROXMOX_CONSTRAINTS_DIGEST } from './generated/constraints/index.ts';
|
|
25
|
+
import { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
|
|
26
|
+
|
|
27
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
28
|
+
const VERIFY = 'pbs:POST /config/verify';
|
|
29
|
+
const BELL = String.fromCodePoint(7);
|
|
30
|
+
const ROCKET = String.fromCodePoint(0x1f680);
|
|
31
|
+
|
|
32
|
+
/** The real declaration, comment length made an argument. */
|
|
33
|
+
const job = (comment: string) =>
|
|
34
|
+
PbsVerifyJob('v-r2-offsite', {
|
|
35
|
+
comment,
|
|
36
|
+
id: 'v-r2-offsite',
|
|
37
|
+
'ignore-verified': true,
|
|
38
|
+
'outdated-after': 30,
|
|
39
|
+
schedule: 'Sun *-1..7 11:00',
|
|
40
|
+
store: 'r2-offsite',
|
|
41
|
+
target: PBS,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('the v-r2-offsite failure, refused at plan instead of by the server', () => {
|
|
45
|
+
const deployWith = async (comment: string) => {
|
|
46
|
+
/**
|
|
47
|
+
* A PBS that does not have this job yet, so the plan is a CREATE — the branch `diff` used to
|
|
48
|
+
* skip entirely (`output === undefined` returned before any check). It remembers the POST so
|
|
49
|
+
* the read-back in `reconcile` can succeed, which is what the 128-character case needs.
|
|
50
|
+
*/
|
|
51
|
+
const live = new Map<string, Record<string, unknown>>();
|
|
52
|
+
const fake = fakePve((call) => {
|
|
53
|
+
if (call.method === 'GET') return live.get(call.path);
|
|
54
|
+
if (call.method === 'POST') live.set(`config/verify/${String(call.form['id'])}`, call.form);
|
|
55
|
+
return undefined;
|
|
56
|
+
});
|
|
57
|
+
const engine = engineOver(PbsVerifyJobProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
58
|
+
const outcome = await withoutBao(() =>
|
|
59
|
+
engine.deploy(job(comment)).then(
|
|
60
|
+
() => ({ error: undefined as unknown }),
|
|
61
|
+
(error: unknown) => ({ error }),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
return { ...outcome, calls: fake.calls, writes: fake.writes() };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
test('129 characters is refused, and NOTHING is written', async () => {
|
|
68
|
+
const { calls, error, writes } = await deployWith('c'.repeat(129));
|
|
69
|
+
expect(String(error)).toContain('comment: at most 128 characters');
|
|
70
|
+
expect(String(error)).toContain(VERIFY);
|
|
71
|
+
// ⛔ THE ASSERTION THE INCIDENT IS ABOUT. A refusal after the POST is the server's own 400.
|
|
72
|
+
expect(writes).toEqual([]);
|
|
73
|
+
/**
|
|
74
|
+
* ⚠️ ONE CALL IS MADE, AND IT IS A GET. Measured 2026-09-22: Alchemy runs the provider's
|
|
75
|
+
* `read` handler before `diff`, so the plan has already asked PBS whether the job exists by
|
|
76
|
+
* the time the constraint is checked. Every call is a read; nothing is created or changed.
|
|
77
|
+
*/
|
|
78
|
+
expect(calls.map((call) => call.method)).toEqual(['GET']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('128 characters is accepted and the create goes through', async () => {
|
|
82
|
+
const { error, writes } = await deployWith('c'.repeat(128));
|
|
83
|
+
expect(error).toBeUndefined();
|
|
84
|
+
expect(writes).toEqual(['POST config/verify']);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('the table is the vendor own rule set, read from the generated file', () => {
|
|
89
|
+
test('POST /config/verify carries the 128 and the safe-id rules PBS publishes', () => {
|
|
90
|
+
const table = constraintsFor(VERIFY);
|
|
91
|
+
expect(table['comment']).toMatchObject({ maxLength: 128, type: 'string' });
|
|
92
|
+
expect(table['id']).toMatchObject({ maxLength: 32, minLength: 3, required: true });
|
|
93
|
+
expect(table['max-depth']).toMatchObject({ maximum: 7, minimum: 0 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an unknown endpoint is a defect that names the command, not a silent pass', () => {
|
|
97
|
+
expect(() => constraintsFor('pbs:POST /config/nope')).toThrow('bun codegen/constraints.ts');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* ⚠️ PBS's `comment` pattern is a POSIX class inside Rust regex delimiters, neither of which
|
|
102
|
+
* JavaScript reads the same way. `new RegExp` accepts BOTH silently and means something else.
|
|
103
|
+
* This asserts the translation, because a wrong one refuses every legal comment.
|
|
104
|
+
*/
|
|
105
|
+
test('the PBS comment pattern is translated, not copied', () => {
|
|
106
|
+
const rule = constraintsFor(VERIFY)['comment'];
|
|
107
|
+
expect(rule?.patternSource).toContain('cntrl');
|
|
108
|
+
expect(formViolations(VERIFY, { comment: 'nightly read-back of the R2 copy' }, false)).toEqual(
|
|
109
|
+
[],
|
|
110
|
+
);
|
|
111
|
+
expect(formViolations(VERIFY, { comment: `bell${BELL}here` }, false)).toEqual([
|
|
112
|
+
`comment: must match ${rule?.patternSource ?? ''}`,
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ⚠️ THE SORT IS PART OF THE DIGEST, and it is spelled out here rather than imported from
|
|
118
|
+
* `codegen/emit.ts`: `src/` is published, and a test that reached out of the package would
|
|
119
|
+
* ship a broken import in the tarball. The generator sorts for the same reason it must be
|
|
120
|
+
* sorted here — it merges per area, this module spreads per import.
|
|
121
|
+
*/
|
|
122
|
+
test('the committed tables match their own digest, so no value was hand-edited', () => {
|
|
123
|
+
const sorted = Object.fromEntries(
|
|
124
|
+
Object.keys(PROXMOX_CONSTRAINTS)
|
|
125
|
+
.sort()
|
|
126
|
+
.map((key) => [key, PROXMOX_CONSTRAINTS[key]]),
|
|
127
|
+
);
|
|
128
|
+
const recomputed = new Bun.CryptoHasher('sha256')
|
|
129
|
+
.update(JSON.stringify(sorted))
|
|
130
|
+
.digest('hex')
|
|
131
|
+
.slice(0, 16);
|
|
132
|
+
expect(recomputed).toBe(PROXMOX_CONSTRAINTS_DIGEST);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* ★ ONE MUTANT PER BRANCH. Each case below fails if that single comparison is deleted, inverted or
|
|
138
|
+
* its boundary moved by one — which is the only way to know the validator checks what it claims
|
|
139
|
+
* rather than passing everything.
|
|
140
|
+
*/
|
|
141
|
+
describe('every rule kind, at and past its boundary', () => {
|
|
142
|
+
const table: EndpointConstraints = {
|
|
143
|
+
depth: { maximum: 7, minimum: 0, type: 'integer' },
|
|
144
|
+
mode: { enum: ['all', 'any'], type: 'string' },
|
|
145
|
+
name: { maxLength: 4, minLength: 2, pattern: '^[a-z]+$', patternSource: '/^[a-z]+$/' },
|
|
146
|
+
store: { required: true, type: 'string' },
|
|
147
|
+
tags: { maxLength: 3, type: 'string' },
|
|
148
|
+
};
|
|
149
|
+
const at = (form: Record<string, string | readonly string[]>, presence = false) =>
|
|
150
|
+
violations(table, form, { presence });
|
|
151
|
+
|
|
152
|
+
test('maxLength: 4 passes, 5 does not', () => {
|
|
153
|
+
expect(at({ name: 'abcd' })).toEqual([]);
|
|
154
|
+
expect(at({ name: 'abcde' })).toEqual(['name: at most 4 characters']);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('minLength: 2 passes, 1 does not', () => {
|
|
158
|
+
expect(at({ name: 'ab' })).toEqual([]);
|
|
159
|
+
expect(at({ name: 'a' })).toEqual(['name: at least 2 characters']);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('minimum and maximum are inclusive on both sides', () => {
|
|
163
|
+
expect(at({ depth: '0' })).toEqual([]);
|
|
164
|
+
expect(at({ depth: '7' })).toEqual([]);
|
|
165
|
+
expect(at({ depth: '-1' })).toEqual(['depth: at least 0']);
|
|
166
|
+
expect(at({ depth: '8' })).toEqual(['depth: at most 7']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('enum quotes the vendor own members', () => {
|
|
170
|
+
expect(at({ mode: 'any' })).toEqual([]);
|
|
171
|
+
expect(at({ mode: 'ALL' })).toEqual(['mode: must be one of all, any']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('pattern reports the VENDOR spelling, not the translated one', () => {
|
|
175
|
+
expect(at({ name: 'abc' })).toEqual([]);
|
|
176
|
+
expect(at({ name: 'ab1' })).toEqual(['name: must match /^[a-z]+$/']);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('required is checked only when presence is asked for — an update form is partial', () => {
|
|
180
|
+
expect(at({})).toEqual([]);
|
|
181
|
+
expect(at({}, true)).toEqual(['store: required']);
|
|
182
|
+
expect(at({ store: 'r2' }, true)).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/** ⚠️ Characters, not UTF-16 code units: an astral character is ONE character to Proxmox. */
|
|
186
|
+
test('length counts characters, so an astral character is one', () => {
|
|
187
|
+
expect(at({ tags: `${ROCKET}ab` })).toEqual([]);
|
|
188
|
+
expect(at({ tags: `${ROCKET}abc` })).toEqual(['tags: at most 3 characters']);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/** ⚠️ A list is repeated keys on the wire (client.ts), so every element faces the same rule. */
|
|
192
|
+
test('every element of an array value is checked', () => {
|
|
193
|
+
expect(at({ tags: ['ab', 'abcd'] })).toEqual(['tags: at most 3 characters']);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('a key the table does not mention is not an error', () => {
|
|
197
|
+
expect(at({ unlisted: 'anything at all' })).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('the refusal names the endpoint and points at the generated table', () => {
|
|
201
|
+
expect(refusal(VERIFY, ['comment: at most 128 characters'])).toContain(VERIFY);
|
|
202
|
+
expect(refusal(VERIFY, ['comment: at most 128 characters'])).toContain('generated/constraints');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* ★ THE OTHER THREE PBS FAMILIES, WITH THE ESTATE'S OWN DECLARATIONS, AGAINST THEIR CREATE TABLE.
|
|
208
|
+
* The risk this feature carries is not the one it fixes: a table that refuses a declaration the
|
|
209
|
+
* vendor would have ACCEPTED blocks a deploy that was always legal, and the operator cannot tell
|
|
210
|
+
* that from a genuine violation. These are the jobs `homeflare-proxmox` actually declares
|
|
211
|
+
* (`prune-cluster-all`, `sync-all-to-r2`, the `r2-offsite` datastore), so a false positive in the
|
|
212
|
+
* presence check or a mistranslated pattern fails here rather than on a Sunday morning.
|
|
213
|
+
*/
|
|
214
|
+
describe('the live PBS declarations pass their own create tables', () => {
|
|
215
|
+
test('prune, sync and datastore create forms have no violations', () => {
|
|
216
|
+
expect(
|
|
217
|
+
formViolations(
|
|
218
|
+
'pbs:POST /config/prune',
|
|
219
|
+
pruneCreateBody({
|
|
220
|
+
comment: 'cluster retention',
|
|
221
|
+
id: 'prune-cluster-all',
|
|
222
|
+
'keep-daily': 7,
|
|
223
|
+
'keep-last': 3,
|
|
224
|
+
'keep-monthly': 6,
|
|
225
|
+
'keep-weekly': 4,
|
|
226
|
+
schedule: 'sat 03:00',
|
|
227
|
+
store: 'cluster',
|
|
228
|
+
target: PBS,
|
|
229
|
+
}),
|
|
230
|
+
true,
|
|
231
|
+
),
|
|
232
|
+
).toEqual([]);
|
|
233
|
+
expect(
|
|
234
|
+
formViolations(
|
|
235
|
+
'pbs:POST /config/sync',
|
|
236
|
+
syncCreateBody({
|
|
237
|
+
comment: 'offsite copy to R2',
|
|
238
|
+
id: 'sync-all-to-r2',
|
|
239
|
+
'remote-store': 'r2-offsite',
|
|
240
|
+
schedule: 'mon 08:30',
|
|
241
|
+
store: 'cluster',
|
|
242
|
+
target: PBS,
|
|
243
|
+
}),
|
|
244
|
+
true,
|
|
245
|
+
),
|
|
246
|
+
).toEqual([]);
|
|
247
|
+
expect(
|
|
248
|
+
formViolations(
|
|
249
|
+
'pbs:POST /config/datastore',
|
|
250
|
+
datastoreCreateForm({
|
|
251
|
+
comment: 'offsite',
|
|
252
|
+
name: 'r2-offsite',
|
|
253
|
+
path: '/mnt/datastore/r2-offsite',
|
|
254
|
+
target: PBS,
|
|
255
|
+
}),
|
|
256
|
+
true,
|
|
257
|
+
),
|
|
258
|
+
).toEqual([]);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vendor's own rules, checked here, before the request is built.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 THE INCIDENT THIS EXISTS FOR, 2026-09-22. `homeflare-proxmox`'s first `deploy:pbs` adopted ten
|
|
5
|
+
* objects and then failed its one create: `PVE POST config/verify -> 400: parameter verification
|
|
6
|
+
* failed - comment: value may only be 128 characters long`. Half a deploy in, on the server, from
|
|
7
|
+
* a limit PBS publishes in its own schema and the generated `comment?: string` does not carry.
|
|
8
|
+
* Plan was green. `bun run check` was green. `hf-adopt-verify` was green — its offline fake serves
|
|
9
|
+
* a fixture and refuses anything but GET, so a newly declared object's VALUES are never exercised.
|
|
10
|
+
*
|
|
11
|
+
* ★ SO THE TABLE IS GENERATED AND THIS FILE IS THE READER. `generated/constraints/*` is emitted
|
|
12
|
+
* from the cluster's own `apidoc.js` by `bun codegen/constraints.ts`; nothing here is hand-typed,
|
|
13
|
+
* because a hand-typed limit is the tribal knowledge that was already in a doc comment and
|
|
14
|
+
* already did not run.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ IT CHECKS THE FORM, NOT THE PROPS, AND THAT IS THE WHOLE POINT. The form is what goes on the
|
|
17
|
+
* wire: `String(props['max-depth'])`, `'0' | '1'` for a boolean, an omitted key for an undeclared
|
|
18
|
+
* field. A props-level check would pass values the form then mangles, and would have to be
|
|
19
|
+
* written once per family — which is how the copy that forgets a field gets written.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ WHAT IT DELIBERATELY DOES NOT ENFORCE, so nobody reads a green plan as more than it is:
|
|
22
|
+
* - a `format` NAME (PVE's `pve-calendar-event`, `pve-node`, 216 of them): the name is a
|
|
23
|
+
* validator PVE implements server-side and publishes nothing about. Recorded, never checked.
|
|
24
|
+
* - a pattern the generator could not translate faithfully (codegen/pattern.ts). Recorded
|
|
25
|
+
* verbatim as `patternSource`, never checked.
|
|
26
|
+
* - PBS `schedule`, which carries `typetext: <calendar-event>` and NO pattern and NO maxLength.
|
|
27
|
+
* The schema cannot help there; a malformed schedule still reaches PBS.
|
|
28
|
+
* Each of those is a fact in the table rather than a guess, which is the rule this follows:
|
|
29
|
+
* if the vendor did not state it, it is not enforced and the table says so.
|
|
30
|
+
*/
|
|
31
|
+
import type { PveForm } from './client.ts';
|
|
32
|
+
|
|
33
|
+
export interface ParamConstraint {
|
|
34
|
+
readonly type?: string;
|
|
35
|
+
/** From the vendor's `optional`. ⚠️ Checked on CREATE only — see `violations`. */
|
|
36
|
+
readonly required?: boolean;
|
|
37
|
+
readonly maxLength?: number;
|
|
38
|
+
readonly minLength?: number;
|
|
39
|
+
readonly minimum?: number;
|
|
40
|
+
readonly maximum?: number;
|
|
41
|
+
readonly enum?: readonly string[];
|
|
42
|
+
/** A JavaScript-safe translation. Absent when the vendor's dialect could not be carried over. */
|
|
43
|
+
readonly pattern?: string;
|
|
44
|
+
/** The vendor's own spelling, always — what a violation message quotes, and the audit trail. */
|
|
45
|
+
readonly patternSource?: string;
|
|
46
|
+
/** PVE's server-side validator name. Recorded so a reader knows what is NOT checked here. */
|
|
47
|
+
readonly format?: string;
|
|
48
|
+
readonly default?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type EndpointConstraints = Readonly<Record<string, ParamConstraint>>;
|
|
52
|
+
|
|
53
|
+
/** `POST /config/verify` — exactly how the generated tables key an endpoint. */
|
|
54
|
+
export type EndpointKey = string;
|
|
55
|
+
|
|
56
|
+
export interface ViolationOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Check that every vendor-required parameter is present.
|
|
59
|
+
*
|
|
60
|
+
* ⛔ CREATE ONLY. An update form is deliberately PARTIAL — `formToSend` sends the fields that
|
|
61
|
+
* changed and nothing else — so requiring presence there would refuse every ordinary edit.
|
|
62
|
+
*/
|
|
63
|
+
readonly presence?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const lengthOf = (value: string): number => [...value].length;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* ⚠️ `[...value].length`, NOT `value.length`. Proxmox counts characters; JavaScript counts UTF-16
|
|
70
|
+
* code units, so an emoji in a comment counts twice and a 128-character limit would refuse a
|
|
71
|
+
* comment PBS accepts. Rejecting a legal value at plan time is the one failure mode worse than
|
|
72
|
+
* the server 400 this replaces.
|
|
73
|
+
*/
|
|
74
|
+
const checkValue = (name: string, value: string, rule: ParamConstraint): string[] => {
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
if (rule.maxLength !== undefined && lengthOf(value) > rule.maxLength) {
|
|
77
|
+
out.push(`${name}: at most ${rule.maxLength} characters`);
|
|
78
|
+
}
|
|
79
|
+
if (rule.minLength !== undefined && lengthOf(value) < rule.minLength) {
|
|
80
|
+
out.push(`${name}: at least ${rule.minLength} characters`);
|
|
81
|
+
}
|
|
82
|
+
if (rule.enum !== undefined && !rule.enum.includes(value)) {
|
|
83
|
+
out.push(`${name}: must be one of ${rule.enum.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
if (rule.pattern !== undefined && !new RegExp(rule.pattern).test(value)) {
|
|
86
|
+
out.push(`${name}: must match ${rule.patternSource ?? rule.pattern}`);
|
|
87
|
+
}
|
|
88
|
+
// ⚠️ NUMBER-NESS IS NOT CHECKED, ONLY THE BOUND. The form is strings by construction and a
|
|
89
|
+
// non-numeric string is a type error upstream; `Number('')` is 0, so a blank would read as in
|
|
90
|
+
// range. Guarding on Number.isFinite keeps a blank from silently passing a minimum of 0.
|
|
91
|
+
const numeric = Number(value);
|
|
92
|
+
const bounded = rule.minimum !== undefined || rule.maximum !== undefined;
|
|
93
|
+
if (bounded && value !== '' && Number.isFinite(numeric)) {
|
|
94
|
+
if (rule.minimum !== undefined && numeric < rule.minimum) {
|
|
95
|
+
out.push(`${name}: at least ${rule.minimum}`);
|
|
96
|
+
}
|
|
97
|
+
if (rule.maximum !== undefined && numeric > rule.maximum) {
|
|
98
|
+
out.push(`${name}: at most ${rule.maximum}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every vendor rule this form breaks, in the order the parameters are declared. Pure: no network,
|
|
106
|
+
* no clock, no environment — so `diff` can call it and a test can call it with a literal.
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ A KEY THE TABLE DOES NOT MENTION IS NOT AN ERROR. The table carries the constrained
|
|
109
|
+
* parameters, not every parameter, and a form may legitimately send an unconstrained one.
|
|
110
|
+
*/
|
|
111
|
+
export const violations = (
|
|
112
|
+
table: EndpointConstraints,
|
|
113
|
+
form: PveForm,
|
|
114
|
+
options: ViolationOptions = {},
|
|
115
|
+
): readonly string[] => {
|
|
116
|
+
const out: string[] = [];
|
|
117
|
+
for (const [name, rule] of Object.entries(table)) {
|
|
118
|
+
const value = form[name];
|
|
119
|
+
if (value === undefined) {
|
|
120
|
+
if (options.presence === true && rule.required === true) out.push(`${name}: required`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// ⚠️ AN ARRAY IS REPEATED KEYS ON THE WIRE (client.ts), so every element faces the same rule.
|
|
124
|
+
for (const item of typeof value === 'string' ? [value] : value) {
|
|
125
|
+
out.push(...checkValue(name, item, rule));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** The message a plan dies with. ⛔ Names the endpoint, because the rule belongs to the vendor. */
|
|
132
|
+
export const refusal = (endpoint: EndpointKey, found: readonly string[]): string =>
|
|
133
|
+
`${endpoint} would be rejected by the vendor:\n - ${found.join('\n - ')}\n` +
|
|
134
|
+
"These are the vendor's own limits, read from its published schema " +
|
|
135
|
+
'(packages/alchemy/src/proxmox/generated/constraints). Fix the declaration.';
|
|
@@ -171,6 +171,11 @@ const handlers = pveHandlers<FirewallAliasProps, FirewallAliasAttributes>({
|
|
|
171
171
|
* is identity and case-only drift (see the header); `ipversion` is derived and unwritable.
|
|
172
172
|
* Comparing either would report an update that no update can settle.
|
|
173
173
|
*/
|
|
174
|
+
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
175
|
+
endpoint: {
|
|
176
|
+
create: 'pve:POST /cluster/firewall/aliases',
|
|
177
|
+
update: 'pve:PUT /cluster/firewall/aliases/{name}',
|
|
178
|
+
},
|
|
174
179
|
matches: (attributes, props) =>
|
|
175
180
|
attributes.cidr === cidr(props.cidr) && attributes.comment === comment(props.comment),
|
|
176
181
|
/**
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every generated Proxmox constraint table, merged — DO NOT EDIT BY HAND.
|
|
3
|
+
*
|
|
4
|
+
* Run: bun codegen/constraints.ts
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ MERGING IS SAFE ONLY BECAUSE EVERY KEY IS PREFIXED `pve:`/`pbs:`. PVE and PBS both publish
|
|
7
|
+
* `PUT /access/acl` and `PUT /access/password` with DIFFERENT rules; an unprefixed merge would
|
|
8
|
+
* silently enforce one product's limits on the other's objects.
|
|
9
|
+
*/
|
|
10
|
+
import type { EndpointConstraints } from '../../constraints.ts';
|
|
11
|
+
import { PVE_ACCESS_CONSTRAINTS } from './pve-access.ts';
|
|
12
|
+
import { PVE_CLUSTER_CONSTRAINTS } from './pve-cluster.ts';
|
|
13
|
+
import { PVE_POOLS_CONSTRAINTS } from './pve-pools.ts';
|
|
14
|
+
import { PVE_STORAGE_CONSTRAINTS } from './pve-storage.ts';
|
|
15
|
+
import { PBS_CONFIG_CONSTRAINTS } from './pbs-config.ts';
|
|
16
|
+
|
|
17
|
+
export const PROXMOX_CONSTRAINTS: Readonly<Record<string, EndpointConstraints>> = {
|
|
18
|
+
...PVE_ACCESS_CONSTRAINTS,
|
|
19
|
+
...PVE_CLUSTER_CONSTRAINTS,
|
|
20
|
+
...PVE_POOLS_CONSTRAINTS,
|
|
21
|
+
...PVE_STORAGE_CONSTRAINTS,
|
|
22
|
+
...PBS_CONFIG_CONSTRAINTS,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* sha256 of the merged table, truncated.
|
|
27
|
+
*
|
|
28
|
+
* ★ A DIGEST OF THE DATA, NOT OF THE FILE TEXT, so reformatting is not a stale generation while
|
|
29
|
+
* changing a 128 to a 129 by hand is. `tests/schema-manifest.test.ts` recomputes it.
|
|
30
|
+
*/
|
|
31
|
+
export const PROXMOX_CONSTRAINTS_DIGEST = '03f403e0c6b3cf7d';
|