defmodule Mix.Tasks.UploadMibs do @shortdoc "Upload MIB files to production API" @moduledoc """ Uploads MIB files from a source directory to the production API. ## Usage # Upload all vendor MIB directories mix upload_mibs --source-path ~/librenms/mibs --token YOUR_SUPERUSER_TOKEN # Upload to specific environment mix upload_mibs --source-path ~/librenms/mibs --token TOKEN --api-url https://towerops.net # Upload specific vendors only mix upload_mibs --source-path ~/librenms/mibs --token TOKEN --vendors mikrotik,cisco,ubiquiti ## Options * `--source-path` - Path to the directory containing MIB files (required) * `--token` - Superuser API token for authentication (required) * `--api-url` - API base URL (default: https://towerops.net) * `--vendors` - Comma-separated list of vendor directories to upload (default: all) ## Notes * Creates temporary tar.gz archives for each vendor directory * Uploads via multipart/form-data to POST /api/v1/mibs * Requires a superuser API token * Cleans up temporary archives after upload """ use Mix.Task require Logger @default_api_url "https://towerops.net" def run(args) do # Start required applications {:ok, _} = Application.ensure_all_started(:req) {opts, _} = OptionParser.parse!(args, strict: [ source_path: :string, token: :string, api_url: :string, vendors: :string ] ) source_path = opts[:source_path] || raise "Missing --source-path argument" token = opts[:token] || raise "Missing --token argument" api_url = opts[:api_url] || @default_api_url vendors = parse_vendors(opts[:vendors]) if !File.exists?(source_path) do raise "Source directory not found: #{source_path}" end case vendors do :all -> upload_all_vendors(source_path, api_url, token) vendor_list -> upload_specific_vendors(source_path, api_url, token, vendor_list) end end defp parse_vendors(nil), do: :all defp parse_vendors(""), do: :all defp parse_vendors(vendors_string) do vendors_string |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) end defp upload_all_vendors(source_path, api_url, token) do Logger.info("Discovering vendor directories in #{source_path}...") vendors = source_path |> File.ls!() |> Enum.filter(fn name -> path = Path.join(source_path, name) File.dir?(path) and name != "lost+found" and !String.starts_with?(name, ".") end) |> Enum.sort() Logger.info("Found #{length(vendors)} vendor directories") upload_vendor_list(source_path, api_url, token, vendors) end defp upload_specific_vendors(source_path, api_url, token, vendors) do Logger.info("Uploading #{length(vendors)} specified vendors") upload_vendor_list(source_path, api_url, token, vendors) end defp upload_vendor_list(source_path, api_url, token, vendors) do results = Enum.map(vendors, fn vendor -> vendor_path = Path.join(source_path, vendor) if File.exists?(vendor_path) do upload_vendor(api_url, token, vendor, vendor_path) else Logger.warning("Vendor directory not found: #{vendor}") {:error, vendor, "Directory not found"} end end) # Summary successes = Enum.count(results, &match?({:ok, _, _}, &1)) failures = Enum.count(results, &match?({:error, _, _}, &1)) Logger.info("Upload complete: #{successes} succeeded, #{failures} failed") if failures > 0 do Logger.error("Failed uploads:") Enum.each(results, fn {:error, vendor, reason} -> Logger.error(" - #{vendor}: #{reason}") _ -> :ok end) end end defp upload_vendor(api_url, token, vendor, vendor_path) do Logger.info("Uploading #{vendor}...") # Create temporary tar.gz archive temp_dir = System.tmp_dir!() archive_name = "#{vendor}.tar.gz" archive_path = Path.join(temp_dir, archive_name) try do # Create tar.gz archive case create_tarball(vendor_path, archive_path) do :ok -> # Upload to API upload_tarball(api_url, token, vendor, archive_path, archive_name) {:error, reason} -> Logger.error("Failed to create archive for #{vendor}: #{inspect(reason)}") {:error, vendor, "Archive creation failed"} end after # Clean up temporary archive File.rm(archive_path) end end defp create_tarball(source_dir, archive_path) do parent_dir = Path.dirname(source_dir) vendor_name = Path.basename(source_dir) case System.cmd( "tar", ["-czf", archive_path, "-C", parent_dir, vendor_name], stderr_to_stdout: true ) do {_output, 0} -> :ok {error, exit_code} -> Logger.error("tar failed (exit #{exit_code}): #{error}") {:error, :tar_failed} end rescue e in ErlangError -> Logger.error("tar command not found: #{inspect(e)}") {:error, :tar_not_found} end defp upload_tarball(api_url, token, vendor, archive_path, archive_name) do url = "#{api_url}/api/v1/mibs" Logger.debug("Uploading #{archive_name} to #{url}") # Create multipart form with file file_content = File.read!(archive_path) form = [ {"vendor", vendor}, {"file", {file_content, filename: archive_name, content_type: "application/gzip"}} ] case Req.post(url, auth: {:bearer, token}, form_multipart: form, receive_timeout: 120_000 ) do {:ok, %{status: 201, body: body}} when is_map(body) -> files_count = Map.get(body, "files_count", "unknown") Logger.info("✓ #{vendor}: uploaded #{files_count} files") {:ok, vendor, files_count} {:ok, %{status: status, body: body}} when is_map(body) -> error = Map.get(body, "error", "Unknown error") # For 403, log the full error message since it's likely a permission issue if status == 403 do Logger.error("✗ #{vendor}: HTTP #{status} - #{error}") Logger.error(" Make sure your API token has superuser permissions!") else Logger.error("✗ #{vendor}: HTTP #{status} - #{error}") end {:error, vendor, "HTTP #{status}: #{error}"} {:ok, %{status: status, body: body}} -> # Non-JSON response (likely HTML error page) error_preview = body |> to_string() |> String.slice(0, 100) Logger.error("✗ #{vendor}: HTTP #{status} - #{error_preview}...") {:error, vendor, "HTTP #{status}: Non-JSON response"} {:error, reason} -> Logger.error("✗ #{vendor}: Request failed - #{inspect(reason)}") {:error, vendor, "Request failed: #{inspect(reason)}"} end end end