@serfab/cadre-host 1.6.0 → 1.8.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 +31 -16
- package/dist/bin/host.js +41 -20
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +27 -4
- package/dist/donation/donation-service.js +57 -6
- package/dist/donation/donation-service.js.map +1 -1
- package/dist/donation/donation-store.d.ts +3 -1
- package/dist/donation/donation-store.js +5 -1
- package/dist/donation/donation-store.js.map +1 -1
- package/dist/donation/grant-service.d.ts +16 -7
- package/dist/donation/grant-service.js +57 -10
- package/dist/donation/grant-service.js.map +1 -1
- package/dist/donation/index.d.ts +2 -2
- package/dist/donation/index.js +1 -1
- package/dist/donation/index.js.map +1 -1
- package/dist/donation/types.d.ts +29 -7
- package/dist/donation/types.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/nat/address-resolver.d.ts +3 -2
- package/dist/nat/address-resolver.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +5 -4
- package/dist/orchestrator/host-process-orchestrator.js +25 -14
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +3 -2
- package/dist/orchestrator/node-identity.js +3 -2
- package/dist/orchestrator/node-identity.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +6 -39
- package/dist/orchestrator/port-allocator.js +9 -89
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/server/events/types.d.ts +3 -0
- package/dist/server/index.d.ts +19 -18
- package/dist/server/index.js +13 -15
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +15 -3
- package/dist/server/routes/grants-admin.js +33 -7
- package/dist/server/routes/grants-admin.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +13 -5
- package/dist/server/routes/nodes.js +58 -39
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/status.d.ts +13 -0
- package/dist/server/routes/status.js +2 -0
- package/dist/server/routes/status.js.map +1 -1
- package/dist/ui/assets/index-C6nELNfC.js +23 -0
- package/dist/ui/assets/index-C6nELNfC.js.map +1 -0
- package/dist/ui/assets/index-TtIYSeZO.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +4 -4
- package/src/__tests__/global-setup.ts +58 -56
- package/src/__tests__/orchestrator-env-scrub.test.ts +12 -11
- package/src/__tests__/orchestrator-handle-contract.ts +134 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +6 -7
- package/src/__tests__/orchestrator-owner.test.ts +6 -13
- package/src/__tests__/orchestrator-pin-keys.test.ts +6 -7
- package/src/__tests__/orchestrator-ports.test.ts +5 -34
- package/src/__tests__/orchestrator-push.test.ts +6 -5
- package/src/__tests__/orchestrator-teardown.ts +65 -0
- package/src/__tests__/orchestrator.test.ts +23 -22
- package/src/auth/__tests__/trust-circle-integration.test.ts +1 -1
- package/src/bin/host.ts +56 -33
- package/src/donation/__tests__/donation-service.test.ts +38 -0
- package/src/donation/__tests__/fake-orchestrator.test.ts +19 -14
- package/src/donation/__tests__/fake-orchestrator.ts +21 -3
- package/src/donation/__tests__/grant-service.test.ts +2 -2
- package/src/donation/donation-service.ts +58 -6
- package/src/donation/donation-store.ts +176 -171
- package/src/donation/grant-service.ts +252 -200
- package/src/donation/index.ts +65 -64
- package/src/donation/types.ts +260 -244
- package/src/index.ts +2 -0
- package/src/nat/address-resolver.ts +3 -2
- package/src/orchestrator/host-process-orchestrator.ts +1260 -1250
- package/src/orchestrator/node-identity.ts +3 -2
- package/src/orchestrator/port-allocator.ts +16 -91
- package/src/server/__tests__/fakes.ts +44 -0
- package/src/server/__tests__/grants-admin-route.test.ts +171 -73
- package/src/server/__tests__/nodes-route.test.ts +49 -25
- package/src/server/__tests__/publishers.test.ts +5 -41
- package/src/server/__tests__/server.smoke.test.ts +7 -37
- package/src/server/__tests__/status-route.test.ts +8 -21
- package/src/server/__tests__/strands-route.test.ts +3 -2
- package/src/server/events/types.ts +24 -23
- package/src/server/index.ts +267 -267
- package/src/server/routes/grants-admin.ts +74 -41
- package/src/server/routes/nodes.ts +61 -43
- package/src/server/routes/status.ts +16 -0
- package/dist/donation/__tests__/fake-orchestrator.d.ts +0 -103
- package/dist/donation/__tests__/fake-orchestrator.js +0 -180
- package/dist/donation/__tests__/fake-orchestrator.js.map +0 -1
- package/dist/ui/assets/index-BVoqLjyX.css +0 -1
- package/dist/ui/assets/index-CEoNRwM_.js +0 -17
- package/dist/ui/assets/index-CEoNRwM_.js.map +0 -1
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* identity, so a node that generated a fresh libp2p keypair per process would
|
|
7
7
|
* become a stranger to the cadre that admitted it on every restart. It also
|
|
8
8
|
* needs a stable on-disk home for its node-local stores — `cadre-cli start`
|
|
9
|
-
* opens `FileBootstrapPeerStore` / `FileTrustedOwnerStore`
|
|
10
|
-
*
|
|
9
|
+
* opens `FileBootstrapPeerStore` / `FileTrustedOwnerStore` /
|
|
10
|
+
* `FileStrandPeerBookStore` in the node's state directory, which defaults to
|
|
11
|
+
* the directory holding the config file the orchestrator writes into the workdir.
|
|
11
12
|
*
|
|
12
13
|
* So every managed node gets its own `identity.key` inside its workdir, written
|
|
13
14
|
* once and reused thereafter. The workdir is removed when the loan is
|
|
@@ -1,52 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*/
|
|
8
|
-
export class PortAllocator {
|
|
9
|
-
private readonly usedPorts = new Set<number>();
|
|
10
|
-
|
|
11
|
-
constructor(
|
|
12
|
-
private readonly start: number,
|
|
13
|
-
private readonly end: number,
|
|
14
|
-
) {
|
|
15
|
-
if (!Number.isInteger(start) || !Number.isInteger(end) || start <= 0 || end < start) {
|
|
16
|
-
throw new Error(`Invalid port range: ${start}..${end}`);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
allocate(): number {
|
|
21
|
-
for (let port = this.start; port <= this.end; port++) {
|
|
22
|
-
if (!this.usedPorts.has(port)) {
|
|
23
|
-
this.usedPorts.add(port);
|
|
24
|
-
return port;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
throw new Error(`No available ports in range ${this.start}..${this.end}`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
release(port: number): void {
|
|
31
|
-
this.usedPorts.delete(port);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Used during restart rehydration to reserve ports already in use. A no-op for a
|
|
36
|
-
* port outside the range, and for a non-integer: a handle persisted by an older
|
|
37
|
-
* build lacks the ports added since, and `undefined` must not enter the used-set.
|
|
38
|
-
*/
|
|
39
|
-
markUsed(port: number): void {
|
|
40
|
-
if (!Number.isInteger(port) || port < this.start || port > this.end) {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
this.usedPorts.add(port);
|
|
44
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
allocatePortSet,
|
|
3
|
+
releasePortSet,
|
|
4
|
+
reservePortSet,
|
|
5
|
+
type PortAllocator,
|
|
6
|
+
} from '@serfab/cadre-provider';
|
|
45
7
|
|
|
46
|
-
|
|
47
|
-
return this.usedPorts.has(port);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
8
|
+
import type { NodePorts } from './types.js';
|
|
50
9
|
|
|
51
10
|
/**
|
|
52
11
|
* Allocation order for a node's port set. Fixed so that swapping a caller onto
|
|
@@ -54,54 +13,20 @@ export class PortAllocator {
|
|
|
54
13
|
* already handed out — which is also why a key added later goes on the end
|
|
55
14
|
* (`ws` came after the first four).
|
|
56
15
|
*/
|
|
57
|
-
const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin', 'ws'] as const;
|
|
16
|
+
const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin', 'ws'] as const satisfies readonly (keyof NodePorts)[];
|
|
58
17
|
|
|
59
18
|
/**
|
|
60
|
-
* Allocate a full NodePorts set atomically
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* Overrides are reserved FIRST, before any allocation, so an override that
|
|
67
|
-
* happens to sit inside the managed range cannot also be handed out to another
|
|
68
|
-
* key. Reserving is `markUsed`, a documented no-op outside the range — which is
|
|
69
|
-
* the production case for the owner node's libp2p port, and why reserving first
|
|
70
|
-
* leaves every real port assignment unchanged.
|
|
71
|
-
*
|
|
72
|
-
* NOTE: an override is trusted, not checked — `markUsed` does not refuse a port
|
|
73
|
-
* another handle already holds, nor two keys naming the same port. Safe for
|
|
74
|
-
* today's callers: a re-spawn's overrides were released by its own handle drop
|
|
75
|
-
* with no `await` in between, and the owner's `libp2pPort` can only collide if an
|
|
76
|
-
* operator configures it inside the range onto a port a node already holds. If a
|
|
77
|
-
* caller ever passes ports it did not just release, refuse an override that
|
|
78
|
-
* `allocator.has()` or that repeats another key's port.
|
|
19
|
+
* Allocate a full NodePorts set atomically, in {@link NODE_PORT_KEYS} order.
|
|
20
|
+
* `overrides` supply a port instead of allocating one (the owner node's p2p
|
|
21
|
+
* port, which is fixed by NAT config; a re-spawn's previous ports, see
|
|
22
|
+
* {@link reusedNodePorts}). Overrides are reserved before anything is allocated
|
|
23
|
+
* and are trusted, not checked — see `allocatePortSet`.
|
|
79
24
|
*/
|
|
80
25
|
export function allocateNodePorts(
|
|
81
26
|
allocator: PortAllocator,
|
|
82
27
|
overrides?: Partial<NodePorts>,
|
|
83
28
|
): NodePorts {
|
|
84
|
-
|
|
85
|
-
const ports: Partial<NodePorts> = {};
|
|
86
|
-
try {
|
|
87
|
-
for (const key of NODE_PORT_KEYS) {
|
|
88
|
-
const override = overrides?.[key];
|
|
89
|
-
if (override === undefined) continue;
|
|
90
|
-
allocator.markUsed(override);
|
|
91
|
-
reserved.push(override);
|
|
92
|
-
ports[key] = override;
|
|
93
|
-
}
|
|
94
|
-
for (const key of NODE_PORT_KEYS) {
|
|
95
|
-
if (ports[key] !== undefined) continue;
|
|
96
|
-
const port = allocator.allocate();
|
|
97
|
-
reserved.push(port);
|
|
98
|
-
ports[key] = port;
|
|
99
|
-
}
|
|
100
|
-
} catch (err) {
|
|
101
|
-
for (const port of reserved) allocator.release(port);
|
|
102
|
-
throw err;
|
|
103
|
-
}
|
|
104
|
-
return ports as NodePorts;
|
|
29
|
+
return allocatePortSet(allocator, NODE_PORT_KEYS, overrides);
|
|
105
30
|
}
|
|
106
31
|
|
|
107
32
|
/**
|
|
@@ -109,7 +34,7 @@ export function allocateNodePorts(
|
|
|
109
34
|
* {@link releaseNodePorts}. A key the set lacks is skipped (see `markUsed`).
|
|
110
35
|
*/
|
|
111
36
|
export function reserveNodePorts(allocator: PortAllocator, ports: NodePorts): void {
|
|
112
|
-
|
|
37
|
+
reservePortSet(allocator, NODE_PORT_KEYS, ports);
|
|
113
38
|
}
|
|
114
39
|
|
|
115
40
|
/**
|
|
@@ -117,7 +42,7 @@ export function reserveNodePorts(allocator: PortAllocator, ports: NodePorts): vo
|
|
|
117
42
|
* nothing: deleting a value the used-set never held is a no-op.
|
|
118
43
|
*/
|
|
119
44
|
export function releaseNodePorts(allocator: PortAllocator, ports: NodePorts): void {
|
|
120
|
-
|
|
45
|
+
releasePortSet(allocator, NODE_PORT_KEYS, ports);
|
|
121
46
|
}
|
|
122
47
|
|
|
123
48
|
/**
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fakes for the local-UI server tests. Holds no tests of its own.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FounderServices } from '../index.js';
|
|
6
|
+
import type { TrustCircleService } from '../../auth/index.js';
|
|
7
|
+
import type { NatService } from '../../nat/index.js';
|
|
8
|
+
import type { NatStatusSnapshot } from '../../nat/types.js';
|
|
9
|
+
import type { StrandService } from '../../strands/index.js';
|
|
10
|
+
|
|
11
|
+
export const SAMPLE_CONNECTIVITY: NatStatusSnapshot = {
|
|
12
|
+
portMode: 'auto-upnp',
|
|
13
|
+
externalPort: 4001,
|
|
14
|
+
internalPort: 4001,
|
|
15
|
+
routerExternalIp: '203.0.113.5',
|
|
16
|
+
mappingLeaseExpiresAt: null,
|
|
17
|
+
externalIp: '203.0.113.5',
|
|
18
|
+
externalIpDetectedAt: null,
|
|
19
|
+
cgnatDetected: false,
|
|
20
|
+
directReachability: 'reachable',
|
|
21
|
+
lastTestedAt: null,
|
|
22
|
+
ddns: {
|
|
23
|
+
providerId: null,
|
|
24
|
+
hostname: null,
|
|
25
|
+
externallyManaged: false,
|
|
26
|
+
lastUpdateAt: null,
|
|
27
|
+
lastUpdateOk: null,
|
|
28
|
+
lastError: null,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A founder-role service set whose members answer only the reads the server
|
|
34
|
+
* makes on its own (status, boot publish): an empty trust circle, the sample
|
|
35
|
+
* connectivity, no strands. Pass the service a test exercises as an override.
|
|
36
|
+
*/
|
|
37
|
+
export function fakeFounder(overrides: Partial<FounderServices> = {}): FounderServices {
|
|
38
|
+
return {
|
|
39
|
+
trustCircle: { list: async () => ({ members: [], pending: [] }) } as unknown as TrustCircleService,
|
|
40
|
+
nat: { getStatus: () => SAMPLE_CONNECTIVITY } as unknown as NatService,
|
|
41
|
+
strands: { list: async () => ({ strands: [], controlConnections: 0 }) } as unknown as StrandService,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -1,73 +1,171 @@
|
|
|
1
|
-
import Fastify from 'fastify';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
|
|
7
|
-
import { registerErrorHandler } from '../error-handler.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
expect(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
expect(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
expect(
|
|
72
|
-
});
|
|
73
|
-
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { registerErrorHandler } from '../error-handler.js';
|
|
8
|
+
import { EventBus } from '../events/bus.js';
|
|
9
|
+
import { registerGrantsAdminRoutes } from '../routes/grants-admin.js';
|
|
10
|
+
import {
|
|
11
|
+
DonationService,
|
|
12
|
+
DonationStore,
|
|
13
|
+
GrantService,
|
|
14
|
+
GrantStore,
|
|
15
|
+
createGrantAdminHandlers,
|
|
16
|
+
} from '../../donation/index.js';
|
|
17
|
+
import type { DonationView, Grant, GrantListing } from '../../donation/index.js';
|
|
18
|
+
import { FakeOrchestrator } from '../../donation/__tests__/fake-orchestrator.js';
|
|
19
|
+
|
|
20
|
+
/** A well-formed 32-byte base64url owner key, which `provision` requires. */
|
|
21
|
+
const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
|
|
22
|
+
|
|
23
|
+
let tmpRoot: string;
|
|
24
|
+
let app: ReturnType<typeof Fastify>;
|
|
25
|
+
let grants: GrantService;
|
|
26
|
+
let donations: DonationService;
|
|
27
|
+
let donationStore: DonationStore;
|
|
28
|
+
let orch: FakeOrchestrator;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-route-'));
|
|
32
|
+
grants = new GrantService({ store: new GrantStore(tmpRoot) });
|
|
33
|
+
orch = new FakeOrchestrator();
|
|
34
|
+
donationStore = new DonationStore(tmpRoot);
|
|
35
|
+
donations = new DonationService({ orchestrator: orch, grants, store: donationStore });
|
|
36
|
+
app = Fastify();
|
|
37
|
+
registerErrorHandler(app);
|
|
38
|
+
registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(grants, donations), events: new EventBus() });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await app.close();
|
|
43
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('/grants-admin routes', () => {
|
|
47
|
+
it('POST issues a grant, GET lists it, DELETE revokes it', async () => {
|
|
48
|
+
const post = await app.inject({
|
|
49
|
+
method: 'POST',
|
|
50
|
+
url: '/grants-admin',
|
|
51
|
+
payload: { label: "Alice's cadre", maxNodes: 2 },
|
|
52
|
+
});
|
|
53
|
+
expect(post.statusCode).toBe(200);
|
|
54
|
+
const created = (post.json() as { grant: Grant }).grant;
|
|
55
|
+
expect(created.token).toBeTruthy();
|
|
56
|
+
expect(created.maxNodes).toBe(2);
|
|
57
|
+
|
|
58
|
+
const list = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
59
|
+
expect(list.statusCode).toBe(200);
|
|
60
|
+
expect((list.json() as { grants: Grant[] }).grants.map(g => g.token)).toContain(created.token);
|
|
61
|
+
|
|
62
|
+
const del = await app.inject({
|
|
63
|
+
method: 'DELETE',
|
|
64
|
+
url: `/grants-admin/${encodeURIComponent(created.token)}`,
|
|
65
|
+
});
|
|
66
|
+
expect(del.statusCode).toBe(200);
|
|
67
|
+
|
|
68
|
+
// Revoked → still listed, now carries revokedAt.
|
|
69
|
+
const after = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
70
|
+
const row = (after.json() as { grants: Grant[] }).grants.find(g => g.token === created.token);
|
|
71
|
+
expect(row?.revokedAt).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('POST with an empty label → 400 invalid_label', async () => {
|
|
75
|
+
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: ' ' } });
|
|
76
|
+
expect(res.statusCode).toBe(400);
|
|
77
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_label');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('POST with a bad maxNodes → 400 invalid_max_nodes', async () => {
|
|
81
|
+
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: 'X', maxNodes: 0 } });
|
|
82
|
+
expect(res.statusCode).toBe(400);
|
|
83
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_max_nodes');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('DELETE of an unknown token → 404 not_found', async () => {
|
|
87
|
+
const res = await app.inject({ method: 'DELETE', url: '/grants-admin/nope' });
|
|
88
|
+
expect(res.statusCode).toBe(404);
|
|
89
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function provisionUnder(token: string): Promise<DonationView> {
|
|
94
|
+
return donations.provision({ grantToken: token, partyId: 'party-P', bootstrapNodes: [], ownerKeys: [OWNER_KEY] });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('/grants-admin listing', () => {
|
|
98
|
+
it("GET reports each grant's live nodes and the donations a revoke would end", async () => {
|
|
99
|
+
const a = grants.issue({ label: 'A', maxNodes: 3 });
|
|
100
|
+
const b = grants.issue({ label: 'B' });
|
|
101
|
+
const seeded = await provisionUnder(a.token);
|
|
102
|
+
const failed = await provisionUnder(a.token);
|
|
103
|
+
const ended = await provisionUnder(a.token);
|
|
104
|
+
const other = await provisionUnder(b.token);
|
|
105
|
+
donationStore.put({ ...donationStore.get(seeded.id)!, status: 'seeded' });
|
|
106
|
+
donationStore.put({ ...donationStore.get(failed.id)!, status: 'error', error: 'respawn gave up' });
|
|
107
|
+
await donations.terminate(ended.id);
|
|
108
|
+
|
|
109
|
+
const res = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
110
|
+
|
|
111
|
+
const listed = (res.json() as { grants: GrantListing[] }).grants;
|
|
112
|
+
const rowA = listed.find(g => g.token === a.token);
|
|
113
|
+
const rowB = listed.find(g => g.token === b.token);
|
|
114
|
+
// `error` is not live, but a revoke still tears it down; `terminated` is neither.
|
|
115
|
+
expect(rowA?.liveNodes).toBe(1);
|
|
116
|
+
expect(rowA?.donations).toEqual(expect.arrayContaining([
|
|
117
|
+
{ id: seeded.id, status: 'seeded' },
|
|
118
|
+
{ id: failed.id, status: 'error' },
|
|
119
|
+
]));
|
|
120
|
+
expect(rowA?.donations).toHaveLength(2);
|
|
121
|
+
expect(rowB?.liveNodes).toBe(1);
|
|
122
|
+
expect(rowB?.donations).toEqual([{ id: other.id, status: 'awaiting_seed' }]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('/grants-admin donated-node teardown', () => {
|
|
127
|
+
it('DELETE /grants-admin/:token terminates the nodes donated under the grant', async () => {
|
|
128
|
+
const { token } = grants.issue({ label: 'Alice' });
|
|
129
|
+
const donation = await provisionUnder(token);
|
|
130
|
+
|
|
131
|
+
const res = await app.inject({ method: 'DELETE', url: `/grants-admin/${encodeURIComponent(token)}` });
|
|
132
|
+
|
|
133
|
+
expect(res.statusCode).toBe(200);
|
|
134
|
+
expect(res.json()).toEqual({ ok: true, terminated: [donation.id] });
|
|
135
|
+
expect(donations.get(donation.id)?.status).toBe('terminated');
|
|
136
|
+
expect(orch.removed).toEqual(['dock_1']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('?keepNodes=true revokes the grant but leaves its nodes running', async () => {
|
|
140
|
+
const { token } = grants.issue({ label: 'Alice' });
|
|
141
|
+
const donation = await provisionUnder(token);
|
|
142
|
+
|
|
143
|
+
const res = await app.inject({
|
|
144
|
+
method: 'DELETE',
|
|
145
|
+
url: `/grants-admin/${encodeURIComponent(token)}?keepNodes=true`,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(res.statusCode).toBe(200);
|
|
149
|
+
expect(res.json()).toEqual({ ok: true, terminated: [] });
|
|
150
|
+
expect(grants.validate(token).reason).toBe('revoked');
|
|
151
|
+
expect(donations.get(donation.id)?.status).toBe('awaiting_seed');
|
|
152
|
+
expect(orch.stopped).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('DELETE /grants-admin/donations/:id ends one node under a revoked grant; an unknown id 404s', async () => {
|
|
156
|
+
const { token } = grants.issue({ label: 'Alice', maxNodes: 2 });
|
|
157
|
+
const kept = await provisionUnder(token);
|
|
158
|
+
const ended = await provisionUnder(token);
|
|
159
|
+
grants.revoke(token);
|
|
160
|
+
|
|
161
|
+
const res = await app.inject({ method: 'DELETE', url: `/grants-admin/donations/${ended.id}` });
|
|
162
|
+
|
|
163
|
+
expect(res.statusCode).toBe(200);
|
|
164
|
+
expect(donations.get(ended.id)?.status).toBe('terminated');
|
|
165
|
+
expect(donations.get(kept.id)?.status).toBe('awaiting_seed');
|
|
166
|
+
|
|
167
|
+
const unknown = await app.inject({ method: 'DELETE', url: '/grants-admin/donations/grn_nope' });
|
|
168
|
+
expect(unknown.statusCode).toBe(404);
|
|
169
|
+
expect((unknown.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -90,27 +90,15 @@ const SAMPLE_NODE: ManagedNodeInfo = {
|
|
|
90
90
|
|
|
91
91
|
describe('/api/nodes routes', () => {
|
|
92
92
|
let app: ReturnType<typeof Fastify>;
|
|
93
|
-
let bus: EventBus;
|
|
94
93
|
let orchestrator: FakeOrchestrator;
|
|
95
94
|
let workdir: string;
|
|
96
95
|
|
|
97
96
|
beforeEach(async () => {
|
|
98
97
|
workdir = mkdtempSync(join(tmpdir(), 'cadre-host-nodes-'));
|
|
99
|
-
bus = new EventBus();
|
|
100
98
|
app = Fastify();
|
|
101
99
|
registerErrorHandler(app);
|
|
102
100
|
orchestrator = fakeOrchestrator([{ ...SAMPLE_NODE, workdir }]);
|
|
103
|
-
registerNodesRoutes(app, { orchestrator });
|
|
104
|
-
// Mirror what createLocalUiServer.start() does — forward orchestrator
|
|
105
|
-
// state changes onto the bus. The route itself doesn't publish; the
|
|
106
|
-
// listener is the single publication point.
|
|
107
|
-
orchestrator.onStateChange((info) => {
|
|
108
|
-
bus.publish({
|
|
109
|
-
type: 'node-state-changed',
|
|
110
|
-
nodeId: info.id,
|
|
111
|
-
status: info.status === 'running' ? 'running' : 'stopped',
|
|
112
|
-
});
|
|
113
|
-
});
|
|
101
|
+
registerNodesRoutes(app, { orchestrator, role: 'founder' });
|
|
114
102
|
});
|
|
115
103
|
|
|
116
104
|
afterEach(async () => {
|
|
@@ -158,17 +146,15 @@ describe('/api/nodes routes', () => {
|
|
|
158
146
|
expect(body.data.lines).toEqual([]);
|
|
159
147
|
});
|
|
160
148
|
|
|
161
|
-
it('POST /api/nodes/:id/stop
|
|
162
|
-
|
|
163
|
-
|
|
149
|
+
it('POST /api/nodes/:id/stop on a non-owner (donated) node returns 501 and stops nothing', async () => {
|
|
150
|
+
// The donation supervisor would respawn it at once — the stop must not look
|
|
151
|
+
// like it worked.
|
|
164
152
|
const res = await app.inject({ method: 'POST', url: '/api/nodes/alice/stop' });
|
|
165
|
-
expect(res.statusCode).toBe(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
expect(
|
|
170
|
-
{ type: 'node-state-changed', nodeId: 'alice', status: 'stopped' },
|
|
171
|
-
]);
|
|
153
|
+
expect(res.statusCode).toBe(501);
|
|
154
|
+
const body = res.json() as { error: { code: string; message: string } };
|
|
155
|
+
expect(body.error.code).toBe('not_implemented');
|
|
156
|
+
expect(body.error.message).toContain('cadre-host grant terminate alice');
|
|
157
|
+
expect(orchestrator.__stoppedDockerIds).toEqual([]);
|
|
172
158
|
});
|
|
173
159
|
|
|
174
160
|
it('POST /api/nodes/:id/start on a non-owner node returns 501 not_implemented', async () => {
|
|
@@ -211,11 +197,35 @@ describe('/api/nodes — owner node start/restart', () => {
|
|
|
211
197
|
app = Fastify();
|
|
212
198
|
registerErrorHandler(app);
|
|
213
199
|
orchestrator = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: true });
|
|
214
|
-
registerNodesRoutes(app, { orchestrator });
|
|
200
|
+
registerNodesRoutes(app, { orchestrator, role: 'founder' });
|
|
215
201
|
});
|
|
216
202
|
|
|
217
203
|
afterEach(async () => { await app.close(); });
|
|
218
204
|
|
|
205
|
+
it('stop calls the orchestrator and publishes exactly one event via the listener', async () => {
|
|
206
|
+
// Mirror what createLocalUiServer.start() does — the orchestrator listener is
|
|
207
|
+
// the single publication point.
|
|
208
|
+
const bus = new EventBus();
|
|
209
|
+
orchestrator.onStateChange((info) => {
|
|
210
|
+
bus.publish({
|
|
211
|
+
type: 'node-state-changed',
|
|
212
|
+
nodeId: info.id,
|
|
213
|
+
status: info.status === 'running' ? 'running' : 'stopped',
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
const events: unknown[] = [];
|
|
217
|
+
bus.subscribe((e) => events.push(e));
|
|
218
|
+
|
|
219
|
+
const res = await app.inject({ method: 'POST', url: '/api/nodes/owner/stop' });
|
|
220
|
+
|
|
221
|
+
expect(res.statusCode).toBe(200);
|
|
222
|
+
expect(orchestrator.__stoppedDockerIds).toEqual([OWNER_NODE.dockerId]);
|
|
223
|
+
// Exactly one event — the route does not re-publish (regression guard).
|
|
224
|
+
expect(events).toEqual([
|
|
225
|
+
{ type: 'node-state-changed', nodeId: 'owner', status: 'stopped' },
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
|
|
219
229
|
it('start ensures the owner node', async () => {
|
|
220
230
|
const res = await app.inject({ method: 'POST', url: '/api/nodes/owner/start' });
|
|
221
231
|
expect(res.statusCode).toBe(200);
|
|
@@ -232,11 +242,25 @@ describe('/api/nodes — owner node start/restart', () => {
|
|
|
232
242
|
const app2 = Fastify();
|
|
233
243
|
registerErrorHandler(app2);
|
|
234
244
|
const orch2 = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: false });
|
|
235
|
-
registerNodesRoutes(app2, { orchestrator: orch2 });
|
|
245
|
+
registerNodesRoutes(app2, { orchestrator: orch2, role: 'founder' });
|
|
236
246
|
const res = await app2.inject({ method: 'POST', url: '/api/nodes/owner/start' });
|
|
237
247
|
expect(res.statusCode).toBe(501);
|
|
238
248
|
await app2.close();
|
|
239
249
|
});
|
|
250
|
+
it('start and restart answer 409 own_cadre_disabled on a donor-only host that kept a saved owner config', async () => {
|
|
251
|
+
const donorApp = Fastify();
|
|
252
|
+
registerErrorHandler(donorApp);
|
|
253
|
+
const donorOrch = fakeOrchestrator([OWNER_NODE], { ownerId: 'owner', hasOwnerConfig: true });
|
|
254
|
+
registerNodesRoutes(donorApp, { orchestrator: donorOrch, role: 'donor' });
|
|
255
|
+
for (const verb of ['start', 'restart']) {
|
|
256
|
+
const res = await donorApp.inject({ method: 'POST', url: `/api/nodes/owner/${verb}` });
|
|
257
|
+
expect(res.statusCode).toBe(409);
|
|
258
|
+
expect(res.json().error.code).toBe('own_cadre_disabled');
|
|
259
|
+
}
|
|
260
|
+
expect(donorOrch.__ensured).toBe(0);
|
|
261
|
+
expect(donorOrch.__restarted).toBe(0);
|
|
262
|
+
await donorApp.close();
|
|
263
|
+
});
|
|
240
264
|
});
|
|
241
265
|
|
|
242
266
|
describe('tailLogFile', () => {
|
|
@@ -14,44 +14,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
14
14
|
import { createLocalUiServer } from '../index.js';
|
|
15
15
|
import { EventBus } from '../events/bus.js';
|
|
16
16
|
import type { LocalUiEvent } from '../events/types.js';
|
|
17
|
+
import { fakeFounder } from './fakes.js';
|
|
17
18
|
import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
18
19
|
import type { ManagedNodeInfo, NodeStateListener } from '../../orchestrator/types.js';
|
|
19
|
-
import type { TrustCircleService } from '../../auth/index.js';
|
|
20
|
-
import type { NatService } from '../../nat/index.js';
|
|
21
|
-
import type { NatStatusSnapshot } from '../../nat/types.js';
|
|
22
20
|
import type { UpdateService } from '../../update/index.js';
|
|
23
21
|
import type { UpdateState } from '../../update/types.js';
|
|
24
22
|
|
|
25
|
-
const CONNECTIVITY: NatStatusSnapshot = {
|
|
26
|
-
portMode: 'auto-upnp',
|
|
27
|
-
externalPort: 4001,
|
|
28
|
-
internalPort: 4001,
|
|
29
|
-
routerExternalIp: '203.0.113.1',
|
|
30
|
-
mappingLeaseExpiresAt: null,
|
|
31
|
-
externalIp: '203.0.113.1',
|
|
32
|
-
externalIpDetectedAt: null,
|
|
33
|
-
cgnatDetected: false,
|
|
34
|
-
directReachability: 'reachable',
|
|
35
|
-
lastTestedAt: null,
|
|
36
|
-
ddns: {
|
|
37
|
-
providerId: null,
|
|
38
|
-
hostname: null,
|
|
39
|
-
externallyManaged: false,
|
|
40
|
-
lastUpdateAt: null,
|
|
41
|
-
lastUpdateOk: null,
|
|
42
|
-
lastError: null,
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
function fakeTrust(): TrustCircleService {
|
|
47
|
-
return {
|
|
48
|
-
list: async () => ({ members: [], pending: [] }),
|
|
49
|
-
} as unknown as TrustCircleService;
|
|
50
|
-
}
|
|
51
|
-
function fakeNat(): NatService {
|
|
52
|
-
return { getStatus: () => CONNECTIVITY, putSettings: async () => CONNECTIVITY } as unknown as NatService;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
23
|
interface ManualOrchestrator extends HostProcessOrchestrator {
|
|
56
24
|
__emit: (info: ManagedNodeInfo) => void;
|
|
57
25
|
}
|
|
@@ -117,8 +85,7 @@ describe('publisher wiring', () => {
|
|
|
117
85
|
uiPort: 8765,
|
|
118
86
|
dataDir,
|
|
119
87
|
orchestrator,
|
|
120
|
-
|
|
121
|
-
nat: fakeNat(),
|
|
88
|
+
founder: fakeFounder(),
|
|
122
89
|
events: bus,
|
|
123
90
|
forcePort: 0,
|
|
124
91
|
});
|
|
@@ -146,8 +113,7 @@ describe('publisher wiring', () => {
|
|
|
146
113
|
uiPort: 8765,
|
|
147
114
|
dataDir,
|
|
148
115
|
orchestrator,
|
|
149
|
-
|
|
150
|
-
nat: fakeNat(),
|
|
116
|
+
founder: fakeFounder(),
|
|
151
117
|
events: bus,
|
|
152
118
|
forcePort: 0,
|
|
153
119
|
});
|
|
@@ -168,8 +134,7 @@ describe('publisher wiring', () => {
|
|
|
168
134
|
uiPort: 8765,
|
|
169
135
|
dataDir,
|
|
170
136
|
orchestrator,
|
|
171
|
-
|
|
172
|
-
nat: fakeNat(),
|
|
137
|
+
founder: fakeFounder(),
|
|
173
138
|
events: bus,
|
|
174
139
|
update: update as unknown as UpdateService,
|
|
175
140
|
forcePort: 0,
|
|
@@ -194,8 +159,7 @@ describe('publisher wiring', () => {
|
|
|
194
159
|
uiPort: 8765,
|
|
195
160
|
dataDir,
|
|
196
161
|
orchestrator,
|
|
197
|
-
|
|
198
|
-
nat: fakeNat(),
|
|
162
|
+
founder: fakeFounder(),
|
|
199
163
|
events: bus,
|
|
200
164
|
update: update as unknown as UpdateService,
|
|
201
165
|
forcePort: 0,
|