@homeflare/alchemy 0.4.0 → 0.5.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 +57 -0
- package/dist/openbao/approle-login-form.d.ts +72 -0
- package/dist/openbao/approle-login-form.d.ts.map +1 -0
- package/dist/openbao/approle-login-result.d.ts +39 -0
- package/dist/openbao/approle-login-result.d.ts.map +1 -0
- package/dist/openbao/approle-login.d.ts +77 -0
- package/dist/openbao/approle-login.d.ts.map +1 -0
- package/dist/openbao/bao-http.d.ts +2 -0
- package/dist/openbao/bao-http.d.ts.map +1 -1
- package/dist/openbao/index.d.ts +7 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +589 -305
- package/dist/openbao/index.js.map +16 -8
- package/dist/openbao/mount-path.d.ts +14 -0
- package/dist/openbao/mount-path.d.ts.map +1 -0
- package/dist/openbao/plugin-form.d.ts +60 -0
- package/dist/openbao/plugin-form.d.ts.map +1 -0
- package/dist/openbao/plugin-reconcile.d.ts +18 -0
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -0
- package/dist/openbao/plugin-wire.d.ts +45 -0
- package/dist/openbao/plugin-wire.d.ts.map +1 -0
- package/dist/openbao/plugin.d.ts +31 -0
- package/dist/openbao/plugin.d.ts.map +1 -0
- package/dist/openbao/ssh-role-form.d.ts +6 -5
- package/dist/openbao/ssh-role-form.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +23 -20
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +19 -1
- package/dist/proxmox/index.js.map +1 -1
- package/package.json +1 -1
- package/src/openbao/approle-login-form.test.ts +64 -0
- package/src/openbao/approle-login-form.ts +153 -0
- package/src/openbao/approle-login-result.ts +96 -0
- package/src/openbao/approle-login.test.ts +200 -0
- package/src/openbao/approle-login.ts +156 -0
- package/src/openbao/auth-role-form.test.ts +42 -0
- package/src/openbao/bao-http.ts +2 -0
- package/src/openbao/index.ts +13 -0
- package/src/openbao/mount-path.test.ts +23 -0
- package/src/openbao/mount-path.ts +25 -0
- package/src/openbao/mount-wire.test.ts +13 -0
- package/src/openbao/plugin-form.test.ts +86 -0
- package/src/openbao/plugin-form.ts +164 -0
- package/src/openbao/plugin-reconcile.ts +92 -0
- package/src/openbao/plugin-wire.test.ts +146 -0
- package/src/openbao/plugin-wire.ts +65 -0
- package/src/openbao/plugin.ts +100 -0
- package/src/openbao/ssh-role-form.test.ts +105 -0
- package/src/openbao/ssh-role-form.ts +18 -16
- package/src/openbao/ssh-role.ts +3 -4
- package/src/proxmox/index.test.ts +53 -0
- package/src/proxmox/index.ts +26 -20
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenBao calls Bao.Plugin makes — read one catalog entry, register, deregister. Split from
|
|
3
|
+
* plugin.ts so the calls can be pinned against a fake server, the way mount-wire.ts is.
|
|
4
|
+
*
|
|
5
|
+
* ★ ENDPOINTS, READ FROM openbao v2.6.2 api/sys_plugins.go (the client `bao plugin` uses) and the
|
|
6
|
+
* handlers in vault/logical_system.go:
|
|
7
|
+
* read GET sys/plugins/catalog/<type>/<name>[?version=] (GetPlugin, :159-183)
|
|
8
|
+
* register PUT sys/plugins/catalog/<type>/<name> (RegisterPlugin, :212-228)
|
|
9
|
+
* deregister DELETE sys/plugins/catalog/<type>/<name>[?version=] (DeregisterPlugin, :249-261)
|
|
10
|
+
* (api/sys_plugins.go diffed identical between v2.6.0 and v2.6.2.)
|
|
11
|
+
* ⚠️ ALL THREE NEED `sudo` ON THE PATH, not just the verb (docs/api/system/plugins-catalog.mdx). A
|
|
12
|
+
* token without it reads as 403, which is an error here, never "absent".
|
|
13
|
+
*/
|
|
14
|
+
import type * as Effect from 'effect/Effect';
|
|
15
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
16
|
+
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
17
|
+
import type { BaoError } from './bao-status.ts';
|
|
18
|
+
import {
|
|
19
|
+
type BaoPluginForm,
|
|
20
|
+
type BaoPluginType,
|
|
21
|
+
catalogPath,
|
|
22
|
+
registerBody,
|
|
23
|
+
versionedPath,
|
|
24
|
+
} from './plugin-form.ts';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One catalog entry, or undefined when the catalog has none at that name, type and version.
|
|
28
|
+
*
|
|
29
|
+
* ★ UNLIKE A MOUNT, A MISSING PLUGIN REALLY IS A 404. handlePluginCatalogRead returns `nil, nil` when
|
|
30
|
+
* the catalog lookup finds nothing (vault/logical_system.go:545-547), and RespondErrorCommon turns
|
|
31
|
+
* a nil READ response into 404 (sdk v2.6.2 logical/response_util.go:23-26). No second question is
|
|
32
|
+
* needed, unlike the mount-table check in mount-wire.ts.
|
|
33
|
+
* ⚠️ A BUILTIN ANSWERS TOO. With no external registration at an unversioned name, the catalog falls
|
|
34
|
+
* through to OpenBao's own builtin of that name and type (plugin_catalog.go get, :1016-1036), which
|
|
35
|
+
* reads back with `builtin: true` — present, but not a registration. plugin.ts decides what that
|
|
36
|
+
* means.
|
|
37
|
+
*/
|
|
38
|
+
export const readPluginData = (
|
|
39
|
+
form: BaoPluginForm,
|
|
40
|
+
): Effect.Effect<Record<string, unknown> | undefined, BaoError, HttpClient.HttpClient> =>
|
|
41
|
+
baoRead(versionedPath(form.type, form.name, form.version));
|
|
42
|
+
|
|
43
|
+
export const registerPlugin = (
|
|
44
|
+
form: BaoPluginForm,
|
|
45
|
+
): Effect.Effect<void, BaoError, HttpClient.HttpClient> =>
|
|
46
|
+
baoWrite('PUT', catalogPath(form.type, form.name), registerBody(form));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ★ IDEMPOTENT ON THE SERVER. deleteInternal deletes the storage key whether or not it exists
|
|
50
|
+
* (plugin_catalog.go:1272-1284) and the handler answers with no body, so a retried delete succeeds.
|
|
51
|
+
* ⚠️ BUT A MISSING TYPED KEY FALLS BACK TO THE BARE NAME. When `<type>/<name>[/<version>]` is not
|
|
52
|
+
* in storage, the same function deletes the key `<name>` instead — where registrations made
|
|
53
|
+
* before plugin types existed live, whatever their type. A fresh 2.x vault has none; one
|
|
54
|
+
* migrated from an old Vault may. REASONED FROM SOURCE, not run.
|
|
55
|
+
* ⛔ AND THAT IS THE DANGER: NOTHING CHECKS WHETHER A MOUNT STILL USES THE PLUGIN. The same function
|
|
56
|
+
* has no mount lookup at all, so deregistering the plugin a live mount runs succeeds. The running
|
|
57
|
+
* process is not killed by it; the mount breaks the next time OpenBao has to START the plugin — a
|
|
58
|
+
* reload, a restart, an unseal — long after the plan that did it. REASONED FROM SOURCE, not run.
|
|
59
|
+
*/
|
|
60
|
+
export const deregisterPlugin = (
|
|
61
|
+
type: BaoPluginType,
|
|
62
|
+
name: string,
|
|
63
|
+
version: string,
|
|
64
|
+
): Effect.Effect<void, BaoError, HttpClient.HttpClient> =>
|
|
65
|
+
baoDelete(versionedPath(type, name, version));
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An OpenBao plugin catalog entry — `sys/plugins/catalog/<type>/<name>`: which binary in
|
|
3
|
+
* plugin_directory, at which SHA-256, may run as a plugin of that name. METADATA ONLY.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ IT REGISTERS; IT DOES NOT DELIVER. The binary must already sit in plugin_directory. Copying it
|
|
6
|
+
* there is a host step.
|
|
7
|
+
* ⚠️ A MISSING FILE FAILS THE WRITE; A WRONG SHA-256 DOES NOT. setInternal (plugin_catalog.go
|
|
8
|
+
* :1150-1230) resolves the path first, which fails loudly, but the run that would notice a
|
|
9
|
+
* checksum mismatch is only the version probe, and that is best-effort: it logs a warning and
|
|
10
|
+
* stores the entry anyway. The mismatch then surfaces when a mount first starts the plugin.
|
|
11
|
+
* ⛔ NO `env` PROP, AND THERE MUST NEVER BE ONE — see registerBody in plugin-form.ts. Alchemy keeps
|
|
12
|
+
* props and attributes UNENCRYPTED in its state store (policy.ts has the reasoning).
|
|
13
|
+
* ⚠️ A NEW SHA-256 DOES NOT RESTART A RUNNING PLUGIN. setInternal writes the catalog entry and
|
|
14
|
+
* reloads nothing, so mounts keep the old process until `sys/plugins/reload/backend` or a
|
|
15
|
+
* restart. Reloading is an operator step, not this resource.
|
|
16
|
+
*
|
|
17
|
+
* ★ `defaultRemovalPolicy: 'retain'`. Deregistering does not check whether a mount still runs the
|
|
18
|
+
* plugin (the ⛔ on deregisterPlugin in plugin-wire.ts), so a destroy would break that mount at its
|
|
19
|
+
* next start. Retain also means a version bump — a REPLACE, below — leaves the old version
|
|
20
|
+
* registered for mounts pinned to it; prune it by hand once nothing uses it.
|
|
21
|
+
*/
|
|
22
|
+
import { Resource } from 'alchemy';
|
|
23
|
+
import { isResolved } from 'alchemy/Diff';
|
|
24
|
+
import * as Provider from 'alchemy/Provider';
|
|
25
|
+
import * as Effect from 'effect/Effect';
|
|
26
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
27
|
+
import { type BaoPluginAttributes, type BaoPluginProps, matches, resolve } from './plugin-form.ts';
|
|
28
|
+
import { readEntry, reconcilePlugin } from './plugin-reconcile.ts';
|
|
29
|
+
import { deregisterPlugin } from './plugin-wire.ts';
|
|
30
|
+
|
|
31
|
+
export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin-form.ts';
|
|
32
|
+
|
|
33
|
+
export interface BaoPlugin extends Resource<
|
|
34
|
+
'Bao.Plugin',
|
|
35
|
+
BaoPluginProps,
|
|
36
|
+
BaoPluginAttributes,
|
|
37
|
+
never,
|
|
38
|
+
HttpClient.HttpClient
|
|
39
|
+
> {}
|
|
40
|
+
|
|
41
|
+
export const BaoPlugin = Resource<BaoPlugin>('Bao.Plugin', {
|
|
42
|
+
defaultRemovalPolicy: 'retain',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const BaoPluginProvider = () =>
|
|
46
|
+
Provider.effect(
|
|
47
|
+
BaoPlugin,
|
|
48
|
+
Effect.succeed(
|
|
49
|
+
BaoPlugin.Provider.of({
|
|
50
|
+
/**
|
|
51
|
+
* ⛔ THE CATALOG IS NOT A LIST OF THINGS THIS OWNS. It answers every builtin plugin and
|
|
52
|
+
* every hand registration. Returning them would invite Alchemy to adopt — and then
|
|
53
|
+
* deregister — plugins it never registered.
|
|
54
|
+
*/
|
|
55
|
+
list: () => Effect.succeed([]),
|
|
56
|
+
|
|
57
|
+
read: Effect.fn(function* ({ olds }) {
|
|
58
|
+
const found = yield* readEntry(resolve(olds));
|
|
59
|
+
return found?.attributes;
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
/** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
|
|
63
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
64
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
65
|
+
const form = resolve(news);
|
|
66
|
+
/**
|
|
67
|
+
* ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so changing one is a different entry,
|
|
68
|
+
* not an edit. Writing the new key would leave the old registration in place under a
|
|
69
|
+
* state record that no longer names it.
|
|
70
|
+
*/
|
|
71
|
+
if (
|
|
72
|
+
output.name !== form.name ||
|
|
73
|
+
output.type !== form.type ||
|
|
74
|
+
output.version !== form.version
|
|
75
|
+
) {
|
|
76
|
+
return { action: 'replace' } as const;
|
|
77
|
+
}
|
|
78
|
+
const found = yield* readEntry(form);
|
|
79
|
+
if (found === undefined) return { action: 'update' } as const;
|
|
80
|
+
return matches(found.attributes, form)
|
|
81
|
+
? ({ action: 'noop' } as const)
|
|
82
|
+
: ({ action: 'update' } as const);
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
/** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
|
|
86
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
87
|
+
return yield* reconcilePlugin(news);
|
|
88
|
+
}),
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* ⛔ READ THE ⛔ ON deregisterPlugin FIRST: nothing checks that a mount still runs this.
|
|
92
|
+
* Idempotent as Alchemy requires — already gone is success on the server.
|
|
93
|
+
*/
|
|
94
|
+
delete: Effect.fn(function* ({ output }) {
|
|
95
|
+
yield* deregisterPlugin(output.type, output.name, output.version);
|
|
96
|
+
return undefined;
|
|
97
|
+
}),
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.SshRole's two shapes — a user-cert role and a host-cert role — through writeBody and back
|
|
3
|
+
* through attributesOf, the round trip every plan rests on.
|
|
4
|
+
*
|
|
5
|
+
* ★ `cert_type` IS NOT A ROLE FIELD. It is a SIGN parameter, default `user` (openbao v2.6.2
|
|
6
|
+
* builtin/logical/ssh/path_sign.go:50-54); a host-cert role is `allow_host_certificates` plus the
|
|
7
|
+
* domain fields, and the host asks for `cert_type=host` when it signs. path_roles.go has no such
|
|
8
|
+
* field, so nothing here sends one.
|
|
9
|
+
*/
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
import {
|
|
13
|
+
type BaoSshRoleProps,
|
|
14
|
+
attributesOf,
|
|
15
|
+
matches,
|
|
16
|
+
resolve,
|
|
17
|
+
writeBody,
|
|
18
|
+
} from './ssh-role-form.ts';
|
|
19
|
+
|
|
20
|
+
const USER: BaoSshRoleProps = {
|
|
21
|
+
allowUserCertificates: true,
|
|
22
|
+
allowedUsers: ['ops', 'deploy'],
|
|
23
|
+
defaultExtensions: { 'permit-pty': '', 'permit-agent-forwarding': '' },
|
|
24
|
+
maxTtl: '16h',
|
|
25
|
+
name: 'operator',
|
|
26
|
+
ttl: '16h',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const HOST: BaoSshRoleProps = {
|
|
30
|
+
allowHostCertificates: true,
|
|
31
|
+
allowSubdomains: true,
|
|
32
|
+
allowedDomains: ['example.test', 'lab.example.test'],
|
|
33
|
+
allowedUsers: [],
|
|
34
|
+
maxTtl: '8760h',
|
|
35
|
+
mount: 'ssh-host/',
|
|
36
|
+
name: 'host',
|
|
37
|
+
ttl: '8760h',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('ssh role writeBody', () => {
|
|
41
|
+
it('sends the maps as JSON objects, never as JSON strings (sdk TypeMap refuses a string)', () => {
|
|
42
|
+
const body = writeBody(resolve(USER));
|
|
43
|
+
assert.deepEqual(body['default_extensions'], {
|
|
44
|
+
'permit-agent-forwarding': '',
|
|
45
|
+
'permit-pty': '',
|
|
46
|
+
});
|
|
47
|
+
assert.deepEqual(body['default_critical_options'], {});
|
|
48
|
+
assert.equal(typeof body['default_extensions'], 'object');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('writes a user-cert role with key_type ca, sorted principals and a stated TTL', () => {
|
|
52
|
+
const body = writeBody(resolve(USER));
|
|
53
|
+
assert.equal(body['key_type'], 'ca');
|
|
54
|
+
assert.equal(body['allowed_users'], 'deploy,ops');
|
|
55
|
+
assert.equal(body['allow_user_certificates'], 'true');
|
|
56
|
+
assert.equal(body['allow_host_certificates'], 'false');
|
|
57
|
+
assert.equal(body['ttl'], '16h');
|
|
58
|
+
assert.equal('cert_type' in body, false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('writes a host-cert role with domains and subdomains on its own mount', () => {
|
|
62
|
+
const form = resolve(HOST);
|
|
63
|
+
const body = writeBody(form);
|
|
64
|
+
assert.equal(form.mount, 'ssh-host');
|
|
65
|
+
assert.equal(body['allow_host_certificates'], 'true');
|
|
66
|
+
assert.equal(body['allow_user_certificates'], 'false');
|
|
67
|
+
assert.equal(body['allowed_domains'], 'example.test,lab.example.test');
|
|
68
|
+
assert.equal(body['allow_subdomains'], 'true');
|
|
69
|
+
assert.equal(body['allowed_users'], '');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('ssh role round trip', () => {
|
|
74
|
+
it('matches the role OpenBao reads back — seconds, objects, comma strings', () => {
|
|
75
|
+
const live = {
|
|
76
|
+
allow_host_certificates: false,
|
|
77
|
+
allow_user_certificates: true,
|
|
78
|
+
allowed_users: 'ops,deploy',
|
|
79
|
+
default_critical_options: {},
|
|
80
|
+
default_extensions: { 'permit-agent-forwarding': '', 'permit-pty': '' },
|
|
81
|
+
key_type: 'ca',
|
|
82
|
+
max_ttl: 57600,
|
|
83
|
+
ttl: 57600,
|
|
84
|
+
};
|
|
85
|
+
const form = resolve(USER);
|
|
86
|
+
assert.equal(matches(attributesOf(form, live), form), true);
|
|
87
|
+
assert.equal(matches(attributesOf(form, { ...live, ttl: 14400 }), form), false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('matches a host role read back', () => {
|
|
91
|
+
const live = {
|
|
92
|
+
allow_host_certificates: true,
|
|
93
|
+
allow_subdomains: true,
|
|
94
|
+
allowed_domains: 'lab.example.test,example.test',
|
|
95
|
+
allowed_users: '',
|
|
96
|
+
default_critical_options: {},
|
|
97
|
+
default_extensions: {},
|
|
98
|
+
key_type: 'ca',
|
|
99
|
+
max_ttl: 31536000,
|
|
100
|
+
ttl: 31536000,
|
|
101
|
+
};
|
|
102
|
+
const form = resolve(HOST);
|
|
103
|
+
assert.equal(matches(attributesOf(form, live), form), true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { sha256 } from './digest.ts';
|
|
15
15
|
import { parseDuration, ttlSeconds } from './mount-form.ts';
|
|
16
|
+
import { sshMountOf as mountOf } from './mount-path.ts';
|
|
16
17
|
|
|
17
18
|
export interface BaoSshRoleProps {
|
|
18
19
|
/** Role name, as `<mount>/roles/{name}` takes it. */
|
|
@@ -76,8 +77,7 @@ export interface BaoSshRoleAttributes extends Omit<BaoSshRoleForm, 'maxTtl' | 't
|
|
|
76
77
|
digest: string;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
export const mountOf = (mount: string | undefined) => (mount ?? 'ssh').replace(/\/+$/, '');
|
|
80
|
+
export { mountOf };
|
|
81
81
|
|
|
82
82
|
export const readPath = (mount: string | undefined, name: string) =>
|
|
83
83
|
`${mountOf(mount)}/roles/${name}`;
|
|
@@ -159,26 +159,28 @@ export const attributesOf = (
|
|
|
159
159
|
};
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
|
-
* ⛔ THE MAPS CROSS THE WIRE AS A JSON STRING
|
|
163
|
-
* `bao write -output-curl-string`
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
162
|
+
* ⛔ THE MAPS CROSS THE WIRE AS JSON OBJECTS. A JSON STRING IS REJECTED, SO EVERY WRITE FAILED.
|
|
163
|
+
* History: `bao write -output-curl-string` showed the CLI sending `k=v` values as strings,
|
|
164
|
+
* so this once sent `"{\"permit-pty\":\"\"}"` on the reasoning that a `framework` TypeMap
|
|
165
|
+
* JSON-decodes a string. ⚠️ IT DOES NOT. MEASURED 2026-09-21 against sdk v2.6.2 itself (a Go
|
|
166
|
+
* probe of framework.FieldData.Validate, no server): TypeMap is `mapstructure.WeakDecode`
|
|
167
|
+
* (framework/field_data.go:271-276), which refuses a string with "expected type
|
|
168
|
+
* 'map[string]interface {}', got unconvertible type 'string'" — and framework/backend.go:
|
|
169
|
+
* 281-285 answers that with a 400 before the handler runs. `"{}"` fails the same way, so every
|
|
170
|
+
* Bao.SshRole write, not only those with extensions, would have been refused. It went unseen
|
|
171
|
+
* because the adopted roles already matched and no write was ever sent. The same probe
|
|
172
|
+
* accepted the object form and every other field of writeBody as sent.
|
|
172
173
|
*/
|
|
173
|
-
const mapValue = (map: Readonly<Record<string, string>>) =>
|
|
174
|
+
const mapValue = (map: Readonly<Record<string, string>>) => extMap(map);
|
|
174
175
|
|
|
175
176
|
/**
|
|
176
177
|
* ⛔ EVERY MANAGED FIELD IS SENT ON EVERY WRITE, INCLUDING THE FALSE ONES. The role write is
|
|
177
178
|
* a full replace (see ssh-role.ts), so an omitted field is not "left alone" — it is reset.
|
|
178
|
-
* Sending the resolved form makes the body and the declaration the same statement.
|
|
179
|
-
*
|
|
179
|
+
* Sending the resolved form makes the body and the declaration the same statement. Strings,
|
|
180
|
+
* exactly the `k=v` pairs `bao write` sent, for `PUT <mount>/roles/<name>` — except the two
|
|
181
|
+
* maps, which must be objects (the ⛔ on mapValue).
|
|
180
182
|
*/
|
|
181
|
-
export const writeBody = (form: BaoSshRoleForm): Record<string,
|
|
183
|
+
export const writeBody = (form: BaoSshRoleForm): Record<string, unknown> => ({
|
|
182
184
|
key_type: 'ca',
|
|
183
185
|
allowed_users: form.allowedUsers.join(','),
|
|
184
186
|
default_user: form.defaultUser,
|
package/src/openbao/ssh-role.ts
CHANGED
|
@@ -209,10 +209,9 @@ export const BaoSshRoleProvider = () =>
|
|
|
209
209
|
yield* baoWrite('PUT', path, writeBody(form));
|
|
210
210
|
}
|
|
211
211
|
/**
|
|
212
|
-
* ⚠️ RE-READ RATHER THAN ECHO THE DECLARATION. The
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* had never confirmed.
|
|
212
|
+
* ⚠️ RE-READ RATHER THAN ECHO THE DECLARATION. The map encoding in the write was
|
|
213
|
+
* wrong once already, unseen until 2026-09-21 (the ⛔ on mapValue), and a provider
|
|
214
|
+
* that returned its own props would record a role it had never confirmed.
|
|
216
215
|
*/
|
|
217
216
|
const after = yield* readRole(form);
|
|
218
217
|
if (after === undefined) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public barrel: control-plane Resource constructors, not guests or NIC apply.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ Lxc/Vm stay Provider-only. A Resource export would let a stack declare
|
|
5
|
+
* guests before storage/SDN exist. NodeNetwork/NetworkApply stay off the
|
|
6
|
+
* barrel for the same reason — they stage interfaces.new.
|
|
7
|
+
*/
|
|
8
|
+
import { expect, test } from 'bun:test';
|
|
9
|
+
import { PbsDatastore, ProxmoxStorage } from './index.ts';
|
|
10
|
+
|
|
11
|
+
const src = await Bun.file(new URL('./index.ts', import.meta.url)).text();
|
|
12
|
+
|
|
13
|
+
const resourceExports = [
|
|
14
|
+
'PbsDatastore',
|
|
15
|
+
'PbsPruneJob',
|
|
16
|
+
'PbsSyncJob',
|
|
17
|
+
'PbsVerifyJob',
|
|
18
|
+
'ProxmoxAcl',
|
|
19
|
+
'ProxmoxBackupJob',
|
|
20
|
+
'ProxmoxGroup',
|
|
21
|
+
'ProxmoxHaResource',
|
|
22
|
+
'ProxmoxHaRule',
|
|
23
|
+
'ProxmoxMetricServer',
|
|
24
|
+
'ProxmoxNotificationTarget',
|
|
25
|
+
'ProxmoxRole',
|
|
26
|
+
'ProxmoxSdnApply',
|
|
27
|
+
'ProxmoxSdnSubnet',
|
|
28
|
+
'ProxmoxSdnVnet',
|
|
29
|
+
'ProxmoxSdnZone',
|
|
30
|
+
'ProxmoxStorage',
|
|
31
|
+
'ProxmoxUser',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
test('control-plane Resource constructors are on the public barrel', () => {
|
|
35
|
+
expect(ProxmoxStorage).toBeDefined();
|
|
36
|
+
expect(PbsDatastore).toBeDefined();
|
|
37
|
+
for (const name of resourceExports) {
|
|
38
|
+
expect(src).toContain(`${name},`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('guests and NIC apply stay Provider-only', () => {
|
|
43
|
+
expect(src).not.toMatch(/export \{ ProxmoxLxc[, }]/);
|
|
44
|
+
expect(src).not.toMatch(/export \{ ProxmoxVm[, }]/);
|
|
45
|
+
expect(src).not.toMatch(/export \{ ProxmoxNodeNetwork[, }]/);
|
|
46
|
+
expect(src).not.toMatch(/export \{ ProxmoxNetworkApply[, }]/);
|
|
47
|
+
expect(src).not.toMatch(/export \{ ProxmoxApiToken[, }]/);
|
|
48
|
+
expect(src).not.toMatch(/export \{ ProxmoxZfsPool[, }]/);
|
|
49
|
+
expect(src).not.toMatch(/export \{ ProxmoxCephOsd[, }]/);
|
|
50
|
+
expect(src).not.toMatch(/export \{ ProxmoxCephFs[, }]/);
|
|
51
|
+
expect(src).not.toMatch(/export \{ ProxmoxCephDaemon[, }]/);
|
|
52
|
+
expect(src).not.toMatch(/export \{ ProxmoxCephFlag[, }]/);
|
|
53
|
+
});
|
package/src/proxmox/index.ts
CHANGED
|
@@ -5,38 +5,44 @@
|
|
|
5
5
|
* These are the symbols a real stack consumes; the rest of the files are internals a
|
|
6
6
|
* provider needs but a consumer should not depend on. An `export *` here would publish
|
|
7
7
|
* every helper as API and make the next refactor a breaking change.
|
|
8
|
-
* ★
|
|
9
|
-
*
|
|
8
|
+
* ★ Control-plane Resource constructors (Storage, SDN, access, HA, backup,
|
|
9
|
+
* metrics, PBS) sit next to their Providers. Guests and NIC apply stay
|
|
10
|
+
* Provider-only so a stack cannot declare them by accident.
|
|
11
|
+
* ★ Anything else unlisted is still reachable by path if you genuinely need
|
|
12
|
+
* it — that is a deliberate, visible act rather than an accident of barrelling.
|
|
10
13
|
*/
|
|
11
|
-
export { ProxmoxAclProvider } from './acl.ts';
|
|
14
|
+
export { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
|
|
12
15
|
export { ProxmoxApiTokenProvider } from './api-token.ts';
|
|
13
|
-
export { ProxmoxBackupJobProvider } from './backup-job.ts';
|
|
16
|
+
export { ProxmoxBackupJob, ProxmoxBackupJobProvider } from './backup-job.ts';
|
|
14
17
|
export { ProxmoxCephDaemonProvider } from './ceph-daemon.ts';
|
|
15
18
|
export { ProxmoxCephFlagProvider } from './ceph-flag.ts';
|
|
16
19
|
export { ProxmoxCephFsProvider } from './ceph-fs.ts';
|
|
17
20
|
export { ProxmoxCephOsdProvider } from './ceph-osd.ts';
|
|
18
21
|
export { ProxmoxCephPool, ProxmoxCephPoolProvider } from './ceph-pool.ts';
|
|
19
22
|
export { ProxmoxFirewallAliasProvider } from './firewall-alias.ts';
|
|
20
|
-
export { ProxmoxGroupProvider } from './group.ts';
|
|
21
|
-
export { ProxmoxHaResourceProvider } from './ha-resource.ts';
|
|
22
|
-
export { ProxmoxHaRuleProvider } from './ha-rule.ts';
|
|
23
|
+
export { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
|
|
24
|
+
export { ProxmoxHaResource, ProxmoxHaResourceProvider } from './ha-resource.ts';
|
|
25
|
+
export { ProxmoxHaRule, ProxmoxHaRuleProvider } from './ha-rule.ts';
|
|
23
26
|
export { ProxmoxLxcProvider } from './lxc.ts';
|
|
24
|
-
export { ProxmoxMetricServerProvider } from './metric-server.ts';
|
|
27
|
+
export { ProxmoxMetricServer, ProxmoxMetricServerProvider } from './metric-server.ts';
|
|
25
28
|
export { ProxmoxNetworkApplyProvider } from './network-apply.ts';
|
|
26
29
|
export { ProxmoxNodeNetworkProvider } from './node-network.ts';
|
|
27
|
-
export {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
export {
|
|
30
|
+
export {
|
|
31
|
+
ProxmoxNotificationTarget,
|
|
32
|
+
ProxmoxNotificationTargetProvider,
|
|
33
|
+
} from './notification-target.ts';
|
|
34
|
+
export { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
|
|
35
|
+
export { PbsPruneJob, PbsPruneJobProvider } from './pbs-prune-job.ts';
|
|
36
|
+
export { PbsSyncJob, PbsSyncJobProvider } from './pbs-sync-job.ts';
|
|
37
|
+
export { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
|
|
32
38
|
export { ProxmoxPool, ProxmoxPoolProvider } from './pool.ts';
|
|
33
39
|
export { ProxmoxVmProvider } from './qemu.ts';
|
|
34
40
|
export { ProxmoxReplicationJobProvider } from './replication-job.ts';
|
|
35
|
-
export { ProxmoxRoleProvider } from './role.ts';
|
|
36
|
-
export { ProxmoxSdnApplyProvider } from './sdn-apply.ts';
|
|
37
|
-
export { ProxmoxSdnSubnetProvider } from './sdn-subnet.ts';
|
|
38
|
-
export { ProxmoxSdnVnetProvider } from './sdn-vnet.ts';
|
|
39
|
-
export { ProxmoxSdnZoneProvider } from './sdn-zone.ts';
|
|
40
|
-
export { ProxmoxStorageProvider } from './storage.ts';
|
|
41
|
-
export { ProxmoxUserProvider } from './user.ts';
|
|
41
|
+
export { ProxmoxRole, ProxmoxRoleProvider } from './role.ts';
|
|
42
|
+
export { ProxmoxSdnApply, ProxmoxSdnApplyProvider } from './sdn-apply.ts';
|
|
43
|
+
export { ProxmoxSdnSubnet, ProxmoxSdnSubnetProvider } from './sdn-subnet.ts';
|
|
44
|
+
export { ProxmoxSdnVnet, ProxmoxSdnVnetProvider } from './sdn-vnet.ts';
|
|
45
|
+
export { ProxmoxSdnZone, ProxmoxSdnZoneProvider } from './sdn-zone.ts';
|
|
46
|
+
export { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
|
|
47
|
+
export { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
|
|
42
48
|
export { ProxmoxZfsPoolProvider } from './zfs-pool.ts';
|