Ctrl K
ComunidadGuías…

app/services/backup_repository.rb

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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