app/services/backup_repository.rb
Rama develop · réplica de lectura del repositorio original.
require 'rubygems/package'
# Facade over the configured backup storage (local filesystem, an external
# host mount, or S3). The storage choice is read from
# `config.yaml.backup.destination` at every call so a freshly-saved survey
# applies immediately without a HELIOS restart. All destination-aware
# operations are delegated to the matching adapter (Local / External / S3);
# tar parsing and the data classes live here because they are
# storage-agnostic.
#
# Backup rows are written exactly once when a detached run completes —
# never by retroactive filesystem/S3 scans — and removed only by explicit
# destroy or prune. Destination switches therefore preserve the previous
# list: a user who moves local → S3 → local sees the local list again as
# soon as the destination is switched back.
class BackupRepository
class NotFound < StandardError; end
# Raised when a destination IO operation (e.g. a sidecar `rm`) fails.
# The message carries the underlying tool output so the controller can
# surface it.
class Error < StandardError; end
MAX_BACKUPS = 5
ERROR_FILENAME = 'error.txt'.freeze
CONFIG_ENTRY_PATH = 'helios/config.yaml'.freeze
# Capture groups isolate the date (yyyymmdd) and time (hhmmss) digits so a
# validated filename can be rebuilt from integers — see External#sidecar_path.
FILENAME_PATTERN = /\Asolectrus-backup-(\d{8})-(\d{6})\.tar\z/
POSTGRESQL_ENTRY_PATTERN = /\Asolectrus-postgresql-backup-\d{4}-\d{2}-\d{2}\.sql\.gz\z/
# InfluxDB ships its backup as a directory of already-gzipped files
# (`*.tar.gz`, `*.bolt.gz`, `*.sqlite.gz`, plus a manifest) — `influx
# backup`'s native output format. Wrapping it in a second gzip layer
# would cost CPU for ~1 % size win, so HELIOS stores those files
# verbatim under `solectrus-influxdb-backup-YYYY-MM-DD/`. The trailing
# `(?:/.+)?` matches both the raw per-shard tar entries (validation
# paths in RestoreRunner / BackupUploader) and the aggregated entry
# name that `summarize_entries` writes into `Backup#files`.
INFLUXDB_ENTRY_PATTERN = %r{\Asolectrus-influxdb-backup-\d{4}-\d{2}-\d{2}(?:/.+)?\z}
INFLUXDB_PREFIX_PATTERN = /\Asolectrus-influxdb-backup-\d{4}-\d{2}-\d{2}/
Entry = Data.define(:name, :bytes)
ArchiveContents = Data.define(:entries, :config)
EMPTY_ARCHIVE = ArchiveContents.new(entries: [], config: nil).freeze
# `phase` discriminates the detached BackupRunner container (:running,
# the dump+tar work in the docker:cli sidecar) from the HELIOS-side
# S3 upload (:uploading, driven by BackupRepository::S3::Uploader).
# `progress` is a Float 0.0..1.0 during :uploading, nil otherwise.
InProgress = Data.define(:started_at, :filename, :phase, :progress) do
def initialize(started_at:, filename:, phase: :running, progress: nil)
super
end
end
class << self
delegate :all, :latest, :directory, :host_directory, :destroy!, :prune!, :download,
:direct_download_url, :mark_pending!, :detect_completion!,
:record_backup!, :clear_error!, :write_error_file!, :read_archive_for, to: :storage
def find!(filename)
raise NotFound unless valid_filename?(filename)
storage.find!(filename)
end
def error_message(filename = ERROR_FILENAME)
RunnerLog.message_for(RunnerLog.kind_for(filename))
end
def valid_filename?(filename)
filename.to_s.match?(FILENAME_PATTERN)
end
# The active storage adapter, picked from configuration at every call.
# Survey changes therefore take effect immediately, without restarting
# HELIOS — external and S3 route every IO through a short-lived
# sidecar, so they do not depend on any HELIOS bind-mount that would
# only refresh on container recreation.
def storage
case destination
when 'external' then External
when 's3' then S3
else Local
end
end
def destination
Configuration.current.backup.destination.to_s.presence || ConfigSchema::BACKUP_DEFAULT_DESTINATION
end
def s3? = destination == 's3'
# True when the active destination is reached only through a short-lived
# sidecar (external mount, S3) — never the locally mounted backups dir.
def remote? = destination != 'local'
# Parses the filename's embedded timestamp as the backup's `created_at`.
# The filename encodes wall-clock time in the app timezone
# (config.time_zone), so parse it back with Time.zone — which is that same
# zone in every thread. Returns nil when the filename does not match.
def created_at_from(filename)
match = filename.to_s.match(FILENAME_PATTERN)
return nil unless match
Time.zone.strptime("#{match[1]}-#{match[2]}", '%Y%m%d-%H%M%S')
rescue ArgumentError
nil
end
def read_archive(path)
::File.open(path, 'rb') do |io|
parse_tar_stream(io)
end
rescue Errno::ENOENT, Gem::Package::TarInvalidError
EMPTY_ARCHIVE
end
# TarReader seeks past file bodies, so this stays cheap even for multi-GB
# backups — only the inner helios/config.yaml is actually read.
def parse_tar_stream(io)
entries = []
config = nil
Gem::Package::TarReader.new(io) do |tar|
tar.each do |entry|
next unless entry.file?
name = entry.full_name.delete_prefix('./')
entries << Entry.new(name: name, bytes: entry.header.size)
config = parse_config_yaml(entry.read) if name == CONFIG_ENTRY_PATH
end
end
ArchiveContents.new(entries: entries, config: config)
end
# Collapses the raw tar entries into the three logical artifacts a
# backup carries: the HELIOS config, the PostgreSQL dump, and the
# InfluxDB backup. `influx backup` writes one tarball per shard, so a
# multi-year database produces hundreds of entries — persisting each
# one would bloat `backups.files` linearly with retention while the
# UI only ever reads three aggregates. Missing artifacts are omitted
# rather than recorded as zero-byte entries; that keeps the
# representation truthful for partial/corrupt archives.
def summarize_entries(entries)
[
find_entry(entries) { |e| e.name == CONFIG_ENTRY_PATH },
find_entry(entries) { |e| e.name.match?(POSTGRESQL_ENTRY_PATTERN) },
influx_aggregate(entries),
].compact
end
def images_from_config(config)
{ influxdb: config&.dig('influxdb', 'image').presence,
postgresql: config&.dig('postgresql', 'image').presence }
end
def parse_config_yaml(raw)
YAML.safe_load(raw, permitted_classes: [Date]) || {}
rescue Psych::Exception
nil
end
private
def find_entry(entries, &)
entry = entries.find(&)
entry && { 'name' => entry.name, 'bytes' => entry.bytes }
end
def influx_aggregate(entries)
shards = entries.select { |e| e.name.match?(INFLUXDB_ENTRY_PATTERN) }
return nil if shards.empty?
{ 'name' => shards.first.name[INFLUXDB_PREFIX_PATTERN], 'bytes' => shards.sum(&:bytes) }
end
end
end