#!/usr/bin/env ruby # frozen_string_literal: true # notify_backup — send a backup run result to the n8n backup monitor # # USAGE # notify_backup.rb [options] # # REQUIRED OPTIONS # --machine NAME Identifier for this host (e.g. desktop, vps-ovh-prod-01) # --destination NAME Backup destination (e.g. synology, backblaze-b2, local) # --status STATUS Result: success | failure | skipped # # OPTIONAL OPTIONS # --bytes N Bytes transferred (raw integer; formatted to human-readable before sending) # --snapshot ID Restic snapshot ID or equivalent # --log-path PATH Path to the backup log file for this run # --notes TEXT Free-form output (restic tail, skip reason, error message, etc.) # # REQUIRED ENV VARS # N8N_SWANSONCLOUD_URL Base URL of the n8n instance (no trailing slash) # e.g. https://n8n.swansoncloud.com # N8N_SWANSONCLOUD_BACKUP_AUTH_TOKEN Bearer token for webhook authentication # # EXIT BEHAVIOR # Always exits 0. A notification failure is logged to stderr but never causes # the calling backup process to fail — the backup ran; the log is the source of truth. # # ADDING A NEW BACKUP SOURCE # 1. Ensure the two ENV vars above are available on the new host # 2. Call this script at the end of the backup process with --machine, --destination, --status # 3. Add the {machine, destination} pair to the Set: Expected Sources node in n8n Workflow 2 # # CLASSES # ByteFormatter raw integer bytes → human-readable string (e.g. "4.50 GB") # PayloadBuilder converts CLI option hash into a clean JSON-ready hash # WebhookClient sends HTTP POST with auth header; knows nothing about backup data # BackupNotifier orchestrates: build payload → send; the only public entry point require 'net/http' require 'json' require 'optparse' class ByteFormatter UNITS = [['GB', 1_073_741_824], ['MB', 1_048_576], ['KB', 1_024]].freeze def self.format(bytes) return nil if bytes.nil? bytes = bytes.to_i unit, divisor = UNITS.find { |_, d| bytes >= d } return "#{bytes} B" unless unit '%.2f %s' % [bytes.to_f / divisor, unit] end end class PayloadBuilder VALID_STATUSES = %w[success failure skipped].freeze def initialize(options) @options = options end def build validate! { timestamp: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ'), machine: @options[:machine], destination: @options[:destination], status: @options[:status], # Raw integer: the backup_runs datatable column is type number; # human formatting belongs in the reporter, not the payload. bytes_transferred: @options[:bytes], snapshot_id: @options[:snapshot], log_path: @options[:log_path], notes: @options[:notes] }.compact end private def validate! return if VALID_STATUSES.include?(@options[:status]) raise ArgumentError, "status must be one of: #{VALID_STATUSES.join(', ')}" end end class WebhookClient def initialize(base_url:, auth_token:, http_adapter: Net::HTTP) @base_url = base_url @auth_token = auth_token @http_adapter = http_adapter end def post(payload) uri = URI("#{@base_url}/webhook/backup-notify") request = build_request(uri, payload) response = @http_adapter.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| http.request(request) end raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess) end private def build_request(uri, payload) req = Net::HTTP::Post.new(uri) req['Content-Type'] = 'application/json' req['Authorization'] = "Bearer #{@auth_token}" req.body = JSON.generate(payload) req end end class BackupNotifier def initialize(options:, client:) @options = options @client = client end def self.from_env(options) url = ENV.fetch('N8N_SWANSONCLOUD_URL') token = ENV.fetch('N8N_SWANSONCLOUD_BACKUP_AUTH_TOKEN') new(options: options, client: WebhookClient.new(base_url: url, auth_token: token)) end def notify payload = PayloadBuilder.new(@options).build @client.post(payload) end end if __FILE__ == $PROGRAM_NAME options = {} OptionParser.new do |opts| opts.banner = 'Usage: notify_backup.rb [options]' opts.separator '' opts.separator 'Required:' opts.on('--machine NAME', 'Host identifier (e.g. desktop, vps-ovh-prod-01)') { |v| options[:machine] = v } opts.on('--destination NAME', 'Backup destination (e.g. synology, backblaze-b2)') { |v| options[:destination] = v } opts.on('--status STATUS', 'success | failure | skipped') { |v| options[:status] = v } opts.separator '' opts.separator 'Optional:' opts.on('--bytes N', Integer, 'Raw bytes transferred (formatted before sending)') { |v| options[:bytes] = v } opts.on('--snapshot ID', 'Snapshot ID (e.g. restic snapshot hash)') { |v| options[:snapshot] = v } opts.on('--log-path PATH', 'Path to the backup log for this run') { |v| options[:log_path] = v } opts.on('--notes TEXT', 'Free-form output: restic tail, skip reason, etc.') { |v| options[:notes] = v } end.parse! %i[machine destination status].each do |required| abort "Error: --#{required} is required. Run with --help for usage." unless options[required] end begin BackupNotifier.from_env(options).notify rescue => e warn "notify_backup: failed to send webhook: #{e.message}" exit 0 end end