towerops/lib/towerops/application.ex
Graham McIntire ae64ec2576
feat: add automatic polling job cleanup on deployment
Added JobCleanupTask that runs on production startup to cancel all
existing polling jobs and reschedule them with the latest worker code.

This ensures:
- No stale jobs run with old code after deployment (e.g., missing SNMPv3 credentials)
- All devices get fresh polling jobs with current logic
- Production deployments are seamless without manual intervention

The task:
- Only runs in production environment (skipped in dev/test)
- Runs asynchronously after supervisor initialization
- Cancels all DevicePollerWorker and DeviceMonitorWorker jobs
- Reschedules polling for all SNMP-enabled devices
- Is idempotent and safe to run multiple times

Removed debug logging from Client and DevicePollerWorker as SNMPv3
implementation is now verified and working correctly.
2026-02-05 08:28:27 -06:00

248 lines
8.1 KiB
Elixir

defmodule Towerops.Application do
@moduledoc """
OTP Application module for Towerops.
Supervises the Repo, PubSub, Telemetry, Endpoint, and monitoring workers.
"""
use Application
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)
# 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, [])
children =
[
{Cluster.Supervisor, [topologies, [name: Towerops.ClusterSupervisor]]},
ToweropsWeb.Telemetry,
Towerops.Repo,
# Encryption vault for sensitive data (passwords, API tokens)
Towerops.Vault,
# Rate limiting backend (ETS-based, per-node)
{Hammer.Backend.ETS, [expiry_ms: to_timeout(minute: 10), cleanup_interval_ms: to_timeout(minute: 1)]},
{Oban, Application.fetch_env!(:towerops, Oban)},
{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
] ++
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)
# 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)
Towerops.Workers.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 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
@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