Ctrl K
ComunidadGuías…

app/services/import/stack_reader.rb

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

Archivo original ↗Preguntar a KiBot
require 'open3'
require 'tmpdir'
require 'fileutils'
require 'yaml'
require 'json'

module Import
  class StackReader
    # Raised when `docker compose config` rejects the stack (malformed YAML,
    # an invalid compose project, etc.).
    class Error < StandardError
      # The `docker compose config` output that caused the failure, stripped
      # of progress/warning noise — safe to show the user verbatim.
      attr_reader :detail

      def initialize(detail)
        @detail = detail
        super("docker compose config failed: #{detail}")
      end
    end

    # Canonical HELIOS service name -> image prefix that identifies it.
    # Lets us resolve legacy installations where the user chose different
    # service names (e.g. SOLECTRUS's historic 'app' + 'db' instead of
    # 'dashboard' + 'postgresql').
    SERVICE_IMAGE_PREFIXES = {
      'dashboard' => %w[ghcr.io/solectrus/solectrus],
      'ingest' => %w[ghcr.io/solectrus/ingest],
      'senec-collector' => %w[ghcr.io/solectrus/senec-collector],
      'mqtt-collector' => %w[ghcr.io/solectrus/mqtt-collector],
      'forecast-collector' => %w[ghcr.io/solectrus/forecast-collector],
      'tibber-collector' => %w[ghcr.io/solectrus/tibber-collector],
      'senec-charger' => %w[ghcr.io/solectrus/senec-charger],
      'shelly-collector' => %w[ghcr.io/solectrus/shelly-collector],
      'power-splitter' => %w[ghcr.io/solectrus/power-splitter],
      'public-data-collector' => %w[ghcr.io/solectrus/public-data-collector],
      'helios' => %w[ghcr.io/solectrus/helios],
      'postgresql-backup' => %w[ghcr.io/solectrus/postgres-s3-backup ghcr.io/solectrus/postgresql-backup],
      'influxdb-backup' => %w[ghcr.io/solectrus/influxdb2-s3-backup ghcr.io/solectrus/influxdb-backup],
      'postgresql' => %w[postgres],
      'redis' => %w[redis],
      'mosquitto' => %w[eclipse-mosquitto],
      'influxdb' => %w[influxdb],
      # containrrr/watchtower is the historical image; nickfedor/watchtower is
      # the community-maintained fork that many users (and fixtures) have moved to.
      'watchtower' => %w[containrrr/watchtower nickfedor/watchtower],
      'traefik' => %w[traefik],
    }.freeze

    ALL_IMAGE_PREFIXES = SERVICE_IMAGE_PREFIXES.values.flatten.freeze

    COLLECTOR_SERVICES = %w[senec-collector mqtt-collector shelly-collector forecast-collector].freeze

    # Collectors that need local hardware/network access (vs. the API-based
    # forecast-collector). Their absence — alongside an exposed InfluxDB — is the
    # signal that the device collectors run remotely (dashboard_only mode).
    DEVICE_COLLECTOR_SERVICES = %w[senec-collector mqtt-collector shelly-collector].freeze

    def initialize(compose_path:, env_path:)
      @compose_path = compose_path
      @env_path = env_path
    end

    # Directory the imported stack lives in. Used to decide whether an
    # absolute volume path from .env is equivalent to the default relative
    # bind mount (./service) and can therefore be dropped.
    def stack_dir
      File.dirname(@compose_path)
    end

    # Full resolved config hash for a single service (image, ports, volumes, healthcheck, restart, etc.).
    # Falls back to image-based lookup so legacy service names (e.g. 'app') resolve
    # to their canonical counterparts.
    def service(name)
      services[name]
    end

    # All resolved services as { name => config_hash }, including canonical aliases
    # for services identified by image.
    def services
      @services ||= resolved_services.merge(aliased_services)
    end

    # Returns true if the given image (from a compose service) matches any of
    # HELIOS's known service images.
    def self.managed_image?(image)
      return false if image.nil?

      ALL_IMAGE_PREFIXES.any? { |prefix| image_matches_prefix?(image, prefix) }
    end

    # Matches an image against one prefix or an array of prefixes, respecting
    # the ':tag' / '@digest' boundary — so 'postgres' matches 'postgres:16-alpine'
    # but not 'ghcr.io/.../postgresql-backup'.
    def self.image_matches?(image, prefix_or_prefixes)
      Array(prefix_or_prefixes).any? { |prefix| image_matches_prefix?(image, prefix) }
    end

    def self.image_matches_prefix?(image, prefix)
      image = image.to_s
      image == prefix || image.start_with?("#{prefix}:", "#{prefix}@")
    end
    private_class_method :image_matches_prefix?

    # Raw (unresolved) compose config parsed from YAML, preserving ${VAR} references.
    # Aliases are allowed (real-world compose files lean on `<<: *anchor` heavily) and
    # the parsed tree is deep-duped so identical sub-hashes from resolved aliases
    # don't share object identity — otherwise YAML.dump would re-emit anchors when
    # downstream consumers persist this data into config.yaml.
    def raw_compose
      @raw_compose ||= (YAML.safe_load_file(@compose_path, permitted_classes: [Symbol], aliases: true) || {}).deep_dup
    end

    # Environment variables from the .env file, read the way docker compose reads
    # them: `$VAR` references expanded, single-quoted values kept literal. That is
    # the value the containers actually received, which is what an import has to
    # persist (#377).
    def raw_env
      @raw_env ||= Env::File.load(@env_path)
    end

    private

    def resolved_services
      resolved_config['services'] || {}
    end

    def aliased_services
      SERVICE_IMAGE_PREFIXES.each_with_object({}) do |(canonical_name, prefix), result|
        next if resolved_services.key?(canonical_name)
        # ShellyExtractor reads every service with the shelly-collector image
        # directly and merges them into `shelly.devices`. A canonical alias
        # would only duplicate the first instance under two keys.
        next if canonical_name == 'shelly-collector'

        # When multiple services share the same image (e.g. parallel
        # forecast-collector instances per provider, or a second mqtt-collector
        # for ingest), the first one wins the canonical alias — HELIOS re-emits
        # it under the canonical name, while the rest stay in
        # _unmanaged.services with their original names. Order is taken from
        # raw_compose (original YAML insertion order, what the user authored)
        # rather than resolved_services (alphabetized by `docker compose
        # config`), so the user's first-listed service wins.
        first_match = ordered_service_names.find do |name|
          self.class.image_matches?(resolved_services.dig(name, 'image'), prefix)
        end
        result[canonical_name] = resolved_services[first_match] if first_match
      end
    end

    def ordered_service_names
      (raw_compose['services'] || {}).keys
    end

    def resolved_config
      @resolved_config ||= run_compose_config
    end

    # Use JSON format to avoid YAML 1.1 type coercion issues.
    def run_compose_config
      Dir.mktmpdir do |tmpdir|
        FileUtils.cp(@compose_path, File.join(tmpdir, 'compose.yaml'))
        # Normalized copy rather than FileUtils.cp: docker replaces invalid
        # bytes with U+FFFD, so a Latin-1 umlaut in a *value* (a password!)
        # would be imported corrupted here, while raw_env reads it correctly.
        # Extractors mix both sources, so the two have to agree.
        File.write(File.join(tmpdir, '.env'), TextEncoding.utf8(File.read(@env_path))) if File.exist?(@env_path)

        # Without the overrides, HELIOS' own TZ/ADMIN_PASSWORD/SECRET_KEY_BASE
        # would outrank the adopted stack's .env and get imported as if they
        # were the user's values (see Env.spawn_overrides).
        stdout, stderr, status = Open3.capture3(
          ::Env.spawn_overrides(@env_path),
          'docker', 'compose', 'config', '--format', 'json',
          chdir: tmpdir
        )
        raise Error, compose_error(stderr.presence || stdout) unless status.success?

        JSON.parse(stdout)
      end
    end

    # docker emits progress/warning lines (`time=... level=warning ...`) to
    # stderr alongside the actual error; drop them so only the error remains.
    def compose_error(output)
      filtered = output.each_line.reject { |line| line.include?('level=warning') }.join.strip
      filtered.presence || output.strip
    end
  end
end