Resolve the API contract path when it is served, not when it is compiled

63deb31d1c06 · AtlantisPleb · · parent 62ca6b831dd3

Resolve the API contract path when it is served, not when it is compiled

`/api/contracts/repositories-v1.json` answers 500 in production and staging
both, and has since it shipped. The path lived in a module attribute, so
`Application.app_dir/2` ran at compile time and froze the build machine's
`_build` directory into the module. A release is built in one container and run
in another, where that path does not exist, so `File.read!/1` raised.

Nothing caught it because the failure needs compilation and execution to happen
in different places, and in a test suite they never do -- it answers 200 in
development for the same reason it answers 500 in a release.

So the guard is source-level: no module attribute may resolve an application
directory. Every other caller in `lib/` already resolves inside a function;
this was the only one. Verified by restoring the defect and watching the guard
fail.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web/controllers/api_contract_controller.ex
  • added test/openagents_web/release_paths_test.exs

Diff

2 files changed, +65 -5

lib/openagents_web/controllers/api_contract_controller.ex modified +9 -5

@@ -3,13 +3,10 @@ defmodule OpenAgentsWeb.ApiContractController do

3 3
4 4
  use OpenAgentsWeb, :controller
5 5
6
  @repository_contract Application.app_dir(
7
                         :openagents,
8
                         "priv/api-contracts/repositories-v1.json"
9
                       )
6
  @contract_path "priv/api-contracts/repositories-v1.json"
10 7
11 8
  def repositories_v1(conn, _params) do
12
    contract = File.read!(@repository_contract)
9
    contract = File.read!(contract_file())
13 10
14 11
    conn
15 12
    |> put_resp_content_type("application/json")

@@ -18,6 +15,13 @@ defmodule OpenAgentsWeb.ApiContractController do

18 15
    |> send_resp(:ok, contract)
19 16
  end
20 17
18
  # Resolved when the request is served, not when the module is compiled. As a
19
  # module attribute this baked the *build* machine's `_build` path into the
20
  # release, and that path does not exist in the image that runs it -- so
21
  # `File.read!/1` raised and this endpoint answered 500 in every deployed
22
  # environment while passing everywhere it was compiled and run together.
23
  defp contract_file, do: Application.app_dir(:openagents, @contract_path)
24
21 25
  defp sha256(bytes) do
22 26
    bytes
23 27
    |> then(&:crypto.hash(:sha256, &1))
test/openagents_web/release_paths_test.exs added +56

@@ -0,0 +1,56 @@

1
defmodule OpenAgentsWeb.ReleasePathsTest do
2
  @moduledoc """
3
  A path to a `priv` file must be resolved when it is used, not when the module
4
  is compiled.
5
6
  `Application.app_dir/2` and `:code.priv_dir/1` answer with the path of the
7
  application *as currently loaded*. Evaluated in a module attribute, that is
8
  the build machine's `_build` directory, and it is frozen into the compiled
9
  module. A release is built in one container and run in another, so the frozen
10
  path does not exist where the code actually runs.
11
12
  The failure is invisible in every environment that compiles and runs in the
13
  same place, which is every environment a test suite runs in. So this is a
14
  source-level check rather than a behavioural one: `/api/contracts/repositories-v1.json`
15
  answered 200 in development and 500 in production and staging both, and no
16
  test could have told the difference.
17
  """
18
19
  use OpenAgentsWeb.ConnCase, async: true
20
21
  # `@name Application.app_dir(...)` and `@name :code.priv_dir(...)`, including
22
  # the wrapped form the formatter produces for a long call.
23
  @baked ~r/^[ \t]*@[a-z_]+[ \t]+(?:Application\.app_dir|:code\.priv_dir)\b/m
24
25
  test "no module attribute resolves an application path at compile time" do
26
    offenders =
27
      for path <- Path.wildcard("lib/**/*.ex"),
28
          source = File.read!(path),
29
          [match] <- Regex.scan(@baked, source),
30
          do: {path, String.trim(match)}
31
32
    assert offenders == [], """
33
    These resolve an application directory into a module attribute, which
34
    freezes the build machine's path into the compiled module. The release runs
35
    somewhere else, so the file is not there and reading it raises:
36
37
    #{Enum.map_join(offenders, "\n", fn {file, line} -> "  #{file}\n    #{line}" end)}
38
39
    Move the call into a function so it is resolved when it is used.
40
    """
41
  end
42
43
  describe "the contract endpoint" do
44
    test "serves the contract with an etag", %{conn: conn} do
45
      conn = get(conn, ~p"/api/contracts/repositories-v1.json")
46
47
      assert response = response(conn, 200)
48
      assert {:ok, decoded} = Jason.decode(response)
49
      assert is_map(decoded)
50
51
      assert ["public, max-age=300"] = get_resp_header(conn, "cache-control")
52
      assert [etag] = get_resp_header(conn, "etag")
53
      assert etag =~ ~r/^"[0-9a-f]{64}"$/
54
    end
55
  end
56
end

This page updates live while a promote is in flight · changelog