@homeflare/alchemy 0.13.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/README.md +7 -0
- package/dist/github/index.d.ts +18 -0
- package/dist/github/index.d.ts.map +1 -0
- package/dist/github/index.js +161 -0
- package/dist/github/index.js.map +12 -0
- package/dist/github/repo-policy-form.d.ts +86 -0
- package/dist/github/repo-policy-form.d.ts.map +1 -0
- package/dist/github/repo-policy-guards.d.ts +72 -0
- package/dist/github/repo-policy-guards.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +21 -0
- package/dist/github/repo-policy.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- 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/docs/repo-policy-ruleset-hazards.md +65 -0
- package/docs/repo-policy.md +155 -0
- package/package.json +5 -1
- package/src/github/index.ts +23 -0
- package/src/github/repo-policy-form.test.ts +239 -0
- package/src/github/repo-policy-form.ts +173 -0
- package/src/github/repo-policy-guards.ts +179 -0
- package/src/github/repo-policy.test.ts +58 -0
- package/src/github/repo-policy.ts +65 -0
- package/src/index.ts +1 -0
- 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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything `repoPolicy` refuses, and why. Extracted from repo-policy-form.ts so the
|
|
3
|
+
* shape stays readable and the reasoning stays whole.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ FIVE DIFFERENT DOORS LEAD TO THE SAME FAILURE — auto-merge with nothing to wait for.
|
|
6
|
+
* `gh pr merge --auto` is a queue only while something is outstanding; with nothing
|
|
7
|
+
* outstanding GitHub merges the pull request on the spot. An empty `checks` is the
|
|
8
|
+
* obvious door. `enforcement: 'disabled'` or `'evaluate'` is the quiet one: the ruleset
|
|
9
|
+
* exists, the required checks are listed on it, and none of them block anything. Then
|
|
10
|
+
* three ways to end up with a ruleset that matches NO REF — which enforces on no ref
|
|
11
|
+
* while the GitHub UI still shows it as `active`: an empty `include`, a blank ref
|
|
12
|
+
* pattern, and an `exclude` that cancels every `include`. All five are refused.
|
|
13
|
+
*
|
|
14
|
+
* ★ THE REF DOORS WERE FOUND BY ADVERSARIAL REVIEW, NOT BY DESIGN. The first cut of this
|
|
15
|
+
* module guarded `include.length === 0` and spread `exclude` raw, so
|
|
16
|
+
* `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` and `include: [' ']`
|
|
17
|
+
* both sailed through with `allowAutoMerge: true` — the exact end state the empty-array
|
|
18
|
+
* guard exists to prevent, reached by a door beside it. Checked by execution, not by
|
|
19
|
+
* reading: see the `matches no ref` tests in repo-policy-form.test.ts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A non-empty, trimmed identifier, or an error naming the field. */
|
|
23
|
+
export function requireName(field: string, value: string): string {
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
if (trimmed === '') throw new Error(`repoPolicy: ${field} is required`);
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Trim, de-duplicate, sort. ★ The sort is what makes a second deploy a no-op: GitHub
|
|
31
|
+
* stores the contexts in the order they were sent, so an unsorted list would diff against
|
|
32
|
+
* itself the first time a caller wrote the same contexts in a different order.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeChecks(checks: readonly string[]): string[] {
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
for (const raw of checks) {
|
|
37
|
+
const context = raw.trim();
|
|
38
|
+
// ⚠️ A blank context is a typo that reads as coverage: GitHub happily stores it and
|
|
39
|
+
// no job ever reports it, so every pull request waits on a check that cannot come.
|
|
40
|
+
if (context === '') {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'repoPolicy: a required check context is blank — name it exactly as the job reports it',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
seen.add(context);
|
|
46
|
+
}
|
|
47
|
+
return [...seen].sort();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Trim, de-duplicate, sort one list of ref patterns, refusing a blank one.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ A BLANK PATTERN IS NOT AN EMPTY LIST, AND THAT IS THE WHOLE TRAP. `[' ']` has
|
|
54
|
+
* length 1, so a guard that counts the array waves it through; GitHub then stores a ref
|
|
55
|
+
* condition that matches nothing. Same end state as `[]`, reached past the guard.
|
|
56
|
+
* ★ Sorted for the same reason `normalizeChecks` is: GitHub echoes the patterns back in
|
|
57
|
+
* the order they were sent, so an unsorted list diffs against itself the first time a
|
|
58
|
+
* caller writes the same patterns in a different order.
|
|
59
|
+
*/
|
|
60
|
+
function normalizeRefs(field: string, patterns: readonly string[]): string[] {
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
for (const raw of patterns) {
|
|
63
|
+
const pattern = raw.trim();
|
|
64
|
+
if (pattern === '') {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`repoPolicy: a ${field} ref pattern is blank — a ruleset that matches no ref enforces nothing.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
seen.add(pattern);
|
|
70
|
+
}
|
|
71
|
+
return [...seen].sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ⛔ AN EMPTY `include` IS REFUSED. Alchemy defaults `conditions.include` to `['~ALL']`
|
|
76
|
+
* when it is absent, but an explicitly empty array is sent as an empty array, and a
|
|
77
|
+
* ruleset that matches no ref protects nothing while still reading as `active` in the
|
|
78
|
+
* GitHub UI — the worst possible combination, because it looks like it worked.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeInclude(
|
|
81
|
+
include: readonly string[] | undefined,
|
|
82
|
+
fallback: readonly string[],
|
|
83
|
+
): string[] {
|
|
84
|
+
if (include === undefined) return [...fallback];
|
|
85
|
+
if (include.length === 0) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'repoPolicy: include is empty — a ruleset that matches no ref enforces nothing. Omit it for the default branch.',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return normalizeRefs('include', include);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ⛔ AN `exclude` THAT CANCELS EVERY `include` IS REFUSED. In a GitHub ruleset the
|
|
95
|
+
* exclusions win, so `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` is a
|
|
96
|
+
* ruleset over nothing — and it reads as a narrowing, which is what makes it worse than
|
|
97
|
+
* the empty array: the caller believes they scoped the policy, not switched it off.
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ EXACT CANCELLATION ONLY, DELIBERATELY. GitHub's patterns are globs, so deciding in
|
|
100
|
+
* general whether some exclude swallows some include means implementing GitHub's
|
|
101
|
+
* matcher and being wrong about it quietly. This refuses the case that is decidable —
|
|
102
|
+
* every include pattern also appears verbatim in `exclude` — and leaves
|
|
103
|
+
* `exclude: ['refs/heads/*']` against `include: ['~DEFAULT_BRANCH']` to the operator.
|
|
104
|
+
* A narrower guard that is always right beats a broad one that false-refuses.
|
|
105
|
+
*/
|
|
106
|
+
export function normalizeExclude(
|
|
107
|
+
exclude: readonly string[] | undefined,
|
|
108
|
+
include: readonly string[],
|
|
109
|
+
): string[] {
|
|
110
|
+
const patterns = exclude === undefined ? [] : normalizeRefs('exclude', exclude);
|
|
111
|
+
if (patterns.length === 0) return patterns;
|
|
112
|
+
const excluded = new Set(patterns);
|
|
113
|
+
if (include.every((pattern) => excluded.has(pattern))) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
[
|
|
116
|
+
'repoPolicy: exclude cancels every include pattern, so the ruleset matches no ref and enforces nothing.',
|
|
117
|
+
`include ${JSON.stringify(include)} is fully covered by exclude ${JSON.stringify(patterns)}.`,
|
|
118
|
+
].join(' '),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return patterns;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* ⛔ `0` IS REFUSED, NOT TREATED AS "NO REVIEWS". A `pull_request` rule with zero
|
|
126
|
+
* approvals is the solo-maintainer shape — every change through a PR, no second person
|
|
127
|
+
* — and it only works with `require_extra_approval_for_unattributed_changes: false`.
|
|
128
|
+
* alchemy@2.0.0-beta.79's `Ruleset` cannot express that field, and GitHub defaults it
|
|
129
|
+
* to `true` when it is omitted (measured 2026-09-17), which blocks the very PRs the
|
|
130
|
+
* zero was meant to let through. Omitting `requiredApprovals` declares no rule at all,
|
|
131
|
+
* which is a different and honest thing.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveApprovals(requested: number | undefined): number | undefined {
|
|
134
|
+
if (requested === undefined) return undefined;
|
|
135
|
+
if (!Number.isInteger(requested) || requested < 1) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
[
|
|
138
|
+
'repoPolicy: requiredApprovals must be a whole number of 1 or more.',
|
|
139
|
+
'Omit it to declare no pull_request rule.',
|
|
140
|
+
'0 is not that: it needs require_extra_approval_for_unattributed_changes: false,',
|
|
141
|
+
'which alchemy@2.0.0-beta.79 cannot send and GitHub defaults to true.',
|
|
142
|
+
].join(' '),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return requested;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* ⚠️ THE TEST IS "SOMETHING OUTSTANDING", NOT "SOME CHECKS". A required review holds a
|
|
150
|
+
* pull request open just as a required check does, so `requiredApprovals` with an empty
|
|
151
|
+
* `checks` is a legitimate policy. What is refused is auto-merge over a ruleset that
|
|
152
|
+
* will not hold anything back.
|
|
153
|
+
*/
|
|
154
|
+
export function assertAutoMergeWaits(input: {
|
|
155
|
+
readonly autoMerge: boolean;
|
|
156
|
+
readonly checkCount: number;
|
|
157
|
+
readonly approvals: number | undefined;
|
|
158
|
+
readonly enforcement: 'active' | 'disabled' | 'evaluate';
|
|
159
|
+
}): void {
|
|
160
|
+
if (!input.autoMerge) return;
|
|
161
|
+
if (input.enforcement !== 'active') {
|
|
162
|
+
throw new Error(
|
|
163
|
+
[
|
|
164
|
+
`repoPolicy: autoMerge with enforcement '${input.enforcement}' merges every pull request immediately.`,
|
|
165
|
+
'A ruleset that is not active enforces none of its rules, so auto-merge has nothing to wait for.',
|
|
166
|
+
'Pass autoMerge: false while the ruleset is being evaluated.',
|
|
167
|
+
].join(' '),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (input.checkCount === 0 && input.approvals === undefined) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
[
|
|
173
|
+
'repoPolicy: autoMerge with nothing required merges every pull request immediately.',
|
|
174
|
+
'Name at least one always-reporting check context, or ask for requiredApprovals,',
|
|
175
|
+
'or pass autoMerge: false for a repository that has no green run to require yet.',
|
|
176
|
+
].join(' '),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declaring half, and the two docs that carry its traps.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY TEST A DOC. Both files ship in the tarball and are the only place a consumer is
|
|
5
|
+
* told that the vendor ruleset adopts nothing and that auto-merge with nothing required
|
|
6
|
+
* merges on the spot. A silent edit that drops either warning would fail nothing else
|
|
7
|
+
* in this repo, and would cost the consumer a duplicate ruleset or a PR merged on red.
|
|
8
|
+
* ⛔ THE HAZARDS LIVE IN A SIBLING FILE, AND THE POINTER IS TESTED TOO. Extracting them
|
|
9
|
+
* is only safe while `repo-policy.md` still sends the reader there; a dangling pointer
|
|
10
|
+
* would read as "no hazard" rather than as a broken link.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import { declareRepoPolicy } from './index.ts';
|
|
14
|
+
|
|
15
|
+
const doc = await Bun.file(new URL('../../docs/repo-policy.md', import.meta.url)).text();
|
|
16
|
+
const hazards = await Bun.file(
|
|
17
|
+
new URL('../../docs/repo-policy-ruleset-hazards.md', import.meta.url),
|
|
18
|
+
).text();
|
|
19
|
+
|
|
20
|
+
describe('declareRepoPolicy', () => {
|
|
21
|
+
test('is a function, not a factory that reaches GitHub at import time', () => {
|
|
22
|
+
// ⚠️ Importing this module must not construct a resource or an Octokit client: the
|
|
23
|
+
// barrel is loaded by the consumer smoke test with no credentials in the process.
|
|
24
|
+
expect(typeof declareRepoPolicy).toBe('function');
|
|
25
|
+
expect(declareRepoPolicy.length).toBe(2);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('docs/repo-policy.md', () => {
|
|
30
|
+
test('stays under the 200-line cap', () => {
|
|
31
|
+
expect(doc.split('\n').length).toBeLessThanOrEqual(200);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('warns that auto-merge with nothing required merges immediately', () => {
|
|
35
|
+
expect(doc).toContain('auto-merge');
|
|
36
|
+
expect(doc).toMatch(/merges? (the pull request )?immediately/i);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('sends the reader to the ruleset hazards, which is where they now live', () => {
|
|
40
|
+
expect(doc).toContain('repo-policy-ruleset-hazards.md');
|
|
41
|
+
expect(doc).toMatch(/cannot be adopted/i);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('docs/repo-policy-ruleset-hazards.md', () => {
|
|
46
|
+
test('stays under the 200-line cap', () => {
|
|
47
|
+
expect(hazards.split('\n').length).toBeLessThanOrEqual(200);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('warns that the vendor ruleset cannot be adopted and duplicates instead', () => {
|
|
51
|
+
expect(hazards).toContain('createRepoRuleset');
|
|
52
|
+
expect(hazards).toContain('rulesets');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('carries the preflight that keeps a first deploy from duplicating', () => {
|
|
56
|
+
expect(hazards).toContain('gh api repos/<owner>/<repo>/rulesets');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `declareRepoPolicy` — the policy from repo-policy-form.ts, as resources.
|
|
3
|
+
*
|
|
4
|
+
* const policy = yield* declareRepoPolicy('api', {
|
|
5
|
+
* owner: 'my-org',
|
|
6
|
+
* repository: 'api',
|
|
7
|
+
* checks: ['ci', 'secret scan'],
|
|
8
|
+
* });
|
|
9
|
+
*
|
|
10
|
+
* Provide `GitHub.providers()`; this subpath adds no provider of its own.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ THE RULESET CANNOT BE ADOPTED, AND A FIRST DEPLOY ONTO A REPO THAT ALREADY HAS ONE
|
|
13
|
+
* MAKES A SECOND. Read out of alchemy@2.0.0-beta.79's own source on 2026-09-22
|
|
14
|
+
* (`node_modules/alchemy/lib/github/Ruleset.js`): `read` returns `undefined` whenever
|
|
15
|
+
* there is no prior output, and `reconcile` looks the live ruleset up only BY THE ID IN
|
|
16
|
+
* THAT OUTPUT — with none, it calls `createRepoRuleset` unconditionally. GitHub permits
|
|
17
|
+
* several rulesets with the same name on one repository, so nothing errors; the repo
|
|
18
|
+
* quietly ends up with two, both enforcing.
|
|
19
|
+
* ★ SO, BEFORE THE FIRST DEPLOY: `gh api repos/<owner>/<repo>/rulesets` and delete a
|
|
20
|
+
* hand-made ruleset of the same name, or give this one a `rulesetName` of its own.
|
|
21
|
+
* Afterwards the state carries the id and every later deploy converges that one.
|
|
22
|
+
* ⚠️ `GitHub.Repository` does NOT share the problem — its `reconcile` probes by name
|
|
23
|
+
* and converges onto whatever is live — so only half of this helper is adopt-safe,
|
|
24
|
+
* and the halves fail differently. See docs/repo-policy.md.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ BOTH RESOURCES RETAIN. `retain` is already the vendor default for each of them; it
|
|
27
|
+
* is piped anyway so that a vendor change cannot quietly turn "drop this call from the
|
|
28
|
+
* stack" into "delete the repository" or "unprotect the default branch".
|
|
29
|
+
*/
|
|
30
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
31
|
+
import * as GitHub from 'alchemy/GitHub';
|
|
32
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
33
|
+
import * as Effect from 'effect/Effect';
|
|
34
|
+
import { type RepoPolicyOptions, repoPolicy } from './repo-policy-form.ts';
|
|
35
|
+
|
|
36
|
+
export interface DeclareRepoPolicyOptions extends RepoPolicyOptions {
|
|
37
|
+
/**
|
|
38
|
+
* `true`/`false` pipe `adopt(…)` onto both resources; omitted, the deploy's own policy
|
|
39
|
+
* decides. ⚠️ It changes nothing about the ruleset hazard above: adoption routes on
|
|
40
|
+
* what a provider's `read` reports, and this one reports nothing without prior state.
|
|
41
|
+
*/
|
|
42
|
+
readonly adopt?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Declares the repository and its ruleset under `<id>` and `<id>-ruleset`. Fails, before
|
|
47
|
+
* declaring either, on any policy the form refuses.
|
|
48
|
+
*/
|
|
49
|
+
export const declareRepoPolicy = (id: string, options: DeclareRepoPolicyOptions) =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const policy = yield* Effect.try({
|
|
52
|
+
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
53
|
+
try: () => repoPolicy(options),
|
|
54
|
+
});
|
|
55
|
+
// ⚠️ `never` in the error channel, not a generic `E`: `AdoptPolicy.adopt` is typed
|
|
56
|
+
// `Effect<A, never, R> => Effect<A, never, R>`, so a wider error type fails to
|
|
57
|
+
// compile here rather than at the call site. Alchemy's resources declare `never`.
|
|
58
|
+
const owned = <A, R>(resource: Effect.Effect<A, never, R>) => {
|
|
59
|
+
const kept = resource.pipe(RemovalPolicy.retain());
|
|
60
|
+
return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
|
|
61
|
+
};
|
|
62
|
+
const repository = yield* owned(GitHub.Repository(id, policy.repository));
|
|
63
|
+
const ruleset = yield* owned(GitHub.Ruleset(`${id}-ruleset`, policy.ruleset));
|
|
64
|
+
return { policy, repository, ruleset };
|
|
65
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* import { R2BucketLock } from '@homeflare/alchemy/cloudflare';
|
|
8
8
|
* import { BranchProtection } from '@homeflare/alchemy/forgejo';
|
|
9
|
+
* import { declareRepoPolicy } from '@homeflare/alchemy/github';
|
|
9
10
|
* import { BaoMount } from '@homeflare/alchemy/openbao';
|
|
10
11
|
* import { TalosCluster } from '@homeflare/alchemy/talos';
|
|
11
12
|
* import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
|
|
@@ -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
|
+
});
|