From 4a8d8cb2084ad5751e102aabf7eb50739f063a6a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 7 May 2026 07:38:05 -0500 Subject: [PATCH] feat(coverage): write rasters to NFS-backed dir, serve via dedicated static plug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage rasters were being written to priv/static/coverage on the pod's ephemeral filesystem — fine on a single-pod dev box, broken under k8s where pods share NFS at /data and a restart wipes computed predictions. * New :coverage_storage_dir config (defaults to priv/static/coverage for dev/test, "/data/coverage" in production). * Raster.output_dir/absolute_raster_path read this config and resolve {otp_app, rel} or absolute paths. * Endpoint now mounts a dedicated Plug.Static at /coverage from that same config — replicas all serve the same NFS-backed file regardless of which one ran the compute. * Drop "coverage" from static_paths/0 since the dedicated plug owns that prefix now. --- config/config.exs | 5 +++++ config/prod.exs | 5 +++++ lib/towerops/coverages/raster.ex | 24 ++++++++++++++++++++---- lib/towerops_web.ex | 2 +- lib/towerops_web/endpoint.ex | 8 ++++++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index 15086551..1c89f254 100644 --- a/config/config.exs +++ b/config/config.exs @@ -123,6 +123,11 @@ config :towerops, ToweropsWeb.Endpoint, pubsub_server: Towerops.PubSub, live_view: [signing_salt: "Uh1ABfdI"] +# Default coverage raster directory for dev/test. Production overrides this +# to "/data/coverage" (the shared NFS mount) in config/prod.exs so all +# replicas can serve the rasters and pod restarts don't lose them. +config :towerops, :coverage_storage_dir, {:towerops, "priv/static/coverage"} + # SNMP MIB directories for production (in Docker image) # MIB files are included in the release at /app/priv/mibs # MibTranslator automatically expands these to include subdirectories diff --git a/config/prod.exs b/config/prod.exs index 3a39a4ef..fe8d108e 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -39,3 +39,8 @@ config :swoosh, local: false # before starting your production server. config :towerops, ToweropsWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" # SSL/TLS is handled by Cloudflared proxy - no force_ssl needed + +# Coverage rasters live on the shared NFS mount so all replicas can serve +# them and a pod restart doesn't lose computed predictions. The +# deployment mounts the NFS share at /data. +config :towerops, :coverage_storage_dir, "/data/coverage" diff --git a/lib/towerops/coverages/raster.ex b/lib/towerops/coverages/raster.ex index 2b6bcbd5..29e95ea0 100644 --- a/lib/towerops/coverages/raster.ex +++ b/lib/towerops/coverages/raster.ex @@ -142,7 +142,15 @@ defmodule Towerops.Coverages.Raster do defp inside_bbox?(_coverage, _lat, _lon), do: false defp absolute_raster_path(%Coverage{raster_path: rel}) do - Path.join(static_dir(), Path.relative_to(rel, "/")) + # rel looks like "/coverage///rssi*.tif"; strip the URL prefix + # and rejoin against the configured storage dir (which already corresponds + # to /coverage in the static plug). + suffix = + rel + |> Path.relative_to("/") + |> String.replace_prefix("coverage/", "") + + Path.join(coverage_dir(), suffix) end # gdallocationinfo with -wgs84 -valonly prints just the pixel value or @@ -179,11 +187,19 @@ defmodule Towerops.Coverages.Raster do end defp output_dir(%Coverage{organization_id: org_id, id: id}) do - Path.join([static_dir(), "coverage", to_string(org_id), to_string(id)]) + Path.join([coverage_dir(), to_string(org_id), to_string(id)]) end - defp static_dir do - Application.app_dir(:towerops, "priv/static") + # Resolves :coverage_storage_dir config to an absolute path. + # + # * `{:otp_app, "rel/path"}` → `Application.app_dir/2` (dev/test default + # so rasters live under priv/static and Mix releases pick them up). + # * `"/abs/path"` → used as-is (production points at the NFS mount). + defp coverage_dir do + case Application.get_env(:towerops, :coverage_storage_dir, {:towerops, "priv/static/coverage"}) do + {app, rel} when is_atom(app) and is_binary(rel) -> Application.app_dir(app, rel) + path when is_binary(path) -> path + end end defp relative_static_path(coverage, filename) do diff --git a/lib/towerops_web.ex b/lib/towerops_web.ex index a101e180..a28ed306 100644 --- a/lib/towerops_web.ex +++ b/lib/towerops_web.ex @@ -17,7 +17,7 @@ defmodule ToweropsWeb do those modules here. """ - def static_paths, do: ~w(assets fonts images vendor coverage favicon.ico robots.txt changelog.txt) + def static_paths, do: ~w(assets fonts images vendor favicon.ico robots.txt changelog.txt) def router do quote do diff --git a/lib/towerops_web/endpoint.ex b/lib/towerops_web/endpoint.ex index eda53092..01011958 100644 --- a/lib/towerops_web/endpoint.ex +++ b/lib/towerops_web/endpoint.ex @@ -41,6 +41,14 @@ defmodule ToweropsWeb.Endpoint do only: ToweropsWeb.static_paths(), raise_on_missing_only: code_reloading? + # Generated coverage rasters live outside priv/static so the path can point + # at a shared NFS mount in production (see :coverage_storage_dir). Keep this + # plug after the main static plug so /coverage URLs only fall through here. + plug Plug.Static, + at: "/coverage", + from: Application.compile_env(:towerops, :coverage_storage_dir, {:towerops, "priv/static/coverage"}), + gzip: false + if Mix.env() == :dev do plug Tidewave end