server-desktop-01/scripts/notify_backup.rb

164 lines
5.5 KiB
Ruby
Raw Normal View History

#!/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
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