Closes #118. Unblocked by #110, which is what makes SSH on sixty devices practical. Arbitrary RouterOS console commands over SSH, because `:put`, `:foreach` and `/interface print` are console syntax that the binary API cannot express, and because a curated command library cannot cover "add a channel to LACP" or "rewrite a WireGuard endpoint" — the things people actually asked for. **Waves are the feature, not the safety rail around it.** A shell loop already runs a command on many hosts, costs nothing, and prints errors as they happen. What it cannot do is stop. Devices run together within a wave, waves run strictly in sequence, and halt-on-failure ends the run before a mistake becomes sixty mistakes. Without that, this would be a worse bash. Proved on hardware rather than argued: a bad command on two devices, one per wave, failed on the first and left the second `skipped`. RouterOS reports most errors in output text rather than an exit code, so output is read for failure markers — otherwise a rejected command and a successful one are indistinguishable and the run would sail on. Conservatively: only unambiguous markers count, since misreading ordinary output would halt a run that was working. Change Guard wraps each device by default, so one that stops answering after the command restores itself. It is one click to turn off, because a sharp tool that refuses to cut is not a tool — the requester's argument, and a fair one. The combination that gets an explicit acknowledgement is a command that can sever management *with the guard disabled*; that is the only case where nothing catches a mistake. A preview names devices, wave assignment, and the reasons a command looks capable of cutting management access. It warns, never blocks. Devices with no SSH credential are listed before the run rather than producing a wave of identical authentication failures. Per-device output is captured and kept, because "it failed" without the device's own words is not actionable, and because some commands exist purely for output. Verified end to end against real hardware: a read-only command across two devices, halt-on-failure stopping a bad command after one device, and a genuine write applied under Change Guard, confirmed, then reverted — with both devices returned to their original configuration afterwards. Also adds services/sshExec.ts, shared with the key work: it selects a verified key or a password, and throws rather than swallowing. That last part is deliberate — a swallowed error is exactly how the data-cap feature spent a week doing nothing while looking healthy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
33 lines
1.6 KiB
TypeScript
33 lines
1.6 KiB
TypeScript
import { looksLikeFailure } from '../../services/sshExec';
|
|
|
|
describe('looksLikeFailure', () => {
|
|
it('catches the ways RouterOS reports a bad command', () => {
|
|
// RouterOS signals most errors in the text, not an exit code — a command
|
|
// that worked and one that was rejected are otherwise indistinguishable.
|
|
expect(looksLikeFailure('syntax error (line 1 column 5)')).toBe(true);
|
|
expect(looksLikeFailure('bad command name foo (line 1 column 1)')).toBe(true);
|
|
expect(looksLikeFailure('expected end of command (line 1 column 20)')).toBe(true);
|
|
expect(looksLikeFailure('no such item (4)')).toBe(true);
|
|
expect(looksLikeFailure('failure: already have such address')).toBe(true);
|
|
expect(looksLikeFailure('input does not match any value of interface')).toBe(true);
|
|
});
|
|
|
|
it('treats ordinary output as success', () => {
|
|
// Misreading normal output as failure would halt a rollout that was working,
|
|
// which is worse than the error it was trying to catch.
|
|
expect(looksLikeFailure('')).toBe(false);
|
|
expect(looksLikeFailure(' name: MikroTik-Test')).toBe(false);
|
|
expect(looksLikeFailure('0 ether1 ether 1500 enabled')).toBe(false);
|
|
expect(looksLikeFailure('142.250.187.206')).toBe(false);
|
|
});
|
|
|
|
it('does not fire on words merely containing an error term', () => {
|
|
expect(looksLikeFailure('comment="no such item handling"')).toBe(false);
|
|
expect(looksLikeFailure('name=syntax-error-test')).toBe(false);
|
|
});
|
|
|
|
it('finds a failure on any line, not only the first', () => {
|
|
expect(looksLikeFailure('0 ether1\n1 ether2\nno such item (4)')).toBe(true);
|
|
});
|
|
});
|