lib/gitlab/qa/runtime/omnibus_configuration.rb



# frozen_string_literal: true

require 'active_support'
require 'active_support/core_ext/object/blank'
require 'active_support/core_ext/string/inflections'
require 'erb'

module Gitlab
  module QA
    module Runtime
      class OmnibusConfiguration
        # @param prefixed_config The configuration to be prefixed to the new configuration
        def initialize(prefixed_config = nil)
          @config = ["# Generated by GitLab QA Omnibus Configurator at #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"]

          return unless prefixed_config

          # remove generation statement if it exists within the prefixed configuration that was passed
          # and insert the rest AFTER the very first generation statement
          config_to_insert = prefixed_config.to_s
          generation_regexp = /# Generated by GitLab QA Omnibus Configurator.*\n/
          config_to_insert = config_to_insert.gsub(generation_regexp, '') if config_to_insert.match?(generation_regexp)
          @config.insert(1, config_to_insert)
        end

        def to_s
          sanitize!.join("\n")
        end

        ERB_PATTERN = /<%=?\s?(\S+)\s?%>/

        # Execute the ERB code to produce completed template
        # @example
        #  external_url '<%= gitlab.address %>' #=> external_url 'http://gitlab-ee-09d62235.test'
        #
        # @param [GitLab::QA::Component::Gitlab] gitlab
        #
        # @return [Array]
        def expand_config_template(gitlab)
          @config.map! do |item|
            item.match(ERB_PATTERN) ? ERB.new(item).result(binding) : item
          end
        end

        def configuration
          raise NotImplementedError
        end

        # Before hook for any additional configuration
        # This would usually be a container that needs to be running
        # @return Any instance of [Gitlab::QA::Component::Base]
        def prepare; end

        # Commands to execute before tests are run against GitLab (after reconfigure)
        def exec_commands
          []
        end

        # Ensures no duplicate entries and sanitizes configurations
        # @raise RuntimeError if competing configurations exist
        # rubocop:disable Metrics/AbcSize
        def sanitize!
          sanitized = @config.map do |config|
            next config if config.start_with?('#') || config.match(/\w+\(/) # allow for comments and method invocations
            next config if config.match(ERB_PATTERN)

            # sometimes "=" is part of a Hash. Only split based on the first "="
            k, v = config.split("=", 2)
            # make sure each config is well-formed
            # e.g., gitlab_rails['packages_enabled'] = true
            #   NOT gitlab_rails['packages_enabled']=true

            v.nil? ? k.strip : "#{k.strip} = #{v.strip.tr('"', "'")}".strip
          end

          sanitized = split_items(sanitized).uniq

          sanitized = merge_arrays(sanitized)

          # check for duplicates
          duplicate_keys = []
          duplicates = sanitized.reject do |n|
            key = n.split('=').first

            duplicate_keys << key unless duplicate_keys.include?(key)
          end

          errors = []
          duplicates.each { |duplicate| errors << "Duplicate entry found: `#{duplicate}`" }

          raise "Errors exist within the Omnibus Configuration!\n#{errors.join(',')}" if errors.any?

          @config = sanitized
        end

        # rubocop:enable Metrics/AbcSize

        def <<(config)
          @config << config.strip unless config.strip.empty?
        end

        private

        # Merge Omnibus configuration values if the value is an array
        # @example
        #  array = ['a["setting"] = [1]', 'a["setting"] = [2]']
        #  merge_arrays(array) #=> ['a["setting"] = [1, 2]']
        #
        # @param [Array] arr
        #
        # @return [Array]
        def merge_arrays(arr)
          entries_with_array = {}

          arr.reject! do |item|
            key, value = item.split("=", 2)

            array_content_match = value&.match(/^\s?\[([\s\S]+)\][\s;]?$/)

            if array_content_match
              if entries_with_array[key]
                entries_with_array[key] << array_content_match[1]
              else
                entries_with_array[key] = [array_content_match[1]]
              end
            end
          end

          entries_with_array.each do |k, v|
            arr << "#{k}= [#{v.map(&:chomp).join(', ')}]".strip
          end

          arr
        end

        # Split each Omnibus setting into an array item
        # @example
        #  input = ["a['setting_1'] = true",
        #  "a['setting_2'] = [
        #    {
        #      name: 'setting_2a_name'
        #    }
        #  ]
        #  a['setting_3'] = false"]
        #
        # split_items(input) #=>
        # ["a['setting_1'] = true",
        #  "a['setting_2'] = [
        #     {
        #       name: 'setting_2a_name'
        #     }
        #   ]",
        # "a['setting_3'] = false"]
        #
        # @param [Array] input
        #
        # @return [Array]
        #
        # rubocop:disable Metrics/AbcSize
        def split_items(input)
          items = []

          input.each do |item|
            if count_occurrences(item, ' = ') > 1
              multi_line_item = []
              item.split("\n").each do |line|
                if /( = |external_url)/.match?(line)

                  if multi_line_item.count > 1
                    items.pop
                    items << multi_line_item.join("\n")
                  end

                  items << line
                  multi_line_item = [line]
                else
                  multi_line_item << line
                end
              end

              if multi_line_item.count > 1
                items.pop
                items << multi_line_item.join("\n")
              end
            else
              items << item
            end
          end

          items
        end
        # rubocop:enable Metrics/AbcSize

        # Count occurrences of a substring in a string
        # @param [String] str
        # @param [String] substr
        #
        # @return [Array]
        def count_occurrences(str, substr)
          str.scan(/(?=#{substr})/).count
        end
      end
    end
  end
end