Code review comment: If the shortest mitigation is empty it means that the situation can be mitigated in 0 moves, i.e it is already fine. This is an inconsistency that will only arise if some change happened to mitigate it between when the nodes were read by CapacityChecker and by this, or if either the CapacityChecker or this has a bug. I don't think we should prefer an unnecessary non-empty mitigation in this case, but arguably we could to signal it upwards such that we don't emit the alert in this case (and perhaps also log it). I'm uncertain on whether it's worth the extra complication.

Code under review:
private Move findMitigatingMove(CapacityChecker.HostFailurePath failurePath) {
        Optional<Node> nodeWhichCantMove = failurePath.failureReason.tenant;
        if (nodeWhichCantMove.isEmpty()) return Move.empty();

        Node node = nodeWhichCantMove.get();
        NodeList allNodes = nodeRepository().list();
        
        
        HostCapacity hostCapacity = new HostCapacity(allNodes, nodeRepository().resourcesCalculator());
        Set<Node> spareHosts = hostCapacity.findSpareHosts(allNodes.hosts().satisfies(node.resources()).asList(), 2);
        List<Node> hosts = allNodes.hosts().except(spareHosts).asList();

        CapacitySolver capacitySolver = new CapacitySolver(hostCapacity, maxIterations);
        List<Move> shortestMitigation = null;
        for (Node spareHost : spareHosts) {
            List<Move> mitigation = capacitySolver.makeRoomFor(node, spareHost, hosts, List.of(), List.of());
            if (mitigation == null) continue;
            if (shortestMitigation == null || shortestMitigation.size() > mitigation.size())
                shortestMitigation = mitigation;
        }
        if (shortestMitigation == null || shortestMitigation.isEmpty()) return Move.empty();
        return shortestMitigation.get(0);
    }

Line commented on: if (shortestMitigation == null || shortestMitigation.size() > mitigation.size())

Revised code:
private Move findMitigatingMove(CapacityChecker.HostFailurePath failurePath) {
        Optional<Node> nodeWhichCantMove = failurePath.failureReason.tenant;
        if (nodeWhichCantMove.isEmpty()) return Move.empty();

        Node node = nodeWhichCantMove.get();
        NodeList allNodes = nodeRepository().list();
        
        
        HostCapacity hostCapacity = new HostCapacity(allNodes, nodeRepository().resourcesCalculator());
        Set<Node> spareHosts = hostCapacity.findSpareHosts(allNodes.hosts().satisfies(node.resources()).asList(), 2);
        List<Node> hosts = allNodes.hosts().except(spareHosts).asList();

        CapacitySolver capacitySolver = new CapacitySolver(hostCapacity, maxIterations);
        List<Move> shortestMitigation = null;
        for (Node spareHost : spareHosts) {
            List<Move> mitigation = capacitySolver.makeRoomFor(node, spareHost, hosts, List.of(), List.of());
            if (mitigation == null) continue;
            if (shortestMitigation == null || shortestMitigation.size() > mitigation.size())
                shortestMitigation = mitigation;
        }
        if (shortestMitigation == null || shortestMitigation.isEmpty()) return Move.empty();
        return shortestMitigation.get(0);
    }