@serfab/cadre-host 0.13.0 → 1.1.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 -80
- 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 +30 -11
- package/dist/server/routes/bootstrap-node-validation.js +32 -13
- package/dist/server/routes/bootstrap-node-validation.js.map +1 -1
- 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 +4 -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 +190 -79
- package/src/bin/host.ts +9 -1
- package/src/donation/__tests__/donation-service.test.ts +1155 -1068
- package/src/donation/__tests__/donation-supervisor.test.ts +548 -510
- 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 -1143
- 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 +12 -8
- package/src/server/__tests__/grants-route.test.ts +72 -4
- 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 +32 -13
- package/src/server/server.ts +157 -122
|
@@ -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[];
|
|
@@ -1,218 +1,263 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
import { Installer } from '../index.js';
|
|
7
|
-
import type { ServiceHost, ServiceHostContext, ServiceHostStatus } from '../service-host/types.js';
|
|
8
|
-
|
|
9
|
-
class FakeServiceHost implements ServiceHost {
|
|
10
|
-
readonly name = 'cadre-host';
|
|
11
|
-
installCalls: ServiceHostContext[] = [];
|
|
12
|
-
uninstallCalls: ServiceHostContext[] = [];
|
|
13
|
-
installed = false;
|
|
14
|
-
running = false;
|
|
15
|
-
|
|
16
|
-
async install(ctx: ServiceHostContext): Promise<void> {
|
|
17
|
-
this.installCalls.push(ctx);
|
|
18
|
-
this.installed = true;
|
|
19
|
-
this.running = true;
|
|
20
|
-
}
|
|
21
|
-
async uninstall(ctx: ServiceHostContext): Promise<void> {
|
|
22
|
-
this.uninstallCalls.push(ctx);
|
|
23
|
-
this.installed = false;
|
|
24
|
-
this.running = false;
|
|
25
|
-
}
|
|
26
|
-
async restart(_ctx: ServiceHostContext): Promise<void> {
|
|
27
|
-
this.running = true;
|
|
28
|
-
}
|
|
29
|
-
async status(_ctx: ServiceHostContext): Promise<ServiceHostStatus> {
|
|
30
|
-
return { installed: this.installed, running: this.running };
|
|
31
|
-
}
|
|
32
|
-
renderUnit(_ctx: ServiceHostContext): string | null {
|
|
33
|
-
return 'rendered-unit';
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe('Installer smoke', () => {
|
|
38
|
-
let tmp: string;
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
tmp = mkdtempSync(join(tmpdir(), 'cadre-host-installer-smoke-'));
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
afterEach(() => {
|
|
45
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('install creates config + identity + invokes service-host', async () => {
|
|
49
|
-
const fake = new FakeServiceHost();
|
|
50
|
-
const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
|
|
51
|
-
const result = await installer.install({
|
|
52
|
-
nonInteractive: true,
|
|
53
|
-
dataDir: tmp,
|
|
54
|
-
uiPort: 19999,
|
|
55
|
-
libp2pPort: 14001,
|
|
56
|
-
openBrowser: false,
|
|
57
|
-
noInvite: true,
|
|
58
|
-
serviceHost: fake,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
expect(result.dataDir).toBe(tmp);
|
|
62
|
-
expect(result.uiUrl).toBe('http://127.0.0.1:19999/');
|
|
63
|
-
expect(result.serviceName).toBe('cadre-host');
|
|
64
|
-
expect(result.configPath).toBe(join(tmp, 'host.config.json'));
|
|
65
|
-
|
|
66
|
-
expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
|
|
67
|
-
expect(existsSync(join(tmp, 'identity.key'))).toBe(true);
|
|
68
|
-
expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
|
|
69
|
-
expect(existsSync(join(tmp, 'logs'))).toBe(true);
|
|
70
|
-
|
|
71
|
-
expect(fake.installCalls).toHaveLength(1);
|
|
72
|
-
expect(fake.installCalls[0]!.dataDir).toBe(tmp);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('
|
|
76
|
-
const fake = new FakeServiceHost();
|
|
77
|
-
const installer = new Installer({ platform: 'linux' });
|
|
78
|
-
await installer.install({
|
|
79
|
-
nonInteractive: true,
|
|
80
|
-
dataDir: tmp,
|
|
81
|
-
uiPort:
|
|
82
|
-
libp2pPort:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
expect(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
expect(
|
|
91
|
-
expect(existsSync(join(tmp, '
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
});
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { Installer } from '../index.js';
|
|
7
|
+
import type { ServiceHost, ServiceHostContext, ServiceHostStatus } from '../service-host/types.js';
|
|
8
|
+
|
|
9
|
+
class FakeServiceHost implements ServiceHost {
|
|
10
|
+
readonly name = 'cadre-host';
|
|
11
|
+
installCalls: ServiceHostContext[] = [];
|
|
12
|
+
uninstallCalls: ServiceHostContext[] = [];
|
|
13
|
+
installed = false;
|
|
14
|
+
running = false;
|
|
15
|
+
|
|
16
|
+
async install(ctx: ServiceHostContext): Promise<void> {
|
|
17
|
+
this.installCalls.push(ctx);
|
|
18
|
+
this.installed = true;
|
|
19
|
+
this.running = true;
|
|
20
|
+
}
|
|
21
|
+
async uninstall(ctx: ServiceHostContext): Promise<void> {
|
|
22
|
+
this.uninstallCalls.push(ctx);
|
|
23
|
+
this.installed = false;
|
|
24
|
+
this.running = false;
|
|
25
|
+
}
|
|
26
|
+
async restart(_ctx: ServiceHostContext): Promise<void> {
|
|
27
|
+
this.running = true;
|
|
28
|
+
}
|
|
29
|
+
async status(_ctx: ServiceHostContext): Promise<ServiceHostStatus> {
|
|
30
|
+
return { installed: this.installed, running: this.running };
|
|
31
|
+
}
|
|
32
|
+
renderUnit(_ctx: ServiceHostContext): string | null {
|
|
33
|
+
return 'rendered-unit';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('Installer smoke', () => {
|
|
38
|
+
let tmp: string;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
tmp = mkdtempSync(join(tmpdir(), 'cadre-host-installer-smoke-'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('install creates config + identity + invokes service-host', async () => {
|
|
49
|
+
const fake = new FakeServiceHost();
|
|
50
|
+
const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
|
|
51
|
+
const result = await installer.install({
|
|
52
|
+
nonInteractive: true,
|
|
53
|
+
dataDir: tmp,
|
|
54
|
+
uiPort: 19999,
|
|
55
|
+
libp2pPort: 14001,
|
|
56
|
+
openBrowser: false,
|
|
57
|
+
noInvite: true,
|
|
58
|
+
serviceHost: fake,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result.dataDir).toBe(tmp);
|
|
62
|
+
expect(result.uiUrl).toBe('http://127.0.0.1:19999/');
|
|
63
|
+
expect(result.serviceName).toBe('cadre-host');
|
|
64
|
+
expect(result.configPath).toBe(join(tmp, 'host.config.json'));
|
|
65
|
+
|
|
66
|
+
expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
|
|
67
|
+
expect(existsSync(join(tmp, 'identity.key'))).toBe(true);
|
|
68
|
+
expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
|
|
69
|
+
expect(existsSync(join(tmp, 'logs'))).toBe(true);
|
|
70
|
+
|
|
71
|
+
expect(fake.installCalls).toHaveLength(1);
|
|
72
|
+
expect(fake.installCalls[0]!.dataDir).toBe(tmp);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('noService writes the data dir without registering a service', async () => {
|
|
76
|
+
const fake = new FakeServiceHost();
|
|
77
|
+
const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
|
|
78
|
+
const result = await installer.install({
|
|
79
|
+
nonInteractive: true,
|
|
80
|
+
dataDir: tmp,
|
|
81
|
+
uiPort: 19995,
|
|
82
|
+
libp2pPort: 14005,
|
|
83
|
+
noService: true,
|
|
84
|
+
serviceHost: fake,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(fake.installCalls).toHaveLength(0);
|
|
88
|
+
expect(result.serviceName).toBeUndefined();
|
|
89
|
+
expect(result.uiUrl).toBe('http://127.0.0.1:19995/');
|
|
90
|
+
expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
|
|
91
|
+
expect(existsSync(join(tmp, 'identity.key'))).toBe(true);
|
|
92
|
+
expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('noService on an interactive install skips the enrollment-invite fetch', async () => {
|
|
96
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
97
|
+
try {
|
|
98
|
+
const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
|
|
99
|
+
const result = await installer.install({
|
|
100
|
+
nonInteractive: false,
|
|
101
|
+
noService: true,
|
|
102
|
+
wizard: async () => ({
|
|
103
|
+
dataDir: tmp,
|
|
104
|
+
uiPort: 19994,
|
|
105
|
+
libp2pPort: 14006,
|
|
106
|
+
upnpEnabled: false,
|
|
107
|
+
configureDdns: false,
|
|
108
|
+
ownCadre: false,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
113
|
+
expect(result.enrollmentInvite).toBeUndefined();
|
|
114
|
+
expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
|
|
115
|
+
} finally {
|
|
116
|
+
fetchSpy.mockRestore();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('uninstall removes data only when --remove-data is set', async () => {
|
|
121
|
+
const fake = new FakeServiceHost();
|
|
122
|
+
const installer = new Installer({ platform: 'linux' });
|
|
123
|
+
await installer.install({
|
|
124
|
+
nonInteractive: true,
|
|
125
|
+
dataDir: tmp,
|
|
126
|
+
uiPort: 19998,
|
|
127
|
+
libp2pPort: 14002,
|
|
128
|
+
openBrowser: false,
|
|
129
|
+
noInvite: true,
|
|
130
|
+
serviceHost: fake,
|
|
131
|
+
});
|
|
132
|
+
expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
|
|
133
|
+
|
|
134
|
+
await installer.uninstall({ yes: true, removeData: false, dataDir: tmp, serviceHost: fake });
|
|
135
|
+
expect(fake.uninstallCalls).toHaveLength(1);
|
|
136
|
+
expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
|
|
137
|
+
|
|
138
|
+
await installer.uninstall({ yes: true, removeData: true, dataDir: tmp, serviceHost: fake });
|
|
139
|
+
expect(existsSync(tmp)).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects --system in v1', async () => {
|
|
143
|
+
const fake = new FakeServiceHost();
|
|
144
|
+
const installer = new Installer({ platform: 'linux' });
|
|
145
|
+
await expect(installer.install({
|
|
146
|
+
nonInteractive: true,
|
|
147
|
+
dataDir: tmp,
|
|
148
|
+
system: true,
|
|
149
|
+
serviceHost: fake,
|
|
150
|
+
openBrowser: false,
|
|
151
|
+
noInvite: true,
|
|
152
|
+
})).rejects.toThrow(/system.*not yet supported/);
|
|
153
|
+
expect(fake.installCalls).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('passes CLI overrides to the wizard as suggested defaults', async () => {
|
|
157
|
+
// Regression: the interactive wizard previously showed the platform
|
|
158
|
+
// default (`~/.local/share/cadre-host`) in the prompt even when the
|
|
159
|
+
// caller passed `--data-dir <path>`. Whatever the user typed was then
|
|
160
|
+
// silently discarded because the CLI flag won post-hoc. The fix: CLI
|
|
161
|
+
// overrides become the wizard's suggested defaults, and the wizard's
|
|
162
|
+
// returned answers stand on their own.
|
|
163
|
+
const fake = new FakeServiceHost();
|
|
164
|
+
const installer = new Installer({ platform: 'linux' });
|
|
165
|
+
let seenDefaults: { dataDir: string; uiPort: number; libp2pPort: number; upnpEnabled: boolean; ownCadre: boolean } | undefined;
|
|
166
|
+
await installer.install({
|
|
167
|
+
nonInteractive: false,
|
|
168
|
+
dataDir: tmp,
|
|
169
|
+
uiPort: 19997,
|
|
170
|
+
libp2pPort: 14003,
|
|
171
|
+
noUpnp: true,
|
|
172
|
+
openBrowser: false,
|
|
173
|
+
noInvite: true,
|
|
174
|
+
serviceHost: fake,
|
|
175
|
+
wizard: async (defaults) => {
|
|
176
|
+
seenDefaults = { ...defaults };
|
|
177
|
+
// Simulate the user pressing Enter at every prompt.
|
|
178
|
+
return {
|
|
179
|
+
dataDir: defaults.dataDir,
|
|
180
|
+
uiPort: defaults.uiPort,
|
|
181
|
+
libp2pPort: defaults.libp2pPort,
|
|
182
|
+
upnpEnabled: defaults.upnpEnabled,
|
|
183
|
+
configureDdns: false,
|
|
184
|
+
ownCadre: defaults.ownCadre,
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(seenDefaults).toEqual({
|
|
190
|
+
dataDir: tmp,
|
|
191
|
+
uiPort: 19997,
|
|
192
|
+
libp2pPort: 14003,
|
|
193
|
+
upnpEnabled: false,
|
|
194
|
+
ownCadre: false,
|
|
195
|
+
});
|
|
196
|
+
expect(fake.installCalls).toHaveLength(1);
|
|
197
|
+
expect(fake.installCalls[0]!.dataDir).toBe(tmp);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('honors wizard-typed input when no CLI override is passed', async () => {
|
|
201
|
+
// Mirror of the previous test: with no `--data-dir`, the wizard's
|
|
202
|
+
// returned value is what the installer uses.
|
|
203
|
+
const fake = new FakeServiceHost();
|
|
204
|
+
const installer = new Installer({ platform: 'linux' });
|
|
205
|
+
await installer.install({
|
|
206
|
+
nonInteractive: false,
|
|
207
|
+
openBrowser: false,
|
|
208
|
+
noInvite: true,
|
|
209
|
+
serviceHost: fake,
|
|
210
|
+
wizard: async () => ({
|
|
211
|
+
dataDir: tmp,
|
|
212
|
+
uiPort: 19996,
|
|
213
|
+
libp2pPort: 14004,
|
|
214
|
+
upnpEnabled: true,
|
|
215
|
+
configureDdns: false,
|
|
216
|
+
ownCadre: false,
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
expect(fake.installCalls).toHaveLength(1);
|
|
220
|
+
expect(fake.installCalls[0]!.dataDir).toBe(tmp);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('writes ownCadre.enabled=false by default and true when opted in', async () => {
|
|
224
|
+
const { readHostConfig } = await import('../config.js');
|
|
225
|
+
|
|
226
|
+
// Default (donor-only): --own-cadre absent → ownCadre.enabled false.
|
|
227
|
+
const fakeA = new FakeServiceHost();
|
|
228
|
+
const installerA = new Installer({ platform: 'linux' });
|
|
229
|
+
await installerA.install({
|
|
230
|
+
nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fakeA,
|
|
231
|
+
});
|
|
232
|
+
const donorCfg = readHostConfig(join(tmp, 'host.config.json'));
|
|
233
|
+
expect(donorCfg.ownCadre).toEqual({ enabled: false });
|
|
234
|
+
|
|
235
|
+
// Opt-in: ownCadre:true (the --own-cadre flag) → ownCadre.enabled true.
|
|
236
|
+
const tmp2 = mkdtempSync(join(tmpdir(), 'cadre-host-installer-owncadre-'));
|
|
237
|
+
try {
|
|
238
|
+
const fakeB = new FakeServiceHost();
|
|
239
|
+
const installerB = new Installer({ platform: 'linux' });
|
|
240
|
+
await installerB.install({
|
|
241
|
+
nonInteractive: true, dataDir: tmp2, ownCadre: true, openBrowser: false, noInvite: true, serviceHost: fakeB,
|
|
242
|
+
});
|
|
243
|
+
const founderCfg = readHostConfig(join(tmp2, 'host.config.json'));
|
|
244
|
+
expect(founderCfg.ownCadre).toEqual({ enabled: true });
|
|
245
|
+
} finally {
|
|
246
|
+
rmSync(tmp2, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('re-running install preserves the existing identity', async () => {
|
|
251
|
+
const fake = new FakeServiceHost();
|
|
252
|
+
const installer = new Installer({ platform: 'linux' });
|
|
253
|
+
await installer.install({
|
|
254
|
+
nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fake,
|
|
255
|
+
});
|
|
256
|
+
const idBefore = (await import('node:fs')).readFileSync(join(tmp, 'identity.key'));
|
|
257
|
+
await installer.install({
|
|
258
|
+
nonInteractive: true, dataDir: tmp, openBrowser: false, noInvite: true, serviceHost: fake,
|
|
259
|
+
});
|
|
260
|
+
const idAfter = (await import('node:fs')).readFileSync(join(tmp, 'identity.key'));
|
|
261
|
+
expect(Buffer.from(idBefore).equals(Buffer.from(idAfter))).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
});
|