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 %>
@@ -68,6 +74,15 @@ > <.icon name="external" /> hexpm/bob + <%= if assigns[:current_user] do %> + <.link class="bob-menu__item" href={~p"/oauth/logout"} method="post"> + Log out ({@current_user["username"]}) + + <% else %> + <.link class="bob-menu__item" 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 + Enum.filter(fn {_os, os_versions} -> os_versions != [] end) + |> Enum.map(fn {os, _os_versions} -> os end) + |> Enum.sort() + + socket = + socket + |> assign( + builds: builds, + oses: oses, + erlang_versions: nil, + elixir_builds: nil, + kind: "erlang", + os: "", + os_version: "", + erlang: "", + elixir_build: "", + my_requests: my_requests(socket) + ) + + socket = + if connected?(socket) do + start_async(socket, :options, fn -> load_options() end) + else + socket + end + + {:ok, socket} + end + + @impl true + def handle_async(:options, {:ok, {erlang_versions, elixir_builds}}, socket) do + {:noreply, assign(socket, erlang_versions: erlang_versions, elixir_builds: elixir_builds)} + end + + def handle_async(:options, {:exit, _reason}, socket) do + {:noreply, put_flash(socket, :error, "Loading versions failed, reload the page to retry.")} + end + + @impl true + def handle_event("validate", params, socket) do + {:noreply, apply_form(socket, params)} + end + + def handle_event("request", params, socket) do + socket = apply_form(socket, params) + %{kind: kind, os: os, os_version: os_version, erlang: erlang} = socket.assigns + + cond do + socket.assigns.erlang_versions == nil -> + {:noreply, put_flash(socket, :error, "Versions are still loading, try again shortly.")} + + os == "" or os_version == "" or erlang == "" or + (kind == "elixir" and socket.assigns.elixir_build == "") -> + {:noreply, put_flash(socket, :error, "Select a value for every field.")} + + true -> + {:noreply, submit(socket)} + end + end + + # Selected values are only kept when they appear in the option lists, which + # are derived from real refs and builds and already encode the build rules. + # Submitted values are therefore valid by construction, also when the params + # bypass the rendered dropdowns. + defp apply_form(socket, params) do + kind = if params["kind"] in ~w(erlang elixir), do: params["kind"], else: "erlang" + os = keep_member(params["os"], socket.assigns.oses) + os_version = keep_member(params["os_version"], Map.get(socket.assigns.builds, os, [])) + + elixir_build = + if kind == "elixir" do + keep_member(params["elixir_build"], elixir_build_options(socket.assigns.elixir_builds)) + else + "" + end + + socket = + assign(socket, kind: kind, os: os, os_version: os_version, elixir_build: elixir_build) + + assign(socket, erlang: keep_member(params["erlang"], erlang_options(socket.assigns))) + end + + defp keep_member(value, options) do + if value in options, do: value, else: "" + end + + defp submit(socket) do + attrs = %{ + username: socket.assigns.current_user["username"], + kind: socket.assigns.kind, + elixir: selected_elixir(socket.assigns), + erlang: socket.assigns.erlang, + os: socket.assigns.os, + os_version: socket.assigns.os_version + } + + case BuildRequests.submit(attrs) do + {:ok, %BuildRequest{}} -> + socket + |> assign(my_requests: my_requests(socket)) + |> put_flash(:info, "Build queued, follow the progress on the jobs dashboard.") + + {:ok, :already_built} -> + put_flash(socket, :info, "This image is already built, find it under Docker tags.") + + {:error, :rate_limited} -> + put_flash( + socket, + :error, + "You reached the limit of #{BuildRequests.hourly_build_limit()} requested builds " <> + "per hour, try again later." + ) + + {:error, :invalid_combo} -> + put_flash(socket, :error, "This combination is not supported by the build rules.") + end + end + + defp my_requests(socket) do + BuildRequests.recent_for_user(socket.assigns.current_user["username"]) + end + + defp load_options() do + erlang_versions = + Bob.Cache.fetch(:erlang_versions, @options_ttl, &DockerChecker.erlang_versions/0) + + elixir_builds = Bob.Cache.fetch(:elixir_builds, @options_ttl, &DockerChecker.elixir_builds/0) + + {erlang_versions, elixir_builds} + end + + defp erlang_options(%{erlang_versions: nil}), do: [] + defp erlang_options(%{os: ""}), do: [] + defp erlang_options(%{os_version: ""}), do: [] + + defp erlang_options(assigns) do + %{erlang_versions: versions, os: os, os_version: os_version} = assigns + + case {assigns.kind, decode_elixir_build(assigns.elixir_build)} do + {"elixir", {elixir, otp_major}} -> + Enum.filter( + versions, + &DockerChecker.valid_elixir_build?(elixir, otp_major, &1, os, os_version) + ) + + _other -> + Enum.filter(versions, &DockerChecker.valid_erlang_build?(&1, os, os_version)) + end + end + + defp elixir_build_options(nil), do: [] + + defp elixir_build_options(elixir_builds) do + Enum.map(elixir_builds, fn {"v" <> elixir, otp_major} -> "#{elixir}-otp-#{otp_major}" end) + end + + defp decode_elixir_build(""), do: nil + + defp decode_elixir_build(elixir_build) do + [elixir, otp_major] = String.split(elixir_build, "-otp-") + {elixir, otp_major} + end + + defp selected_elixir(%{kind: "elixir", elixir_build: elixir_build}) do + {elixir, _otp_major} = decode_elixir_build(elixir_build) + elixir + end + + defp selected_elixir(_assigns), do: nil + + # The erlang base image gets built first when it is missing; surface that so + # users know why their elixir tag appears only after a later cycle. + defp staged_notice?(assigns) do + assigns.kind == "elixir" and assigns.erlang != "" and assigns.os_version != "" and + not erlang_base_present?(assigns) + end + + defp erlang_base_present?(assigns) do + tag = "#{assigns.erlang}-#{assigns.os}-#{assigns.os_version}" + + Enum.all?(DockerChecker.archs(), fn arch -> + "hexpm/erlang-#{arch}" + |> Bob.Artifacts.docker_tags_present([tag]) + |> MapSet.member?(tag) + end) + end + + defp fmt(datetime), do: Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S") + + defp target(%{kind: "erlang"} = request) do + "#{request.erlang}-#{request.os}-#{request.os_version}" + end + + defp target(%{kind: "elixir"} = request) do + "#{request.elixir}-erlang-#{request.erlang}-#{request.os}-#{request.os_version}" + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+

Request a build

+
+
+ +
+

+ Bob automatically builds Docker images for the latest patch release of every + Erlang and Elixir version. Need an older patch version? Request it here and it + is built for both architectures, including the multi-arch manifest. +

+ +
+ <.form_select name="kind" label="image" value={@kind} options={~w(erlang elixir)} /> + <.form_select name="os" label="os" value={@os} options={@oses} /> + <.form_select + name="os_version" + label="os version" + value={@os_version} + options={Map.get(@builds, @os, [])} + disabled={@os == ""} + /> + <.form_select + :if={@kind == "elixir"} + name="elixir_build" + label="elixir" + value={@elixir_build} + options={elixir_build_options(@elixir_builds)} + disabled={@elixir_builds == nil} + /> + <.form_select + name="erlang" + label="erlang" + value={@erlang} + options={erlang_options(assigns)} + disabled={@erlang_versions == nil or @os_version == ""} + /> + +
+ +
Loading versions...
+ +
+ The Erlang base image for this combination does not exist yet, so it is built + first and the Elixir image follows automatically afterwards. +
+
+ +
+

Your recent requests

+ + <.table rows={@my_requests} class="jt--req"> + <:col :let={request} label="image"> + <%= request.kind %> + + <:col :let={request} label="tag"> + <%= target(request) %> + + <:col :let={request} label="state"> + <%= request.state %> + + <:col :let={request} label="requested"> + <%= fmt(request.inserted_at) %> + + +
+
+ """ + end + + attr(:name, :string, required: true) + attr(:label, :string, required: true) + attr(:value, :string, default: "") + attr(:options, :list, required: true) + attr(:disabled, :boolean, default: false) + + defp form_select(assigns) do + ~H""" + + """ + end +end diff --git a/lib/bob_web/router.ex b/lib/bob_web/router.ex index d175257f..9059f0ba 100644 --- a/lib/bob_web/router.ex +++ b/lib/bob_web/router.ex @@ -36,4 +36,12 @@ defmodule BobWeb.Router do live("/docker", DockerTagsLive) end end + + scope "/", BobWeb do + pipe_through([:browser, :require_authenticated_user]) + + live_session :authenticated, on_mount: [{BobWeb.UserAuth, :ensure_authenticated}] do + live("/request", RequestLive) + end + end end diff --git a/test/bob_web/live/request_live_test.exs b/test/bob_web/live/request_live_test.exs new file mode 100644 index 00000000..6338c7b6 --- /dev/null +++ b/test/bob_web/live/request_live_test.exs @@ -0,0 +1,209 @@ +defmodule BobWeb.RequestLiveTest do + use BobWeb.ConnCase + + import Phoenix.LiveViewTest + + alias Bob.Artifacts.BaseImageTag + alias Bob.BuildRequests.BuildRequest + alias Bob.Queue.Job + + @builds_txt_url "https://s3.amazonaws.com/s3.hex.pm/builds/elixir/builds.txt" + + setup %{conn: conn} do + Bob.FakeHttpClient.reset() + Bob.FakeGitHub.reset() + Bob.Cache.clear() + + Repo.insert!(%BaseImageTag{repo: "library/ubuntu", tag: "noble-20250101"}) + Bob.FakeGitHub.stub_refs("erlang/otp", [{"OTP-27.0", "sha1"}, {"OTP-27.0.1", "sha2"}]) + Bob.FakeHttpClient.stub(:get, @builds_txt_url, 200, "v1.18.0-otp-27 abc123\n") + + {:ok, conn: conn} + end + + defp log_in(conn) do + init_test_session(conn, %{ + "current_user" => %{"username" => "eric"}, + "token_expires_at" => System.system_time(:second) + 1800 + }) + end + + defp select_erlang(view, erlang) do + view + |> element("form") + |> render_change(%{ + "kind" => "erlang", + "os" => "ubuntu", + "os_version" => "noble-20250101", + "erlang" => erlang + }) + end + + test "redirects anonymous users to the login page", %{conn: conn} do + assert {:error, {:redirect, %{to: "/oauth/login"}}} = live(conn, ~p"/request") + end + + test "renders the form for a logged in user", %{conn: conn} do + {:ok, view, html} = live(log_in(conn), ~p"/request") + + assert html =~ "Request a build" + assert html =~ "Loading versions" + refute render_async(view) =~ "Loading versions" + + html = view |> element("form") |> render_change(%{"kind" => "erlang", "os" => "ubuntu"}) + assert html =~ "noble-20250101" + end + + test "offers only erlang versions the build rules allow", %{conn: conn} do + Bob.FakeGitHub.stub_refs("erlang/otp", [{"OTP-27.0", "sha1"}, {"OTP-24.1", "sha2"}]) + + {:ok, view, _html} = live(log_in(conn), ~p"/request") + render_async(view) + + html = select_erlang(view, "") + + assert html =~ "27.0" + refute html =~ ">24.1<" + end + + test "submits an erlang build request", %{conn: conn} do + {:ok, view, _html} = live(log_in(conn), ~p"/request") + render_async(view) + select_erlang(view, "27.0") + + html = + view + |> element("form") + |> render_submit(%{ + "kind" => "erlang", + "os" => "ubuntu", + "os_version" => "noble-20250101", + "erlang" => "27.0" + }) + + assert html =~ "Build queued" + assert html =~ "27.0-ubuntu-noble-20250101" + + assert [%BuildRequest{username: "eric", kind: "erlang", erlang: "27.0"}] = + Repo.all(BuildRequest) + + assert Enum.count(Repo.all(Job)) == 2 + end + + test "submits an elixir build request and explains the staged erlang base", %{conn: conn} do + {:ok, view, _html} = live(log_in(conn), ~p"/request") + render_async(view) + + params = %{ + "kind" => "elixir", + "os" => "ubuntu", + "os_version" => "noble-20250101", + "elixir_build" => "1.18.0-otp-27", + "erlang" => "27.0" + } + + html = view |> element("form") |> render_change(params) + assert html =~ "does not exist yet" + + html = view |> element("form") |> render_submit(params) + assert html =~ "Build queued" + + assert [%BuildRequest{kind: "elixir", elixir: "1.18.0", erlang: "27.0", builds_count: 4}] = + Repo.all(BuildRequest) + + assert Repo.all(Job) |> Enum.map(& &1.module_key) |> Enum.sort() == + Enum.sort([ + {Bob.Job.BuildDockerErlang, "amd64"}, + {Bob.Job.BuildDockerErlang, "arm64"} + ]) + end + + test "reports already built tags", %{conn: conn} do + Bob.Artifacts.add_docker_tag("hexpm/erlang-amd64", "27.0-ubuntu-noble-20250101", ["amd64"]) + Bob.Artifacts.add_docker_tag("hexpm/erlang-arm64", "27.0-ubuntu-noble-20250101", ["arm64"]) + + {:ok, view, _html} = live(log_in(conn), ~p"/request") + render_async(view) + select_erlang(view, "27.0") + + html = + view + |> element("form") + |> render_submit(%{ + "kind" => "erlang", + "os" => "ubuntu", + "os_version" => "noble-20250101", + "erlang" => "27.0" + }) + + assert html =~ "already built" + assert Repo.all(BuildRequest) == [] + assert Repo.all(Job) == [] + end + + test "rejects submissions over the hourly build limit", %{conn: conn} do + {:ok, _request} = + Bob.BuildRequests.create(%{ + username: "eric", + kind: "erlang", + erlang: "26.0", + os: "ubuntu", + os_version: "noble-20250101", + builds_count: 10 + }) + + {:ok, view, _html} = live(log_in(conn), ~p"/request") + render_async(view) + select_erlang(view, "27.0") + + html = + view + |> element("form") + |> render_submit(%{ + "kind" => "erlang", + "os" => "ubuntu", + "os_version" => "noble-20250101", + "erlang" => "27.0" + }) + + assert html =~ "limit of 10 requested builds" + assert Repo.all(Job) == [] + end + + test "rejects values outside the option lists", %{conn: conn} do + {:ok, view, _html} = live(log_in(conn), ~p"/request") + render_async(view) + + html = + view + |> element("form") + |> render_submit(%{ + "kind" => "erlang", + "os" => "ubuntu", + "os_version" => "noble-20250101", + "erlang" => "24.1" + }) + + assert html =~ "Select a value for every field" + assert Repo.all(Job) == [] + assert Repo.all(BuildRequest) == [] + end + + test "lists the user's recent requests", %{conn: conn} do + {:ok, _request} = + Bob.BuildRequests.create(%{ + username: "eric", + kind: "erlang", + erlang: "26.0", + os: "ubuntu", + os_version: "noble-20250101", + builds_count: 2 + }) + + {:ok, _view, html} = live(log_in(conn), ~p"/request") + + assert html =~ "Your recent requests" + assert html =~ "26.0-ubuntu-noble-20250101" + assert html =~ "pending" + end +end From cb8a7e29bc7ac0458830dec2597e4795fc96936a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 12 Jun 2026 19:59:07 +0200 Subject: [PATCH 05/13] Document the Docker tag policy and manual build requests --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aa79ab33..d35f4257 100644 --- a/README.md +++ b/README.md @@ -37,22 +37,32 @@ For lists of builds see: ## Docker images -Docker images for Bob's Elixir and Erlang builds are built periodically. Bob checks for new Elixir and Erlang releases every 15 minutes and builds images for any new versions it discovers. The list of operating system distributions we base the images on can be found here: https://github.com/hexpm/bob/blob/main/lib/bob/job/docker_checker.ex#L5. +Docker images for Bob's Elixir and Erlang builds are built periodically. Bob checks for new Elixir and Erlang releases every 15 minutes and builds images for any new versions it discovers. The list of operating system distributions we base the images on is defined by `builds/0` in https://github.com/hexpm/bob/blob/main/lib/bob/job/docker_checker.ex. + +Bob automatically builds the latest patch release of every version family: the newest release per Erlang `major.minor` line (for example only `26.2.5.21` out of the `26.2.x` line) and the newest Elixir patch release per minor version, for every compatible OTP major. Older patch versions are not built automatically but can be requested manually, see below. Tagged images are never changed, that means `hexpm/erlang@22.0-alpine-3.11.2` will always target the tag `OTP-22.0` and won't update when `OTP-22.0.1` is released. -Erlang builds are found at https://hub.docker.com/r/hexpm/erlang, they use the versioning scheme `${OTP_VER}-${OS_NAME}-${OS_VER}` for tags. Builds for all releases since OTP 17 are provided. +Erlang builds are found at https://hub.docker.com/r/hexpm/erlang, they use the versioning scheme `${OTP_VER}-${OS_NAME}-${OS_VER}` for tags. -Elixir builds are found at https://hub.docker.com/r/hexpm/elixir, they use the versioning scheme `${ELIXIR_VER}-erlang-${OTP_VER}-${OS_NAME}-${OS_VER}`. Builds for all major releases since Elixir 1.0.0 are provided. Images are built for all pairs of compatible Elixir and OTP versions. +Elixir builds are found at https://hub.docker.com/r/hexpm/elixir, they use the versioning scheme `${ELIXIR_VER}-erlang-${OTP_VER}-${OS_NAME}-${OS_VER}`. Builds are provided for the following OS and architectures using the docker `OS/ARCH` scheme: * `linux/amd64` * `linux/arm64/v8` +All provided images and tags can be searched at https://bob.hex.pm/docker. + ### Examples of image names * `hexpm/erlang:22.2.8-alpine-3.11.3` * `hexpm/erlang:22.2-ubuntu-bionic-20200219` * `hexpm/elixir:1.10.2-erlang-22.2.8-alpine-3.11.3` * `hexpm/elixir:1.10.0-erlang-22.2-ubuntu-bionic-20200219` + +### Requesting builds + +If you need a specific patch version that is not built automatically, request it at https://bob.hex.pm/request. Sign in with your hex.pm account, then pick the image kind (Erlang or Elixir), the OS and base image version, and the exact Erlang (and Elixir) version. The image is built for both architectures, including the multi-arch manifest; when an Elixir image needs an Erlang base image that does not exist yet, the base is built first and the Elixir image follows automatically. + +Requested builds are limited to ten image builds per user per hour. Build progress can be followed on the jobs dashboard at https://bob.hex.pm. From 81b3408497e446782ced15c9fe62f708cf9cd9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 12 Jun 2026 23:12:03 +0200 Subject: [PATCH 06/13] Use GitHub releases for request builds --- assets/css/app.css | 11 +-- lib/bob/cache.ex | 2 +- lib/bob/github.ex | 22 ++++- lib/bob/job/docker_checker.ex | 25 ++--- lib/bob/store.ex | 20 ---- lib/bob_web/live/request_live.ex | 116 +++++++++++++----------- test/bob/job/docker_checker_test.exs | 59 ++++++++---- test/bob/store_test.exs | 36 -------- test/bob_web/live/request_live_test.exs | 99 ++++++++++++++++++-- test/support/fake_github.ex | 14 ++- 10 files changed, 245 insertions(+), 159 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index b9f95e7e..5241d550 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -72,13 +72,13 @@ pre, } .flash { - margin: 10px auto 0; - max-width: 1240px; + margin: 0 0 18px; border-radius: 8px; background: var(--color-grey-800); color: #fff; - padding: 8px 12px; + padding: 11px 14px; font-size: 13px; + line-height: 1.35; } .bob { @@ -299,11 +299,6 @@ pre, color: var(--color-grey-600); } -.req-heading { - padding: 14px 20px 0; - font-size: 14px; -} - .bob-main { flex: 1; } diff --git a/lib/bob/cache.ex b/lib/bob/cache.ex index 449278b9..7b4c5865 100644 --- a/lib/bob/cache.ex +++ b/lib/bob/cache.ex @@ -1,7 +1,7 @@ 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. + (external version metadata, refs) but fine to serve slightly stale. """ use GenServer diff --git a/lib/bob/github.ex b/lib/bob/github.ex index 9e5f055b..518ab08c 100644 --- a/lib/bob/github.ex +++ b/lib/bob/github.ex @@ -3,8 +3,20 @@ defmodule Bob.GitHub do def fetch_repo_refs(repo) do branches = github_request(@github_url <> "repos/#{repo}/branches?per_page=100") - tags = github_request(@github_url <> "repos/#{repo}/tags?per_page=100") - response_to_refs(branches) ++ response_to_refs(tags) + response_to_refs(branches) ++ fetch_repo_tags(repo) + end + + def fetch_repo_tags(repo) do + repo + |> tags_url() + |> github_request() + |> response_to_refs() + end + + def fetch_repo_releases(repo) do + repo + |> releases_url() + |> github_request() end defp response_to_refs(response) do @@ -17,7 +29,7 @@ defmodule Bob.GitHub do user = Application.get_env(:bob, :github_user) token = Application.get_env(:bob, :github_token) - opts = [basic_auth: {user, token}] + opts = if user && token, do: [basic_auth: {user, token}], else: [] {:ok, 200, headers, body} = Bob.HTTP.retry("GitHub #{url}", fn -> Bob.HTTP.request(:get, url, [], "", opts) end) @@ -46,4 +58,8 @@ defmodule Bob.GitHub do end end) end + + defp tags_url(repo), do: @github_url <> "repos/#{repo}/tags?per_page=100" + + defp releases_url(repo), do: @github_url <> "repos/#{repo}/releases?per_page=100" end diff --git a/lib/bob/job/docker_checker.ex b/lib/bob/job/docker_checker.ex index c7df883d..d799fafc 100644 --- a/lib/bob/job/docker_checker.ex +++ b/lib/bob/job/docker_checker.ex @@ -3,6 +3,7 @@ defmodule Bob.Job.DockerChecker do @erlang_tag_regex ~r"^(.+)-(alpine|ubuntu|debian)-(.+)$" @elixir_tag_regex ~r"^(.+)-erlang-(.+)-(alpine|ubuntu|debian)-(.+)$" + @elixir_release_asset_regex ~r"^elixir-otp-(\d+)\.zip$" @archs ["amd64", "arm64"] @erlang_arch_repos Enum.map(@archs, &"hexpm/erlang-#{&1}") @@ -363,13 +364,11 @@ defmodule Bob.Job.DockerChecker do end def elixir_builds() do - "builds/elixir" - |> Bob.Store.fetch_built_refs() - |> Stream.map(fn {build_name, _ref} -> build_name end) - |> Stream.map(&split_elixir_build/1) + "elixir-lang/elixir" + |> github().fetch_repo_releases() + |> Stream.flat_map(&release_elixir_builds/1) |> Stream.filter(&build_elixir_ref?/1) |> Enum.sort(&cmp_elixir_tags/2) - |> Enum.reject(fn {_elixir, otp} -> otp == nil end) |> Enum.reject(fn {"v" <> elixir, _otp} -> skip_elixir?(elixir) end) end @@ -391,13 +390,6 @@ defmodule Bob.Job.DockerChecker do end) end - defp split_elixir_build(build_name) do - case String.split(build_name, "-otp-") do - [elixir, major_otp] -> {elixir, major_otp} - [elixir] -> {elixir, nil} - end - end - defp cmp_elixir_tags({"v" <> elixir_left, otp_left}, {"v" <> elixir_right, otp_right}) do case Version.compare(normalize_version(elixir_left), normalize_version(elixir_right)) do :gt -> true @@ -429,6 +421,15 @@ defmodule Bob.Job.DockerChecker do end end + defp release_elixir_builds(%{"tag_name" => tag_name, "assets" => assets}) do + Enum.flat_map(assets, fn %{"name" => name} -> + case Regex.run(@elixir_release_asset_regex, name, capture: :all_but_first) do + [otp_major] -> [{tag_name, otp_major}] + _other -> [] + end + end) + end + defp compatible_elixir_and_erlang?(otp_major, erlang) do String.starts_with?(erlang, otp_major <> ".") end diff --git a/lib/bob/store.ex b/lib/bob/store.ex index 999f24e3..eac655bd 100644 --- a/lib/bob/store.ex +++ b/lib/bob/store.ex @@ -1,21 +1,6 @@ defmodule Bob.Store do @bucket "s3.hex.pm" - # TODO: Use S3 object metadata - def fetch_built_refs(build_path) do - path = Path.join(build_path, "builds.txt") - - case ExAws.S3.get_object(@bucket, path, []) |> ExAws.request() do - {:ok, %{body: body}} -> - body - |> String.split("\n", trim: true) - |> Map.new(&line_to_ref/1) - - {:error, {:http_error, 404, _}} -> - %{} - end - end - def fetch_file(path) do %{body: body} = ExAws.S3.get_object(@bucket, path, []) |> ExAws.request!() body @@ -38,9 +23,4 @@ defmodule Bob.Store do |> ExAws.stream!() |> Stream.map(&Map.get(&1, :key)) end - - defp line_to_ref(line) do - destructure [ref_name, ref], String.split(line, " ", trim: true) - {ref_name, ref} - end end diff --git a/lib/bob_web/live/request_live.ex b/lib/bob_web/live/request_live.ex index 4084e963..0aa0c300 100644 --- a/lib/bob_web/live/request_live.ex +++ b/lib/bob_web/live/request_live.ex @@ -28,7 +28,7 @@ defmodule BobWeb.RequestLive do os: "", os_version: "", erlang: "", - elixir_build: "", + elixir: "", my_requests: my_requests(socket) ) @@ -48,12 +48,13 @@ defmodule BobWeb.RequestLive do end def handle_async(:options, {:exit, _reason}, socket) do - {:noreply, put_flash(socket, :error, "Loading versions failed, reload the page to retry.")} + {:noreply, + replace_flash(socket, :error, "Loading versions failed, reload the page to retry.")} end @impl true def handle_event("validate", params, socket) do - {:noreply, apply_form(socket, params)} + {:noreply, socket |> apply_form(params) |> clear_flash()} end def handle_event("request", params, socket) do @@ -62,11 +63,12 @@ defmodule BobWeb.RequestLive do cond do socket.assigns.erlang_versions == nil -> - {:noreply, put_flash(socket, :error, "Versions are still loading, try again shortly.")} + {:noreply, + replace_flash(socket, :error, "Versions are still loading, try again shortly.")} os == "" or os_version == "" or erlang == "" or - (kind == "elixir" and socket.assigns.elixir_build == "") -> - {:noreply, put_flash(socket, :error, "Select a value for every field.")} + (kind == "elixir" and socket.assigns.elixir == "") -> + {:noreply, replace_flash(socket, :error, "Select a value for every field.")} true -> {:noreply, submit(socket)} @@ -82,17 +84,18 @@ defmodule BobWeb.RequestLive do os = keep_member(params["os"], socket.assigns.oses) os_version = keep_member(params["os_version"], Map.get(socket.assigns.builds, os, [])) - elixir_build = + assigns = %{socket.assigns | kind: kind, os: os, os_version: os_version} + erlang = keep_member(params["erlang"], erlang_options(assigns)) + assigns = %{assigns | erlang: erlang} + + elixir = if kind == "elixir" do - keep_member(params["elixir_build"], elixir_build_options(socket.assigns.elixir_builds)) + keep_member(params["elixir"], elixir_options(assigns)) else "" end - socket = - assign(socket, kind: kind, os: os, os_version: os_version, elixir_build: elixir_build) - - assign(socket, erlang: keep_member(params["erlang"], erlang_options(socket.assigns))) + assign(socket, kind: kind, os: os, os_version: os_version, elixir: elixir, erlang: erlang) end defp keep_member(value, options) do @@ -113,13 +116,13 @@ defmodule BobWeb.RequestLive do {:ok, %BuildRequest{}} -> socket |> assign(my_requests: my_requests(socket)) - |> put_flash(:info, "Build queued, follow the progress on the jobs dashboard.") + |> replace_flash(:info, "Build queued, follow the progress on the jobs dashboard.") {:ok, :already_built} -> - put_flash(socket, :info, "This image is already built, find it under Docker tags.") + replace_flash(socket, :info, "This image is already built, find it under Docker tags.") {:error, :rate_limited} -> - put_flash( + replace_flash( socket, :error, "You reached the limit of #{BuildRequests.hourly_build_limit()} requested builds " <> @@ -127,10 +130,16 @@ defmodule BobWeb.RequestLive do ) {:error, :invalid_combo} -> - put_flash(socket, :error, "This combination is not supported by the build rules.") + replace_flash(socket, :error, "This combination is not supported by the build rules.") end end + defp replace_flash(socket, kind, message) do + socket + |> clear_flash() + |> put_flash(kind, message) + end + defp my_requests(socket) do BuildRequests.recent_for_user(socket.assigns.current_user["username"]) end @@ -151,36 +160,35 @@ defmodule BobWeb.RequestLive do defp erlang_options(assigns) do %{erlang_versions: versions, os: os, os_version: os_version} = assigns - case {assigns.kind, decode_elixir_build(assigns.elixir_build)} do - {"elixir", {elixir, otp_major}} -> - Enum.filter( - versions, - &DockerChecker.valid_elixir_build?(elixir, otp_major, &1, os, os_version) - ) - - _other -> - Enum.filter(versions, &DockerChecker.valid_erlang_build?(&1, os, os_version)) - end + Enum.filter(versions, &DockerChecker.valid_erlang_build?(&1, os, os_version)) end - defp elixir_build_options(nil), do: [] + defp elixir_options(%{elixir_builds: nil}), do: [] + defp elixir_options(%{erlang: ""}), do: [] - defp elixir_build_options(elixir_builds) do - Enum.map(elixir_builds, fn {"v" <> elixir, otp_major} -> "#{elixir}-otp-#{otp_major}" end) - end + defp elixir_options(%{ + elixir_builds: elixir_builds, + erlang: erlang, + os: os, + os_version: os_version + }) do + otp_major = otp_major(erlang) - defp decode_elixir_build(""), do: nil + elixir_builds + |> Enum.filter(fn + {"v" <> elixir, ^otp_major} -> + DockerChecker.valid_elixir_build?(elixir, otp_major, erlang, os, os_version) - defp decode_elixir_build(elixir_build) do - [elixir, otp_major] = String.split(elixir_build, "-otp-") - {elixir, otp_major} + _other -> + false + end) + |> Enum.map(fn {"v" <> elixir, _otp_major} -> elixir end) + |> Enum.uniq() end - defp selected_elixir(%{kind: "elixir", elixir_build: elixir_build}) do - {elixir, _otp_major} = decode_elixir_build(elixir_build) - elixir - end + defp otp_major(erlang), do: erlang |> String.split(".") |> hd() + defp selected_elixir(%{kind: "elixir", elixir: elixir}), do: elixir defp selected_elixir(_assigns), do: nil # The erlang base image gets built first when it is missing; surface that so @@ -229,21 +237,14 @@ defmodule BobWeb.RequestLive do
<.form_select name="kind" label="image" value={@kind} options={~w(erlang elixir)} /> - <.form_select name="os" label="os" value={@os} options={@oses} /> + <.form_select name="os" label="os" value={@os} options={@oses} prompt="Select OS" /> <.form_select name="os_version" label="os version" value={@os_version} options={Map.get(@builds, @os, [])} disabled={@os == ""} - /> - <.form_select - :if={@kind == "elixir"} - name="elixir_build" - label="elixir" - value={@elixir_build} - options={elixir_build_options(@elixir_builds)} - disabled={@elixir_builds == nil} + prompt="Select OS version" /> <.form_select name="erlang" @@ -251,6 +252,16 @@ defmodule BobWeb.RequestLive do value={@erlang} options={erlang_options(assigns)} disabled={@erlang_versions == nil or @os_version == ""} + prompt="Select Erlang" + /> + <.form_select + :if={@kind == "elixir"} + name="elixir" + label="elixir" + value={@elixir} + options={elixir_options(assigns)} + disabled={@elixir_builds == nil or @erlang == ""} + prompt="Select Elixir" />
@@ -263,9 +274,7 @@ defmodule BobWeb.RequestLive do
-
-

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"""