@homeflare/alchemy 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What sudo's own failure means, for sudo-runner.ts: which exit-1 answers are sudo refusing to run
|
|
3
|
+
* the command (so NOTHING ran as root), and what an EACCES outside every prefix most likely means.
|
|
4
|
+
* Split out of sudo-runner.ts, which keeps only the runner.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ REASONED, NOT MEASURED: sudo's failure text. The kit never runs sudo. The strings below follow
|
|
7
|
+
* sudoers(5) for sudo 1.9.17p2 on macOS 27.2 ("a password is required": -n was given but a
|
|
8
|
+
* password was needed) and sudo(8) (sudo exits 1 when it fails itself).
|
|
9
|
+
*/
|
|
10
|
+
import { errnoCode } from './local-runner.ts';
|
|
11
|
+
import type { ExecResult } from './runner.ts';
|
|
12
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ⚠️ ANCHORED TO sudo'S OWN MESSAGE FORMS (`sudo: …` lines, `Sorry, user …`), so a launchctl or
|
|
16
|
+
* install error that happens to say "not allowed" is never reported as "the command did not run".
|
|
17
|
+
* The refusals are the three a sudoers denial prints: the command not allowed, the user not in
|
|
18
|
+
* sudoers, and the user not allowed on this host (`<user> is not allowed to run sudo on <host>.`).
|
|
19
|
+
* REASONED from sudo 1.9's sudoers plugin, like the rest (header).
|
|
20
|
+
*/
|
|
21
|
+
const PASSWORD =
|
|
22
|
+
/^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
|
|
23
|
+
const NOT_ALLOWED =
|
|
24
|
+
/^(?:Sorry, user \S+ (?:is not allowed to execute|may not run sudo)|\S+ is not (?:in the sudoers file|allowed to run sudo on ))/m;
|
|
25
|
+
|
|
26
|
+
/** ★ An EACCES outside every prefix is almost always a directory the stack forgot to declare. */
|
|
27
|
+
export const undeclared =
|
|
28
|
+
(path: string, prefixes: readonly string[]) =>
|
|
29
|
+
(cause: unknown): never => {
|
|
30
|
+
const code = errnoCode(cause);
|
|
31
|
+
if (code !== 'EACCES' && code !== 'EPERM') throw cause;
|
|
32
|
+
throw new Error(
|
|
33
|
+
`sudoRunner ${path}: ${code} as the deploying user, and the path is under no declared prefix ` +
|
|
34
|
+
`(${prefixes.join(', ')}). If root owns its directory, declare that directory as a prefix.`,
|
|
35
|
+
{ cause },
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The refusal sudo itself answered for `shown` (the argv as JSON), or `undefined` when the command
|
|
41
|
+
* ran — whatever its own exit code. ★ `SudoRefusedError` always means nothing ran as root.
|
|
42
|
+
*/
|
|
43
|
+
export const sudoRefusal = (shown: string, result: ExecResult): SudoRefusedError | undefined => {
|
|
44
|
+
if (result.exitCode !== 1) return undefined;
|
|
45
|
+
if (PASSWORD.test(result.stderr)) {
|
|
46
|
+
return new SudoRefusedError(
|
|
47
|
+
`sudo -n ${shown}: a password is required, and this runner never prompts. Run \`sudo -v\` ` +
|
|
48
|
+
'in the deploying terminal just before the deploy, or grant exactly these commands ' +
|
|
49
|
+
'NOPASSWD (docs/launchd-sudo.md). The command did not run.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (NOT_ALLOWED.test(result.stderr)) {
|
|
53
|
+
return new SudoRefusedError(
|
|
54
|
+
`sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
package/src/openbao/README.md
CHANGED
|
@@ -17,8 +17,11 @@ plus `FetchHttpClient.layer`.
|
|
|
17
17
|
- ⛔ A rename or move onto a name or path that already exists fails the plan, before anything is
|
|
18
18
|
written (every family but the mounts, whose moves are below). Move through a free name, in two
|
|
19
19
|
deploys, or remove the target first.
|
|
20
|
-
-
|
|
21
|
-
|
|
20
|
+
- ⛔ **Nothing is adopted without `--adopt`** (0.9.0), not even an object identical to the
|
|
21
|
+
declaration: a live name with no state fails the plan, or the apply when a prop was an Output.
|
|
22
|
+
Our own interrupted create still resumes when its row can prove it — not one killed while a
|
|
23
|
+
prop was still an Output. [docs/ownership.md](../../docs/ownership.md). Change
|
|
24
|
+
a logical id with Alchemy's `renamedFrom` (REPLACE.md), never by adopting.
|
|
22
25
|
|
|
23
26
|
## assertBaoIdentity: call it first
|
|
24
27
|
|
package/src/openbao/REPLACE.md
CHANGED
|
@@ -139,17 +139,19 @@ retained."). It is live and unmanaged from then on:
|
|
|
139
139
|
`declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
|
|
140
140
|
orphan delete, which `retain` also keeps live.
|
|
141
141
|
|
|
142
|
-
## A new declaration of a live name:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
142
|
+
## A new declaration of a live name: refused unless adopted
|
|
143
|
+
|
|
144
|
+
⛔ A NEW logical id whose name already exists now FAILS THE PLAN (0.9.0): with no state, `read`
|
|
145
|
+
answers `Unowned` for any live object, identical or not (`src/ownership/probe.ts`,
|
|
146
|
+
[docs/ownership.md](../../docs/ownership.md)). Nothing moved, so `judgeMove` never runs; the
|
|
147
|
+
probe is what catches it. 🔴 Measured before (`rename-adoption.test.ts`): the families' `read`
|
|
148
|
+
answered plain attributes, so Alchemy silently adopted the object, and under `destroy` the old
|
|
149
|
+
owner's delete then ran after the adoption wrote and removed the object the new id claimed, in a
|
|
150
|
+
green deploy — for each of the nine batch-2 families whose delete runs (a TOTP method and its
|
|
151
|
+
enrolments included), and when a new `Bao.AuthRole` took a name another one moved off in the same
|
|
152
|
+
deploy. ⚠️ `--adopt` is exactly that takeover again, on purpose. Change a logical id with Alchemy's
|
|
153
|
+
`renamedFrom('<old id>')`, which migrates the state row and plans an `update`. Take a freed name
|
|
154
|
+
in the deploy after the move.
|
|
153
155
|
|
|
154
156
|
## Why the moves fail instead of replacing
|
|
155
157
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ownership rule for the five families fake-core.ts carries — Bao.Policy, Bao.CloudflareRole,
|
|
3
|
+
* Bao.ProxmoxRole, Bao.Mount and Bao.AuthMethod — through Alchemy's own plan and apply: the same
|
|
4
|
+
* claims adopt-families.test.ts and adopt-recovery.test.ts make for the other nine.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { describe, it } from 'node:test';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import { BaoAuthRole } from './auth-role.ts';
|
|
12
|
+
import { crashAfterWrite } from './fake-bao.ts';
|
|
13
|
+
import { CORE, type CoreRow, withCore } from './fake-core.ts';
|
|
14
|
+
import { writesOf } from './fake-stack.ts';
|
|
15
|
+
import { BaoMount } from './mount.ts';
|
|
16
|
+
|
|
17
|
+
const NOT_OWNED = /Cannot adopt resource 'Z'.*not owned by this stack.*--adopt/s;
|
|
18
|
+
const TAKEOVER = /: already exists, and this stack holds no state for it.*Nothing was written/s;
|
|
19
|
+
|
|
20
|
+
/** `Z`, with its knob an Output until apply: an upstream AppRole's `tokenTtl`, created with it. */
|
|
21
|
+
const pending = (row: CoreRow) =>
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const up = yield* BaoAuthRole('Upstream', {
|
|
24
|
+
name: 'upstream',
|
|
25
|
+
secretIdTtl: '24h',
|
|
26
|
+
tokenMaxTtl: '1h',
|
|
27
|
+
tokenPolicies: ['default'],
|
|
28
|
+
tokenTtl: '15m',
|
|
29
|
+
});
|
|
30
|
+
yield* row.declare('Z', up.tokenTtl);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const row of CORE) {
|
|
34
|
+
describe(`${row.family}: ownership`, () => {
|
|
35
|
+
it('a live object another stack owns, identical, fails the plan; nothing is written', async () => {
|
|
36
|
+
await withCore(async (stack, estate, seen, owner) => {
|
|
37
|
+
await owner.deploy(row.declare('X', '15m'));
|
|
38
|
+
seen.length = 0;
|
|
39
|
+
await assert.rejects(stack.deploy(row.declare('Z', '15m')), NOT_OWNED);
|
|
40
|
+
assert.deepEqual(writesOf(seen), []);
|
|
41
|
+
assert.ok(row.has(estate));
|
|
42
|
+
assert.deepEqual(await stack.deploy(row.declare('Z', '15m'), { adopt: true }), {
|
|
43
|
+
Z: 'adopted',
|
|
44
|
+
});
|
|
45
|
+
assert.deepEqual(writesOf(seen), []);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('with a prop still an Output, the apply refuses it; the next plan refuses too', async () => {
|
|
50
|
+
await withCore(async (stack, _estate, seen, owner) => {
|
|
51
|
+
await owner.deploy(row.declare('X', '15m'));
|
|
52
|
+
seen.length = 0;
|
|
53
|
+
await assert.rejects(stack.deploy(pending(row)), TAKEOVER);
|
|
54
|
+
// ★ The refused create forgot its `creating` row, so the next plan probes: still refused.
|
|
55
|
+
await assert.rejects(stack.deploy(pending(row)), NOT_OWNED);
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
writesOf(seen).filter((line) => !line.endsWith('/upstream')),
|
|
58
|
+
[],
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('with a prop still an Output, --adopt takes it over at apply', async () => {
|
|
64
|
+
await withCore(async (stack, _estate, _seen, owner) => {
|
|
65
|
+
await owner.deploy(row.declare('X', '15m'));
|
|
66
|
+
assert.deepEqual(await stack.deploy(pending(row), { adopt: true }), {
|
|
67
|
+
Upstream: 'create',
|
|
68
|
+
Z: 'create',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('an interrupted create is resumed without --adopt, and nothing is rewritten', async () => {
|
|
74
|
+
const fault = crashAfterWrite();
|
|
75
|
+
await withCore(async (stack, estate, seen) => {
|
|
76
|
+
// ★ Bao.Policy reads nothing back after its write, so the crash is the write's own reply.
|
|
77
|
+
fault.crashNext(row.family === 'Bao.Policy' ? 'write' : 'read-back');
|
|
78
|
+
await assert.rejects(stack.deploy(row.declare('X', '15m')), /injected/);
|
|
79
|
+
assert.ok(row.has(estate));
|
|
80
|
+
seen.length = 0;
|
|
81
|
+
assert.deepEqual(await stack.deploy(row.declare('X', '15m')), { X: 'create' });
|
|
82
|
+
assert.deepEqual(writesOf(seen), []);
|
|
83
|
+
}, fault.wrap);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('Bao.Mount: remountFrom on a create', () => {
|
|
89
|
+
const moved = BaoMount('Kv', { path: 'kv-moved', remountFrom: 'kv', type: 'kv' });
|
|
90
|
+
const SOURCE = /Bao\.Mount kv \(the remountFrom source\): already exists/;
|
|
91
|
+
|
|
92
|
+
it('moving a live mount this stack holds no state for is refused before the move', async () => {
|
|
93
|
+
await withCore(async (stack, estate, seen, owner) => {
|
|
94
|
+
await owner.deploy(BaoMount('Src', { path: 'kv', type: 'kv' }));
|
|
95
|
+
seen.length = 0;
|
|
96
|
+
// ★ The probe reads only the target, which is free: the plan says create.
|
|
97
|
+
await assert.rejects(stack.deploy(moved), SOURCE);
|
|
98
|
+
assert.deepEqual(writesOf(seen), []);
|
|
99
|
+
assert.deepEqual([...estate.mounts.table.keys()], ['kv/']);
|
|
100
|
+
assert.deepEqual(await stack.deploy(moved, { adopt: true }), { Kv: 'create' });
|
|
101
|
+
assert.deepEqual([...estate.mounts.table.keys()], ['kv-moved/']);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('Bao.Mount: a create interrupted between its enable and its tune', () => {
|
|
107
|
+
it('is not proven ours — the mount is not what was declared — so it resumes with --adopt', async () => {
|
|
108
|
+
const fault = crashAfterWrite();
|
|
109
|
+
const kv = BaoMount('Kv', { defaultLeaseTtl: '15m', path: 'kv', type: 'kv' });
|
|
110
|
+
await withCore(async (stack, estate) => {
|
|
111
|
+
fault.crashNext('write');
|
|
112
|
+
await assert.rejects(stack.deploy(kv), /injected/);
|
|
113
|
+
assert.ok(estate.mounts.table.has('kv/'));
|
|
114
|
+
await assert.rejects(stack.deploy(kv), /Cannot resume creating resource 'Kv'.*--adopt/s);
|
|
115
|
+
assert.deepEqual(await stack.deploy(kv, { adopt: true }), { Kv: 'create' });
|
|
116
|
+
assert.deepEqual(await stack.deploy(kv), { Kv: 'noop' });
|
|
117
|
+
}, fault.wrap);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOTHING IS ADOPTED WITHOUT `--adopt` (decided 2026-09-21), for the nine batch-2 families, through
|
|
3
|
+
* Alchemy's own plan and apply (fake-stack.ts). The object is put there by ANOTHER stack over the
|
|
4
|
+
* same fake (`owner`) — the case the rule exists for: identical to this declaration, and still
|
|
5
|
+
* not ours.
|
|
6
|
+
* · plan time: the adoption probe answers `Unowned` (ownership/probe.ts);
|
|
7
|
+
* · apply time: where the probe never ran — a prop still an Output — reconcile refuses before any
|
|
8
|
+
* write (ownership/adopt.ts).
|
|
9
|
+
* adopt-recovery.test.ts pins the other side: our own interrupted create is still resumed.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
12
|
+
*/
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { describe, it } from 'node:test';
|
|
15
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import type { Seen } from './fake-bao.ts';
|
|
18
|
+
import { FAMILIES, type Family, upstream, withEstate } from './fake-families.ts';
|
|
19
|
+
import { writesOf } from './fake-stack.ts';
|
|
20
|
+
|
|
21
|
+
const NOT_OWNED = /Cannot adopt resource 'Z'.*not owned by this stack.*--adopt/s;
|
|
22
|
+
const TAKEOVER = /: already exists, and this stack holds no state for it.*Nothing was written/s;
|
|
23
|
+
const STILL_REFUSED = /Cannot adopt resource 'Z'.*--adopt/s;
|
|
24
|
+
|
|
25
|
+
/** `Z` names `a`, with its knob an Output until apply: the upstream AppRole is created with it. */
|
|
26
|
+
const pending = (row: Family, scoped?: boolean) =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const up = yield* upstream('15m');
|
|
29
|
+
const declared = row.declare('Z', 'a', up.tokenTtl);
|
|
30
|
+
yield* scoped === undefined ? declared : declared.pipe(adopt(scoped));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Writes other than the upstream AppRole's own create. */
|
|
34
|
+
const writesToA = (seen: readonly Seen[]) =>
|
|
35
|
+
writesOf(seen).filter((line) => !line.endsWith('/upstream'));
|
|
36
|
+
|
|
37
|
+
for (const row of FAMILIES) {
|
|
38
|
+
describe(`${row.family}: a live object another stack owns`, () => {
|
|
39
|
+
it('identical to the declaration, still fails the plan; nothing is written', async () => {
|
|
40
|
+
await withEstate(async (stack, estate, seen, owner) => {
|
|
41
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
42
|
+
seen.length = 0;
|
|
43
|
+
await assert.rejects(stack.deploy(row.declare('Z', 'a', '15m')), NOT_OWNED);
|
|
44
|
+
assert.deepEqual(writesOf(seen), []);
|
|
45
|
+
assert.ok(row.has(estate, 'a'));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is adopted with --adopt, and with adopt(true) — identical, so nothing is written', async () => {
|
|
50
|
+
for (const deploy of ['flag', 'scope'] as const) {
|
|
51
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
52
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
53
|
+
seen.length = 0;
|
|
54
|
+
const body =
|
|
55
|
+
deploy === 'flag'
|
|
56
|
+
? stack.deploy(row.declare('Z', 'a', '15m'), { adopt: true })
|
|
57
|
+
: stack.deploy(row.declare('Z', 'a', '15m').pipe(adopt(true)));
|
|
58
|
+
assert.deepEqual(await body, { Z: 'adopted' });
|
|
59
|
+
assert.deepEqual(writesOf(seen), []);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('with a prop still an Output, the plan cannot ask, so the apply refuses', async () => {
|
|
65
|
+
await withEstate(async (stack, estate, seen, owner) => {
|
|
66
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
67
|
+
seen.length = 0;
|
|
68
|
+
await assert.rejects(stack.deploy(pending(row)), TAKEOVER);
|
|
69
|
+
assert.deepEqual(writesToA(seen), []);
|
|
70
|
+
assert.ok(row.has(estate, 'a'));
|
|
71
|
+
// ★ The refused create forgot its `creating` row, so the next plan probes: still refused.
|
|
72
|
+
await assert.rejects(stack.deploy(pending(row)), STILL_REFUSED);
|
|
73
|
+
assert.deepEqual(writesToA(seen), []);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('with a prop still an Output, --adopt and adopt(true) take it over at apply', async () => {
|
|
78
|
+
for (const deploy of ['flag', 'scope'] as const) {
|
|
79
|
+
await withEstate(async (stack, _estate, _seen, owner) => {
|
|
80
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
81
|
+
const planned =
|
|
82
|
+
deploy === 'flag'
|
|
83
|
+
? await stack.deploy(pending(row), { adopt: true })
|
|
84
|
+
: await stack.deploy(pending(row, true));
|
|
85
|
+
assert.deepEqual(planned, { Upstream: 'create', Z: 'create' });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('adopt(false) on the resource wins over --adopt, at apply as at plan', async () => {
|
|
91
|
+
await withEstate(async (stack, _estate, _seen, owner) => {
|
|
92
|
+
await owner.deploy(row.declare('X', 'a', '15m'));
|
|
93
|
+
await assert.rejects(stack.deploy(pending(row, false), { adopt: true }), TAKEOVER);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deploy killed BEFORE a create's claim ran leaves a `creating` (or `replacing`) row that proves
|
|
3
|
+
* nothing: its create never asked whose object sat at its identity. Found by the red team on
|
|
4
|
+
* 2026-09-21, through Alchemy's own plan and apply, before ownership/whole.ts and the proof in
|
|
5
|
+
* ownership/resume.ts:
|
|
6
|
+
* · an Output IDENTITY — every role family read an empty name, found nothing, "resumed" the create
|
|
7
|
+
* and wrote over another owner's object;
|
|
8
|
+
* · an Output KNOB — Bao.Mount and Bao.AuthMethod read the hole as "not managed", adopted another
|
|
9
|
+
* owner's mount in the recovery read and tuned it;
|
|
10
|
+
* · a REPLACE killed before its write — the next deploy wrote over whatever another owner had put
|
|
11
|
+
* at the new identity since.
|
|
12
|
+
* Each is now refused, writing nothing; `--adopt` resumes it, as Alchemy offers for a create.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
15
|
+
*/
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { describe, it } from 'node:test';
|
|
18
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import { BaoAuthRole } from './auth-role.ts';
|
|
21
|
+
import { type Seen, refuseWriteOnce } from './fake-bao.ts';
|
|
22
|
+
import { CORE, type CoreRow, withCore } from './fake-core.ts';
|
|
23
|
+
import { FAMILIES, type Family, upstream, withEstate } from './fake-families.ts';
|
|
24
|
+
import { writesOf } from './fake-stack.ts';
|
|
25
|
+
import { BaoMount } from './mount.ts';
|
|
26
|
+
import { BaoPkiRole } from './pki-role.ts';
|
|
27
|
+
|
|
28
|
+
const REFUSED =
|
|
29
|
+
/Cannot resume creating resource 'Z'|: already exists, and this stack holds no state/s;
|
|
30
|
+
const RESUME_REFUSED = /already exists, and this stack holds no state.*Deploy with --adopt/s;
|
|
31
|
+
|
|
32
|
+
/** Writes other than the upstream's. */
|
|
33
|
+
const writesBut = (seen: readonly Seen[], upstreamPath: string) =>
|
|
34
|
+
writesOf(seen).filter((line) => !line.endsWith(upstreamPath));
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `Z`'s NAME is an Output: an upstream named `a` hands it over. An AppRole, or — for Bao.AuthRole,
|
|
38
|
+
* whose own `a` that would be — a PKI role.
|
|
39
|
+
*/
|
|
40
|
+
const namedByUpstream = (row: Family) => {
|
|
41
|
+
const approle = row.family !== 'Bao.AuthRole';
|
|
42
|
+
const path = approle ? '/auth/approle/role/a' : '/pki/roles/a';
|
|
43
|
+
const body = Effect.gen(function* () {
|
|
44
|
+
const up = approle
|
|
45
|
+
? yield* BaoAuthRole('Upstream', {
|
|
46
|
+
name: 'a',
|
|
47
|
+
secretIdTtl: '24h',
|
|
48
|
+
tokenMaxTtl: '1h',
|
|
49
|
+
tokenPolicies: ['default'],
|
|
50
|
+
tokenTtl: '15m',
|
|
51
|
+
})
|
|
52
|
+
: yield* BaoPkiRole('Upstream', {
|
|
53
|
+
allowedDomains: ['x.test'],
|
|
54
|
+
maxTtl: '1h',
|
|
55
|
+
name: 'a',
|
|
56
|
+
ttl: '30m',
|
|
57
|
+
});
|
|
58
|
+
yield* row.declare('Z', up.name, '15m');
|
|
59
|
+
});
|
|
60
|
+
return { body, path };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
for (const row of FAMILIES) {
|
|
64
|
+
describe(`${row.family}: a create killed before its claim, its name an Output`, () => {
|
|
65
|
+
it('never writes over the object another stack owns; --adopt takes it over', async () => {
|
|
66
|
+
const { body, path } = namedByUpstream(row);
|
|
67
|
+
const fault = refuseWriteOnce(path);
|
|
68
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
69
|
+
await owner.deploy(row.declare('X', 'a', '30m'));
|
|
70
|
+
await assert.rejects(stack.deploy(body), /injected/);
|
|
71
|
+
seen.length = 0;
|
|
72
|
+
await assert.rejects(stack.deploy(body), REFUSED);
|
|
73
|
+
assert.deepEqual(writesBut(seen, path), []);
|
|
74
|
+
await stack.deploy(body, { adopt: true });
|
|
75
|
+
}, fault);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe(`${row.family}: a create killed before its claim, a knob an Output`, () => {
|
|
80
|
+
it('never adopts the object another stack owns', async () => {
|
|
81
|
+
const fault = refuseWriteOnce('/upstream');
|
|
82
|
+
const body = Effect.gen(function* () {
|
|
83
|
+
yield* row.declare('Z', 'a', (yield* upstream('15m')).tokenTtl);
|
|
84
|
+
});
|
|
85
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
86
|
+
await owner.deploy(row.declare('X', 'a', '30m'));
|
|
87
|
+
await assert.rejects(stack.deploy(body), /injected/);
|
|
88
|
+
seen.length = 0;
|
|
89
|
+
await assert.rejects(stack.deploy(body), REFUSED);
|
|
90
|
+
assert.deepEqual(writesBut(seen, '/upstream'), []);
|
|
91
|
+
}, fault);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `Z` at the row's fixed identity, its knob an upstream AppRole's `tokenTtl`. */
|
|
97
|
+
const pendingCore = (row: CoreRow) =>
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const up = yield* BaoAuthRole('Upstream', {
|
|
100
|
+
name: 'upstream',
|
|
101
|
+
secretIdTtl: '24h',
|
|
102
|
+
tokenMaxTtl: '1h',
|
|
103
|
+
tokenPolicies: ['default'],
|
|
104
|
+
tokenTtl: '15m',
|
|
105
|
+
});
|
|
106
|
+
yield* row.declare('Z', up.tokenTtl);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
for (const row of CORE) {
|
|
110
|
+
describe(`${row.family}: a create killed before its claim, a knob an Output`, () => {
|
|
111
|
+
it('never adopts — or tunes — the object another stack owns; --adopt takes it over', async () => {
|
|
112
|
+
const fault = refuseWriteOnce('/upstream');
|
|
113
|
+
await withCore(async (stack, _estate, seen, owner) => {
|
|
114
|
+
await owner.deploy(row.declare('X', '30m'));
|
|
115
|
+
await assert.rejects(stack.deploy(pendingCore(row)), /injected/);
|
|
116
|
+
seen.length = 0;
|
|
117
|
+
await assert.rejects(stack.deploy(pendingCore(row)), REFUSED);
|
|
118
|
+
assert.deepEqual(writesBut(seen, '/upstream'), []);
|
|
119
|
+
await stack.deploy(pendingCore(row), { adopt: true });
|
|
120
|
+
}, fault);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('Bao.Mount: a create killed before its claim, its path an Output', () => {
|
|
126
|
+
it('never tunes the mount another stack owns; --adopt takes it over', async () => {
|
|
127
|
+
const fault = refuseWriteOnce('/auth/approle/role/kv');
|
|
128
|
+
const body = Effect.gen(function* () {
|
|
129
|
+
const up = yield* BaoAuthRole('Upstream', {
|
|
130
|
+
name: 'kv',
|
|
131
|
+
secretIdTtl: '24h',
|
|
132
|
+
tokenMaxTtl: '1h',
|
|
133
|
+
tokenPolicies: ['default'],
|
|
134
|
+
tokenTtl: '15m',
|
|
135
|
+
});
|
|
136
|
+
yield* BaoMount('Z', { defaultLeaseTtl: '15m', path: up.name, type: 'kv' });
|
|
137
|
+
});
|
|
138
|
+
await withCore(async (stack, _estate, seen, owner) => {
|
|
139
|
+
await owner.deploy(BaoMount('X', { defaultLeaseTtl: '30m', path: 'kv', type: 'kv' }));
|
|
140
|
+
await assert.rejects(stack.deploy(body), /injected/);
|
|
141
|
+
seen.length = 0;
|
|
142
|
+
await assert.rejects(stack.deploy(body), REFUSED);
|
|
143
|
+
assert.deepEqual(writesBut(seen, '/auth/approle/role/kv'), []);
|
|
144
|
+
await stack.deploy(body, { adopt: true });
|
|
145
|
+
}, fault);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Bao.MfaTotpMethod never renames (mfa-totp.ts), so it has no replace to interrupt. */
|
|
150
|
+
for (const row of FAMILIES.filter((each) => each.family !== 'Bao.MfaTotpMethod')) {
|
|
151
|
+
describe(`${row.family}: a replace killed before its write`, () => {
|
|
152
|
+
it('never writes over what another stack put at the new identity since; --adopt does', async () => {
|
|
153
|
+
const suffix = row.family === 'Bao.JwtAuthConfig' ? '/b/config' : '/b';
|
|
154
|
+
const fault = refuseWriteOnce(suffix);
|
|
155
|
+
const { retain } = RemovalPolicy;
|
|
156
|
+
await withEstate(async (stack, _estate, seen, owner) => {
|
|
157
|
+
await stack.deploy(row.declare('X', 'a', '15m', retain));
|
|
158
|
+
await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), /injected/);
|
|
159
|
+
await owner.deploy(row.declare('O', 'b', '30m'));
|
|
160
|
+
seen.length = 0;
|
|
161
|
+
await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), RESUME_REFUSED);
|
|
162
|
+
assert.deepEqual(writesOf(seen), []);
|
|
163
|
+
await stack.deploy(row.declare('X', 'b', '15m', retain), { adopt: true });
|
|
164
|
+
assert.notDeepEqual(writesOf(seen), []);
|
|
165
|
+
}, fault);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash recovery survives the ownership rule: a deploy that dies after its write and before its
|
|
3
|
+
* commit leaves a `creating` (or `replacing`) row and a live object, and the next deploy must
|
|
4
|
+
* finish it WITHOUT `--adopt` — the object is ours (ownership/probe.ts, ownership/resume.ts).
|
|
5
|
+
* The crash is injected: the first read after a write answers 500, which is exactly a reconcile
|
|
6
|
+
* whose write landed and whose read-back, and so its commit, never did.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
9
|
+
*/
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
13
|
+
import { type Seen, crashAfterWrite } from './fake-bao.ts';
|
|
14
|
+
import type { Estate } from './fake-engines-roles.ts';
|
|
15
|
+
import { FAMILIES, withEstate } from './fake-families.ts';
|
|
16
|
+
import { type FakeStack, writesOf } from './fake-stack.ts';
|
|
17
|
+
|
|
18
|
+
const RESUME_REFUSED = /Cannot resume creating resource 'X'.*--adopt/s;
|
|
19
|
+
|
|
20
|
+
type Run = (stack: FakeStack, estate: Estate, seen: Seen[], owner: FakeStack) => Promise<void>;
|
|
21
|
+
const withCrashes = (body: (crashNext: () => void) => Run) => {
|
|
22
|
+
const fault = crashAfterWrite();
|
|
23
|
+
return withEstate(body(fault.crashNext), fault.wrap);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
for (const row of FAMILIES) {
|
|
27
|
+
describe(`${row.family}: an interrupted create`, () => {
|
|
28
|
+
it('is resumed without --adopt when the object is what it wrote; nothing is rewritten', async () => {
|
|
29
|
+
await withCrashes((crashNext) => async (stack, estate, seen) => {
|
|
30
|
+
crashNext();
|
|
31
|
+
await assert.rejects(stack.deploy(row.declare('X', 'a', '15m')), /injected/);
|
|
32
|
+
assert.ok(row.has(estate, 'a'));
|
|
33
|
+
seen.length = 0;
|
|
34
|
+
assert.deepEqual(await stack.deploy(row.declare('X', 'a', '15m')), { X: 'create' });
|
|
35
|
+
assert.deepEqual(writesOf(seen), []);
|
|
36
|
+
assert.deepEqual(await stack.deploy(row.declare('X', 'a', '15m')), { X: 'noop' });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('is refused when the object has since changed, and resumed with --adopt', async () => {
|
|
41
|
+
await withCrashes((crashNext) => async (stack, _estate, _seen, owner) => {
|
|
42
|
+
crashNext();
|
|
43
|
+
await assert.rejects(stack.deploy(row.declare('X', 'a', '15m')), /injected/);
|
|
44
|
+
// ★ Someone else rewrites it in between: another stack, taking it over with --adopt.
|
|
45
|
+
await owner.deploy(row.declare('O', 'a', '30m'), { adopt: true });
|
|
46
|
+
await assert.rejects(stack.deploy(row.declare('X', 'a', '15m')), RESUME_REFUSED);
|
|
47
|
+
const resumed = await stack.deploy(row.declare('X', 'a', '15m'), { adopt: true });
|
|
48
|
+
assert.deepEqual(resumed, { X: 'create' });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Bao.MfaTotpMethod never renames (mfa-totp.ts), so it has no replace to interrupt. */
|
|
55
|
+
for (const row of FAMILIES.filter((each) => each.family !== 'Bao.MfaTotpMethod')) {
|
|
56
|
+
describe(`${row.family}: an interrupted replace`, () => {
|
|
57
|
+
it('is resumed without --adopt: the new generation it wrote is not taken over', async () => {
|
|
58
|
+
await withCrashes((crashNext) => async (stack, estate) => {
|
|
59
|
+
const { retain } = RemovalPolicy;
|
|
60
|
+
await stack.deploy(row.declare('X', 'a', '15m', retain));
|
|
61
|
+
crashNext();
|
|
62
|
+
await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), /injected/);
|
|
63
|
+
assert.ok(row.has(estate, 'b'));
|
|
64
|
+
assert.deepEqual(await stack.deploy(row.declare('X', 'b', '15m', retain)), {
|
|
65
|
+
X: 'replace',
|
|
66
|
+
});
|
|
67
|
+
assert.deepEqual(await stack.deploy(row.declare('X', 'b', '15m', retain)), { X: 'noop' });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as Effect from 'effect/Effect';
|
|
8
8
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
9
|
+
import type { Claim } from '../ownership/adopt.ts';
|
|
9
10
|
import {
|
|
10
11
|
type BaoAuthMethodAttributes,
|
|
11
12
|
type BaoAuthMethodProps,
|
|
@@ -36,22 +37,32 @@ const moveTarget = (props: BaoAuthMethodProps): MoveTarget => ({
|
|
|
36
37
|
wire: (path) => `auth/${authPath(path)}`,
|
|
37
38
|
});
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Converge one auth method. `stated` is the path in Alchemy's state, if any.
|
|
42
|
+
* ⛔ `claim`: the same first-reconcile takeover refusal as reconcileMount's (auth-method.ts passes it).
|
|
43
|
+
*/
|
|
40
44
|
export const reconcileAuthMethod = (
|
|
41
45
|
news: BaoAuthMethodProps,
|
|
42
46
|
stated: string | undefined,
|
|
43
47
|
options?: RemountOptions,
|
|
48
|
+
claim?: Claim,
|
|
44
49
|
): Effect.Effect<BaoAuthMethodAttributes, BaoError | RemountError, HttpClient.HttpClient> =>
|
|
45
50
|
Effect.gen(function* () {
|
|
46
51
|
const move = planMove('Bao.AuthMethod', stated, news.path, news.remountFrom);
|
|
47
52
|
if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
|
|
53
|
+
const first = stated === undefined ? claim : undefined;
|
|
54
|
+
if (first !== undefined && move.kind === 'move') {
|
|
55
|
+
const source = yield* readMethod({ ...news, path: move.from });
|
|
56
|
+
if (source !== undefined)
|
|
57
|
+
yield* first(`Bao.AuthMethod ${move.from} (the remountFrom source)`);
|
|
58
|
+
}
|
|
48
59
|
if (move.kind === 'move') yield* performMove(moveTarget(news), move.from, options);
|
|
49
60
|
|
|
50
61
|
let live = yield* readMethod(news);
|
|
51
62
|
if (live === undefined) {
|
|
52
63
|
yield* enableAuthMethod(news);
|
|
53
64
|
if (wantsTune(news)) yield* tuneAuthMethod(news);
|
|
54
|
-
} else
|
|
65
|
+
} else {
|
|
55
66
|
if (live.type !== news.type) {
|
|
56
67
|
return yield* Effect.die(
|
|
57
68
|
new Error(
|
|
@@ -60,7 +71,8 @@ export const reconcileAuthMethod = (
|
|
|
60
71
|
),
|
|
61
72
|
);
|
|
62
73
|
}
|
|
63
|
-
yield*
|
|
74
|
+
if (first !== undefined) yield* first(`Bao.AuthMethod ${authPath(news.path)}`);
|
|
75
|
+
if (!matches(live, news)) yield* tuneAuthMethod(news);
|
|
64
76
|
}
|
|
65
77
|
live = yield* readMethod(news);
|
|
66
78
|
if (live === undefined) {
|