towerops/lib/towerops/workers/mikrotik_backup_worker.ex
Graham McIntire 91e3181bbc dialyzer: fix all unmatched_return warnings (154 → 0)
Prefix fire-and-forget calls (PubSub.broadcast, Oban enqueue, Logger,
update_*, etc.) with `_ =` so dialyzer knows the return value is
intentionally discarded. Wrap a few if/case expressions whose
branches produce mixed types the same way.

No behavior changes — only explicit acknowledgement of discarded
returns.

Warnings: 242 → 88.
2026-04-21 10:03:55 -05:00

150 lines
4.7 KiB
Elixir

defmodule Towerops.Workers.MikrotikBackupWorker do
@moduledoc """
Oban worker that triggers daily MikroTik configuration backups.
Runs daily at 3 AM via cron schedule. For each eligible device, sends a backup
job to the assigned agent and creates a tracking request.
Eligible devices:
- Have MikroTik detected (manufacturer contains "MikroTik" or "RouterOS")
- Have MikroTik API enabled
- Have credentials configured (username present)
- Are assigned to an agent
"""
use Oban.Worker, queue: :maintenance, max_attempts: 3
alias Towerops.Agent.AgentJob
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Devices
alias Towerops.Devices.BackupRequests
require Logger
@impl Oban.Worker
@spec perform(Oban.Job.t()) :: :ok | {:error, String.t()}
def perform(%Oban.Job{}) do
Logger.info("Starting MikroTik configuration backup job")
devices = list_backup_eligible_devices()
Logger.info("Found #{length(devices)} MikroTik devices to backup")
results = Enum.map(devices, &backup_device/1)
success_count = Enum.count(results, &match?({:ok, _}, &1))
error_count = Enum.count(results, &match?({:error, _}, &1))
skipped_count = Enum.count(results, &match?({:skipped, _}, &1))
Logger.info("Backup job complete",
success: success_count,
errors: error_count,
skipped: skipped_count
)
if error_count > 0, do: {:error, "#{error_count} backups failed"}, else: :ok
end
defp list_backup_eligible_devices do
# Query all devices with:
# - MikroTik detected (manufacturer contains "MikroTik" or "RouterOS")
# - MikroTik API enabled
# - Has credentials (username present)
# - Assigned to an agent
Devices.list_mikrotik_devices_with_api()
end
defp backup_device(device) do
# Resolve which agent this device should use (hierarchical: device -> site -> org -> global)
agent_token_id = Devices.resolve_agent_token_id(device)
if is_nil(agent_token_id) do
Logger.warning("Device has no agent assigned, skipping backup", device_id: device.id)
{:skipped, :no_agent}
else
# Build backup job
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
mikrotik_config = Devices.get_mikrotik_config(device)
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
# Build chunked read commands
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
# Most MikroTik configs are under this size
chunk_size = 32_768
num_chunks = 10
read_commands =
for i <- 0..(num_chunks - 1) do
%MikrotikCommand{
command: "/file/read",
args: %{
"file" => "#{backup_filename}.rsc",
"offset" => to_string(i * chunk_size),
"chunk-size" => to_string(chunk_size)
}
}
end
job = %AgentJob{
job_id: job_id,
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: %MikrotikDevice{
ip: device.ip_address,
username: mikrotik_config.username,
password: mikrotik_config.password,
port: mikrotik_config.port,
ssh_port: mikrotik_config.ssh_port,
use_ssl: mikrotik_config.use_ssl
},
mikrotik_commands:
[
# Step 1: Export config to file on router (compact format, no defaults)
%MikrotikCommand{
command: "/export",
args: %{"file" => backup_filename, "compact" => ""}
}
] ++
read_commands ++
[
# Final step: Delete the temporary file
%MikrotikCommand{
command: "/file/remove",
args: %{"numbers" => "#{backup_filename}.rsc"}
}
]
}
# Create tracking request
case BackupRequests.create_request(device.id, job_id) do
{:ok, _request} ->
# Broadcast to agent via PubSub
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token_id}:backup",
{:backup_requested, job}
)
Logger.info("Backup job sent to agent",
device_id: device.id,
job_id: job_id,
agent_token_id: agent_token_id
)
{:ok, job_id}
{:error, reason} ->
Logger.error("Failed to create backup request",
device_id: device.id,
reason: inspect(reason)
)
{:error, reason}
end
end
end
end