@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
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The swap the plan-time guard cannot see: a NEW declaration of a name that is live.
|
|
3
|
-
* 2026-09-21 through Alchemy's own plan and apply (fake-stack.ts)
|
|
4
|
-
*
|
|
5
|
-
* (alchemy@2.0.0-beta.79 Plan.ts, the adoption probe; AdoptPolicy.ts).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
2
|
+
* The swap the plan-time rename guard cannot see: a NEW declaration of a name that is live.
|
|
3
|
+
* 🔴 MEASURED 2026-09-21 through Alchemy's own plan and apply (fake-stack.ts), before the ownership
|
|
4
|
+
* rule: with no state row the engine asked `read`, which answered plain attributes, so Alchemy
|
|
5
|
+
* silently adopted the object (alchemy@2.0.0-beta.79 Plan.ts, the adoption probe; AdoptPolicy.ts).
|
|
6
|
+
* Under `RemovalPolicy.destroy()` the old owner's delete then ran in Apply's phase 2 and removed the
|
|
7
|
+
* object the new declaration claimed — in a green deploy.
|
|
8
|
+
* ★ NOW (decided 2026-09-21): the probe answers `Unowned` for any live object (ownership/probe.ts),
|
|
9
|
+
* so that plan FAILS before anything is written, and the takeover happens only when asked for —
|
|
10
|
+
* which is exactly the old hazard, on purpose. These pin both, and the two ways round it
|
|
11
|
+
* REPLACE.md gives: `renamedFrom` for a logical id that changes, and a second deploy for a name
|
|
12
|
+
* another resource is leaving.
|
|
13
13
|
*
|
|
14
14
|
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
15
15
|
*/
|
|
@@ -25,14 +25,25 @@ const { destroy } = RemovalPolicy;
|
|
|
25
25
|
|
|
26
26
|
/** No delete endpoint (jwt-config.ts), and a delete the provider refuses (mfa-enforcement.ts). */
|
|
27
27
|
const KEEPS = new Set(['Bao.JwtAuthConfig', 'Bao.MfaLoginEnforcement']);
|
|
28
|
-
const REFUSES_DELETE = /Bao\.MfaLoginEnforcement/;
|
|
28
|
+
const REFUSES_DELETE = /Bao\.MfaLoginEnforcement a: REFUSING to delete/;
|
|
29
|
+
const NOT_OWNED = /Cannot adopt resource 'Z'.*--adopt/s;
|
|
29
30
|
|
|
30
31
|
describe('a logical id changed without renamedFrom, under destroy', () => {
|
|
31
32
|
for (const row of FAMILIES) {
|
|
32
|
-
it(`${row.family}: the
|
|
33
|
+
it(`${row.family}: the plan refuses the live name, and nothing is written or deleted`, async () => {
|
|
34
|
+
await withEstate(async (stack, estate, seen) => {
|
|
35
|
+
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
36
|
+
seen.length = 0;
|
|
37
|
+
await assert.rejects(stack.deploy(row.declare('Z', 'a', '15m', destroy)), NOT_OWNED);
|
|
38
|
+
assert.deepEqual(writesOf(seen), []);
|
|
39
|
+
assert.ok(row.has(estate, 'a'));
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it(`${row.family}: --adopt is the takeover, and the old id's delete follows it`, async () => {
|
|
33
44
|
await withEstate(async (stack, estate) => {
|
|
34
45
|
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
35
|
-
const again = stack.deploy(row.declare('Z', 'a', '15m', destroy));
|
|
46
|
+
const again = stack.deploy(row.declare('Z', 'a', '15m', destroy), { adopt: true });
|
|
36
47
|
if (row.family === 'Bao.MfaLoginEnforcement') await assert.rejects(again, REFUSES_DELETE);
|
|
37
48
|
else assert.deepEqual(await again, { X: 'delete', Z: 'adopted' });
|
|
38
49
|
assert.equal(row.has(estate, 'a'), KEEPS.has(row.family));
|
|
@@ -55,19 +66,21 @@ describe('the ways round it', () => {
|
|
|
55
66
|
});
|
|
56
67
|
});
|
|
57
68
|
|
|
58
|
-
it('a name another resource moves off is
|
|
59
|
-
await withEstate(async (stack, estate) => {
|
|
69
|
+
it('a name another resource moves off is refused when taken in the same deploy', async () => {
|
|
70
|
+
await withEstate(async (stack, estate, seen) => {
|
|
60
71
|
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
72
|
+
seen.length = 0;
|
|
61
73
|
const both = Effect.gen(function* () {
|
|
62
74
|
yield* row.declare('X', 'b', '15m', destroy);
|
|
63
75
|
yield* row.declare('Z', 'a', '30m', destroy);
|
|
64
76
|
});
|
|
65
|
-
assert.
|
|
66
|
-
assert.
|
|
77
|
+
await assert.rejects(stack.deploy(both), NOT_OWNED);
|
|
78
|
+
assert.deepEqual(writesOf(seen), []);
|
|
79
|
+
assert.ok(row.has(estate, 'a') && !row.has(estate, 'b'));
|
|
67
80
|
});
|
|
68
81
|
});
|
|
69
82
|
|
|
70
|
-
it('and
|
|
83
|
+
it('and succeeds when taken in the deploy after the move', async () => {
|
|
71
84
|
await withEstate(async (stack, estate) => {
|
|
72
85
|
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
73
86
|
await stack.deploy(row.declare('X', 'b', '15m', destroy));
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import * as Effect from 'effect/Effect';
|
|
17
17
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
18
|
+
import type { Claim, Owner } from '../ownership/adopt.ts';
|
|
19
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
18
20
|
import { baoRead, baoWrite } from './bao-http.ts';
|
|
19
21
|
import type { BaoError } from './bao-status.ts';
|
|
20
22
|
|
|
@@ -40,6 +42,21 @@ export const readRoleAt = <A>(
|
|
|
40
42
|
live === undefined ? undefined : { attributes: spec.attributesOf(live), live },
|
|
41
43
|
);
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* `read`: the live attributes — `Unowned` with no state unless our own interrupted create wrote
|
|
47
|
+
* them, which is `planRole`'s `noop` against that create's props (ownership/probe.ts).
|
|
48
|
+
*/
|
|
49
|
+
export const readOwnedRole = <A extends object>(
|
|
50
|
+
ask: Owner,
|
|
51
|
+
spec: RoleSpec<A>,
|
|
52
|
+
): Effect.Effect<A | undefined, BaoError, HttpClient.HttpClient> =>
|
|
53
|
+
Effect.flatMap(readRoleAt(spec), (found) => {
|
|
54
|
+
const ours = Effect.sync(
|
|
55
|
+
() => found !== undefined && spec.problems.length === 0 && spec.matches(found.attributes),
|
|
56
|
+
);
|
|
57
|
+
return ownedRead(ask, found?.attributes, ours);
|
|
58
|
+
});
|
|
59
|
+
|
|
43
60
|
/** `update` or `noop` — the part of a diff after the identity checks. */
|
|
44
61
|
export const planRole = <A>(
|
|
45
62
|
spec: RoleSpec<A>,
|
|
@@ -53,12 +70,18 @@ export const planRole = <A>(
|
|
|
53
70
|
const refuse = (spec: { readonly family: string; readonly path: string }, message: string) =>
|
|
54
71
|
Effect.die(new Error(`${spec.family} ${spec.path}: ${message}`));
|
|
55
72
|
|
|
73
|
+
/**
|
|
74
|
+
* ⛔ `claim` — every provider passes it — refuses, before any write, a create that finds a live
|
|
75
|
+
* object this stack holds no state for (ownership/adopt.ts).
|
|
76
|
+
*/
|
|
56
77
|
export const reconcileRole = <A>(
|
|
57
78
|
spec: RoleSpec<A>,
|
|
79
|
+
claim?: Claim,
|
|
58
80
|
): Effect.Effect<A, BaoError, HttpClient.HttpClient> =>
|
|
59
81
|
Effect.gen(function* () {
|
|
60
82
|
if (spec.problems.length > 0) return yield* refuse(spec, spec.problems.join('; '));
|
|
61
83
|
const found = yield* readRoleAt(spec);
|
|
84
|
+
if (found !== undefined && claim !== undefined) yield* claim(`${spec.family} ${spec.path}`);
|
|
62
85
|
if (found === undefined || !spec.matches(found.attributes)) {
|
|
63
86
|
const lost = found === undefined ? [] : (spec.wouldErase?.(found.live) ?? []);
|
|
64
87
|
if (lost.length > 0) {
|
package/src/openbao/ssh-role.ts
CHANGED
|
@@ -36,6 +36,9 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
36
36
|
import * as Provider from 'alchemy/Provider';
|
|
37
37
|
import * as Effect from 'effect/Effect';
|
|
38
38
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
39
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
40
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
41
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
39
42
|
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
40
43
|
import {
|
|
41
44
|
type BaoSshRoleAttributes,
|
|
@@ -91,9 +94,13 @@ export const BaoSshRoleProvider = () =>
|
|
|
91
94
|
*/
|
|
92
95
|
list: () => Effect.succeed([]),
|
|
93
96
|
|
|
94
|
-
|
|
97
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
98
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
95
99
|
const found = yield* readRole(resolve(olds));
|
|
96
|
-
|
|
100
|
+
const ours = Effect.sync(
|
|
101
|
+
() => found !== undefined && matches(found.attributes, resolve(olds)),
|
|
102
|
+
);
|
|
103
|
+
return yield* ownedRead({ fqn, instanceId, output }, found?.attributes, ours);
|
|
97
104
|
}),
|
|
98
105
|
|
|
99
106
|
/**
|
|
@@ -115,6 +122,7 @@ export const BaoSshRoleProvider = () =>
|
|
|
115
122
|
* ⛔ a move onto a role that exists fails the plan (rename-identity.ts).
|
|
116
123
|
*/
|
|
117
124
|
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
125
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
118
126
|
if (output === undefined) return undefined;
|
|
119
127
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
120
128
|
if (!isResolved(news)) return undefined;
|
|
@@ -126,7 +134,7 @@ export const BaoSshRoleProvider = () =>
|
|
|
126
134
|
: ({ action: 'update' } as const);
|
|
127
135
|
}),
|
|
128
136
|
|
|
129
|
-
reconcile: Effect.fn(function* ({ news, output }) {
|
|
137
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
130
138
|
const form = resolve(news);
|
|
131
139
|
const path = readPath(form.mount, form.name);
|
|
132
140
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
@@ -157,6 +165,8 @@ export const BaoSshRoleProvider = () =>
|
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
const found = yield* readRole(form);
|
|
168
|
+
if (found !== undefined)
|
|
169
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.SshRole ${path}`);
|
|
160
170
|
if (found === undefined || !matches(found.attributes, form)) {
|
|
161
171
|
if (found !== undefined) {
|
|
162
172
|
const lost = wouldErase(found.live);
|
|
@@ -197,5 +207,5 @@ export const BaoSshRoleProvider = () =>
|
|
|
197
207
|
return undefined;
|
|
198
208
|
}),
|
|
199
209
|
}),
|
|
200
|
-
),
|
|
210
|
+
).pipe(Effect.map(provingResumes)),
|
|
201
211
|
);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a live object this stack holds no state for may be written — the apply-time half of the
|
|
3
|
+
* house rule that NOTHING IS ADOPTED WITHOUT `--adopt` (decided 2026-09-21 for HostFile, LaunchdJob,
|
|
4
|
+
* CaddyConfig and every Bao.* family). probe.ts is the plan-time half.
|
|
5
|
+
*
|
|
6
|
+
* ★ FAMILY-NEUTRAL ON PURPOSE. The engine questions here — what `--adopt` resolves to for one FQN,
|
|
7
|
+
* whether this apply resumes the resource's own interrupted create — are the same for a vault
|
|
8
|
+
* role, a launchd plist and a Caddy config, so they live once, outside every subpath's directory.
|
|
9
|
+
*/
|
|
10
|
+
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
11
|
+
import { AlchemyContext } from 'alchemy/AlchemyContext';
|
|
12
|
+
import { Stack } from 'alchemy/Stack';
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
import * as Option from 'effect/Option';
|
|
15
|
+
import { resumes, unfinished } from './resume.ts';
|
|
16
|
+
import { forgetRefusedCreate, isCreate } from './rows.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether the resource at `fqn` may be adopted, resolved exactly as the planner resolves it (alchemy
|
|
20
|
+
* beta.79 Plan.ts: `resource.Adopt ?? shouldAdopt`): a resource-scoped `adopt(…)`, else the
|
|
21
|
+
* `AdoptPolicy` service, else `AlchemyContext.adopt`, else off.
|
|
22
|
+
* ★ FOR A CREATE THE ENGINE NEVER PROBED. Alchemy skips the adoption probe while `news` holds an
|
|
23
|
+
* Output, so an ownership check that must still honour `--adopt` can only run at apply. All three
|
|
24
|
+
* reach the apply: Alchemist runs plan AND apply under the session context, which provides the
|
|
25
|
+
* services from the CLI flag and the Stack the resources registered on (Alchemist/Session.ts,
|
|
26
|
+
* routes/stack.ts; Apply.ts reads the same Stack).
|
|
27
|
+
* ⛔ THE RESOURCE-SCOPED SETTING WINS, BOTH WAYS. The engine captures `adopt(…)` on the resource at
|
|
28
|
+
* registration (Resource.ts `Adopt`, into `Stack.resources[fqn]`) and never hands it to a
|
|
29
|
+
* provider. Reading only the deploy-wide flag let `--adopt` take over a resource declared
|
|
30
|
+
* `.pipe(adopt(false))` — which the planner refuses — and refused one declared `adopt(true)`.
|
|
31
|
+
* ⚠️ `resources` carries an `@internal` comment in Alchemy's source, but it is in the published types
|
|
32
|
+
* and the planner reads `Adopt` from it. caddy/adopt-scope.test.ts registers through the real
|
|
33
|
+
* `Resource`, so an upgrade that stops recording it there fails the suite.
|
|
34
|
+
*/
|
|
35
|
+
export const adoptEnabled = (fqn: string): Effect.Effect<boolean> =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const stack = yield* Effect.serviceOption(Stack);
|
|
38
|
+
const scoped = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
|
|
39
|
+
if (scoped !== undefined) return scoped;
|
|
40
|
+
const policy = yield* Effect.serviceOption(AdoptPolicy);
|
|
41
|
+
if (Option.isSome(policy)) return policy.value;
|
|
42
|
+
const context = yield* Effect.serviceOption(AlchemyContext);
|
|
43
|
+
return Option.isSome(context) ? context.value.adopt : false;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The three fields of a `reconcile` (or `read`) call that say whose object this is. */
|
|
47
|
+
export type Owner = {
|
|
48
|
+
readonly fqn: string;
|
|
49
|
+
readonly instanceId: string;
|
|
50
|
+
/** The attributes state holds; `undefined` on a create. */
|
|
51
|
+
readonly output: unknown;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether `--adopt` may speak for this apply's generation: a CREATE (rows.ts isCreate), or an
|
|
56
|
+
* UNFINISHED generation whose row this plan's diff saw with no attributes (resume.ts) — the
|
|
57
|
+
* resume Alchemy itself offers `--adopt` for ("Cannot resume creating … Re-run with --adopt").
|
|
58
|
+
* ⛔ NOT A FRESH REPLACE'S NEW GENERATION. The planner never probes it and never offers it adoption,
|
|
59
|
+
* so the identity it moves to was never asked about under `--adopt`: 🔴 MEASURED 2026-09-21
|
|
60
|
+
* (launchd/adopt-apply.test.ts), a HostFile whose path changed onto a file it did not own
|
|
61
|
+
* overwrote that file under a deploy-wide `--adopt` meant for something else. A Bao rename onto a
|
|
62
|
+
* taken identity already fails the plan, `--adopt` or not (rename.ts judgeMove); this keeps the
|
|
63
|
+
* apply to the same answer.
|
|
64
|
+
*/
|
|
65
|
+
const adoptable = (owner: Owner): Effect.Effect<boolean> =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
if (yield* isCreate(owner.fqn, owner.instanceId)) return true;
|
|
68
|
+
return yield* unfinished(owner.instanceId);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** Whether a stateless `reconcile` may take over a live object: adoption on, for an adoptable one. */
|
|
72
|
+
export const adoptsAtApply = (owner: Owner): Effect.Effect<boolean> =>
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
if (!(yield* adoptEnabled(owner.fqn))) return false;
|
|
75
|
+
return yield* adoptable(owner);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** A lifecycle helper's hook for refuseTakeover, bound to one call's Owner: `(what) => …`. */
|
|
79
|
+
export type Claim = (what: string) => Effect.Effect<void>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `reconcile`'s check, called with the live object it found BEFORE any write: dies unless that
|
|
83
|
+
* object may be written. It may when state already vouches for it (`output`), when this apply
|
|
84
|
+
* resumes the resource's own interrupted create or replace and its diff proved the object that
|
|
85
|
+
* generation's (resume.ts), or when adoption is on for a create or an unfinished generation
|
|
86
|
+
* (adoptable).
|
|
87
|
+
* ⛔ WHY IT EXISTS AT ALL: THE PROBE DOES NOT GUARD EVERY CREATE. Alchemy skips it while `news` holds
|
|
88
|
+
* an Output (Plan.ts), a new generation of a replace is never probed, and an object can appear
|
|
89
|
+
* between plan and apply. Each of those used to be a silent takeover: written over, then recorded
|
|
90
|
+
* as ours, and deleted with the stack under `destroy`.
|
|
91
|
+
* ⚠️ A CHECK, THEN A WRITE — NOT A LOCK. Two creates of one name in the same deploy run
|
|
92
|
+
* concurrently, both read nothing, and both write (measured with the fakes, 2026-09-21). Nothing
|
|
93
|
+
* between a read and a write can close that; declare each name once.
|
|
94
|
+
* ⚠️ AN IDENTICAL OBJECT IS STILL A TAKEOVER (decided 2026-09-21). Nothing would be written, but the
|
|
95
|
+
* state row would claim it, and a later delete would remove what another owner still relies on.
|
|
96
|
+
* ⛔ A REFUSED CREATE FORGETS ITS `creating` ROW (rows.ts forgetRefusedCreate), or the next plan's
|
|
97
|
+
* recovery read would adopt the very object this refused.
|
|
98
|
+
*/
|
|
99
|
+
export const refuseTakeover = (owner: Owner, what: string): Effect.Effect<void> =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
if (owner.output !== undefined) return;
|
|
102
|
+
if (yield* resumes(owner.instanceId)) return;
|
|
103
|
+
const may = yield* adoptable(owner);
|
|
104
|
+
if (may && (yield* adoptEnabled(owner.fqn))) return;
|
|
105
|
+
yield* forgetRefusedCreate(owner.fqn, owner.instanceId);
|
|
106
|
+
const remedy = may
|
|
107
|
+
? 'Deploy with --adopt (or wrap the resource in adopt(true)) to take it over, or remove it first.'
|
|
108
|
+
: 'It is the new identity of a replace, which --adopt does not cover: remove it first.';
|
|
109
|
+
return yield* Effect.die(
|
|
110
|
+
new Error(
|
|
111
|
+
`${what}: already exists, and this stack holds no state for it — the plan could not ask ` +
|
|
112
|
+
'first (a prop was still an Output, a replace moved onto it, or it appeared after the ' +
|
|
113
|
+
`plan). Nothing was written. ${remedy}`,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** refuseTakeover bound to one call's Owner, for a lifecycle helper that finds the live object. */
|
|
119
|
+
export const claimFor =
|
|
120
|
+
(owner: Owner): Claim =>
|
|
121
|
+
(what) =>
|
|
122
|
+
refuseTakeover(owner, what);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine-facing edges of ownership/: which instance a read is about, whether its row carries
|
|
3
|
+
* the whole declaration, what a resume note is worth without a shared bag or a proof, and that a
|
|
4
|
+
* `settled` check that cannot run never fails the plan. whole.test.ts has the hole walk itself;
|
|
5
|
+
* the families' end-to-end behaviour is pinned through the engine in openbao/adopt-*.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
|
|
9
|
+
import { Artifacts, makeScopedArtifacts } from 'alchemy/Artifacts';
|
|
10
|
+
import { InMemoryService } from 'alchemy/State/InMemoryState';
|
|
11
|
+
import type { ResourceState } from 'alchemy/State/ResourceState';
|
|
12
|
+
import { State } from 'alchemy/State/State';
|
|
13
|
+
import { Stack } from 'alchemy/Stack';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import { refuseTakeover } from './adopt.ts';
|
|
16
|
+
import { ownedRead } from './probe.ts';
|
|
17
|
+
import { noteResume, noteUnfinished, resumes } from './resume.ts';
|
|
18
|
+
import { forgetRefusedCreate, isCreate, recordedInstance } from './rows.ts';
|
|
19
|
+
|
|
20
|
+
const row = (fqn: string, instanceId: string, old?: unknown, props: unknown = { name: 'a' }) =>
|
|
21
|
+
({
|
|
22
|
+
fqn,
|
|
23
|
+
instanceId,
|
|
24
|
+
old,
|
|
25
|
+
props,
|
|
26
|
+
status: old === undefined ? 'creating' : 'replacing',
|
|
27
|
+
}) as unknown as ResourceState;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A stack `s` at stage `test` whose store holds `rows`: `A` and `B` declared with a `name`, and `Z`
|
|
31
|
+
* declared `renamedFrom('X')`.
|
|
32
|
+
*/
|
|
33
|
+
const withStore = <A>(rows: Record<string, ResourceState>, effect: Effect.Effect<A>): Promise<A> =>
|
|
34
|
+
Effect.runPromise(
|
|
35
|
+
effect.pipe(
|
|
36
|
+
Effect.provideService(State, InMemoryService({ s: { test: rows } })),
|
|
37
|
+
Effect.provideService(Stack, {
|
|
38
|
+
actions: {},
|
|
39
|
+
bindings: {},
|
|
40
|
+
name: 's',
|
|
41
|
+
resources: {
|
|
42
|
+
A: { Props: { name: 'a' } },
|
|
43
|
+
B: { Props: { name: 'b' } },
|
|
44
|
+
Z: { FormerFqns: ['X'], Props: { name: 'a' } },
|
|
45
|
+
},
|
|
46
|
+
stage: 'test',
|
|
47
|
+
} as never),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
describe('recordedInstance', () => {
|
|
52
|
+
test('is the row at the FQN, any generation of its `old` chain, or a renamedFrom row', async () => {
|
|
53
|
+
const rows = { A: row('A', 'new', row('A', 'older')), X: row('X', 'moved') };
|
|
54
|
+
expect(await withStore(rows, recordedInstance('A', 'new'))).toBe('whole');
|
|
55
|
+
expect(await withStore(rows, recordedInstance('A', 'older'))).toBe('whole');
|
|
56
|
+
expect(await withStore(rows, recordedInstance('Z', 'moved'))).toBe('whole');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the probe's freshly minted instance is never recorded", async () => {
|
|
60
|
+
expect(await withStore({ A: row('A', 'mine') }, recordedInstance('A', 'probe'))).toBe('absent');
|
|
61
|
+
expect(await withStore({}, recordedInstance('A', 'mine'))).toBe('absent');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a row missing a declared prop (an Output stripped at commit) is partial', async () => {
|
|
65
|
+
const holed = { A: row('A', 'mine', undefined, {}) };
|
|
66
|
+
expect(await withStore(holed, recordedInstance('A', 'mine'))).toBe('partial');
|
|
67
|
+
// ★ No longer declared (an orphan's delete): nothing to compare, so never whole.
|
|
68
|
+
expect(await withStore({ C: row('C', 'mine') }, recordedInstance('C', 'mine'))).toBe('partial');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('fails closed with no Stack or State in context', async () => {
|
|
72
|
+
expect(await Effect.runPromise(recordedInstance('A', 'mine'))).toBe('absent');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('isCreate', () => {
|
|
77
|
+
test("is this instance's `creating` row — never a replace's `replacing` one", async () => {
|
|
78
|
+
const rows = { A: row('A', 'mine'), B: row('B', 'new', row('B', 'older')) };
|
|
79
|
+
expect(await withStore(rows, isCreate('A', 'mine'))).toBe(true);
|
|
80
|
+
expect(await withStore(rows, isCreate('B', 'new'))).toBe(false);
|
|
81
|
+
expect(await withStore(rows, isCreate('A', 'other'))).toBe(false);
|
|
82
|
+
expect(await Effect.runPromise(isCreate('A', 'mine'))).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('forgetRefusedCreate', () => {
|
|
87
|
+
test('drops only the creating row of this instance, with no attributes', async () => {
|
|
88
|
+
const rows: Record<string, ResourceState> = {
|
|
89
|
+
A: row('A', 'mine'),
|
|
90
|
+
B: row('B', 'mine', row('B', 'older')),
|
|
91
|
+
C: row('C', 'theirs'),
|
|
92
|
+
D: { ...row('D', 'mine'), attr: { name: 'd' } },
|
|
93
|
+
};
|
|
94
|
+
for (const fqn of ['A', 'B', 'C', 'D']) await withStore(rows, forgetRefusedCreate(fqn, 'mine'));
|
|
95
|
+
expect(Object.keys(rows).sort()).toEqual(['B', 'C', 'D']);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('ownedRead', () => {
|
|
100
|
+
const found = { name: 'a' };
|
|
101
|
+
const ask = (output?: unknown) => ({ fqn: 'A', instanceId: 'mine', output });
|
|
102
|
+
const rows = { A: row('A', 'mine') };
|
|
103
|
+
|
|
104
|
+
test('with state it is the object as found; absent is absent', async () => {
|
|
105
|
+
const plain = await Effect.runPromise(ownedRead(ask({}), found, Effect.succeed(false)));
|
|
106
|
+
expect(Unowned.is(plain)).toBe(false);
|
|
107
|
+
expect(await Effect.runPromise(ownedRead(ask(), undefined, Effect.succeed(true)))).toBe(
|
|
108
|
+
undefined,
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the probe answers Unowned even when the object is settled', async () => {
|
|
113
|
+
const probed = await withStore({}, ownedRead(ask(), found, Effect.succeed(true)));
|
|
114
|
+
expect(Unowned.is(probed)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the recovery read is ours only when settled, over a row with the whole declaration', async () => {
|
|
118
|
+
expect(Unowned.is(await withStore(rows, ownedRead(ask(), found, Effect.succeed(true))))).toBe(
|
|
119
|
+
false,
|
|
120
|
+
);
|
|
121
|
+
expect(Unowned.is(await withStore(rows, ownedRead(ask(), found, Effect.succeed(false))))).toBe(
|
|
122
|
+
true,
|
|
123
|
+
);
|
|
124
|
+
const holed = { A: row('A', 'mine', undefined, {}) };
|
|
125
|
+
expect(Unowned.is(await withStore(holed, ownedRead(ask(), found, Effect.succeed(true))))).toBe(
|
|
126
|
+
true,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a settled check that fails or dies is Unowned, never a failed plan', async () => {
|
|
131
|
+
const failing = Effect.fail(new Error('fragments directory moved'));
|
|
132
|
+
const dying = Effect.die(new TypeError('olds.tokenPolicies is undefined'));
|
|
133
|
+
expect(Unowned.is(await withStore(rows, ownedRead(ask(), found, failing)))).toBe(true);
|
|
134
|
+
expect(Unowned.is(await withStore(rows, ownedRead(ask(), found, dying)))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('resume notes and refuseTakeover', () => {
|
|
139
|
+
const bag = makeScopedArtifacts(new Map(), 'A');
|
|
140
|
+
const inBag = <A>(effect: Effect.Effect<A>) =>
|
|
141
|
+
Effect.runPromise(effect.pipe(Effect.provideService(Artifacts, bag)));
|
|
142
|
+
|
|
143
|
+
const ours = Effect.succeed({ name: 'a' });
|
|
144
|
+
|
|
145
|
+
test('a note is read back for its own instance only, and nothing is noted without a bag', async () => {
|
|
146
|
+
await inBag(noteResume('mine', ours));
|
|
147
|
+
expect(await inBag(resumes('mine'))).toBe(true);
|
|
148
|
+
expect(await inBag(resumes('other'))).toBe(false);
|
|
149
|
+
await Effect.runPromise(noteResume('mine', ours));
|
|
150
|
+
expect(await Effect.runPromise(resumes('mine'))).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('nothing is noted unless the read proves the object ours', async () => {
|
|
154
|
+
const fresh = makeScopedArtifacts(new Map(), 'A');
|
|
155
|
+
const unproven = [
|
|
156
|
+
Effect.succeed(undefined),
|
|
157
|
+
Effect.succeed(Unowned({ name: 'a' })),
|
|
158
|
+
Effect.fail(new Error('403')),
|
|
159
|
+
Effect.die(new TypeError('olds.name is undefined')),
|
|
160
|
+
];
|
|
161
|
+
for (const read of unproven) {
|
|
162
|
+
await Effect.runPromise(
|
|
163
|
+
noteResume('mine', read).pipe(Effect.provideService(Artifacts, fresh)),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
expect(
|
|
167
|
+
await Effect.runPromise(resumes('mine').pipe(Effect.provideService(Artifacts, fresh))),
|
|
168
|
+
).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('refuses a create with no note and no adoption; state or a note lets it through', async () => {
|
|
172
|
+
const owner = { fqn: 'A', instanceId: 'mine', output: undefined };
|
|
173
|
+
await expect(Effect.runPromise(refuseTakeover(owner, 'Thing a'))).rejects.toThrow(
|
|
174
|
+
/Thing a: already exists.*--adopt/s,
|
|
175
|
+
);
|
|
176
|
+
await Effect.runPromise(refuseTakeover({ ...owner, output: {} }, 'Thing a'));
|
|
177
|
+
await inBag(noteResume('mine', ours));
|
|
178
|
+
await inBag(refuseTakeover(owner, 'Thing a'));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("--adopt covers a create or an unfinished generation, never a fresh replace's", async () => {
|
|
182
|
+
const rows = { A: row('A', 'mine'), B: row('B', 'new', row('B', 'older')) };
|
|
183
|
+
const adopting = <A>(effect: Effect.Effect<A>, bag = makeScopedArtifacts(new Map(), 'B')) =>
|
|
184
|
+
withStore(
|
|
185
|
+
rows,
|
|
186
|
+
effect.pipe(
|
|
187
|
+
Effect.provideService(AdoptPolicy, true),
|
|
188
|
+
Effect.provideService(Artifacts, bag),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
await adopting(refuseTakeover({ fqn: 'A', instanceId: 'mine', output: undefined }, 'Thing a'));
|
|
192
|
+
const fresh = { fqn: 'B', instanceId: 'new', output: undefined };
|
|
193
|
+
await expect(adopting(refuseTakeover(fresh, 'Thing b'))).rejects.toThrow(
|
|
194
|
+
/Thing b: already exists.*new identity of a replace, which --adopt does not cover/s,
|
|
195
|
+
);
|
|
196
|
+
const seen = makeScopedArtifacts(new Map(), 'B');
|
|
197
|
+
await adopting(noteUnfinished('new'), seen);
|
|
198
|
+
await adopting(refuseTakeover(fresh, 'Thing b'), seen);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `read` with no attributes — the plan-time half of "nothing is adopted without `--adopt`"
|
|
3
|
+
* (adopt.ts has the apply-time half). Alchemy asks it in two situations and passes `output:
|
|
4
|
+
* undefined` in both (alchemy beta.79 Plan.ts):
|
|
5
|
+
* · THE ADOPTION PROBE — no state row at all; `olds` is the declaration, `instanceId` fresh.
|
|
6
|
+
* · THE RECOVERY READ — a `creating` row whose create was interrupted after its write and before
|
|
7
|
+
* its commit; `olds` is that row's props, `instanceId` the row's. (Apply.ts asks the same for a
|
|
8
|
+
* row it must delete that never recorded attributes.)
|
|
9
|
+
* The answer routes the plan: `undefined` → create; plain attributes → ours; `Unowned(…)` → the
|
|
10
|
+
* plan fails with OwnedBySomeoneElse unless `--adopt` / `adopt(true)` (AdoptPolicy.ts).
|
|
11
|
+
*
|
|
12
|
+
* ⛔ THE PROBE ALWAYS ANSWERS `Unowned` FOR A LIVE OBJECT, EVEN ONE IDENTICAL TO THE DECLARATION
|
|
13
|
+
* (decided 2026-09-21). Identical is not ours: a name another stack, a person or an old script put
|
|
14
|
+
* there reads the same, and once state claims it, a delete under `destroy` removes it from its
|
|
15
|
+
* real owner. 🔴 Measured before the change (openbao/rename-adoption.test.ts): a new logical id
|
|
16
|
+
* for a live Bao name adopted it silently, and the old id's delete then removed it, green.
|
|
17
|
+
* ★ THE RECOVERY READ STILL ADOPTS WHAT THE INTERRUPTED CREATE MADE — the instance is ours by the
|
|
18
|
+
* state row, and the object is proven ours when it matches that row's props (`settled`). One that
|
|
19
|
+
* does not match may have lost a race to someone else: `Unowned`, as Plan.ts itself asks.
|
|
20
|
+
* ⛔ ONLY WHEN THE ROW'S PROPS ARE THE WHOLE DECLARATION (whole.ts). A row written while a prop was
|
|
21
|
+
* still an Output has a hole there, and "matches" against a hole proves nothing — nor had that
|
|
22
|
+
* deploy's plan ever asked whose object it was, because Alchemy skips the probe for an Output.
|
|
23
|
+
* Such a create resumes with `--adopt`.
|
|
24
|
+
*/
|
|
25
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
26
|
+
import * as Effect from 'effect/Effect';
|
|
27
|
+
import type { Owner } from './adopt.ts';
|
|
28
|
+
import { recordedInstance } from './rows.ts';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A family's `read` answer. With attributes in state it is `found` as is. Without, it is the probe
|
|
32
|
+
* or the recovery read (header): `found` is ours only when the state store records this instance
|
|
33
|
+
* with its whole declaration AND `settled` — the family's own "live matches these props" — says so.
|
|
34
|
+
* ⛔ `settled` NEVER FAILS THE READ. The recovery read runs with the INTERRUPTED deploy's props, so
|
|
35
|
+
* a declaration that cannot be evaluated any more (a fragments directory since moved, a group
|
|
36
|
+
* name since renamed) would fail every later plan, the fix included. It reads as "not proven
|
|
37
|
+
* ours" instead — `Unowned`, which `--adopt` resolves — with the reason logged.
|
|
38
|
+
*/
|
|
39
|
+
export const ownedRead = <A extends object, E, R>(
|
|
40
|
+
ask: Owner,
|
|
41
|
+
found: A | undefined,
|
|
42
|
+
settled: Effect.Effect<boolean, E, R>,
|
|
43
|
+
): Effect.Effect<A | undefined, never, R> =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
if (found === undefined || ask.output !== undefined) return found;
|
|
46
|
+
const recorded = yield* recordedInstance(ask.fqn, ask.instanceId);
|
|
47
|
+
if (recorded === 'absent') return Unowned(found);
|
|
48
|
+
if (recorded === 'partial') {
|
|
49
|
+
yield* Effect.logWarning(
|
|
50
|
+
`${ask.fqn}: the interrupted create's state row lacks part of the declaration (a prop ` +
|
|
51
|
+
'was still an Output when it was written), so the live object is not proven ours',
|
|
52
|
+
);
|
|
53
|
+
return Unowned(found);
|
|
54
|
+
}
|
|
55
|
+
const unproven = (reason: unknown) =>
|
|
56
|
+
Effect.as(
|
|
57
|
+
Effect.logWarning(
|
|
58
|
+
`${ask.fqn}: the interrupted create's object could not be compared with its props ` +
|
|
59
|
+
`(${reason instanceof Error ? reason.message : String(reason)}), so it is not treated as ours`,
|
|
60
|
+
),
|
|
61
|
+
false,
|
|
62
|
+
);
|
|
63
|
+
const ours = yield* settled.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));
|
|
64
|
+
return ours ? found : Unowned(found);
|
|
65
|
+
});
|