# frozen_string_literal: true
require 'json'
require 'open3'
require 'shellwords'

require 'kubernetes-deploy/remote_logs'

module KubernetesDeploy
  class KubernetesResource
    attr_reader :name, :namespace, :context
    attr_writer :type, :deploy_started_at

    GLOBAL = false
    TIMEOUT = 5.minutes
    LOG_LINE_COUNT = 250

    DISABLE_FETCHING_LOG_INFO = 'DISABLE_FETCHING_LOG_INFO'
    DISABLE_FETCHING_EVENT_INFO = 'DISABLE_FETCHING_EVENT_INFO'
    DISABLED_LOG_INFO_MESSAGE = "collection is disabled by the #{DISABLE_FETCHING_LOG_INFO} env var."
    DISABLED_EVENT_INFO_MESSAGE = "collection is disabled by the #{DISABLE_FETCHING_EVENT_INFO} env var."
    DEBUG_RESOURCE_NOT_FOUND_MESSAGE = "None found. Please check your usual logging service (e.g. Splunk)."
    UNUSUAL_FAILURE_MESSAGE = <<~MSG
      It is very unusual for this resource type to fail to deploy. Please try the deploy again.
      If that new deploy also fails, contact your cluster administrator.
      MSG
    STANDARD_TIMEOUT_MESSAGE = <<~MSG
      Kubernetes will continue to attempt to deploy this resource in the cluster, but at this point it is considered unlikely that it will succeed.
      If you have reason to believe it will succeed, retry the deploy to continue to monitor the rollout.
      MSG

    TIMEOUT_OVERRIDE_ANNOTATION = "kubernetes-deploy.shopify.io/timeout-override"
    LAST_APPLIED_ANNOTATION = "kubectl.kubernetes.io/last-applied-configuration"
    SENSITIVE_TEMPLATE_CONTENT = false

    class << self
      def build(namespace:, context:, definition:, logger:, statsd_tags:, crd: nil)
        validate_definition_essentials(definition)
        opts = { namespace: namespace, context: context, definition: definition, logger: logger,
                 statsd_tags: statsd_tags }
        if (klass = class_for_kind(definition["kind"]))
          return klass.new(**opts)
        end
        if crd
          CustomResource.new(crd: crd, **opts)
        else
          inst = new(**opts)
          inst.type = definition["kind"]
          inst
        end
      end

      def class_for_kind(kind)
        if KubernetesDeploy.const_defined?(kind)
          KubernetesDeploy.const_get(kind)
        end
      rescue NameError
        nil
      end

      def timeout
        self::TIMEOUT
      end

      def kind
        name.demodulize
      end

      private

      def validate_definition_essentials(definition)
        debug_content = <<~STRING
          apiVersion: #{definition.fetch('apiVersion', '<missing>')}
          kind: #{definition.fetch('kind', '<missing>')}
          metadata: #{definition.fetch('metadata', {})}
          <Template body suppressed because content sensitivity could not be determined.>
        STRING
        if definition["kind"].blank?
          raise InvalidTemplateError.new("Template is missing required field 'kind'", content: debug_content)
        end

        if definition.dig('metadata', 'name').blank?
          raise InvalidTemplateError.new("Template is missing required field 'metadata.name'", content: debug_content)
        end
      end
    end

    def timeout
      return timeout_override if timeout_override.present?
      self.class.timeout
    end

    def timeout_override
      return @timeout_override if defined?(@timeout_override)
      @timeout_override = DurationParser.new(timeout_annotation).parse!.to_i
    rescue DurationParser::ParsingError
      @timeout_override = nil
    end

    def pretty_timeout_type
      "timeout: #{timeout}s"
    end

    def initialize(namespace:, context:, definition:, logger:, statsd_tags: [])
      # subclasses must also set these if they define their own initializer
      @name = definition.dig("metadata", "name").to_s
      @optional_statsd_tags = statsd_tags
      @namespace = namespace
      @context = context
      @logger = logger
      @definition = definition
      @statsd_report_done = false
      @disappeared = false
      @validation_errors = []
      @instance_data = {}
    end

    def to_kubeclient_resource
      Kubeclient::Resource.new(@definition)
    end

    def validate_definition(kubectl, selector: nil)
      @validation_errors = []
      validate_selector(selector) if selector
      validate_timeout_annotation
      validate_spec_with_kubectl(kubectl)
      @validation_errors.present?
    end

    def validation_error_msg
      @validation_errors.join("\n")
    end

    def validation_failed?
      @validation_errors.present?
    end

    def id
      "#{type}/#{name}"
    end

    def <=>(other)
      id <=> other.id
    end

    def file_path
      file.path
    end

    def sync(cache)
      @instance_data = cache.get_instance(kubectl_resource_type, name, raise_if_not_found: true)
    rescue KubernetesDeploy::Kubectl::ResourceNotFoundError
      @disappeared = true if deploy_started?
      @instance_data = {}
    end

    def after_sync
    end

    def terminating?
      @instance_data.dig('metadata', 'deletionTimestamp').present?
    end

    def disappeared?
      @disappeared
    end

    def deploy_failed?
      false
    end

    def deploy_started?
      @deploy_started_at.present?
    end

    def deploy_succeeded?
      return false unless deploy_started?
      unless @success_assumption_warning_shown
        @logger.warn("Don't know how to monitor resources of type #{type}. Assuming #{id} deployed successfully.")
        @success_assumption_warning_shown = true
      end
      true
    end

    def exists?
      @instance_data.present?
    end

    def current_generation
      return -1 unless exists? # must be different default than observed_generation
      @instance_data.dig("metadata", "generation")
    end

    def observed_generation
      return -2 unless exists?
      # populating this is a best practice, but not all controllers actually do it
      @instance_data.dig('status', 'observedGeneration')
    end

    def status
      exists? ? "Exists" : "Not Found"
    end

    def type
      @type || self.class.kind
    end

    def kubectl_resource_type
      type
    end

    def deploy_timed_out?
      return false unless deploy_started?
      !deploy_succeeded? && !deploy_failed? && (Time.now.utc - @deploy_started_at > timeout)
    end

    # Expected values: :apply, :replace, :replace_force
    def deploy_method
      :apply
    end

    def sync_debug_info(kubectl)
      @debug_events = fetch_events(kubectl) unless ENV[DISABLE_FETCHING_EVENT_INFO]
      @debug_logs = fetch_debug_logs if print_debug_logs? && !ENV[DISABLE_FETCHING_LOG_INFO]
    end

    def debug_message(cause = nil, info_hash = {})
      helpful_info = []
      if cause == :gave_up
        debug_heading = ColorizedString.new("#{id}: GLOBAL WATCH TIMEOUT (#{info_hash[:timeout]} seconds)").yellow
        helpful_info << "If you expected it to take longer than #{info_hash[:timeout]} seconds for your deploy"\
        " to roll out, increase --max-watch-seconds."
      elsif deploy_failed?
        debug_heading = ColorizedString.new("#{id}: FAILED").red
        helpful_info << failure_message if failure_message.present?
      elsif deploy_timed_out?
        debug_heading = ColorizedString.new("#{id}: TIMED OUT (#{pretty_timeout_type})").yellow
        helpful_info << timeout_message if timeout_message.present?
      else
        # Arriving in debug_message when we neither failed nor timed out is very unexpected. Dump all available info.
        debug_heading = ColorizedString.new("#{id}: MONITORING ERROR").red
        helpful_info << failure_message if failure_message.present?
        helpful_info << timeout_message if timeout_message.present? && timeout_message != STANDARD_TIMEOUT_MESSAGE
      end

      final_status = "  - Final status: #{status}"
      final_status = "\n#{final_status}" if helpful_info.present? && !helpful_info.last.end_with?("\n")
      helpful_info.prepend(debug_heading)
      helpful_info << final_status

      if @debug_events.present?
        helpful_info << "  - Events (common success events excluded):"
        @debug_events.each do |identifier, event_hashes|
          event_hashes.each { |event| helpful_info << "      [#{identifier}]\t#{event}" }
        end
      elsif ENV[DISABLE_FETCHING_EVENT_INFO]
        helpful_info << "  - Events: #{DISABLED_EVENT_INFO_MESSAGE}"
      else
        helpful_info << "  - Events: #{DEBUG_RESOURCE_NOT_FOUND_MESSAGE}"
      end

      if print_debug_logs?
        if ENV[DISABLE_FETCHING_LOG_INFO]
          helpful_info << "  - Logs: #{DISABLED_LOG_INFO_MESSAGE}"
        elsif @debug_logs.blank?
          helpful_info << "  - Logs: #{DEBUG_RESOURCE_NOT_FOUND_MESSAGE}"
        else
          container_logs = @debug_logs.container_logs.sort_by { |c| c.lines.length }
          container_logs.each do |logs|
            if logs.empty?
              helpful_info << "  - Logs from container '#{logs.container_name}': #{DEBUG_RESOURCE_NOT_FOUND_MESSAGE}"
              next
            end

            if logs.lines.length == ContainerLogs::DEFAULT_LINE_LIMIT
              truncated = " (last #{ContainerLogs::DEFAULT_LINE_LIMIT} lines shown)"
            end
            helpful_info << "  - Logs from container '#{logs.container_name}'#{truncated}:"
            logs.lines.each do |line|
              helpful_info << "      #{line}"
            end
          end
        end
      end

      helpful_info.join("\n")
    end

    # Returns a hash in the following format:
    # {
    #   "pod/web-1" => [
    #     "Pulling: pulling image "hello-world:latest" (1 events)",
    #     "Pulled: Successfully pulled image "hello-world:latest" (1 events)"
    #   ]
    # }
    def fetch_events(kubectl)
      return {} unless exists?
      out, _err, st = kubectl.run("get", "events", "--output=go-template=#{Event.go_template_for(type, name)}",
        log_failure: false)
      return {} unless st.success?

      event_collector = Hash.new { |hash, key| hash[key] = [] }
      Event.extract_all_from_go_template_blob(out).each_with_object(event_collector) do |candidate, events|
        events[id] << candidate.to_s if candidate.seen_since?(@deploy_started_at - 5.seconds)
      end
    end

    def timeout_message
      STANDARD_TIMEOUT_MESSAGE
    end

    def failure_message
    end

    def pretty_status
      padding = " " * [50 - id.length, 1].max
      "#{id}#{padding}#{status}"
    end

    def report_status_to_statsd(watch_time)
      unless @statsd_report_done
        StatsD.distribution('resource.duration', watch_time, tags: statsd_tags)
        @statsd_report_done = true
      end
    end

    def sensitive_template_content?
      self.class::SENSITIVE_TEMPLATE_CONTENT
    end

    class Event
      EVENT_SEPARATOR = "ENDEVENT--BEGINEVENT"
      FIELD_SEPARATOR = "ENDFIELD--BEGINFIELD"
      FIELDS = %w(
        .involvedObject.kind
        .involvedObject.name
        .count
        .lastTimestamp
        .reason
        .message
      )

      def self.go_template_for(kind, name)
        and_conditions = [
          %[(eq .involvedObject.kind "#{kind}")],
          %[(eq .involvedObject.name "#{name}")],
          '(ne .reason "Started")',
          '(ne .reason "Created")',
          '(ne .reason "SuccessfulCreate")',
          '(ne .reason "Scheduled")',
          '(ne .reason "Pulling")',
          '(ne .reason "Pulled")',
        ]
        condition_start = "{{if and #{and_conditions.join(' ')}}}"
        field_part = FIELDS.map { |f| "{{#{f}}}" }.join(%({{print "#{FIELD_SEPARATOR}"}}))
        %({{range .items}}#{condition_start}#{field_part}{{print "#{EVENT_SEPARATOR}"}}{{end}}{{end}})
      end

      def self.extract_all_from_go_template_blob(blob)
        blob.split(EVENT_SEPARATOR).map do |event_blob|
          pieces = event_blob.split(FIELD_SEPARATOR, FIELDS.length)
          new(
            subject_kind: pieces[FIELDS.index(".involvedObject.kind")],
            subject_name: pieces[FIELDS.index(".involvedObject.name")],
            count: pieces[FIELDS.index(".count")],
            last_timestamp: pieces[FIELDS.index(".lastTimestamp")],
            reason: pieces[FIELDS.index(".reason")],
            message: pieces[FIELDS.index(".message")]
          )
        end
      end

      def initialize(subject_kind:, last_timestamp:, reason:, message:, count:, subject_name:)
        @subject_kind = subject_kind
        @subject_name = subject_name
        @last_timestamp = Time.parse(last_timestamp)
        @reason = reason
        @message = message.tr("\n", '')
        @count = count.to_i
      end

      def seen_since?(time)
        time.to_i <= @last_timestamp.to_i
      end

      def to_s
        "#{@reason}: #{@message} (#{@count} events)"
      end
    end

    def global?
      self.class::GLOBAL
    end

    private

    def validate_timeout_annotation
      return if timeout_annotation.nil?

      override = DurationParser.new(timeout_annotation).parse!
      if override <= 0
        @validation_errors << "#{TIMEOUT_OVERRIDE_ANNOTATION} annotation is invalid: Value must be greater than 0"
      elsif override > 24.hours
        @validation_errors << "#{TIMEOUT_OVERRIDE_ANNOTATION} annotation is invalid: Value must be less than 24h"
      end
    rescue DurationParser::ParsingError => e
      @validation_errors << "#{TIMEOUT_OVERRIDE_ANNOTATION} annotation is invalid: #{e}"
    end

    def timeout_annotation
      @definition.dig("metadata", "annotations", TIMEOUT_OVERRIDE_ANNOTATION)
    end

    def validate_selector(selector)
      if labels.nil?
        @validation_errors << "selector #{selector} passed in, but no labels were defined"
        return
      end

      unless selector.to_h <= labels
        label_name = 'label'.pluralize(labels.size)
        label_string = LabelSelector.new(labels).to_s
        @validation_errors << "selector #{selector} does not match #{label_name} #{label_string}"
      end
    end

    def validate_spec_with_kubectl(kubectl)
      command = ["create", "-f", file_path, "--dry-run", "--output=name"]
      _, err, st = kubectl.run(*command, log_failure: false, output_is_sensitive: sensitive_template_content?,
        retry_whitelist: [:client_timeout], attempts: 3)

      return true if st.success?
      @validation_errors << if sensitive_template_content?
        "Validation for #{id} failed. Detailed information is unavailable as the raw error may contain sensitive data."
      else
        err
      end
    end

    def labels
      @definition.dig("metadata", "labels")
    end

    def file
      @file ||= create_definition_tempfile
    end

    def create_definition_tempfile
      file = Tempfile.new(["#{type}-#{name}", ".yml"])
      file.write(YAML.dump(@definition))
      file
    ensure
      file&.close
    end

    def print_debug_logs?
      false
    end

    def statsd_tags
      status = if deploy_failed?
        "failure"
      elsif deploy_timed_out?
        "timeout"
      elsif deploy_succeeded?
        "success"
      else
        "unknown"
      end
      tags = %W(context:#{context} namespace:#{namespace} type:#{type} status:#{status})
      tags | @optional_statsd_tags
    end
  end
end


# frozen_string_literal: true
module KubernetesDeploy
  # Adds the methods kubernetes-deploy requires to your logger class.
  # These methods include helpers for logging consistent headings, as well as facilities for
  # displaying key information later, in a summary section, rather than when it occurred.
  module DeferredSummaryLogging
    attr_reader :summary
    def initialize(*args)
      reset
      super
    end

    def reset
      @summary = DeferredSummary.new
      @current_phase = 0
    end

    def blank_line(level = :info)
      public_send(level, "")
    end

    def phase_heading(phase_name)
      @current_phase += 1
      heading("Phase #{@current_phase}: #{phase_name}")
    end

    def heading(text, secondary_msg = '', secondary_msg_color = :cyan)
      padding = (100.0 - (text.length + secondary_msg.length)) / 2
      blank_line
      part1 = ColorizedString.new("#{'-' * padding.floor}#{text}").cyan
      part2 = ColorizedString.new(secondary_msg).colorize(secondary_msg_color)
      part3 = ColorizedString.new('-' * padding.ceil).cyan
      info(part1 + part2 + part3)
    end

    # Outputs the deferred summary information saved via @logger.summary.add_action and @logger.summary.add_paragraph
    def print_summary(status)
      status_string = status.to_s.humanize.upcase
      if status == :success
        heading("Result: ", status_string, :green)
        level = :info
      elsif status == :timed_out
        heading("Result: ", status_string, :yellow)
        level = :fatal
      else
        heading("Result: ", status_string, :red)
        level = :fatal
      end

      if (actions_sentence = summary.actions_sentence.presence)
        public_send(level, actions_sentence)
        blank_line(level)
      end

      summary.paragraphs.each do |para|
        msg_lines = para.split("\n")
        msg_lines.each { |line| public_send(level, line) }
        blank_line(level) unless para == summary.paragraphs.last
      end
    end

    class DeferredSummary
      attr_reader :paragraphs

      def initialize
        @actions_taken = []
        @paragraphs = []
      end

      def actions_sentence
        return unless @actions_taken.present?
        @actions_taken.to_sentence.capitalize
      end

      # Saves a sentence fragment to be displayed in the first sentence of the summary section
      #
      # Example:
      # # The resulting summary will begin with "Created 3 secrets and failed to deploy 2 resources"
      # @logger.summary.add_action("created 3 secrets")
      # @logger.summary.add_action("failed to deploy 2 resources")
      def add_action(sentence_fragment)
        @actions_taken << sentence_fragment
      end

      # Adds a paragraph to be displayed in the summary section
      # Paragraphs will be printed in the order they were added, separated by a blank line
      # This can be used to log a block of data on a particular topic, e.g. debug info for a particular failed resource
      def add_paragraph(paragraph)
        paragraphs << paragraph
      end
    end
  end
end


# frozen_string_literal: true
require 'open3'
require 'yaml'
require 'shellwords'
require 'tempfile'
require 'fileutils'
require 'kubernetes-deploy/kubernetes_resource'
%w(
  custom_resource
  cloudsql
  config_map
  deployment
  ingress
  persistent_volume_claim
  pod
  redis
  network_policy
  memcached
  service
  pod_template
  pod_disruption_budget
  replica_set
  service_account
  daemon_set
  resource_quota
  elasticsearch
  statefulservice
  topic
  stateful_set
  cron_job
  job
  custom_resource_definition
  horizontal_pod_autoscaler
  secret
).each do |subresource|
  require "kubernetes-deploy/kubernetes_resource/#{subresource}"
end
require 'kubernetes-deploy/resource_watcher'
require 'kubernetes-deploy/kubectl'
require 'kubernetes-deploy/kubeclient_builder'
require 'kubernetes-deploy/ejson_secret_provisioner'
require 'kubernetes-deploy/renderer'
require 'kubernetes-deploy/cluster_resource_discovery'
require 'kubernetes-deploy/template_discovery'

module KubernetesDeploy
  class DeployTask
    extend KubernetesDeploy::StatsD::MeasureMethods

    PROTECTED_NAMESPACES = %w(
      default
      kube-system
      kube-public
    )
    # Things removed from default prune whitelist at https://github.com/kubernetes/kubernetes/blob/0dff56b4d88ec7551084bf89028dbeebf569620e/pkg/kubectl/cmd/apply.go#L411:
    # core/v1/Namespace -- not namespaced
    # core/v1/PersistentVolume -- not namespaced
    # core/v1/Endpoints -- managed by services
    # core/v1/PersistentVolumeClaim -- would delete data
    # core/v1/ReplicationController -- superseded by deployments/replicasets
    # extensions/v1beta1/ReplicaSet -- managed by deployments

    def predeploy_sequence
      before_crs = %w(
        ResourceQuota
        NetworkPolicy
      )
      after_crs = %w(
        ConfigMap
        PersistentVolumeClaim
        ServiceAccount
        Role
        RoleBinding
        Secret
        Pod
      )

      before_crs + cluster_resource_discoverer.crds.map(&:kind) + after_crs
    end

    def prune_whitelist
      wl = %w(
        core/v1/ConfigMap
        core/v1/Pod
        core/v1/Service
        core/v1/ResourceQuota
        core/v1/Secret
        batch/v1/Job
        extensions/v1beta1/DaemonSet
        extensions/v1beta1/Deployment
        extensions/v1beta1/Ingress
        networking.k8s.io/v1/NetworkPolicy
        apps/v1beta1/StatefulSet
        autoscaling/v1/HorizontalPodAutoscaler
        policy/v1beta1/PodDisruptionBudget
        batch/v1beta1/CronJob
      )
      wl + cluster_resource_discoverer.crds.select(&:prunable?).map(&:group_version_kind)
    end

    def server_version
      kubectl.server_version
    end

    def initialize(namespace:, context:, current_sha:, template_dir:, logger:, kubectl_instance: nil, bindings: {},
      max_watch_seconds: nil, selector: nil)
      @namespace = namespace
      @namespace_tags = []
      @context = context
      @current_sha = current_sha
      @template_dir = File.expand_path(template_dir)
      @logger = logger
      @kubectl = kubectl_instance
      @max_watch_seconds = max_watch_seconds
      @renderer = KubernetesDeploy::Renderer.new(
        current_sha: @current_sha,
        template_dir: @template_dir,
        logger: @logger,
        bindings: bindings,
      )
      @selector = selector
    end

    def run(*args)
      run!(*args)
      true
    rescue FatalDeploymentError
      false
    end

    def run!(verify_result: true, allow_protected_ns: false, prune: true)
      start = Time.now.utc
      @logger.reset

      @logger.phase_heading("Initializing deploy")
      validate_configuration(allow_protected_ns: allow_protected_ns, prune: prune)
      resources = discover_resources
      validate_resources(resources)

      @logger.phase_heading("Checking initial resource statuses")
      check_initial_status(resources)

      if deploy_has_priority_resources?(resources)
        @logger.phase_heading("Predeploying priority resources")
        predeploy_priority_resources(resources)
      end

      @logger.phase_heading("Deploying all resources")
      if PROTECTED_NAMESPACES.include?(@namespace) && prune
        raise FatalDeploymentError, "Refusing to deploy to protected namespace '#{@namespace}' with pruning enabled"
      end

      if verify_result
        deploy_all_resources(resources, prune: prune, verify: true)
        failed_resources = resources.reject(&:deploy_succeeded?)
        success = failed_resources.empty?
        if !success && failed_resources.all?(&:deploy_timed_out?)
          raise DeploymentTimeoutError
        end
        raise FatalDeploymentError unless success
      else
        deploy_all_resources(resources, prune: prune, verify: false)
        @logger.summary.add_action("deployed #{resources.length} #{'resource'.pluralize(resources.length)}")
        warning = <<~MSG
          Deploy result verification is disabled for this deploy.
          This means the desired changes were communicated to Kubernetes, but the deploy did not make sure they actually succeeded.
        MSG
        @logger.summary.add_paragraph(ColorizedString.new(warning).yellow)
      end
      StatsD.event("Deployment of #{@namespace} succeeded",
        "Successfully deployed all #{@namespace} resources to #{@context}",
        alert_type: "success", tags: statsd_tags << "status:success")
      StatsD.distribution('all_resources.duration', StatsD.duration(start), tags: statsd_tags << "status:success")
      @logger.print_summary(:success)
    rescue DeploymentTimeoutError
      @logger.print_summary(:timed_out)
      StatsD.event("Deployment of #{@namespace} timed out",
        "One or more #{@namespace} resources failed to deploy to #{@context} in time",
        alert_type: "error", tags: statsd_tags << "status:timeout")
      StatsD.distribution('all_resources.duration', StatsD.duration(start), tags: statsd_tags << "status:timeout")
      raise
    rescue FatalDeploymentError => error
      @logger.summary.add_action(error.message) if error.message != error.class.to_s
      @logger.print_summary(:failure)
      StatsD.event("Deployment of #{@namespace} failed",
        "One or more #{@namespace} resources failed to deploy to #{@context}",
        alert_type: "error", tags: statsd_tags << "status:failed")
      StatsD.distribution('all_resources.duration', StatsD.duration(start), tags: statsd_tags << "status:failed")
      raise
    end

    private

    def kubeclient_builder
      @kubeclient_builder ||= KubeclientBuilder.new
    end

    def cluster_resource_discoverer
      @cluster_resource_discoverer ||= ClusterResourceDiscovery.new(
        namespace: @namespace,
        context: @context,
        logger: @logger,
        namespace_tags: @namespace_tags
      )
    end

    def ejson_provisioner
      @ejson_provisioner ||= EjsonSecretProvisioner.new(
        namespace: @namespace,
        context: @context,
        template_dir: @template_dir,
        logger: @logger,
        statsd_tags: @namespace_tags,
        selector: @selector,
      )
    end

    def deploy_has_priority_resources?(resources)
      resources.any? { |r| predeploy_sequence.include?(r.type) }
    end

    def predeploy_priority_resources(resource_list)
      bare_pods = resource_list.select { |resource| resource.is_a?(Pod) }
      if bare_pods.count == 1
        bare_pods.first.stream_logs = true
      end

      predeploy_sequence.each do |resource_type|
        matching_resources = resource_list.select { |r| r.type == resource_type }
        next if matching_resources.empty?
        deploy_resources(matching_resources, verify: true, record_summary: false)

        failed_resources = matching_resources.reject(&:deploy_succeeded?)
        fail_count = failed_resources.length
        if fail_count > 0
          KubernetesDeploy::Concurrency.split_across_threads(failed_resources) do |r|
            r.sync_debug_info(kubectl)
          end
          failed_resources.each { |r| @logger.summary.add_paragraph(r.debug_message) }
          raise FatalDeploymentError, "Failed to deploy #{fail_count} priority #{'resource'.pluralize(fail_count)}"
        end
        @logger.blank_line
      end
    end
    measure_method(:predeploy_priority_resources, 'priority_resources.duration')

    def validate_resources(resources)
      KubernetesDeploy::Concurrency.split_across_threads(resources) do |r|
        r.validate_definition(kubectl, selector: @selector)
      end
      failed_resources = resources.select(&:validation_failed?)
      return unless failed_resources.present?

      failed_resources.each do |r|
        content = File.read(r.file_path) if File.file?(r.file_path) && !r.sensitive_template_content?
        record_invalid_template(err: r.validation_error_msg, filename: File.basename(r.file_path), content: content)
      end
      raise FatalDeploymentError, "Template validation failed"
    end
    measure_method(:validate_resources)

    def check_initial_status(resources)
      cache = ResourceCache.new(@namespace, @context, @logger)
      KubernetesDeploy::Concurrency.split_across_threads(resources) { |r| r.sync(cache) }
      resources.each { |r| @logger.info(r.pretty_status) }
    end
    measure_method(:check_initial_status, "initial_status.duration")

    def secrets_from_ejson
      ejson_provisioner.resources
    end

    def discover_resources
      resources = []
      crds = cluster_resource_discoverer.crds.group_by(&:kind)
      @logger.info("Discovering templates:")

      TemplateDiscovery.new(@template_dir).templates.each do |filename|
        split_templates(filename) do |r_def|
          crd = crds[r_def["kind"]]&.first
          r = KubernetesResource.build(namespace: @namespace, context: @context, logger: @logger, definition: r_def,
            statsd_tags: @namespace_tags, crd: crd)
          resources << r
          @logger.info("  - #{r.id}")
        end
      end
      secrets_from_ejson.each do |secret|
        resources << secret
        @logger.info("  - #{secret.id} (from ejson)")
      end
      if (global = resources.select(&:global?).presence)
        @logger.warn("Detected non-namespaced #{'resource'.pluralize(global.count)} which will never be pruned:")
        global.each { |r| @logger.warn("  - #{r.id}") }
      end
      resources.sort
    end
    measure_method(:discover_resources)

    def split_templates(filename)
      file_content = File.read(File.join(@template_dir, filename))
      rendered_content = @renderer.render_template(filename, file_content)
      YAML.load_stream(rendered_content, "<rendered> #{filename}") do |doc|
        next if doc.blank?
        unless doc.is_a?(Hash)
          raise InvalidTemplateError.new("Template is not a valid Kubernetes manifest",
            filename: filename, content: doc)
        end
        yield doc
      end
    rescue InvalidTemplateError => e
      e.filename ||= filename
      record_invalid_template(err: e.message, filename: e.filename, content: e.content)
      raise FatalDeploymentError, "Failed to render and parse template"
    rescue Psych::SyntaxError => e
      record_invalid_template(err: e.message, filename: filename, content: rendered_content)
      raise FatalDeploymentError, "Failed to render and parse template"
    end

    def record_invalid_template(err:, filename:, content: nil)
      debug_msg = ColorizedString.new("Invalid template: #{filename}\n").red
      debug_msg += "> Error message:\n#{FormattedLogger.indent_four(err)}"
      if content
        debug_msg += if content =~ /kind:\s*Secret/
          "\n> Template content: Suppressed because it may contain a Secret"
        else
          "\n> Template content:\n#{FormattedLogger.indent_four(content)}"
        end
      end
      @logger.summary.add_paragraph(debug_msg)
    end

    def validate_configuration(allow_protected_ns:, prune:)
      errors = []
      errors += kubeclient_builder.validate_config_files

      if !File.directory?(@template_dir)
        errors << "Template directory `#{@template_dir}` doesn't exist"
      elsif Dir.entries(@template_dir).none? { |file| file =~ /(\.ya?ml(\.erb)?)$|(secrets\.ejson)$/ }
        errors << "`#{@template_dir}` doesn't contain valid templates (secrets.ejson or postfix .yml, .yml.erb)"
      end

      if @namespace.blank?
        errors << "Namespace must be specified"
      elsif PROTECTED_NAMESPACES.include?(@namespace)
        if allow_protected_ns && prune
          errors << "Refusing to deploy to protected namespace '#{@namespace}' with pruning enabled"
        elsif allow_protected_ns
          @logger.warn("You're deploying to protected namespace #{@namespace}, which cannot be pruned.")
          @logger.warn("Existing resources can only be removed manually with kubectl. " \
            "Removing templates from the set deployed will have no effect.")
          @logger.warn("***Please do not deploy to #{@namespace} unless you really know what you are doing.***")
        else
          errors << "Refusing to deploy to protected namespace '#{@namespace}'"
        end
      end

      if @context.blank?
        errors << "Context must be specified"
      end

      unless errors.empty?
        @logger.summary.add_paragraph(errors.map { |err| "- #{err}" }.join("\n"))
        raise FatalDeploymentError, "Configuration invalid"
      end

      confirm_context_exists
      confirm_namespace_exists
      confirm_ejson_keys_not_prunable if prune
      @logger.info("Using resource selector #{@selector}") if @selector
      @namespace_tags |= tags_from_namespace_labels
      @logger.info("All required parameters and files are present")
    end
    measure_method(:validate_configuration)

    def deploy_resources(resources, prune: false, verify:, record_summary: true)
      return if resources.empty?
      deploy_started_at = Time.now.utc

      if resources.length > 1
        @logger.info("Deploying resources:")
        resources.each do |r|
          @logger.info("- #{r.id} (#{r.pretty_timeout_type})")
        end
      else
        resource = resources.first
        @logger.info("Deploying #{resource.id} (#{resource.pretty_timeout_type})")
      end

      # Apply can be done in one large batch, the rest have to be done individually
      applyables, individuals = resources.partition { |r| r.deploy_method == :apply }
      # Prunable resources should also applied so that they can  be pruned
      pruneable_types = prune_whitelist.map { |t| t.split("/").last }
      applyables += individuals.select { |r| pruneable_types.include?(r.type) }

      individuals.each do |r|
        r.deploy_started_at = Time.now.utc
        case r.deploy_method
        when :replace
          _, _, replace_st = kubectl.run("replace", "-f", r.file_path, log_failure: false)
        when :replace_force
          _, _, replace_st = kubectl.run("replace", "--force", "--cascade", "-f", r.file_path,
            log_failure: false)
        else
          # Fail Fast! This is a programmer mistake.
          raise ArgumentError, "Unexpected deploy method! (#{r.deploy_method.inspect})"
        end

        next if replace_st.success?
        # it doesn't exist so we can't replace it
        _, err, create_st = kubectl.run("create", "-f", r.file_path, log_failure: false)

        next if create_st.success?
        raise FatalDeploymentError, <<~MSG
          Failed to replace or create resource: #{r.id}
          #{err}
        MSG
      end

      apply_all(applyables, prune)

      if verify
        watcher = ResourceWatcher.new(resources: resources, logger: @logger, deploy_started_at: deploy_started_at,
          timeout: @max_watch_seconds, namespace: @namespace, context: @context, sha: @current_sha)
        watcher.run(record_summary: record_summary)
      end
    end

    def deploy_all_resources(resources, prune: false, verify:, record_summary: true)
      deploy_resources(resources, prune: prune, verify: verify, record_summary: record_summary)
    end
    measure_method(:deploy_all_resources, 'normal_resources.duration')

    def apply_all(resources, prune)
      return unless resources.present?
      command = %w(apply)

      Dir.mktmpdir do |tmp_dir|
        resources.each do |r|
          FileUtils.symlink(r.file_path, tmp_dir)
          r.deploy_started_at = Time.now.utc
        end
        command.push("-f", tmp_dir)

        if prune
          command.push("--prune")
          if @selector
            command.push("--selector", @selector.to_s)
          else
            command.push("--all")
          end
          prune_whitelist.each { |type| command.push("--prune-whitelist=#{type}") }
        end

        output_is_sensitive = resources.any?(&:sensitive_template_content?)
        out, err, st = kubectl.run(*command, log_failure: false, output_is_sensitive: output_is_sensitive)

        if st.success?
          log_pruning(out) if prune
        else
          record_apply_failure(err, resources: resources)
          raise FatalDeploymentError, "Command failed: #{Shellwords.join(command)}"
        end
      end
    end
    measure_method(:apply_all)

    def log_pruning(kubectl_output)
      pruned = kubectl_output.scan(/^(.*) pruned$/)
      return unless pruned.present?

      @logger.info("The following resources were pruned: #{pruned.join(', ')}")
      @logger.summary.add_action("pruned #{pruned.length} #{'resource'.pluralize(pruned.length)}")
    end

    def record_apply_failure(err, resources: [])
      warn_msg = "WARNING: Any resources not mentioned in the error(s) below were likely created/updated. " \
        "You may wish to roll back this deploy."
      @logger.summary.add_paragraph(ColorizedString.new(warn_msg).yellow)

      unidentified_errors = []
      filenames_with_sensitive_content = resources
        .select(&:sensitive_template_content?)
        .map { |r| File.basename(r.file_path) }

      err.each_line do |line|
        bad_files = find_bad_files_from_kubectl_output(line)
        if bad_files.present?
          bad_files.each do |f|
            if filenames_with_sensitive_content.include?(f[:filename])
              # Hide the error and template contents in case it has senitive information
              record_invalid_template(err: "SUPPRESSED FOR SECURITY", filename: f[:filename], content: nil)
            else
              record_invalid_template(err: f[:err], filename: f[:filename], content: f[:content])
            end
          end
        else
          unidentified_errors << line
        end
      end

      if unidentified_errors.present? && filenames_with_sensitive_content.any?
        warn_msg = "WARNING: There was an error applying some or all resources. The raw output may be sensitive and " \
          "so cannot be displayed."
        @logger.summary.add_paragraph(ColorizedString.new(warn_msg).yellow)
      elsif unidentified_errors.present?
        heading = ColorizedString.new('Unidentified error(s):').red
        msg = FormattedLogger.indent_four(unidentified_errors.join)
        @logger.summary.add_paragraph("#{heading}\n#{msg}")
      end
    end

    # Inspect the file referenced in the kubectl stderr
    # to make it easier for developer to understand what's going on
    def find_bad_files_from_kubectl_output(line)
      # stderr often contains one or more lines like the following, from which we can extract the file path(s):
      # Error from server (TypeOfError): error when creating "/path/to/service-gqq5oh.yml": Service "web" is invalid:

      line.scan(%r{"(/\S+\.ya?ml\S*)"}).each_with_object([]) do |matches, bad_files|
        matches.each do |path|
          content = File.read(path) if File.file?(path)
          bad_files << { filename: File.basename(path), err: line, content: content }
        end
      end
    end

    def confirm_context_exists
      out, err, st = kubectl.run("config", "get-contexts", "-o", "name",
        use_namespace: false, use_context: false, log_failure: false)
      available_contexts = out.split("\n")
      if !st.success?
        raise FatalDeploymentError, err
      elsif !available_contexts.include?(@context)
        raise FatalDeploymentError, "Context #{@context} is not available. Valid contexts: #{available_contexts}"
      end
      confirm_cluster_reachable
      @logger.info("Context #{@context} found")
    end

    def confirm_cluster_reachable
      success = false
      with_retries(2) do
        begin
          success = kubectl.version_info
        rescue KubectlError
          success = false
        end
      end
      raise FatalDeploymentError, "Failed to reach server for #{@context}" unless success
      if kubectl.server_version < Gem::Version.new(MIN_KUBE_VERSION)
        @logger.warn(KubernetesDeploy::Errors.server_version_warning(server_version))
      end
    end

    def confirm_namespace_exists
      raise FatalDeploymentError, "Namespace #{@namespace} not found" unless namespace_definition.present?
      @logger.info("Namespace #{@namespace} found")
    end

    def namespace_definition
      @namespace_definition ||= begin
        definition, _err, st = kubectl.run("get", "namespace", @namespace, use_namespace: false,
          log_failure: true, raise_if_not_found: true, attempts: 3, output: 'json')
        st.success? ? JSON.parse(definition, symbolize_names: true) : nil
      end
    rescue Kubectl::ResourceNotFoundError
      nil
    end

    # make sure to never prune the ejson-keys secret
    def confirm_ejson_keys_not_prunable
      secret = ejson_provisioner.ejson_keys_secret
      return unless secret.dig("metadata", "annotations", KubernetesResource::LAST_APPLIED_ANNOTATION)

      @logger.error("Deploy cannot proceed because protected resource " \
        "Secret/#{EjsonSecretProvisioner::EJSON_KEYS_SECRET} would be pruned.")
      raise EjsonPrunableError
    rescue Kubectl::ResourceNotFoundError => e
      @logger.debug("Secret/#{EjsonSecretProvisioner::EJSON_KEYS_SECRET} does not exist: #{e}")
    end

    def tags_from_namespace_labels
      return [] if namespace_definition.blank?
      namespace_labels = namespace_definition.fetch(:metadata, {}).fetch(:labels, {})
      namespace_labels.map { |key, value| "#{key}:#{value}" }
    end

    def kubectl
      @kubectl ||= Kubectl.new(namespace: @namespace, context: @context, logger: @logger, log_failure_by_default: true)
    end

    def statsd_tags
      %W(namespace:#{@namespace} sha:#{@current_sha} context:#{@context}) | @namespace_tags
    end

    def with_retries(limit)
      retried = 0
      while retried <= limit
        success = yield
        break if success
        retried += 1
      end
    end
  end
end


#
# Copyright 2014-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  module Compressor
    include Logging

    autoload :Base, "omnibus/compressors/base"
    autoload :DMG,  "omnibus/compressors/dmg"
    autoload :Null, "omnibus/compressors/null"
    autoload :TGZ,  "omnibus/compressors/tgz"

    #
    # Determine the best compressor for the current system. This method returns
    # the class, not an instance of the class.
    #
    # @example
    #   Compressor.for_current_system([:dmg, :tgz]) #=> Packager::DMG
    #
    # @param [Array<Symbol>] compressors
    #   the list of configured compressors
    #
    # @return [~Compressor::Base]
    #
    def for_current_system(compressors)
      family = Ohai["platform_family"]

      if family == "mac_os_x"
        if compressors.include?(:dmg)
          return DMG
        end

        if compressors.include?(:tgz)
          return TGZ
        end
      end

      if compressors.include?(:tgz)
        return TGZ
      else
        log.info(log_key) { "No compressor defined for `#{family}'." }
        return Null
      end
    end
    module_function :for_current_system
  end
end


#
# Copyright 2014-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  class Packager::BFF < Packager::Base
    # @return [Hash]
    SCRIPT_MAP = {
      # Default Omnibus naming
      preinst: "Pre-installation Script",
      postinst: "Post-installation Script",
      config: "Configuration Script",
      unconfig: "Unconfiguration Script",
      prerm: "Pre_rm Script",
      postrm: "Unconfiguration Script",
    }.freeze

    id :bff

    setup do
      # Copy the full-stack installer into our scratch directory, accounting for
      # any excluded files.
      #
      # /opt/hamlet => /tmp/daj29013/opt/hamlet
      destination = File.join(staging_dir, project.install_dir)
      FileSyncer.sync(project.install_dir, destination, exclude: exclusions)

      # Create the scripts staging directory
      create_directory(scripts_staging_dir)
    end

    build do
      # Copy scripts
      write_scripts

      # Render the gen template
      write_gen_template

      # Create the package
      create_bff_file
    end

    # @see Base#package_name
    def package_name
      "#{safe_base_package_name}-#{project.build_version}-#{project.build_iteration}.#{safe_architecture}.bff"
    end

    #
    # The path where the package scripts in the install directory.
    #
    # @return [String]
    #
    def scripts_install_dir
      File.expand_path(File.join(project.install_dir, "embedded/share/installp"))
    end

    #
    # The path where the package scripts will staged.
    #
    # @return [String]
    #
    def scripts_staging_dir
      File.expand_path(File.join(staging_dir, scripts_install_dir))
    end

    #
    # Copy all scripts in {Project#package_scripts_path} to the package
    # directory.
    #
    # @return [void]
    #
    def write_scripts
      SCRIPT_MAP.each do |script, _installp_name|
        source_path = File.join(project.package_scripts_path, script.to_s)

        if File.file?(source_path)
          log.debug(log_key) { "Adding script `#{script}' to `#{scripts_staging_dir}'" }
          copy_file(source_path, scripts_staging_dir)
        end
      end
    end

    #
    # Create the gen template for +mkinstallp+.
    #
    # @return [void]
    #
    # Some details on the various lifecycle scripts:
    #
    # The order of the installp scripts is:
    # - install
    #   - pre-install
    #   - post-install
    #   - config
    # - upgrade
    #   - pre-remove (of previous version)
    #   - pre-install (previous version of software not present anymore)
    #   - post-install
    #   - config
    # - remove
    #   - unconfig
    #   - unpre-install
    #
    # To run the new version of scc, the post-install will do.
    # To run the previous version with an upgrade, use the pre-remove script.
    # To run a source install of scc upon installation of installp package, use the pre-install.
    # Upon upgrade, both the pre-remove and the pre-install scripts will run.
    # As scc has been removed between the runs of these scripts, it will only run once during upgrade.
    #
    # Keywords for scripts:
    #
    #   Pre-installation Script: /path/script
    #   Unpre-installation Script: /path/script
    #   Post-installation Script: /path/script
    #   Pre_rm Script: /path/script
    #   Configuration Script: /path/script
    #   Unconfiguration Script: /path/script
    #
    def write_gen_template
      # Get a list of all files
      files = FileSyncer.glob("#{staging_dir}/**/*").reject do |path|
        # remove any files with spaces or braces.
        if path =~ /[[:space:]{}]/
          log.warn(log_key) { "Skipping packaging '#{path}' file due to whitespace or braces in filename" }
          true
        end
      end
      files.map! do |path|
        # If paths have colons or commas, rename them and add them to a post-install,
        # post-sysck renaming script ('config') which is created if needed
        if path =~ /:|,/
          alt = path.gsub(/(:|,)/, "__")
          log.debug(log_key) { "Renaming #{path} to #{alt}" }

          File.rename(path, alt) if File.exist?(path)

          # Create a config script if needed based on resources/bff/config.erb
          config_script_path = File.join(scripts_staging_dir, "config")
          unless File.exist? config_script_path
            render_template(resource_path("config.erb"),
              destination: "#{scripts_staging_dir}/config",
              variables: {
                name: project.name,
              }
            )
          end

          File.open(File.join(scripts_staging_dir, "config"), "a") do |file|
            file.puts "mv '#{alt.gsub(/^#{staging_dir}/, '')}' '#{path.gsub(/^#{staging_dir}/, '')}'"
          end

          path = alt
        end

        path.gsub(/^#{staging_dir}/, "")
      end

      # Create a map of scripts that exist to inject into the template
      scripts = SCRIPT_MAP.inject({}) do |hash, (script, installp_key)|
        staging_path = File.join(scripts_staging_dir, script.to_s)

        if File.file?(staging_path)
          hash[installp_key] = staging_path
          log.debug(log_key) { installp_key + ":\n" + File.read(staging_path) }
        end

        hash
      end

      render_template(resource_path("gen.template.erb"),
        destination: File.join(staging_dir, "gen.template"),
        variables: {
          name: safe_base_package_name,
          install_dir: project.install_dir,
          friendly_name: project.friendly_name,
          version: bff_version,
          description: project.description,
          files: files,
          scripts: scripts,
        }
      )

      # Print the full contents of the rendered template file for mkinstallp's use
      log.debug(log_key) { "Rendered Template:\n" + File.read(File.join(staging_dir, "gen.template")) }
    end

    #
    # Create the bff file using +mkinstallp+.
    #
    # Warning: This command runs as sudo! AIX requires the use of sudo to run
    # the +mkinstallp+ command.
    #
    # @return [void]
    #
    def create_bff_file
      # We are making the assumption that sudo exists.
      # Unforunately, the owner of the file in the staging directory is what
      # will be on the target machine, and mkinstallp can't tell you if that
      # is a bad thing (it usually is).
      # The match is so we only pick the lowest level of the project dir.
      # This implies that if we are in /tmp/staging/project/dir/things,
      # we will chown from 'project' on, rather than 'project/dir', which leaves
      # project owned by the build user (which is incorrect)
      # First - let's find out who we are.
      shellout!("sudo chown -Rh 0:0 #{File.join(staging_dir, project.install_dir.match(/^\/?(\w+)/).to_s)}")
      log.info(log_key) { "Creating .bff file" }

      # Since we want the owner to be root, we need to sudo the mkinstallp
      # command, otherwise it will not have access to the previously chowned
      # directory.
      shellout!("sudo /usr/sbin/mkinstallp -d #{staging_dir} -T #{File.join(staging_dir, 'gen.template')}")

      # Print the full contents of the inventory file generated by mkinstallp
      # from within the staging_dir's .info folder (where control files for the
      # packaging process are kept.)
      log.debug(log_key) do
        "With .inventory file of:\n" + File.read("#{File.join( staging_dir, '.info', "#{safe_base_package_name}.inventory" )}")
      end

      # Copy the resulting package up to the package_dir
      FileSyncer.glob(File.join(staging_dir, "tmp/*.bff")).each do |bff|
        copy_file(bff, File.join(Config.package_dir, create_bff_file_name))
      end

    ensure
      # chown back to original user's uid/gid so cleanup works correctly
      original_uid = shellout!("id -u").stdout.chomp
      original_gid = shellout!("id -g").stdout.chomp

      shellout!("sudo chown -Rh #{original_uid}:#{original_gid} #{staging_dir}")
    end

    #
    # Create bff file name
    #
    # +mkinstallp+ names the bff file according to the version specified in
    # the template. We want to differentiate the build specific version
    # correctly.
    #
    # @return [String]
    #
    def create_bff_file_name
      "#{safe_base_package_name}-#{project.build_version}-#{project.build_iteration}.#{safe_architecture}.bff"
    end

    #
    # Return the BFF-ready base package name, converting any invalid characters to
    # dashes (+-+).
    #
    # @return [String]
    #
    def safe_base_package_name
      if project.package_name =~ /\A[a-z0-9\.\+\-]+\z/
        project.package_name.dup
      else
        converted = project.package_name.downcase.gsub(/[^a-z0-9\.\+\-]+/, "-")

        log.warn(log_key) do
          "The `name' component of BFF package names can only include " \
          "lowercase alphabetical characters (a-z), numbers (0-9), dots (.), " \
          "plus signs (+), and dashes (-). Converting `#{project.package_name}' to " \
          "`#{converted}'."
        end

        converted
      end
    end

    #
    # Return the BFF-specific version for this package. This is calculated
    # using the first three digits of the version, concatenated by a dot, then
    # suffixed with the build_iteration.
    #
    # @todo This is probably not the best way to extract the version and
    #   probably misses edge cases like when using git describe!
    #
    # @return [String]
    #
    def bff_version
      version = project.build_version.split(/[^\d]/)[0..2].join(".")
      "#{version}.#{project.build_iteration}"
    end

    #
    # The architecture for this RPM package.
    #
    # @return [String]
    #
    def safe_architecture
      Ohai["kernel"]["machine"]
    end
  end
end


#
# Copyright 2014-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "fileutils"

module Omnibus
  module FileSyncer
    extend self

    # Files to be ignored during a directory globbing
    IGNORED_FILES = %w{. ..}.freeze

    #
    # Glob across the given pattern, accounting for dotfiles, removing Ruby's
    # dumb idea to include +'.'+ and +'..'+ as entries.
    #
    # @param [String] pattern
    #   the path or glob pattern to get all files from
    #
    # @return [Array<String>]
    #   the list of all files
    #
    def glob(pattern)
      pattern = Pathname.new(pattern).cleanpath.to_s
      Dir.glob(pattern, File::FNM_DOTMATCH).sort.reject do |file|
        basename = File.basename(file)
        IGNORED_FILES.include?(basename)
      end
    end

    #
    # Glob for all files under a given path/pattern, removing Ruby's
    # dumb idea to include +'.'+ and +'..'+ as entries.
    #
    # @param [String] source
    #   the path or glob pattern to get all files from
    #
    # @option options [String, Array<String>] :exclude
    #   a file, folder, or globbing pattern of files to ignore when syncing
    #
    # @return [Array<String>]
    #   the list of all files
    #
    def all_files_under(source, options = {})
      excludes = Array(options[:exclude]).map do |exclude|
        [exclude, "#{exclude}/*"]
      end.flatten

      source_files = glob(File.join(source, "**/*"))
      source_files = source_files.reject do |source_file|
        basename = relative_path_for(source_file, source)
        excludes.any? { |exclude| File.fnmatch?(exclude, basename, File::FNM_DOTMATCH) }
      end
    end

    #
    # Copy the files from +source+ to +destination+, while removing any files
    # in +destination+ that are not present in +source+.
    #
    # The method accepts an optional +:exclude+ parameter to ignore files and
    # folders that match the given pattern(s). Note the exclude pattern behaves
    # on paths relative to the given source. If you want to exclude a nested
    # directory, you will need to use something like +**/directory+.
    #
    # @raise ArgumentError
    #   if the +source+ parameter is not a directory
    #
    # @param [String] source
    #   the path on disk to sync from
    # @param [String] destination
    #   the path on disk to sync to
    #
    # @option options [String, Array<String>] :exclude
    #   a file, folder, or globbing pattern of files to ignore when syncing
    #
    # @return [true]
    #
    def sync(source, destination, options = {})
      unless File.directory?(source)
        raise ArgumentError, "`source' must be a directory, but was a " \
          "`#{File.ftype(source)}'! If you just want to sync a file, use " \
          "the `copy' method instead."
      end

      source_files = all_files_under(source, options)

      # Ensure the destination directory exists
      FileUtils.mkdir_p(destination) unless File.directory?(destination)

      # Copy over the filtered source files
      source_files.each do |source_file|
        relative_path = relative_path_for(source_file, source)

        # Create the parent directory
        parent = File.join(destination, File.dirname(relative_path))
        FileUtils.mkdir_p(parent) unless File.directory?(parent)

        case File.ftype(source_file).to_sym
        when :directory
          FileUtils.mkdir_p("#{destination}/#{relative_path}")
        when :link
          target = File.readlink(source_file)

          Dir.chdir(destination) do
            FileUtils.ln_sf(target, "#{destination}/#{relative_path}")
          end
        when :file
          source_stat = File.stat(source_file)
          # Detect 'files' which are hard links and use ln instead of cp to
          # duplicate them, provided their source is in place already
          if hardlink? source_stat
            if existing = hardlink_sources[[source_stat.dev, source_stat.ino]]
              FileUtils.ln(existing, "#{destination}/#{relative_path}", force: true)
            else
              begin
                FileUtils.cp(source_file, "#{destination}/#{relative_path}")
              rescue Errno::EACCES
                FileUtils.cp_r(source_file, "#{destination}/#{relative_path}", remove_destination: true)
              end
              hardlink_sources.store([source_stat.dev, source_stat.ino], "#{destination}/#{relative_path}")
            end
          else
            # First attempt a regular copy. If we don't have write
            # permission on the File, open will probably fail with
            # EACCES (making it hard to sync files with permission
            # r--r--r--). Rescue this error and use cp_r's
            # :remove_destination option.
            begin
              FileUtils.cp(source_file, "#{destination}/#{relative_path}")
            rescue Errno::EACCES
              FileUtils.cp_r(source_file, "#{destination}/#{relative_path}", remove_destination: true)
            end
          end
        else
          raise "Unknown file type: `File.ftype(source_file)' at `#{source_file}'!"
        end
      end

      # Remove any files in the destination that are not in the source files
      destination_files = glob("#{destination}/**/*")

      # Calculate the relative paths of files so we can compare to the
      # source.
      relative_source_files = source_files.map do |file|
        relative_path_for(file, source)
      end
      relative_destination_files = destination_files.map do |file|
        relative_path_for(file, destination)
      end

      # Remove any extra files that are present in the destination, but are
      # not in the source list
      extra_files = relative_destination_files - relative_source_files
      extra_files.each do |file|
        FileUtils.rm_rf(File.join(destination, file))
      end

      true
    end

    private

    #
    # The relative path of the given +path+ to the +parent+.
    #
    # @param [String] path
    #   the path to get relative with
    # @param [String] parent
    #   the parent where the path is contained (hopefully)
    #
    # @return [String]
    #
    def relative_path_for(path, parent)
      Pathname.new(path).relative_path_from(Pathname.new(parent)).to_s
    end

    #
    # A list of hard link file(s) sources which have already been copied,
    # indexed by device and inode number.
    #
    # @api private
    #
    # @return [Hash{Array<FixNum, FixNum> => String}]
    #
    def hardlink_sources
      @hardlink_sources ||= {}
    end

    #
    # Determines whether or not a file is a hardlink.
    #
    # @param [File::Stat] stat
    #   the File::Stat object for a file you wand to test
    #
    # @return [true, false]
    #
    def hardlink?(stat)
      case stat.ftype.to_sym
      when :file
        stat.nlink > 1
      else
        false
      end
    end
  end
end


#
# Copyright 2014-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  module Packager
    include Logging
    include Sugarable

    autoload :Base,     "omnibus/packagers/base"
    autoload :BFF,      "omnibus/packagers/bff"
    autoload :DEB,      "omnibus/packagers/deb"
    autoload :Makeself, "omnibus/packagers/makeself"
    autoload :MSI,      "omnibus/packagers/msi"
    autoload :APPX,     "omnibus/packagers/appx"
    autoload :PKG,      "omnibus/packagers/pkg"
    autoload :PKGSRC,   "omnibus/packagers/pkgsrc"
    autoload :Solaris,  "omnibus/packagers/solaris"
    autoload :IPS,      "omnibus/packagers/ips"
    autoload :RPM,      "omnibus/packagers/rpm"

    #
    # The list of Ohai platform families mapped to the respective packager
    # class.
    #
    # @return [Hash<String, Class>]
    #
    PLATFORM_PACKAGER_MAP = {
      "debian" => DEB,
      "fedora" => RPM,
      "suse" => RPM,
      "rhel" => RPM,
      "wrlinux" => RPM,
      "amazon" => RPM,
      "aix" => BFF,
      "solaris" => Solaris,
      "ips" => IPS,
      "windows" => [MSI, APPX],
      "mac_os_x" => PKG,
      "smartos" => PKGSRC,
    }.freeze

    #
    # Determine the packager(s) for the current system. This method returns the
    # class, not an instance of the class.
    #
    # @example
    #   Packager.for_current_system #=> [Packager::RPM]
    #
    # @return [[~Packager::Base]]
    #
    def for_current_system
      family = Ohai["platform_family"]
      version = Ohai["platform_version"]

      if family == "solaris2" && Chef::Sugar::Constraints::Version.new(version).satisfies?(">= 5.11")
        family = "ips"
      elsif family == "solaris2" && Chef::Sugar::Constraints::Version.new(version).satisfies?(">= 5.10")
        family = "solaris"
      end
      if klass = PLATFORM_PACKAGER_MAP[family]
        package_types = klass.is_a?(Array) ? klass : [ klass ]

        if package_types.include?(APPX) &&
            !Chef::Sugar::Constraints::Version.new(version).satisfies?(">= 6.2")
          log.warn(log_key) { "APPX generation is only supported on Windows versions 2012 and above" }
          package_types -= [APPX]
        end

        package_types
      else
        log.warn(log_key) do
          "Could not determine packager for `#{family}', defaulting " \
          "to `makeself'!"
        end
        [Makeself]
      end
    end
    module_function :for_current_system
  end
end


#
# Copyright 2014-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  class Compressor::DMG < Compressor::Base
    id :dmg

    setup do
      # Clean any previously mounted disks
      clean_disks

      # Create the resources directory
      create_directory(resources_dir)

      # Copy the compiled pkg into the dmg
      copy_file(packager.package_path, "#{resources_dir}/")

      # Copy support files
      support = create_directory("#{resources_dir}/.support")
      copy_file(resource_path("background.png"), "#{support}/background.png")
    end

    build do
      create_writable_dmg
      attach_dmg
      copy_assets_to_dmg
      # Give some time to the system so attached dmg shows up in Finder
      sleep 5
      set_volume_icon
      prettify_dmg
      compress_dmg
      set_dmg_icon
    end

    #
    # @!group DSL methods
    # --------------------------------------------------

    #
    # Set or return the starting x,y and ending x,y positions for the created
    # DMG window.
    #
    # @example
    #   window_bounds "100, 100, 750, 600"
    #
    # @param [String] val
    #   the DMG window bounds
    #
    # @return [String]
    #   the DMG window bounds
    #
    def window_bounds(val = NULL)
      if null?(val)
        @window_bounds || "100, 100, 750, 600"
      else
        @window_bounds = val
      end
    end
    expose :window_bounds

    #
    # Set or return the starting x,y position where the .pkg file should live
    # in the DMG window.
    #
    # @example
    #   pkg_position "535, 50"
    #
    # @param [String] val
    #   the PKG position inside the DMG
    #
    # @return [String]
    #   the PKG position inside the DMG
    #
    def pkg_position(val = NULL)
      if null?(val)
        @pkg_position || "535, 50"
      else
        @pkg_position = val
      end
    end
    expose :pkg_position

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # The path where the MSI resources will live.
    #
    # @return [String]
    #
    def resources_dir
      File.expand_path("#{staging_dir}/Resources")
    end

    #
    # Cleans any previously left over mounted disks.
    #
    # We are trying to detach disks that look like:
    #
    #   /dev/disk1s1 on /Volumes/chef (hfs, local, nodev, nosuid, read-only, noowners, quarantine, mounted by serdar)
    #   /dev/disk2s1 on /Volumes/chef 1 (hfs, local, nodev, nosuid, read-only, noowners, quarantine, mounted by serdar)
    #
    # @return [void]
    #
    def clean_disks
      log.info(log_key) { "Cleaning previously mounted disks" }

      existing_disks = shellout!("mount | grep \"/Volumes/#{volume_name}\" | awk '{print $1}'")
      existing_disks.stdout.lines.each do |existing_disk|
        existing_disk.chomp!

        Omnibus.logger.debug(log_key) do
          "Detaching disk `#{existing_disk}' before starting dmg packaging."
        end

        shellout!("hdiutil detach '#{existing_disk}'")
      end
    end

    #
    # Create a writable dmg we can put assets on.
    #
    def create_writable_dmg
      log.info(log_key) { "Creating writable dmg" }

      shellout! <<-EOH.gsub(/^ {8}/, "")
        hdiutil create \\
          -volname "#{volume_name}" \\
          -fs HFS+ \\
          -fsargs "-c c=64,a=16,e=16" \\
          -size 512000k \\
          "#{writable_dmg}"
      EOH
    end

    #
    # Attach the dmg, storing a reference to the device for later use.
    #
    # @return [String]
    #   the name of the attached device
    #
    def attach_dmg
      @device ||= Dir.chdir(staging_dir) do
        log.info(log_key) { "Attaching dmg as disk" }

        cmd = shellout! <<-EOH.gsub(/^ {10}/, "")
          hdiutil attach \\
            -readwrite \\
            -noverify \\
            -noautoopen \\
            "#{writable_dmg}" | egrep '^/dev/' | sed 1q | awk '{print $1}'
        EOH

        cmd.stdout.strip
      end
    end

    #
    # Copy assets to dmg
    #
    def copy_assets_to_dmg
      log.info(log_key) { "Copying assets into dmg" }

      FileSyncer.glob("#{resources_dir}/*").each do |file|
        FileUtils.cp_r(file, "/Volumes/#{volume_name}")
      end
    end

    #
    # Create the icon for the volume using sips.
    #
    # @return [void]
    #
    def set_volume_icon
      log.info(log_key) { "Setting volume icon" }

      icon = resource_path("icon.png")

      Dir.chdir(staging_dir) do
        shellout! <<-EOH.gsub(/^ {10}/, "")
          # Generate the icns
          mkdir tmp.iconset
          sips -z 16 16     #{icon} --out tmp.iconset/icon_16x16.png
          sips -z 32 32     #{icon} --out tmp.iconset/icon_16x16@2x.png
          sips -z 32 32     #{icon} --out tmp.iconset/icon_32x32.png
          sips -z 64 64     #{icon} --out tmp.iconset/icon_32x32@2x.png
          sips -z 128 128   #{icon} --out tmp.iconset/icon_128x128.png
          sips -z 256 256   #{icon} --out tmp.iconset/icon_128x128@2x.png
          sips -z 256 256   #{icon} --out tmp.iconset/icon_256x256.png
          sips -z 512 512   #{icon} --out tmp.iconset/icon_256x256@2x.png
          sips -z 512 512   #{icon} --out tmp.iconset/icon_512x512.png
          sips -z 1024 1024 #{icon} --out tmp.iconset/icon_512x512@2x.png
          iconutil -c icns tmp.iconset

          # Copy it over
          cp tmp.icns "/Volumes/#{volume_name}/.VolumeIcon.icns"

          # Source the icon
          SetFile -a C "/Volumes/#{volume_name}"
        EOH
      end
    end

    #
    # Use Applescript to setup the DMG with pretty logos and colors.
    #
    # @return [void]
    #
    def prettify_dmg
      log.info(log_key) { "Making the dmg all pretty and stuff" }

      render_template(resource_path("create_dmg.osascript.erb"),
        destination: "#{staging_dir}/create_dmg.osascript",
        variables: {
          volume_name: volume_name,
          pkg_name: packager.package_name,
          window_bounds: window_bounds,
          pkg_position: pkg_position,
        }
      )

      Dir.chdir(staging_dir) do
        shellout! <<-EOH.gsub(/^ {10}/, "")
          osascript "#{staging_dir}/create_dmg.osascript"
        EOH
      end
    end

    #
    # Compress the dmg using hdiutil and zlib. zlib offers better compression
    # levels than bzip2 (10.4+) or LZFSE (10.11+), but takes longer to compress.
    # We're willing to trade slightly longer build times for smaller package sizes.
    #
    # @return [void]
    #
    def compress_dmg
      log.info(log_key) { "Compressing dmg" }

      Dir.chdir(staging_dir) do
        shellout! <<-EOH.gsub(/^ {10}/, "")
          chmod -Rf go-w "/Volumes/#{volume_name}"
          sync
          hdiutil detach "#{@device}"
          hdiutil convert \\
            "#{writable_dmg}" \\
            -format UDZO \\
            -imagekey \\
            zlib-level=9 \\
            -o "#{package_path}"
          rm -rf "#{writable_dmg}"
        EOH
      end
    end

    #
    # Set the dmg icon to our custom icon.
    #
    # @return [void]
    #
    def set_dmg_icon
      log.info(log_key) { "Setting dmg icon" }

      Dir.chdir(staging_dir) do
        shellout! <<-EOH.gsub(/^ {10}/, "")
          # Convert the png to an icon
          sips -i "#{resource_path('icon.png')}"

          # Extract the icon into its own resource
          DeRez -only icns "#{resource_path('icon.png')}" > tmp.rsrc

          # Append the icon reosurce to the DMG
          Rez -append tmp.rsrc -o "#{package_path}"

          # Source the icon
          SetFile -a C "#{package_path}"
        EOH
      end
    end

    # @see Base#package_name
    def package_name
      extname = File.extname(packager.package_name)
      packager.package_name.sub(extname, ".dmg")
    end

    # The path to the writable dmg on disk.
    #
    # @return [String]
    def writable_dmg
      File.expand_path("#{staging_dir}/#{project.name}-writable.dmg")
    end

    #
    # The name of the volume to create. By defauly, this is the project's
    # friendly name.
    #
    # @return [String]
    #
    def volume_name
      project.friendly_name
    end
  end
end


#
# Copyright 2016-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  class Packager::IPS < Packager::Base
    id :ips

    setup do
      create_directory(source_dir)
      # Copy the full-stack installer into our scratch directory, accounting for
      # any excluded files.
      #
      # /opt/hamlet => /tmp/daj29013/proto_install/opt/hamlet
      # Create the proto_install directory inside staging_dir
      destination = File.join(source_dir, project.install_dir)
      FileSyncer.sync(project.install_dir, destination, exclude: exclusions)
      write_transform_file
      write_versionlock_file
    end

    build do
      #
      # Package manifest generation is divided into the following stages:
      #
      write_pkg_metadata
      generate_pkg_contents
      generate_pkg_deps
      validate_pkg_manifest

      #
      # To create an IPS package we need to generate a repo, publish our
      # package into said repo. Finally, we export a portable `*.p5p`
      # file from this repo.
      #
      create_ips_repo
      publish_ips_pkg
      export_pkg_archive_file
    end

    #
    # @!group DSL methods
    # --------------------------------------------------

    #
    # The publisher prefix for the IPS package.
    #
    # @example
    #   identifier 'Chef'
    #
    # @param [String] val
    #   the package identifier
    #
    # @return [String]
    #
    def publisher_prefix(val = NULL)
      if null?(val)
        @publisher_prefix || "Omnibus"
      else
        @publisher_prefix = val
      end
    end
    expose :publisher_prefix

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # @see Base#package_name
    #
    def package_name
      "#{safe_base_package_name}-#{project.build_version}-#{project.build_iteration}.#{safe_architecture}.p5p"
    end

    #
    # For more info about fmri see:
    #
    #   http://docs.oracle.com/cd/E23824_01/html/E21796/pkg-5.html
    #
    def fmri_package_name
      version = project.build_version.split(/[^\d]/)[0..2].join(".")
      platform = Ohai["platform_version"]
      "#{safe_base_package_name}@#{version},#{platform}-#{project.build_iteration}"
    end

    #
    # The full path to the transform file on disk.
    #
    # @return [String]
    #
    def transform_file
      @transform_file ||= File.join(staging_dir, "doc-transform")
    end

    #
    # The full path to the version-lock file on disk.
    #
    # @return [String]
    #
    def versionlock_file
      @versionlock_file ||= File.join(staging_dir, "version-lock")
    end

    #
    # The full path to the pkg metadata file on disk.
    #
    # @return [String]
    #
    def pkg_metadata_file
      @pkg_metadata_file ||= File.join(staging_dir, "gen.manifestfile")
    end

    #
    # The full path to the pkg manifest file on disk.
    #
    # @return [String]
    #
    def pkg_manifest_file
      @pkg_manifest_file ||= File.join(staging_dir, "#{safe_base_package_name}.p5m")
    end

    #
    # The path to the +publish/repo+ directory inside the staging directory.
    #
    # @return [String]
    #
    def repo_dir
      @repo_dir ||= File.join(staging_dir, "publish", "repo")
    end

    #
    # The path to the +proto-install+ directory inside the staging directory.
    #
    # @return [String]
    #
    def source_dir
      @source_dir ||= File.join(staging_dir, "proto_install")
    end

    #
    # Return the IPS-ready base package name, converting any invalid characters to
    # dashes (+-+).
    #
    # @return [String]
    #
    def safe_base_package_name
      if project.package_name =~ /\A[a-z0-9\.\+\-]+\z/
        project.package_name.dup
      else
        converted = project.package_name.downcase.gsub(/[^a-z0-9\.\+\-]+/, "-")

        log.warn(log_key) do
          "The `name' component of IPS package names can only include " \
          "lowercase alphabetical characters (a-z), numbers (0-9), dots (.), " \
          "plus signs (+), and dashes (-). Converting `#{project.package_name}' to " \
          "`#{converted}'."
        end
        converted
      end
    end

    #
    # The architecture for this IPS package.
    #
    # @return [String]
    #
    def safe_architecture
      if intel?
        "i386"
      elsif sparc?
        "sparc"
      else
        Ohai["kernel"]["machine"]
      end
    end

    #
    # A version-lock rule that `pkgmogrify' will apply to at the end of package
    # manifest.
    #
    # @return [void]
    #
    def write_versionlock_file
      transform_str = "<transform pkg depend -> default facet.version-lock.*> false>"
      File.write("#{staging_dir}/version-lock", transform_str)
    end

    #
    # A set of transform rules that `pkgmogrify' will apply to the package
    # manifest.
    #
    # @return [void]
    #
    def write_transform_file
      render_template(resource_path("doc-transform.erb"),
        destination: transform_file,
        variables: {
          pathdir: project.install_dir.split("/")[1],
        }
      )
    end

    #
    # The name of the project specific template if it exists
    # The resource exists locally. For example for project omnibus-toolchain
    # resource_path("#{safe_base_package_name}-symlinks.erb") #=>
    # {"/path/to/omnibus-toolchain/resources/omnibus-toolchain/ips/omnibus-toolchain-symlinks.erb"}
    # OR {"/path/to/omnibus-toolchain/resources/omnibus-toolchain/ips/symlinks.erb"}
    #
    # @return [String]
    #
    def symlinks_file
      if File.exist?(resource_path("#{safe_base_package_name}-symlinks.erb"))
        "#{safe_base_package_name}-symlinks.erb"
      elsif File.exist?(resource_path("symlinks.erb"))
        "symlinks.erb"
      end
    end

    #
    # A set of symbolic links to installed commands that
    # `pkgmogrify' will apply to the package manifest. Is called only when
    # "#{safe_base_package_name}-symlinks.erb" or "symlinks.erb" template resource
    # exists locally
    #
    # @return [String]
    #
    def render_symlinks
      render_template_content(resource_path(symlinks_file),
        {
          projectdir: project.install_dir,
        }
      )
    end

    #
    # Generate package metadata
    #
    # Create the gen template for `pkgmogrify`
    #
    # @return [void]
    #
    def write_pkg_metadata
      render_template(resource_path("gen.manifestfile.erb"),
        destination: pkg_metadata_file,
        variables: {
          name: safe_base_package_name,
          fmri_package_name: fmri_package_name,
          description: project.description,
          summary: project.friendly_name,
          arch: safe_architecture,
        }
      )

      # Append the contents of symlinks_file if it exists
      if symlinks_file
        File.open(pkg_metadata_file, "a") do |symlink|
          symlink.write(render_symlinks)
        end
      end

      # Print the full contents of the rendered template file to generate package contents
      log.debug(log_key) { "Rendered Template:\n" + File.read(pkg_metadata_file) }
    end

    #
    # Create the package contents using `pkgsend` and `pkgfmt`
    #
    # @return [void]
    #
    def generate_pkg_contents
      shellout!("pkgsend generate #{source_dir} | pkgfmt > #{pkg_manifest_file}.1")
      shellout!("pkgmogrify -DARCH=`uname -p` #{pkg_manifest_file}.1 #{pkg_metadata_file} #{transform_file} | pkgfmt > #{pkg_manifest_file}.2")
    end

    #
    # Generate the package deps
    #
    # @return [void]
    #
    def generate_pkg_deps
      shellout!("pkgdepend generate -md #{source_dir} #{pkg_manifest_file}.2 | pkgfmt > #{pkg_manifest_file}.3")
      shellout!("pkgmogrify -DARCH=`uname -p` #{pkg_manifest_file}.3 #{transform_file} | pkgfmt > #{pkg_manifest_file}.4")
      shellout!("pkgdepend resolve -m #{pkg_manifest_file}.4")
      shellout!("pkgmogrify #{pkg_manifest_file}.4.res #{versionlock_file} > #{pkg_manifest_file}.5.res")
    end

    #
    # Validate the generated package manifest using `pkglint`
    #
    # @return [void]
    #
    def validate_pkg_manifest
      log.info(log_key) { "Validating package manifest" }
      shellout!("pkglint -c /tmp/lint-cache -r http://pkg.oracle.com/solaris/release #{pkg_manifest_file}.5.res")
    end

    #
    # Create a local IPS repo for publishing
    #
    # @return [void]
    #
    def create_ips_repo
      shellout!("pkgrepo create #{repo_dir}")
      log.info(log_key) { "Created IPS repo: #{repo_dir}" }
    end

    #
    # Publish the IPS pkg into the local IPS repo
    #
    # @return [void]
    #
    def publish_ips_pkg
      shellout!("pkgrepo -s #{repo_dir} set publisher/prefix=#{publisher_prefix}")
      shellout!("pkgsend publish -s #{repo_dir} -d #{source_dir} #{pkg_manifest_file}.5.res")
      log.info(log_key) { "Published IPS package to repo: #{repo_dir}" }

      repo_info = shellout("pkg list -afv -g #{repo_dir}").stdout
      log.debug(log_key) do
        <<-EOH.strip
          Published IPS package:

            #{repo_info}
        EOH
      end
    end

    #
    # Convert a the published IPS pkg from the local repo into the more
    # easily distributable `*.p5p` archive.
    #
    # @return [void]
    #
    def export_pkg_archive_file
      # The destination file cannot already exist
      File.delete(package_path) if File.exist?(package_path)
      shellout!("pkgrecv -s #{repo_dir} -a -d #{package_path} #{safe_base_package_name}")
      log.info(log_key) { "Exported IPS package archive: #{package_path}" }

      list_pkgarchive = shellout("pkgrepo list -s #{package_path} '*@latest'").stdout
      log.debug(log_key) do
        <<-EOH.strip
          IPS package archive contents:

            #{list_pkgarchive}
        EOH
      end
    end
  end
end


# Copyright 2012-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "omnibus/sugarable"
require "omnibus/whitelist"
begin
  require "pedump"
rescue LoadError
  STDERR.puts "pedump not found - windows health checks disabled"
end

module Omnibus
  class HealthCheck
    include Instrumentation
    include Logging
    include Util
    include Sugarable

    class << self
      # @see (HealthCheck#new)
      def run!(project)
        new(project).run!
      end
    end

    #
    # The project to healthcheck.
    #
    # @return [Project]
    #
    attr_reader :project

    #
    # Run the healthchecks against the given project. It is assumed that the
    # project has already been built.
    #
    # @param [Project] project
    #   the project to health check
    #
    def initialize(project)
      @project = project
    end

    #
    # Run the given health check. Healthcheks are skipped on Windows.
    #
    # @raise [HealthCheckFailed]
    #   if the health check fails
    #
    # @return [true]
    #   if the healthchecks pass
    #
    def run!
      measure("Health check time") do
        log.info(log_key) { "Running health on #{project.name}" }
        bad_libs =  case Ohai["platform"]
                    when "mac_os_x"
                      health_check_otool
                    when "aix"
                      health_check_aix
                    when "windows"
                      # TODO: objdump -p will provided a very limited check of
                      # explicit dependencies on windows. Most dependencies are
                      # implicit and hence not detected.
                      log.warn(log_key) { "Skipping dependency health checks on Windows." }
                      {}
                    else
                      health_check_ldd
                    end

        unresolved = []
        unreliable = []
        detail = []

        if bad_libs.keys.length > 0
          bad_libs.each do |name, lib_hash|
            lib_hash.each do |lib, linked_libs|
              linked_libs.each do |linked, count|
                if linked =~ /not found/
                  unresolved << lib unless unresolved.include? lib
                else
                  unreliable << linked unless unreliable.include? linked
                end
                detail << "#{name}|#{lib}|#{linked}|#{count}"
              end
            end
          end

          log.error(log_key) { "Failed!" }
          bad_omnibus_libs, bad_omnibus_bins = bad_libs.keys.partition { |k| k.include? "embedded/lib" }

          log.error(log_key) do
            out = "The following libraries have unsafe or unmet dependencies:\n"

            bad_omnibus_libs.each do |lib|
              out << "    --> #{lib}\n"
            end

            out
          end

          log.error(log_key) do
            out = "The following binaries have unsafe or unmet dependencies:\n"

            bad_omnibus_bins.each do |bin|
              out << "    --> #{bin}\n"
            end

            out
          end

          if unresolved.length > 0
            log.error(log_key) do
              out = "The following requirements could not be resolved:\n"

              unresolved.each do |lib|
                out << "    --> #{lib}\n"
              end

              out
            end
          end

          if unreliable.length > 0
            log.error(log_key) do
              out =  "The following libraries cannot be guaranteed to be on "
              out << "target systems:\n"

              unreliable.each do |lib|
                out << "    --> #{lib}\n"
              end

              out
            end
          end

          log.error(log_key) do
            out = "The precise failures were:\n"

            detail.each do |line|
              item, dependency, location, count = line.split("|")
              reason = location =~ /not found/ ? "Unresolved dependency" : "Unsafe dependency"

              out << "    --> #{item}\n"
              out << "    DEPENDS ON: #{dependency}\n"
              out << "      COUNT: #{count}\n"
              out << "      PROVIDED BY: #{location}\n"
              out << "      FAILED BECAUSE: #{reason}\n"
            end

            out
          end

          raise HealthCheckFailed
        end

        conflict_map = {}

        conflict_map = relocation_check if relocation_checkable?

        if conflict_map.keys.length > 0
          log.warn(log_key) { "Multiple dlls with overlapping images detected" }

          conflict_map.each do |lib_name, data|
            base = data[:base]
            size = data[:size]
            next_valid_base = data[:base] + data[:size]

            log.warn(log_key) do
              out =  "Overlapping dll detected:\n"
              out << "    #{lib_name} :\n"
              out << "    IMAGE BASE: #{hex}\n" % base
              out << "    IMAGE SIZE: #{hex} (#{size} bytes)\n" % size
              out << "    NEXT VALID BASE: #{hex}\n" % next_valid_base
              out << "    CONFLICTS:\n"

              data[:conflicts].each do |conflict_name|
                cbase = conflict_map[conflict_name][:base]
                csize = conflict_map[conflict_name][:size]
                out << "    - #{conflict_name} #{hex} + #{hex}\n" % [cbase, csize]
              end

              out
            end
          end

          # Don't raise an error yet. This is only bad for FIPS mode.
        end

        true
      end
    end

    # Ensure the method relocation_check is able to run
    #
    # @return [Boolean]
    #
    def relocation_checkable?
      return false unless windows?

      begin
        require "pedump"
        true
      rescue LoadError
        false
      end
    end

    # Check dll image location overlap/conflicts on windows.
    #
    # @return [Hash<String, Hash<Symbol, ...>>]
    #   library_name ->
    #     :base -> base address
    #     :size -> the total image size in bytes
    #     :conflicts -> array of library names that overlap
    #
    def relocation_check
      conflict_map = {}

      embedded_bin = "#{project.install_dir}/embedded/bin"
      Dir.glob("#{embedded_bin}/*.dll") do |lib_path|
        log.debug(log_key) { "Analyzing dependencies for #{lib_path}" }

        File.open(lib_path, "rb") do |f|
          dump = PEdump.new(lib_path)
          pe = dump.pe f

          # Don't scan dlls for a different architecture.
          next if windows_arch_i386? == pe.x64?

          lib_name = File.basename(lib_path)
          base = pe.ioh.ImageBase
          size = pe.ioh.SizeOfImage
          conflicts = []

          # This can be done more smartly but O(n^2) is just fine for n = small
          conflict_map.each do |candidate_name, details|
            unless details[:base] >= base + size ||
                details[:base] + details[:size] <= base
              details[:conflicts] << lib_name
              conflicts << candidate_name
            end
          end

          conflict_map[lib_name] = {
            base: base,
            size: size,
            conflicts: conflicts,
          }

          log.debug(log_key) { "Discovered #{lib_name} at #{hex} + #{hex}" % [ base, size ] }
        end
      end

      # Filter out non-conflicting entries.
      conflict_map.delete_if do |lib_name, details|
        details[:conflicts].empty?
      end
    end

    #
    # Run healthchecks against otool.
    #
    # @return [Hash<String, Hash<String, Hash<String, Int>>>]
    #   the bad libraries (library_name -> dependency_name -> satisfied_lib_path -> count)
    #
    def health_check_otool
      current_library = nil
      bad_libs = {}

      read_shared_libs("find #{project.install_dir}/ -type f | egrep '\.(dylib|bundle)$' | xargs otool -L") do |line|
        case line
        when /^(.+):$/
          current_library = Regexp.last_match[1]
        when /^\s+(.+) \(.+\)$/
          linked = Regexp.last_match[1]
          name = File.basename(linked)
          bad_libs = check_for_bad_library(bad_libs, current_library, name, linked)
        end
      end

      bad_libs
    end

    #
    # Run healthchecks against aix.
    #
    # @return [Hash<String, Hash<String, Hash<String, Int>>>]
    #   the bad libraries (library_name -> dependency_name -> satisfied_lib_path -> count)
    #
    def health_check_aix
      current_library = nil
      bad_libs = {}

      read_shared_libs("find #{project.install_dir}/ -type f | xargs file | grep \"RISC System\" | awk -F: '{print $1}' | xargs -n 1 ldd") do |line|
        case line
        when /^(.+) needs:$/
          current_library = Regexp.last_match[1]
          log.debug(log_key) { "Analyzing dependencies for #{current_library}" }
        when /^\s+(.+)$/
          name = Regexp.last_match[1]
          linked = Regexp.last_match[1]
          bad_libs = check_for_bad_library(bad_libs, current_library, name, linked)
        when /File is not an executable XCOFF file/ # ignore non-executable files
        else
          log.warn(log_key) { "Line did not match for #{current_library}\n#{line}" }
        end
      end

      bad_libs
    end

    #
    # Run healthchecks against ldd.
    #
    # @return [Hash<String, Hash<String, Hash<String, Int>>>]
    #   the bad libraries (library_name -> dependency_name -> satisfied_lib_path -> count)
    #
    def health_check_ldd
      regexp_ends = ".*(" + IGNORED_ENDINGS.map { |e| e.gsub(/\./, '\.') }.join("|") + ")$"
      regexp_patterns = IGNORED_PATTERNS.map { |e| ".*" + e.gsub(/\//, '\/') + ".*" }.join("|")
      regexp = regexp_ends + "|" + regexp_patterns

      current_library = nil
      bad_libs = {}

      read_shared_libs("find #{project.install_dir}/ -type f -regextype posix-extended ! -regex '#{regexp}' | xargs ldd") do |line|
        case line
        when /^(.+):$/
          current_library = Regexp.last_match[1]
          log.debug(log_key) { "Analyzing dependencies for #{current_library}" }
        when /^\s+(.+) \=\>\s+(.+)( \(.+\))?$/
          name = Regexp.last_match[1]
          linked = Regexp.last_match[2]
          bad_libs = check_for_bad_library(bad_libs, current_library, name, linked)
        when /^\s+(.+) \(.+\)$/
          next
        when /^\s+statically linked$/
          next
        when /^\s+libjvm.so/
          next
        when /^\s+libjava.so/
          next
        when /^\s+libmawt.so/
          next
        when /^\s+not a dynamic executable$/ # ignore non-executable files
        else
          log.warn(log_key) do
            "Line did not match for #{current_library}\n#{line}"
          end
        end
      end

      bad_libs
    end

    private

    #
    # This is the printf style format string to render a pointer/size_t on the
    # current platform.
    #
    # @return [String]
    #
    def hex
      windows_arch_i386? ? "0x%08x" : "0x%016x"
    end

    #
    # The list of whitelisted (ignored) files from the project and softwares.
    #
    # @return [Array<String, Regexp>]
    #
    def whitelist_files
      project.library.components.inject([]) do |array, component|
        array += component.whitelist_files
        array
      end
    end

    #
    # Execute the given command, yielding each line.
    #
    # @param [String] command
    #   the command to execute
    # @yield [String]
    #   each line
    #
    def read_shared_libs(command)
      cmd = shellout(command)
      cmd.stdout.each_line do |line|
        yield line
      end
    end

    #
    # Check the given path and library for "bad" libraries.
    #
    # @param [Hash<String, Hash<String, Hash<String, Int>>>]
    #   the bad libraries (library_name -> dependency_name -> satisfied_lib_path -> count)
    # @param [String]
    #   the library being analyzed
    # @param [String]
    #   dependency library name
    # @param [String]
    #   actual path of library satisfying the dependency
    #
    # @return the modified bad_library hash
    #
    def check_for_bad_library(bad_libs, current_library, name, linked)
      safe = nil

      whitelist_libs = case Ohai["platform"]
                       when "arch"
                         ARCH_WHITELIST_LIBS
                       when "mac_os_x"
                         MAC_WHITELIST_LIBS
                       when "solaris2"
                         SOLARIS_WHITELIST_LIBS
                       when "smartos"
                         SMARTOS_WHITELIST_LIBS
                       when "freebsd"
                         FREEBSD_WHITELIST_LIBS
                       when "aix"
                         AIX_WHITELIST_LIBS
                       else
                         WHITELIST_LIBS
                       end

      whitelist_libs.each do |reg|
        safe ||= true if reg.match(name)
      end

      whitelist_files.each do |reg|
        safe ||= true if reg.match(current_library)
      end

      log.debug(log_key) { "  --> Dependency: #{name}" }
      log.debug(log_key) { "  --> Provided by: #{linked}" }

      if !safe && linked !~ Regexp.new(project.install_dir)
        log.debug(log_key) { "    -> FAILED: #{current_library} has unsafe dependencies" }
        bad_libs[current_library] ||= {}
        bad_libs[current_library][name] ||= {}
        if bad_libs[current_library][name].key?(linked)
          bad_libs[current_library][name][linked] += 1
        else
          bad_libs[current_library][name][linked] = 1
        end
      else
        log.debug(log_key) { "    -> PASSED: #{name} is either whitelisted or safely provided." }
      end

      bad_libs
    end
  end
end