From ad05ec40adbaf4f15bdeeb1aa1a3e441cefe4a33 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 7 May 2026 14:48:53 -0500 Subject: [PATCH] feat: runtime conf and health check endpoint --- README.md | 24 +++ config/config.exs | 7 + config/runtime.exs | 32 +++- config/test.exs | 9 + docs/tasks/00-foundation.md | 16 +- lib/tempest.ex | 19 +++ lib/tempest/application.ex | 2 + lib/tempest/config.ex | 157 ++++++++++++++++++ .../controllers/health_controller.ex | 10 ++ lib/tempest_web/router.ex | 9 +- test/smoke/README.md | 15 ++ test/smoke/health.hurl | 6 + test/tempest/config_test.exs | 60 +++++++ .../controllers/health_controller_test.exs | 12 ++ 14 files changed, 365 insertions(+), 13 deletions(-) create mode 100644 lib/tempest/config.ex create mode 100644 lib/tempest_web/controllers/health_controller.ex create mode 100644 test/smoke/README.md create mode 100644 test/smoke/health.hurl create mode 100644 test/tempest/config_test.exs create mode 100644 test/tempest_web/controllers/health_controller_test.exs diff --git a/README.md b/README.md index 0ba7af7..234f3b4 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,27 @@ A self-hostable AT Protocol Personal Data Server (PDS) built in Elixir for single-users or small communities. + +## Local Server + +Start the Phoenix server: + +```bash +mix phx.server +``` + +By default, development uses `localhost`, `http://localhost:4000`, a data directory under +`priv/tempest_dev`, and a 10 MB blob limit. Override those settings with: + +```bash +TEMPEST_HOSTNAME=localhost +TEMPEST_PUBLIC_URL=http://localhost:4000 +TEMPEST_DATA_DIR=/absolute/path/to/tempest/priv/tempest_dev +TEMPEST_BLOB_MAX_BYTES=10000000 +``` + +Run the foundation smoke test with Hurl: + +```bash +hurl --test --variable base_url=http://localhost:4000 test/smoke/health.hurl +``` diff --git a/config/config.exs b/config/config.exs index d79f612..9f9700e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,8 +9,15 @@ import Config config :tempest, ecto_repos: [Tempest.Repo], + env: config_env(), generators: [timestamp_type: :utc_datetime] +config :tempest, Tempest.Config, + hostname: "localhost", + public_url: "http://localhost:4000", + data_dir: Path.expand("../priv/tempest_dev", __DIR__), + blob_max_bytes: 10_000_000 + # Configure the endpoint config :tempest, TempestWeb.Endpoint, url: [host: "localhost"], diff --git a/config/runtime.exs b/config/runtime.exs index f39cdc5..2287122 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -23,6 +23,22 @@ end config :tempest, TempestWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))] +if hostname = System.get_env("TEMPEST_HOSTNAME") do + config :tempest, Tempest.Config, hostname: hostname +end + +if public_url = System.get_env("TEMPEST_PUBLIC_URL") do + config :tempest, Tempest.Config, public_url: public_url +end + +if data_dir = System.get_env("TEMPEST_DATA_DIR") do + config :tempest, Tempest.Config, data_dir: data_dir +end + +if blob_max_bytes = System.get_env("TEMPEST_BLOB_MAX_BYTES") do + config :tempest, Tempest.Config, blob_max_bytes: String.to_integer(blob_max_bytes) +end + if config_env() == :prod do database_url = System.get_env("DATABASE_URL") || @@ -53,10 +69,24 @@ if config_env() == :prod do You can generate one by calling: mix phx.gen.secret """ - host = System.get_env("PHX_HOST") || "example.com" + host = + System.get_env("TEMPEST_HOSTNAME") || + System.get_env("PHX_HOST") || + raise """ + environment variable TEMPEST_HOSTNAME is missing. + For example: tempest.example.com + """ + + public_url = System.get_env("TEMPEST_PUBLIC_URL") || "https://#{host}" + data_dir = System.get_env("TEMPEST_DATA_DIR") || "/var/lib/tempest" config :tempest, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + config :tempest, Tempest.Config, + hostname: host, + public_url: public_url, + data_dir: data_dir + config :tempest, TempestWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ diff --git a/config/test.exs b/config/test.exs index e81a64e..306d217 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,14 @@ import Config +config :tempest, Tempest.Config, + data_dir: + Path.join([ + System.tmp_dir!(), + "tempest_test", + System.get_env("MIX_TEST_PARTITION") || "default" + ]), + public_url: "http://localhost:4002" + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/docs/tasks/00-foundation.md b/docs/tasks/00-foundation.md index 7111d2a..e21e82c 100644 --- a/docs/tasks/00-foundation.md +++ b/docs/tasks/00-foundation.md @@ -9,14 +9,14 @@ Goal: make the project ready for PDS implementation without changing protocol be ## Tasks -- [ ] T00-01: Add `TEMPEST_DATA_DIR` config with dev/test defaults under `priv/tempest_dev` and test temp directories. -- [ ] T00-02: Add a `Tempest.Config` module that validates hostname, public URL, data dir, and blob limits. -- [ ] T00-03: Add a public `/xrpc/_health` route that returns JSON. -- [ ] T00-04: Include app version and boot status in health output. -- [ ] T00-05: Add test coverage for health success. -- [ ] T00-06: Add test coverage for invalid config refusing to boot. -- [ ] T00-07: Document local server startup and Hurl smoke-test commands. -- [ ] T00-08: Add a `test/smoke` directory placeholder with README. +- [x] T00-01: Add `TEMPEST_DATA_DIR` config with dev/test defaults under `priv/tempest_dev` and test temp directories. +- [x] T00-02: Add a `Tempest.Config` module that validates hostname, public URL, data dir, and blob limits. +- [x] T00-03: Add a public `/xrpc/_health` route that returns JSON. +- [x] T00-04: Include app version and boot status in health output. +- [x] T00-05: Add test coverage for health success. +- [x] T00-06: Add test coverage for invalid config refusing to boot. +- [x] T00-07: Document local server startup and Hurl smoke-test commands. +- [x] T00-08: Add a `test/smoke` directory placeholder with README. ## Integration Tests diff --git a/lib/tempest.ex b/lib/tempest.ex index 9942e85..5950550 100644 --- a/lib/tempest.ex +++ b/lib/tempest.ex @@ -6,4 +6,23 @@ defmodule Tempest do Contexts are also responsible for managing your data, regardless if it comes from the database, an external API or others. """ + + @version Mix.Project.config()[:version] + @git_commit_count (case System.cmd("git", ["rev-list", "--count", "HEAD"], + stderr_to_stdout: true + ) do + {count, 0} -> String.trim(count) + _ -> "0" + end) + @git_short_sha (case System.cmd("git", ["rev-parse", "--short=7", "HEAD"], + stderr_to_stdout: true + ) do + {sha, 0} -> String.trim(sha) + _ -> "0000000" + end) + + @doc """ + Returns the public Tempest application version. + """ + def version, do: "v#{@version}.dev#{@git_commit_count}+g#{@git_short_sha}" end diff --git a/lib/tempest/application.ex b/lib/tempest/application.ex index 4cdbf05..036a88c 100644 --- a/lib/tempest/application.ex +++ b/lib/tempest/application.ex @@ -7,6 +7,8 @@ defmodule Tempest.Application do @impl true def start(_type, _args) do + Tempest.Config.load!() + children = [ TempestWeb.Telemetry, Tempest.Repo, diff --git a/lib/tempest/config.ex b/lib/tempest/config.ex new file mode 100644 index 0000000..4475115 --- /dev/null +++ b/lib/tempest/config.ex @@ -0,0 +1,157 @@ +defmodule Tempest.Config do + @moduledoc """ + Runtime configuration boundary for Tempest-specific settings. + """ + + @enforce_keys [:hostname, :public_url, :data_dir, :blob_max_bytes] + defstruct [:hostname, :public_url, :data_dir, :blob_max_bytes] + + @default_secret_key_bases [ + "+uUQkQUThGq4zX4Vl0a0Jfn8JGPw6ZlqzIJ2FRI+qzdG6VLTMlZN0Pyq7xKGQBRH", + "jUcVlVDRHeNy2EGaVJyMzeDJ1AcW9fVjUDsiCRVq/sHuh0JxjgVHT+0FVrs49BPO" + ] + + @doc """ + Loads and validates Tempest runtime configuration from application env. + """ + def load! do + :tempest + |> Application.get_env(__MODULE__, []) + |> validate!( + env: Application.get_env(:tempest, :env, :prod), + endpoint_config: Application.get_env(:tempest, TempestWeb.Endpoint, []) + ) + end + + @doc """ + Validates Tempest configuration and returns a normalized struct. + """ + def validate!(config, opts \\ []) when is_list(config) do + config = + %__MODULE__{ + hostname: Keyword.get(config, :hostname), + public_url: Keyword.get(config, :public_url), + data_dir: Keyword.get(config, :data_dir), + blob_max_bytes: Keyword.get(config, :blob_max_bytes) + } + + env = Keyword.get(opts, :env, Application.get_env(:tempest, :env, :prod)) + endpoint_config = Keyword.get(opts, :endpoint_config, []) + + with :ok <- validate_hostname(config.hostname), + :ok <- validate_public_url(config.public_url, config.hostname), + :ok <- validate_data_dir(config.data_dir), + :ok <- validate_blob_max_bytes(config.blob_max_bytes), + :ok <- validate_prod_secret(env, endpoint_config) do + config + else + {:error, reason} -> raise RuntimeError, "invalid Tempest config: #{reason}" + end + end + + defp validate_hostname(hostname) when is_binary(hostname) do + hostname = String.trim(hostname) + + cond do + hostname == "" -> + {:error, "hostname is required"} + + String.contains?(hostname, ["://", "/", "\\", " "]) -> + {:error, "hostname must be a bare host without scheme, port, path, or spaces"} + + hostname == "localhost" -> + :ok + + ip_address?(hostname) -> + :ok + + dns_hostname?(hostname) -> + :ok + + true -> + {:error, "hostname must be localhost, an IP address, or a DNS hostname"} + end + end + + defp validate_hostname(_hostname), do: {:error, "hostname is required"} + + defp validate_public_url(public_url, hostname) when is_binary(public_url) do + uri = URI.parse(public_url) + + cond do + uri.scheme not in ["http", "https"] -> + {:error, "public_url must use http or https"} + + is_nil(uri.host) or uri.host == "" -> + {:error, "public_url must include a host"} + + uri.host != hostname -> + {:error, "public_url host must match hostname"} + + not is_nil(uri.query) or not is_nil(uri.fragment) -> + {:error, "public_url must not include query string or fragment"} + + true -> + :ok + end + end + + defp validate_public_url(_public_url, _hostname), do: {:error, "public_url is required"} + + defp validate_data_dir(data_dir) when is_binary(data_dir) do + cond do + String.trim(data_dir) == "" -> + {:error, "data_dir is required"} + + Path.type(data_dir) != :absolute -> + {:error, "data_dir must be an absolute path"} + + true -> + :ok + end + end + + defp validate_data_dir(_data_dir), do: {:error, "data_dir is required"} + + defp validate_blob_max_bytes(blob_max_bytes) + when is_integer(blob_max_bytes) and blob_max_bytes > 0, + do: :ok + + defp validate_blob_max_bytes(_blob_max_bytes), + do: {:error, "blob_max_bytes must be a positive integer"} + + defp validate_prod_secret(:prod, endpoint_config) do + secret_key_base = Keyword.get(endpoint_config, :secret_key_base) + + cond do + not is_binary(secret_key_base) or secret_key_base == "" -> + {:error, "production secret_key_base is required"} + + secret_key_base in @default_secret_key_bases -> + {:error, "production secret_key_base cannot use a default development or test secret"} + + true -> + :ok + end + end + + defp validate_prod_secret(_env, _endpoint_config), do: :ok + + defp ip_address?(hostname) do + case :inet.parse_address(String.to_charlist(hostname)) do + {:ok, _address} -> true + {:error, _reason} -> false + end + end + + defp dns_hostname?(hostname) do + byte_size(hostname) <= 253 and + hostname + |> String.split(".") + |> Enum.all?(&dns_label?/1) + end + + defp dns_label?(label) do + String.match?(label, ~r/\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\z/) + end +end diff --git a/lib/tempest_web/controllers/health_controller.ex b/lib/tempest_web/controllers/health_controller.ex new file mode 100644 index 0000000..b7e1e28 --- /dev/null +++ b/lib/tempest_web/controllers/health_controller.ex @@ -0,0 +1,10 @@ +defmodule TempestWeb.HealthController do + use TempestWeb, :controller + + def show(conn, _params) do + json(conn, %{ + status: "ok", + version: Tempest.version() + }) + end +end diff --git a/lib/tempest_web/router.ex b/lib/tempest_web/router.ex index dc174ea..ef76fd0 100644 --- a/lib/tempest_web/router.ex +++ b/lib/tempest_web/router.ex @@ -20,10 +20,11 @@ defmodule TempestWeb.Router do get "/", PageController, :home end - # Other scopes may use custom stacks. - # scope "/api", TempestWeb do - # pipe_through :api - # end + scope "/xrpc", TempestWeb do + pipe_through :api + + get "/_health", HealthController, :show + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:tempest, :dev_routes) do diff --git a/test/smoke/README.md b/test/smoke/README.md new file mode 100644 index 0000000..1b1a719 --- /dev/null +++ b/test/smoke/README.md @@ -0,0 +1,15 @@ +# Smoke Tests + +These Hurl tests exercise Tempest through public HTTP endpoints. + +Start the local server before running them: + +```bash +mix phx.server +``` + +Run the health smoke test: + +```bash +hurl --test --variable base_url=http://localhost:4000 test/smoke/health.hurl +``` diff --git a/test/smoke/health.hurl b/test/smoke/health.hurl new file mode 100644 index 0000000..2828cef --- /dev/null +++ b/test/smoke/health.hurl @@ -0,0 +1,6 @@ +GET {{base_url}}/xrpc/_health +HTTP 200 +[Asserts] +header "content-type" contains "application/json" +jsonpath "$.version" exists +jsonpath "$.status" == "ok" diff --git a/test/tempest/config_test.exs b/test/tempest/config_test.exs new file mode 100644 index 0000000..492023e --- /dev/null +++ b/test/tempest/config_test.exs @@ -0,0 +1,60 @@ +defmodule Tempest.ConfigTest do + use ExUnit.Case, async: false + + @valid_config [ + hostname: "localhost", + public_url: "http://localhost:4000", + data_dir: "/tmp/tempest-test", + blob_max_bytes: 10_000_000 + ] + + @default_dev_secret "+uUQkQUThGq4zX4Vl0a0Jfn8JGPw6ZlqzIJ2FRI+qzdG6VLTMlZN0Pyq7xKGQBRH" + + test "validates and normalizes Tempest config" do + assert %Tempest.Config{ + hostname: "localhost", + public_url: "http://localhost:4000", + data_dir: "/tmp/tempest-test", + blob_max_bytes: 10_000_000 + } = Tempest.Config.validate!(@valid_config, env: :test) + end + + test "rejects invalid config before boot" do + original_config = Application.get_env(:tempest, Tempest.Config) + + on_exit(fn -> + Application.put_env(:tempest, Tempest.Config, original_config) + end) + + Application.put_env( + :tempest, + Tempest.Config, + Keyword.put(@valid_config, :hostname, "https://localhost") + ) + + assert_raise RuntimeError, ~r/hostname must be a bare host/, fn -> + Tempest.Application.start(:normal, []) + end + end + + test "rejects default secrets in production config" do + assert_raise RuntimeError, ~r/production secret_key_base cannot use a default/, fn -> + Tempest.Config.validate!(@valid_config, + env: :prod, + endpoint_config: [secret_key_base: @default_dev_secret] + ) + end + end + + test "rejects public URLs that do not match the hostname" do + assert_raise RuntimeError, ~r/public_url host must match hostname/, fn -> + Tempest.Config.validate!( + Keyword.merge(@valid_config, + hostname: "tempest.example.com", + public_url: "https://other.example.com" + ), + env: :test + ) + end + end +end diff --git a/test/tempest_web/controllers/health_controller_test.exs b/test/tempest_web/controllers/health_controller_test.exs new file mode 100644 index 0000000..b416cf5 --- /dev/null +++ b/test/tempest_web/controllers/health_controller_test.exs @@ -0,0 +1,12 @@ +defmodule TempestWeb.HealthControllerTest do + use TempestWeb.ConnCase + + test "GET /xrpc/_health returns public JSON without auth", %{conn: conn} do + conn = get(conn, ~p"/xrpc/_health") + response = json_response(conn, 200) + + assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] + assert response["status"] == "ok" + assert response["version"] =~ ~r/\Av0\.1\.0\.dev\d+\+g[0-9a-f]{7}\z/ + end +end -- 2.51.2