From 323eb9594b1bf90677bde9402dc7b027c8765b10 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?=
Date: Fri, 12 Jun 2026 16:57:21 +0200
Subject: [PATCH 01/13] Only build the latest patch per Erlang and Elixir
version family
Docker images were built for every Erlang patch release crossed with
every Elixir build, producing tens of thousands of redundant tags per
new OS version. Collapse the expected set to the newest ref per Erlang
major.minor line and the newest Elixir build per minor and OTP major,
and restrict the Elixir expansion to that latest-patch Erlang set so
historical or manually built Erlang tags no longer fan out.
Also expose the build rules as a public validation API for the
upcoming user-requested builds feature.
---
config/test.exs | 3 +-
lib/bob/job/docker_checker.ex | 62 ++++++++-
test/bob/job/docker_checker_test.exs | 193 +++++++++++++++++++++++++++
test/support/fake_github.ex | 15 +++
4 files changed, 268 insertions(+), 5 deletions(-)
create mode 100644 test/support/fake_github.ex
diff --git a/config/test.exs b/config/test.exs
index f43b4b78..d28c92a3 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -2,7 +2,8 @@ import Config
config :bob,
master_schedule: [],
- agent_schedule: []
+ agent_schedule: [],
+ github: Bob.FakeGitHub
config :ex_aws,
access_key_id: "test",
diff --git a/lib/bob/job/docker_checker.ex b/lib/bob/job/docker_checker.ex
index 94c4cfbf..2a87ef0e 100644
--- a/lib/bob/job/docker_checker.ex
+++ b/lib/bob/job/docker_checker.ex
@@ -87,7 +87,7 @@ defmodule Bob.Job.DockerChecker do
defp erlang_tag_name({erlang, os, os_version, _arch}), do: "#{erlang}-#{os}-#{os_version}"
def expected_erlang_tags() do
- refs = erlang_refs()
+ refs = latest_erlang_refs(erlang_refs())
Stream.flat_map(builds(), fn {os, os_versions} ->
Stream.flat_map(refs, fn ref ->
@@ -113,6 +113,20 @@ defmodule Bob.Job.DockerChecker do
end)
end
+ def archs(), do: @archs
+
+ def erlang_versions() do
+ Enum.map(erlang_refs(), fn "OTP-" <> version -> version end)
+ end
+
+ # Whether the build rules allow this erlang/os/os_version combination on all archs.
+ def valid_erlang_build?(erlang, os, os_version) do
+ ref = "OTP-" <> erlang
+
+ build_erlang_ref?(os, ref) and build_erlang_ref?(os, os_version, ref) and
+ Enum.all?(@archs, &build_erlang_ref?(&1, os, os_version, ref))
+ end
+
defp build_erlang_ref?(_os, "OTP-18.0-rc2"), do: false
defp build_erlang_ref?("alpine", "OTP-17" <> _), do: false
@@ -222,12 +236,32 @@ defmodule Bob.Job.DockerChecker do
defp erlang_refs() do
"erlang/otp"
- |> Bob.GitHub.fetch_repo_refs()
+ |> github().fetch_repo_refs()
|> Enum.map(fn {ref_name, _ref} -> ref_name end)
|> Enum.filter(&String.starts_with?(&1, "OTP-"))
|> Enum.sort(&(cmp_erlang_tags(&1, &2) != :lt))
end
+ defp github(), do: Application.get_env(:bob, :github, Bob.GitHub)
+
+ # Keeps only the newest ref per OTP major.minor line. Stable releases rank
+ # above prereleases within a line, so RCs drop out once a stable lands.
+ # Selection happens before the per-OS filters: a line whose newest ref an OS
+ # rule rejects gets no older fallback.
+ def latest_erlang_refs(refs) do
+ refs
+ |> Enum.sort(&(cmp_erlang_tags(&1, &2) != :lt))
+ |> Enum.uniq_by(fn "OTP-" <> version ->
+ version |> to_matchable() |> elem(0) |> Enum.take(2)
+ end)
+ end
+
+ defp latest_erlang_versions() do
+ erlang_refs()
+ |> latest_erlang_refs()
+ |> MapSet.new(fn "OTP-" <> version -> version end)
+ end
+
defp cmp_erlang_tags("OTP-" <> left, "OTP-" <> right) do
cmp_erlang_components(to_matchable(left), to_matchable(right))
end
@@ -293,10 +327,12 @@ defmodule Bob.Job.DockerChecker do
def expected_elixir_tags() do
builds = builds()
- refs = elixir_builds()
+ refs = latest_elixir_builds(elixir_builds())
+ latest_erlang = latest_erlang_versions()
Stream.flat_map(current_erlang_tags(builds), fn {erlang, os, os_version, erlang_arch} ->
- if not skip_elixir_for_erlang?(erlang) and os_version in builds[os] do
+ if MapSet.member?(latest_erlang, erlang) and not skip_elixir_for_erlang?(erlang) and
+ os_version in builds[os] do
Stream.flat_map(refs, fn {"v" <> elixir, otp_major} ->
if compatible_elixir_and_erlang?(otp_major, erlang) do
[{elixir, erlang, os, os_version, erlang_arch}]
@@ -335,6 +371,24 @@ defmodule Bob.Job.DockerChecker do
|> Enum.reject(fn {"v" <> elixir, _otp} -> skip_elixir?(elixir) end)
end
+ # Whether the build rules allow this elixir build on this exact erlang/os/os_version.
+ def valid_elixir_build?(elixir, otp_major, erlang, os, os_version) do
+ build_elixir_ref?({"v" <> elixir, otp_major}) and not skip_elixir?(elixir) and
+ not skip_elixir_for_erlang?(erlang) and compatible_elixir_and_erlang?(otp_major, erlang) and
+ valid_erlang_build?(erlang, os, os_version)
+ end
+
+ # Keeps only the newest build per {elixir major.minor, otp major} pair.
+ # Stable releases rank above prereleases within a pair.
+ def latest_elixir_builds(builds) do
+ builds
+ |> Enum.sort(&cmp_elixir_tags/2)
+ |> Enum.uniq_by(fn {"v" <> elixir, otp} ->
+ version = Version.parse!(normalize_version(elixir))
+ {version.major, version.minor, otp}
+ end)
+ end
+
defp split_elixir_build(build_name) do
case String.split(build_name, "-otp-") do
[elixir, major_otp] -> {elixir, major_otp}
diff --git a/test/bob/job/docker_checker_test.exs b/test/bob/job/docker_checker_test.exs
index 80a5304e..a91fd5dc 100644
--- a/test/bob/job/docker_checker_test.exs
+++ b/test/bob/job/docker_checker_test.exs
@@ -12,9 +12,170 @@ defmodule Bob.Job.DockerCheckerTest do
setup do
Bob.FakeHttpClient.reset()
+ Bob.FakeGitHub.reset()
:ok
end
+ describe "latest_erlang_refs/1" do
+ test "keeps only the newest ref per major.minor line" do
+ refs = ["OTP-26.2", "OTP-26.2.5", "OTP-26.2.5.21", "OTP-26.1.2", "OTP-27.0"]
+
+ assert DockerChecker.latest_erlang_refs(refs) ==
+ ["OTP-27.0", "OTP-26.2.5.21", "OTP-26.1.2"]
+ end
+
+ test "orders multi-component patch versions numerically" do
+ refs = ["OTP-26.2.5.3", "OTP-26.2.5.21"]
+
+ assert DockerChecker.latest_erlang_refs(refs) == ["OTP-26.2.5.21"]
+ end
+
+ test "drops prereleases once a stable release exists in the line" do
+ refs = ["OTP-29.0-rc1", "OTP-29.0-rc3", "OTP-29.0.2"]
+
+ assert DockerChecker.latest_erlang_refs(refs) == ["OTP-29.0.2"]
+ end
+
+ test "keeps the newest prerelease in a line without stable releases" do
+ refs = ["OTP-30.0-rc1", "OTP-30.0-rc2", "OTP-29.0.2"]
+
+ assert DockerChecker.latest_erlang_refs(refs) == ["OTP-30.0-rc2", "OTP-29.0.2"]
+ end
+
+ test "is independent of input order" do
+ refs = ["OTP-26.2.5.21", "OTP-27.0", "OTP-26.2", "OTP-26.1.2", "OTP-26.2.5"]
+
+ assert DockerChecker.latest_erlang_refs(refs) ==
+ ["OTP-27.0", "OTP-26.2.5.21", "OTP-26.1.2"]
+ end
+ end
+
+ describe "latest_elixir_builds/1" do
+ test "keeps only the newest build per elixir minor and otp major" do
+ builds = [
+ {"v1.19.0", "27"},
+ {"v1.19.5", "27"},
+ {"v1.19.5", "28"},
+ {"v1.18.4", "27"}
+ ]
+
+ assert DockerChecker.latest_elixir_builds(builds) ==
+ [{"v1.19.5", "28"}, {"v1.19.5", "27"}, {"v1.18.4", "27"}]
+ end
+
+ test "drops prereleases once a stable release exists in the minor" do
+ builds = [{"v1.19.0-rc.0", "27"}, {"v1.19.5", "27"}]
+
+ assert DockerChecker.latest_elixir_builds(builds) == [{"v1.19.5", "27"}]
+ end
+
+ test "normalizes two-component versions" do
+ builds = [{"v1.18", "27"}, {"v1.18.4", "27"}]
+
+ assert DockerChecker.latest_elixir_builds(builds) == [{"v1.18.4", "27"}]
+ end
+ end
+
+ describe "expected_erlang_tags/0" do
+ test "crosses only the latest patch per minor with current base images" do
+ Repo.insert!(%BaseImageTag{repo: "library/ubuntu", tag: "noble-20250101"})
+
+ Bob.FakeGitHub.stub_refs("erlang/otp", [
+ {"OTP-26.2.5", "sha1"},
+ {"OTP-26.2.5.21", "sha2"},
+ {"OTP-27.0", "sha3"}
+ ])
+
+ assert Enum.sort(DockerChecker.expected_erlang_tags()) ==
+ Enum.sort([
+ {"26.2.5.21", "ubuntu", "noble-20250101", "amd64"},
+ {"26.2.5.21", "ubuntu", "noble-20250101", "arm64"},
+ {"27.0", "ubuntu", "noble-20250101", "amd64"},
+ {"27.0", "ubuntu", "noble-20250101", "arm64"}
+ ])
+ end
+
+ test "applies the os rules after the latest-patch collapse" do
+ Repo.insert!(%BaseImageTag{repo: "library/ubuntu", tag: "resolute-20260101"})
+
+ Bob.FakeGitHub.stub_refs("erlang/otp", [
+ {"OTP-25.3.2.21", "sha1"},
+ {"OTP-26.2.5.21", "sha2"}
+ ])
+
+ assert Enum.sort(DockerChecker.expected_erlang_tags()) ==
+ Enum.sort([
+ {"26.2.5.21", "ubuntu", "resolute-20260101", "amd64"},
+ {"26.2.5.21", "ubuntu", "resolute-20260101", "arm64"}
+ ])
+ end
+ end
+
+ describe "valid_erlang_build?/3" do
+ test "requires openssl 3 support on ubuntu and debian" do
+ refute DockerChecker.valid_erlang_build?("24.1", "ubuntu", "noble-20250101")
+ assert DockerChecker.valid_erlang_build?("24.2", "ubuntu", "noble-20250101")
+ refute DockerChecker.valid_erlang_build?("24.1", "debian", "bookworm-20250101")
+ assert DockerChecker.valid_erlang_build?("24.2", "debian", "bookworm-20250101")
+ end
+
+ test "requires c23 compatibility on ubuntu resolute" do
+ refute DockerChecker.valid_erlang_build?("25.3.2.21", "ubuntu", "resolute-20260101")
+ refute DockerChecker.valid_erlang_build?("26.0-rc3", "ubuntu", "resolute-20260101")
+ assert DockerChecker.valid_erlang_build?("26.0", "ubuntu", "resolute-20260101")
+ end
+
+ test "applies the alpine rules" do
+ refute DockerChecker.valid_erlang_build?("20.3", "alpine", "3.22.1")
+ refute DockerChecker.valid_erlang_build?("25.3", "alpine", "3.23.5")
+ assert DockerChecker.valid_erlang_build?("26.1", "alpine", "3.23.5")
+ end
+ end
+
+ describe "valid_elixir_build?/5" do
+ test "accepts a compatible combination" do
+ assert DockerChecker.valid_elixir_build?("1.18.0", "27", "27.0", "ubuntu", "noble-20250101")
+ end
+
+ test "rejects an otp major mismatch" do
+ refute DockerChecker.valid_elixir_build?("1.18.0", "26", "27.0", "ubuntu", "noble-20250101")
+ end
+
+ test "rejects erlang versions elixir is never built for" do
+ refute DockerChecker.valid_elixir_build?(
+ "1.14.5",
+ "26",
+ "26.0-rc1",
+ "ubuntu",
+ "noble-20250101"
+ )
+ end
+
+ test "rejects elixir versions below 1.10" do
+ refute DockerChecker.valid_elixir_build?(
+ "1.9.4",
+ "22",
+ "22.3",
+ "debian",
+ "bullseye-20250101"
+ )
+ end
+
+ test "rejects prereleases below 1.12" do
+ refute DockerChecker.valid_elixir_build?(
+ "1.11.0-rc.0",
+ "23",
+ "23.3",
+ "debian",
+ "bullseye-20250101"
+ )
+ end
+
+ test "rejects combinations the erlang rules reject" do
+ refute DockerChecker.valid_elixir_build?("1.18.0", "24", "24.1", "ubuntu", "noble-20250101")
+ end
+ end
+
describe "builds/0" do
test "finds the latest base-image tag matching each regex" do
for tag <- ["3.23.4", "3.23.5", "3.22.1"] do
@@ -32,6 +193,7 @@ defmodule Bob.Job.DockerCheckerTest do
describe "expected_elixir_tags/0" do
test "crosses current-os-version erlang tags with compatible elixir builds" do
Repo.insert!(%BaseImageTag{repo: "library/ubuntu", tag: "noble-20250101"})
+ Bob.FakeGitHub.stub_refs("erlang/otp", [{"OTP-27.0", "sha"}])
Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
# An erlang tag on a base image that is no longer current contributes nothing.
Artifacts.add_docker_tag("hexpm/erlang-arm64", "27.0-ubuntu-noble-20240101", ["arm64"])
@@ -46,11 +208,42 @@ defmodule Bob.Job.DockerCheckerTest do
assert Enum.to_list(DockerChecker.expected_elixir_tags()) ==
[{"1.18.0", "27.0", "ubuntu", "noble-20250101", "amd64"}]
end
+
+ test "expands only over the latest patch per erlang minor" do
+ Repo.insert!(%BaseImageTag{repo: "library/ubuntu", tag: "noble-20250101"})
+ Bob.FakeGitHub.stub_refs("erlang/otp", [{"OTP-27.0", "sha1"}, {"OTP-27.0.1", "sha2"}])
+ Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0.1-ubuntu-noble-20250101", ["amd64"])
+ # An erlang tag that is not the latest patch, e.g. built on user request,
+ # contributes nothing.
+ Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
+
+ Bob.FakeHttpClient.stub(:get, @builds_txt_url, 200, "v1.18.0-otp-27 abc123\n")
+
+ assert Enum.to_list(DockerChecker.expected_elixir_tags()) ==
+ [{"1.18.0", "27.0.1", "ubuntu", "noble-20250101", "amd64"}]
+ end
+
+ test "expands only over the latest elixir build per minor and otp major" do
+ Repo.insert!(%BaseImageTag{repo: "library/ubuntu", tag: "noble-20250101"})
+ Bob.FakeGitHub.stub_refs("erlang/otp", [{"OTP-27.0", "sha"}])
+ Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
+
+ Bob.FakeHttpClient.stub(
+ :get,
+ @builds_txt_url,
+ 200,
+ "v1.18.0-otp-27 abc123\nv1.18.4-otp-27 def456\n"
+ )
+
+ assert Enum.to_list(DockerChecker.expected_elixir_tags()) ==
+ [{"1.18.4", "27.0", "ubuntu", "noble-20250101", "amd64"}]
+ end
end
describe "elixir/0" do
setup do
Repo.insert!(%BaseImageTag{repo: "library/ubuntu", tag: "noble-20250101"})
+ Bob.FakeGitHub.stub_refs("erlang/otp", [{"OTP-27.0", "sha"}])
Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
Bob.FakeHttpClient.stub(:get, @builds_txt_url, 200, "v1.18.0-otp-27 abc123\n")
:ok
diff --git a/test/support/fake_github.ex b/test/support/fake_github.ex
new file mode 100644
index 00000000..54c83d05
--- /dev/null
+++ b/test/support/fake_github.ex
@@ -0,0 +1,15 @@
+defmodule Bob.FakeGitHub do
+ def fetch_repo_refs(repo) do
+ :persistent_term.get({__MODULE__, repo}, [])
+ end
+
+ def stub_refs(repo, refs) do
+ :persistent_term.put({__MODULE__, repo}, refs)
+ end
+
+ def reset() do
+ for {{__MODULE__, _repo} = key, _value} <- :persistent_term.get() do
+ :persistent_term.erase(key)
+ end
+ end
+end
From ca6b1e43b5dfce959467fc53b6c8408ca8f54542 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?=
Date: Fri, 12 Jun 2026 17:05:50 +0200
Subject: [PATCH 02/13] Add hex.pm OAuth login
Authenticate users against hexpm's OAuth 2.0 provider with the
authorization code + PKCE flow, mirroring the hexdocs integration.
Tokens and the hex.pm username live in the (now encrypted) session
cookie; access tokens are refreshed five minutes before expiry and the
session lasts 30 days. Only identity is requested (api:read scope).
This prepares for user-requested Docker builds, which will require a
logged-in hex.pm user.
---
assets/css/app.css | 6 +
config/config.exs | 3 +-
config/dev.exs | 5 +-
config/runtime.exs | 5 +-
config/test.exs | 6 +-
lib/bob/hexpm.ex | 29 +++
lib/bob/hexpm/impl.ex | 78 ++++++++
lib/bob/oauth.ex | 43 +++++
lib/bob_web/components/layouts/root.html.heex | 15 ++
lib/bob_web/controllers/oauth_controller.ex | 86 +++++++++
lib/bob_web/endpoint.ex | 6 +-
lib/bob_web/router.ex | 15 +-
lib/bob_web/user_auth.ex | 115 ++++++++++++
test/bob/oauth_test.exs | 49 +++++
.../controllers/oauth_controller_test.exs | 155 ++++++++++++++++
test/bob_web/endpoint_config_test.exs | 2 +
test/bob_web/user_auth_test.exs | 169 ++++++++++++++++++
test/support/fake_hexpm.ex | 29 +++
18 files changed, 807 insertions(+), 9 deletions(-)
create mode 100644 lib/bob/hexpm.ex
create mode 100644 lib/bob/hexpm/impl.ex
create mode 100644 lib/bob/oauth.ex
create mode 100644 lib/bob_web/controllers/oauth_controller.ex
create mode 100644 lib/bob_web/user_auth.ex
create mode 100644 test/bob/oauth_test.exs
create mode 100644 test/bob_web/controllers/oauth_controller_test.exs
create mode 100644 test/bob_web/user_auth_test.exs
create mode 100644 test/support/fake_hexpm.ex
diff --git a/assets/css/app.css b/assets/css/app.css
index 2e226430..2dceec8b 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -287,6 +287,12 @@ pre,
color: var(--color-grey-400);
}
+.nav-user {
+ color: var(--color-grey-300);
+ font-family: var(--font-mono);
+ font-size: 12.5px;
+}
+
.bob-main {
flex: 1;
}
diff --git a/config/config.exs b/config/config.exs
index 8cc60f70..c8d70650 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -50,7 +50,8 @@ config :bob,
master?: true,
parallel_jobs: 1,
local_jobs: [],
- remote_jobs: []
+ remote_jobs: [],
+ hexpm_impl: Bob.Hexpm.Impl
config :mime, :types, %{
"application/vnd.bob+erlang" => ["erlang"]
diff --git a/config/dev.exs b/config/dev.exs
index 5e7f6895..e0f14875 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -2,7 +2,10 @@ import Config
config :bob,
master_schedule: [],
- agent_schedule: []
+ agent_schedule: [],
+ hexpm_url: "http://localhost:4000",
+ oauth_client_id: "b0b00000-0000-4000-8000-000000000b0b",
+ oauth_client_secret: "dev_secret_for_testing"
config :bob, Bob.Repo,
username: "postgres",
diff --git a/config/runtime.exs b/config/runtime.exs
index 3566302d..a3c30c93 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -16,7 +16,10 @@ if config_env() == :prod do
master?: System.fetch_env!("BOB_WHO") == "master",
parallel_jobs: String.to_integer(System.fetch_env!("BOB_PARALLEL_JOBS")),
local_jobs: jobs_fun.("BOB_LOCAL_JOBS"),
- remote_jobs: jobs_fun.("BOB_REMOTE_JOBS")
+ remote_jobs: jobs_fun.("BOB_REMOTE_JOBS"),
+ hexpm_url: System.get_env("BOB_HEXPM_URL", "https://hex.pm"),
+ oauth_client_id: System.fetch_env!("BOB_OAUTH_CLIENT_ID"),
+ oauth_client_secret: System.fetch_env!("BOB_OAUTH_CLIENT_SECRET")
config :ex_aws,
access_key_id: System.fetch_env!("BOB_S3_ACCESS_KEY"),
diff --git a/config/test.exs b/config/test.exs
index d28c92a3..7f177e89 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -3,7 +3,11 @@ import Config
config :bob,
master_schedule: [],
agent_schedule: [],
- github: Bob.FakeGitHub
+ github: Bob.FakeGitHub,
+ hexpm_impl: Bob.FakeHexpm,
+ hexpm_url: "http://localhost:4000",
+ oauth_client_id: "b0b00000-0000-4000-8000-000000000b0b",
+ oauth_client_secret: "test_secret"
config :ex_aws,
access_key_id: "test",
diff --git a/lib/bob/hexpm.ex b/lib/bob/hexpm.ex
new file mode 100644
index 00000000..f85c7be3
--- /dev/null
+++ b/lib/bob/hexpm.ex
@@ -0,0 +1,29 @@
+defmodule Bob.Hexpm do
+ @callback exchange_code(
+ code :: String.t(),
+ code_verifier :: String.t(),
+ redirect_uri :: String.t()
+ ) ::
+ {:ok, map()} | {:error, term()}
+ @callback refresh_token(refresh_token :: String.t()) :: {:ok, map()} | {:error, term()}
+ @callback revoke_token(token :: String.t()) :: :ok | {:error, term()}
+ @callback get_current_user(access_token :: String.t()) :: {:ok, map()} | {:error, term()}
+
+ def exchange_code(code, code_verifier, redirect_uri) do
+ impl().exchange_code(code, code_verifier, redirect_uri)
+ end
+
+ def refresh_token(refresh_token) do
+ impl().refresh_token(refresh_token)
+ end
+
+ def revoke_token(token) do
+ impl().revoke_token(token)
+ end
+
+ def get_current_user(access_token) do
+ impl().get_current_user(access_token)
+ end
+
+ defp impl(), do: Application.fetch_env!(:bob, :hexpm_impl)
+end
diff --git a/lib/bob/hexpm/impl.ex b/lib/bob/hexpm/impl.ex
new file mode 100644
index 00000000..f19af4a2
--- /dev/null
+++ b/lib/bob/hexpm/impl.ex
@@ -0,0 +1,78 @@
+defmodule Bob.Hexpm.Impl do
+ @behaviour Bob.Hexpm
+
+ # Token requests are not retried: authorization codes are single-use, so a
+ # replay after a flaky response would be rejected by hexpm anyway.
+ @impl true
+ def exchange_code(code, code_verifier, redirect_uri) do
+ token_request(%{
+ "grant_type" => "authorization_code",
+ "code" => code,
+ "redirect_uri" => redirect_uri,
+ "client_id" => Bob.OAuth.client_id(),
+ "client_secret" => Bob.OAuth.client_secret(),
+ "code_verifier" => code_verifier,
+ "name" => "bob.hex.pm"
+ })
+ end
+
+ @impl true
+ def refresh_token(refresh_token) do
+ token_request(%{
+ "grant_type" => "refresh_token",
+ "refresh_token" => refresh_token,
+ "client_id" => Bob.OAuth.client_id(),
+ "client_secret" => Bob.OAuth.client_secret()
+ })
+ end
+
+ @impl true
+ def revoke_token(token) do
+ url = Bob.OAuth.hexpm_url() <> "/api/oauth/revoke"
+ headers = [{"content-type", "application/json"}]
+
+ body =
+ JSON.encode!(%{
+ "token" => token,
+ "client_id" => Bob.OAuth.client_id(),
+ "client_secret" => Bob.OAuth.client_secret()
+ })
+
+ case Bob.HTTP.request(:post, url, headers, body) do
+ {:ok, status, _headers, _body} when status in 200..299 -> :ok
+ {:ok, status, _headers, body} -> {:error, {status, body}}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @impl true
+ def get_current_user(access_token) do
+ url = Bob.OAuth.hexpm_url() <> "/api/users/me"
+ headers = [{"authorization", "Bearer " <> access_token}, {"accept", "application/json"}]
+
+ :get
+ |> Bob.HTTP.request(url, headers, "")
+ |> read_response()
+ end
+
+ defp token_request(body) do
+ url = Bob.OAuth.hexpm_url() <> "/api/oauth/token"
+ headers = [{"content-type", "application/json"}]
+
+ :post
+ |> Bob.HTTP.request(url, headers, JSON.encode!(body))
+ |> read_response()
+ end
+
+ defp read_response({:ok, status, _headers, body}) when status in 200..299 do
+ {:ok, JSON.decode!(body)}
+ end
+
+ defp read_response({:ok, status, _headers, body}) do
+ {:error, {status, body}}
+ end
+
+ defp read_response({:error, reason}) do
+ {:error, reason}
+ end
+end
diff --git a/lib/bob/oauth.ex b/lib/bob/oauth.ex
new file mode 100644
index 00000000..0d315fef
--- /dev/null
+++ b/lib/bob/oauth.ex
@@ -0,0 +1,43 @@
+defmodule Bob.OAuth do
+ @moduledoc """
+ OAuth 2.0 Authorization Code with PKCE helpers for authenticating against hexpm.
+ """
+
+ def generate_code_verifier() do
+ :crypto.strong_rand_bytes(32)
+ |> Base.url_encode64(padding: false)
+ end
+
+ def generate_code_challenge(verifier) do
+ :crypto.hash(:sha256, verifier)
+ |> Base.url_encode64(padding: false)
+ end
+
+ def generate_state() do
+ :crypto.strong_rand_bytes(16)
+ |> Base.url_encode64(padding: false)
+ end
+
+ def authorization_url(opts) do
+ state = Keyword.fetch!(opts, :state)
+ code_challenge = Keyword.fetch!(opts, :code_challenge)
+ redirect_uri = Keyword.fetch!(opts, :redirect_uri)
+
+ query =
+ URI.encode_query(%{
+ "response_type" => "code",
+ "client_id" => client_id(),
+ "redirect_uri" => redirect_uri,
+ "scope" => "api:read",
+ "state" => state,
+ "code_challenge" => code_challenge,
+ "code_challenge_method" => "S256"
+ })
+
+ "#{hexpm_url()}/oauth/authorize?#{query}"
+ end
+
+ def hexpm_url(), do: Application.fetch_env!(:bob, :hexpm_url)
+ def client_id(), do: Application.fetch_env!(:bob, :oauth_client_id)
+ def client_secret(), do: Application.fetch_env!(:bob, :oauth_client_secret)
+end
diff --git a/lib/bob_web/components/layouts/root.html.heex b/lib/bob_web/components/layouts/root.html.heex
index 2c16711c..1ae1d554 100644
--- a/lib/bob_web/components/layouts/root.html.heex
+++ b/lib/bob_web/components/layouts/root.html.heex
@@ -43,6 +43,12 @@
>
<.icon name="external" /> hexpm/bob
+ <%= if assigns[:current_user] do %>
+ {@current_user["username"]}
+ <.link class="nav-ext" href={~p"/oauth/logout"} method="post">Log out
+ <% else %>
+ <.link class="nav-ext" href={~p"/oauth/login"}>Log in
+ <% end %>
diff --git a/lib/bob_web/controllers/oauth_controller.ex b/lib/bob_web/controllers/oauth_controller.ex
new file mode 100644
index 00000000..0e8ee392
--- /dev/null
+++ b/lib/bob_web/controllers/oauth_controller.ex
@@ -0,0 +1,86 @@
+defmodule BobWeb.OAuthController do
+ use BobWeb, :controller
+
+ import BobWeb.UserAuth, only: [safe_return_path: 1, log_in_user: 3, log_out_user: 1]
+
+ require Logger
+
+ def login(conn, params) do
+ return_to =
+ safe_return_path(params["return_to"] || get_session(conn, "oauth_return_to") || "/")
+
+ if conn.assigns[:current_user] do
+ redirect(conn, to: return_to)
+ else
+ code_verifier = Bob.OAuth.generate_code_verifier()
+ state = Bob.OAuth.generate_state()
+
+ authorization_url =
+ Bob.OAuth.authorization_url(
+ state: state,
+ code_challenge: Bob.OAuth.generate_code_challenge(code_verifier),
+ redirect_uri: url(~p"/oauth/callback")
+ )
+
+ conn
+ |> put_session("oauth_state", state)
+ |> put_session("oauth_code_verifier", code_verifier)
+ |> put_session("oauth_return_to", return_to)
+ |> redirect(external: authorization_url)
+ end
+ end
+
+ def callback(conn, params) do
+ cond do
+ error = params["error"] ->
+ auth_error(conn, params["error_description"] || error)
+
+ params["state"] == nil or params["state"] != get_session(conn, "oauth_state") ->
+ auth_error(conn, "invalid OAuth state")
+
+ params["code"] == nil ->
+ auth_error(conn, "missing OAuth code")
+
+ true ->
+ exchange_code(conn, params["code"])
+ end
+ end
+
+ def logout(conn, _params) do
+ if refresh_token = get_session(conn, "refresh_token") do
+ case Bob.Hexpm.revoke_token(refresh_token) do
+ :ok -> :ok
+ {:error, reason} -> Logger.warning("OAUTH token revocation failed: #{inspect(reason)}")
+ end
+ end
+
+ conn
+ |> log_out_user()
+ |> redirect(to: ~p"/")
+ end
+
+ defp exchange_code(conn, code) do
+ code_verifier = get_session(conn, "oauth_code_verifier")
+ return_to = safe_return_path(get_session(conn, "oauth_return_to") || "/")
+
+ with {:ok, tokens} <- Bob.Hexpm.exchange_code(code, code_verifier, url(~p"/oauth/callback")),
+ {:ok, user} <- Bob.Hexpm.get_current_user(tokens["access_token"]) do
+ conn
+ |> delete_session("oauth_state")
+ |> delete_session("oauth_code_verifier")
+ |> delete_session("oauth_return_to")
+ |> log_in_user(%{"username" => user["username"]}, tokens)
+ |> redirect(to: return_to)
+ else
+ {:error, reason} ->
+ Logger.error("OAUTH code exchange failed: #{inspect(reason)}")
+ auth_error(conn, "logging in to hex.pm failed")
+ end
+ end
+
+ defp auth_error(conn, message) do
+ conn
+ |> put_flash(:error, "Authentication failed: #{message}")
+ |> redirect(to: ~p"/")
+ end
+end
diff --git a/lib/bob_web/endpoint.ex b/lib/bob_web/endpoint.ex
index ccc55703..dc781429 100644
--- a/lib/bob_web/endpoint.ex
+++ b/lib/bob_web/endpoint.ex
@@ -6,6 +6,8 @@ defmodule BobWeb.Endpoint do
store: :cookie,
key: "_bob_key",
signing_salt: "9kLm2Qx7",
+ encryption_salt: "X4vR7nTq",
+ max_age: 60 * 60 * 24 * 30,
same_site: "Lax"
]
@@ -36,8 +38,8 @@ defmodule BobWeb.Endpoint do
plug(BobWeb.Plugs.Secret, api_only: true)
plug(Plug.Parsers,
- parsers: [:json, Bob.Plug.Parser],
- pass: ["application/json", "application/vnd.bob+erlang"],
+ parsers: [:urlencoded, :json, Bob.Plug.Parser],
+ pass: ["application/x-www-form-urlencoded", "application/json", "application/vnd.bob+erlang"],
json_decoder: JSON
)
diff --git a/lib/bob_web/router.ex b/lib/bob_web/router.ex
index 81db01e0..d175257f 100644
--- a/lib/bob_web/router.ex
+++ b/lib/bob_web/router.ex
@@ -1,6 +1,8 @@
defmodule BobWeb.Router do
use BobWeb, :router
+ import BobWeb.UserAuth
+
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
@@ -8,6 +10,7 @@ defmodule BobWeb.Router do
plug(:put_root_layout, html: {BobWeb.Layouts, :root})
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
+ plug(:fetch_current_user)
end
scope "/api", BobWeb do
@@ -23,8 +26,14 @@ defmodule BobWeb.Router do
scope "/", BobWeb do
pipe_through(:browser)
- live("/", JobsLive)
- live("/artifacts", ArtifactsLive)
- live("/docker", DockerTagsLive)
+ get("/oauth/login", OAuthController, :login)
+ get("/oauth/callback", OAuthController, :callback)
+ post("/oauth/logout", OAuthController, :logout)
+
+ live_session :default, on_mount: [{BobWeb.UserAuth, :mount_current_user}] do
+ live("/", JobsLive)
+ live("/artifacts", ArtifactsLive)
+ live("/docker", DockerTagsLive)
+ end
end
end
diff --git a/lib/bob_web/user_auth.ex b/lib/bob_web/user_auth.ex
new file mode 100644
index 00000000..6e049ea0
--- /dev/null
+++ b/lib/bob_web/user_auth.ex
@@ -0,0 +1,115 @@
+defmodule BobWeb.UserAuth do
+ use BobWeb, :verified_routes
+
+ import Plug.Conn
+ import Phoenix.Controller
+
+ require Logger
+
+ # Refresh access tokens this close to expiry so they don't expire mid-request.
+ @token_refresh_buffer 5 * 60
+
+ def fetch_current_user(conn, _opts) do
+ case get_session(conn, "current_user") do
+ nil ->
+ assign(conn, :current_user, nil)
+
+ user ->
+ if token_needs_refresh?(conn) do
+ refresh_tokens(conn, user)
+ else
+ assign(conn, :current_user, user)
+ end
+ end
+ end
+
+ def require_authenticated_user(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ else
+ conn
+ |> put_session("oauth_return_to", current_path(conn))
+ |> redirect(to: ~p"/oauth/login")
+ |> halt()
+ end
+ end
+
+ def on_mount(:mount_current_user, _params, session, socket) do
+ {:cont, Phoenix.Component.assign(socket, :current_user, session["current_user"])}
+ end
+
+ def on_mount(:ensure_authenticated, _params, session, socket) do
+ case session["current_user"] do
+ nil ->
+ {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/oauth/login")}
+
+ user ->
+ {:cont, Phoenix.Component.assign(socket, :current_user, user)}
+ end
+ end
+
+ def log_in_user(conn, user, tokens) do
+ conn
+ |> configure_session(renew: true)
+ |> put_session("current_user", user)
+ |> store_tokens(tokens)
+ |> assign(:current_user, user)
+ end
+
+ def log_out_user(conn) do
+ conn
+ |> configure_session(drop: true)
+ |> assign(:current_user, nil)
+ end
+
+ def safe_return_path("/" <> rest = path) do
+ if String.starts_with?(rest, "/") do
+ "/"
+ else
+ path
+ end
+ end
+
+ def safe_return_path(_other), do: "/"
+
+ defp store_tokens(conn, tokens) do
+ expires_at = System.system_time(:second) + tokens["expires_in"]
+ refresh_token = tokens["refresh_token"] || get_session(conn, "refresh_token")
+
+ conn
+ |> put_session("access_token", tokens["access_token"])
+ |> put_session("refresh_token", refresh_token)
+ |> put_session("token_expires_at", expires_at)
+ end
+
+ defp token_needs_refresh?(conn) do
+ case get_session(conn, "token_expires_at") do
+ nil -> true
+ expires_at -> expires_at - System.system_time(:second) <= @token_refresh_buffer
+ end
+ end
+
+ defp refresh_tokens(conn, user) do
+ with refresh_token when is_binary(refresh_token) <- get_session(conn, "refresh_token"),
+ {:ok, tokens} <- Bob.Hexpm.refresh_token(refresh_token) do
+ conn
+ |> store_tokens(tokens)
+ |> assign(:current_user, user)
+ else
+ error ->
+ Logger.warning("OAUTH token refresh failed: #{inspect(error)}")
+
+ conn
+ |> clear_auth_session()
+ |> assign(:current_user, nil)
+ end
+ end
+
+ defp clear_auth_session(conn) do
+ Enum.reduce(
+ ["current_user", "access_token", "refresh_token", "token_expires_at"],
+ conn,
+ &delete_session(&2, &1)
+ )
+ end
+end
diff --git a/test/bob/oauth_test.exs b/test/bob/oauth_test.exs
new file mode 100644
index 00000000..3e1771cc
--- /dev/null
+++ b/test/bob/oauth_test.exs
@@ -0,0 +1,49 @@
+defmodule Bob.OAuthTest do
+ use ExUnit.Case, async: true
+
+ alias Bob.OAuth
+
+ describe "generate_code_verifier/0" do
+ test "returns a 43-character url-safe string" do
+ verifier = OAuth.generate_code_verifier()
+
+ assert String.length(verifier) == 43
+ assert verifier =~ ~r/^[A-Za-z0-9_-]+$/
+ end
+
+ test "returns a unique value per call" do
+ assert OAuth.generate_code_verifier() != OAuth.generate_code_verifier()
+ end
+ end
+
+ describe "generate_code_challenge/1" do
+ test "returns the url-encoded sha256 of the verifier" do
+ assert OAuth.generate_code_challenge("verifier") ==
+ Base.url_encode64(:crypto.hash(:sha256, "verifier"), padding: false)
+ end
+ end
+
+ describe "authorization_url/1" do
+ test "builds the hexpm authorize url with pkce parameters" do
+ url =
+ OAuth.authorization_url(
+ state: "state123",
+ code_challenge: "challenge123",
+ redirect_uri: "http://localhost:4003/oauth/callback"
+ )
+
+ assert %URI{} = uri = URI.parse(url)
+ assert url =~ "http://localhost:4000/oauth/authorize?"
+
+ assert URI.decode_query(uri.query) == %{
+ "response_type" => "code",
+ "client_id" => "b0b00000-0000-4000-8000-000000000b0b",
+ "redirect_uri" => "http://localhost:4003/oauth/callback",
+ "scope" => "api:read",
+ "state" => "state123",
+ "code_challenge" => "challenge123",
+ "code_challenge_method" => "S256"
+ }
+ end
+ end
+end
diff --git a/test/bob_web/controllers/oauth_controller_test.exs b/test/bob_web/controllers/oauth_controller_test.exs
new file mode 100644
index 00000000..d10469de
--- /dev/null
+++ b/test/bob_web/controllers/oauth_controller_test.exs
@@ -0,0 +1,155 @@
+defmodule BobWeb.OAuthControllerTest do
+ use BobWeb.ConnCase
+
+ setup do
+ Bob.FakeHexpm.reset()
+ :ok
+ end
+
+ @tokens %{
+ "access_token" => "eyJ.access",
+ "refresh_token" => "eyJ.refresh",
+ "expires_in" => 1800
+ }
+
+ describe "GET /oauth/login" do
+ test "redirects to the hexpm authorize url and stores the flow in the session", %{conn: conn} do
+ conn = get(conn, ~p"/oauth/login")
+
+ location = redirected_to(conn, 302)
+ assert location =~ "http://localhost:4000/oauth/authorize?"
+
+ query = URI.decode_query(URI.parse(location).query)
+ assert query["response_type"] == "code"
+ assert query["scope"] == "api:read"
+ assert query["code_challenge_method"] == "S256"
+ assert query["redirect_uri"] == "http://localhost:4002/oauth/callback"
+
+ assert get_session(conn, "oauth_state") == query["state"]
+ assert get_session(conn, "oauth_code_verifier")
+ assert get_session(conn, "oauth_return_to") == "/"
+ end
+
+ test "stores the sanitized return path", %{conn: conn} do
+ conn = get(conn, ~p"/oauth/login?return_to=/request")
+ assert get_session(conn, "oauth_return_to") == "/request"
+
+ conn = get(conn, ~p"/oauth/login?return_to=//evil.com")
+ assert get_session(conn, "oauth_return_to") == "/"
+ end
+
+ test "redirects straight to the return path when already logged in", %{conn: conn} do
+ conn =
+ conn
+ |> init_test_session(session_fixture())
+ |> get(~p"/oauth/login?return_to=/request")
+
+ assert redirected_to(conn, 302) == "/request"
+ end
+ end
+
+ describe "GET /oauth/callback" do
+ test "logs the user in and redirects to the stored return path", %{conn: conn} do
+ Bob.FakeHexpm.stub(:exchange_code, {:ok, @tokens})
+ Bob.FakeHexpm.stub(:get_current_user, {:ok, %{"username" => "eric"}})
+
+ conn =
+ conn
+ |> init_test_session(%{
+ "oauth_state" => "state123",
+ "oauth_code_verifier" => "verifier",
+ "oauth_return_to" => "/request"
+ })
+ |> get(~p"/oauth/callback?code=code123&state=state123")
+
+ assert redirected_to(conn, 302) == "/request"
+ assert get_session(conn, "current_user") == %{"username" => "eric"}
+ assert get_session(conn, "access_token") == "eyJ.access"
+ assert get_session(conn, "refresh_token") == "eyJ.refresh"
+ assert get_session(conn, "token_expires_at")
+ refute get_session(conn, "oauth_state")
+ refute get_session(conn, "oauth_code_verifier")
+ refute get_session(conn, "oauth_return_to")
+ end
+
+ test "rejects a state mismatch", %{conn: conn} do
+ conn =
+ conn
+ |> init_test_session(%{"oauth_state" => "expected", "oauth_code_verifier" => "verifier"})
+ |> get(~p"/oauth/callback?code=code123&state=wrong")
+
+ assert redirected_to(conn, 302) == "/"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "invalid OAuth state"
+ refute get_session(conn, "current_user")
+ end
+
+ test "rejects a missing state", %{conn: conn} do
+ conn = get(conn, ~p"/oauth/callback?code=code123")
+
+ assert redirected_to(conn, 302) == "/"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "invalid OAuth state"
+ end
+
+ test "surfaces errors returned by hexpm", %{conn: conn} do
+ conn = get(conn, ~p"/oauth/callback?error=access_denied")
+
+ assert redirected_to(conn, 302) == "/"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "access_denied"
+ end
+
+ test "rejects a missing code", %{conn: conn} do
+ conn =
+ conn
+ |> init_test_session(%{"oauth_state" => "state123", "oauth_code_verifier" => "verifier"})
+ |> get(~p"/oauth/callback?state=state123")
+
+ assert redirected_to(conn, 302) == "/"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "missing OAuth code"
+ end
+
+ test "shows an error when the code exchange fails", %{conn: conn} do
+ Bob.FakeHexpm.stub(:exchange_code, {:error, {400, "invalid_grant"}})
+
+ conn =
+ init_test_session(conn, %{
+ "oauth_state" => "state123",
+ "oauth_code_verifier" => "verifier"
+ })
+
+ {conn, log} =
+ ExUnit.CaptureLog.with_log(fn ->
+ get(conn, ~p"/oauth/callback?code=code123&state=state123")
+ end)
+
+ assert log =~ "code exchange failed"
+ assert redirected_to(conn, 302) == "/"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "logging in to hex.pm failed"
+ refute get_session(conn, "current_user")
+ refute get_session(conn, "access_token")
+ end
+ end
+
+ describe "POST /oauth/logout" do
+ test "drops the session and redirects home", %{conn: conn} do
+ Bob.FakeHexpm.stub(:revoke_token, :ok)
+
+ conn =
+ conn
+ |> init_test_session(session_fixture())
+ |> post(~p"/oauth/logout")
+
+ assert redirected_to(conn, 302) == "/"
+ assert conn.private.plug_session_info == :drop
+ assert conn.assigns.current_user == nil
+ end
+ end
+
+ defp session_fixture() do
+ %{
+ "current_user" => %{"username" => "eric"},
+ "access_token" => "eyJ.access",
+ "refresh_token" => "eyJ.refresh",
+ "token_expires_at" => System.system_time(:second) + 1800
+ }
+ end
+end
diff --git a/test/bob_web/endpoint_config_test.exs b/test/bob_web/endpoint_config_test.exs
index e94ec2f3..8a5e9aa7 100644
--- a/test/bob_web/endpoint_config_test.exs
+++ b/test/bob_web/endpoint_config_test.exs
@@ -11,6 +11,8 @@ defmodule BobWeb.EndpointConfigTest do
"BOB_HOSTNAME" => "gce",
"BOB_LOCAL_JOBS" => "[]",
"BOB_MASTER_URL" => "https://bob.hex.pm",
+ "BOB_OAUTH_CLIENT_ID" => "client-id",
+ "BOB_OAUTH_CLIENT_SECRET" => "client-secret",
"BOB_PARALLEL_JOBS" => "10",
"BOB_PORT" => "4003",
"BOB_REMOTE_JOBS" => "[]",
diff --git a/test/bob_web/user_auth_test.exs b/test/bob_web/user_auth_test.exs
new file mode 100644
index 00000000..4d1bd714
--- /dev/null
+++ b/test/bob_web/user_auth_test.exs
@@ -0,0 +1,169 @@
+defmodule BobWeb.UserAuthTest do
+ use BobWeb.ConnCase
+
+ import ExUnit.CaptureLog
+
+ alias BobWeb.UserAuth
+
+ @user %{"username" => "eric"}
+
+ setup %{conn: conn} do
+ Bob.FakeHexpm.reset()
+
+ conn =
+ conn
+ |> Map.replace!(:secret_key_base, BobWeb.Endpoint.config(:secret_key_base))
+ |> init_test_session(%{})
+
+ {:ok, conn: conn}
+ end
+
+ describe "fetch_current_user/2" do
+ test "assigns nil without a session user", %{conn: conn} do
+ conn = UserAuth.fetch_current_user(conn, [])
+
+ assert conn.assigns.current_user == nil
+ end
+
+ test "assigns the session user when the token is fresh", %{conn: conn} do
+ conn =
+ conn
+ |> put_session("current_user", @user)
+ |> put_session("token_expires_at", System.system_time(:second) + 1800)
+ |> UserAuth.fetch_current_user([])
+
+ assert conn.assigns.current_user == @user
+ end
+
+ test "refreshes tokens close to expiry", %{conn: conn} do
+ Bob.FakeHexpm.stub(
+ :refresh_token,
+ {:ok,
+ %{
+ "access_token" => "eyJ.new-access",
+ "refresh_token" => "eyJ.new-refresh",
+ "expires_in" => 1800
+ }}
+ )
+
+ conn =
+ conn
+ |> put_session("current_user", @user)
+ |> put_session("access_token", "eyJ.old-access")
+ |> put_session("refresh_token", "eyJ.old-refresh")
+ |> put_session("token_expires_at", System.system_time(:second) + 60)
+ |> UserAuth.fetch_current_user([])
+
+ assert conn.assigns.current_user == @user
+ assert get_session(conn, "access_token") == "eyJ.new-access"
+ assert get_session(conn, "refresh_token") == "eyJ.new-refresh"
+ assert get_session(conn, "token_expires_at") > System.system_time(:second) + 1700
+ end
+
+ test "logs the user out when the refresh fails", %{conn: conn} do
+ Bob.FakeHexpm.stub(:refresh_token, {:error, {401, "invalid_grant"}})
+
+ conn =
+ conn
+ |> put_session("current_user", @user)
+ |> put_session("access_token", "eyJ.old-access")
+ |> put_session("refresh_token", "eyJ.old-refresh")
+ |> put_session("token_expires_at", System.system_time(:second) - 1)
+
+ {conn, log} = with_log(fn -> UserAuth.fetch_current_user(conn, []) end)
+
+ assert log =~ "token refresh failed"
+ assert conn.assigns.current_user == nil
+ refute get_session(conn, "current_user")
+ refute get_session(conn, "access_token")
+ refute get_session(conn, "refresh_token")
+ end
+
+ test "logs the user out when the token expired without a refresh token", %{conn: conn} do
+ conn =
+ conn
+ |> put_session("current_user", @user)
+ |> put_session("token_expires_at", System.system_time(:second) - 1)
+
+ {conn, _log} = with_log(fn -> UserAuth.fetch_current_user(conn, []) end)
+
+ assert conn.assigns.current_user == nil
+ refute get_session(conn, "current_user")
+ end
+ end
+
+ describe "require_authenticated_user/2" do
+ test "passes an authenticated conn through", %{conn: conn} do
+ conn =
+ conn
+ |> assign(:current_user, @user)
+ |> UserAuth.require_authenticated_user([])
+
+ refute conn.halted
+ end
+
+ test "redirects anonymous users to the login page", %{conn: conn} do
+ conn =
+ %{conn | path_info: ["request"], request_path: "/request"}
+ |> Phoenix.Controller.fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert conn.halted
+ assert redirected_to(conn, 302) == "/oauth/login"
+ assert get_session(conn, "oauth_return_to") == "/request"
+ end
+ end
+
+ describe "on_mount/4" do
+ test "mount_current_user assigns from the session" do
+ assert {:cont, socket} =
+ UserAuth.on_mount(
+ :mount_current_user,
+ %{},
+ %{"current_user" => @user},
+ %Phoenix.LiveView.Socket{}
+ )
+
+ assert socket.assigns.current_user == @user
+
+ assert {:cont, socket} =
+ UserAuth.on_mount(:mount_current_user, %{}, %{}, %Phoenix.LiveView.Socket{})
+
+ assert socket.assigns.current_user == nil
+ end
+
+ test "ensure_authenticated halts anonymous sockets" do
+ socket = %Phoenix.LiveView.Socket{
+ endpoint: BobWeb.Endpoint,
+ router: BobWeb.Router
+ }
+
+ assert {:halt, socket} = UserAuth.on_mount(:ensure_authenticated, %{}, %{}, socket)
+ assert {:redirect, %{to: "/oauth/login"}} = socket.redirected
+ end
+
+ test "ensure_authenticated continues authenticated sockets" do
+ assert {:cont, socket} =
+ UserAuth.on_mount(
+ :ensure_authenticated,
+ %{},
+ %{"current_user" => @user},
+ %Phoenix.LiveView.Socket{}
+ )
+
+ assert socket.assigns.current_user == @user
+ end
+ end
+
+ describe "safe_return_path/1" do
+ test "allows normal paths" do
+ assert UserAuth.safe_return_path("/request") == "/request"
+ end
+
+ test "rejects protocol-relative and external urls" do
+ assert UserAuth.safe_return_path("//evil.com") == "/"
+ assert UserAuth.safe_return_path("https://evil.com") == "/"
+ assert UserAuth.safe_return_path(nil) == "/"
+ end
+ end
+end
diff --git a/test/support/fake_hexpm.ex b/test/support/fake_hexpm.ex
new file mode 100644
index 00000000..7ee17edd
--- /dev/null
+++ b/test/support/fake_hexpm.ex
@@ -0,0 +1,29 @@
+defmodule Bob.FakeHexpm do
+ @behaviour Bob.Hexpm
+
+ @impl true
+ def exchange_code(_code, _code_verifier, _redirect_uri), do: stubbed(:exchange_code)
+
+ @impl true
+ def refresh_token(_refresh_token), do: stubbed(:refresh_token)
+
+ @impl true
+ def revoke_token(_token), do: stubbed(:revoke_token)
+
+ @impl true
+ def get_current_user(_access_token), do: stubbed(:get_current_user)
+
+ def stub(function, response) do
+ :persistent_term.put({__MODULE__, function}, response)
+ end
+
+ def reset() do
+ for {{__MODULE__, _function} = key, _value} <- :persistent_term.get() do
+ :persistent_term.erase(key)
+ end
+ end
+
+ defp stubbed(function) do
+ :persistent_term.get({__MODULE__, function}, {:error, :not_stubbed})
+ end
+end
From 492bdf7abd7a2417101da898076b0dc94f1cb20c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?=
Date: Fri, 12 Jun 2026 17:11:13 +0200
Subject: [PATCH 03/13] Add user build requests with staged reconciliation
Authenticated users can request builds of specific patch versions the
latest-patch policy skips. Requests are one-time (pinned to the current
base image), validated against the existing build rules, and capped at
ten builds per user per hour. Every request is recorded in the
build_requests table.
The DockerChecker reconciles pending requests each cycle: Elixir images
build FROM the Erlang image and failed jobs are not retried, so the
Erlang base is enqueued first and the Elixir job follows once the base
tag exists. Requests complete when all target tags exist and expire
when their os_version rotates out or after 14 days.
---
lib/bob/build_requests.ex | 136 +++++++++++++++
lib/bob/build_requests/build_request.ex | 27 +++
lib/bob/job/docker_checker.ex | 71 ++++++++
.../20260612170000_create_build_requests.exs | 20 +++
test/bob/build_requests_test.exs | 157 ++++++++++++++++++
test/bob/job/docker_checker_test.exs | 110 ++++++++++++
6 files changed, 521 insertions(+)
create mode 100644 lib/bob/build_requests.ex
create mode 100644 lib/bob/build_requests/build_request.ex
create mode 100644 priv/repo/migrations/20260612170000_create_build_requests.exs
create mode 100644 test/bob/build_requests_test.exs
diff --git a/lib/bob/build_requests.ex b/lib/bob/build_requests.ex
new file mode 100644
index 00000000..778dd747
--- /dev/null
+++ b/lib/bob/build_requests.ex
@@ -0,0 +1,136 @@
+defmodule Bob.BuildRequests do
+ import Ecto.Query
+
+ require Logger
+
+ alias Bob.BuildRequests.BuildRequest
+ alias Bob.Repo
+
+ @hourly_build_limit 10
+
+ def hourly_build_limit(), do: @hourly_build_limit
+
+ def submit(attrs) do
+ request = struct!(BuildRequest, attrs)
+
+ with :ok <- validate_combo(request) do
+ case Bob.Job.DockerChecker.request_jobs(request) do
+ [] ->
+ {:ok, :already_built}
+
+ jobs ->
+ submit_jobs(request, attrs, jobs)
+ end
+ end
+ end
+
+ defp submit_jobs(request, attrs, jobs) do
+ builds_count = builds_count(request.kind, jobs)
+
+ if over_limit?(request.username, builds_count) do
+ {:error, :rate_limited}
+ else
+ {:ok, request} = create(Map.put(attrs, :builds_count, builds_count))
+ Bob.Queue.add_many(jobs)
+
+ Logger.info(
+ "BUILD REQUEST by #{request.username}: #{request.kind} #{request_target(request)}"
+ )
+
+ {:ok, request}
+ end
+ end
+
+ defp validate_combo(%{kind: "erlang"} = request) do
+ if Bob.Job.DockerChecker.valid_erlang_build?(request.erlang, request.os, request.os_version) do
+ :ok
+ else
+ {:error, :invalid_combo}
+ end
+ end
+
+ defp validate_combo(%{kind: "elixir"} = request) do
+ otp_major = request.erlang |> String.split(".") |> hd()
+
+ if request.elixir != nil and
+ Bob.Job.DockerChecker.valid_elixir_build?(
+ request.elixir,
+ otp_major,
+ request.erlang,
+ request.os,
+ request.os_version
+ ) do
+ :ok
+ else
+ {:error, :invalid_combo}
+ end
+ end
+
+ # An erlang job under an elixir request implies a follow-up elixir build on
+ # the same arch, so it counts as two builds against the hourly limit.
+ defp builds_count("erlang", jobs), do: length(jobs)
+
+ defp builds_count("elixir", jobs) do
+ Enum.reduce(jobs, 0, fn
+ {{Bob.Job.BuildDockerErlang, _arch}, _args}, acc -> acc + 2
+ {{Bob.Job.BuildDockerElixir, _arch}, _args}, acc -> acc + 1
+ end)
+ end
+
+ defp over_limit?(username, builds_count) do
+ hour_ago = DateTime.add(DateTime.utc_now(), -1, :hour)
+ builds_count_for_user_since(username, hour_ago) + builds_count > @hourly_build_limit
+ end
+
+ defp request_target(%{kind: "erlang"} = request) do
+ "#{request.erlang}-#{request.os}-#{request.os_version}"
+ end
+
+ defp request_target(%{kind: "elixir"} = request) do
+ "#{request.elixir}-erlang-#{request.erlang}-#{request.os}-#{request.os_version}"
+ end
+
+ def create(attrs) do
+ %BuildRequest{}
+ |> BuildRequest.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ def pending() do
+ Repo.all(
+ from(request in BuildRequest,
+ where: request.state == "pending",
+ order_by: [asc: request.inserted_at]
+ )
+ )
+ end
+
+ def complete(request), do: set_state(request, "completed")
+
+ def expire(request), do: set_state(request, "expired")
+
+ def builds_count_for_user_since(username, since) do
+ Repo.one(
+ from(request in BuildRequest,
+ where: request.username == ^username and request.inserted_at >= ^since,
+ select: coalesce(sum(request.builds_count), 0)
+ )
+ )
+ end
+
+ def recent_for_user(username, limit \\ 10) do
+ Repo.all(
+ from(request in BuildRequest,
+ where: request.username == ^username,
+ order_by: [desc: request.inserted_at],
+ limit: ^limit
+ )
+ )
+ end
+
+ defp set_state(request, state) do
+ request
+ |> Ecto.Changeset.change(state: state)
+ |> Repo.update!()
+ end
+end
diff --git a/lib/bob/build_requests/build_request.ex b/lib/bob/build_requests/build_request.ex
new file mode 100644
index 00000000..d95fff51
--- /dev/null
+++ b/lib/bob/build_requests/build_request.ex
@@ -0,0 +1,27 @@
+defmodule Bob.BuildRequests.BuildRequest do
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ @fields [:username, :kind, :elixir, :erlang, :os, :os_version, :builds_count]
+ @required [:username, :kind, :erlang, :os, :os_version, :builds_count]
+
+ schema "build_requests" do
+ field(:username, :string)
+ field(:kind, :string)
+ field(:elixir, :string)
+ field(:erlang, :string)
+ field(:os, :string)
+ field(:os_version, :string)
+ field(:builds_count, :integer)
+ field(:state, :string, default: "pending")
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ def changeset(build_request, attrs) do
+ build_request
+ |> cast(attrs, @fields)
+ |> validate_required(@required)
+ |> validate_inclusion(:kind, ~w(erlang elixir))
+ end
+end
diff --git a/lib/bob/job/docker_checker.ex b/lib/bob/job/docker_checker.ex
index 2a87ef0e..c7df883d 100644
--- a/lib/bob/job/docker_checker.ex
+++ b/lib/bob/job/docker_checker.ex
@@ -55,11 +55,13 @@ defmodule Bob.Job.DockerChecker do
def run() do
erlang()
elixir()
+ requests()
manifest()
end
def run(:erlang), do: erlang()
def run(:elixir), do: elixir()
+ def run(:requests), do: requests()
def run(:manifest), do: manifest()
def priority(), do: 1
@@ -442,6 +444,75 @@ defmodule Bob.Job.DockerChecker do
Version.compare(normalize_version(elixir), "1.10.0-0") == :lt
end
+ # Requests that still have no tags after this long are unbuildable; expiring
+ # them stops the reconciliation from re-enqueueing doomed jobs forever.
+ @request_ttl_days 14
+
+ # User-requested builds are one-time: pinned to the os_version current at
+ # request time and reconciled here until every target tag exists. Requests
+ # whose os_version rotated out of builds() expire instead.
+ def requests() do
+ builds = builds()
+
+ Enum.each(Bob.BuildRequests.pending(), fn request ->
+ cond do
+ request.os_version not in Map.get(builds, request.os, []) ->
+ Bob.BuildRequests.expire(request)
+
+ DateTime.diff(DateTime.utc_now(), request.inserted_at, :day) >= @request_ttl_days ->
+ Bob.BuildRequests.expire(request)
+
+ true ->
+ case request_jobs(request) do
+ [] -> Bob.BuildRequests.complete(request)
+ jobs -> Bob.Queue.add_many(jobs)
+ end
+ end
+ end)
+ end
+
+ def request_jobs(%{kind: "erlang"} = request) do
+ %{erlang: erlang, os: os, os_version: os_version} = request
+ tag = "#{erlang}-#{os}-#{os_version}"
+
+ Enum.flat_map(@archs, fn arch ->
+ if tag_present?("hexpm/erlang-#{arch}", tag) do
+ []
+ else
+ [{{Bob.Job.BuildDockerErlang, arch}, [erlang, os, os_version]}]
+ end
+ end)
+ end
+
+ # The elixir image is built FROM the erlang image, and failed jobs are not
+ # retried by the queue, so the elixir job is only enqueued once its base tag
+ # exists; until then the erlang build is enqueued and the next reconciliation
+ # cycle picks the request up again.
+ def request_jobs(%{kind: "elixir"} = request) do
+ %{elixir: elixir, erlang: erlang, os: os, os_version: os_version} = request
+ erlang_tag = "#{erlang}-#{os}-#{os_version}"
+ elixir_tag = "#{elixir}-erlang-#{erlang}-#{os}-#{os_version}"
+
+ Enum.flat_map(@archs, fn arch ->
+ cond do
+ tag_present?("hexpm/elixir-#{arch}", elixir_tag) ->
+ []
+
+ tag_present?("hexpm/erlang-#{arch}", erlang_tag) ->
+ [{{Bob.Job.BuildDockerElixir, arch}, [elixir, erlang, os, os_version]}]
+
+ true ->
+ [{{Bob.Job.BuildDockerErlang, arch}, [erlang, os, os_version]}]
+ end
+ end)
+ end
+
+ defp tag_present?(repo, tag) do
+ repo
+ |> Bob.Artifacts.docker_tags_present([tag])
+ |> MapSet.member?(tag)
+ end
+
def manifest() do
check_manifests("erlang")
check_manifests("elixir")
diff --git a/priv/repo/migrations/20260612170000_create_build_requests.exs b/priv/repo/migrations/20260612170000_create_build_requests.exs
new file mode 100644
index 00000000..740a02ee
--- /dev/null
+++ b/priv/repo/migrations/20260612170000_create_build_requests.exs
@@ -0,0 +1,20 @@
+defmodule Bob.Repo.Migrations.CreateBuildRequests do
+ use Ecto.Migration
+
+ def change do
+ create table(:build_requests) do
+ add :username, :string, null: false
+ add :kind, :string, null: false
+ add :elixir, :string
+ add :erlang, :string, null: false
+ add :os, :string, null: false
+ add :os_version, :string, null: false
+ add :builds_count, :integer, null: false
+ add :state, :string, null: false, default: "pending"
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create index(:build_requests, [:username, :inserted_at])
+ create index(:build_requests, [:state], where: "state = 'pending'")
+ end
+end
diff --git a/test/bob/build_requests_test.exs b/test/bob/build_requests_test.exs
new file mode 100644
index 00000000..3a3dfdb9
--- /dev/null
+++ b/test/bob/build_requests_test.exs
@@ -0,0 +1,157 @@
+defmodule Bob.BuildRequestsTest do
+ use Bob.DataCase
+
+ alias Bob.Artifacts
+ alias Bob.BuildRequests
+ alias Bob.BuildRequests.BuildRequest
+ alias Bob.Queue.Job
+
+ @erlang_attrs %{
+ username: "eric",
+ kind: "erlang",
+ erlang: "27.0",
+ os: "ubuntu",
+ os_version: "noble-20250101"
+ }
+
+ @elixir_attrs %{
+ username: "eric",
+ kind: "elixir",
+ elixir: "1.18.0",
+ erlang: "27.0",
+ os: "ubuntu",
+ os_version: "noble-20250101"
+ }
+
+ describe "submit/1 with an erlang request" do
+ test "enqueues a build per missing arch and records the request" do
+ assert {:ok, %BuildRequest{} = request} = submit(@erlang_attrs)
+
+ assert request.builds_count == 2
+ assert request.state == "pending"
+
+ jobs = Repo.all(Job) |> Enum.map(&{&1.module_key, &1.args}) |> Enum.sort()
+
+ assert jobs ==
+ Enum.sort([
+ {{Bob.Job.BuildDockerErlang, "amd64"}, ["27.0", "ubuntu", "noble-20250101"]},
+ {{Bob.Job.BuildDockerErlang, "arm64"}, ["27.0", "ubuntu", "noble-20250101"]}
+ ])
+ end
+
+ test "enqueues only the missing arch" do
+ Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
+
+ assert {:ok, %BuildRequest{builds_count: 1}} = submit(@erlang_attrs)
+
+ assert [%Job{module_key: {Bob.Job.BuildDockerErlang, "arm64"}}] = Repo.all(Job)
+ end
+
+ test "reports already built tags without recording a request" do
+ Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
+ Artifacts.add_docker_tag("hexpm/erlang-arm64", "27.0-ubuntu-noble-20250101", ["arm64"])
+
+ assert BuildRequests.submit(@erlang_attrs) == {:ok, :already_built}
+ assert Repo.all(Job) == []
+ assert Repo.all(BuildRequest) == []
+ end
+
+ test "rejects combinations the build rules reject" do
+ attrs = %{@erlang_attrs | erlang: "24.1"}
+
+ assert BuildRequests.submit(attrs) == {:error, :invalid_combo}
+ assert Repo.all(Job) == []
+ assert Repo.all(BuildRequest) == []
+ end
+ end
+
+ describe "submit/1 with an elixir request" do
+ test "enqueues the elixir builds when the erlang base exists" do
+ Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
+ Artifacts.add_docker_tag("hexpm/erlang-arm64", "27.0-ubuntu-noble-20250101", ["arm64"])
+
+ assert {:ok, %BuildRequest{builds_count: 2}} = submit(@elixir_attrs)
+
+ jobs = Repo.all(Job) |> Enum.map(&{&1.module_key, &1.args}) |> Enum.sort()
+
+ assert jobs ==
+ Enum.sort([
+ {{Bob.Job.BuildDockerElixir, "amd64"},
+ ["1.18.0", "27.0", "ubuntu", "noble-20250101"]},
+ {{Bob.Job.BuildDockerElixir, "arm64"},
+ ["1.18.0", "27.0", "ubuntu", "noble-20250101"]}
+ ])
+ end
+
+ test "enqueues the erlang base first when it is missing" do
+ assert {:ok, %BuildRequest{builds_count: 4}} = submit(@elixir_attrs)
+
+ jobs = Repo.all(Job) |> Enum.map(& &1.module_key) |> Enum.sort()
+
+ assert jobs ==
+ Enum.sort([
+ {Bob.Job.BuildDockerErlang, "amd64"},
+ {Bob.Job.BuildDockerErlang, "arm64"}
+ ])
+ end
+
+ test "rejects erlang versions elixir is never built for" do
+ attrs = %{@elixir_attrs | elixir: "1.14.5", erlang: "26.0-rc1"}
+
+ assert BuildRequests.submit(attrs) == {:error, :invalid_combo}
+ end
+
+ test "rejects a missing elixir version" do
+ attrs = %{@elixir_attrs | elixir: nil}
+
+ assert BuildRequests.submit(attrs) == {:error, :invalid_combo}
+ end
+ end
+
+ describe "submit/1 rate limiting" do
+ test "rejects requests over the hourly build limit" do
+ {:ok, _request} =
+ BuildRequests.create(Map.put(@erlang_attrs, :builds_count, 9))
+
+ assert BuildRequests.submit(%{@erlang_attrs | erlang: "28.0"}) == {:error, :rate_limited}
+ assert Repo.all(Job) == []
+ end
+
+ test "ignores requests older than an hour" do
+ {:ok, request} = BuildRequests.create(Map.put(@erlang_attrs, :builds_count, 10))
+
+ request
+ |> Ecto.Changeset.change(inserted_at: DateTime.add(DateTime.utc_now(), -2, :hour))
+ |> Repo.update!()
+
+ assert {:ok, %BuildRequest{}} = submit(%{@erlang_attrs | erlang: "28.0"})
+ end
+
+ test "counts builds across requests within the hour" do
+ {:ok, _request} = BuildRequests.create(Map.put(@erlang_attrs, :builds_count, 8))
+
+ assert {:ok, %BuildRequest{builds_count: 2}} = submit(%{@erlang_attrs | erlang: "28.0"})
+ assert BuildRequests.submit(%{@erlang_attrs | erlang: "28.1"}) == {:error, :rate_limited}
+ end
+ end
+
+ describe "builds_count_for_user_since/2" do
+ test "sums only the given user's requests" do
+ {:ok, _} = BuildRequests.create(Map.put(@erlang_attrs, :builds_count, 2))
+ {:ok, _} = BuildRequests.create(Map.put(@elixir_attrs, :builds_count, 4))
+
+ {:ok, _} =
+ BuildRequests.create(%{@erlang_attrs | username: "jose"} |> Map.put(:builds_count, 2))
+
+ hour_ago = DateTime.add(DateTime.utc_now(), -1, :hour)
+
+ assert BuildRequests.builds_count_for_user_since("eric", hour_ago) == 6
+ assert BuildRequests.builds_count_for_user_since("jose", hour_ago) == 2
+ assert BuildRequests.builds_count_for_user_since("nobody", hour_ago) == 0
+ end
+ end
+
+ defp submit(attrs) do
+ BuildRequests.submit(attrs)
+ end
+end
diff --git a/test/bob/job/docker_checker_test.exs b/test/bob/job/docker_checker_test.exs
index a91fd5dc..2b39d5b6 100644
--- a/test/bob/job/docker_checker_test.exs
+++ b/test/bob/job/docker_checker_test.exs
@@ -269,6 +269,116 @@ defmodule Bob.Job.DockerCheckerTest do
end
end
+ describe "requests/0" do
+ setup do
+ Repo.insert!(%BaseImageTag{repo: "library/ubuntu", tag: "noble-20250101"})
+ :ok
+ end
+
+ test "enqueues missing builds and keeps the request pending" do
+ request = insert_request(kind: "erlang", erlang: "27.0")
+
+ DockerChecker.requests()
+
+ assert Enum.count(Repo.all(Job)) == 2
+ assert Repo.reload!(request).state == "pending"
+ end
+
+ test "completes an erlang request once both archs are built" do
+ request = insert_request(kind: "erlang", erlang: "27.0")
+ Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
+ Artifacts.add_docker_tag("hexpm/erlang-arm64", "27.0-ubuntu-noble-20250101", ["arm64"])
+
+ DockerChecker.requests()
+
+ assert Repo.all(Job) == []
+ assert Repo.reload!(request).state == "completed"
+ end
+
+ test "stages an elixir request through the erlang base build" do
+ request = insert_request(kind: "elixir", elixir: "1.18.0", erlang: "27.0")
+ Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
+
+ DockerChecker.requests()
+
+ jobs = Repo.all(Job) |> Enum.map(&{&1.module_key, &1.args}) |> Enum.sort()
+
+ assert jobs ==
+ Enum.sort([
+ {{Bob.Job.BuildDockerElixir, "amd64"},
+ ["1.18.0", "27.0", "ubuntu", "noble-20250101"]},
+ {{Bob.Job.BuildDockerErlang, "arm64"}, ["27.0", "ubuntu", "noble-20250101"]}
+ ])
+
+ assert Repo.reload!(request).state == "pending"
+ end
+
+ test "completes an elixir request once both archs are built" do
+ request = insert_request(kind: "elixir", elixir: "1.18.0", erlang: "27.0")
+
+ Artifacts.add_docker_tag(
+ "hexpm/elixir-amd64",
+ "1.18.0-erlang-27.0-ubuntu-noble-20250101",
+ ["amd64"]
+ )
+
+ Artifacts.add_docker_tag(
+ "hexpm/elixir-arm64",
+ "1.18.0-erlang-27.0-ubuntu-noble-20250101",
+ ["arm64"]
+ )
+
+ DockerChecker.requests()
+
+ assert Repo.all(Job) == []
+ assert Repo.reload!(request).state == "completed"
+ end
+
+ test "expires requests whose os_version is no longer current" do
+ request = insert_request(kind: "erlang", erlang: "27.0", os_version: "noble-20240101")
+
+ DockerChecker.requests()
+
+ assert Repo.all(Job) == []
+ assert Repo.reload!(request).state == "expired"
+ end
+
+ test "expires requests that never completed within the ttl" do
+ request = insert_request(kind: "erlang", erlang: "27.0")
+
+ request
+ |> Ecto.Changeset.change(inserted_at: DateTime.add(DateTime.utc_now(), -15, :day))
+ |> Repo.update!()
+
+ DockerChecker.requests()
+
+ assert Repo.all(Job) == []
+ assert Repo.reload!(request).state == "expired"
+ end
+
+ test "does not duplicate jobs already queued" do
+ insert_request(kind: "erlang", erlang: "27.0")
+
+ DockerChecker.requests()
+ DockerChecker.requests()
+
+ assert Enum.count(Repo.all(Job)) == 2
+ end
+
+ defp insert_request(attrs) do
+ attrs =
+ Enum.into(attrs, %{
+ username: "eric",
+ os: "ubuntu",
+ os_version: "noble-20250101",
+ builds_count: 2
+ })
+
+ {:ok, request} = Bob.BuildRequests.create(attrs)
+ request
+ end
+ end
+
describe "manifest/0" do
test "enqueues manifest jobs for per-arch tags missing from the manifest repo" do
Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"])
From 9edf316c1a845e0b32fb17ce10edb025ae82d341 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?=
Date: Fri, 12 Jun 2026 17:16:32 +0200
Subject: [PATCH 04/13] Add the build request page
Authenticated hex.pm users pick an image kind, OS, base image version,
and the exact Erlang (and Elixir) version from dropdowns derived from
the real OTP refs and Elixir builds, so submitted values are valid by
construction. Versions load through a small read-through cache since
listing OTP refs takes over a dozen GitHub requests. Recent requests
are listed with their reconciliation state.
---
assets/css/app.css | 11 +
lib/bob/application.ex | 1 +
lib/bob/cache.ex | 39 +++
lib/bob_web/components/layouts/root.html.heex | 6 +
lib/bob_web/live/request_live.ex | 308 ++++++++++++++++++
lib/bob_web/router.ex | 8 +
test/bob_web/live/request_live_test.exs | 209 ++++++++++++
7 files changed, 582 insertions(+)
create mode 100644 lib/bob/cache.ex
create mode 100644 lib/bob_web/live/request_live.ex
create mode 100644 test/bob_web/live/request_live_test.exs
diff --git a/assets/css/app.css b/assets/css/app.css
index 2dceec8b..b9f95e7e 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -293,6 +293,17 @@ pre,
font-size: 12.5px;
}
+.req-intro {
+ padding: 14px 20px 0;
+ font-size: 13.5px;
+ color: var(--color-grey-600);
+}
+
+.req-heading {
+ padding: 14px 20px 0;
+ font-size: 14px;
+}
+
.bob-main {
flex: 1;
}
diff --git a/lib/bob/application.ex b/lib/bob/application.ex
index 65b26aab..26bb7e19 100644
--- a/lib/bob/application.ex
+++ b/lib/bob/application.ex
@@ -17,6 +17,7 @@ defmodule Bob.Application do
{Finch, name: Bob.Finch},
{Task.Supervisor, [name: Bob.Tasks]},
{Phoenix.PubSub, name: Bob.PubSub},
+ Bob.Cache,
Bob.DockerHub.RateLimiter,
Bob.DockerHub.Auth,
runner_spec(),
diff --git a/lib/bob/cache.ex b/lib/bob/cache.ex
new file mode 100644
index 00000000..449278b9
--- /dev/null
+++ b/lib/bob/cache.ex
@@ -0,0 +1,39 @@
+defmodule Bob.Cache do
+ @moduledoc """
+ Read-through cache with per-key TTLs for values that are expensive to fetch
+ (GitHub refs, S3 build lists) but fine to serve slightly stale.
+ """
+
+ use GenServer
+
+ @table __MODULE__
+
+ def start_link(opts) do
+ GenServer.start_link(__MODULE__, opts, name: __MODULE__)
+ end
+
+ def fetch(key, ttl_seconds, fun) do
+ now = System.system_time(:second)
+
+ case :ets.lookup(@table, key) do
+ [{^key, value, expires_at}] when expires_at > now ->
+ value
+
+ _ ->
+ value = fun.()
+ :ets.insert(@table, {key, value, now + ttl_seconds})
+ value
+ end
+ end
+
+ def clear() do
+ :ets.delete_all_objects(@table)
+ :ok
+ end
+
+ @impl true
+ def init(_opts) do
+ :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
+ {:ok, nil}
+ end
+end
diff --git a/lib/bob_web/components/layouts/root.html.heex b/lib/bob_web/components/layouts/root.html.heex
index 1ae1d554..4d517cb0 100644
--- a/lib/bob_web/components/layouts/root.html.heex
+++ b/lib/bob_web/components/layouts/root.html.heex
@@ -32,6 +32,9 @@
<.link href={~p"/docker"} class={nav_class(@conn.request_path, "/docker")}>
<.icon name="docker" /> Docker Tags
+ <.link href={~p"/request"} class={nav_class(@conn.request_path, "/request")}>
+ <.icon name="box" /> Request Build
+
@@ -66,6 +69,9 @@
<.link href={~p"/docker"} class={menu_class(@conn.request_path, "/docker")}>
<.icon name="docker" /> Docker Tags
+ <.link href={~p"/request"} class={menu_class(@conn.request_path, "/request")}>
+ <.icon name="box" /> Request Build
+
-
- Your recent requests
-
+ <.section :if={@my_requests != []} title="Your recent requests" icon="clock">
<.table rows={@my_requests} class="jt--req">
<:col :let={request} label="image">
<%= request.kind %>
@@ -280,7 +289,7 @@ defmodule BobWeb.RequestLive do
<%= fmt(request.inserted_at) %>
-
+
"""
end
@@ -290,13 +299,16 @@ defmodule BobWeb.RequestLive do
attr(:value, :string, default: "")
attr(:options, :list, required: true)
attr(:disabled, :boolean, default: false)
+ attr(:prompt, :string, default: nil)
defp form_select(assigns) do
~H"""