mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
fix(mesh): surface stopped-stack opt-ins on routing node cards (#1098)
Add `currentlyResolvable: boolean` per entry in `MeshNodeStatus.optedInStacks`, derived from the existing alias cache so the new field stays consistent with `/api/mesh/aliases` without any extra Dockerode or cross-node inspect on the status path. The Routing tab renders an amber `suspended` pill for entries whose stack is opted in but currently has no running services, plus a single explanatory caption below the suspended list. Resolves the contradictory `Mesh stacks: 1 / Aliases: 0 / No mesh services on this node yet` copy on the node card when a meshed stack's container has been stopped; the misleading line is now only shown when the node truly has no opt-ins. The opt-in itself remains sticky: when the stack starts again, its aliases reappear automatically on the next refresh. Defensive de-dup in the UI filters suspended entries against the live alias snapshot to handle the transient gap where `/mesh/status` and `/mesh/aliases` return slightly inconsistent views from their separate fetches. Tests: - new `mesh-status-resolvability.test.ts` (6 cases) locks the resolvable / suspended / mixed / empty / per-node-scoping / stale-alias-no-phantom invariants for `getStatus`. - `mesh-topology-layout.test.ts` gains a `stacksKey` resolvability-flip case and a `meshNodeStateEqual` case asserting a resolvability flip on an otherwise identical stack registers as a state change so the topology layout re-runs. Operator docs gain one new troubleshooting accordion in `/docs/features/sencho-mesh.mdx` explaining the suspended state.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* `MeshService.getStatus().optedInStacks[i].currentlyResolvable` contract.
|
||||
*
|
||||
* The Routing tab needs to distinguish two states for an opted-in stack:
|
||||
* - resolvable: the alias cache currently carries at least one alias for
|
||||
* `(nodeId, stackName)` (services are running and inspectable).
|
||||
* - suspended: the `mesh_stacks` row still exists, but the alias cache has
|
||||
* nothing for that pair (stack stopped, ports gone).
|
||||
*
|
||||
* Reading `this.aliasCache` directly keeps the new field aligned with what
|
||||
* `/api/mesh/aliases` reports without paying an extra Dockerode / cross-node
|
||||
* inspect on every status poll.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { MeshService, type MeshGlobalAlias } from '../services/MeshService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
let tmpDir: string;
|
||||
beforeAll(async () => { tmpDir = await setupTestDb(); });
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function uniqueSuffix(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function seedProxyNode(meshEnabled: boolean): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
const id = db.addNode({
|
||||
name: `peer-resolv-${uniqueSuffix()}`,
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
api_url: `https://peer-${uniqueSuffix()}.example.com`,
|
||||
api_token: `tok-${uniqueSuffix()}`,
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
});
|
||||
if (meshEnabled) db.setNodeMeshEnabled(id, true);
|
||||
return id;
|
||||
}
|
||||
|
||||
function setAliasCache(entries: MeshGlobalAlias[]): void {
|
||||
const svc = MeshService.getInstance() as unknown as {
|
||||
aliasCache: Map<string, MeshGlobalAlias>;
|
||||
};
|
||||
svc.aliasCache = new Map(entries.map((a) => [a.host, a]));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM nodes WHERE is_default = 0').run();
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM mesh_stacks').run();
|
||||
setAliasCache([]);
|
||||
});
|
||||
|
||||
describe('MeshService.getStatus optedInStacks.currentlyResolvable', () => {
|
||||
it('reports currentlyResolvable=true when the alias cache has an alias for the stack', async () => {
|
||||
const id = seedProxyNode(true);
|
||||
const db = DatabaseService.getInstance();
|
||||
db.insertMeshStack(id, 'whoami', 'admin');
|
||||
setAliasCache([
|
||||
{ host: 'whoami.whoami.peer.sencho', nodeId: id, nodeName: 'peer', stackName: 'whoami', serviceName: 'whoami', port: 80 },
|
||||
]);
|
||||
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
expect(entry?.optedInStacks).toEqual([{ stackName: 'whoami', currentlyResolvable: true }]);
|
||||
});
|
||||
|
||||
it('reports currentlyResolvable=false when the alias cache is empty for the stack', async () => {
|
||||
const id = seedProxyNode(true);
|
||||
DatabaseService.getInstance().insertMeshStack(id, 'whoami', 'admin');
|
||||
setAliasCache([]);
|
||||
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
expect(entry?.optedInStacks).toEqual([{ stackName: 'whoami', currentlyResolvable: false }]);
|
||||
});
|
||||
|
||||
it('handles a mix of resolvable and suspended opt-ins on the same node', async () => {
|
||||
const id = seedProxyNode(true);
|
||||
const db = DatabaseService.getInstance();
|
||||
db.insertMeshStack(id, 'whoami', 'admin');
|
||||
db.insertMeshStack(id, 'paperless', 'admin');
|
||||
setAliasCache([
|
||||
{ host: 'whoami.whoami.peer.sencho', nodeId: id, nodeName: 'peer', stackName: 'whoami', serviceName: 'whoami', port: 80 },
|
||||
]);
|
||||
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
const byName = new Map(entry?.optedInStacks.map((s) => [s.stackName, s.currentlyResolvable]) ?? []);
|
||||
expect(byName.get('whoami')).toBe(true);
|
||||
expect(byName.get('paperless')).toBe(false);
|
||||
expect(entry?.optedInStacks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns an empty optedInStacks array for a node with no opt-ins (shape regression)', async () => {
|
||||
const id = seedProxyNode(true);
|
||||
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
expect(entry?.optedInStacks).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not invent a phantom opt-in from a stale alias whose stack is not in mesh_stacks', async () => {
|
||||
// Invariant: `optedInStacks` is derived from the persistent
|
||||
// `mesh_stacks` table; the alias cache only adjusts the
|
||||
// `currentlyResolvable` flag for existing rows. A stale alias entry
|
||||
// (e.g. cached for a stack that was just deleted) must NOT surface as
|
||||
// a phantom opt-in.
|
||||
const id = seedProxyNode(true);
|
||||
// No `insertMeshStack` call — node has no opt-ins.
|
||||
setAliasCache([
|
||||
{ host: 'ghost.ghost.peer.sencho', nodeId: id, nodeName: 'peer', stackName: 'ghost', serviceName: 'ghost', port: 80 },
|
||||
]);
|
||||
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const entry = statuses.find((s) => s.nodeId === id);
|
||||
expect(entry?.optedInStacks).toEqual([]);
|
||||
});
|
||||
|
||||
it('scopes resolvability per node: an alias on node A does not make stack X resolvable on node B', async () => {
|
||||
const idA = seedProxyNode(true);
|
||||
const idB = seedProxyNode(true);
|
||||
const db = DatabaseService.getInstance();
|
||||
db.insertMeshStack(idA, 'shared', 'admin');
|
||||
db.insertMeshStack(idB, 'shared', 'admin');
|
||||
// Alias exists only for node A.
|
||||
setAliasCache([
|
||||
{ host: 'shared.shared.peer-a.sencho', nodeId: idA, nodeName: 'peer-a', stackName: 'shared', serviceName: 'shared', port: 80 },
|
||||
]);
|
||||
|
||||
const statuses = await MeshService.getInstance().getStatus();
|
||||
const a = statuses.find((s) => s.nodeId === idA);
|
||||
const b = statuses.find((s) => s.nodeId === idB);
|
||||
expect(a?.optedInStacks).toEqual([{ stackName: 'shared', currentlyResolvable: true }]);
|
||||
expect(b?.optedInStacks).toEqual([{ stackName: 'shared', currentlyResolvable: false }]);
|
||||
});
|
||||
});
|
||||
@@ -190,7 +190,18 @@ export interface MeshNodeStatus {
|
||||
reachableReason: string | null;
|
||||
/** Peer→central reverse path state. `not_applicable` for non-proxy peers. */
|
||||
reverseCallbackStatus: MeshReverseCallbackStatus;
|
||||
optedInStacks: string[];
|
||||
/**
|
||||
* Stacks opted into the mesh on this node, with a per-stack resolvability
|
||||
* flag. `currentlyResolvable` is `true` iff the alias cache currently
|
||||
* carries at least one alias for that (nodeId, stackName) pair, i.e. the
|
||||
* stack's services were inspectable and exposed at least one port the
|
||||
* last time `refreshAliasCache()` ran (every 60 s on a timer, plus on
|
||||
* opt-in / opt-out and pilot reconnect). A suspended opt-in (stack
|
||||
* stopped, services not running) reports `currentlyResolvable: false` so
|
||||
* the Routing tab can surface the asymmetry between the persistent
|
||||
* registry and the live alias list.
|
||||
*/
|
||||
optedInStacks: Array<{ stackName: string; currentlyResolvable: boolean }>;
|
||||
activeStreamCount: number;
|
||||
}
|
||||
|
||||
@@ -2131,6 +2142,15 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
||||
const localListening = this.isLocalForwarderActive();
|
||||
const ptm = PilotTunnelManager.getInstance();
|
||||
const dialer = MeshProxyTunnelDialer.getInstance();
|
||||
// Precompute the (nodeId, stackName) pairs that currently have at least
|
||||
// one alias in the cache. Reading the cache (refreshed every 60 s and
|
||||
// on opt-in/opt-out) keeps the new `currentlyResolvable` field aligned
|
||||
// with what `/api/mesh/aliases` reports, without paying the cost of a
|
||||
// fresh Dockerode/cross-node inspect per status poll.
|
||||
const resolvableKeys = new Set<string>();
|
||||
for (const alias of this.aliasCache.values()) {
|
||||
resolvableKeys.add(`${alias.nodeId}:${alias.stackName}`);
|
||||
}
|
||||
return nodes.map((node) => {
|
||||
const reach = this.computeReachable(node, localNodeId);
|
||||
const meshEnabled = db.getNodeMeshEnabled(node.id);
|
||||
@@ -2148,7 +2168,10 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
|
||||
reachableMode: reach.mode,
|
||||
reachableReason: reach.reason,
|
||||
reverseCallbackStatus: this.computeReverseCallbackStatus(node, meshEnabled, dialer),
|
||||
optedInStacks: db.listMeshStacks(node.id).map((s) => s.stack_name),
|
||||
optedInStacks: db.listMeshStacks(node.id).map((s) => ({
|
||||
stackName: s.stack_name,
|
||||
currentlyResolvable: resolvableKeys.has(`${node.id}:${s.stack_name}`),
|
||||
})),
|
||||
activeStreamCount: this.activeStreams.size,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user