@serfab/cadre-host 0.12.0 → 1.0.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 +6 -4
- package/dist/bin/host.js +9 -1
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/__tests__/fake-orchestrator.js +1 -1
- package/dist/donation/__tests__/fake-orchestrator.js.map +1 -1
- package/dist/donation/donation-service.d.ts +7 -2
- package/dist/donation/donation-service.js +16 -2
- package/dist/donation/donation-service.js.map +1 -1
- package/dist/donation/donation-supervisor.d.ts +12 -2
- package/dist/donation/donation-supervisor.js +25 -2
- package/dist/donation/donation-supervisor.js.map +1 -1
- package/dist/donation/types.d.ts +2 -1
- package/dist/donation/types.js.map +1 -1
- package/dist/installer/index.d.ts +9 -1
- package/dist/installer/index.js +5 -1
- package/dist/installer/index.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +71 -11
- package/dist/orchestrator/host-process-orchestrator.js +173 -75
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +44 -2
- package/dist/orchestrator/port-allocator.js +63 -5
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/orchestrator/port-probe.d.ts +15 -0
- package/dist/orchestrator/port-probe.js +22 -0
- package/dist/orchestrator/port-probe.js.map +1 -0
- package/dist/orchestrator/types.d.ts +13 -1
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/server/routes/bootstrap-node-validation.d.ts +115 -0
- package/dist/server/routes/bootstrap-node-validation.js +197 -0
- package/dist/server/routes/bootstrap-node-validation.js.map +1 -0
- package/dist/server/routes/grants.js +8 -25
- package/dist/server/routes/grants.js.map +1 -1
- package/dist/server/routes/provision-request-validation.d.ts +45 -0
- package/dist/server/routes/provision-request-validation.js +97 -0
- package/dist/server/routes/provision-request-validation.js.map +1 -0
- package/dist/server/server.d.ts +4 -1
- package/dist/server/server.js +36 -2
- package/dist/server/server.js.map +1 -1
- package/dist/ui/assets/{index-C-iks41M.js → index-CEoNRwM_.js} +3 -3
- package/dist/ui/assets/{index-C-iks41M.js.map → index-CEoNRwM_.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/package.json +5 -4
- package/src/__tests__/orchestrator-env-scrub.test.ts +22 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +4 -4
- package/src/__tests__/orchestrator-owner.test.ts +45 -2
- package/src/__tests__/orchestrator-ports.test.ts +172 -0
- package/src/__tests__/orchestrator.test.ts +1062 -951
- package/src/bin/host.ts +9 -1
- package/src/donation/__tests__/donation-service.test.ts +94 -7
- package/src/donation/__tests__/donation-supervisor.test.ts +50 -12
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
- package/src/donation/__tests__/fake-orchestrator.ts +1 -1
- package/src/donation/donation-service.ts +22 -3
- package/src/donation/donation-supervisor.ts +25 -2
- package/src/donation/types.ts +2 -1
- package/src/installer/__tests__/installer.smoke.test.ts +263 -218
- package/src/installer/index.ts +327 -314
- package/src/orchestrator/host-process-orchestrator.ts +1250 -1138
- package/src/orchestrator/port-allocator.ts +148 -91
- package/src/orchestrator/port-probe.ts +31 -0
- package/src/orchestrator/types.ts +13 -1
- package/src/server/__tests__/bootstrap-node-validation.test.ts +138 -0
- package/src/server/__tests__/grants-route.test.ts +394 -263
- package/src/server/__tests__/nodes-route.test.ts +2 -2
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/status-route.test.ts +1 -1
- package/src/server/routes/bootstrap-node-validation.ts +206 -0
- package/src/server/routes/grants.ts +9 -26
- package/src/server/routes/provision-request-validation.ts +105 -0
- package/src/server/server.ts +157 -122
|
@@ -1,168 +1,168 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contract tests for `FakeOrchestrator` itself — the handle lifecycle it borrows
|
|
3
|
-
* from `HostProcessOrchestrator`. The donation suites lean on these semantics to
|
|
4
|
-
* express races that only exist because the real class drops and rejects
|
|
5
|
-
* handles, so this file exists to stop a later relaxation of the fake from
|
|
6
|
-
* quietly turning one of those tests green. Each case names the real behaviour
|
|
7
|
-
* it mirrors.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { describe, expect, it } from 'vitest';
|
|
11
|
-
|
|
12
|
-
import type { OrchestratorCreateRequest } from '@serfab/cadre-provider';
|
|
13
|
-
|
|
14
|
-
import { FakeOrchestrator } from './fake-orchestrator.js';
|
|
15
|
-
|
|
16
|
-
const request = (containerId: string): OrchestratorCreateRequest => ({
|
|
17
|
-
containerId,
|
|
18
|
-
partyId: 'party-P',
|
|
19
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/
|
|
20
|
-
profile: 'storage',
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe('FakeOrchestrator handle lifecycle', () => {
|
|
24
|
-
it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
|
|
25
|
-
const orch = new FakeOrchestrator();
|
|
26
|
-
const first = await orch.createContainer(request('grn_1'));
|
|
27
|
-
const second = await orch.createContainer(request('grn_1'));
|
|
28
|
-
|
|
29
|
-
await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
|
|
30
|
-
`Container not found: ${first.dockerId}`,
|
|
31
|
-
);
|
|
32
|
-
// The rejected call left no trace: `stopped` records handles acted on, not
|
|
33
|
-
// calls attempted.
|
|
34
|
-
expect(orch.stopped).toEqual([]);
|
|
35
|
-
// …and the container now resolves only to the new spawn.
|
|
36
|
-
expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
|
|
37
|
-
|
|
38
|
-
await orch.stopContainer(second.dockerId);
|
|
39
|
-
expect(orch.stopped).toEqual([second.dockerId]);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('drops only the re-spawned container, leaving another container untouched', async () => {
|
|
43
|
-
const orch = new FakeOrchestrator();
|
|
44
|
-
const other = await orch.createContainer(request('grn_2'));
|
|
45
|
-
await orch.createContainer(request('grn_1'));
|
|
46
|
-
await orch.createContainer(request('grn_1'));
|
|
47
|
-
|
|
48
|
-
// The real drop filters on containerId; a fake that cleared the whole map
|
|
49
|
-
// would still pass every same-container case above.
|
|
50
|
-
await expect(orch.stopContainer(other.dockerId)).resolves.toBeUndefined();
|
|
51
|
-
expect(orch.resolveDockerId('grn_2')).toBe(other.dockerId);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('keeps the prior handle live across the whole pre-drop await window', async () => {
|
|
55
|
-
const orch = new FakeOrchestrator();
|
|
56
|
-
const first = await orch.createContainer(request('grn_1'));
|
|
57
|
-
|
|
58
|
-
// The invariant the donation race tests rest on: a concurrent stop started
|
|
59
|
-
// from `onCreate` still finds the old handle. Deferring it onto a microtask
|
|
60
|
-
// puts it strictly after `onCreate` returns and strictly before the
|
|
61
|
-
// `createDelayMs` timer, so this one case pins the drop behind BOTH — with
|
|
62
|
-
// no dependence on wall-clock timing. The outcome is captured rather than
|
|
63
|
-
// rethrown so a regression fails this assertion instead of surfacing as an
|
|
64
|
-
// unhandled rejection.
|
|
65
|
-
orch.createDelayMs = 1;
|
|
66
|
-
let outcome: Promise<string> | undefined;
|
|
67
|
-
orch.onCreate = () => {
|
|
68
|
-
outcome ??= Promise.resolve()
|
|
69
|
-
.then(() => orch.stopContainer(first.dockerId))
|
|
70
|
-
.then(() => 'stopped', (err: Error) => `rejected: ${err.message}`);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const second = await orch.createContainer(request('grn_1'));
|
|
74
|
-
|
|
75
|
-
expect(await outcome).toBe('stopped');
|
|
76
|
-
expect(orch.stopped).toEqual([first.dockerId]);
|
|
77
|
-
expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('leaves the prior handle in place when the re-spawn fails (mirrors restoreDroppedHandles)', async () => {
|
|
81
|
-
const orch = new FakeOrchestrator();
|
|
82
|
-
const first = await orch.createContainer(request('grn_1'));
|
|
83
|
-
|
|
84
|
-
orch.failCreate = true;
|
|
85
|
-
await expect(orch.createContainer(request('grn_1'))).rejects.toThrow('spawn boom');
|
|
86
|
-
|
|
87
|
-
// Host state is exactly as the failed attempt found it — which is what lets
|
|
88
|
-
// `DonationSupervisor.giveUp` still stop the child it knows about.
|
|
89
|
-
await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
|
|
90
|
-
expect(orch.stopped).toEqual([first.dockerId]);
|
|
91
|
-
expect(orch.resolveDockerId('grn_1')).toBe(first.dockerId);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('rejects a never-issued dockerId (mirrors requireHandle)', async () => {
|
|
95
|
-
const orch = new FakeOrchestrator();
|
|
96
|
-
|
|
97
|
-
await expect(orch.removeContainer('dock_nope')).rejects.toThrow(
|
|
98
|
-
'Container not found: dock_nope',
|
|
99
|
-
);
|
|
100
|
-
expect(orch.removed).toEqual([]);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('rejects a second call once a handle has been removed (mirrors removeContainer deleting it)', async () => {
|
|
104
|
-
const orch = new FakeOrchestrator();
|
|
105
|
-
const { dockerId } = await orch.createContainer(request('grn_1'));
|
|
106
|
-
|
|
107
|
-
await orch.removeContainer(dockerId);
|
|
108
|
-
expect(orch.removed).toEqual([dockerId]);
|
|
109
|
-
|
|
110
|
-
await expect(orch.stopContainer(dockerId)).rejects.toThrow(
|
|
111
|
-
`Container not found: ${dockerId}`,
|
|
112
|
-
);
|
|
113
|
-
expect(orch.stopped).toEqual([]);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('fires onStop for a rejected stop, so a test can still observe the attempt', async () => {
|
|
117
|
-
const orch = new FakeOrchestrator();
|
|
118
|
-
const attempted: string[] = [];
|
|
119
|
-
orch.onStop = (id) => { attempted.push(id); };
|
|
120
|
-
|
|
121
|
-
await expect(orch.stopContainer('dock_nope')).rejects.toThrow('Container not found');
|
|
122
|
-
expect(attempted).toEqual(['dock_nope']);
|
|
123
|
-
expect(orch.stopped).toEqual([]);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('refuses to reclaim the workdir of a container that still resolves (mirrors the live-handle guard)', async () => {
|
|
127
|
-
const orch = new FakeOrchestrator();
|
|
128
|
-
await orch.createContainer(request('grn_1'));
|
|
129
|
-
|
|
130
|
-
// A live handle owns that directory; only `removeContainer` — which stops
|
|
131
|
-
// the child first — may delete it.
|
|
132
|
-
expect(orch.reclaimWorkdir('grn_1')).toBe(false);
|
|
133
|
-
expect(orch.reclaimedWorkdirs).toEqual([]);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('reclaims the workdir of a container no handle owns', async () => {
|
|
137
|
-
const orch = new FakeOrchestrator();
|
|
138
|
-
|
|
139
|
-
// Never spawned — the stuck-`provisioning` record whose host died before a
|
|
140
|
-
// handle ever existed.
|
|
141
|
-
expect(orch.reclaimWorkdir('grn_orphan')).toBe(true);
|
|
142
|
-
expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan']);
|
|
143
|
-
|
|
144
|
-
// …and once the container's own handle is removed, its id becomes
|
|
145
|
-
// reclaimable too.
|
|
146
|
-
const { dockerId } = await orch.createContainer(request('grn_1'));
|
|
147
|
-
expect(orch.reclaimWorkdir('grn_1')).toBe(false);
|
|
148
|
-
await orch.removeContainer(dockerId);
|
|
149
|
-
expect(orch.reclaimWorkdir('grn_1')).toBe(true);
|
|
150
|
-
expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan', 'grn_1']);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
|
|
154
|
-
const orch = new FakeOrchestrator();
|
|
155
|
-
const first = await orch.createContainer(request('grn_1'));
|
|
156
|
-
|
|
157
|
-
let observed: { spawned: string; resolves: string | undefined } | undefined;
|
|
158
|
-
orch.onSpawned = (dockerId) => {
|
|
159
|
-
observed = { spawned: dockerId, resolves: orch.resolveDockerId('grn_1') };
|
|
160
|
-
};
|
|
161
|
-
const second = await orch.createContainer(request('grn_1'));
|
|
162
|
-
|
|
163
|
-
// The post-drop world: the container already resolves to the new handle, so
|
|
164
|
-
// work driven from here sees what `abandonRespawn`'s caller would see.
|
|
165
|
-
expect(observed).toEqual({ spawned: second.dockerId, resolves: second.dockerId });
|
|
166
|
-
expect(second.dockerId).not.toBe(first.dockerId);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for `FakeOrchestrator` itself — the handle lifecycle it borrows
|
|
3
|
+
* from `HostProcessOrchestrator`. The donation suites lean on these semantics to
|
|
4
|
+
* express races that only exist because the real class drops and rejects
|
|
5
|
+
* handles, so this file exists to stop a later relaxation of the fake from
|
|
6
|
+
* quietly turning one of those tests green. Each case names the real behaviour
|
|
7
|
+
* it mirrors.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import type { OrchestratorCreateRequest } from '@serfab/cadre-provider';
|
|
13
|
+
|
|
14
|
+
import { FakeOrchestrator } from './fake-orchestrator.js';
|
|
15
|
+
|
|
16
|
+
const request = (containerId: string): OrchestratorCreateRequest => ({
|
|
17
|
+
containerId,
|
|
18
|
+
partyId: 'party-P',
|
|
19
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
20
|
+
profile: 'storage',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('FakeOrchestrator handle lifecycle', () => {
|
|
24
|
+
it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
|
|
25
|
+
const orch = new FakeOrchestrator();
|
|
26
|
+
const first = await orch.createContainer(request('grn_1'));
|
|
27
|
+
const second = await orch.createContainer(request('grn_1'));
|
|
28
|
+
|
|
29
|
+
await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
|
|
30
|
+
`Container not found: ${first.dockerId}`,
|
|
31
|
+
);
|
|
32
|
+
// The rejected call left no trace: `stopped` records handles acted on, not
|
|
33
|
+
// calls attempted.
|
|
34
|
+
expect(orch.stopped).toEqual([]);
|
|
35
|
+
// …and the container now resolves only to the new spawn.
|
|
36
|
+
expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
|
|
37
|
+
|
|
38
|
+
await orch.stopContainer(second.dockerId);
|
|
39
|
+
expect(orch.stopped).toEqual([second.dockerId]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('drops only the re-spawned container, leaving another container untouched', async () => {
|
|
43
|
+
const orch = new FakeOrchestrator();
|
|
44
|
+
const other = await orch.createContainer(request('grn_2'));
|
|
45
|
+
await orch.createContainer(request('grn_1'));
|
|
46
|
+
await orch.createContainer(request('grn_1'));
|
|
47
|
+
|
|
48
|
+
// The real drop filters on containerId; a fake that cleared the whole map
|
|
49
|
+
// would still pass every same-container case above.
|
|
50
|
+
await expect(orch.stopContainer(other.dockerId)).resolves.toBeUndefined();
|
|
51
|
+
expect(orch.resolveDockerId('grn_2')).toBe(other.dockerId);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps the prior handle live across the whole pre-drop await window', async () => {
|
|
55
|
+
const orch = new FakeOrchestrator();
|
|
56
|
+
const first = await orch.createContainer(request('grn_1'));
|
|
57
|
+
|
|
58
|
+
// The invariant the donation race tests rest on: a concurrent stop started
|
|
59
|
+
// from `onCreate` still finds the old handle. Deferring it onto a microtask
|
|
60
|
+
// puts it strictly after `onCreate` returns and strictly before the
|
|
61
|
+
// `createDelayMs` timer, so this one case pins the drop behind BOTH — with
|
|
62
|
+
// no dependence on wall-clock timing. The outcome is captured rather than
|
|
63
|
+
// rethrown so a regression fails this assertion instead of surfacing as an
|
|
64
|
+
// unhandled rejection.
|
|
65
|
+
orch.createDelayMs = 1;
|
|
66
|
+
let outcome: Promise<string> | undefined;
|
|
67
|
+
orch.onCreate = () => {
|
|
68
|
+
outcome ??= Promise.resolve()
|
|
69
|
+
.then(() => orch.stopContainer(first.dockerId))
|
|
70
|
+
.then(() => 'stopped', (err: Error) => `rejected: ${err.message}`);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const second = await orch.createContainer(request('grn_1'));
|
|
74
|
+
|
|
75
|
+
expect(await outcome).toBe('stopped');
|
|
76
|
+
expect(orch.stopped).toEqual([first.dockerId]);
|
|
77
|
+
expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('leaves the prior handle in place when the re-spawn fails (mirrors restoreDroppedHandles)', async () => {
|
|
81
|
+
const orch = new FakeOrchestrator();
|
|
82
|
+
const first = await orch.createContainer(request('grn_1'));
|
|
83
|
+
|
|
84
|
+
orch.failCreate = true;
|
|
85
|
+
await expect(orch.createContainer(request('grn_1'))).rejects.toThrow('spawn boom');
|
|
86
|
+
|
|
87
|
+
// Host state is exactly as the failed attempt found it — which is what lets
|
|
88
|
+
// `DonationSupervisor.giveUp` still stop the child it knows about.
|
|
89
|
+
await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
|
|
90
|
+
expect(orch.stopped).toEqual([first.dockerId]);
|
|
91
|
+
expect(orch.resolveDockerId('grn_1')).toBe(first.dockerId);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects a never-issued dockerId (mirrors requireHandle)', async () => {
|
|
95
|
+
const orch = new FakeOrchestrator();
|
|
96
|
+
|
|
97
|
+
await expect(orch.removeContainer('dock_nope')).rejects.toThrow(
|
|
98
|
+
'Container not found: dock_nope',
|
|
99
|
+
);
|
|
100
|
+
expect(orch.removed).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects a second call once a handle has been removed (mirrors removeContainer deleting it)', async () => {
|
|
104
|
+
const orch = new FakeOrchestrator();
|
|
105
|
+
const { dockerId } = await orch.createContainer(request('grn_1'));
|
|
106
|
+
|
|
107
|
+
await orch.removeContainer(dockerId);
|
|
108
|
+
expect(orch.removed).toEqual([dockerId]);
|
|
109
|
+
|
|
110
|
+
await expect(orch.stopContainer(dockerId)).rejects.toThrow(
|
|
111
|
+
`Container not found: ${dockerId}`,
|
|
112
|
+
);
|
|
113
|
+
expect(orch.stopped).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('fires onStop for a rejected stop, so a test can still observe the attempt', async () => {
|
|
117
|
+
const orch = new FakeOrchestrator();
|
|
118
|
+
const attempted: string[] = [];
|
|
119
|
+
orch.onStop = (id) => { attempted.push(id); };
|
|
120
|
+
|
|
121
|
+
await expect(orch.stopContainer('dock_nope')).rejects.toThrow('Container not found');
|
|
122
|
+
expect(attempted).toEqual(['dock_nope']);
|
|
123
|
+
expect(orch.stopped).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('refuses to reclaim the workdir of a container that still resolves (mirrors the live-handle guard)', async () => {
|
|
127
|
+
const orch = new FakeOrchestrator();
|
|
128
|
+
await orch.createContainer(request('grn_1'));
|
|
129
|
+
|
|
130
|
+
// A live handle owns that directory; only `removeContainer` — which stops
|
|
131
|
+
// the child first — may delete it.
|
|
132
|
+
expect(orch.reclaimWorkdir('grn_1')).toBe(false);
|
|
133
|
+
expect(orch.reclaimedWorkdirs).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('reclaims the workdir of a container no handle owns', async () => {
|
|
137
|
+
const orch = new FakeOrchestrator();
|
|
138
|
+
|
|
139
|
+
// Never spawned — the stuck-`provisioning` record whose host died before a
|
|
140
|
+
// handle ever existed.
|
|
141
|
+
expect(orch.reclaimWorkdir('grn_orphan')).toBe(true);
|
|
142
|
+
expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan']);
|
|
143
|
+
|
|
144
|
+
// …and once the container's own handle is removed, its id becomes
|
|
145
|
+
// reclaimable too.
|
|
146
|
+
const { dockerId } = await orch.createContainer(request('grn_1'));
|
|
147
|
+
expect(orch.reclaimWorkdir('grn_1')).toBe(false);
|
|
148
|
+
await orch.removeContainer(dockerId);
|
|
149
|
+
expect(orch.reclaimWorkdir('grn_1')).toBe(true);
|
|
150
|
+
expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan', 'grn_1']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
|
|
154
|
+
const orch = new FakeOrchestrator();
|
|
155
|
+
const first = await orch.createContainer(request('grn_1'));
|
|
156
|
+
|
|
157
|
+
let observed: { spawned: string; resolves: string | undefined } | undefined;
|
|
158
|
+
orch.onSpawned = (dockerId) => {
|
|
159
|
+
observed = { spawned: dockerId, resolves: orch.resolveDockerId('grn_1') };
|
|
160
|
+
};
|
|
161
|
+
const second = await orch.createContainer(request('grn_1'));
|
|
162
|
+
|
|
163
|
+
// The post-drop world: the container already resolves to the new handle, so
|
|
164
|
+
// work driven from here sees what `abandonRespawn`'s caller would see.
|
|
165
|
+
expect(observed).toEqual({ spawned: second.dockerId, resolves: second.dockerId });
|
|
166
|
+
expect(second.dockerId).not.toBe(first.dockerId);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -220,6 +220,6 @@ function toNodeInfo(dockerId: string, child: FakeChild): ManagedNodeInfo {
|
|
|
220
220
|
status: child.running ? 'running' : 'stopped',
|
|
221
221
|
spawnedAt: new Date(0).toISOString(),
|
|
222
222
|
workdir: `/fake/${child.containerId}`,
|
|
223
|
-
ports: { health: 0, metrics: 0, p2p: 0, admin: 0 },
|
|
223
|
+
ports: { health: 0, metrics: 0, p2p: 0, admin: 0, ws: 0 },
|
|
224
224
|
};
|
|
225
225
|
}
|
|
@@ -78,7 +78,12 @@ export interface DonationProvisionRequest {
|
|
|
78
78
|
grantToken: string;
|
|
79
79
|
/** The REQUESTER's cadre — the party the donated node joins. */
|
|
80
80
|
partyId: string;
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Requester control-network bootstrap multiaddrs (dialable, with `/p2p/`). Empty
|
|
83
|
+
* means the requester dials the node itself: a phone has no address to give, so it
|
|
84
|
+
* reaches the node at the WebSocket address {@link DonationService.getPeer} reports
|
|
85
|
+
* rather than being dialed by it.
|
|
86
|
+
*/
|
|
82
87
|
bootstrapNodes: string[];
|
|
83
88
|
/**
|
|
84
89
|
* The requester's owner public key(s), base64url. Pinned as cold-start
|
|
@@ -450,6 +455,18 @@ export class DonationService {
|
|
|
450
455
|
} catch (err) {
|
|
451
456
|
throw new DonationError('seed_failed', `Donated node unreachable: ${errorMessage(err)}`);
|
|
452
457
|
}
|
|
458
|
+
if (res.status === 401) {
|
|
459
|
+
// The node refused the host's bearer, not the requester's seed — the node
|
|
460
|
+
// never looked at the seed. So the fault is the host's own token
|
|
461
|
+
// bookkeeping (the record names a token the running child was not started
|
|
462
|
+
// with), and the message must not read as a trust-policy rejection.
|
|
463
|
+
//
|
|
464
|
+
// Still `seed_failed`, deliberately: the phone retries that code, and a
|
|
465
|
+
// bookkeeping gap that is mid-repair (a respawn about to write its new
|
|
466
|
+
// token) can clear inside that retry window.
|
|
467
|
+
log('donated node for donation %s refused the host seed credential (401)', id);
|
|
468
|
+
throw new DonationError('seed_failed', 'Donated node rejected the host\'s seed credential (401)');
|
|
469
|
+
}
|
|
453
470
|
if (!res.ok) {
|
|
454
471
|
const body = await res.text();
|
|
455
472
|
throw new DonationError('seed_failed', `Donated node /seed returned ${res.status}: ${body}`);
|
|
@@ -524,7 +541,9 @@ export class DonationService {
|
|
|
524
541
|
`Donation ${id} cannot be respawned in status ${donation.status}`,
|
|
525
542
|
);
|
|
526
543
|
}
|
|
527
|
-
|
|
544
|
+
// An empty `bootstrapNodes` is a real spawn input (a requester that dials the node
|
|
545
|
+
// itself); only a missing field marks a record written before inputs were persisted.
|
|
546
|
+
if (donation.bootstrapNodes === undefined || !donation.ownerKeys?.length) {
|
|
528
547
|
log('donation %s is not respawnable (record predates persisted spawn inputs)', id);
|
|
529
548
|
return { outcome: 'not_respawnable' };
|
|
530
549
|
}
|
|
@@ -613,7 +632,7 @@ export class DonationService {
|
|
|
613
632
|
* (`<rootDir>/<containerId>`), reclaiming here would delete exactly what
|
|
614
633
|
* `giveUp` meant to keep.
|
|
615
634
|
*
|
|
616
|
-
* NOTE: that skipped reclaim leaks the new spawn's
|
|
635
|
+
* NOTE: that skipped reclaim leaks the new spawn's ports — the record
|
|
617
636
|
* still names the *previous* `dockerId`, so a later `terminate` cleans up the
|
|
618
637
|
* old handle, not this one. Unreachable today (`giveUp` is only called from
|
|
619
638
|
* the supervisor's serialized pass, so it cannot overlap a respawn); if a
|
|
@@ -23,7 +23,8 @@ export const DONATION_RESPAWN_BACKOFF_BASE_MS = 5_000;
|
|
|
23
23
|
*
|
|
24
24
|
* NOTE: not reachable at the current {@link DONATION_RESPAWN_MAX_ATTEMPTS} of 5
|
|
25
25
|
* — the longest wait actually served is the one before the 5th attempt,
|
|
26
|
-
* `5s * 2^4` = 80s, and
|
|
26
|
+
* `5s * 2^4` = 80s, and once that attempt fails or its node dies the supervisor
|
|
27
|
+
* gives up rather than waiting again. The cap only starts clamping
|
|
27
28
|
* once a record can reach 6 recorded attempts (`5s * 2^6` = 320s), so raising
|
|
28
29
|
* the attempt cap without revisiting this one changes nothing.
|
|
29
30
|
*/
|
|
@@ -33,6 +34,12 @@ export const DONATION_RESPAWN_BACKOFF_MAX_MS = 5 * 60_000;
|
|
|
33
34
|
* Consecutive respawn attempts after which the host stops trying and marks the
|
|
34
35
|
* donation `error` — a node that will not stay up is a host-side fault the
|
|
35
36
|
* borrower cannot see, so surface it instead of spinning forever.
|
|
37
|
+
*
|
|
38
|
+
* Every attempt counts, whether its spawn failed or succeeded: a node that spawns
|
|
39
|
+
* and then dies before {@link DONATION_RESPAWN_HEALTHY_MS} refills the budget is
|
|
40
|
+
* as much a crash loop as one that never spawns. Give-up happens on whichever
|
|
41
|
+
* comes first — an attempt that throws with the cap reached, or a pass that finds
|
|
42
|
+
* the node down with the cap already reached.
|
|
36
43
|
*/
|
|
37
44
|
export const DONATION_RESPAWN_MAX_ATTEMPTS = 5;
|
|
38
45
|
|
|
@@ -232,6 +239,19 @@ export class DonationSupervisor {
|
|
|
232
239
|
this.refillBudgetIfHealthy(donation);
|
|
233
240
|
return undefined;
|
|
234
241
|
}
|
|
242
|
+
// The budget is spent, and the last attempt did not stay up long enough to
|
|
243
|
+
// refill it. Checked here and not only when `respawn` throws: a spawn that
|
|
244
|
+
// succeeds and then dies at once (a crash on boot, a port clash) never
|
|
245
|
+
// throws, so a catch-only check would respawn it forever.
|
|
246
|
+
// NOTE: a record at the cap whose last respawn was still up but not yet
|
|
247
|
+
// refilled when the host went down is given up on host restart rather than
|
|
248
|
+
// tried once more; if that is ever seen, skip this check when the host
|
|
249
|
+
// started after `respawn.lastAttemptAt`.
|
|
250
|
+
const attempts = donation.respawn?.attempts ?? 0;
|
|
251
|
+
if (attempts >= DONATION_RESPAWN_MAX_ATTEMPTS) {
|
|
252
|
+
await this.giveUp(donation.id, attempts, 'the node did not stay running after its last respawn');
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
235
255
|
if (!this.backoffElapsed(donation)) {
|
|
236
256
|
log('donation %s is down but still inside its respawn backoff', donation.id);
|
|
237
257
|
return undefined;
|
|
@@ -294,7 +314,10 @@ export class DonationSupervisor {
|
|
|
294
314
|
return this.now().getTime() - last > DONATION_RESPAWN_HEALTHY_MS;
|
|
295
315
|
}
|
|
296
316
|
|
|
297
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* One respawn attempt, with the give-up check on failure. (The check for an
|
|
319
|
+
* attempt that spawned but then died lives in {@link reconcileOne}.)
|
|
320
|
+
*/
|
|
298
321
|
private async attemptRespawn(donation: Donation): Promise<string | undefined> {
|
|
299
322
|
try {
|
|
300
323
|
const result = await this.service.respawn(donation.id);
|
package/src/donation/types.ts
CHANGED
|
@@ -149,7 +149,8 @@ export interface Donation {
|
|
|
149
149
|
/** The REQUESTER's cadre (a foreign party this host merely hosts a node for). */
|
|
150
150
|
partyId: string;
|
|
151
151
|
/**
|
|
152
|
-
* Requester control-network bootstrap multiaddrs — replayed on respawn.
|
|
152
|
+
* Requester control-network bootstrap multiaddrs — replayed on respawn. Empty when
|
|
153
|
+
* the requester dials the node itself (see `DonationProvisionRequest.bootstrapNodes`).
|
|
153
154
|
* Absent on records written before respawn support; those are not respawnable.
|
|
154
155
|
*/
|
|
155
156
|
bootstrapNodes?: string[];
|