@homeflare/alchemy 0.3.2 → 0.4.1

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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Bao.AuthMethod's calls against a fake. A missing method is a 400, not a 404 —
3
+ * the same trap as secrets mounts (mount-wire.ts). Only the auth table may
4
+ * turn that 400 into "absent".
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import {
9
+ disableAuthMethod,
10
+ enableAuthMethod,
11
+ readAuthMethodData,
12
+ tuneAuthMethod,
13
+ } from './auth-method-wire.ts';
14
+ import { BaoError } from './bao-status.ts';
15
+ import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
16
+
17
+ const NO_AUTH = { json: { errors: ['No auth engine at approle-new/'] }, status: 400 };
18
+ const table = (...paths: string[]) => ({
19
+ json: { data: Object.fromEntries(paths.map((path) => [path, { type: 'approle' }])) },
20
+ status: 200,
21
+ });
22
+
23
+ const routes = (method: Reply, listing: Reply) => (seen: Seen) =>
24
+ seen.path === '/v1/sys/auth' ? listing : method;
25
+
26
+ const statusOf = async (error: Promise<unknown>) => {
27
+ const settled = await error;
28
+ assert.ok(settled instanceof BaoError);
29
+ return settled.status;
30
+ };
31
+
32
+ describe('readAuthMethodData', () => {
33
+ it('reads a method that exists, without consulting the table', async () => {
34
+ const reply = { json: { data: { config: {}, type: 'approle' } }, status: 200 };
35
+ await withFake(routes(reply, table()), async (bao) => {
36
+ const data = await run({ BAO_ADDR: bao.address }, readAuthMethodData('approle'));
37
+ assert.equal(data?.['type'], 'approle');
38
+ assert.deepEqual(
39
+ bao.seen.map((seen) => seen.path),
40
+ ['/v1/sys/auth/approle'],
41
+ );
42
+ });
43
+ });
44
+
45
+ it("reads a missing method as absent once the table confirms OpenBao's 400", async () => {
46
+ await withFake(routes(NO_AUTH, table('token/', 'userpass/')), async (bao) => {
47
+ assert.equal(
48
+ await run({ BAO_ADDR: bao.address }, readAuthMethodData('approle-new/')),
49
+ undefined,
50
+ );
51
+ assert.deepEqual(
52
+ bao.seen.map((seen) => seen.path),
53
+ ['/v1/sys/auth/approle-new', '/v1/sys/auth'],
54
+ );
55
+ });
56
+ });
57
+
58
+ it('keeps the 400 when the table lists the method after all', async () => {
59
+ await withFake(routes(NO_AUTH, table('approle-new/')), async (bao) => {
60
+ const error = runFailure({ BAO_ADDR: bao.address }, readAuthMethodData('approle-new'));
61
+ assert.equal(await statusOf(error), 400);
62
+ });
63
+ });
64
+
65
+ it('keeps the 400 when the table read is refused', async () => {
66
+ const refused = { json: { errors: ['permission denied'] }, status: 403 };
67
+ await withFake(routes(NO_AUTH, refused), async (bao) => {
68
+ const error = runFailure({ BAO_ADDR: bao.address }, readAuthMethodData('approle-new'));
69
+ assert.equal(await statusOf(error), 400);
70
+ });
71
+ });
72
+
73
+ it('fails a refused method read (403) without consulting the table', async () => {
74
+ const refused = { json: { errors: ['permission denied'] }, status: 403 };
75
+ await withFake(routes(refused, table()), async (bao) => {
76
+ const error = runFailure({ BAO_ADDR: bao.address }, readAuthMethodData('approle'));
77
+ assert.equal(await statusOf(error), 403);
78
+ assert.equal(bao.seen.length, 1);
79
+ });
80
+ });
81
+ });
82
+
83
+ describe('enable, tune, disable', () => {
84
+ it('sends the endpoints and bodies configure-engines sent', async () => {
85
+ await withFake(
86
+ () => ({ status: 204 }),
87
+ async (bao) => {
88
+ const env = { BAO_ADDR: bao.address };
89
+ const props = {
90
+ description: 'Alchemy and CI machine login',
91
+ path: 'approle/',
92
+ type: 'approle',
93
+ };
94
+ await run(env, enableAuthMethod(props));
95
+ await run(env, tuneAuthMethod(props));
96
+ await run(env, disableAuthMethod('approle'));
97
+ const calls = bao.seen.map((seen) => [seen.method, seen.path, seen.body]);
98
+ assert.deepEqual(calls, [
99
+ [
100
+ 'POST',
101
+ '/v1/sys/auth/approle',
102
+ JSON.stringify({ type: 'approle', description: 'Alchemy and CI machine login' }),
103
+ ],
104
+ [
105
+ 'POST',
106
+ '/v1/sys/auth/approle/tune',
107
+ JSON.stringify({ description: 'Alchemy and CI machine login' }),
108
+ ],
109
+ ['DELETE', '/v1/sys/auth/approle', ''],
110
+ ]);
111
+ },
112
+ );
113
+ });
114
+
115
+ it('fails a refused enable with OpenBao errors', async () => {
116
+ await withFake(
117
+ () => ({ json: { errors: ['path is already in use at approle/'] }, status: 400 }),
118
+ async (bao) => {
119
+ const error = runFailure(
120
+ { BAO_ADDR: bao.address },
121
+ enableAuthMethod({ path: 'approle', type: 'approle' }),
122
+ );
123
+ assert.equal(await statusOf(error), 400);
124
+ },
125
+ );
126
+ });
127
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The OpenBao calls Bao.AuthMethod makes — read one method, enable, tune, disable.
3
+ *
4
+ * ★ ENDPOINTS, READ FROM openbao v2.6.2 api/sys_auth.go — the client configure-engines
5
+ * used via curl:
6
+ * read GET sys/auth/<path>
7
+ * enable POST sys/auth/<path> — `bao auth enable`
8
+ * tune POST sys/auth/<path>/tune — `bao auth tune`
9
+ * disable DELETE sys/auth/<path> — `bao auth disable`
10
+ * table GET sys/auth
11
+ */
12
+ import * as Effect from 'effect/Effect';
13
+ import {
14
+ type BaoAuthMethodProps,
15
+ authPath,
16
+ enableBody,
17
+ readPath,
18
+ tuneBody,
19
+ tunePath,
20
+ } from './auth-method-form.ts';
21
+ import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
22
+ import { BaoError } from './bao-status.ts';
23
+
24
+ /**
25
+ * Confirm a method is absent from the auth table, or give back the error that asked.
26
+ *
27
+ * ⛔ A MISSING AUTH METHOD IS NOT A 404, IT IS A 400 — THE SAME TRAP AS SECRETS MOUNTS
28
+ * (mount-wire.ts). `handleReadAuth` answers an empty path with a bare error; the CLI
29
+ * therefore failed and never reached `auth enable`. REASONED FROM THE MOUNT ANALOG
30
+ * AND PINNED AGAINST A FAKE — a live missing-method read was not measured.
31
+ * ★ SO A 400 IS SETTLED BY GET sys/auth. Only a 200 table WITHOUT the `${path}/` key
32
+ * is absence. A listed method, a refused listing, or a 404 listing leaves the 400.
33
+ */
34
+ const absentFromTable = (path: string, asked: BaoError) =>
35
+ baoRead('sys/auth').pipe(
36
+ Effect.mapError(
37
+ (listing) =>
38
+ new BaoError(asked.status, asked.method, asked.path, [
39
+ ...asked.errors,
40
+ `sys/auth could not confirm the method is absent: ${listing.message}`,
41
+ ]),
42
+ ),
43
+ Effect.flatMap((table) =>
44
+ table !== undefined && !Object.hasOwn(table, `${authPath(path)}/`)
45
+ ? Effect.succeed(undefined)
46
+ : Effect.fail(asked),
47
+ ),
48
+ );
49
+
50
+ /** One method's config (`type`, `description`, `config.*_lease_ttl`), or undefined when absent. */
51
+ export const readAuthMethodData = (path: string) =>
52
+ baoRead(readPath(path)).pipe(
53
+ Effect.catchIf(
54
+ (error) => error.status === 400,
55
+ (error) => absentFromTable(path, error),
56
+ ),
57
+ );
58
+
59
+ export const enableAuthMethod = (props: BaoAuthMethodProps) =>
60
+ baoWrite('POST', readPath(props.path), enableBody(props));
61
+
62
+ export const tuneAuthMethod = (props: BaoAuthMethodProps) =>
63
+ baoWrite('POST', tunePath(props.path), tuneBody(props));
64
+
65
+ /** ⚠️ THERE IS NO FORCE. Disable is a plain DELETE, same as `bao auth disable`. */
66
+ export const disableAuthMethod = (path: string) => baoDelete(readPath(path));
@@ -0,0 +1,119 @@
1
+ /**
2
+ * An OpenBao auth method — path, type, description and lease TTLs.
3
+ *
4
+ * ⛔ NO SECRET IN PROPS OR ATTRIBUTES — see policy.ts. This enables `approle` (or jwt/oidc)
5
+ * as a mount. Role ids, OIDC client secrets, and secret_ids are out of scope.
6
+ *
7
+ * ★ `defaultRemovalPolicy: 'retain'` — disabling a method destroys every role under it
8
+ * and revokes tokens minted through those roles. Opt in with
9
+ * `.pipe(RemovalPolicy.destroy())`.
10
+ */
11
+ import { Resource } from 'alchemy';
12
+ import { isResolved } from 'alchemy/Diff';
13
+ import * as Provider from 'alchemy/Provider';
14
+ import * as Effect from 'effect/Effect';
15
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
16
+ import {
17
+ type BaoAuthMethodAttributes,
18
+ type BaoAuthMethodProps,
19
+ attributesOf,
20
+ authPath,
21
+ matches,
22
+ wantsTune,
23
+ } from './auth-method-form.ts';
24
+ import {
25
+ disableAuthMethod,
26
+ enableAuthMethod,
27
+ readAuthMethodData,
28
+ tuneAuthMethod,
29
+ } from './auth-method-wire.ts';
30
+
31
+ export type { BaoAuthMethodAttributes, BaoAuthMethodProps };
32
+
33
+ export interface BaoAuthMethod extends Resource<
34
+ 'Bao.AuthMethod',
35
+ BaoAuthMethodProps,
36
+ BaoAuthMethodAttributes,
37
+ never,
38
+ HttpClient.HttpClient
39
+ > {}
40
+
41
+ export const BaoAuthMethod = Resource<BaoAuthMethod>('Bao.AuthMethod', {
42
+ defaultRemovalPolicy: 'retain',
43
+ });
44
+
45
+ const readMethod = (props: BaoAuthMethodProps) =>
46
+ Effect.gen(function* () {
47
+ const live = yield* readAuthMethodData(props.path);
48
+ if (live === undefined) return undefined;
49
+ return attributesOf(props, live);
50
+ });
51
+
52
+ export const BaoAuthMethodProvider = () =>
53
+ Provider.effect(
54
+ BaoAuthMethod,
55
+ Effect.succeed(
56
+ BaoAuthMethod.Provider.of({
57
+ /**
58
+ * ⛔ `bao auth list` IS NOT A LIST OF THINGS THIS OWNS. `token/` is always
59
+ * present. Returning it would invite Alchemy to adopt — and then disable —
60
+ * methods it never created.
61
+ */
62
+ list: () => Effect.succeed([]),
63
+
64
+ read: Effect.fn(function* ({ olds }) {
65
+ return yield* readMethod(olds);
66
+ }),
67
+
68
+ diff: Effect.fn(function* ({ news, output }) {
69
+ if (output === undefined || !isResolved(news)) return undefined;
70
+ const live = yield* readMethod(news);
71
+ if (live === undefined) return { action: 'update' } as const;
72
+ /**
73
+ * ⛔ A CHANGED `type` IS A REPLACE. Delete is an auth disable — every role
74
+ * under the method dies. `retain` guards both orphan-delete and replace
75
+ * (same Alchemy Apply.ts path Bao.Mount documents).
76
+ */
77
+ if (live.type !== news.type) return { action: 'replace' } as const;
78
+ if (matches(live, news)) return { action: 'noop' } as const;
79
+ return { action: 'update' } as const;
80
+ }),
81
+
82
+ reconcile: Effect.fn(function* ({ news }) {
83
+ let live = yield* readMethod(news);
84
+ if (live === undefined) {
85
+ yield* enableAuthMethod(news);
86
+ if (wantsTune(news)) yield* tuneAuthMethod(news);
87
+ } else if (!matches(live, news)) {
88
+ if (live.type !== news.type) {
89
+ return yield* Effect.die(
90
+ new Error(
91
+ `Bao.AuthMethod ${authPath(news.path)}: live type ${live.type} != ${news.type}. ` +
92
+ 'Auth method type is immutable — replace manually.',
93
+ ),
94
+ );
95
+ }
96
+ yield* tuneAuthMethod(news);
97
+ }
98
+ live = yield* readMethod(news);
99
+ if (live === undefined) {
100
+ return yield* Effect.die(
101
+ new Error(
102
+ `Bao.AuthMethod ${authPath(news.path)}: write returned no error but the method is still absent.`,
103
+ ),
104
+ );
105
+ }
106
+ return live;
107
+ }),
108
+
109
+ /**
110
+ * ⛔ DISABLING A METHOD DESTROYS EVERY ROLE UNDER IT. Idempotent as Alchemy
111
+ * requires — already gone is success — but retain is the default for a reason.
112
+ */
113
+ delete: Effect.fn(function* ({ output }) {
114
+ yield* disableAuthMethod(output.path);
115
+ return undefined;
116
+ }),
117
+ }),
118
+ ),
119
+ );
@@ -8,6 +8,7 @@
8
8
  * ★ Anything unlisted is still reachable by path if you genuinely need it — that is a
9
9
  * deliberate, visible act rather than an accident of barrelling.
10
10
  */
11
+ export { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
11
12
  export { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
12
13
  export { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
13
14
  export {
@@ -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
+ });
@@ -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
- * ★ Anything unlisted is still reachable by path if you genuinely need it — that is a
9
- * deliberate, visible act rather than an accident of barrelling.
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 { ProxmoxNotificationTargetProvider } from './notification-target.ts';
28
- export { PbsDatastoreProvider } from './pbs-datastore.ts';
29
- export { PbsPruneJobProvider } from './pbs-prune-job.ts';
30
- export { PbsSyncJobProvider } from './pbs-sync-job.ts';
31
- export { PbsVerifyJobProvider } from './pbs-verify-job.ts';
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';