fix(notifications): neutralize satellite-local node names in alert bodies (#1640)

* fix(notifications): neutralize satellite-local node names in alert bodies

Fleet-aggregated alerts embedded each instance seed name (often Local)
while the hub badge already named the remote. Drop identity prefixes and
use type-aware local wording so attribution stays on the badge.

* fix(docs): correct image-update default check cadence

Operator docs still said six-hour polling; the seeded default is two
hours in interval mode, and the cadence is configurable or cron-based.

* test(notifications): assert hub stamps roster name on neutral remote bodies

Cover the fan-in path that attaches hub roster identity while leaving the
satellite message body unchanged.
This commit is contained in:
Anso
2026-07-16 15:51:52 -04:00
committed by GitHub
parent b91025dc8b
commit d8e4ede94f
12 changed files with 288 additions and 48 deletions
+103
View File
@@ -469,3 +469,106 @@ describe('BlueprintService marker parsing + name-conflict guard', () => {
expect(BlueprintService.parseMarker(JSON.stringify({ blueprintId: 'nope', revision: 1 }))).toBeNull();
});
});
describe('BlueprintReconciler drift alert node wording', () => {
type ReconcilerWithDrift = {
handleDrift: (blueprint: Blueprint, node: Node, reason: string) => Promise<void>;
};
function seedRemoteNode(name: string): number {
counter += 1;
const db = DatabaseService.getInstance().getDb();
const result = db.prepare(
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
VALUES (?, 'remote', 'proxy', '/tmp/compose', 0, 'online', ?)`
).run(name, Date.now());
return result.lastInsertRowid as number;
}
it('suggest-mode local drift uses on this node', async () => {
const { NotificationService } = await import('../services/NotificationService');
const dispatchSpy = vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
const nodeId = seedNode();
const bp = seedBlueprint({ name: 'drift-local', drift_mode: 'suggest', nodeIds: [nodeId] });
const node = DatabaseService.getInstance().getNode(nodeId)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithDrift;
await reconciler.handleDrift(bp, node, 'compose changed');
expect(dispatchSpy).toHaveBeenCalledWith(
'warning',
'blueprint_drift_detected',
'Blueprint "drift-local" drifted on this node: compose changed',
{ stackName: 'drift-local', actor: 'system:blueprint' },
);
});
it('suggest-mode remote drift keeps the authoritative node name', async () => {
const { NotificationService } = await import('../services/NotificationService');
const dispatchSpy = vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
const nodeId = seedRemoteNode('sencho-test-02');
const bp = seedBlueprint({ name: 'drift-remote', drift_mode: 'suggest', nodeIds: [nodeId] });
const node = DatabaseService.getInstance().getNode(nodeId)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithDrift;
await reconciler.handleDrift(bp, node, 'compose changed');
expect(dispatchSpy).toHaveBeenCalledWith(
'warning',
'blueprint_drift_detected',
'Blueprint "drift-remote" drifted on node "sencho-test-02": compose changed',
{ stackName: 'drift-remote', actor: 'system:blueprint' },
);
});
it('stateful marker-loss on local uses on this node', async () => {
const { NotificationService } = await import('../services/NotificationService');
const dispatchSpy = vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
vi.spyOn(BlueprintService.getInstance(), 'readMarker').mockResolvedValue(null);
const nodeId = seedNode();
const bp = seedBlueprint({
name: 'marker-local',
drift_mode: 'enforce',
classification: 'stateful',
nodeIds: [nodeId],
});
const node = DatabaseService.getInstance().getNode(nodeId)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithDrift;
await reconciler.handleDrift(bp, node, 'volumes diverged');
expect(dispatchSpy).toHaveBeenCalledWith(
'warning',
'blueprint_drift_detected',
'Blueprint "marker-local" lost its marker on this node; auto-fix declined to avoid stomping unowned data. Reason: volumes diverged',
{ stackName: 'marker-local', actor: 'system:blueprint' },
);
});
it('enforce correction-failure on local uses on this node', async () => {
const { NotificationService } = await import('../services/NotificationService');
const dispatchSpy = vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({
status: 'failed',
error: 'compose up failed',
} as Awaited<ReturnType<typeof BlueprintService.prototype.deployToNode>>);
const nodeId = seedNode();
const bp = seedBlueprint({
name: 'fix-local',
drift_mode: 'enforce',
classification: 'stateless',
nodeIds: [nodeId],
});
const node = DatabaseService.getInstance().getNode(nodeId)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithDrift;
await reconciler.handleDrift(bp, node, 'compose changed');
expect(dispatchSpy).toHaveBeenCalledWith(
'error',
'blueprint_drift_correction_failed',
'Auto-fix for "fix-local" on this node failed: compose up failed',
{ stackName: 'fix-local', actor: 'system:blueprint' },
);
});
});