@serfab/cadre-host 1.13.0 → 1.15.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 +347 -423
- package/dist/bin/host.d.ts +6 -3
- package/dist/bin/host.js +332 -524
- package/dist/bin/host.js.map +1 -1
- package/dist/bin/join-output.d.ts +56 -0
- package/dist/bin/join-output.js +92 -0
- package/dist/bin/join-output.js.map +1 -0
- package/dist/bin/nat-output.d.ts +54 -0
- package/dist/bin/nat-output.js +138 -0
- package/dist/bin/nat-output.js.map +1 -0
- package/dist/bin/ui-access.d.ts +22 -0
- package/dist/bin/ui-access.js +44 -0
- package/dist/bin/ui-access.js.map +1 -0
- package/dist/hosted/claim-details.d.ts +66 -0
- package/dist/hosted/claim-details.js +60 -0
- package/dist/hosted/claim-details.js.map +1 -0
- package/dist/hosted/hosted-node-service.d.ts +325 -0
- package/dist/hosted/hosted-node-service.js +678 -0
- package/dist/hosted/hosted-node-service.js.map +1 -0
- package/dist/hosted/hosted-node-store.d.ts +40 -0
- package/dist/hosted/hosted-node-store.js +126 -0
- package/dist/hosted/hosted-node-store.js.map +1 -0
- package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
- package/dist/hosted/hosted-node-supervisor.js +396 -0
- package/dist/hosted/hosted-node-supervisor.js.map +1 -0
- package/dist/hosted/hosted-node-watcher.d.ts +95 -0
- package/dist/hosted/hosted-node-watcher.js +260 -0
- package/dist/hosted/hosted-node-watcher.js.map +1 -0
- package/dist/hosted/index.d.ts +16 -0
- package/dist/hosted/index.js +13 -0
- package/dist/hosted/index.js.map +1 -0
- package/dist/hosted/node-status.d.ts +51 -0
- package/dist/hosted/node-status.js +77 -0
- package/dist/hosted/node-status.js.map +1 -0
- package/dist/hosted/types.d.ts +141 -0
- package/dist/hosted/types.js +22 -0
- package/dist/hosted/types.js.map +1 -0
- package/dist/index.d.ts +12 -24
- package/dist/index.js +9 -19
- package/dist/index.js.map +1 -1
- package/dist/installer/config.d.ts +27 -34
- package/dist/installer/config.js +46 -15
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +9 -19
- package/dist/installer/index.js +21 -85
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/paths.d.ts +0 -2
- package/dist/installer/paths.js +0 -4
- package/dist/installer/paths.js.map +1 -1
- package/dist/installer/wizard.d.ts +12 -9
- package/dist/installer/wizard.js +15 -14
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/address-resolver.d.ts +18 -17
- package/dist/nat/address-resolver.js +79 -25
- package/dist/nat/address-resolver.js.map +1 -1
- package/dist/nat/address-watch.d.ts +37 -0
- package/dist/nat/address-watch.js +84 -0
- package/dist/nat/address-watch.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +2 -1
- package/dist/nat/ddns/duckdns.js +5 -1
- package/dist/nat/ddns/duckdns.js.map +1 -1
- package/dist/nat/ddns/index.d.ts +6 -0
- package/dist/nat/ddns/index.js.map +1 -1
- package/dist/nat/index.d.ts +13 -9
- package/dist/nat/index.js +7 -5
- package/dist/nat/index.js.map +1 -1
- package/dist/nat/nat-service.d.ts +220 -77
- package/dist/nat/nat-service.js +653 -239
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/nat-store.d.ts +12 -5
- package/dist/nat/nat-store.js +124 -29
- package/dist/nat/nat-store.js.map +1 -1
- package/dist/nat/network-mode.d.ts +19 -0
- package/dist/nat/network-mode.js +32 -0
- package/dist/nat/network-mode.js.map +1 -0
- package/dist/nat/port-mapper.d.ts +83 -79
- package/dist/nat/port-mapper.js +148 -189
- package/dist/nat/port-mapper.js.map +1 -1
- package/dist/nat/reachability.d.ts +32 -15
- package/dist/nat/reachability.js +110 -22
- package/dist/nat/reachability.js.map +1 -1
- package/dist/nat/types.d.ts +98 -27
- package/dist/nat/types.js +8 -5
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +32 -74
- package/dist/orchestrator/host-process-orchestrator.js +84 -233
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
- package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
- package/dist/orchestrator/identity-file.js.map +1 -0
- package/dist/orchestrator/index.d.ts +2 -3
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +6 -6
- package/dist/orchestrator/node-identity.js +7 -7
- package/dist/orchestrator/node-identity.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +5 -6
- package/dist/orchestrator/port-allocator.js +9 -11
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +3 -5
- package/dist/orchestrator/state-store.js +2 -10
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +33 -22
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/package-version.d.ts +1 -0
- package/dist/package-version.js +4 -0
- package/dist/package-version.js.map +1 -0
- package/dist/server/error-handler.d.ts +5 -25
- package/dist/server/error-handler.js +11 -74
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +7 -11
- package/dist/server/events/types.js +2 -1
- package/dist/server/events/types.js.map +1 -1
- package/dist/server/index.d.ts +11 -37
- package/dist/server/index.js +30 -48
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/hosted-nodes.d.ts +28 -0
- package/dist/server/routes/hosted-nodes.js +68 -0
- package/dist/server/routes/hosted-nodes.js.map +1 -0
- package/dist/server/routes/nat.d.ts +4 -1
- package/dist/server/routes/nat.js +13 -2
- package/dist/server/routes/nat.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +6 -20
- package/dist/server/routes/nodes.js +7 -86
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +3 -7
- package/dist/server/routes/settings.js +18 -8
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +2 -25
- package/dist/server/routes/status.js +3 -43
- package/dist/server/routes/status.js.map +1 -1
- package/dist/server/server.js +8 -8
- package/dist/server/settings-store.d.ts +2 -2
- package/dist/server/settings-store.js +2 -2
- package/dist/server/static.js +1 -2
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BHd8CWZA.css +1 -0
- package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
- package/dist/ui/assets/index-Ccr2GjPT.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/update/index.d.ts +2 -2
- package/dist/update/index.js +2 -2
- package/dist/update/store.d.ts +1 -1
- package/dist/update/store.js +1 -1
- package/dist/update/types.d.ts +1 -1
- package/dist/update/types.js +1 -1
- package/package.json +5 -7
- package/src/__tests__/cli-nat.smoke.test.ts +219 -212
- package/src/__tests__/cli.smoke.test.ts +2 -2
- package/src/__tests__/global-setup.ts +2 -3
- package/src/__tests__/orchestrator-claim-secret.test.ts +111 -0
- package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
- package/src/__tests__/orchestrator-handle-contract.ts +134 -134
- package/src/__tests__/orchestrator-node-identity.test.ts +177 -203
- package/src/__tests__/orchestrator-ports.test.ts +124 -143
- package/src/__tests__/orchestrator-push.test.ts +138 -146
- package/src/__tests__/orchestrator.test.ts +1017 -1063
- package/src/__tests__/ui-access.test.ts +30 -0
- package/src/bin/host.ts +397 -572
- package/src/bin/join-output.ts +120 -0
- package/src/bin/nat-output.ts +188 -0
- package/src/bin/ui-access.ts +50 -0
- package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +172 -173
- package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +43 -23
- package/src/hosted/__tests__/hosted-node-service.test.ts +708 -0
- package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -0
- package/src/hosted/__tests__/test-invitation.ts +33 -0
- package/src/hosted/claim-details.ts +103 -0
- package/src/hosted/hosted-node-service.ts +814 -0
- package/src/hosted/hosted-node-store.ts +144 -0
- package/src/hosted/hosted-node-supervisor.ts +439 -0
- package/src/hosted/hosted-node-watcher.ts +288 -0
- package/src/hosted/index.ts +53 -0
- package/src/hosted/node-status.ts +97 -0
- package/src/hosted/types.ts +160 -0
- package/src/index.ts +72 -91
- package/src/installer/__tests__/config.test.ts +15 -40
- package/src/installer/__tests__/installer.smoke.test.ts +184 -263
- package/src/installer/__tests__/wizard.test.ts +25 -13
- package/src/installer/config.ts +201 -175
- package/src/installer/index.ts +252 -327
- package/src/installer/paths.ts +104 -109
- package/src/installer/wizard.ts +24 -26
- package/src/nat/__tests__/address-resolver.test.ts +107 -82
- package/src/nat/__tests__/nat-service.test.ts +608 -515
- package/src/nat/__tests__/nat-store.test.ts +49 -12
- package/src/nat/__tests__/network-mode.test.ts +38 -0
- package/src/nat/__tests__/port-mapper.test.ts +30 -191
- package/src/nat/__tests__/reachability.test.ts +96 -13
- package/src/nat/address-resolver.ts +94 -52
- package/src/nat/address-watch.ts +92 -0
- package/src/nat/ddns/duckdns.ts +5 -1
- package/src/nat/ddns/index.ts +6 -0
- package/src/nat/index.ts +33 -13
- package/src/nat/nat-service.ts +942 -501
- package/src/nat/nat-store.ts +126 -31
- package/src/nat/network-mode.ts +36 -0
- package/src/nat/port-mapper.ts +241 -329
- package/src/nat/reachability.ts +155 -34
- package/src/nat/types.ts +110 -37
- package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +1089 -1260
- package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
- package/src/orchestrator/index.ts +9 -10
- package/src/orchestrator/node-identity.ts +41 -41
- package/src/orchestrator/port-allocator.ts +71 -73
- package/src/orchestrator/state-store.ts +76 -90
- package/src/orchestrator/types.ts +34 -23
- package/src/package-version.ts +4 -0
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +81 -118
- package/src/server/__tests__/fakes.ts +15 -18
- package/src/server/__tests__/hosted-nodes-route.test.ts +161 -0
- package/src/server/__tests__/nodes-route.test.ts +114 -283
- package/src/server/__tests__/publishers.test.ts +193 -180
- package/src/server/__tests__/server.smoke.test.ts +134 -223
- package/src/server/__tests__/settings-route.test.ts +210 -195
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/status-route.test.ts +82 -121
- package/src/server/error-handler.ts +112 -178
- package/src/server/events/types.ts +20 -24
- package/src/server/index.ts +223 -267
- package/src/server/routes/hosted-nodes.ts +83 -0
- package/src/server/routes/nat.ts +75 -63
- package/src/server/routes/nodes.ts +96 -188
- package/src/server/routes/settings.ts +20 -15
- package/src/server/routes/status.ts +5 -67
- package/src/server/server.ts +8 -8
- package/src/server/settings-store.ts +2 -2
- package/src/server/static.ts +1 -2
- package/src/update/index.ts +2 -2
- package/src/update/store.ts +1 -1
- package/src/update/types.ts +1 -1
- package/dist/auth/duration.d.ts +0 -1
- package/dist/auth/duration.js +0 -32
- package/dist/auth/duration.js.map +0 -1
- package/dist/auth/index.d.ts +0 -11
- package/dist/auth/index.js +0 -9
- package/dist/auth/index.js.map +0 -1
- package/dist/auth/self-label.d.ts +0 -36
- package/dist/auth/self-label.js +0 -57
- package/dist/auth/self-label.js.map +0 -1
- package/dist/auth/trust-circle-store.d.ts +0 -26
- package/dist/auth/trust-circle-store.js +0 -129
- package/dist/auth/trust-circle-store.js.map +0 -1
- package/dist/auth/trust-circle.d.ts +0 -156
- package/dist/auth/trust-circle.js +0 -307
- package/dist/auth/trust-circle.js.map +0 -1
- package/dist/auth/types.d.ts +0 -84
- package/dist/auth/types.js +0 -24
- package/dist/auth/types.js.map +0 -1
- package/dist/donation/donation-service.d.ts +0 -389
- package/dist/donation/donation-service.js +0 -828
- package/dist/donation/donation-service.js.map +0 -1
- package/dist/donation/donation-store.d.ts +0 -51
- package/dist/donation/donation-store.js +0 -152
- package/dist/donation/donation-store.js.map +0 -1
- package/dist/donation/donation-supervisor.js +0 -364
- package/dist/donation/donation-supervisor.js.map +0 -1
- package/dist/donation/grant-service.d.ts +0 -80
- package/dist/donation/grant-service.js +0 -210
- package/dist/donation/grant-service.js.map +0 -1
- package/dist/donation/grant-store.d.ts +0 -38
- package/dist/donation/grant-store.js +0 -135
- package/dist/donation/grant-store.js.map +0 -1
- package/dist/donation/index.d.ts +0 -20
- package/dist/donation/index.js +0 -17
- package/dist/donation/index.js.map +0 -1
- package/dist/donation/types.d.ts +0 -224
- package/dist/donation/types.js +0 -40
- package/dist/donation/types.js.map +0 -1
- package/dist/installer/identity.js.map +0 -1
- package/dist/owner/index.d.ts +0 -11
- package/dist/owner/index.js +0 -11
- package/dist/owner/index.js.map +0 -1
- package/dist/owner/owner-node-client.d.ts +0 -110
- package/dist/owner/owner-node-client.js +0 -184
- package/dist/owner/owner-node-client.js.map +0 -1
- package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
- package/dist/server/routes/bootstrap-node-validation.js +0 -197
- package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
- package/dist/server/routes/grants-admin.d.ts +0 -29
- package/dist/server/routes/grants-admin.js +0 -58
- package/dist/server/routes/grants-admin.js.map +0 -1
- package/dist/server/routes/grants.d.ts +0 -28
- package/dist/server/routes/grants.js +0 -165
- package/dist/server/routes/grants.js.map +0 -1
- package/dist/server/routes/provision-request-validation.d.ts +0 -45
- package/dist/server/routes/provision-request-validation.js +0 -97
- package/dist/server/routes/provision-request-validation.js.map +0 -1
- package/dist/server/routes/strands.d.ts +0 -20
- package/dist/server/routes/strands.js +0 -38
- package/dist/server/routes/strands.js.map +0 -1
- package/dist/server/routes/trust-circle.d.ts +0 -17
- package/dist/server/routes/trust-circle.js +0 -35
- package/dist/server/routes/trust-circle.js.map +0 -1
- package/dist/strands/index.d.ts +0 -7
- package/dist/strands/index.js +0 -6
- package/dist/strands/index.js.map +0 -1
- package/dist/strands/strand-service.d.ts +0 -50
- package/dist/strands/strand-service.js +0 -103
- package/dist/strands/strand-service.js.map +0 -1
- package/dist/strands/types.d.ts +0 -93
- package/dist/strands/types.js +0 -23
- package/dist/strands/types.js.map +0 -1
- package/dist/ui/assets/index-C6nELNfC.js +0 -23
- package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
- package/dist/ui/assets/index-TtIYSeZO.css +0 -1
- package/src/__tests__/cli-invite.smoke.test.ts +0 -192
- package/src/__tests__/host-owner.smoke.test.ts +0 -123
- package/src/__tests__/orchestrator-owner.test.ts +0 -234
- package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
- package/src/auth/__tests__/duration.test.ts +0 -28
- package/src/auth/__tests__/self-label.test.ts +0 -85
- package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
- package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
- package/src/auth/__tests__/trust-circle.test.ts +0 -445
- package/src/auth/duration.ts +0 -33
- package/src/auth/index.ts +0 -21
- package/src/auth/self-label.ts +0 -74
- package/src/auth/trust-circle-store.ts +0 -155
- package/src/auth/trust-circle.ts +0 -395
- package/src/auth/types.ts +0 -99
- package/src/donation/__tests__/donation-service.test.ts +0 -1193
- package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
- package/src/donation/__tests__/grant-service.test.ts +0 -213
- package/src/donation/__tests__/grant-store.test.ts +0 -113
- package/src/donation/donation-service.ts +0 -1033
- package/src/donation/donation-store.ts +0 -176
- package/src/donation/donation-supervisor.ts +0 -411
- package/src/donation/grant-service.ts +0 -252
- package/src/donation/grant-store.ts +0 -152
- package/src/donation/index.ts +0 -65
- package/src/donation/types.ts +0 -260
- package/src/owner/__tests__/owner-node-client.test.ts +0 -194
- package/src/owner/index.ts +0 -12
- package/src/owner/owner-node-client.ts +0 -259
- package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
- package/src/server/__tests__/grants-admin-route.test.ts +0 -171
- package/src/server/__tests__/grants-route.test.ts +0 -394
- package/src/server/__tests__/strands-route.test.ts +0 -234
- package/src/server/routes/bootstrap-node-validation.ts +0 -206
- package/src/server/routes/grants-admin.ts +0 -74
- package/src/server/routes/grants.ts +0 -193
- package/src/server/routes/provision-request-validation.ts +0 -105
- package/src/server/routes/strands.ts +0 -54
- package/src/server/routes/trust-circle.ts +0 -49
- package/src/strands/__tests__/strand-service.test.ts +0 -165
- package/src/strands/index.ts +0 -14
- package/src/strands/strand-service.ts +0 -135
- package/src/strands/types.ts +0 -102
|
@@ -1,1193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DonationService unit tests — the donate-a-node lifecycle on the host side,
|
|
3
|
-
* exercised against a fake orchestrator (no real child processes) and a real
|
|
4
|
-
* on-disk DonationStore + GrantService.
|
|
5
|
-
*
|
|
6
|
-
* Covers: provision → awaiting_seed (pinned keys threaded, seedToken persisted +
|
|
7
|
-
* redacted), seedToken survival across a store reconstruct, per-grant quota-race
|
|
8
|
-
* serialization, reclaim-on-post-spawn-failure, seeding, respawn, terminate, the
|
|
9
|
-
* stale-awaiting_seed reap, and the stuck-provisioning reap (the host died
|
|
10
|
-
* between writing the row and finishing the spawn).
|
|
11
|
-
*
|
|
12
|
-
* A recurring theme: **an ending that lands mid-operation wins.** `provision`,
|
|
13
|
-
* `applySeed`, and `respawn` each hold an entry-time copy of the record across a
|
|
14
|
-
* slow `await`, and `DonationStore.put` replaces the whole row — so each must
|
|
15
|
-
* re-read before writing or it resurrects a loan the borrower just ended. The
|
|
16
|
-
* three suites drive that race through `FakeOrchestrator.onCreate` / `onSpawned`
|
|
17
|
-
* (the spawn window, before and after the orchestrator drops the old handle) and
|
|
18
|
-
* a stubbed `globalThis.fetch` (seed window).
|
|
19
|
-
*
|
|
20
|
-
* `applySeed` is exercised here against that `fetch` stub. `getPeer` still does a
|
|
21
|
-
* real `fetch` to a live node, so its happy path lives in the cross-package
|
|
22
|
-
* integration scenario (`cadre-host-node-donation`), not here.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
26
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
27
|
-
import { tmpdir } from 'node:os';
|
|
28
|
-
import { join } from 'node:path';
|
|
29
|
-
|
|
30
|
-
import {
|
|
31
|
-
DonationService,
|
|
32
|
-
DONATION_AWAITING_SEED_TTL_MS,
|
|
33
|
-
DONATION_PROVISIONING_TTL_MS,
|
|
34
|
-
} from '../donation-service.js';
|
|
35
|
-
import { DonationStore } from '../donation-store.js';
|
|
36
|
-
import { GrantService } from '../grant-service.js';
|
|
37
|
-
import { GrantStore } from '../grant-store.js';
|
|
38
|
-
import type { Donation } from '../types.js';
|
|
39
|
-
import { DonationError } from '../types.js';
|
|
40
|
-
import { FakeOrchestrator } from './fake-orchestrator.js';
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A well-formed 32-byte base64url owner key. `provision` shape-checks the pins at
|
|
44
|
-
* the boundary, so a placeholder string like `OWNER_KEY` is now a
|
|
45
|
-
* rejection rather than an opaque fixture.
|
|
46
|
-
*/
|
|
47
|
-
const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
|
|
48
|
-
|
|
49
|
-
/** A store whose next `put` fails once — the post-spawn write-failure path. */
|
|
50
|
-
class FlakyDonationStore extends DonationStore {
|
|
51
|
-
failNextPut = false;
|
|
52
|
-
|
|
53
|
-
override put(donation: Donation): void {
|
|
54
|
-
if (this.failNextPut) {
|
|
55
|
-
this.failNextPut = false;
|
|
56
|
-
throw new DonationError('storage_error', 'disk full');
|
|
57
|
-
}
|
|
58
|
-
super.put(donation);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
let tmpRoot: string;
|
|
63
|
-
const originalFetch = globalThis.fetch;
|
|
64
|
-
|
|
65
|
-
beforeEach(() => {
|
|
66
|
-
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-donation-svc-'));
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
afterEach(() => {
|
|
70
|
-
globalThis.fetch = originalFetch;
|
|
71
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Stub the donated node's `POST /seed` with `body`. `duringRequest` — when given
|
|
76
|
-
* — runs exactly once and is awaited before the response resolves, so whatever
|
|
77
|
-
* it starts lands *inside* the seed window. It is the `fetch` analogue of
|
|
78
|
-
* `FakeOrchestrator.onCreate`.
|
|
79
|
-
*/
|
|
80
|
-
function stubSeedFetch(body: unknown, duringRequest?: () => unknown): void {
|
|
81
|
-
let ran: Promise<unknown> | undefined;
|
|
82
|
-
globalThis.fetch = (async () => {
|
|
83
|
-
ran ??= (async () => duringRequest?.())();
|
|
84
|
-
await ran;
|
|
85
|
-
return { ok: true, json: async () => body } as unknown as Response;
|
|
86
|
-
}) as typeof globalThis.fetch;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function makeGrants(opts?: { maxNodes?: number }): { grants: GrantService; token: string } {
|
|
90
|
-
const grants = new GrantService({ store: new GrantStore(join(tmpRoot, 'grants')) });
|
|
91
|
-
const token = grants.issue({ label: 'Alice', ...(opts?.maxNodes ? { maxNodes: opts.maxNodes } : {}) }).token;
|
|
92
|
-
return { grants, token };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const baseRequest = (grantToken: string) => ({
|
|
96
|
-
grantToken,
|
|
97
|
-
partyId: 'party-P',
|
|
98
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
99
|
-
ownerKeys: [OWNER_KEY],
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe('DonationService.provision', () => {
|
|
103
|
-
it('spawns → awaiting_seed, threads pinned owner keys, persists (redacts) the seedToken', async () => {
|
|
104
|
-
const orch = new FakeOrchestrator();
|
|
105
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
106
|
-
const { grants, token } = makeGrants();
|
|
107
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
108
|
-
|
|
109
|
-
const view = await svc.provision(baseRequest(token));
|
|
110
|
-
|
|
111
|
-
expect(view.status).toBe('awaiting_seed');
|
|
112
|
-
expect(view.partyId).toBe('party-P');
|
|
113
|
-
expect(view.profile).toBe('storage'); // default
|
|
114
|
-
expect(view.grantToken).toBe(token);
|
|
115
|
-
// Redacted: host-only secret + loopback URL never cross the boundary.
|
|
116
|
-
expect((view as Record<string, unknown>).seedToken).toBeUndefined();
|
|
117
|
-
expect((view as Record<string, unknown>).seedEndpoint).toBeUndefined();
|
|
118
|
-
|
|
119
|
-
// The orchestrator got the requester's pinned owner key (the seed-trust anchor).
|
|
120
|
-
expect(orch.createCalls).toHaveLength(1);
|
|
121
|
-
expect(orch.createCalls[0].pinnedOwnerKeys).toEqual([OWNER_KEY]);
|
|
122
|
-
expect(orch.createCalls[0].containerId).toBe(view.id);
|
|
123
|
-
expect(orch.createCalls[0].partyId).toBe('party-P');
|
|
124
|
-
|
|
125
|
-
// But the record on disk DOES retain the seedToken (crash-recovery).
|
|
126
|
-
const stored = store.get(view.id);
|
|
127
|
-
expect(stored?.seedToken).toBe('seed-token-1');
|
|
128
|
-
expect(stored?.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
|
|
129
|
-
expect(store.liveNodeCount(token)).toBe(1);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// A phone has no address to give: it sends no bootstrap nodes and dials the lent
|
|
133
|
-
// node itself. The empty list is a real spawn input, forwarded and persisted as given.
|
|
134
|
-
it('provisions with no bootstrap nodes, forwarding and persisting the empty list', async () => {
|
|
135
|
-
const orch = new FakeOrchestrator();
|
|
136
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
137
|
-
const { grants, token } = makeGrants();
|
|
138
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
139
|
-
|
|
140
|
-
const view = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
|
|
141
|
-
|
|
142
|
-
expect(view.status).toBe('awaiting_seed');
|
|
143
|
-
expect(orch.createCalls[0].bootstrapNodes).toEqual([]);
|
|
144
|
-
expect(store.get(view.id)?.bootstrapNodes).toEqual([]);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* The pins are checked before the grant is even validated, so a typo costs the
|
|
149
|
-
* requester nothing: no orchestrator call, no record, and — the point — no quota
|
|
150
|
-
* slot burned on a node that could never have booted. Previously the request was
|
|
151
|
-
* answered as provisioned and the child died at startup, leaving an `error`
|
|
152
|
-
* record and a slot the grantee had to notice and reclaim.
|
|
153
|
-
*/
|
|
154
|
-
it('rejects a malformed ownerKeys entry without provisioning or burning a quota slot', async () => {
|
|
155
|
-
const orch = new FakeOrchestrator();
|
|
156
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
157
|
-
const { grants, token } = makeGrants();
|
|
158
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
159
|
-
|
|
160
|
-
await expect(
|
|
161
|
-
svc.provision({ ...baseRequest(token), ownerKeys: [OWNER_KEY, 'this-is-not-a-key'] }),
|
|
162
|
-
).rejects.toMatchObject({ code: 'invalid_request', message: expect.stringContaining('this-is-not-a-key') });
|
|
163
|
-
|
|
164
|
-
expect(orch.createCalls).toEqual([]);
|
|
165
|
-
expect(store.list()).toEqual([]);
|
|
166
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('rejects a blank ownerKeys entry the same way', async () => {
|
|
170
|
-
const orch = new FakeOrchestrator();
|
|
171
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
172
|
-
const { grants, token } = makeGrants();
|
|
173
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
174
|
-
|
|
175
|
-
await expect(
|
|
176
|
-
svc.provision({ ...baseRequest(token), ownerKeys: [''] }),
|
|
177
|
-
).rejects.toBeInstanceOf(DonationError);
|
|
178
|
-
|
|
179
|
-
expect(orch.createCalls).toEqual([]);
|
|
180
|
-
expect(store.list()).toEqual([]);
|
|
181
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('threads the trimmed keys onto the record and the child, not the raw input', async () => {
|
|
185
|
-
const orch = new FakeOrchestrator();
|
|
186
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
187
|
-
const { grants, token } = makeGrants();
|
|
188
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
189
|
-
|
|
190
|
-
const view = await svc.provision({ ...baseRequest(token), ownerKeys: [` ${OWNER_KEY}\n`] });
|
|
191
|
-
|
|
192
|
-
expect(orch.createCalls[0].pinnedOwnerKeys).toEqual([OWNER_KEY]);
|
|
193
|
-
expect(store.get(view.id)?.ownerKeys).toEqual([OWNER_KEY]);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('honours an explicit transaction profile', async () => {
|
|
197
|
-
const orch = new FakeOrchestrator();
|
|
198
|
-
const { grants, token } = makeGrants();
|
|
199
|
-
const svc = new DonationService({
|
|
200
|
-
orchestrator: orch,
|
|
201
|
-
grants,
|
|
202
|
-
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
const view = await svc.provision({ ...baseRequest(token), profile: 'transaction' });
|
|
206
|
-
expect(view.profile).toBe('transaction');
|
|
207
|
-
expect(orch.createCalls[0].profile).toBe('transaction');
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('denies an unknown grant token before spawning (unauthorized)', async () => {
|
|
211
|
-
const orch = new FakeOrchestrator();
|
|
212
|
-
const { grants } = makeGrants();
|
|
213
|
-
const svc = new DonationService({
|
|
214
|
-
orchestrator: orch,
|
|
215
|
-
grants,
|
|
216
|
-
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
await expect(svc.provision(baseRequest('not-a-real-token')))
|
|
220
|
-
.rejects.toMatchObject({ code: 'unauthorized' });
|
|
221
|
-
expect(orch.createCalls).toHaveLength(0);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('lets a borrower terminate that lands mid-spawn win, and reclaims the new child', async () => {
|
|
225
|
-
const orch = new FakeOrchestrator();
|
|
226
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
227
|
-
const { grants, token } = makeGrants();
|
|
228
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
229
|
-
|
|
230
|
-
// The borrower's DELETE lands inside the spawn window. The record names no
|
|
231
|
-
// dockerId yet, so terminate's own cleanup branch finds nothing to stop —
|
|
232
|
-
// the abandon path here is the only thing that can reclaim this child.
|
|
233
|
-
orch.createDelayMs = 20;
|
|
234
|
-
let terminated: Promise<void> | undefined;
|
|
235
|
-
orch.onCreate = (request) => { terminated ??= svc.terminate(request.containerId); };
|
|
236
|
-
|
|
237
|
-
await expect(svc.provision(baseRequest(token))).rejects.toMatchObject({ code: 'invalid_state' });
|
|
238
|
-
await terminated;
|
|
239
|
-
|
|
240
|
-
const stored = store.list().find((d) => d.grantToken === token)!;
|
|
241
|
-
expect(stored.status).toBe('terminated');
|
|
242
|
-
expect(stored.dockerId).toBeUndefined();
|
|
243
|
-
expect(orch.removed).toEqual(['dock_1']);
|
|
244
|
-
// The ended loan no longer holds a quota slot.
|
|
245
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it('does not rewrite a terminated record as error when the spawn then fails', async () => {
|
|
249
|
-
const orch = new FakeOrchestrator();
|
|
250
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
251
|
-
const { grants, token } = makeGrants();
|
|
252
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
253
|
-
|
|
254
|
-
// Ending first, host fault second: the borrower's `terminated` must survive
|
|
255
|
-
// — a host-side error is not allowed to overwrite the borrower's own ending.
|
|
256
|
-
orch.createDelayMs = 20;
|
|
257
|
-
orch.failCreate = true;
|
|
258
|
-
let terminated: Promise<void> | undefined;
|
|
259
|
-
orch.onCreate = (request) => { terminated ??= svc.terminate(request.containerId); };
|
|
260
|
-
|
|
261
|
-
await expect(svc.provision(baseRequest(token)))
|
|
262
|
-
.rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
263
|
-
await terminated;
|
|
264
|
-
|
|
265
|
-
const stored = store.list().find((d) => d.grantToken === token)!;
|
|
266
|
-
expect(stored.status).toBe('terminated');
|
|
267
|
-
expect(stored.error).toBeUndefined();
|
|
268
|
-
expect(orch.removed).toEqual([]);
|
|
269
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it('reclaims the new child and recreates nothing when the record vanishes mid-spawn', async () => {
|
|
273
|
-
const orch = new FakeOrchestrator();
|
|
274
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
275
|
-
const { grants, token } = makeGrants();
|
|
276
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
277
|
-
|
|
278
|
-
orch.createDelayMs = 20;
|
|
279
|
-
orch.onCreate = (request) => { store.remove(request.containerId); };
|
|
280
|
-
|
|
281
|
-
await expect(svc.provision(baseRequest(token))).rejects.toMatchObject({ code: 'not_found' });
|
|
282
|
-
|
|
283
|
-
// No row to protect, so none is written back and the child is fully
|
|
284
|
-
// reclaimed rather than left holding ports and a workdir nothing names.
|
|
285
|
-
expect(store.list()).toEqual([]);
|
|
286
|
-
expect(orch.removed).toEqual(['dock_1']);
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
describe('DonationService seedToken persistence', () => {
|
|
291
|
-
it('reproduces the seedToken from a store rebuilt off disk (host-restart gap)', async () => {
|
|
292
|
-
const orch = new FakeOrchestrator();
|
|
293
|
-
const dir = join(tmpRoot, 'donations');
|
|
294
|
-
const { grants, token } = makeGrants();
|
|
295
|
-
const svc = new DonationService({ orchestrator: orch, grants, store: new DonationStore(dir) });
|
|
296
|
-
|
|
297
|
-
const view = await svc.provision(baseRequest(token));
|
|
298
|
-
|
|
299
|
-
// Simulate a host restart: brand-new store instance, same directory.
|
|
300
|
-
const reloaded = new DonationStore(dir);
|
|
301
|
-
const record = reloaded.get(view.id);
|
|
302
|
-
expect(record?.status).toBe('awaiting_seed');
|
|
303
|
-
expect(record?.seedToken).toBe('seed-token-1');
|
|
304
|
-
expect(record?.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
describe('DonationService quota-race serialization', () => {
|
|
309
|
-
it('serializes per grant so two concurrent provisions at the boundary cannot both pass', async () => {
|
|
310
|
-
const orch = new FakeOrchestrator();
|
|
311
|
-
orch.createDelayMs = 40; // widen the check→create window so an unserialized impl would race
|
|
312
|
-
const { grants, token } = makeGrants({ maxNodes: 1 });
|
|
313
|
-
const svc = new DonationService({
|
|
314
|
-
orchestrator: orch,
|
|
315
|
-
grants,
|
|
316
|
-
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
const [a, b] = await Promise.allSettled([
|
|
320
|
-
svc.provision(baseRequest(token)),
|
|
321
|
-
svc.provision(baseRequest(token)),
|
|
322
|
-
]);
|
|
323
|
-
|
|
324
|
-
const fulfilled = [a, b].filter((r) => r.status === 'fulfilled');
|
|
325
|
-
const rejected = [a, b].filter((r) => r.status === 'rejected');
|
|
326
|
-
expect(fulfilled).toHaveLength(1);
|
|
327
|
-
expect(rejected).toHaveLength(1);
|
|
328
|
-
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'quota_exceeded' });
|
|
329
|
-
// Exactly one node was actually spawned.
|
|
330
|
-
expect(orch.createCalls).toHaveLength(1);
|
|
331
|
-
});
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
describe('DonationService reclaim-on-failure', () => {
|
|
335
|
-
it('reclaims the spawned resources and marks error when persistence fails after spawn', async () => {
|
|
336
|
-
const orch = new FakeOrchestrator();
|
|
337
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
338
|
-
// Fail the SECOND put — the awaiting_seed write, after the orchestrator has
|
|
339
|
-
// already handed back a dockerId. The service must reclaim it.
|
|
340
|
-
const realPut = store.put.bind(store);
|
|
341
|
-
let puts = 0;
|
|
342
|
-
(store as unknown as { put: (d: unknown) => void }).put = (d: unknown) => {
|
|
343
|
-
puts += 1;
|
|
344
|
-
if (puts === 2) throw new DonationError('storage_error', 'disk full');
|
|
345
|
-
realPut(d as never);
|
|
346
|
-
};
|
|
347
|
-
const { grants, token } = makeGrants();
|
|
348
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
349
|
-
|
|
350
|
-
await expect(svc.provision(baseRequest(token)))
|
|
351
|
-
.rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
352
|
-
|
|
353
|
-
// The spawned child was reclaimed (removeContainer called with its dockerId).
|
|
354
|
-
expect(orch.removed).toEqual(['dock_1']);
|
|
355
|
-
// The record is marked error (the 3rd put, which succeeds) → not counted live.
|
|
356
|
-
const errored = store.list().find((d) => d.grantToken === token);
|
|
357
|
-
expect(errored?.status).toBe('error');
|
|
358
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it('does not reclaim when the spawn itself throws (nothing was allocated)', async () => {
|
|
362
|
-
const orch = new FakeOrchestrator();
|
|
363
|
-
orch.failCreate = true;
|
|
364
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
365
|
-
const { grants, token } = makeGrants();
|
|
366
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
367
|
-
|
|
368
|
-
await expect(svc.provision(baseRequest(token)))
|
|
369
|
-
.rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
370
|
-
expect(orch.removed).toEqual([]);
|
|
371
|
-
// Nor does it reclaim the workdir by name: `createContainer`'s own unwind
|
|
372
|
-
// already removed a directory its failed spawn created, and a second
|
|
373
|
-
// attempt from here would be redundant.
|
|
374
|
-
expect(orch.reclaimedWorkdirs).toEqual([]);
|
|
375
|
-
expect(store.list().find((d) => d.grantToken === token)?.status).toBe('error');
|
|
376
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
377
|
-
});
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
describe('DonationService.applySeed', () => {
|
|
381
|
-
it('marks the record seeded and reports the peers the node added', async () => {
|
|
382
|
-
const orch = new FakeOrchestrator();
|
|
383
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
384
|
-
const { grants, token } = makeGrants();
|
|
385
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
386
|
-
|
|
387
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
388
|
-
stubSeedFetch({ success: true, peersAdded: 2 });
|
|
389
|
-
|
|
390
|
-
const result = await svc.applySeed(provisioned.id, 'encoded-seed');
|
|
391
|
-
|
|
392
|
-
expect(result).toEqual({ outcome: 'seeded', peersAdded: 2 });
|
|
393
|
-
expect(store.get(provisioned.id)?.status).toBe('seeded');
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
it('reports a node that refuses the seed, and writes nothing', async () => {
|
|
397
|
-
const orch = new FakeOrchestrator();
|
|
398
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
399
|
-
const { grants, token } = makeGrants();
|
|
400
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
401
|
-
|
|
402
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
403
|
-
const before = store.get(provisioned.id)!;
|
|
404
|
-
stubSeedFetch({ success: false, error: 'seed signer is not a trusted owner' });
|
|
405
|
-
|
|
406
|
-
const result = await svc.applySeed(provisioned.id, 'encoded-seed');
|
|
407
|
-
|
|
408
|
-
expect(result).toEqual({ outcome: 'rejected', error: 'seed signer is not a trusted owner' });
|
|
409
|
-
// The node's own policy call is not our business to record.
|
|
410
|
-
expect(store.get(provisioned.id)).toEqual(before);
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
// A 401 is the node refusing the HOST's bearer — it never looked at the seed —
|
|
414
|
-
// so it must not read as the requester's seed being untrusted.
|
|
415
|
-
it('reports a node that refuses the host credential as a credential fault, not a seed rejection', async () => {
|
|
416
|
-
const orch = new FakeOrchestrator();
|
|
417
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
418
|
-
const { grants, token } = makeGrants();
|
|
419
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
420
|
-
|
|
421
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
422
|
-
const before = store.get(provisioned.id)!;
|
|
423
|
-
globalThis.fetch = (async () => new Response(
|
|
424
|
-
JSON.stringify({ success: false, error: 'unauthorized' }),
|
|
425
|
-
{ status: 401, headers: { 'content-type': 'application/json' } },
|
|
426
|
-
)) as typeof globalThis.fetch;
|
|
427
|
-
|
|
428
|
-
const err = await svc.applySeed(provisioned.id, 'encoded-seed').catch((e: unknown) => e);
|
|
429
|
-
|
|
430
|
-
expect(err).toBeInstanceOf(DonationError);
|
|
431
|
-
expect(err).toMatchObject({
|
|
432
|
-
code: 'seed_failed',
|
|
433
|
-
message: 'Donated node rejected the host\'s seed credential (401)',
|
|
434
|
-
});
|
|
435
|
-
expect(store.get(provisioned.id)).toEqual(before);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
it('lets a borrower terminate that lands mid-seed win', async () => {
|
|
439
|
-
const orch = new FakeOrchestrator();
|
|
440
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
441
|
-
const { grants, token } = makeGrants();
|
|
442
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
443
|
-
|
|
444
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
445
|
-
// The borrower's DELETE lands inside the seed window: the node accepts the
|
|
446
|
-
// seed, but by the time it answers the loan is over.
|
|
447
|
-
let terminated: Promise<void> | undefined;
|
|
448
|
-
stubSeedFetch({ success: true, peersAdded: 2 }, () => {
|
|
449
|
-
terminated = svc.terminate(provisioned.id);
|
|
450
|
-
return terminated;
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
const result = await svc.applySeed(provisioned.id, 'encoded-seed');
|
|
454
|
-
await terminated;
|
|
455
|
-
|
|
456
|
-
expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
|
|
457
|
-
const stored = store.get(provisioned.id)!;
|
|
458
|
-
expect(stored.status).toBe('terminated');
|
|
459
|
-
expect(stored.dockerId).toBe('dock_1');
|
|
460
|
-
// Unlike an abandoned respawn there is nothing left for us to clean up: the
|
|
461
|
-
// record named its dockerId throughout, so the ending stopped + reclaimed
|
|
462
|
-
// the child itself, and no live record remains for a supervisor to respawn.
|
|
463
|
-
expect(orch.stopped).toEqual(['dock_1']);
|
|
464
|
-
expect(orch.removed).toEqual(['dock_1']);
|
|
465
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
it('lets a stale-seed reap that lands mid-seed win without restarting the TTL clock', async () => {
|
|
469
|
-
const orch = new FakeOrchestrator();
|
|
470
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
471
|
-
const { grants, token } = makeGrants();
|
|
472
|
-
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
473
|
-
const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
|
|
474
|
-
|
|
475
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
476
|
-
|
|
477
|
-
// Past the awaiting_seed TTL, so the sweep collects this record.
|
|
478
|
-
clock = new Date(clock.getTime() + DONATION_AWAITING_SEED_TTL_MS + 60_000);
|
|
479
|
-
const reapAtIso = clock.toISOString();
|
|
480
|
-
let reaped: Promise<string[]> | undefined;
|
|
481
|
-
stubSeedFetch({ success: true, peersAdded: 2 }, () => {
|
|
482
|
-
// Move the clock on once the reap's terminal write has landed, so a seed
|
|
483
|
-
// write that overwrote it would show up as a *later* updatedAt.
|
|
484
|
-
reaped = svc.reapStaleAwaitingSeed().then((ids) => {
|
|
485
|
-
clock = new Date(clock.getTime() + 1_000);
|
|
486
|
-
return ids;
|
|
487
|
-
});
|
|
488
|
-
return reaped;
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
const result = await svc.applySeed(provisioned.id, 'encoded-seed');
|
|
492
|
-
await expect(reaped).resolves.toEqual([provisioned.id]);
|
|
493
|
-
|
|
494
|
-
expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
|
|
495
|
-
const stored = store.get(provisioned.id)!;
|
|
496
|
-
expect(stored.status).toBe('terminated');
|
|
497
|
-
// The reap's own "now" — a seed write here would restart the TTL clock.
|
|
498
|
-
expect(stored.updatedAt).toBe(reapAtIso);
|
|
499
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
it('abandons with no status when the record vanishes mid-seed', async () => {
|
|
503
|
-
const orch = new FakeOrchestrator();
|
|
504
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
505
|
-
const { grants, token } = makeGrants();
|
|
506
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
507
|
-
|
|
508
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
509
|
-
stubSeedFetch({ success: true, peersAdded: 2 }, () => { store.remove(provisioned.id); });
|
|
510
|
-
|
|
511
|
-
const result = await svc.applySeed(provisioned.id, 'encoded-seed');
|
|
512
|
-
|
|
513
|
-
expect(result).toEqual({ outcome: 'abandoned' });
|
|
514
|
-
expect(store.get(provisioned.id)).toBeUndefined();
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
it('refuses to seed a terminated loan on entry', async () => {
|
|
518
|
-
const orch = new FakeOrchestrator();
|
|
519
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
520
|
-
const { grants, token } = makeGrants();
|
|
521
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
522
|
-
|
|
523
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
524
|
-
await svc.terminate(provisioned.id);
|
|
525
|
-
stubSeedFetch({ success: true, peersAdded: 2 });
|
|
526
|
-
|
|
527
|
-
await expect(svc.applySeed(provisioned.id, 'encoded-seed'))
|
|
528
|
-
.rejects.toMatchObject({ code: 'invalid_state' });
|
|
529
|
-
});
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
// The orchestrator refuses to re-spawn a container whose child is still running, so
|
|
533
|
-
// each case that expects a spawn has the first child (`dock_1`) go down first.
|
|
534
|
-
describe('DonationService.respawn', () => {
|
|
535
|
-
it('replays the persisted spawn inputs, swaps the handles, and leaves status alone', async () => {
|
|
536
|
-
const orch = new FakeOrchestrator();
|
|
537
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
538
|
-
const { grants, token } = makeGrants();
|
|
539
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
540
|
-
|
|
541
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
542
|
-
// Pretend the borrower seeded it, then the child died.
|
|
543
|
-
store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
|
|
544
|
-
|
|
545
|
-
orch.crash('dock_1');
|
|
546
|
-
const result = await svc.respawn(provisioned.id);
|
|
547
|
-
|
|
548
|
-
expect(result).toMatchObject({ outcome: 'respawned' });
|
|
549
|
-
expect(result.outcome === 'respawned' && result.donation.status).toBe('seeded');
|
|
550
|
-
// The replayed spawn carried the persisted inputs, not fresh ones.
|
|
551
|
-
expect(orch.createCalls).toHaveLength(2);
|
|
552
|
-
expect(orch.createCalls[1]).toMatchObject({
|
|
553
|
-
containerId: provisioned.id,
|
|
554
|
-
partyId: 'party-P',
|
|
555
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
556
|
-
pinnedOwnerKeys: [OWNER_KEY],
|
|
557
|
-
profile: 'storage',
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
const stored = store.get(provisioned.id)!;
|
|
561
|
-
expect(stored.status).toBe('seeded');
|
|
562
|
-
expect(stored.dockerId).toBe('dock_2');
|
|
563
|
-
expect(stored.seedEndpoint).toBe('http://127.0.0.1:9002/seed');
|
|
564
|
-
expect(stored.seedToken).toBe('seed-token-2');
|
|
565
|
-
expect(stored.respawn?.attempts).toBe(1);
|
|
566
|
-
// Nothing was stopped or reclaimed — the workdir (identity key, node-local
|
|
567
|
-
// stores) has to survive for the respawn to be the same node.
|
|
568
|
-
expect(orch.stopped).toEqual([]);
|
|
569
|
-
expect(orch.removed).toEqual([]);
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
it('keeps awaiting_seed records awaiting_seed so a later seed hits the new endpoint', async () => {
|
|
573
|
-
const orch = new FakeOrchestrator();
|
|
574
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
575
|
-
const { grants, token } = makeGrants();
|
|
576
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
577
|
-
|
|
578
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
579
|
-
orch.crash('dock_1');
|
|
580
|
-
const result = await svc.respawn(provisioned.id);
|
|
581
|
-
|
|
582
|
-
expect(result).toMatchObject({ outcome: 'respawned' });
|
|
583
|
-
expect(result.outcome === 'respawned' && result.donation.status).toBe('awaiting_seed');
|
|
584
|
-
expect(store.get(provisioned.id)?.seedToken).toBe('seed-token-2');
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
it('skips a record written before the spawn inputs were persisted', async () => {
|
|
588
|
-
const orch = new FakeOrchestrator();
|
|
589
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
590
|
-
const { grants, token } = makeGrants();
|
|
591
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
592
|
-
|
|
593
|
-
// A pre-existing row on disk: no bootstrapNodes / ownerKeys to replay.
|
|
594
|
-
store.put({
|
|
595
|
-
id: 'grn_legacy',
|
|
596
|
-
grantToken: token,
|
|
597
|
-
partyId: 'party-P',
|
|
598
|
-
profile: 'storage',
|
|
599
|
-
status: 'seeded',
|
|
600
|
-
dockerId: 'dock_old',
|
|
601
|
-
createdAt: '2025-01-01T00:00:00.000Z',
|
|
602
|
-
updatedAt: '2025-01-01T00:00:00.000Z',
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
// A skip, not a throw — a sweep over the store must survive these.
|
|
606
|
-
await expect(svc.respawn('grn_legacy')).resolves.toEqual({ outcome: 'not_respawnable' });
|
|
607
|
-
expect(orch.createCalls).toHaveLength(0);
|
|
608
|
-
expect(store.get('grn_legacy')?.dockerId).toBe('dock_old');
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
// The empty list a phone requester provisions with is a persisted spawn input like
|
|
612
|
-
// any other, so that loan must come back too.
|
|
613
|
-
it('respawns a record provisioned with no bootstrap nodes, replaying the empty list', async () => {
|
|
614
|
-
const orch = new FakeOrchestrator();
|
|
615
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
616
|
-
const { grants, token } = makeGrants();
|
|
617
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
618
|
-
|
|
619
|
-
const provisioned = await svc.provision({ ...baseRequest(token), bootstrapNodes: [] });
|
|
620
|
-
store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
|
|
621
|
-
|
|
622
|
-
orch.crash('dock_1');
|
|
623
|
-
const result = await svc.respawn(provisioned.id);
|
|
624
|
-
|
|
625
|
-
expect(result).toMatchObject({ outcome: 'respawned' });
|
|
626
|
-
expect(orch.createCalls).toHaveLength(2);
|
|
627
|
-
expect(orch.createCalls[1].bootstrapNodes).toEqual([]);
|
|
628
|
-
expect(store.get(provisioned.id)?.dockerId).toBe('dock_2');
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
// Only the field's ABSENCE marks a record from before spawn inputs were persisted,
|
|
632
|
-
// so owner keys alone do not make a record replayable.
|
|
633
|
-
it('skips a record that has owner keys but no bootstrapNodes field', async () => {
|
|
634
|
-
const orch = new FakeOrchestrator();
|
|
635
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
636
|
-
const { grants, token } = makeGrants();
|
|
637
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
638
|
-
|
|
639
|
-
store.put({
|
|
640
|
-
id: 'grn_partial',
|
|
641
|
-
grantToken: token,
|
|
642
|
-
partyId: 'party-P',
|
|
643
|
-
ownerKeys: [OWNER_KEY],
|
|
644
|
-
profile: 'storage',
|
|
645
|
-
status: 'seeded',
|
|
646
|
-
dockerId: 'dock_old',
|
|
647
|
-
createdAt: '2025-01-01T00:00:00.000Z',
|
|
648
|
-
updatedAt: '2025-01-01T00:00:00.000Z',
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
await expect(svc.respawn('grn_partial')).resolves.toEqual({ outcome: 'not_respawnable' });
|
|
652
|
-
expect(orch.createCalls).toHaveLength(0);
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
it('refuses to resurrect a terminated loan', async () => {
|
|
656
|
-
const orch = new FakeOrchestrator();
|
|
657
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
658
|
-
const { grants, token } = makeGrants();
|
|
659
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
660
|
-
|
|
661
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
662
|
-
await svc.terminate(provisioned.id);
|
|
663
|
-
|
|
664
|
-
await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'invalid_state' });
|
|
665
|
-
expect(orch.createCalls).toHaveLength(1);
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
it('refuses a record whose provision is still in flight', async () => {
|
|
669
|
-
const orch = new FakeOrchestrator();
|
|
670
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
671
|
-
const { grants, token } = makeGrants();
|
|
672
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
673
|
-
|
|
674
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
675
|
-
// A host that died mid-provision leaves exactly this row: spawn inputs
|
|
676
|
-
// persisted, status never advanced. Replaying it would race the provision
|
|
677
|
-
// and strand the record in a status no reap sweep collects.
|
|
678
|
-
store.put({ ...store.get(provisioned.id)!, status: 'provisioning' });
|
|
679
|
-
|
|
680
|
-
await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'invalid_state' });
|
|
681
|
-
expect(orch.createCalls).toHaveLength(1);
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
it('records the attempt and throws when the spawn fails', async () => {
|
|
685
|
-
const orch = new FakeOrchestrator();
|
|
686
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
687
|
-
const { grants, token } = makeGrants();
|
|
688
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
689
|
-
|
|
690
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
691
|
-
orch.failCreate = true;
|
|
692
|
-
|
|
693
|
-
await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
694
|
-
|
|
695
|
-
const stored = store.get(provisioned.id)!;
|
|
696
|
-
expect(stored.respawn?.attempts).toBe(1);
|
|
697
|
-
// The old handles are untouched — the caller owns backoff, not cleanup. The
|
|
698
|
-
// seed token above all: the child still running was started with it, so a
|
|
699
|
-
// rotated token would make that child refuse every later seed.
|
|
700
|
-
expect(stored.dockerId).toBe('dock_1');
|
|
701
|
-
expect(stored.seedEndpoint).toBe('http://127.0.0.1:9001/seed');
|
|
702
|
-
expect(stored.seedToken).toBe('seed-token-1');
|
|
703
|
-
expect(stored.status).toBe('awaiting_seed');
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
it('stops — but never reclaims — the new child when the post-spawn write fails', async () => {
|
|
707
|
-
const orch = new FakeOrchestrator();
|
|
708
|
-
// Fails exactly once, on the write that follows the second createContainer.
|
|
709
|
-
// It must fail only once, or the merge write below fails too and the test
|
|
710
|
-
// proves nothing.
|
|
711
|
-
const store = new FlakyDonationStore(join(tmpRoot, 'donations'));
|
|
712
|
-
const { grants, token } = makeGrants();
|
|
713
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
714
|
-
|
|
715
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
716
|
-
store.failNextPut = true;
|
|
717
|
-
|
|
718
|
-
orch.crash('dock_1');
|
|
719
|
-
await expect(svc.respawn(provisioned.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
720
|
-
|
|
721
|
-
// Stopped, not removed: `removeContainer` deletes the workdir, and the
|
|
722
|
-
// workdir is the identity key that makes a later respawn the SAME node.
|
|
723
|
-
expect(orch.stopped).toEqual(['dock_2']);
|
|
724
|
-
expect(orch.removed).toEqual([]);
|
|
725
|
-
// The record names the child that actually exists. `createContainer`
|
|
726
|
-
// already dropped the handle `dock_1` named, so leaving it there would aim
|
|
727
|
-
// every later stop/reclaim at an id the orchestrator cannot resolve — and
|
|
728
|
-
// strand the node's workdir on disk forever.
|
|
729
|
-
const stored = store.get(provisioned.id)!;
|
|
730
|
-
expect(stored.dockerId).toBe('dock_2');
|
|
731
|
-
expect(stored.seedEndpoint).toBe('http://127.0.0.1:9002/seed');
|
|
732
|
-
expect(stored.seedToken).toBe('seed-token-2');
|
|
733
|
-
// The attempt failed, so status and the reap clock are left alone.
|
|
734
|
-
expect(stored.status).toBe('awaiting_seed');
|
|
735
|
-
expect(stored.updatedAt).toBe(provisioned.updatedAt);
|
|
736
|
-
expect(stored.respawn?.attempts).toBe(1);
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
it('lets a borrower terminate that lands mid-spawn win, and cleans up the new child', async () => {
|
|
740
|
-
const orch = new FakeOrchestrator();
|
|
741
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
742
|
-
const { grants, token } = makeGrants();
|
|
743
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
744
|
-
|
|
745
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
746
|
-
store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
|
|
747
|
-
|
|
748
|
-
// The borrower's DELETE lands inside the respawn's spawn window.
|
|
749
|
-
orch.createDelayMs = 20;
|
|
750
|
-
let terminated: Promise<void> | undefined;
|
|
751
|
-
orch.onCreate = () => { terminated ??= svc.terminate(provisioned.id); };
|
|
752
|
-
|
|
753
|
-
const result = await svc.respawn(provisioned.id);
|
|
754
|
-
await terminated;
|
|
755
|
-
|
|
756
|
-
expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
|
|
757
|
-
const stored = store.get(provisioned.id)!;
|
|
758
|
-
// The ending's write stands whole: no new handles, no attempt counter.
|
|
759
|
-
expect(stored.status).toBe('terminated');
|
|
760
|
-
expect(stored.dockerId).toBe('dock_1');
|
|
761
|
-
expect(stored.seedToken).toBe('seed-token-1');
|
|
762
|
-
expect(stored.respawn).toBeUndefined();
|
|
763
|
-
// The child the ending could not see is stopped AND reclaimed — it is the
|
|
764
|
-
// only thing left holding that spawn's ports and workdir.
|
|
765
|
-
expect(orch.stopped).toContain('dock_2');
|
|
766
|
-
expect(orch.removed).toContain('dock_2');
|
|
767
|
-
// The ended loan no longer holds a quota slot.
|
|
768
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
it('reclaims the new child because the terminate could not clean up the old one', async () => {
|
|
772
|
-
const orch = new FakeOrchestrator();
|
|
773
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
774
|
-
const { grants, token } = makeGrants();
|
|
775
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
776
|
-
|
|
777
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
778
|
-
store.put({ ...store.get(provisioned.id)!, status: 'seeded' });
|
|
779
|
-
|
|
780
|
-
// The borrower's DELETE lands in the window *after* the spawn dropped the
|
|
781
|
-
// old handle — the window `abandonRespawn` was written for. (The test above
|
|
782
|
-
// drives the same terminate from `onCreate`, i.e. before the drop, where the
|
|
783
|
-
// terminate's own cleanup still works.) No `createDelayMs` needed: `onSpawned`
|
|
784
|
-
// fires synchronously and `terminate` writes its `terminated` row before its
|
|
785
|
-
// first `await`, so the row is terminal by the time `respawn` re-reads.
|
|
786
|
-
let terminated: Promise<void> | undefined;
|
|
787
|
-
orch.onSpawned = () => { terminated ??= svc.terminate(provisioned.id); };
|
|
788
|
-
|
|
789
|
-
orch.crash('dock_1');
|
|
790
|
-
const result = await svc.respawn(provisioned.id);
|
|
791
|
-
await terminated;
|
|
792
|
-
|
|
793
|
-
expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
|
|
794
|
-
// Exact equality, not `toContain`: dock_1's ABSENCE is the whole claim. The
|
|
795
|
-
// terminate aimed its own stop and reclaim at dock_1 — the handle this spawn
|
|
796
|
-
// had already dropped — so it cleaned up nothing at all. That is why the
|
|
797
|
-
// abandoned respawn has to reclaim dock_2 rather than merely stop it: dock_2
|
|
798
|
-
// is the only thing left holding that spawn's ports and workdir.
|
|
799
|
-
expect(orch.stopped).toEqual(['dock_2']);
|
|
800
|
-
expect(orch.removed).toEqual(['dock_2']);
|
|
801
|
-
// The ending's write stands whole, still naming the child it knew about.
|
|
802
|
-
expect(store.get(provisioned.id)!.status).toBe('terminated');
|
|
803
|
-
expect(store.get(provisioned.id)!.dockerId).toBe('dock_1');
|
|
804
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
it('lets a stale-seed reap that lands mid-spawn win without restarting the TTL clock', async () => {
|
|
808
|
-
const orch = new FakeOrchestrator();
|
|
809
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
810
|
-
const { grants, token } = makeGrants();
|
|
811
|
-
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
812
|
-
const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
|
|
813
|
-
|
|
814
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
815
|
-
|
|
816
|
-
// Past the awaiting_seed TTL, so the sweep collects this record.
|
|
817
|
-
clock = new Date(clock.getTime() + DONATION_AWAITING_SEED_TTL_MS + 60_000);
|
|
818
|
-
const reapAtIso = clock.toISOString();
|
|
819
|
-
orch.createDelayMs = 20;
|
|
820
|
-
let reaped: Promise<string[]> | undefined;
|
|
821
|
-
orch.onCreate = () => {
|
|
822
|
-
// Move the clock on once the reap's terminal write has landed, so a
|
|
823
|
-
// respawn that overwrote it would show up as a *later* updatedAt.
|
|
824
|
-
reaped ??= svc.reapStaleAwaitingSeed().then((ids) => {
|
|
825
|
-
clock = new Date(clock.getTime() + 1_000);
|
|
826
|
-
return ids;
|
|
827
|
-
});
|
|
828
|
-
};
|
|
829
|
-
|
|
830
|
-
const result = await svc.respawn(provisioned.id);
|
|
831
|
-
await expect(reaped).resolves.toEqual([provisioned.id]);
|
|
832
|
-
|
|
833
|
-
expect(result).toEqual({ outcome: 'abandoned', status: 'terminated' });
|
|
834
|
-
const stored = store.get(provisioned.id)!;
|
|
835
|
-
expect(stored.status).toBe('terminated');
|
|
836
|
-
// The reap's own "now" — a respawn write here would restart the TTL clock.
|
|
837
|
-
expect(stored.updatedAt).toBe(reapAtIso);
|
|
838
|
-
expect(orch.stopped).toContain('dock_2');
|
|
839
|
-
expect(orch.removed).toContain('dock_2');
|
|
840
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
it('stops but does not reclaim the new child when the record goes error mid-spawn', async () => {
|
|
844
|
-
const orch = new FakeOrchestrator();
|
|
845
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
846
|
-
const { grants, token } = makeGrants();
|
|
847
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
848
|
-
|
|
849
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
850
|
-
|
|
851
|
-
// A give-up write landing inside the spawn window. Both spawns share one
|
|
852
|
-
// workdir, so reclaiming here would delete the identity key `error`
|
|
853
|
-
// deliberately keeps.
|
|
854
|
-
orch.createDelayMs = 20;
|
|
855
|
-
orch.onCreate = () => {
|
|
856
|
-
store.put({ ...store.get(provisioned.id)!, status: 'error', error: 'gave up' });
|
|
857
|
-
};
|
|
858
|
-
|
|
859
|
-
orch.crash('dock_1');
|
|
860
|
-
const result = await svc.respawn(provisioned.id);
|
|
861
|
-
|
|
862
|
-
expect(result).toEqual({ outcome: 'abandoned', status: 'error' });
|
|
863
|
-
expect(orch.stopped).toContain('dock_2');
|
|
864
|
-
expect(orch.removed).toEqual([]);
|
|
865
|
-
const stored = store.get(provisioned.id)!;
|
|
866
|
-
expect(stored.status).toBe('error');
|
|
867
|
-
expect(stored.dockerId).toBe('dock_1');
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
it('abandons with no status when the record vanishes mid-spawn', async () => {
|
|
871
|
-
const orch = new FakeOrchestrator();
|
|
872
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
873
|
-
const { grants, token } = makeGrants();
|
|
874
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
875
|
-
|
|
876
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
877
|
-
|
|
878
|
-
orch.createDelayMs = 20;
|
|
879
|
-
orch.onCreate = () => { store.remove(provisioned.id); };
|
|
880
|
-
|
|
881
|
-
orch.crash('dock_1');
|
|
882
|
-
const result = await svc.respawn(provisioned.id);
|
|
883
|
-
|
|
884
|
-
// No row to protect, so nothing is written back and the child is fully
|
|
885
|
-
// reclaimed rather than left holding ports and a workdir nothing names.
|
|
886
|
-
expect(result).toEqual({ outcome: 'abandoned' });
|
|
887
|
-
expect(store.get(provisioned.id)).toBeUndefined();
|
|
888
|
-
expect(orch.stopped).toContain('dock_2');
|
|
889
|
-
expect(orch.removed).toContain('dock_2');
|
|
890
|
-
});
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
describe('DonationService.terminate', () => {
|
|
894
|
-
it('marks the record terminated BEFORE stopping the child', async () => {
|
|
895
|
-
const orch = new FakeOrchestrator();
|
|
896
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
897
|
-
const { grants, token } = makeGrants();
|
|
898
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
899
|
-
|
|
900
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
901
|
-
// The stop fires the orchestrator's state-change; a respawn supervisor
|
|
902
|
-
// reacting to it must already see a terminal record, or it resurrects a
|
|
903
|
-
// loan the borrower just ended.
|
|
904
|
-
let statusAtStop: string | undefined;
|
|
905
|
-
orch.onStop = () => { statusAtStop = store.get(provisioned.id)?.status; };
|
|
906
|
-
|
|
907
|
-
await svc.terminate(provisioned.id);
|
|
908
|
-
|
|
909
|
-
expect(statusAtStop).toBe('terminated');
|
|
910
|
-
expect(orch.stopped).toEqual(['dock_1']);
|
|
911
|
-
expect(orch.removed).toEqual(['dock_1']);
|
|
912
|
-
// `removeContainer` already deleted the workdir — reclaiming by name on top
|
|
913
|
-
// of that would be redundant, and would race the next spawn of the same id.
|
|
914
|
-
expect(orch.reclaimedWorkdirs).toEqual([]);
|
|
915
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
it('reclaims the workdir by name for a record that never got a dockerId', async () => {
|
|
919
|
-
const orch = new FakeOrchestrator();
|
|
920
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
921
|
-
const { grants, token } = makeGrants();
|
|
922
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
923
|
-
|
|
924
|
-
// A record the host died mid-provision on: `ensureNodeIdentity` created
|
|
925
|
-
// `<rootDir>/grn_stuck` before anything produced a dockerId. Without this
|
|
926
|
-
// branch, terminating it would move it to `terminated` — which the
|
|
927
|
-
// stuck-`provisioning` reap no longer matches — stranding the directory.
|
|
928
|
-
store.put({
|
|
929
|
-
id: 'grn_stuck',
|
|
930
|
-
grantToken: token,
|
|
931
|
-
partyId: 'party-P',
|
|
932
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
933
|
-
ownerKeys: [OWNER_KEY],
|
|
934
|
-
profile: 'storage',
|
|
935
|
-
status: 'provisioning',
|
|
936
|
-
createdAt: '2025-01-01T00:00:00.000Z',
|
|
937
|
-
updatedAt: '2025-01-01T00:00:00.000Z',
|
|
938
|
-
});
|
|
939
|
-
|
|
940
|
-
await svc.terminate('grn_stuck');
|
|
941
|
-
|
|
942
|
-
expect(store.get('grn_stuck')?.status).toBe('terminated');
|
|
943
|
-
expect(orch.reclaimedWorkdirs).toEqual(['grn_stuck']);
|
|
944
|
-
expect(orch.stopped).toEqual([]);
|
|
945
|
-
expect(orch.removed).toEqual([]);
|
|
946
|
-
});
|
|
947
|
-
|
|
948
|
-
it('swallows the second terminate of the same donation', async () => {
|
|
949
|
-
const orch = new FakeOrchestrator();
|
|
950
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
951
|
-
const { grants, token } = makeGrants();
|
|
952
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
953
|
-
|
|
954
|
-
const provisioned = await svc.provision(baseRequest(token));
|
|
955
|
-
await svc.terminate(provisioned.id);
|
|
956
|
-
|
|
957
|
-
// `terminate` does not gate on status, so the second pass re-aims its stop
|
|
958
|
-
// and reclaim at a handle the first pass already removed. The orchestrator
|
|
959
|
-
// rejects both; `safeStop` / `safeReclaim` log and swallow, so a duplicate
|
|
960
|
-
// DELETE is a no-op rather than a 500. Reaps and retries both land here.
|
|
961
|
-
await expect(svc.terminate(provisioned.id)).resolves.toBeUndefined();
|
|
962
|
-
expect(orch.stopped).toEqual(['dock_1']);
|
|
963
|
-
expect(orch.removed).toEqual(['dock_1']);
|
|
964
|
-
expect(store.get(provisioned.id)!.status).toBe('terminated');
|
|
965
|
-
});
|
|
966
|
-
});
|
|
967
|
-
|
|
968
|
-
describe('DonationService.terminateGrant', () => {
|
|
969
|
-
it('terminates every unterminated donation under the grant, error included, and leaves other grants alone', async () => {
|
|
970
|
-
const orch = new FakeOrchestrator();
|
|
971
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
972
|
-
const { grants, token } = makeGrants({ maxNodes: 5 });
|
|
973
|
-
const otherToken = grants.issue({ label: 'Bob' }).token;
|
|
974
|
-
const svc = new DonationService({ orchestrator: orch, grants, store });
|
|
975
|
-
|
|
976
|
-
const seeded = await svc.provision(baseRequest(token)); // dock_1
|
|
977
|
-
store.put({ ...store.get(seeded.id)!, status: 'seeded' });
|
|
978
|
-
// A loan the supervisor gave up on keeps its workdir for a later terminate —
|
|
979
|
-
// which, once the grant is revoked, only this teardown can make.
|
|
980
|
-
const gaveUp = await svc.provision(baseRequest(token)); // dock_2
|
|
981
|
-
store.put({ ...store.get(gaveUp.id)!, status: 'error', error: 'respawn gave up' });
|
|
982
|
-
const ended = await svc.provision(baseRequest(token)); // dock_3
|
|
983
|
-
await svc.terminate(ended.id);
|
|
984
|
-
const bobs = await svc.provision(baseRequest(otherToken)); // dock_4
|
|
985
|
-
|
|
986
|
-
grants.revoke(token);
|
|
987
|
-
const terminated = await svc.terminateGrant(token);
|
|
988
|
-
|
|
989
|
-
expect(terminated.sort()).toEqual([seeded.id, gaveUp.id].sort());
|
|
990
|
-
expect(store.get(seeded.id)?.status).toBe('terminated');
|
|
991
|
-
expect(store.get(gaveUp.id)?.status).toBe('terminated');
|
|
992
|
-
expect(store.get(bobs.id)?.status).toBe('awaiting_seed');
|
|
993
|
-
expect(orch.removed.sort()).toEqual(['dock_1', 'dock_2', 'dock_3']);
|
|
994
|
-
});
|
|
995
|
-
});
|
|
996
|
-
|
|
997
|
-
describe('DonationService.reapStaleAwaitingSeed', () => {
|
|
998
|
-
it('terminates awaiting_seed donations past the TTL and leaves fresh ones alone', async () => {
|
|
999
|
-
const orch = new FakeOrchestrator();
|
|
1000
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
1001
|
-
const { grants, token } = makeGrants({ maxNodes: 5 });
|
|
1002
|
-
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
1003
|
-
const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
|
|
1004
|
-
|
|
1005
|
-
// Old donation (enters awaiting_seed at T0).
|
|
1006
|
-
const old = await svc.provision(baseRequest(token));
|
|
1007
|
-
|
|
1008
|
-
// Advance well past the TTL, then provision a fresh one at the new "now".
|
|
1009
|
-
const ttlMs = 30 * 60 * 1000;
|
|
1010
|
-
clock = new Date(Date.parse('2025-01-01T00:00:00.000Z') + ttlMs + 60_000);
|
|
1011
|
-
const fresh = await svc.provision(baseRequest(token));
|
|
1012
|
-
|
|
1013
|
-
const reaped = await svc.reapStaleAwaitingSeed(ttlMs);
|
|
1014
|
-
|
|
1015
|
-
expect(reaped).toEqual([old.id]);
|
|
1016
|
-
expect(store.get(old.id)?.status).toBe('terminated');
|
|
1017
|
-
expect(store.get(fresh.id)?.status).toBe('awaiting_seed');
|
|
1018
|
-
// The stale child was stopped + removed.
|
|
1019
|
-
expect(orch.stopped).toContain('dock_1');
|
|
1020
|
-
expect(orch.removed).toContain('dock_1');
|
|
1021
|
-
// Only the fresh donation still counts against the quota.
|
|
1022
|
-
expect(store.liveNodeCount(token)).toBe(1);
|
|
1023
|
-
});
|
|
1024
|
-
|
|
1025
|
-
it('leaves a loan alone when a seed lands mid-sweep, before its turn comes up', async () => {
|
|
1026
|
-
const orch = new FakeOrchestrator();
|
|
1027
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
1028
|
-
const { grants, token } = makeGrants({ maxNodes: 5 });
|
|
1029
|
-
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
1030
|
-
const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
|
|
1031
|
-
|
|
1032
|
-
const first = await svc.provision(baseRequest(token));
|
|
1033
|
-
const second = await svc.provision(baseRequest(token));
|
|
1034
|
-
clock = new Date(clock.getTime() + DONATION_AWAITING_SEED_TTL_MS + 60_000);
|
|
1035
|
-
|
|
1036
|
-
// The borrower seeds the second loan while the sweep is still awaiting the
|
|
1037
|
-
// FIRST record's stop — the very write `applySeed` makes once its node
|
|
1038
|
-
// answers. The sweep's candidate list predates it, so acting on that stale
|
|
1039
|
-
// copy would terminate a loan that just came good.
|
|
1040
|
-
orch.onStop = (dockerId) => {
|
|
1041
|
-
if (dockerId !== 'dock_1') return;
|
|
1042
|
-
store.put({ ...store.get(second.id)!, status: 'seeded', updatedAt: clock.toISOString() });
|
|
1043
|
-
};
|
|
1044
|
-
|
|
1045
|
-
await expect(svc.reapStaleAwaitingSeed()).resolves.toEqual([first.id]);
|
|
1046
|
-
|
|
1047
|
-
expect(store.get(first.id)?.status).toBe('terminated');
|
|
1048
|
-
expect(store.get(second.id)?.status).toBe('seeded');
|
|
1049
|
-
expect(orch.stopped).toEqual(['dock_1']);
|
|
1050
|
-
expect(orch.removed).toEqual(['dock_1']);
|
|
1051
|
-
// The seeded loan still holds its quota slot; the reaped one does not.
|
|
1052
|
-
expect(store.liveNodeCount(token)).toBe(1);
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
it('is a no-op when nothing is stale', async () => {
|
|
1056
|
-
const orch = new FakeOrchestrator();
|
|
1057
|
-
const { grants, token } = makeGrants();
|
|
1058
|
-
const svc = new DonationService({
|
|
1059
|
-
orchestrator: orch,
|
|
1060
|
-
grants,
|
|
1061
|
-
store: new DonationStore(join(tmpRoot, 'donations')),
|
|
1062
|
-
});
|
|
1063
|
-
await svc.provision(baseRequest(token));
|
|
1064
|
-
expect(await svc.reapStaleAwaitingSeed(30 * 60 * 1000)).toEqual([]);
|
|
1065
|
-
expect(orch.removed).toEqual([]);
|
|
1066
|
-
});
|
|
1067
|
-
});
|
|
1068
|
-
|
|
1069
|
-
describe('DonationService.reapStaleProvisioning', () => {
|
|
1070
|
-
/**
|
|
1071
|
-
* A donation whose host died right after writing the `provisioning` row —
|
|
1072
|
-
* exactly what `provisionLocked` writes before it ever calls the
|
|
1073
|
-
* orchestrator (see `donation-service.ts`). No `dockerId`: nothing beyond
|
|
1074
|
-
* this raw write ever happened for the record itself.
|
|
1075
|
-
*/
|
|
1076
|
-
const stuckRecord = (opts: { id: string; token: string; at: string }): Donation => ({
|
|
1077
|
-
id: opts.id,
|
|
1078
|
-
grantToken: opts.token,
|
|
1079
|
-
partyId: 'party-P',
|
|
1080
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
1081
|
-
ownerKeys: [OWNER_KEY],
|
|
1082
|
-
profile: 'storage',
|
|
1083
|
-
status: 'provisioning',
|
|
1084
|
-
createdAt: opts.at,
|
|
1085
|
-
updatedAt: opts.at,
|
|
1086
|
-
});
|
|
1087
|
-
|
|
1088
|
-
it('reaps a donation stuck in provisioning past the TTL and marks it error', async () => {
|
|
1089
|
-
const orch = new FakeOrchestrator();
|
|
1090
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
1091
|
-
const { grants, token } = makeGrants({ maxNodes: 5 });
|
|
1092
|
-
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
1093
|
-
const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
|
|
1094
|
-
|
|
1095
|
-
store.put(stuckRecord({ id: 'grn_stuck', token, at: clock.toISOString() }));
|
|
1096
|
-
clock = new Date(clock.getTime() + DONATION_PROVISIONING_TTL_MS + 60_000);
|
|
1097
|
-
|
|
1098
|
-
const reaped = await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS);
|
|
1099
|
-
|
|
1100
|
-
expect(reaped).toEqual(['grn_stuck']);
|
|
1101
|
-
expect(store.get('grn_stuck')?.status).toBe('error');
|
|
1102
|
-
expect(store.get('grn_stuck')?.error).toMatch(/provisioning/);
|
|
1103
|
-
// Nothing was ever spawned — no dockerId to resolve, so nothing to stop or
|
|
1104
|
-
// remove by handle. The workdir the dead spawn created is still reachable
|
|
1105
|
-
// by container name, though, and this is the only path that will ever
|
|
1106
|
-
// remove it.
|
|
1107
|
-
expect(orch.stopped).toEqual([]);
|
|
1108
|
-
expect(orch.removed).toEqual([]);
|
|
1109
|
-
expect(orch.reclaimedWorkdirs).toEqual(['grn_stuck']);
|
|
1110
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
1111
|
-
});
|
|
1112
|
-
|
|
1113
|
-
it('stops and reclaims the child when the orchestrator can resolve its dockerId', async () => {
|
|
1114
|
-
const orch = new FakeOrchestrator();
|
|
1115
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
1116
|
-
const { grants, token } = makeGrants({ maxNodes: 5 });
|
|
1117
|
-
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
1118
|
-
const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
|
|
1119
|
-
|
|
1120
|
-
// Simulates a crash between the host spawning the child (the orchestrator
|
|
1121
|
-
// already knows it by dockerId) and `provisionLocked` writing that dockerId
|
|
1122
|
-
// onto the donation record — the resource-leak wrinkle from the ticket.
|
|
1123
|
-
const spawn = await orch.createContainer({
|
|
1124
|
-
containerId: 'grn_stuck',
|
|
1125
|
-
partyId: 'party-P',
|
|
1126
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
1127
|
-
profile: 'storage',
|
|
1128
|
-
pinnedOwnerKeys: [OWNER_KEY],
|
|
1129
|
-
});
|
|
1130
|
-
store.put(stuckRecord({ id: 'grn_stuck', token, at: clock.toISOString() }));
|
|
1131
|
-
clock = new Date(clock.getTime() + DONATION_PROVISIONING_TTL_MS + 60_000);
|
|
1132
|
-
|
|
1133
|
-
const reaped = await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS);
|
|
1134
|
-
|
|
1135
|
-
expect(reaped).toEqual(['grn_stuck']);
|
|
1136
|
-
expect(store.get('grn_stuck')?.status).toBe('error');
|
|
1137
|
-
expect(orch.stopped).toEqual([spawn.dockerId]);
|
|
1138
|
-
expect(orch.removed).toEqual([spawn.dockerId]);
|
|
1139
|
-
// `removeContainer` deletes that child's workdir itself; reclaiming by name
|
|
1140
|
-
// on top of it would be redundant.
|
|
1141
|
-
expect(orch.reclaimedWorkdirs).toEqual([]);
|
|
1142
|
-
expect(store.liveNodeCount(token)).toBe(0);
|
|
1143
|
-
});
|
|
1144
|
-
|
|
1145
|
-
it('leaves a fresh provisioning record within the TTL alone', async () => {
|
|
1146
|
-
const orch = new FakeOrchestrator();
|
|
1147
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
1148
|
-
const { grants, token } = makeGrants({ maxNodes: 5 });
|
|
1149
|
-
const clock = new Date('2025-01-01T00:00:00.000Z');
|
|
1150
|
-
const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
|
|
1151
|
-
|
|
1152
|
-
store.put(stuckRecord({ id: 'grn_fresh', token, at: clock.toISOString() }));
|
|
1153
|
-
|
|
1154
|
-
expect(await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS)).toEqual([]);
|
|
1155
|
-
expect(store.get('grn_fresh')?.status).toBe('provisioning');
|
|
1156
|
-
expect(orch.stopped).toEqual([]);
|
|
1157
|
-
});
|
|
1158
|
-
|
|
1159
|
-
it('leaves a record alone that legitimately advances between the sweep snapshot and its per-record re-read', async () => {
|
|
1160
|
-
const orch = new FakeOrchestrator();
|
|
1161
|
-
const store = new DonationStore(join(tmpRoot, 'donations'));
|
|
1162
|
-
const { grants, token } = makeGrants({ maxNodes: 5 });
|
|
1163
|
-
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
1164
|
-
const svc = new DonationService({ orchestrator: orch, grants, store, now: () => clock });
|
|
1165
|
-
|
|
1166
|
-
const firstSpawn = await orch.createContainer({
|
|
1167
|
-
containerId: 'grn_first',
|
|
1168
|
-
partyId: 'party-P',
|
|
1169
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
1170
|
-
profile: 'storage',
|
|
1171
|
-
pinnedOwnerKeys: [OWNER_KEY],
|
|
1172
|
-
});
|
|
1173
|
-
store.put(stuckRecord({ id: 'grn_first', token, at: clock.toISOString() }));
|
|
1174
|
-
store.put(stuckRecord({ id: 'grn_second', token, at: clock.toISOString() }));
|
|
1175
|
-
clock = new Date(clock.getTime() + DONATION_PROVISIONING_TTL_MS + 60_000);
|
|
1176
|
-
|
|
1177
|
-
// The second record's own in-flight `provisionLocked` call finally lands
|
|
1178
|
-
// while the sweep is still awaiting the FIRST record's stop — the sweep's
|
|
1179
|
-
// candidate list predates that write, so acting on the stale copy would
|
|
1180
|
-
// wrongly terminate a provision that just came good.
|
|
1181
|
-
orch.onStop = (dockerId) => {
|
|
1182
|
-
if (dockerId !== firstSpawn.dockerId) return;
|
|
1183
|
-
store.put({ ...store.get('grn_second')!, status: 'awaiting_seed', updatedAt: clock.toISOString() });
|
|
1184
|
-
};
|
|
1185
|
-
|
|
1186
|
-
const reaped = await svc.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS);
|
|
1187
|
-
|
|
1188
|
-
expect(reaped).toEqual(['grn_first']);
|
|
1189
|
-
expect(store.get('grn_first')?.status).toBe('error');
|
|
1190
|
-
expect(store.get('grn_second')?.status).toBe('awaiting_seed');
|
|
1191
|
-
expect(store.liveNodeCount(token)).toBe(1);
|
|
1192
|
-
});
|
|
1193
|
-
});
|