fix(mesh): log boot state to console so docker logs surfaces mesh status (#1159)

MeshService records its boot summary and setup failures only through
logActivity (in-memory ring buffer + WS listeners), which the Routing
tab consumes. The docker-logs surface was silent for the mesh
subsystem, so operators running Sencho-in-Docker had no boot-time
visibility into whether the data plane came up cleanly.

Mirror the existing logActivity entries to console without replacing
them:

- MeshService.start() success summary: console.log /
  console.warn / console.error gated on the summary level. Format:
  [Mesh] data plane ok, self attached at <ip>, subnet <X>
  [Mesh] data plane unavailable (<reason>: <message>)
- recordSetupFailure: console.warn for the expected dev-mode
  not_in_docker case, console.error for real failures. Format:
  [Mesh] data plane unavailable (<reason>, subnet <X>): <sanitized>

The activity entries that already drive the Routing tab banner stay
intact; the console lines are purely additive for the docker logs
workflow. Two new unit tests assert the failure and not_in_docker
console mirrors fire with the [Mesh] prefix.

Fixes F-5.
This commit is contained in:
Anso
2026-05-22 16:30:10 -04:00
committed by GitHub
parent c87dc7e747
commit 474290081d
3 changed files with 61 additions and 2 deletions
@@ -441,3 +441,43 @@ describe('MeshService.setupMeshNetwork subnet auto-fallback', () => {
expect(status.reason).toBe('subnet_mismatch');
});
});
describe('MeshService console.log mirror (F-5)', () => {
it('mirrors a setup failure to console.error with the [Mesh] prefix', async () => {
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
process.env.HOSTNAME = 'sencho';
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockDocker({
createNetwork: vi.fn().mockRejectedValue(
Object.assign(new Error('Pool overlaps with other one on this address space'), { statusCode: 500 }),
),
});
const svc = MeshService.getInstance();
await callSetup(svc);
const meshLines = errSpy.mock.calls
.map((args) => String(args[0]))
.filter((line) => line.startsWith('[Mesh] data plane unavailable'));
expect(meshLines.length).toBeGreaterThan(0);
expect(meshLines[0]).toContain('subnet_overlap');
expect(meshLines[0]).toContain('10.42.0.0/24');
expect(meshLines[0]).toMatch(/overlap/i);
});
it('mirrors a not_in_docker condition to console.warn (expected dev-mode case)', async () => {
process.env.SENCHO_MESH_SUBNET = '10.42.0.0/24';
delete process.env.HOSTNAME;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
mockDocker();
const svc = MeshService.getInstance();
await callSetup(svc);
const meshLines = warnSpy.mock.calls
.map((args) => String(args[0]))
.filter((line) => line.startsWith('[Mesh] data plane unavailable'));
expect(meshLines.length).toBeGreaterThan(0);
expect(meshLines[0]).toContain('not_in_docker');
});
});
+19 -2
View File
@@ -422,13 +422,22 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
const dpReason = this.dataPlaneStatus.reason;
const dataPlane = this.senchoIp ? 'ok' : `unavailable (${dpReason}: ${this.networkSetupError ?? 'unknown'})`;
const subnetSuffix = this.senchoIp ? `, subnet ${this.meshSubnet}` : '';
const ipSuffix = this.senchoIp ? `, self attached at ${this.senchoIp}` : '';
const summaryLevel: MeshActivityLevel = dpReason === 'ok'
? 'info'
: dpReason === 'not_in_docker' ? 'warn' : 'error';
const summaryMessage = `MeshService started (data plane ${dataPlane}${subnetSuffix}, self nodeId ${this.selfCentralNodeId})`;
this.logActivity({
source: 'mesh', level: summaryLevel, type: 'mesh.enable',
message: `MeshService started (data plane ${dataPlane}${subnetSuffix}, self nodeId ${this.selfCentralNodeId})`,
message: summaryMessage,
});
// Mirror to console so `docker logs sencho` surfaces the boot state.
// The activity entry above feeds the Routing tab; this line feeds the
// operator's `docker logs` workflow. Same content, different surface.
const consoleLine = `[Mesh] data plane ${dataPlane}${ipSuffix}${subnetSuffix}`;
if (summaryLevel === 'error') console.error(consoleLine);
else if (summaryLevel === 'warn') console.warn(consoleLine);
else console.log(consoleLine);
}
public async stop(): Promise<void> {
@@ -554,13 +563,21 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
this.networkSetupError = message;
this.dataPlaneStatus = { ok: false, reason, message, subnet };
this.senchoIp = null;
const sanitized = sanitizeForLog(message);
this.logActivity({
source: 'mesh',
level,
type: 'mesh.disable',
message: `mesh data plane unavailable (${reason}): ${sanitizeForLog(message)}`,
message: `mesh data plane unavailable (${reason}): ${sanitized}`,
details: { reason, subnet },
});
// Mirror to console so the failure shows in `docker logs sencho`. The
// start() summary will also emit a boot line after setupMeshNetwork
// returns; this per-failure line gives the specific reason + Docker
// error message for diagnostic purposes.
const consoleLine = `[Mesh] data plane unavailable (${reason}, subnet ${subnet}): ${sanitized}`;
if (level === 'warn') console.warn(consoleLine);
else console.error(consoleLine);
}
/**
+2
View File
@@ -216,6 +216,8 @@ These are the explicit boundaries of the v1 mesh.
- `attach_failed`: the Docker daemon refused the network attachment for a reason that does not match the patterns above. The full error appears in the activity log entry and on `/api/health`.
The `mesh.dataPlane.subnet` field shows the CIDR Sencho settled on (or the last candidate it tried), so the operator can verify which subnet is configured before changing anything.
For the container-log view, `docker logs sencho 2>&1 | grep '\[Mesh\]'` shows the boot summary (one line) and any setup failures with the same reason discriminator: `[Mesh] data plane ok, self attached at 172.31.0.2, subnet 172.31.0.0/24` on success, or `[Mesh] data plane unavailable (subnet_overlap, subnet 172.30.0.0/24): Pool overlaps with other one on this address space` on failure.
</Accordion>
<Accordion title="Opt-in rejects with 'host-network service'">