0c41889c

Created Started Finished

Check a configuration before it can brick a router

`apply` resets the router and replays the file. So the interesting question
about a configuration file is not whether it is tidy, it is what happens to the
router if RouterOS will not have it. This adds the two checks that answer that,
and an example of everything either of them can find.

## What was wrong with the old answer

`/import` compiles the whole file before it runs any of it. A command RouterOS
cannot compile — an unknown menu, an unknown parameter — aborts the import
having applied **nothing at all**, however far down the offending line is.
Paired with the reset, that leaves the router with no bridge, no address of its
own and no dhcp server: nothing but a password prompt on a serial console.

A command that fails *while running* is the survivable case: the import stops
at that line and keeps everything above it. Putting the parts that get you back
in first bounds the damage from those, and does nothing whatsoever about a
compile error.

Both halves are pinned down by examples in `examples/caught-by-vmtest`.

## `routeros-check`, the cheap one

Reads the file, reports every problem it knows about rather than the first one,
and quotes the lines they are on in colour with a caret under the argument at
fault. Milliseconds, no router and no emulator.

```
[error duplicate-lease-mac]: Two leases are for aa:aa:aa:aa:aa:aa.
     ╭──▶ configuration.rsc@16:39-16:56
     │
  15 │ add address=192.168.99.10 mac-address=aa:aa:aa:aa:aa:aa server=dhcp1
     •                                       ┬────────────────
     •                                       ╰╸ leased here
  16 │ add address=192.168.99.11 mac-address=AA:AA:AA:AA:AA:AA server=dhcp1
     •                                       ┬────────────────
     •                                       ╰╸ and again here
     •
     │ Note: Compared without regard to case, because these files mix both.
─────╯
```

Reporting all of them at once is the point of the `Validation` applicative: a
checker that reports one problem per run makes you run it once per problem
against a file that resets a router. It is deliberately not a `Monad`, because
a `Monad` instance would have to stop at the first failure to make `>>=`
typecheck, and code written against it would silently lose the accumulation.

It is not a RouterOS parser and cannot be one: it splits lines into a section, a
verb and key=value pairs. A clean run means "none of the problems it knows
about are here", not "this will import".

## `routeros-vmtest`, the one that actually knows

Boots MikroTik's CHR under qemu, dresses it in the chassis the file is written
for, and imports the file with the same commands `apply` uses. RouterOS is the
only thing that parses RouterOS.

The chassis matters rather than being cosmetic, because RouterOS decides what a
command means from what the hardware is: `hw-offload=` is only a word on a
chassis whose switch can offload, a chassis with one serial port has no
`/port 1` to name, and only the arm64 build has `/system routerboard`. So it
takes a chassis file:

```json
{
  "architecture": "arm64",
  "interfaces": ["ether1", "sfp-sfpplus1", "sfp28-1", "sfp28-2"],
  "serialPorts": 1
}
```

CHR calls every interface `etherN` whatever it stands in for, and a reset undoes
any renaming, so the chassis names are put back in the window between the reset
and the import. That is the whole of the pretence; the file itself is never
touched, and reaches the router byte for byte over qemu's own tftp server, with
no host network involved.

It takes `--image`, so whoever runs it decides where MikroTik's proprietary
image comes from. `nix/chr.nix` has pinned ones for those who would rather not
care; they are not a dependency of anything else, because MikroTik's licence
does not grant redistribution and they should not be pushed to a cache other
people can read.

## An example of everything either check can find

`examples/caught-by-check` has a file per problem `routeros-check` knows about,
nineteen of them, named after the problem, each the minimal working
configuration with exactly one thing wrong with it so that its golden output
names that problem and nothing else. That is enforced rather than curated:
`ProblemCode` derives `Bounded` and `Enum`, so `--list-problems` prints every
problem there is and the build insists each has such a file which actually
provokes it. Adding a problem fails the build until it has an example; adding a
`Problem` constructor fails it until it has a code.

`examples/caught-by-vmtest` is the other half, and every file in it passes
`routeros-check` on purpose: they are what the cheap check cannot see. An
uncompilable stanza, `hw-offload=` on a chassis that cannot offload, an
interface the chassis does not have, a serial port it does not have, and one
command that fails while running, which is there to show the difference that
matters.

`examples/valid` keeps the pair honest in the other direction, since a checker
that rejected everything would satisfy all of the above.

## Running it

- `nix flake check` runs `routeros-check`, and therefore every example of
  everything it can find.
- `nix run .#routeros-vmtest-selftest` runs the vm test against
  `examples/valid` and `examples/caught-by-vmtest`. Not a flake check: it boots
  a router per example and needs the proprietary image.

## Notes for review

- `haskellPackages.diagnose` is marked broken in nixpkgs and builds fine once
  asked to; only its bounds are stale. `markUnbroken . doJailbreak`, with a
  comment saying so.
- The golden files hold escape codes as well as words, so they are best read
  with `cat`. Colour is part of what a report says, so it is part of what the
  examples pin down.
- `-Werror`, because an inexhaustive `problemCode` is exactly the warning that
  would let a problem lose its code, and a code is what ties it to an example.