#!/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], bytes_transferred: ByteFormatter.format(@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 end if __FILE__ == $PROGRAM_NAME # CLI entry point — implemented in Task 5 end