Ctrl K
ComunidadGuías…

app/services/import/compatibility_check.rb

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

Archivo original ↗Preguntar a KiBot
module Import
  # Import-time compatibility gate: decides whether HELIOS can adopt a given
  # stack before any import happens.
  #
  # HELIOS regenerates compose.yaml in full, so it can only accept a stack it
  # can faithfully reproduce. Two criteria:
  #
  # * Every service must be reproducible, either as a managed service (typed
  #   exporter) or verbatim under `_unmanaged.services`. A service whose image
  #   HELIOS doesn't recognize would silently vanish on the next export.
  # * Every Docker network a service joins must be one HELIOS writes again:
  #   the stack's own `default`, and the one external network an outside proxy
  #   reaches the stack over (see
  #   ConfigurationImporter::ReverseProxyExtractor#shared_network). A second
  #   network would be dropped, and the services on it would lose the way they
  #   are reached today without anything saying so.
  # * Every Traefik label on a managed service must be one HELIOS writes
  #   again. HELIOS owns the routers of its own services, so a label that says
  #   something else (a middleware it does not know, a router at another
  #   address) would be replaced by its own on the next export.
  #
  # Either way the whole import is refused, with an actionable error naming
  # what stands in the way.
  class CompatibilityCheck
    include ConfigurationImporter::Helpers

    # SOLECTRUS-universe images HELIOS round-trips today. All fully-modeled
    # services live in StackReader::ALL_IMAGE_PREFIXES — including
    # tibber-collector (Phase 2a) and senec-charger (Phase 2b), both promoted to
    # first-class managed services that match via their StackReader prefixes and
    # are never treated as `_unmanaged`.
    SOLECTRUS_IMAGE_PREFIXES = StackReader::ALL_IMAGE_PREFIXES

    # Images of the services HELIOS can only run, never take over. Each service
    # class says so itself (Export::Services::Base.adoptable?), so the reason
    # stands with the service and this gate does not repeat it.
    UNADOPTABLE_IMAGE_PREFIXES =
      StackReader::SERVICE_IMAGE_PREFIXES
      .values_at(*Export::Compose::SERVICE_ORDER.reject(&:adoptable?).map(&:service_name))
      .flatten
      .compact
      .freeze

    # Traefik argument that opens an MQTT entrypoint, so the proxy fronts a
    # broker. HELIOS writes two of them, one plain and one for TLS, and a
    # user who routes a broker through Traefik writes the same thing (see
    # #supported?). Traefik reads its flags case-insensitively and its own
    # documentation spells them camelCase, so the match ignores case. It also
    # scans the whole argument, because Compose accepts `command` as a single
    # string that holds every flag.
    BROKER_ENTRYPOINT_PATTERN = /--entrypoints\.mqtts?\./i

    # Host ports a proxy holds only to front a broker. A proxy can also take
    # its entrypoints from a configuration file, which HELIOS never reads, so
    # the published ports are the second trace to look for.
    BROKER_HOST_PORTS = [
      Export::Services::Mosquitto::CONTAINER_PORT,
      Export::Services::Mosquitto::TLS_HOST_PORT,
    ].freeze

    # Curated third-party companion images HELIOS tolerates but never
    # configures. dozzle was recommended in earlier SOLECTRUS hosting guides,
    # so many installations include it; it round-trips verbatim under
    # `_unmanaged.services`.
    COMPANION_IMAGE_PREFIXES = %w[amir20/dozzle].freeze

    ALLOWED_IMAGE_PREFIXES = (SOLECTRUS_IMAGE_PREFIXES + COMPANION_IMAGE_PREFIXES).freeze

    # Traefik labels HELIOS writes again for a service of its own. The router
    # name is the user's in the wild ("app-solectrus", "influx-api"), so the
    # shape is matched rather than the name.
    #
    # The redirect middleware is in here although HELIOS writes no middleware:
    # it is how the older SOLECTRUS setup sent port 80 to HTTPS, and HELIOS
    # does the same on the `web` entrypoint (see Export::Services::Traefik).
    #
    # The network label belongs to a stack an outside proxy routes over a
    # shared network. HELIOS writes it for every service it routes there (see
    # Export::Services::Base#network_label), so a stack that carries it already
    # is one it can write again.
    REPRODUCIBLE_LABELS = [
      /\Atraefik\.enable=/i,
      /\Atraefik\.docker\.network=/i,
      /\Atraefik\.http\.routers\.[^.]+\.(rule|entrypoints|service|middlewares|tls|tls\.certresolver)=/i,
      /\Atraefik\.http\.services\.[^.]+\.loadbalancer\.server\.port=/i,
      /\Atraefik\.http\.middlewares\.[^.]+\.redirectscheme\./i,
    ].freeze

    ROUTER_RULE = /\Atraefik\.http\.routers\.([^.]+)\.rule=(.*)\z/i
    ROUTER_MIDDLEWARES = /\Atraefik\.http\.routers\.([^.]+)\.middlewares=(.*)\z/i
    REDIRECT_MIDDLEWARE = /\Atraefik\.http\.middlewares\.([^.]+)\.redirectscheme\./i
    HOST_RULE = /\AHost\(`([^`]+)`\)\z/i

    def initialize(reader)
      @reader = reader
    end

    # Offending services as [{ 'service' => name, 'image' => image }, ...];
    # empty when every service is recognized. A service refused for the
    # broker carries a 'reason' as well (see #refusal_reason).
    def unsupported_services
      service_configs
        .reject { |_name, config| supported?(config) }
        .map { |name, config| unsupported_entry(name, config) }
    end

    # Networks a service joins that the export would not write again. The
    # compose default is always written, and so is the single external network
    # the importer captures for an outside proxy; anything beyond that is left
    # over.
    def unsupported_networks
      raw = @reader.raw_compose
      reproduced = ['default'] + ConfigurationImporter::ReverseProxyExtractor.foreign_networks(raw).first(1)

      (raw['services'] || {}).values
                             .flat_map { |service| ConfigurationImporter::ReverseProxyExtractor.network_names(service) }
                             .uniq - reproduced
    end

    # Traefik labels on managed services that the export would not write
    # again, as [{ 'service' => name, 'label' => label }, ...]. Two shapes
    # count: a label outside REPRODUCIBLE_LABELS, and a router that answers at
    # another address than the one HELIOS routes the stack at.
    def unsupported_routing
      managed_labels.flat_map do |name, labels|
        unknown = labels.reject { |label| reproducible?(label) }

        (unknown + foreign_rules(labels)).uniq.map { |label| { 'service' => name, 'label' => label } }
      end
    end

    # Raise UnsupportedStackError unless the stack is compatible.
    def call!
      services = unsupported_services
      networks = unsupported_networks
      routing = unsupported_routing
      return if services.empty? && networks.empty? && routing.empty?

      raise UnsupportedStackError.new(services, networks, routing)
    end

    private

    # Each service as the user authored it, paired with its resolved compose
    # entry so `${VAR}`-based image references are expanded before matching.
    def service_configs
      (@reader.raw_compose['services'] || {}).keys.index_with do |name|
        @reader.service(name) || {}
      end
    end

    # A service HELIOS only runs is the exception to matching on the image: it
    # is refused however well HELIOS knows it, even where HELIOS wrote it
    # itself (see Export::Services::Mosquitto.adoptable?).
    def supported?(config)
      image = config['image']
      return false if image.blank?
      return false if StackReader.image_matches?(image, UNADOPTABLE_IMAGE_PREFIXES)
      return false if routes_a_broker?(config)

      StackReader.image_matches?(image, ALLOWED_IMAGE_PREFIXES)
    end

    def unsupported_entry(name, config)
      { 'service' => name, 'image' => config['image'], 'reason' => refusal_reason(config) }.compact
    end

    # The way out differs for the broker: 'broker' for the broker itself,
    # which HELIOS runs on request, 'broker_proxy' for a proxy that only has
    # to drop its MQTT entrypoints. nil for a service HELIOS does not know.
    def refusal_reason(config)
      image = config['image']
      return 'broker' if image.present? && StackReader.image_matches?(image, UNADOPTABLE_IMAGE_PREFIXES)

      'broker_proxy' if routes_a_broker?(config)
    end

    # A reverse proxy that fronts a broker is the second trace of one, and it
    # stays behind when the user removes the broker service alone. HELIOS
    # would then publish the broker port that the proxy still holds, and
    # Docker refuses the stack with "port is already allocated". So the proxy
    # has to lose that trace before the import, and the broker keeps one
    # single shape on export.
    #
    # The entrypoint can come from the command line or from a configuration
    # file HELIOS never reads, so the published ports count as well.
    def routes_a_broker?(config)
      Array(config['command']).any? { |arg| arg.to_s.match?(BROKER_ENTRYPOINT_PATTERN) } ||
        Array(config['ports']).any? { |entry| BROKER_HOST_PORTS.include?(published_host_port(entry).to_i) }
    end

    # Traefik labels per managed service, read from the resolved view so a
    # rule spelled `Host(${APP_DOMAIN})` arrives with the domain in it.
    def managed_labels
      @managed_labels ||= StackReader::SERVICE_IMAGE_PREFIXES.keys.index_with { |name| traefik_labels_of(name) }
                                                             .reject { |_name, labels| labels.empty? }
    end

    def traefik_labels_of(name)
      labels = @reader.service(name)&.dig('labels')
      case labels
      when Array then labels.map(&:to_s).select { |label| label.start_with?('traefik.') }
      when Hash then labels.filter_map { |key, value| "#{key}=#{value}" if key.to_s.start_with?('traefik.') }
      else []
      end
    end

    def reproducible?(label)
      REPRODUCIBLE_LABELS.any? { |pattern| label.match?(pattern) }
    end

    # Rules of routers that answer somewhere else than HELIOS routes the
    # stack. A router that only sends a request on to HTTPS is left out: its
    # rule matches every host on purpose, and HELIOS does the same on the
    # `web` entrypoint.
    def foreign_rules(labels)
      redirecting = redirect_routers(labels)

      labels.select do |label|
        router, rule = label.match(ROUTER_RULE)&.captures
        router && redirecting.exclude?(router) && rule[HOST_RULE, 1] != routed_host
      end
    end

    def redirect_routers(labels)
      labels.filter_map do |label|
        router, names = label.match(ROUTER_MIDDLEWARES)&.captures
        router if router && middleware_names(names).all? { |name| redirect_middlewares.include?(name) }
      end
    end

    def middleware_names(value)
      value.split(',').map { |name| name.strip.sub(/@\w+\z/, '') }
    end

    # Names of the redirect middlewares the whole stack defines, because a
    # router may name one that another service declares.
    def redirect_middlewares
      @redirect_middlewares ||= managed_labels.values.flatten.filter_map { |l| l[REDIRECT_MIDDLEWARE, 1] }
    end

    # The address the stack answers at, taken from the dashboard router,
    # which is the one HELIOS rebuilds every other router against.
    def routed_host
      @routed_host ||= traefik_labels_of('dashboard').filter_map { |label| label.match(ROUTER_RULE)&.captures&.last }
                                                     .filter_map { |rule| rule[HOST_RULE, 1] }
                                                     .first
    end
  end
end