@homeflare/alchemy 0.8.0 → 0.10.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 +22 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -19,6 +19,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
19
19
|
import * as Provider from 'alchemy/Provider';
|
|
20
20
|
import * as Effect from 'effect/Effect';
|
|
21
21
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
22
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
23
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
24
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
22
25
|
import {
|
|
23
26
|
type BaoAuthMethodAttributes,
|
|
24
27
|
type BaoAuthMethodProps,
|
|
@@ -54,12 +57,17 @@ export const BaoAuthMethodProvider = () =>
|
|
|
54
57
|
*/
|
|
55
58
|
list: () => Effect.succeed([]),
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
61
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
62
|
+
const found = yield* readMethod(olds);
|
|
63
|
+
const ours = Effect.sync(() => found !== undefined && matches(found, olds));
|
|
64
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
59
65
|
}),
|
|
60
66
|
|
|
61
67
|
diff: Effect.fn(function* ({ news, output }) {
|
|
62
|
-
|
|
68
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
69
|
+
if (output === undefined) return undefined;
|
|
70
|
+
if (!isResolved(news)) return undefined;
|
|
63
71
|
/**
|
|
64
72
|
* ⛔ A CHANGED PATH FAILS HERE unless `remountFrom` names the old one — the same trap
|
|
65
73
|
* Bao.Mount had: reading the new path finds nothing, plans `update`, and enables an
|
|
@@ -81,8 +89,9 @@ export const BaoAuthMethodProvider = () =>
|
|
|
81
89
|
}),
|
|
82
90
|
|
|
83
91
|
/** The body, and the move, live in auth-method-reconcile.ts. */
|
|
84
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
85
|
-
|
|
92
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
93
|
+
const claim = claimFor({ fqn, instanceId, output });
|
|
94
|
+
return yield* reconcileAuthMethod(news, output?.path, undefined, claim);
|
|
86
95
|
}),
|
|
87
96
|
|
|
88
97
|
/**
|
|
@@ -94,5 +103,5 @@ export const BaoAuthMethodProvider = () =>
|
|
|
94
103
|
return undefined;
|
|
95
104
|
}),
|
|
96
105
|
}),
|
|
97
|
-
),
|
|
106
|
+
).pipe(Effect.map(provingResumes)),
|
|
98
107
|
);
|
package/src/openbao/auth-role.ts
CHANGED
|
@@ -14,6 +14,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
14
14
|
import * as Provider from 'alchemy/Provider';
|
|
15
15
|
import * as Effect from 'effect/Effect';
|
|
16
16
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
17
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
18
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
19
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
17
20
|
import {
|
|
18
21
|
type BaoAuthRoleAttributes,
|
|
19
22
|
type BaoAuthRoleProps,
|
|
@@ -66,8 +69,11 @@ export const BaoAuthRoleProvider = () =>
|
|
|
66
69
|
*/
|
|
67
70
|
list: () => Effect.succeed([]),
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
73
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
74
|
+
const found = yield* readRole(olds);
|
|
75
|
+
const ours = Effect.sync(() => found !== undefined && matches(found, olds));
|
|
76
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
71
77
|
}),
|
|
72
78
|
|
|
73
79
|
/**
|
|
@@ -87,6 +93,7 @@ export const BaoAuthRoleProvider = () =>
|
|
|
87
93
|
* opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
|
|
88
94
|
*/
|
|
89
95
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
96
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
90
97
|
if (output === undefined) return undefined;
|
|
91
98
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
92
99
|
if (!isResolved(news)) return undefined;
|
|
@@ -96,10 +103,13 @@ export const BaoAuthRoleProvider = () =>
|
|
|
96
103
|
return { action: 'update' } as const;
|
|
97
104
|
}),
|
|
98
105
|
|
|
99
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
106
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
100
107
|
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
101
108
|
yield* guardRename(IDENTITY, news, output);
|
|
102
109
|
const live = yield* readRole(news);
|
|
110
|
+
const path = readPath(news.name);
|
|
111
|
+
if (live !== undefined)
|
|
112
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.AuthRole ${path}`);
|
|
103
113
|
if (live === undefined || !matches(live, news)) {
|
|
104
114
|
yield* baoWrite('PUT', readPath(news.name), writeBody(news));
|
|
105
115
|
}
|
|
@@ -130,5 +140,5 @@ export const BaoAuthRoleProvider = () =>
|
|
|
130
140
|
return undefined;
|
|
131
141
|
}),
|
|
132
142
|
}),
|
|
133
|
-
),
|
|
143
|
+
).pipe(Effect.map(provingResumes)),
|
|
134
144
|
);
|
|
@@ -21,6 +21,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
21
21
|
import * as Provider from 'alchemy/Provider';
|
|
22
22
|
import * as Effect from 'effect/Effect';
|
|
23
23
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
24
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
25
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
26
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
24
27
|
import { groupKey, scopeOfResource } from './cloudflare-group-scope.ts';
|
|
25
28
|
import { CloudflarePermissionGroups, type GroupRef } from './cloudflare-permission-groups.ts';
|
|
26
29
|
import { resolvePolicies } from './cloudflare-policy.ts';
|
|
@@ -106,9 +109,15 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
106
109
|
*/
|
|
107
110
|
list: () => Effect.succeed([]),
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
113
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
110
114
|
const live = yield* readCloudflareRole(olds.mount, olds.name);
|
|
111
|
-
|
|
115
|
+
const found = live === undefined ? undefined : attributesOf(olds, live);
|
|
116
|
+
const ours = Effect.gen(function* () {
|
|
117
|
+
if (live === undefined || refusalOf(olds) !== undefined) return false;
|
|
118
|
+
return differences(olds, yield* resolved(olds), live).length === 0;
|
|
119
|
+
});
|
|
120
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
112
121
|
}),
|
|
113
122
|
|
|
114
123
|
/**
|
|
@@ -129,6 +138,7 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
129
138
|
*/
|
|
130
139
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
131
140
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
141
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
132
142
|
if (output === undefined) return undefined;
|
|
133
143
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
134
144
|
if (!isResolved(news)) return undefined;
|
|
@@ -145,7 +155,7 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
145
155
|
: ({ action: 'update' } as const);
|
|
146
156
|
}),
|
|
147
157
|
|
|
148
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
158
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
149
159
|
const path = rolePath(news.mount, news.name);
|
|
150
160
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
151
161
|
yield* guardRename(IDENTITY, news, output);
|
|
@@ -155,6 +165,9 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
155
165
|
}
|
|
156
166
|
const policies = yield* resolved(news);
|
|
157
167
|
const live = yield* readCloudflareRole(news.mount, news.name);
|
|
168
|
+
if (live !== undefined) {
|
|
169
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.CloudflareRole ${path}`);
|
|
170
|
+
}
|
|
158
171
|
/**
|
|
159
172
|
* ⛔ ADOPTING A ROLE THAT ALREADY MATCHES MUST NOT REWRITE IT. Alchemy's `adopted` action
|
|
160
173
|
* routes through reconcile (policy.ts has the Apply.ts reading), so without this guard
|
|
@@ -199,5 +212,5 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
199
212
|
return undefined;
|
|
200
213
|
}),
|
|
201
214
|
});
|
|
202
|
-
}),
|
|
215
|
+
}).pipe(Effect.map(provingResumes)),
|
|
203
216
|
);
|
package/src/openbao/fake-bao.ts
CHANGED
|
@@ -82,3 +82,55 @@ export const runFailure = <A, E>(
|
|
|
82
82
|
env: BaoEnvironment,
|
|
83
83
|
effect: Effect.Effect<A, E, HttpClient.HttpClient>,
|
|
84
84
|
) => Effect.runPromise(provided(env, Effect.flip(effect)));
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An injected crash between a write and its commit. Once armed, `crashNext()` fails the first read
|
|
88
|
+
* that follows a write with a 500 — a reconcile whose write landed and whose read-back, and so the
|
|
89
|
+
* engine's commit, never did — and `crashNext('write')` lands the next write and then answers it
|
|
90
|
+
* 500, for a reconcile that reads nothing back (Bao.Policy). Either leaves the `creating` /
|
|
91
|
+
* `replacing` row a killed deploy leaves.
|
|
92
|
+
*/
|
|
93
|
+
export const crashAfterWrite = () => {
|
|
94
|
+
let armed: 'read-back' | 'write' | undefined;
|
|
95
|
+
let wrote = false;
|
|
96
|
+
const CRASH: Reply = { json: { errors: ['injected: the deploy died here'] }, status: 500 };
|
|
97
|
+
return {
|
|
98
|
+
crashNext: (at: 'read-back' | 'write' = 'read-back') => {
|
|
99
|
+
armed = at;
|
|
100
|
+
wrote = false;
|
|
101
|
+
},
|
|
102
|
+
wrap:
|
|
103
|
+
(answer: (seen: Seen) => Reply) =>
|
|
104
|
+
(seen: Seen): Reply => {
|
|
105
|
+
if (armed === 'write' && seen.method !== 'GET') {
|
|
106
|
+
armed = undefined;
|
|
107
|
+
answer(seen);
|
|
108
|
+
return CRASH;
|
|
109
|
+
}
|
|
110
|
+
if (armed !== undefined && seen.method !== 'GET') wrote = true;
|
|
111
|
+
if (armed !== undefined && wrote && seen.method === 'GET') {
|
|
112
|
+
armed = undefined;
|
|
113
|
+
return CRASH;
|
|
114
|
+
}
|
|
115
|
+
return answer(seen);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A deploy killed BEFORE a resource's reconcile ran: the first write to a path ending in `suffix` is
|
|
122
|
+
* refused (500) without landing. Aimed at an upstream, it fails every create waiting on it after
|
|
123
|
+
* Apply has committed their `creating` rows — rows whose create never asked whose object sat at
|
|
124
|
+
* its identity (ownership/whole.ts).
|
|
125
|
+
*/
|
|
126
|
+
export const refuseWriteOnce = (suffix: string) => {
|
|
127
|
+
let armed = true;
|
|
128
|
+
return (answer: (seen: Seen) => Reply) =>
|
|
129
|
+
(seen: Seen): Reply => {
|
|
130
|
+
if (armed && seen.method !== 'GET' && seen.path.endsWith(suffix)) {
|
|
131
|
+
armed = false;
|
|
132
|
+
return { json: { errors: ['injected: the upstream was refused'] }, status: 500 };
|
|
133
|
+
}
|
|
134
|
+
return answer(seen);
|
|
135
|
+
};
|
|
136
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five families fake-families.ts does not cover — Bao.Policy, Bao.CloudflareRole,
|
|
3
|
+
* Bao.ProxmoxRole, Bao.Mount and Bao.AuthMethod — as rows of one table over one routed fake, so the
|
|
4
|
+
* ownership tests (adopt-core.test.ts) drive each through Alchemy's own plan and apply. Each row
|
|
5
|
+
* declares one object whose identity is fixed; `knob` is a duration-like prop that is NOT its
|
|
6
|
+
* identity, so an Output can stand in for it and a second owner can change it.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
9
|
+
*/
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
13
|
+
import type * as Output from 'alchemy/Output';
|
|
14
|
+
import { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
|
|
15
|
+
import { BaoAuthRoleProvider } from './auth-role.ts';
|
|
16
|
+
import { groupKey } from './cloudflare-group-scope.ts';
|
|
17
|
+
import { CloudflarePermissionGroups } from './cloudflare-permission-groups.ts';
|
|
18
|
+
import { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
|
|
19
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
20
|
+
import { aclPolicies, cloudflareRoles, fakePolicyProviders, proxmoxRoles } from './fake-engines.ts';
|
|
21
|
+
import { wireRoles } from './fake-engines-roles.ts';
|
|
22
|
+
import { liveTable } from './fake-mounts.ts';
|
|
23
|
+
import { type FakeStack, type StackBody, fakeStack, withFakeStack } from './fake-stack.ts';
|
|
24
|
+
import { BaoMount, BaoMountProvider } from './mount.ts';
|
|
25
|
+
import { BaoPolicy } from './policy.ts';
|
|
26
|
+
import { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
|
|
27
|
+
|
|
28
|
+
/** Every group resolves, to an id named after it — these tests are about ownership, not groups. */
|
|
29
|
+
const groups = Layer.succeed(CloudflarePermissionGroups, {
|
|
30
|
+
resolve: (_mount, refs) =>
|
|
31
|
+
Effect.succeed(new Map(refs.map((ref) => [groupKey(ref.name, ref.scope), `id-${ref.name}`]))),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const coreProviders = Layer.mergeAll(
|
|
35
|
+
fakePolicyProviders,
|
|
36
|
+
BaoCloudflareRoleProvider().pipe(Layer.provide(groups)),
|
|
37
|
+
BaoProxmoxRoleProvider(),
|
|
38
|
+
BaoMountProvider(),
|
|
39
|
+
BaoAuthMethodProvider(),
|
|
40
|
+
// ★ For `upstream`: an AppRole whose `tokenTtl` is the Output a knob is fed.
|
|
41
|
+
BaoAuthRoleProvider(),
|
|
42
|
+
FetchHttpClient.layer,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Every store behind one answer, routed by path. */
|
|
46
|
+
export const coreEstate = () => {
|
|
47
|
+
const stores = {
|
|
48
|
+
auth: liveTable('sys/auth', {}),
|
|
49
|
+
cloudflare: cloudflareRoles(),
|
|
50
|
+
mounts: liveTable('sys/mounts', {}),
|
|
51
|
+
policies: aclPolicies(),
|
|
52
|
+
proxmox: proxmoxRoles(),
|
|
53
|
+
roles: wireRoles(),
|
|
54
|
+
};
|
|
55
|
+
const answer = (seen: Seen): Reply => {
|
|
56
|
+
const path = seen.path.replace(/^\/v1\//, '');
|
|
57
|
+
if (path.startsWith('sys/policies/acl/')) return stores.policies(seen);
|
|
58
|
+
if (path.startsWith('sys/auth')) return stores.auth.answer(seen);
|
|
59
|
+
if (path.startsWith('sys/mounts') || path.startsWith('sys/remount')) {
|
|
60
|
+
return stores.mounts.answer(seen);
|
|
61
|
+
}
|
|
62
|
+
if (path.startsWith('cloudflare-acme-dns/')) return stores.cloudflare(seen);
|
|
63
|
+
if (path.startsWith('proxmox-lab/')) return stores.proxmox(seen);
|
|
64
|
+
return stores.roles(seen);
|
|
65
|
+
};
|
|
66
|
+
return Object.assign(answer, stores);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type CoreEstate = ReturnType<typeof coreEstate>;
|
|
70
|
+
|
|
71
|
+
type Text = string | Output.Output<string>;
|
|
72
|
+
|
|
73
|
+
export interface CoreRow {
|
|
74
|
+
readonly family: string;
|
|
75
|
+
/** One resource, logical id `id`, at the row's fixed identity. */
|
|
76
|
+
readonly declare: (id: string, knob: Text) => StackBody;
|
|
77
|
+
/** Whether the fake holds the object. */
|
|
78
|
+
readonly has: (estate: CoreEstate) => boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const CORE: readonly CoreRow[] = [
|
|
82
|
+
{
|
|
83
|
+
// ★ The fragments directory IS the content (fake-engines.ts), so a knob changes the policy.
|
|
84
|
+
declare: (id, knob) => BaoPolicy(id, { fragments: knob, name: 'app' }),
|
|
85
|
+
family: 'Bao.Policy',
|
|
86
|
+
has: (estate) => estate.policies.live.has('sys/policies/acl/app'),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
declare: (id, knob) =>
|
|
90
|
+
BaoCloudflareRole(id, {
|
|
91
|
+
description: 'Read DNS records on one zone. (zone: example.com)',
|
|
92
|
+
maxTtl: '1h',
|
|
93
|
+
mount: 'cloudflare-acme-dns',
|
|
94
|
+
name: 'dns-read',
|
|
95
|
+
policies: [
|
|
96
|
+
{
|
|
97
|
+
effect: 'allow',
|
|
98
|
+
groupOrder: 'declared',
|
|
99
|
+
groups: ['DNS Read'],
|
|
100
|
+
resource: 'com.cloudflare.api.account.zone.z1',
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
ttl: knob,
|
|
104
|
+
}),
|
|
105
|
+
family: 'Bao.CloudflareRole',
|
|
106
|
+
has: (estate) => estate.cloudflare.live.has('cloudflare-acme-dns/roles/dns-read'),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
declare: (id, knob) =>
|
|
110
|
+
BaoProxmoxRole(id, {
|
|
111
|
+
maxTtl: '6h',
|
|
112
|
+
mintUser: 'reader@pve',
|
|
113
|
+
mount: 'proxmox-lab',
|
|
114
|
+
name: 'read',
|
|
115
|
+
ttl: knob,
|
|
116
|
+
}),
|
|
117
|
+
family: 'Bao.ProxmoxRole',
|
|
118
|
+
has: (estate) => estate.proxmox.live.has('proxmox-lab/roles/read'),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
declare: (id, knob) => BaoMount(id, { defaultLeaseTtl: knob, path: 'kv', type: 'kv' }),
|
|
122
|
+
family: 'Bao.Mount',
|
|
123
|
+
has: (estate) => estate.mounts.table.has('kv/'),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
declare: (id, knob) => BaoAuthMethod(id, { defaultLeaseTtl: knob, path: 'jwt', type: 'jwt' }),
|
|
127
|
+
family: 'Bao.AuthMethod',
|
|
128
|
+
has: (estate) => estate.auth.table.has('jwt/'),
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/** One stack over a fresh core estate, plus `owner`: a second stack over the same fake. */
|
|
133
|
+
export const withCore = (
|
|
134
|
+
body: (stack: FakeStack, estate: CoreEstate, seen: Seen[], owner: FakeStack) => Promise<void>,
|
|
135
|
+
answer: (estate: CoreEstate) => (seen: Seen) => Reply = (estate) => estate,
|
|
136
|
+
): Promise<void> => {
|
|
137
|
+
const estate = coreEstate();
|
|
138
|
+
return withFakeStack(coreProviders, answer(estate), (stack, bao) =>
|
|
139
|
+
body(stack, estate, bao.seen, fakeStack(coreProviders, { BAO_ADDR: bao.address }, 'Owner')),
|
|
140
|
+
);
|
|
141
|
+
};
|
|
@@ -13,9 +13,9 @@ import * as Effect from 'effect/Effect';
|
|
|
13
13
|
import * as Layer from 'effect/Layer';
|
|
14
14
|
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
15
15
|
import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
|
|
16
|
-
import type { Seen } from './fake-bao.ts';
|
|
16
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
17
17
|
import { type Estate, fakeEstate } from './fake-engines-roles.ts';
|
|
18
|
-
import { type FakeStack, type StackBody, withFakeStack } from './fake-stack.ts';
|
|
18
|
+
import { type FakeStack, type StackBody, fakeStack, withFakeStack } from './fake-stack.ts';
|
|
19
19
|
import { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
|
|
20
20
|
import { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
|
|
21
21
|
import { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
|
|
@@ -38,12 +38,18 @@ export const familyProviders = Layer.mergeAll(
|
|
|
38
38
|
FetchHttpClient.layer,
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* One stack over a fresh estate: every family's provider, and every call the fake saw — plus
|
|
43
|
+
* `owner`, a SECOND stack over the same fake with its own state: another owner of the same names.
|
|
44
|
+
*/
|
|
42
45
|
export const withEstate = (
|
|
43
|
-
body: (stack: FakeStack, estate: Estate, seen: Seen[]) => Promise<void>,
|
|
46
|
+
body: (stack: FakeStack, estate: Estate, seen: Seen[], owner: FakeStack) => Promise<void>,
|
|
47
|
+
answer: (estate: Estate) => (seen: Seen) => Reply = (estate) => estate,
|
|
44
48
|
): Promise<void> => {
|
|
45
49
|
const estate = fakeEstate();
|
|
46
|
-
return withFakeStack(familyProviders, estate, (stack, bao) =>
|
|
50
|
+
return withFakeStack(familyProviders, answer(estate), (stack, bao) =>
|
|
51
|
+
body(stack, estate, bao.seen, fakeStack(familyProviders, { BAO_ADDR: bao.address }, 'Owner')),
|
|
52
|
+
);
|
|
47
53
|
};
|
|
48
54
|
|
|
49
55
|
/** The row for one family, by name. */
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake mount table for fake-bao — `sys/mounts` (secrets engines) or `sys/auth` (auth methods):
|
|
3
|
+
* enable, tune, read, the listing a 400 is settled against (mount-wire.ts), and `sys/remount`.
|
|
4
|
+
* Split out of mount-move.test.ts so the engine-level ownership tests drive Bao.Mount and
|
|
5
|
+
* Bao.AuthMethod through the same table.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
|
|
8
|
+
*/
|
|
9
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
10
|
+
|
|
11
|
+
type Entry = { type: string; description: string; config: Record<string, unknown> };
|
|
12
|
+
|
|
13
|
+
/** A fresh mount's lease TTLs: 0, "use the system default", as the server reports them. */
|
|
14
|
+
const zero = () => ({ default_lease_ttl: 0, max_lease_ttl: 0 });
|
|
15
|
+
|
|
16
|
+
/** The table keyed `<path>/` as the server keys it; `initial` maps a bare path to its type. */
|
|
17
|
+
export const liveTable = (prefix: string, initial: Record<string, string>) => {
|
|
18
|
+
const entries = new Map<string, Entry>(
|
|
19
|
+
Object.entries(initial).map(([path, type]) => [
|
|
20
|
+
`${path}/`,
|
|
21
|
+
{ config: zero(), description: '', type },
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
const types = () => new Map([...entries].map(([path, entry]) => [path, entry.type]));
|
|
25
|
+
const answer = (seen: Seen): Reply => {
|
|
26
|
+
const path = seen.path.replace(/^\/v1\//, '');
|
|
27
|
+
if (path === 'sys/remount' && seen.method === 'POST') {
|
|
28
|
+
const { from, to } = JSON.parse(seen.body) as { from: string; to: string };
|
|
29
|
+
const key = `${from.replace(/^auth\//, '')}/`;
|
|
30
|
+
const entry = entries.get(key);
|
|
31
|
+
if (entry === undefined) return { json: { errors: ['no matching mount'] }, status: 400 };
|
|
32
|
+
entries.delete(key);
|
|
33
|
+
entries.set(`${to.replace(/^auth\//, '')}/`, entry);
|
|
34
|
+
return { json: { data: { migration_id: 'm-1' } }, status: 200 };
|
|
35
|
+
}
|
|
36
|
+
if (path.startsWith('sys/remount/status/')) {
|
|
37
|
+
return { json: { data: { migration_info: { status: 'success' } } }, status: 200 };
|
|
38
|
+
}
|
|
39
|
+
if (path === prefix) return { json: { data: Object.fromEntries(types()) }, status: 200 };
|
|
40
|
+
const tune = path.endsWith('/tune');
|
|
41
|
+
const key = `${path.slice(prefix.length + 1).replace(/\/tune$/, '')}/`;
|
|
42
|
+
if (seen.method === 'POST') {
|
|
43
|
+
const body = JSON.parse(seen.body || '{}') as Record<string, unknown>;
|
|
44
|
+
const entry = entries.get(key) ?? {
|
|
45
|
+
config: zero(),
|
|
46
|
+
description: '',
|
|
47
|
+
type: 'enabled-by-test',
|
|
48
|
+
};
|
|
49
|
+
if (!tune && typeof body['type'] === 'string') entry.type = body['type'];
|
|
50
|
+
if (tune) {
|
|
51
|
+
const { description, ...config } = body;
|
|
52
|
+
if (typeof description === 'string') entry.description = description;
|
|
53
|
+
entry.config = { ...entry.config, ...config };
|
|
54
|
+
}
|
|
55
|
+
entries.set(key, entry);
|
|
56
|
+
return { status: 204 };
|
|
57
|
+
}
|
|
58
|
+
if (seen.method === 'DELETE') {
|
|
59
|
+
entries.delete(key);
|
|
60
|
+
return { status: 204 };
|
|
61
|
+
}
|
|
62
|
+
const entry = entries.get(key);
|
|
63
|
+
return entry === undefined
|
|
64
|
+
? { json: { errors: [`No secret engine mount at ${key}`] }, status: 400 }
|
|
65
|
+
: { json: { data: entry }, status: 200 };
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
answer,
|
|
69
|
+
/** The live table as `<path>/` → type, the shape the move tests compare. */
|
|
70
|
+
get table() {
|
|
71
|
+
return types();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* in the shell running the tests is never read. No provider imports this file.
|
|
17
17
|
*/
|
|
18
18
|
import * as Alchemy from 'alchemy';
|
|
19
|
+
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
20
|
+
import { provideFreshArtifactStore } from 'alchemy/Artifacts';
|
|
19
21
|
import * as Effect from 'effect/Effect';
|
|
20
22
|
import * as Layer from 'effect/Layer';
|
|
21
23
|
import type { BaoEnvironment } from './bao-address.ts';
|
|
@@ -28,9 +30,12 @@ export type Planned = Readonly<Record<string, string>>;
|
|
|
28
30
|
/** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
|
|
29
31
|
export type StackBody = Effect.Effect<unknown, unknown, unknown>;
|
|
30
32
|
|
|
33
|
+
/** What the CLI would set for one deploy: `adopt` is `--adopt` (the AdoptPolicy service). */
|
|
34
|
+
export type DeployOptions = { readonly adopt?: boolean };
|
|
35
|
+
|
|
31
36
|
export interface FakeStack {
|
|
32
37
|
/** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
|
|
33
|
-
readonly deploy: (body: StackBody) => Promise<Planned>;
|
|
38
|
+
readonly deploy: (body: StackBody, options?: DeployOptions) => Promise<Planned>;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
type Node = { readonly action: string };
|
|
@@ -72,22 +77,30 @@ export const fakeStack = <ROut, E, RIn>(
|
|
|
72
77
|
) => Effect.Effect<PlanView, unknown, never>;
|
|
73
78
|
const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
/**
|
|
81
|
+
* ★ ONE ARTIFACT STORE ACROSS PLAN AND APPLY, as Deploy.ts's evalStack provides — the channel a
|
|
82
|
+
* diff uses to tell reconcile it resumes an interrupted create (ownership/resume.ts).
|
|
83
|
+
* ★ `--adopt` reaches registration, plan and apply alike, as the Alchemist session provides it.
|
|
84
|
+
*/
|
|
85
|
+
const deploy = (body: StackBody, options: DeployOptions = {}) =>
|
|
76
86
|
Effect.gen(function* () {
|
|
77
87
|
const compiled = yield* stack(name, { providers, state }, body);
|
|
78
88
|
return yield* Effect.gen(function* () {
|
|
79
89
|
const planned = yield* plan(compiled);
|
|
80
90
|
yield* apply(planned);
|
|
81
91
|
return actionsOf(planned);
|
|
82
|
-
}).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
|
|
92
|
+
}).pipe(provideFreshArtifactStore, Effect.provide(Layer.succeedContext(compiled.services)));
|
|
83
93
|
}).pipe(
|
|
94
|
+
options.adopt === undefined ? (e) => e : Effect.provideService(AdoptPolicy, options.adopt),
|
|
84
95
|
Effect.provideService(Alchemy.Stage, 'test'),
|
|
85
96
|
Effect.provideService(BaoEnv, env),
|
|
86
97
|
Effect.provide(state),
|
|
87
98
|
Effect.scoped,
|
|
88
99
|
);
|
|
89
100
|
|
|
90
|
-
return {
|
|
101
|
+
return {
|
|
102
|
+
deploy: (body, options) => Effect.runPromise(deploy(body, options) as Effect.Effect<Planned>),
|
|
103
|
+
};
|
|
91
104
|
};
|
|
92
105
|
|
|
93
106
|
/**
|
|
@@ -21,6 +21,8 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
21
21
|
import * as Provider from 'alchemy/Provider';
|
|
22
22
|
import * as Effect from 'effect/Effect';
|
|
23
23
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
24
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
25
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
24
26
|
import {
|
|
25
27
|
type BaoJwtAuthConfigAttributes,
|
|
26
28
|
type BaoJwtAuthConfigProps,
|
|
@@ -33,7 +35,7 @@ import {
|
|
|
33
35
|
} from './jwt-config-form.ts';
|
|
34
36
|
import { declaredOr } from './rename.ts';
|
|
35
37
|
import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
|
|
36
|
-
import { type RoleSpec, planRole,
|
|
38
|
+
import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
|
|
37
39
|
|
|
38
40
|
export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
|
|
39
41
|
|
|
@@ -78,14 +80,15 @@ export const BaoJwtAuthConfigProvider = () =>
|
|
|
78
80
|
BaoJwtAuthConfig.Provider.of({
|
|
79
81
|
list: () => Effect.succeed([]),
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return
|
|
83
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
84
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
85
|
+
return yield* readOwnedRole({ fqn, instanceId, output }, jwtConfigSpec(olds));
|
|
84
86
|
}),
|
|
85
87
|
|
|
86
88
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
87
89
|
// ⛔ The mount first, before isResolved; onto a mount with a config fails the plan.
|
|
88
90
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
91
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
89
92
|
if (output === undefined) return undefined;
|
|
90
93
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
91
94
|
if (!isResolved(news)) return undefined;
|
|
@@ -93,14 +96,14 @@ export const BaoJwtAuthConfigProvider = () =>
|
|
|
93
96
|
}),
|
|
94
97
|
|
|
95
98
|
/** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
|
|
96
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
99
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
97
100
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
98
101
|
yield* guardRename(IDENTITY, news, output);
|
|
99
|
-
return yield* reconcileRole(jwtConfigSpec(news));
|
|
102
|
+
return yield* reconcileRole(jwtConfigSpec(news), claimFor({ fqn, instanceId, output }));
|
|
100
103
|
}),
|
|
101
104
|
|
|
102
105
|
/** ⚠️ Writes nothing — there is no delete endpoint (header). */
|
|
103
106
|
delete: () => Effect.void,
|
|
104
107
|
}),
|
|
105
|
-
),
|
|
108
|
+
).pipe(Effect.map(provingResumes)),
|
|
106
109
|
);
|
package/src/openbao/jwt-role.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
23
23
|
import * as Provider from 'alchemy/Provider';
|
|
24
24
|
import * as Effect from 'effect/Effect';
|
|
25
25
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
26
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
27
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
26
28
|
import { baoDelete } from './bao-http.ts';
|
|
27
29
|
import {
|
|
28
30
|
type BaoJwtRoleAttributes,
|
|
@@ -34,7 +36,7 @@ import {
|
|
|
34
36
|
writeBody,
|
|
35
37
|
} from './jwt-role-form.ts';
|
|
36
38
|
import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
37
|
-
import { type RoleSpec, planRole,
|
|
39
|
+
import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
|
|
38
40
|
|
|
39
41
|
export type {
|
|
40
42
|
BaoJwtCallbackMode,
|
|
@@ -87,25 +89,26 @@ export const BaoJwtRoleProvider = () =>
|
|
|
87
89
|
*/
|
|
88
90
|
list: () => Effect.succeed([]),
|
|
89
91
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return
|
|
92
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
93
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
94
|
+
return yield* readOwnedRole({ fqn, instanceId, output }, jwtRoleSpec(olds));
|
|
93
95
|
}),
|
|
94
96
|
|
|
95
97
|
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST — a hand-widened audience is drift. */
|
|
96
98
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
97
99
|
// ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
|
|
98
100
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
101
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
99
102
|
if (output === undefined) return undefined;
|
|
100
103
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
101
104
|
if (!isResolved(news)) return undefined;
|
|
102
105
|
return { action: yield* planRole(jwtRoleSpec(news)) } as const;
|
|
103
106
|
}),
|
|
104
107
|
|
|
105
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
108
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
106
109
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
107
110
|
yield* guardRename(IDENTITY, news, output);
|
|
108
|
-
return yield* reconcileRole(jwtRoleSpec(news));
|
|
111
|
+
return yield* reconcileRole(jwtRoleSpec(news), claimFor({ fqn, instanceId, output }));
|
|
109
112
|
}),
|
|
110
113
|
|
|
111
114
|
/** Idempotent as Alchemy requires — a missing role deletes as success. */
|
|
@@ -114,5 +117,5 @@ export const BaoJwtRoleProvider = () =>
|
|
|
114
117
|
return undefined;
|
|
115
118
|
}),
|
|
116
119
|
}),
|
|
117
|
-
),
|
|
120
|
+
).pipe(Effect.map(provingResumes)),
|
|
118
121
|
);
|