@homeflare/alchemy 0.8.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +370 -0
- package/dist/verify/cli.js.map +17 -0
- package/dist/verify/fake-engine.d.ts +65 -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 +347 -0
- package/dist/verify/index.js.map +16 -0
- package/dist/verify/plan-scope.d.ts +14 -0
- package/dist/verify/plan-scope.d.ts.map +1 -0
- package/dist/verify/recheck.d.ts +42 -0
- package/dist/verify/recheck.d.ts.map +1 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +67 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +43 -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 +119 -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 +120 -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 +182 -0
- package/src/verify/index.ts +37 -0
- package/src/verify/plan-scope.ts +34 -0
- package/src/verify/recheck.test.ts +56 -0
- package/src/verify/recheck.ts +71 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +210 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +160 -0
- package/src/verify/verify.test.ts +146 -0
- package/src/verify/verify.ts +185 -0
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
|
);
|
|
@@ -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);
|