test/openagents/forge/relup_deployment_test.exs

main at 58e6347eeb72 · 9 KB

defmodule OpenAgents.Forge.RelupDeploymentTest do
  # Not async: the reverse-direction case drives the real RelupNode against the
  # singleton test release handler.
  use ExUnit.Case, async: false

  alias OpenAgents.Forge.RelupDeployment
  alias OpenAgents.ReleaseState
  alias OpenAgents.ReleaseState.State
  alias OpenAgents.Test.ReleaseHandler

  @sha String.duplicate("a", 40)
  @from_sha String.duplicate("c", 40)
  @digest String.duplicate("b", 64)
  @manifest_digest String.duplicate("d", 64)
  @nodes [:first@local, :second@local, :third@local]

  test "upgrades and makes each node permanent before touching the next" do
    parent = self()

    rpc = fn node, _module, function, _arguments, _timeout ->
      send(parent, {:phase, node, function})
      {:ok, %{"phase" => to_string(function)}}
    end

    assert {:ok, %{status: "live"}} =
             RelupDeployment.run(request(),
               members: fn -> @nodes end,
               gate_verifier: fn @sha -> {:ok, %{}} end,
               rpc: rpc
             )

    expected =
      for node <- @nodes,
          phase <- [
            :check_topology,
            :stage,
            :verify_stage,
            :unpack,
            :check_install,
            :install,
            :verify,
            :make_permanent,
            :verify
          ],
          do: {:phase, node, phase}

    assert drain_messages([]) == expected
  end

  test "reverses an unhealthy node and aborts before the next node" do
    parent = self()

    rpc = fn node, _module, function, arguments, _timeout ->
      send(parent, {:phase, node, function})

      case {node, function, arguments} do
        {:second@local, :verify, [_request, :current]} -> {:error, :unhealthy}
        {:second@local, :reverse, _arguments} -> {:ok, %{"restored" => true}}
        _other -> {:ok, %{}}
      end
    end

    assert {:error, %{status: "failed", error_code: error_code}} =
             RelupDeployment.run(request(),
               members: fn -> @nodes end,
               gate_verifier: fn @sha -> {:ok, %{}} end,
               rpc: rpc
             )

    assert is_binary(error_code)
    events = drain_messages([])
    assert {:phase, :second@local, :reverse} in events
    assert {:phase, :first@local, :reverse} in events
    refute Enum.any?(events, fn {_, node, _phase} -> node == :third@local end)
  end

  test "reverses the newly permanent node when membership changes before continuation" do
    parent = self()
    calls = start_supervised!({Agent, fn -> 0 end})

    members = fn ->
      Agent.get_and_update(calls, fn count ->
        next = count + 1
        members = if next >= 3, do: Enum.drop(@nodes, -1), else: @nodes
        {members, next}
      end)
    end

    rpc = fn node, _module, function, _arguments, _timeout ->
      send(parent, {:phase, node, function})
      {:ok, %{}}
    end

    assert {:error, %{status: "failed"}} =
             RelupDeployment.run(request(),
               members: members,
               gate_verifier: fn @sha -> {:ok, %{}} end,
               rpc: rpc
             )

    assert {:phase, :first@local, :reverse} in drain_messages([])
  end

  defp request do
    %{
      sha: @sha,
      from_revision: @from_sha,
      release_name: "openagents",
      from_version: "0.1.0",
      to_version: "0.2.0",
      from_state_version: 1,
      to_state_version: 2,
      artifact_bytes: "artifact",
      artifact_digest: @digest,
      package_manifest_digest: @manifest_digest,
      expected_nodes: @nodes,
      expected_fleet_size: 3
    }
  end

  describe "version admission" do
    test "admits an arbitrary forward semver transition, not only the proof pair" do
      parent = self()

      rpc = fn node, _module, function, _arguments, _timeout ->
        send(parent, {:phase, node, function})
        {:ok, %{}}
      end

      request =
        request()
        |> Map.merge(%{from_version: "0.2.0", to_version: "0.3.0", from_state_version: 2})

      assert {:ok, %{status: "live", from_version: "0.2.0", to_version: "0.3.0"}} =
               RelupDeployment.run(request,
                 members: fn -> @nodes end,
                 gate_verifier: fn @sha -> {:ok, %{}} end,
                 rpc: rpc
               )

      assert length(drain_messages([])) == 27
    end

    test "refuses a degenerate transition" do
      request = request() |> Map.put(:to_version, request().from_version)

      assert {:error, :degenerate_version_transition} =
               RelupDeployment.run(request,
                 members: fn -> @nodes end,
                 gate_verifier: fn @sha -> {:ok, %{}} end,
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
               )
    end

    test "refuses malformed versions" do
      assert {:error, :invalid_from_version} =
               RelupDeployment.run(request() |> Map.put(:from_version, "zero-point-twelve"),
                 members: fn -> @nodes end,
                 gate_verifier: fn @sha -> {:ok, %{}} end,
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
               )

      assert {:error, :invalid_to_version} =
               RelupDeployment.run(request() |> Map.put(:to_version, "0.2"),
                 members: fn -> @nodes end,
                 gate_verifier: fn @sha -> {:ok, %{}} end,
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
               )
    end

    test "refuses a malformed source revision or package manifest digest" do
      assert {:error, :invalid_from_git_sha} =
               RelupDeployment.run(request() |> Map.put(:from_revision, "not-a-sha"))

      assert {:error, :invalid_package_manifest_digest} =
               RelupDeployment.run(request() |> Map.put(:package_manifest_digest, "short"))
    end

    test "refuses state version regression and unsupported state versions" do
      regression =
        request()
        |> Map.merge(%{from_version: "0.2.0", to_version: "0.3.0", from_state_version: 2})
        |> Map.put(:to_state_version, 1)

      assert {:error, :state_version_regression} =
               RelupDeployment.run(regression,
                 members: fn -> @nodes end,
                 gate_verifier: fn @sha -> {:ok, %{}} end,
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
               )

      unsupported = request() |> Map.put(:to_state_version, 3)

      assert {:error, :unsupported_to_state_version} =
               RelupDeployment.run(unsupported,
                 members: fn -> @nodes end,
                 gate_verifier: fn @sha -> {:ok, %{}} end,
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
               )
    end
  end

  describe "the reverse direction on a same-schema pair" do
    test "restores the from release, its permanence, and the process state" do
      state = start_supervised!({ReleaseState, name: nil})
      :ok = ReleaseState.observe("retained", state)

      installer = fn
        "0.3.0" -> migrate(state, ~c"0.2.0", schema_version: 2)
        "0.2.0" -> migrate(state, {:down, ~c"0.3.0"}, schema_version: 2)
      end

      start_supervised!(
        {ReleaseHandler,
         %{
           releases: [{~c"openagents", ~c"0.2.0", [], :permanent}],
           pair: {"0.2.0", "0.3.0"},
           on_install: installer
         }}
      )

      artifact = "immutable 0.3.0 artifact"

      root =
        Path.join(
          System.tmp_dir!(),
          "openagents-relup-fleet-#{System.unique_integer([:positive])}"
        )

      File.mkdir_p!(Path.join(root, "releases"))
      on_exit(fn -> File.rm_rf!(root) end)

      node_opts = [
        release_root: root,
        release_handler: ReleaseHandler,
        # This case is about the reverse path, not the running topology. The
        # real refusal lives in OpenAgents.Forge.RelupTopologyTest, which drives
        # the running libring supervisor.
        topology: fn _opts -> :ok end,
        generate_config: fn _version -> :ok end,
        revision: fn -> request().sha end,
        health: &unready_once/0,
        state: fn -> ReleaseState.snapshot(state) end
      ]

      request =
        request()
        |> Map.merge(%{
          from_version: "0.2.0",
          to_version: "0.3.0",
          from_state_version: 2,
          to_state_version: 2,
          artifact_bytes: artifact,
          artifact_digest: sha256(artifact),
          expected_nodes: [Node.self()],
          expected_fleet_size: 1
        })

      assert {:error, %{status: "failed"}} =
               RelupDeployment.run(request,
                 members: fn -> [Node.self()] end,
                 gate_verifier: fn @sha -> {:ok, %{}} end,
                 rpc: fn _node, module, function, arguments, _timeout ->
                   apply(module, function, arguments ++ [node_opts])
                 end
               )

      assert %State{schema_version: 2, observations: ["retained"], integrity: integrity} =
               ReleaseState.snapshot(state)

      assert is_binary(integrity)

      assert Enum.any?(ReleaseHandler.which_releases(), fn {_name, version, _apps, status} ->
               to_string(version) == "0.2.0" and status == :permanent
             end)
    end
  end

  # The post-install health check fails once, which is what sends the node down
  # the reverse path; every later check reports ready.
  defp unready_once do
    case Process.get(:relup_health_calls, 0) do
      0 ->
        Process.put(:relup_health_calls, 1)
        %{"ready" => false}

      _later ->
        %{"ready" => true}
    end
  end

  defp migrate(pid, version, extra) do
    :ok = :sys.suspend(pid)
    :ok = :sys.change_code(pid, ReleaseState, version, extra)
    :ok = :sys.resume(pid)
  end

  defp sha256(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)

  defp drain_messages(acc) do
    receive do
      event -> drain_messages([event | acc])
    after
      0 -> Enum.reverse(acc)
    end
  end
end