166 lines
5.6 KiB
Ruby
166 lines
5.6 KiB
Ruby
#!/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
|