@homeflare/alchemy 0.7.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 +45 -45
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +270 -44
- package/dist/caddy/index.js.map +15 -11
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- 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 +51 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.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.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +642 -49
- package/dist/launchd/index.js.map +24 -14
- 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-validate.d.ts +2 -0
- package/dist/launchd/job-validate.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/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +14 -5
- 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-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +83 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.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-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- 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-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +52 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- 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/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +709 -359
- package/dist/openbao/index.js.map +32 -25
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +12 -5
- 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/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.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-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- 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/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +147 -0
- package/docs/launchd.md +14 -7
- package/docs/ownership.md +113 -0
- package/docs/peers.md +39 -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 +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -10
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +1 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +41 -15
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +21 -3
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +14 -5
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +230 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +216 -0
- package/src/launchd/sudo-said.ts +58 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +11 -2
- package/src/openbao/REPLACE.md +67 -37
- 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-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +48 -17
- package/src/openbao/cloudflare-role.ts +23 -18
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +188 -0
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +35 -11
- package/src/openbao/jwt-role.ts +33 -14
- package/src/openbao/kubernetes-role.ts +32 -14
- package/src/openbao/mfa-enforcement.ts +42 -13
- package/src/openbao/mfa-totp.ts +42 -14
- 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 +33 -10
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +57 -20
- package/src/openbao/policy.ts +24 -11
- package/src/openbao/proxmox-role.ts +20 -20
- package/src/openbao/rename-adoption.test.ts +95 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename.ts +19 -15
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +29 -57
- 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
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,
|
|
@@ -43,12 +46,13 @@ import {
|
|
|
43
46
|
type BaoSshRoleProps,
|
|
44
47
|
attributesOf,
|
|
45
48
|
badDurations,
|
|
46
|
-
extMap,
|
|
47
49
|
matches,
|
|
48
50
|
readPath,
|
|
49
51
|
resolve,
|
|
50
52
|
writeBody,
|
|
51
53
|
} from './ssh-role-form.ts';
|
|
54
|
+
import { wouldErase } from './ssh-role-erase.ts';
|
|
55
|
+
import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
52
56
|
|
|
53
57
|
export type { BaoSshRoleAttributes, BaoSshRoleProps };
|
|
54
58
|
|
|
@@ -64,6 +68,9 @@ export const BaoSshRole = Resource<BaoSshRole>('Bao.SshRole', {
|
|
|
64
68
|
defaultRemovalPolicy: 'retain',
|
|
65
69
|
});
|
|
66
70
|
|
|
71
|
+
/** Exact: the engine stores `roles/<name>` verbatim (builtin/logical/ssh/path_roles.go:602). */
|
|
72
|
+
const IDENTITY = roleIdentity<BaoSshRoleAttributes>('Bao.SshRole', readPath, 'ssh');
|
|
73
|
+
|
|
67
74
|
/** Live role, plus the raw body — reconcile needs the raw half to see what it would erase. */
|
|
68
75
|
const readRole = (form: BaoSshRoleForm) =>
|
|
69
76
|
Effect.gen(function* () {
|
|
@@ -72,51 +79,6 @@ const readRole = (form: BaoSshRoleForm) =>
|
|
|
72
79
|
return { attributes: attributesOf(form, live), live };
|
|
73
80
|
});
|
|
74
81
|
|
|
75
|
-
/**
|
|
76
|
-
* Fields this resource deliberately does not model, mapped to the value OpenBao's own write
|
|
77
|
-
* puts there when nobody says otherwise. The templating flags change how a principal is
|
|
78
|
-
* DERIVED at signing time, which is a different kind of decision from which principals
|
|
79
|
-
* exist; the rest are issuance mechanics. MEASURED against all three live roles — every one
|
|
80
|
-
* of them sits on exactly these values, so the guard below fires on nothing that exists.
|
|
81
|
-
*/
|
|
82
|
-
const UNMANAGED_DEFAULTS: Readonly<Record<string, unknown>> = {
|
|
83
|
-
algorithm_signer: 'default',
|
|
84
|
-
allow_commas_in_identity_templates: false,
|
|
85
|
-
allow_user_key_ids: false,
|
|
86
|
-
allowed_domains_template: false,
|
|
87
|
-
allowed_users_template: false,
|
|
88
|
-
default_extensions_template: false,
|
|
89
|
-
default_user_template: false,
|
|
90
|
-
issuer_ref: 'default',
|
|
91
|
-
key_id_format: '',
|
|
92
|
-
not_before_duration: 30,
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* ⚠️ ABSENT AND EMPTY BOTH COUNT AS "NEVER SET". A role this resource wrote omits these
|
|
97
|
-
* fields entirely, so a stricter test would refuse on the provider's own output and
|
|
98
|
-
* deadlock the update after next.
|
|
99
|
-
*/
|
|
100
|
-
const isDefault = (live: Record<string, unknown>, field: string) =>
|
|
101
|
-
live[field] === undefined || live[field] === '' || live[field] === UNMANAGED_DEFAULTS[field];
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* ⛔ THE FIELDS A WRITE WOULD SILENTLY DESTROY. Because the write is a full replace, a role
|
|
105
|
-
* someone tuned by hand — `allowed_user_key_lengths` (a minimum RSA size), a
|
|
106
|
-
* `key_id_format` the audit trail is grepped by, a `not_before_duration` widened to
|
|
107
|
-
* survive clock skew, a non-default issuer — loses all of it the moment this resource
|
|
108
|
-
* decides to update. A vanished key-length floor is a security regression that no plan
|
|
109
|
-
* output would ever mention, and a vanished backdating window breaks signing on exactly
|
|
110
|
-
* the skewed host it was added for. So reconcile refuses instead of writing.
|
|
111
|
-
*/
|
|
112
|
-
const wouldErase = (live: Record<string, unknown>): readonly string[] => {
|
|
113
|
-
const lost = Object.keys(UNMANAGED_DEFAULTS).filter((field) => !isDefault(live, field));
|
|
114
|
-
if (Object.keys(extMap(live['allowed_user_key_lengths'])).length > 0) {
|
|
115
|
-
lost.push('allowed_user_key_lengths');
|
|
116
|
-
}
|
|
117
|
-
return lost.sort();
|
|
118
|
-
};
|
|
119
|
-
|
|
120
82
|
const refuse = (message: string) => Effect.die(new Error(message));
|
|
121
83
|
|
|
122
84
|
export const BaoSshRoleProvider = () =>
|
|
@@ -132,9 +94,13 @@ export const BaoSshRoleProvider = () =>
|
|
|
132
94
|
*/
|
|
133
95
|
list: () => Effect.succeed([]),
|
|
134
96
|
|
|
135
|
-
|
|
97
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
98
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
136
99
|
const found = yield* readRole(resolve(olds));
|
|
137
|
-
|
|
100
|
+
const ours = Effect.sync(
|
|
101
|
+
() => found !== undefined && matches(found.attributes, resolve(olds)),
|
|
102
|
+
);
|
|
103
|
+
return yield* ownedRead({ fqn, instanceId, output }, found?.attributes, ours);
|
|
138
104
|
}),
|
|
139
105
|
|
|
140
106
|
/**
|
|
@@ -147,18 +113,20 @@ export const BaoSshRoleProvider = () =>
|
|
|
147
113
|
* at risk when something actually writes; if every managed field already matches,
|
|
148
114
|
* the honest answer is `noop` and nothing gets destroyed.
|
|
149
115
|
*/
|
|
150
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
151
|
-
if (output === undefined || !isResolved(news)) return undefined;
|
|
152
|
-
const form = resolve(news);
|
|
116
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
153
117
|
/**
|
|
154
118
|
* ⛔ A RENAMED ROLE IS A NEW PATH, NOT AN EDIT. `ssh/roles/x` and `ssh-host/roles/x`
|
|
155
119
|
* are different mounts with different CAs. Without this, changing `name` or
|
|
156
120
|
* `mount` would write the new role and leave the old one signing certificates
|
|
157
|
-
* for as long as anyone remembered its name.
|
|
121
|
+
* for as long as anyone remembered its name. Judged before `isResolved(news)`, and
|
|
122
|
+
* ⛔ a move onto a role that exists fails the plan (rename-identity.ts).
|
|
158
123
|
*/
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
124
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
125
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
126
|
+
if (output === undefined) return undefined;
|
|
127
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
128
|
+
if (!isResolved(news)) return undefined;
|
|
129
|
+
const form = resolve(news);
|
|
162
130
|
const found = yield* readRole(form);
|
|
163
131
|
if (found === undefined) return { action: 'update' } as const;
|
|
164
132
|
return matches(found.attributes, form)
|
|
@@ -166,9 +134,11 @@ export const BaoSshRoleProvider = () =>
|
|
|
166
134
|
: ({ action: 'update' } as const);
|
|
167
135
|
}),
|
|
168
136
|
|
|
169
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
137
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
170
138
|
const form = resolve(news);
|
|
171
139
|
const path = readPath(form.mount, form.name);
|
|
140
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
141
|
+
yield* guardRename(IDENTITY, news, output);
|
|
172
142
|
|
|
173
143
|
const bad = badDurations(form);
|
|
174
144
|
if (bad.length > 0) {
|
|
@@ -195,6 +165,8 @@ export const BaoSshRoleProvider = () =>
|
|
|
195
165
|
}
|
|
196
166
|
|
|
197
167
|
const found = yield* readRole(form);
|
|
168
|
+
if (found !== undefined)
|
|
169
|
+
yield* refuseTakeover({ fqn, instanceId, output }, `Bao.SshRole ${path}`);
|
|
198
170
|
if (found === undefined || !matches(found.attributes, form)) {
|
|
199
171
|
if (found !== undefined) {
|
|
200
172
|
const lost = wouldErase(found.live);
|
|
@@ -235,5 +207,5 @@ export const BaoSshRoleProvider = () =>
|
|
|
235
207
|
return undefined;
|
|
236
208
|
}),
|
|
237
209
|
}),
|
|
238
|
-
),
|
|
210
|
+
).pipe(Effect.map(provingResumes)),
|
|
239
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
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this apply RESUMES a create or replace the engine already started for this resource AND
|
|
3
|
+
* the live object it will find is proven that generation's own — the one case in which a stateless
|
|
4
|
+
* `reconcile` may write over a live object without `--adopt`.
|
|
5
|
+
*
|
|
6
|
+
* ★ HOW THE ENGINE TELLS US. Alchemy calls `diff` whenever a state row exists, and hands it `output`
|
|
7
|
+
* = that row's attributes (alchemy beta.79 Plan.ts). A row with NO attributes is exactly an
|
|
8
|
+
* unfinished generation: `creating` (a create interrupted after its write, before its commit),
|
|
9
|
+
* `replacing` (the same for a replace), or `deleting` re-driven as a create. A fresh create is
|
|
10
|
+
* never diffed at all. So the diff of such a row decides, and the apply that follows (the same
|
|
11
|
+
* instance id: Apply.ts reuses the row's) finds the note.
|
|
12
|
+
* ⛔ A ROW PROVES NOTHING BY ITSELF (red team, 2026-09-21). Apply commits it BEFORE reconcile runs, so
|
|
13
|
+
* a deploy killed before the claim — an upstream that failed, a Ctrl-C — leaves a row whose create
|
|
14
|
+
* never asked whose object sat at its identity. 🔴 MEASURED (openbao/adopt-holes.test.ts): with the
|
|
15
|
+
* name an Output, the next deploy "resumed" that create and wrote over another owner's role, in
|
|
16
|
+
* every family that had a note on the row alone. So the note is left only when the family's own
|
|
17
|
+
* `read`, asked as the recovery read (probe.ts ownedRead: a recorded instance, a row carrying the
|
|
18
|
+
* whole declaration, and a live object that matches it), answers "ours". For a `creating` row
|
|
19
|
+
* that is the question Plan.ts already asked; for a `replacing` row it is the only time it is asked.
|
|
20
|
+
* ⚠️ WHY NOT THE STATE STORE ALONE. At reconcile both look the same: Apply commits the fresh create's
|
|
21
|
+
* `creating` row before calling reconcile, so a row with this instance id is always there.
|
|
22
|
+
* ★ THE CHANNEL IS ALCHEMY'S OWN: `Artifacts`, the per-FQN bag the engine shares between one plan
|
|
23
|
+
* and its apply (Artifacts.ts: "diff computes … create / update reads the same artifact").
|
|
24
|
+
* Deploy.ts and the Alchemist session both hold one store across the pair.
|
|
25
|
+
* ⚠️ IT FAILS CLOSED. No bag, a bag the plan never wrote, or an object the read cannot prove ours
|
|
26
|
+
* reads as a fresh create, and adopt.ts then asks for `--adopt` — the safe direction.
|
|
27
|
+
* ★ AN UNPROVEN RESUME IS STILL MARKED UNFINISHED (`noteUnfinished`), so `--adopt` can take it over
|
|
28
|
+
* the way Alchemy's own "Cannot resume creating … Re-run with --adopt" offers for a create — while
|
|
29
|
+
* a FRESH replace's new generation, which no diff ever saw without attributes, stays unadoptable.
|
|
30
|
+
*/
|
|
31
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
32
|
+
import { Artifacts } from 'alchemy/Artifacts';
|
|
33
|
+
import * as Effect from 'effect/Effect';
|
|
34
|
+
import * as Option from 'effect/Option';
|
|
35
|
+
|
|
36
|
+
const PROVEN = 'homeflare/ownership/resumes';
|
|
37
|
+
const UNFINISHED = 'homeflare/ownership/unfinished';
|
|
38
|
+
|
|
39
|
+
const note = (key: string, instanceId: string): Effect.Effect<void> =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const bag = yield* Effect.serviceOption(Artifacts);
|
|
42
|
+
if (Option.isSome(bag)) yield* bag.value.set(key, instanceId);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const noted = (key: string, instanceId: string): Effect.Effect<boolean> =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const bag = yield* Effect.serviceOption(Artifacts);
|
|
48
|
+
if (Option.isNone(bag)) return false;
|
|
49
|
+
return (yield* bag.value.get<string>(key)) === instanceId;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `diff`'s note for a row with no attributes: `instanceId` is an unfinished generation of this
|
|
54
|
+
* resource's own, which `--adopt` may resume when nothing proves it (adopt.ts adoptable). Returns
|
|
55
|
+
* `undefined`, a diff's "defer to the engine".
|
|
56
|
+
*/
|
|
57
|
+
export const noteUnfinished = (instanceId: string): Effect.Effect<undefined> =>
|
|
58
|
+
Effect.as(note(UNFINISHED, instanceId), undefined);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Note `instanceId` unfinished, and a proven resume when `recovered` — the family's `read` of the
|
|
62
|
+
* row's props, asked with no attributes — answers a plain object: ours. `Unowned`, nothing, or a
|
|
63
|
+
* read that fails or dies proves nothing. ⚠️ Never fails the plan: an unproven resume is refused
|
|
64
|
+
* at apply instead, unless `--adopt`.
|
|
65
|
+
*/
|
|
66
|
+
export const noteResume = <R>(
|
|
67
|
+
instanceId: string,
|
|
68
|
+
recovered: Effect.Effect<unknown, unknown, R>,
|
|
69
|
+
): Effect.Effect<void, never, R> =>
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
yield* noteUnfinished(instanceId);
|
|
72
|
+
const unproven = () => Effect.succeed(undefined);
|
|
73
|
+
const found = yield* recovered.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));
|
|
74
|
+
if (found === undefined || found === null || Unowned.is(found)) return;
|
|
75
|
+
yield* note(PROVEN, instanceId);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Whether the plan for this apply noted `instanceId` as a proven resumed create or replace. */
|
|
79
|
+
export const resumes = (instanceId: string): Effect.Effect<boolean> => noted(PROVEN, instanceId);
|
|
80
|
+
|
|
81
|
+
/** Whether the plan for this apply saw `instanceId` as an unfinished generation (proven or not). */
|
|
82
|
+
export const unfinished = (instanceId: string): Effect.Effect<boolean> =>
|
|
83
|
+
noted(UNFINISHED, instanceId);
|
|
84
|
+
|
|
85
|
+
/** The fields of a `read` or `diff` call provingResumes uses; a family's own input carries more. */
|
|
86
|
+
type Ask = {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly fqn: string;
|
|
89
|
+
readonly instanceId: string;
|
|
90
|
+
readonly olds: unknown;
|
|
91
|
+
readonly output: unknown;
|
|
92
|
+
};
|
|
93
|
+
type Hook = Effect.Effect<unknown, unknown, unknown>;
|
|
94
|
+
|
|
95
|
+
/** The two hooks provingResumes wraps, as Alchemy's ProviderService declares them. */
|
|
96
|
+
interface Resumable {
|
|
97
|
+
read?(input: Ask): Hook;
|
|
98
|
+
diff?(input: Ask): Hook;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A provider whose `diff`, for a row with no attributes, first asks the provider's own `read` whether
|
|
103
|
+
* the live object is that unfinished generation's (noteResume), then runs as written. Every Bao.*
|
|
104
|
+
* family is built through it, so their diffs answer such a row with a plain `undefined`.
|
|
105
|
+
*/
|
|
106
|
+
export const provingResumes = <S extends Resumable>(service: S): S => {
|
|
107
|
+
const { diff, read } = service;
|
|
108
|
+
if (diff === undefined || read === undefined) return service;
|
|
109
|
+
const proving: Resumable['diff'] = (input) =>
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
if (input.output === undefined) {
|
|
112
|
+
yield* noteResume(input.instanceId, read({ ...input, output: undefined }));
|
|
113
|
+
}
|
|
114
|
+
return yield* diff(input);
|
|
115
|
+
});
|
|
116
|
+
// ⚠️ The one cast: `diff` keeps the family's own signature — it is the family's diff, run after.
|
|
117
|
+
return { ...service, diff: proving } as S;
|
|
118
|
+
};
|