Start a named Finch pool with CAStore certificate path and configure Req to use it by default. Fixes integration sync failures on Dokku where the OS CA trust store is unavailable.
324 lines
11 KiB
Elixir
324 lines
11 KiB
Elixir
defmodule Towerops.Application do
|
|
@moduledoc """
|
|
OTP Application module for Towerops.
|
|
|
|
Supervises the Repo, PubSub, Telemetry, Endpoint, and monitoring workers.
|
|
"""
|
|
|
|
use Application
|
|
|
|
alias Towerops.Workers.JobCleanupTask
|
|
|
|
require Logger
|
|
|
|
# Capture the build timestamp at compile time (fallback for development)
|
|
@build_timestamp DateTime.utc_now()
|
|
|
|
@impl true
|
|
def start(_type, _args) do
|
|
# Ensure parsetools is started for MIB parsing (yecc dependency)
|
|
_ = Application.ensure_all_started(:parsetools)
|
|
|
|
# Register file logger backend for development (using new LoggerBackends API)
|
|
if Application.get_env(:towerops, :env) == :dev do
|
|
register_file_logger()
|
|
end
|
|
|
|
# Add snmpkit ebin directory to code path for mib_grammar_elixir.beam
|
|
# This is needed because snmpkit has vendored Erlang modules (compiled from .yrl)
|
|
# Note: With pre-compiled MIBs, this may no longer be needed
|
|
snmpkit_ebin = Application.app_dir(:towerops, "../snmpkit/ebin")
|
|
|
|
_ =
|
|
if File.dir?(snmpkit_ebin) do
|
|
:code.add_patha(to_charlist(snmpkit_ebin))
|
|
end
|
|
|
|
# Run migrations on startup (Ecto handles locking for concurrent runs)
|
|
_ =
|
|
if Application.get_env(:towerops, Towerops.Repo)[:database] != "towerops_test" do
|
|
Towerops.Release.migrate()
|
|
end
|
|
|
|
# Configure MIB directories for NIF resolution (before supervisor starts)
|
|
Logger.info("Configuring MIB directories for NIF resolution...")
|
|
mib_dir = Application.app_dir(:towerops, "priv/mibs")
|
|
|
|
# Build list of all MIB directories (including subdirectories for vendor MIBs)
|
|
mib_dirs =
|
|
if File.dir?(mib_dir) do
|
|
# Get main directory plus all subdirectories
|
|
subdirs =
|
|
mib_dir
|
|
|> File.ls!()
|
|
|> Enum.map(&Path.join(mib_dir, &1))
|
|
|> Enum.filter(&File.dir?/1)
|
|
|
|
[mib_dir | subdirs]
|
|
else
|
|
[mib_dir]
|
|
end
|
|
|
|
# Set MIBDIRS environment variable for net-snmp (must be set BEFORE init)
|
|
# Join with colon for Unix-style path list
|
|
mibdirs_env = Enum.join(mib_dirs, ":")
|
|
System.put_env("MIBDIRS", mibdirs_env)
|
|
System.put_env("MIBS", "ALL")
|
|
|
|
Logger.info("MIB directories configured: #{length(mib_dirs)} directories")
|
|
|
|
# Add each directory to net-snmp's search path
|
|
Enum.each(mib_dirs, fn dir ->
|
|
:ok = ToweropsNative.load_mib_directory(dir)
|
|
end)
|
|
|
|
# Initialize the MIB library (loads all MIBs into memory)
|
|
case ToweropsNative.init_mib_library() do
|
|
result when result in ["initialized", "already_initialized"] ->
|
|
Logger.info("MIB library initialized: #{result}")
|
|
|
|
other ->
|
|
Logger.warning("Unexpected MIB library init result: #{inspect(other)}")
|
|
end
|
|
|
|
topologies = Application.get_env(:libcluster, :topologies, [])
|
|
|
|
# Supervision tree order matters for shutdown: children shut down in reverse start order.
|
|
# Oban is placed AFTER its dependencies (SnmpKit, TaskSupervisor) so it shuts down
|
|
# BEFORE them, allowing in-flight jobs to complete cleanly during the grace period.
|
|
children =
|
|
[
|
|
{Cluster.Supervisor, [topologies, [name: Towerops.ClusterSupervisor]]},
|
|
ToweropsWeb.Telemetry,
|
|
Towerops.Repo,
|
|
# HTTP client pool with CA certificate configuration
|
|
# Ensures HTTPS works in containers where OS CA store is unavailable
|
|
{Finch,
|
|
name: Towerops.Finch,
|
|
pools: %{
|
|
default: [
|
|
conn_opts: [
|
|
transport_opts: [
|
|
cacertfile: CAStore.file_path()
|
|
]
|
|
]
|
|
]
|
|
}},
|
|
# Encryption vault for sensitive data (passwords, API tokens)
|
|
Towerops.Vault,
|
|
# Error notifications via email (production only, gracefully shuts down in dev/test)
|
|
{ErrorTrackerNotifier, []},
|
|
# Redis connection for brute force protection and rate limiting
|
|
redis_spec(),
|
|
# Rate limiting backend (ETS-based, per-node)
|
|
{Towerops.RateLimit, [clean_period: to_timeout(minute: 1)]},
|
|
{DNSCluster, query: Application.get_env(:towerops, :dns_cluster_query) || :ignore},
|
|
pubsub_spec(),
|
|
# MIB name cache for fast profile matching (starts asynchronous pre-resolution)
|
|
Towerops.Profiles.MibCache,
|
|
# Load YAML profiles into ETS cache (depends on MibCache)
|
|
Towerops.Profiles.YamlProfiles,
|
|
# SnmpKit services for SNMP operations (used for SNMP protocol, not MIB resolution)
|
|
SnmpKit.SnmpMgr.Config,
|
|
SnmpKit.SnmpMgr.MIB,
|
|
# Task supervisor for fire-and-forget background tasks (e.g. polling data processing)
|
|
{Task.Supervisor, name: Towerops.TaskSupervisor},
|
|
# Oban after its dependencies so it drains jobs before they shut down
|
|
{Oban, Application.fetch_env!(:towerops, Oban)}
|
|
] ++
|
|
dev_only_workers() ++
|
|
background_workers() ++
|
|
[
|
|
# Start to serve requests, typically the last entry
|
|
ToweropsWeb.Endpoint
|
|
]
|
|
|
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
|
# for other strategies and supported options
|
|
opts = [strategy: :one_for_one, name: Towerops.Supervisor]
|
|
result = Supervisor.start_link(children, opts)
|
|
|
|
# Attach telemetry filter to suppress noisy health check logs
|
|
ToweropsWeb.TelemetryFilter.attach()
|
|
|
|
# Run post-startup cleanup tasks (production only)
|
|
# This ensures all polling jobs use the latest worker code after deployment
|
|
Task.start(fn ->
|
|
# Wait for supervisor to fully initialize
|
|
Process.sleep(2000)
|
|
JobCleanupTask.run()
|
|
end)
|
|
|
|
result
|
|
end
|
|
|
|
# Tell Phoenix to update the endpoint configuration
|
|
# whenever the application is updated.
|
|
@impl true
|
|
def config_change(changed, _new, removed) do
|
|
ToweropsWeb.Endpoint.config_change(changed, removed)
|
|
:ok
|
|
end
|
|
|
|
# Configure background workers - don't start in test environment
|
|
defp background_workers do
|
|
if Application.get_env(:towerops, :env) == :test do
|
|
[]
|
|
else
|
|
[
|
|
# Start event logger (subscribes to PubSub)
|
|
Towerops.Devices.EventLogger
|
|
# Note: NeighborCleanupWorker, StaleAgentWorker, AgentLatencyEvaluator,
|
|
# and JobHealthCheckWorker are now Oban Cron jobs (see config/runtime.exs)
|
|
]
|
|
end
|
|
end
|
|
|
|
# Configure dev-only workers - profile watcher for auto-reload
|
|
defp dev_only_workers do
|
|
if Application.get_env(:towerops, :env) == :dev do
|
|
[
|
|
# Watch YAML profiles for changes and auto-reload
|
|
Towerops.Profiles.ProfileWatcher
|
|
]
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
|
|
# Returns Redis connection spec for brute force protection
|
|
defp redis_spec do
|
|
redis_config = Application.get_env(:towerops, :redis, [])
|
|
|
|
if Keyword.has_key?(redis_config, :host) do
|
|
opts = [
|
|
host: Keyword.get(redis_config, :host, "localhost"),
|
|
port: Keyword.get(redis_config, :port, 6379),
|
|
name: Towerops.Redix
|
|
]
|
|
|
|
# Add password if configured
|
|
opts =
|
|
case Keyword.get(redis_config, :password) do
|
|
nil -> opts
|
|
"" -> opts
|
|
password -> Keyword.put(opts, :password, password)
|
|
end
|
|
|
|
{Redix, opts}
|
|
else
|
|
# No Redis configured - start a stub process that will fail gracefully
|
|
# This allows the app to start in environments without Redis
|
|
Supervisor.child_spec({Agent, fn -> nil end}, id: Towerops.Redix)
|
|
end
|
|
end
|
|
|
|
# Returns PubSub spec - uses Redis in production/clustered environments,
|
|
# falls back to default PG2 adapter for Mix tasks and development
|
|
defp pubsub_spec do
|
|
redis_config = Application.get_env(:towerops, :redis, [])
|
|
node_name = node()
|
|
|
|
# Use Redis adapter only when:
|
|
# 1. Redis config exists with host
|
|
# 2. Node has a name (not nonode@nohost)
|
|
use_redis? =
|
|
Keyword.has_key?(redis_config, :host) &&
|
|
node_name != :nonode@nohost
|
|
|
|
if use_redis? do
|
|
base_config = [name: Towerops.PubSub, adapter: Phoenix.PubSub.Redis]
|
|
redis_opts = redis_pubsub_config()
|
|
{Phoenix.PubSub, Keyword.merge(base_config, redis_opts)}
|
|
else
|
|
# Default PG2 adapter for Mix tasks and development
|
|
{Phoenix.PubSub, name: Towerops.PubSub}
|
|
end
|
|
end
|
|
|
|
defp redis_pubsub_config do
|
|
redis_config = Application.get_env(:towerops, :redis, [])
|
|
|
|
# Base configuration for Phoenix.PubSub.Redis
|
|
base_opts = [
|
|
host: Keyword.get(redis_config, :host, "localhost"),
|
|
port: Keyword.get(redis_config, :port, 6379),
|
|
node_name: node()
|
|
]
|
|
|
|
# Add password if configured (read from application config set by runtime.exs)
|
|
base_opts =
|
|
case Keyword.get(redis_config, :password) do
|
|
nil -> base_opts
|
|
"" -> base_opts
|
|
password -> Keyword.put(base_opts, :password, password)
|
|
end
|
|
|
|
# Add resilience options for better reconnection handling
|
|
resilience_opts = [
|
|
# Redix connection options for better resilience
|
|
socket_opts: [
|
|
# TCP keepalive to detect dead connections
|
|
keepalive: true
|
|
],
|
|
# Connection timeout (5 seconds)
|
|
timeout: 5_000,
|
|
# Backoff settings for reconnection
|
|
backoff_initial: 500,
|
|
backoff_max: 30_000,
|
|
# Don't exit process on disconnection - let PubSub handle it
|
|
exit_on_disconnection: false,
|
|
# Sync connect to fail fast if Redis unavailable at startup
|
|
sync_connect: false
|
|
]
|
|
|
|
Keyword.merge(base_opts, resilience_opts)
|
|
end
|
|
|
|
# Register file logger backend for development (only available in :dev environment)
|
|
defp register_file_logger do
|
|
if Code.ensure_loaded?(LoggerBackends) and Code.ensure_loaded?(LoggerFileBackend) do
|
|
# Add the backend with a unique identifier (using apply to avoid compile-time warnings)
|
|
apply(LoggerBackends, :add, [{LoggerFileBackend, :file_log}])
|
|
|
|
# Configure the backend with path and level
|
|
Logger.configure_backend({LoggerFileBackend, :file_log},
|
|
path: "_build/dev.log",
|
|
level: :debug
|
|
)
|
|
else
|
|
Logger.debug("LoggerBackends not available - file logging disabled (dev-only feature)")
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the deployment timestamp.
|
|
|
|
In production (Kubernetes), this reads from the DEPLOY_TIMESTAMP environment
|
|
variable which is automatically set by GitLab CI during deployment. Falls back
|
|
to compile time in development.
|
|
|
|
The timestamp is set by GitLab CI using:
|
|
```bash
|
|
kubectl set env deployment/towerops DEPLOY_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -n towerops
|
|
```
|
|
|
|
This ensures all pods in the deployment show the same deployment time, regardless
|
|
of when individual pods were created or restarted.
|
|
"""
|
|
@spec build_timestamp() :: DateTime.t()
|
|
def build_timestamp do
|
|
case System.get_env("DEPLOY_TIMESTAMP") do
|
|
nil ->
|
|
# Development/fallback: use compile time
|
|
@build_timestamp
|
|
|
|
timestamp_string ->
|
|
# Production: parse from environment variable
|
|
case DateTime.from_iso8601(timestamp_string) do
|
|
{:ok, datetime, _offset} -> datetime
|
|
{:error, _} -> @build_timestamp
|
|
end
|
|
end
|
|
end
|
|
end
|