@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
|
@@ -17,6 +17,8 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
17
17
|
import * as Provider from 'alchemy/Provider';
|
|
18
18
|
import * as Effect from 'effect/Effect';
|
|
19
19
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
20
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
21
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
20
22
|
import { baoDelete } from './bao-http.ts';
|
|
21
23
|
import {
|
|
22
24
|
type BaoKubernetesRoleAttributes,
|
|
@@ -28,7 +30,7 @@ import {
|
|
|
28
30
|
writeBody,
|
|
29
31
|
} from './kubernetes-role-form.ts';
|
|
30
32
|
import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
31
|
-
import { type RoleSpec, planRole,
|
|
33
|
+
import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
|
|
32
34
|
|
|
33
35
|
export type {
|
|
34
36
|
BaoKubernetesAliasSource,
|
|
@@ -75,25 +77,29 @@ export const BaoKubernetesRoleProvider = () =>
|
|
|
75
77
|
/** ⛔ The mount's role listing is not a list of things this owns. */
|
|
76
78
|
list: () => Effect.succeed([]),
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
80
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
81
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
82
|
+
return yield* readOwnedRole({ fqn, instanceId, output }, kubernetesRoleSpec(olds));
|
|
81
83
|
}),
|
|
82
84
|
|
|
83
85
|
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
|
|
84
86
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
85
87
|
// ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
|
|
86
88
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
89
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
87
90
|
if (output === undefined) return undefined;
|
|
88
91
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
89
92
|
if (!isResolved(news)) return undefined;
|
|
90
93
|
return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
|
|
91
94
|
}),
|
|
92
95
|
|
|
93
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
96
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
94
97
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
95
98
|
yield* guardRename(IDENTITY, news, output);
|
|
96
|
-
return yield* reconcileRole(
|
|
99
|
+
return yield* reconcileRole(
|
|
100
|
+
kubernetesRoleSpec(news),
|
|
101
|
+
claimFor({ fqn, instanceId, output }),
|
|
102
|
+
);
|
|
97
103
|
}),
|
|
98
104
|
|
|
99
105
|
/** Idempotent as Alchemy requires — a missing role deletes as success. */
|
|
@@ -102,5 +108,5 @@ export const BaoKubernetesRoleProvider = () =>
|
|
|
102
108
|
return undefined;
|
|
103
109
|
}),
|
|
104
110
|
}),
|
|
105
|
-
),
|
|
111
|
+
).pipe(Effect.map(provingResumes)),
|
|
106
112
|
);
|
|
@@ -31,6 +31,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
31
31
|
import * as Provider from 'alchemy/Provider';
|
|
32
32
|
import * as Effect from 'effect/Effect';
|
|
33
33
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
34
|
+
import { type Claim, claimFor } from '../ownership/adopt.ts';
|
|
35
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
36
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
34
37
|
import type { BaoError } from './bao-status.ts';
|
|
35
38
|
import {
|
|
36
39
|
type BaoMfaLoginEnforcementAttributes,
|
|
@@ -96,14 +99,21 @@ export const planEnforcement = (
|
|
|
96
99
|
return live !== undefined && matches(live, props, resolved) ? 'noop' : 'update';
|
|
97
100
|
});
|
|
98
101
|
|
|
99
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Write when live differs, then prove it by reading back.
|
|
104
|
+
* ⛔ `claim` (the provider passes it): a create never takes over an enforcement it finds by name.
|
|
105
|
+
*/
|
|
100
106
|
export const reconcileEnforcement = (
|
|
101
107
|
props: BaoMfaLoginEnforcementProps,
|
|
108
|
+
claim?: Claim,
|
|
102
109
|
): Effect.Effect<BaoMfaLoginEnforcementAttributes, BaoError, Env> =>
|
|
103
110
|
Effect.gen(function* () {
|
|
104
111
|
const { bad, resolved } = yield* resolveTargets(props);
|
|
105
112
|
if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
|
|
106
113
|
const live = yield* readLive(props.name);
|
|
114
|
+
if (live !== undefined && claim !== undefined) {
|
|
115
|
+
yield* claim(`Bao.MfaLoginEnforcement ${props.name}`);
|
|
116
|
+
}
|
|
107
117
|
if (live === undefined || !matches(live, props, resolved)) {
|
|
108
118
|
yield* writeEnforcement(props.name, writeBody(canonicalFromProps(props, resolved)));
|
|
109
119
|
}
|
|
@@ -124,24 +134,28 @@ export const BaoMfaLoginEnforcementProvider = () =>
|
|
|
124
134
|
/** ⛔ The namespace's enforcement listing is not a list of things this owns. */
|
|
125
135
|
list: () => Effect.succeed([]),
|
|
126
136
|
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
138
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
139
|
+
const found = yield* readLive(olds.name);
|
|
140
|
+
const ours = Effect.map(planEnforcement(olds), (plan) => plan === 'noop');
|
|
141
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
129
142
|
}),
|
|
130
143
|
|
|
131
144
|
/** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
|
|
132
145
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
133
146
|
// ⛔ The name first, before isResolved; onto an enforcement that exists fails the plan.
|
|
134
147
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
148
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
135
149
|
if (output === undefined) return undefined;
|
|
136
150
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
137
151
|
if (!isResolved(news)) return undefined;
|
|
138
152
|
return { action: yield* planEnforcement(news) } as const;
|
|
139
153
|
}),
|
|
140
154
|
|
|
141
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
155
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
142
156
|
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
143
157
|
yield* guardRename(IDENTITY, news, output);
|
|
144
|
-
return yield* reconcileEnforcement(news);
|
|
158
|
+
return yield* reconcileEnforcement(news, claimFor({ fqn, instanceId, output }));
|
|
145
159
|
}),
|
|
146
160
|
|
|
147
161
|
/** ⛔ REFUSED — read the header: in 2.6.2 the delete does not survive a restart. */
|
|
@@ -154,5 +168,5 @@ export const BaoMfaLoginEnforcementProvider = () =>
|
|
|
154
168
|
);
|
|
155
169
|
}),
|
|
156
170
|
}),
|
|
157
|
-
),
|
|
171
|
+
).pipe(Effect.map(provingResumes)),
|
|
158
172
|
);
|
package/src/openbao/mfa-totp.ts
CHANGED
|
@@ -20,6 +20,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
20
20
|
import * as Provider from 'alchemy/Provider';
|
|
21
21
|
import * as Effect from 'effect/Effect';
|
|
22
22
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
23
|
+
import { type Claim, claimFor } from '../ownership/adopt.ts';
|
|
24
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
25
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
23
26
|
import type { BaoError } from './bao-status.ts';
|
|
24
27
|
import {
|
|
25
28
|
type BaoMfaTotpMethodAttributes,
|
|
@@ -70,14 +73,19 @@ export const planTotp = (
|
|
|
70
73
|
return live !== undefined && matches(live, props) ? 'noop' : 'update';
|
|
71
74
|
});
|
|
72
75
|
|
|
73
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Upsert by name when live differs, then prove it by reading back.
|
|
78
|
+
* ⛔ `claim` (the provider passes it): a create never takes over a method it finds by that name.
|
|
79
|
+
*/
|
|
74
80
|
export const reconcileTotp = (
|
|
75
81
|
props: BaoMfaTotpMethodProps,
|
|
82
|
+
claim?: Claim,
|
|
76
83
|
): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
|
|
77
84
|
Effect.gen(function* () {
|
|
78
85
|
const bad = problems(props);
|
|
79
86
|
if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
|
|
80
87
|
const live = yield* readTotp(props);
|
|
88
|
+
if (live !== undefined && claim !== undefined) yield* claim(`Bao.MfaTotpMethod ${props.name}`);
|
|
81
89
|
if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
|
|
82
90
|
const after = yield* readTotp(props);
|
|
83
91
|
if (after === undefined)
|
|
@@ -94,8 +102,13 @@ export const BaoMfaTotpMethodProvider = () =>
|
|
|
94
102
|
/** ⛔ The namespace's method listing is not a list of things this owns. */
|
|
95
103
|
list: () => Effect.succeed([]),
|
|
96
104
|
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
106
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
107
|
+
const found = yield* readTotp(olds);
|
|
108
|
+
const ours = Effect.sync(
|
|
109
|
+
() => found !== undefined && problems(olds).length === 0 && matches(found, olds),
|
|
110
|
+
);
|
|
111
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
99
112
|
}),
|
|
100
113
|
|
|
101
114
|
/**
|
|
@@ -110,15 +123,17 @@ export const BaoMfaTotpMethodProvider = () =>
|
|
|
110
123
|
const rename = renameProblem(output.name, name);
|
|
111
124
|
if (rename !== undefined) return yield* refuse(name, rename);
|
|
112
125
|
}
|
|
113
|
-
|
|
126
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
127
|
+
if (output === undefined) return undefined;
|
|
128
|
+
if (!isResolved(news)) return undefined;
|
|
114
129
|
return { action: yield* planTotp(news) } as const;
|
|
115
130
|
}),
|
|
116
131
|
|
|
117
132
|
/** ⛔ A rename the diff could not see (the name was an Output), refused before any write. */
|
|
118
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
133
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
119
134
|
const rename = output === undefined ? undefined : renameProblem(output.name, news.name);
|
|
120
135
|
if (rename !== undefined) return yield* refuse(news.name, rename);
|
|
121
|
-
return yield* reconcileTotp(news);
|
|
136
|
+
return yield* reconcileTotp(news, claimFor({ fqn, instanceId, output }));
|
|
122
137
|
}),
|
|
123
138
|
|
|
124
139
|
/** ⛔ Read the header first. Idempotent: an unknown id deletes as success. */
|
|
@@ -127,5 +142,5 @@ export const BaoMfaTotpMethodProvider = () =>
|
|
|
127
142
|
return undefined;
|
|
128
143
|
}),
|
|
129
144
|
}),
|
|
130
|
-
),
|
|
145
|
+
).pipe(Effect.map(provingResumes)),
|
|
131
146
|
);
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import { describe, it } from 'node:test';
|
|
7
7
|
import { reconcileAuthMethod } from './auth-method-reconcile.ts';
|
|
8
|
-
import { type
|
|
8
|
+
import { type Seen, run, withFake } from './fake-bao.ts';
|
|
9
|
+
import { liveTable } from './fake-mounts.ts';
|
|
9
10
|
import { planMove } from './mount-move.ts';
|
|
10
11
|
import { reconcileMount } from './mount-reconcile.ts';
|
|
11
12
|
|
|
@@ -32,40 +33,6 @@ describe('planMove', () => {
|
|
|
32
33
|
});
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
/** A fake with a live table under `prefix` (`sys/mounts` or `sys/auth`) that sys/remount edits. */
|
|
36
|
-
const liveTable = (prefix: string, initial: Record<string, string>) => {
|
|
37
|
-
const table = new Map(Object.entries(initial).map(([path, type]) => [`${path}/`, type]));
|
|
38
|
-
const entry = (type: string) => ({ config: { default_lease_ttl: 0, max_lease_ttl: 0 }, type });
|
|
39
|
-
const answer = (seen: Seen): Reply => {
|
|
40
|
-
const path = seen.path.replace(/^\/v1\//, '');
|
|
41
|
-
if (path === 'sys/remount' && seen.method === 'POST') {
|
|
42
|
-
const { from, to } = JSON.parse(seen.body) as { from: string; to: string };
|
|
43
|
-
const key = `${from.replace(/^auth\//, '')}/`;
|
|
44
|
-
const type = table.get(key);
|
|
45
|
-
if (type === undefined) return { json: { errors: ['no matching mount'] }, status: 400 };
|
|
46
|
-
table.delete(key);
|
|
47
|
-
table.set(`${to.replace(/^auth\//, '')}/`, type);
|
|
48
|
-
return { json: { data: { migration_id: 'm-1' } }, status: 200 };
|
|
49
|
-
}
|
|
50
|
-
if (path.startsWith('sys/remount/status/')) {
|
|
51
|
-
return { json: { data: { migration_info: { status: 'success' } } }, status: 200 };
|
|
52
|
-
}
|
|
53
|
-
if (path === prefix) {
|
|
54
|
-
return { json: { data: Object.fromEntries(table) }, status: 200 };
|
|
55
|
-
}
|
|
56
|
-
const mount = path.slice(prefix.length + 1).replace(/\/tune$/, '');
|
|
57
|
-
if (seen.method === 'POST') {
|
|
58
|
-
if (!path.endsWith('/tune')) table.set(`${mount}/`, 'enabled-by-test');
|
|
59
|
-
return { status: 204 };
|
|
60
|
-
}
|
|
61
|
-
const type = table.get(`${mount}/`);
|
|
62
|
-
return type === undefined
|
|
63
|
-
? { json: { errors: [`No secret engine mount at ${mount}/`] }, status: 400 }
|
|
64
|
-
: { json: { data: entry(type) }, status: 200 };
|
|
65
|
-
};
|
|
66
|
-
return { answer, table };
|
|
67
|
-
};
|
|
68
|
-
|
|
69
36
|
const posts = (seen: Seen[]) =>
|
|
70
37
|
seen.filter((each) => each.method === 'POST').map((each) => each.path);
|
|
71
38
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as Effect from 'effect/Effect';
|
|
6
6
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
7
|
+
import type { Claim } from '../ownership/adopt.ts';
|
|
7
8
|
import type { BaoError } from './bao-status.ts';
|
|
8
9
|
import {
|
|
9
10
|
type BaoMountAttributes,
|
|
@@ -37,22 +38,31 @@ const moveTarget = (props: BaoMountProps): MoveTarget => ({
|
|
|
37
38
|
/**
|
|
38
39
|
* Converge one mount. `stated` is the path in Alchemy's state (undefined on a first reconcile).
|
|
39
40
|
* Dies on a refusal; fails with OpenBao's own error on a refused call.
|
|
41
|
+
* ⛔ `claim` (mount.ts always passes it) refuses, before any write, a first reconcile that would take
|
|
42
|
+
* over a live mount: the one at the path, or the `remountFrom` source it would move
|
|
43
|
+
* (ownership/adopt.ts).
|
|
40
44
|
*/
|
|
41
45
|
export const reconcileMount = (
|
|
42
46
|
news: BaoMountProps,
|
|
43
47
|
stated: string | undefined,
|
|
44
48
|
options?: RemountOptions,
|
|
49
|
+
claim?: Claim,
|
|
45
50
|
): Effect.Effect<BaoMountAttributes, BaoError | RemountError, HttpClient.HttpClient> =>
|
|
46
51
|
Effect.gen(function* () {
|
|
47
52
|
const move = planMove('Bao.Mount', stated, news.path, news.remountFrom);
|
|
48
53
|
if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
|
|
54
|
+
const first = stated === undefined ? claim : undefined;
|
|
55
|
+
if (first !== undefined && move.kind === 'move') {
|
|
56
|
+
const source = yield* readMount({ ...news, path: move.from });
|
|
57
|
+
if (source !== undefined) yield* first(`Bao.Mount ${move.from} (the remountFrom source)`);
|
|
58
|
+
}
|
|
49
59
|
if (move.kind === 'move') yield* performMove(moveTarget(news), move.from, options);
|
|
50
60
|
|
|
51
61
|
let live = yield* readMount(news);
|
|
52
62
|
if (live === undefined) {
|
|
53
63
|
yield* enableMount(news);
|
|
54
64
|
if (wantsTune(news)) yield* tuneMount(news);
|
|
55
|
-
} else
|
|
65
|
+
} else {
|
|
56
66
|
if (live.type !== news.type) {
|
|
57
67
|
return yield* Effect.die(
|
|
58
68
|
new Error(
|
|
@@ -61,7 +71,8 @@ export const reconcileMount = (
|
|
|
61
71
|
),
|
|
62
72
|
);
|
|
63
73
|
}
|
|
64
|
-
yield*
|
|
74
|
+
if (first !== undefined) yield* first(`Bao.Mount ${mountPath(news.path)}`);
|
|
75
|
+
if (!matches(live, news)) yield* tuneMount(news);
|
|
65
76
|
}
|
|
66
77
|
live = yield* readMount(news);
|
|
67
78
|
if (live === undefined) {
|
package/src/openbao/mount.ts
CHANGED
|
@@ -22,6 +22,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
22
22
|
import * as Provider from 'alchemy/Provider';
|
|
23
23
|
import * as Effect from 'effect/Effect';
|
|
24
24
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
25
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
26
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
27
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
25
28
|
import { type BaoMountAttributes, type BaoMountProps, matches } from './mount-form.ts';
|
|
26
29
|
import { planMove } from './mount-move.ts';
|
|
27
30
|
import { readMount, reconcileMount } from './mount-reconcile.ts';
|
|
@@ -55,12 +58,17 @@ export const BaoMountProvider = () =>
|
|
|
55
58
|
*/
|
|
56
59
|
list: () => Effect.succeed([]),
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
62
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
63
|
+
const found = yield* readMount(olds);
|
|
64
|
+
const ours = Effect.sync(() => found !== undefined && matches(found, olds));
|
|
65
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
60
66
|
}),
|
|
61
67
|
|
|
62
68
|
diff: Effect.fn(function* ({ news, output }) {
|
|
63
|
-
|
|
69
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
70
|
+
if (output === undefined) return undefined;
|
|
71
|
+
if (!isResolved(news)) return undefined;
|
|
64
72
|
/**
|
|
65
73
|
* ⛔ A CHANGED PATH IS DECIDED HERE, BEFORE ANY READ OF THE NEW PATH — reading it would
|
|
66
74
|
* find nothing and plan `update`, which is how an empty mount used to get enabled.
|
|
@@ -98,8 +106,9 @@ export const BaoMountProvider = () =>
|
|
|
98
106
|
* method and path — the job the per-command exit-code checks used to do by hand.
|
|
99
107
|
* The body, and the move, live in mount-reconcile.ts.
|
|
100
108
|
*/
|
|
101
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
102
|
-
|
|
109
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
110
|
+
const claim = claimFor({ fqn, instanceId, output });
|
|
111
|
+
return yield* reconcileMount(news, output?.path, undefined, claim);
|
|
103
112
|
}),
|
|
104
113
|
|
|
105
114
|
/**
|
|
@@ -111,5 +120,5 @@ export const BaoMountProvider = () =>
|
|
|
111
120
|
return undefined;
|
|
112
121
|
}),
|
|
113
122
|
}),
|
|
114
|
-
),
|
|
123
|
+
).pipe(Effect.map(provingResumes)),
|
|
115
124
|
);
|
package/src/openbao/pki-role.ts
CHANGED
|
@@ -43,6 +43,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
43
43
|
import * as Provider from 'alchemy/Provider';
|
|
44
44
|
import * as Effect from 'effect/Effect';
|
|
45
45
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
46
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
47
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
48
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
46
49
|
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
47
50
|
import {
|
|
48
51
|
type BaoPkiRoleAttributes,
|
|
@@ -95,8 +98,13 @@ export const BaoPkiRoleProvider = () =>
|
|
|
95
98
|
*/
|
|
96
99
|
list: () => Effect.succeed([]),
|
|
97
100
|
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
102
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
103
|
+
const found = yield* readRole(olds);
|
|
104
|
+
const ours = Effect.sync(
|
|
105
|
+
() => found !== undefined && problems(olds).length === 0 && matches(found, olds),
|
|
106
|
+
);
|
|
107
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
100
108
|
}),
|
|
101
109
|
|
|
102
110
|
/**
|
|
@@ -119,6 +127,7 @@ export const BaoPkiRoleProvider = () =>
|
|
|
119
127
|
* and left the old one issuing certificates under no state record at all.
|
|
120
128
|
*/
|
|
121
129
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
130
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
122
131
|
if (output === undefined) return undefined;
|
|
123
132
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
124
133
|
if (!isResolved(news)) return undefined;
|
|
@@ -137,7 +146,7 @@ export const BaoPkiRoleProvider = () =>
|
|
|
137
146
|
: ({ action: 'update' } as const);
|
|
138
147
|
}),
|
|
139
148
|
|
|
140
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
149
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
141
150
|
const path = rolePath(news);
|
|
142
151
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
143
152
|
yield* guardRename(IDENTITY, news, output);
|
|
@@ -153,6 +162,8 @@ export const BaoPkiRoleProvider = () =>
|
|
|
153
162
|
return yield* Effect.die(new Error(`Bao.PkiRole ${path}: ${bad.join(' ')}`));
|
|
154
163
|
}
|
|
155
164
|
const live = yield* readRole(news);
|
|
165
|
+
if (live !== undefined)
|
|
166
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.PkiRole ${path}`);
|
|
156
167
|
if (live === undefined || !matches(live, news)) {
|
|
157
168
|
yield* baoWrite('PUT', path, writeBody(news));
|
|
158
169
|
}
|
|
@@ -195,5 +206,5 @@ export const BaoPkiRoleProvider = () =>
|
|
|
195
206
|
return undefined;
|
|
196
207
|
}),
|
|
197
208
|
}),
|
|
198
|
-
),
|
|
209
|
+
).pipe(Effect.map(provingResumes)),
|
|
199
210
|
);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as Effect from 'effect/Effect';
|
|
6
6
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
7
|
+
import type { Claim } from '../ownership/adopt.ts';
|
|
7
8
|
import type { BaoError } from './bao-status.ts';
|
|
8
9
|
import {
|
|
9
10
|
type BaoPluginAttributes,
|
|
@@ -39,18 +40,25 @@ const refuse = (form: BaoPluginForm, message: string) =>
|
|
|
39
40
|
* nothing, or finds an OLDER genuinely-unversioned entry. The Cloudflare engine reports one
|
|
40
41
|
* (`framework.Backend.RunningVersion`). REASONED FROM SOURCE, not measured against a server.
|
|
41
42
|
* ⚠️ THE WRITE HAS ALREADY LANDED when this fires, so the catalog holds a registration no state
|
|
42
|
-
* record names. Declaring the reported version
|
|
43
|
-
* command, so reconcile matches and writes nothing); declaring a DIFFERENT version is
|
|
44
|
-
* the server itself (setInternal :1219-1221, "plugin version mismatch").
|
|
43
|
+
* record names. Declaring the reported version and deploying once with `--adopt` adopts it (same
|
|
44
|
+
* sha256 and command, so reconcile matches and writes nothing); declaring a DIFFERENT version is
|
|
45
|
+
* refused by the server itself (setInternal :1219-1221, "plugin version mismatch").
|
|
46
|
+
* ⚠️ `--adopt`, NOT A SILENT RESUME (2026-09-21): the row this create left names the UNVERSIONED
|
|
47
|
+
* entry, so it cannot prove the versioned one ours (docs/ownership.md).
|
|
45
48
|
*/
|
|
46
49
|
const SELF_REPORTED =
|
|
47
50
|
'the write succeeded but the read-back does not match. If the binary reports its own version, ' +
|
|
48
51
|
'OpenBao filed the registration under it (plugin_catalog.go setInternal) — declare `version` ' +
|
|
49
|
-
'as exactly the version the binary reports
|
|
52
|
+
'as exactly the version the binary reports, and deploy once with --adopt: that adopts the ' +
|
|
53
|
+
'entry this write just made.';
|
|
50
54
|
|
|
51
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Register when the live entry differs, then prove it by reading back. Dies on a refusal.
|
|
57
|
+
* ⛔ `claim` (the provider passes it): a create never takes over an entry already registered.
|
|
58
|
+
*/
|
|
52
59
|
export const reconcilePlugin = (
|
|
53
60
|
props: BaoPluginProps,
|
|
61
|
+
claim?: Claim,
|
|
54
62
|
): Effect.Effect<BaoPluginAttributes, BaoError, HttpClient.HttpClient> =>
|
|
55
63
|
Effect.gen(function* () {
|
|
56
64
|
const form = resolve(props);
|
|
@@ -58,6 +66,9 @@ export const reconcilePlugin = (
|
|
|
58
66
|
if (bad.length > 0) return yield* refuse(form, bad.join('; '));
|
|
59
67
|
|
|
60
68
|
const found = yield* readEntry(form);
|
|
69
|
+
if (found !== undefined && claim !== undefined) {
|
|
70
|
+
yield* claim(`Bao.Plugin ${versionedPath(form.type, form.name, form.version)}`);
|
|
71
|
+
}
|
|
61
72
|
if (found !== undefined && !matches(found.attributes, form)) {
|
|
62
73
|
/**
|
|
63
74
|
* ⛔ A DECLARATIVE OR OCI ENTRY BELONGS TO THE SERVER'S CONFIG FILE (`plugin` stanzas with
|
package/src/openbao/plugin.ts
CHANGED
|
@@ -24,11 +24,15 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
24
24
|
import * as Provider from 'alchemy/Provider';
|
|
25
25
|
import * as Effect from 'effect/Effect';
|
|
26
26
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
27
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
28
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
29
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
27
30
|
import {
|
|
28
31
|
type BaoPluginAttributes,
|
|
29
32
|
type BaoPluginProps,
|
|
30
33
|
type BaoPluginType,
|
|
31
34
|
matches,
|
|
35
|
+
problems,
|
|
32
36
|
resolve,
|
|
33
37
|
versionedPath,
|
|
34
38
|
} from './plugin-form.ts';
|
|
@@ -81,9 +85,15 @@ export const BaoPluginProvider = () =>
|
|
|
81
85
|
*/
|
|
82
86
|
list: () => Effect.succeed([]),
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
89
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
90
|
+
const form = resolve(olds);
|
|
91
|
+
const found = yield* readEntry(form);
|
|
92
|
+
const ours = Effect.sync(
|
|
93
|
+
() =>
|
|
94
|
+
found !== undefined && problems(form).length === 0 && matches(found.attributes, form),
|
|
95
|
+
);
|
|
96
|
+
return yield* ownedRead({ fqn, instanceId, output }, found?.attributes, ours);
|
|
87
97
|
}),
|
|
88
98
|
|
|
89
99
|
/** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
|
|
@@ -95,6 +105,7 @@ export const BaoPluginProvider = () =>
|
|
|
95
105
|
* that is already registered fails the plan (rename-identity.ts).
|
|
96
106
|
*/
|
|
97
107
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
108
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
98
109
|
if (output === undefined) return undefined;
|
|
99
110
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
100
111
|
if (!isResolved(news)) return undefined;
|
|
@@ -107,10 +118,10 @@ export const BaoPluginProvider = () =>
|
|
|
107
118
|
}),
|
|
108
119
|
|
|
109
120
|
/** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
|
|
110
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
121
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
111
122
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
112
123
|
yield* guardRename(IDENTITY, news, output);
|
|
113
|
-
return yield* reconcilePlugin(news);
|
|
124
|
+
return yield* reconcilePlugin(news, claimFor({ fqn, instanceId, output }));
|
|
114
125
|
}),
|
|
115
126
|
|
|
116
127
|
/**
|
|
@@ -122,5 +133,5 @@ export const BaoPluginProvider = () =>
|
|
|
122
133
|
return undefined;
|
|
123
134
|
}),
|
|
124
135
|
}),
|
|
125
|
-
),
|
|
136
|
+
).pipe(Effect.map(provingResumes)),
|
|
126
137
|
);
|
package/src/openbao/policy.ts
CHANGED
|
@@ -5,6 +5,9 @@ import * as Effect from 'effect/Effect';
|
|
|
5
5
|
import * as FileSystem from 'effect/FileSystem';
|
|
6
6
|
import * as Path from 'effect/Path';
|
|
7
7
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
8
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
9
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
10
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
8
11
|
import { sha256 } from './digest.ts';
|
|
9
12
|
import { isEmptyAssembly } from './policy-assembly.ts';
|
|
10
13
|
import { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
|
|
@@ -138,9 +141,16 @@ export const BaoPolicyProvider = () =>
|
|
|
138
141
|
list: () => Effect.succeed([]),
|
|
139
142
|
|
|
140
143
|
// ★ '' means genuinely absent (a 404); a refusal fails. policy-wire.ts has the history.
|
|
141
|
-
|
|
144
|
+
// ⛔ With no attributes in state, a live policy is `Unowned` unless it is the one our own
|
|
145
|
+
// interrupted create wrote: these fragments, byte for byte (ownership/probe.ts).
|
|
146
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
142
147
|
const live = yield* readPolicy(olds.name);
|
|
143
|
-
|
|
148
|
+
const found = live.length === 0 ? undefined : attributesOf(olds.name, live, 0);
|
|
149
|
+
const ours = Effect.map(
|
|
150
|
+
assemble(olds.fragments),
|
|
151
|
+
(a) => sha256(a.joined) === found?.digest,
|
|
152
|
+
);
|
|
153
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
144
154
|
}),
|
|
145
155
|
|
|
146
156
|
/**
|
|
@@ -161,6 +171,7 @@ export const BaoPolicyProvider = () =>
|
|
|
161
171
|
* PR (REPLACE.md).
|
|
162
172
|
*/
|
|
163
173
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
174
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
164
175
|
if (output === undefined) return undefined;
|
|
165
176
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
166
177
|
// ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
|
|
@@ -174,7 +185,7 @@ export const BaoPolicyProvider = () =>
|
|
|
174
185
|
: ({ action: 'update' } as const);
|
|
175
186
|
}),
|
|
176
187
|
|
|
177
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
188
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
178
189
|
const name = news.name;
|
|
179
190
|
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
180
191
|
yield* guardRename(IDENTITY, news, output);
|
|
@@ -207,6 +218,8 @@ export const BaoPolicyProvider = () =>
|
|
|
207
218
|
* sibling PVE provider does exactly this in pveOperations.reconcile.
|
|
208
219
|
*/
|
|
209
220
|
const current = yield* readPolicy(name);
|
|
221
|
+
if (current.length > 0)
|
|
222
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.Policy ${name}`);
|
|
210
223
|
if (current.length > 0 && sha256(current) === sha256(joined)) {
|
|
211
224
|
return attributesOf(name, joined, parts);
|
|
212
225
|
}
|
|
@@ -231,5 +244,5 @@ export const BaoPolicyProvider = () =>
|
|
|
231
244
|
return undefined;
|
|
232
245
|
}),
|
|
233
246
|
});
|
|
234
|
-
}),
|
|
247
|
+
}).pipe(Effect.map(provingResumes)),
|
|
235
248
|
);
|
|
@@ -40,6 +40,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
40
40
|
import * as Provider from 'alchemy/Provider';
|
|
41
41
|
import * as Effect from 'effect/Effect';
|
|
42
42
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
43
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
44
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
45
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
43
46
|
import { baoDelete, baoWrite } from './bao-http.ts';
|
|
44
47
|
import {
|
|
45
48
|
type BaoProxmoxRoleAttributes,
|
|
@@ -84,8 +87,11 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
84
87
|
*/
|
|
85
88
|
list: () => Effect.succeed([]),
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
91
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
92
|
+
const found = yield* readRole(olds);
|
|
93
|
+
const ours = Effect.sync(() => found !== undefined && matches(found, olds));
|
|
94
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, ours);
|
|
89
95
|
}),
|
|
90
96
|
|
|
91
97
|
/**
|
|
@@ -107,6 +113,7 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
107
113
|
* resulting `update`.
|
|
108
114
|
*/
|
|
109
115
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
116
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
110
117
|
if (output === undefined) return undefined;
|
|
111
118
|
if (move !== undefined) {
|
|
112
119
|
const mintUser = declaredString(news, 'mintUser');
|
|
@@ -142,11 +149,13 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
142
149
|
return { action: 'update' } as const;
|
|
143
150
|
}),
|
|
144
151
|
|
|
145
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
152
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
146
153
|
const path = rolePath(news.mount, news.name);
|
|
147
154
|
// ⛔ An `update` across a move the diff could not see — refused before any read or write.
|
|
148
155
|
yield* guardRename(IDENTITY, news, output);
|
|
149
156
|
const live = yield* readRole(news);
|
|
157
|
+
if (live !== undefined)
|
|
158
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.ProxmoxRole ${path}`);
|
|
150
159
|
/**
|
|
151
160
|
* ⛔ A mint_user CHANGE RE-SCOPES EVERY CREDENTIAL THE ROLE WILL EVER MINT, SILENTLY.
|
|
152
161
|
* The minted token inherits that PVE user's standing ACL and nothing else, so
|
|
@@ -226,5 +235,5 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
226
235
|
return undefined;
|
|
227
236
|
}),
|
|
228
237
|
}),
|
|
229
|
-
),
|
|
238
|
+
).pipe(Effect.map(provingResumes)),
|
|
230
239
|
);
|