test/openagents/forge/deployment_lane_test.exs

main at 58e6347eeb72 · 6 KB

defmodule OpenAgents.Forge.DeploymentLaneTest do
  @moduledoc """
  A candidate's lane is chosen from the fleet's own topology verdict, before
  any node is touched (RELEASE-009).

  These tests hold three things: the direct and rolling split the hot loader
  already made stays exactly what it was, a fleet that cannot support relup
  never enters the relup lane, and the verdict is recorded on every
  classification whether or not it decided anything.
  """
  use ExUnit.Case, async: true

  alias OpenAgents.Forge.DeploymentLane

  @relup_reasons [
    "toolchain_application_spec_sha256_changed",
    "toolchain_application_version_changed"
  ]

  defp manifest(reasons) do
    %{
      "classification" =>
        if(reasons == [], do: "direct_candidate", else: "needs_rolling_replace"),
      "structural_reasons" => reasons
    }
  end

  defp supported_topology do
    DeploymentLane.fleet_topology(
      members: fn -> [:a@fleet, :b@fleet] end,
      report: fn _node, _opts -> {:ok, %{"incompatible" => []}} end
    )
  end

  defp libring_topology do
    DeploymentLane.fleet_topology(
      members: fn -> [:a@fleet, :b@fleet] end,
      report: fn _node, _opts ->
        {:ok, %{"incompatible" => ["libring:HashRing.Supervisor"]}}
      end
    )
  end

  # ── the split the hot loader already made ────────────────────────────────

  test "a clean, allowlisted candidate takes the direct lane" do
    lane = DeploymentLane.classify(manifest([]), topology: supported_topology())

    assert lane["lane"] == "direct"
    assert lane["reasons"] == []
  end

  test "an off-allowlist module routes a clean candidate to rolling" do
    lane =
      DeploymentLane.classify(manifest([]),
        offending: ["Elixir.OpenAgents.Forge.HotLoader"],
        topology: supported_topology()
      )

    assert lane["lane"] == "rolling"
    assert lane["reasons"] == ["off_allowlist:Elixir.OpenAgents.Forge.HotLoader"]
  end

  test "a structural candidate routes to rolling on its own reasons, whatever the fleet says" do
    lane =
      DeploymentLane.classify(manifest(["config_changed", "migration_added"]),
        topology: supported_topology()
      )

    assert lane["lane"] == "rolling"
    assert lane["reasons"] == ["config_changed", "migration_added"]
    refute Enum.any?(lane["reasons"], &String.starts_with?(&1, "topology"))
  end

  # ── the topology verdict decides the relup lane ──────────────────────────

  test "an application version transition takes the relup lane when the fleet supports it" do
    lane =
      DeploymentLane.classify(manifest(@relup_reasons),
        relup_admitted: true,
        topology: supported_topology()
      )

    assert lane["lane"] == "relup"
    assert lane["topology"]["supported"]
  end

  test "a fleet that cannot support relup never enters the relup lane" do
    lane =
      DeploymentLane.classify(manifest(@relup_reasons),
        relup_admitted: true,
        topology: libring_topology()
      )

    assert lane["lane"] == "rolling"
    assert "topology_incompatible:libring:HashRing.Supervisor" in lane["reasons"]
    refute lane["topology"]["supported"]
  end

  test "the hot loader's own call never reaches the relup lane, because it admits no runner" do
    lane =
      DeploymentLane.classify(manifest(@relup_reasons), topology: supported_topology())

    assert lane["lane"] == "rolling"
    assert "relup_lane_unadmitted" in lane["reasons"]
  end

  # ── the verdict is recorded, and fails closed ────────────────────────────

  test "every classification records the verdict, decisive or not" do
    for reasons <- [[], ["config_changed"], @relup_reasons] do
      lane = DeploymentLane.classify(manifest(reasons), topology: libring_topology())

      assert lane["topology"]["schema"] == "openagents.deployment-lane.topology.v1"
      assert lane["topology"]["nodes"] == 2
      assert lane["topology"]["incompatible"] == ["libring:HashRing.Supervisor"]
    end
  end

  test "an unread verdict refuses relup rather than assuming it" do
    lane = DeploymentLane.classify(manifest(@relup_reasons), relup_admitted: true)

    assert lane["lane"] == "rolling"
    assert "topology_unread" in lane["reasons"]
    refute lane["topology"]["supported"]
  end

  test "a node that cannot be read makes the whole fleet unsupported" do
    topology =
      DeploymentLane.fleet_topology(
        members: fn -> [:a@fleet, :b@fleet] end,
        report: fn
          :a@fleet, _opts -> {:ok, %{"incompatible" => []}}
          :b@fleet, _opts -> :error
        end
      )

    refute topology["supported"]
    assert topology["unreadable"] == 1

    lane =
      DeploymentLane.classify(manifest(@relup_reasons),
        relup_admitted: true,
        topology: topology
      )

    assert lane["lane"] == "rolling"
    assert "topology_unreadable" in lane["reasons"]
  end

  test "a reporting node that raises is unreadable rather than compatible" do
    topology =
      DeploymentLane.fleet_topology(
        members: fn -> [:a@fleet] end,
        report: fn _node, _opts -> {:ok, %{"applications" => 3}} end
      )

    refute topology["supported"]
    assert topology["unreadable"] == 1
  end

  test "an empty fleet is unread, not unanimous" do
    topology = DeploymentLane.fleet_topology(members: fn -> [] end)

    refute topology["supported"]
    assert topology["nodes"] == 0
  end

  test "the verdict names applications and counts nodes, never node names" do
    encoded = Jason.encode!(libring_topology())

    refute encoded =~ "fleet"
    assert encoded =~ "libring"
  end

  # ── this node's own fleet ────────────────────────────────────────────────

  test "the running fleet reports a real verdict, and libring keeps it off relup" do
    topology = DeploymentLane.fleet_topology()

    assert topology["nodes"] >= 1
    assert topology["unreadable"] == 0
    assert "libring:HashRing.Supervisor" in topology["incompatible"]
    refute topology["supported"]

    lane =
      DeploymentLane.classify(manifest(@relup_reasons),
        relup_admitted: true,
        topology: topology
      )

    assert lane["lane"] == "rolling"
  end
end