lib/openagents/forge/relup_package.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Forge.RelupPackage do
  @moduledoc """
  Validates a digest-addressed relup package and deploys it to the current fleet.

  The package must match the running revision and the target node operating
  system and architecture. This prevents an operator from passing a macOS or
  ARM release to Linux/AMD64 nodes.
  """

  alias OpenAgents.Forge.RelupDeployment

  @schema "openagents.relup-package.v1"
  @sha_pattern ~r/\A[0-9a-f]{40}\z/
  @digest_pattern ~r/\A[0-9a-f]{64}\z/
  @version_pattern ~r/\A[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?\z/
  @maximum_manifest_bytes 65_536
  @maximum_artifact_bytes 536_870_912

  @doc "Validate and load the target release from a relup package directory."
  def load(directory, opts \\ [])

  def load(directory, opts) when is_binary(directory) do
    with {:ok, manifest, package_manifest_digest} <- read_manifest(directory),
         :ok <- validate_manifest(manifest, opts),
         {:ok, artifact_bytes} <- read_artifact(directory, manifest),
         :ok <- verify_artifact(artifact_bytes, manifest["to_artifact_digest"]) do
      nodes =
        Keyword.get_lazy(opts, :expected_nodes, &OpenAgents.Cluster.members/0) |> Enum.sort()

      {:ok,
       %{
         sha: manifest["to_revision"],
         from_revision: manifest["from_revision"],
         release_name: manifest["release_name"],
         from_version: manifest["from_version"],
         to_version: manifest["to_version"],
         from_state_version: manifest["from_state_version"],
         to_state_version: manifest["to_state_version"],
         artifact_bytes: artifact_bytes,
         artifact_digest: manifest["to_artifact_digest"],
         package_manifest_digest: package_manifest_digest,
         expected_nodes: nodes,
         expected_fleet_size: length(nodes)
       }}
    end
  end

  def load(_directory, _opts), do: {:error, :invalid_package_directory}

  @doc "Validate a relup package and deploy it one node at a time."
  def deploy(directory, opts \\ []) do
    started_at = System.monotonic_time(:millisecond)

    with {:ok, request} <- load(directory, opts) do
      deployment_opts =
        Keyword.drop(opts, [:current_revision, :expected_nodes, :system_architecture])

      case RelupDeployment.run(request, deployment_opts) do
        {status, result} when status in [:ok, :error] and is_map(result) ->
          {status,
           Map.put(
             result,
             :duration_ms,
             System.monotonic_time(:millisecond) - started_at
           )}

        other ->
          other
      end
    end
  end

  defp read_manifest(directory) do
    path = Path.join(directory, "package.json")

    with {:ok, stat} <- File.stat(path),
         true <- stat.type == :regular and stat.size <= @maximum_manifest_bytes,
         {:ok, bytes} <- File.read(path),
         {:ok, manifest} <- Jason.decode(bytes) do
      {:ok, manifest, digest(bytes)}
    else
      {:error, :enoent} -> {:error, :missing_package_manifest}
      {:error, %Jason.DecodeError{}} -> {:error, :invalid_package_manifest_json}
      {:error, reason} -> {:error, {:package_manifest_unreadable, safe_code(reason)}}
      false -> {:error, :invalid_package_manifest_file}
    end
  end

  defp validate_manifest(manifest, opts) when is_map(manifest) do
    current_revision =
      Keyword.get_lazy(opts, :current_revision, fn -> OpenAgents.BuildInfo.revision() end)

    cond do
      manifest["schema"] != @schema ->
        {:error, :wrong_package_schema}

      manifest["release_name"] != "openagents" ->
        {:error, :invalid_release_name}

      not sha?(manifest["from_revision"]) ->
        {:error, :invalid_from_revision}

      not sha?(manifest["to_revision"]) ->
        {:error, :invalid_to_revision}

      manifest["from_revision"] != current_revision ->
        {:error, :running_revision_mismatch}

      not version?(manifest["from_version"]) ->
        {:error, :invalid_from_version}

      not version?(manifest["to_version"]) ->
        {:error, :invalid_to_version}

      manifest["from_version"] == manifest["to_version"] ->
        {:error, :degenerate_version_transition}

      manifest["from_state_version"] not in [1, 2] ->
        {:error, :invalid_from_state_version}

      manifest["to_state_version"] not in [1, 2] ->
        {:error, :invalid_to_state_version}

      manifest["to_state_version"] < manifest["from_state_version"] ->
        {:error, :state_version_regression}

      not digest?(manifest["to_artifact_digest"]) ->
        {:error, :invalid_target_artifact_digest}

      manifest["target_system"] != expected_system(opts) ->
        {:error, :target_system_mismatch}

      true ->
        :ok
    end
  end

  defp validate_manifest(_manifest, _opts), do: {:error, :invalid_package_manifest}

  defp read_artifact(directory, manifest) do
    filename = "openagents-#{manifest["to_version"]}.tar.gz"
    path = Path.join(directory, filename)

    with {:ok, stat} <- File.stat(path),
         true <- stat.type == :regular and stat.size <= @maximum_artifact_bytes,
         {:ok, bytes} <- File.read(path) do
      {:ok, bytes}
    else
      {:error, :enoent} -> {:error, :missing_target_artifact}
      {:error, reason} -> {:error, {:target_artifact_unreadable, safe_code(reason)}}
      false -> {:error, :invalid_target_artifact_file}
    end
  end

  defp verify_artifact(bytes, expected_digest) do
    if digest(bytes) == expected_digest, do: :ok, else: {:error, :target_artifact_digest_mismatch}
  end

  defp expected_system(opts) do
    Keyword.get_lazy(opts, :system_architecture, fn ->
      :erlang.system_info(:system_architecture) |> to_string()
    end)
  end

  defp sha?(value), do: is_binary(value) and Regex.match?(@sha_pattern, value)
  defp digest?(value), do: is_binary(value) and Regex.match?(@digest_pattern, value)
  defp version?(value), do: is_binary(value) and Regex.match?(@version_pattern, value)
  defp digest(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)
  defp safe_code(reason), do: OpenAgents.OperationalLog.code(reason)
end