summaryrefslogtreecommitdiff
path: root/lib/chef/formatters/error_inspectors/cookbook_resolve_error_inspector.rb
blob: 4a6fa49fe7e8677fe33babc3cf3fe18ddb8a1fac (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
#--
# Author:: Daniel DeLeo (<dan@chef.io>)
# Copyright:: Copyright 2012-2016, Chef Software, Inc.
# License:: Apache License, Version 2.0
#
# 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_relative "api_error_formatting"

class Chef
  module Formatters
    module ErrorInspectors
      class CookbookResolveErrorInspector

        attr_reader :exception
        attr_reader :expanded_run_list

        include APIErrorFormatting

        def initialize(expanded_run_list, exception)
          @expanded_run_list = expanded_run_list
          @exception = exception
        end

        def add_explanation(error_description)
          case exception
          when Net::HTTPClientException, Net::HTTPFatalError
            humanize_http_exception(error_description)
          when EOFError
            describe_eof_error(error_description)
          when *NETWORK_ERROR_CLASSES
            describe_network_errors(error_description)
          else
            error_description.section("Unexpected Error:", "#{exception.class.name}: #{exception.message}")
          end
        end

        def humanize_http_exception(error_description)
          response = exception.response
          case response
          when Net::HTTPUnauthorized
            # TODO: this is where you'd see conflicts b/c of username/clientname stuff
            describe_401_error(error_description)
          when Net::HTTPForbidden
            # TODO: we're rescuing errors from Node.find_or_create
            # * could be no write on nodes container
            # * could be no read on the node
            error_description.section("Authorization Error", <<~E)
              This client is not authorized to read some of the information required to
              access its cookbooks (HTTP 403).

              To access its cookbooks, a client needs to be able to read its environment and
              all of the cookbooks in its expanded run list.
            E
            error_description.section("Expanded Run List:", expanded_run_list_ul)
            error_description.section("Server Response:", format_rest_error)
          when Net::HTTPPreconditionFailed
            describe_412_error(error_description)
          when Net::HTTPBadRequest
            describe_400_error(error_description)
          when Net::HTTPNotFound
          when Net::HTTPInternalServerError
            describe_500_error(error_description)
          when Net::HTTPBadGateway, Net::HTTPServiceUnavailable
            describe_503_error(error_description)
          when Net::HTTPNotAcceptable
            describe_406_error(error_description, response)
          else
            describe_http_error(error_description)
          end
        end

        def describe_412_error(error_description)
          explanation = ""
          error_reasons = extract_412_error_message

          # Prepare the error message if there is detailed information
          # about individual cookbooks.
          if !error_reasons.respond_to?(:key?)
            explanation << error_reasons.to_s
          else
            if error_reasons.key?("non_existent_cookbooks") && !Array(error_reasons["non_existent_cookbooks"]).empty?
              explanation << "The following cookbooks are required by the client but don't exist on the server:\n"
              Array(error_reasons["non_existent_cookbooks"]).each do |cookbook|
                explanation << "* #{cookbook}\n"
              end
              explanation << "\n"
            end
            if error_reasons.key?("cookbooks_with_no_versions") && !Array(error_reasons["cookbooks_with_no_versions"]).empty?
              explanation << "The following cookbooks exist on the server, but there is no version that meets\nthe version constraints in this environment:\n"
              Array(error_reasons["cookbooks_with_no_versions"]).each do |cookbook|
                explanation << "* #{cookbook}\n"
              end
              explanation << "\n"
            end
          end

          if !explanation.empty?
            error_description.section("Missing Cookbooks:", explanation)
          else
            # If we don't have any cookbook details print a more
            # generic error message.
            if error_reasons.respond_to?(:key?) && error_reasons["message"]
              explanation << "Error message: #{error_reasons["message"]}\n"
            end

            explanation << <<~EOM
              You might be able to resolve this issue with:
                1-) Removing cookbook versions that depend on deleted cookbooks.
                2-) Removing unused cookbook versions.
                3-) Pinning exact cookbook versions using environments.
            EOM
            error_description.section("Cookbook dependency resolution error:", explanation)
          end

          error_description.section("Expanded Run List:", expanded_run_list_ul)
        end

        def expanded_run_list_ul
          @expanded_run_list.map { |i| "* #{i}" }.join("\n")
        end

        # In my tests, the error from the server is double JSON encoded, but we
        # should not rely on this not getting fixed.
        #
        # Return *should* be a Hash like this:
        #   { "non_existent_cookbooks"     => ["nope"],
        #     "cookbooks_with_no_versions" => [],
        #     "message" => "Run list contains invalid items: no such cookbook nope."}
        def extract_412_error_message
          # Example:
          # "{\"error\":[\"{\\\"non_existent_cookbooks\\\":[\\\"nope\\\"],\\\"cookbooks_with_no_versions\\\":[],\\\"message\\\":\\\"Run list contains invalid items: no such cookbook nope.\\\"}\"]}"

          wrapped_error_message = attempt_json_parse(exception.response.body)
          unless wrapped_error_message.is_a?(Hash) && wrapped_error_message.key?("error")
            return wrapped_error_message.to_s
          end

          error_description = Array(wrapped_error_message["error"]).first
          if error_description.is_a?(Hash)
            return error_description
          end

          attempt_json_parse(error_description)
        end

        private

        def attempt_json_parse(maybe_json_string)
          Chef::JSONCompat.from_json(maybe_json_string)
        rescue Exception
          maybe_json_string
        end

      end
    end
  end
end