server-desktop-01/scripts/notify_backup.rb

123 lines
3.8 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
end
if __FILE__ == $PROGRAM_NAME
# CLI entry point — implemented in Task 5
end