summaryrefslogtreecommitdiff
path: root/lib/chef/data_collector.rb
blob: 167b9f5d541e4ccdc5928e94ca05a9399a66b9ab (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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
#
# Author:: Adam Leff (<adamleff@chef.io>)
# Author:: Ryan Cragun (<ryan@chef.io>)
#
# Copyright:: Copyright 2012-2019, 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 "server_api"
require_relative "http/simple_json"
require_relative "event_dispatch/base"
require "set"
require_relative "data_collector/run_end_message"
require_relative "data_collector/run_start_message"
require_relative "data_collector/config_validation"
require_relative "data_collector/error_handlers"
require_relative "dist"

class Chef
  class DataCollector
    # The DataCollector is mode-agnostic reporting tool which can be used with
    # server-based and solo-based clients.  It can report to a file, to an
    # authenticated Chef Automate reporting endpoint, or to a user-supplied
    # webhook.  It sends two messages:  one at the start of the run and one
    # at the end of the run.  Most early failures in the actual Chef::Client itself
    # are reported, but parsing of the client.rb must have succeeded and some code
    # in Chef::Application could throw so early as to prevent reporting.  If
    # exceptions are thrown both run-start and run-end messages are still sent in
    # pairs.
    #
    class Reporter < EventDispatch::Base
      include Chef::DataCollector::ErrorHandlers

      # @return [Chef::RunList::RunListExpansion] the expanded run list
      attr_reader :expanded_run_list

      # @return [Chef::RunStatus] the run status
      attr_reader :run_status

      # @return [Chef::Node] the chef node
      attr_reader :node

      # @return [Set<Hash>] the acculumated list of deprecation warnings
      attr_reader :deprecations

      # @return [Chef::ActionCollection] the action collection object
      attr_reader :action_collection

      # @return [Chef::EventDispatch::Dispatcher] the event dispatcher
      attr_reader :events

      # @param events [Chef::EventDispatch::Dispatcher] the event dispatcher
      def initialize(events)
        @events = events
        @expanded_run_list       = {}
        @deprecations            = Set.new
      end

      # Hook to grab the run_status.  We also make the decision to run or not run here (our
      # config has been parsed so we should know if we need to run, we unregister if we do
      # not want to run).
      #
      # (see EventDispatch::Base#run_start)
      #
      def run_start(chef_version, run_status)
        events.unregister(self) unless should_be_enabled?
        @run_status = run_status
      end

      # Hook to grab the node object after it has been successfully loaded
      #
      # (see EventDispatch::Base#node_load_success)
      #
      def node_load_success(node)
        @node = node
      end

      # The expanded run list is stored for later use by the run_completed
      # event and message.
      #
      # (see EventDispatch::Base#run_list_expanded)
      #
      def run_list_expanded(run_list_expansion)
        @expanded_run_list = run_list_expansion
      end

      # Hook event to register with the action_collection if we are still enabled.
      #
      # This is also how we wire up to the action_collection since it passes itself as the argument.
      #
      # (see EventDispatch::Base#action_collection_registration)
      #
      def action_collection_registration(action_collection)
        @action_collection = action_collection
        action_collection.register(self)
      end

      # - Creates and writes our NodeUUID back to the node object
      # - Sanity checks the data collector
      # - Sends the run start message
      # - If the run_start message fails, this may disable the rest of data collection or fail hard
      #
      # (see EventDispatch::Base#run_started)
      #
      def run_started(run_status)
        Chef::DataCollector::ConfigValidation.validate_server_url!
        Chef::DataCollector::ConfigValidation.validate_output_locations!

        send_run_start
      end

      # Hook event to accumulating deprecation messages
      #
      # (see EventDispatch::Base#deprecation)
      #
      def deprecation(message, location = caller(2..2)[0])
        @deprecations << { message: message.message, url: message.url, location: message.location }
      end

      # Hook to send the run completion message with a status of success
      #
      # (see EventDispatch::Base#run_completed)
      #
      def run_completed(node)
        send_run_completion("success")
      end

      # Hook to send the run completion message with a status of failed
      #
      # (see EventDispatch::Base#run_failed)
      #
      def run_failed(exception)
        send_run_completion("failure")
      end

      private

      # Construct a http client for either the main data collector or for the http output_locations.
      #
      # Note that based on the token setting either the main data collector and all the http output_locations
      # are going to all require chef-server authentication or not.  There is no facility to mix-and-match on
      # a per-url basis.
      #
      # @param url [String] the string url to connect to
      # @returns [Chef::HTTP] the appropriate Chef::HTTP subclass instance to use
      #
      def setup_http_client(url)
        if Chef::Config[:data_collector][:token].nil?
          Chef::ServerAPI.new(url, validate_utf8: false)
        else
          Chef::HTTP::SimpleJSON.new(url, validate_utf8: false)
        end
      end

      # Handle POST'ing data to the data collector.  Note that this is a totally separate concern
      # from the array of URI's in the extra configured output_locations.
      #
      # On failure this will unregister the data collector (if there are no other configured output_locations)
      # and optionally will either silently continue or fail hard depending on configuration.
      #
      # @param message [Hash] message to send
      #
      def send_to_data_collector(message)
        return unless Chef::Config[:data_collector][:server_url]
        @http ||= setup_http_client(Chef::Config[:data_collector][:server_url])
        @http.post(nil, message, headers)
      rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET,
        Errno::ECONNREFUSED, EOFError, Net::HTTPBadResponse,
        Net::HTTPHeaderSyntaxError, Net::ProtocolError, OpenSSL::SSL::SSLError,
        Errno::EHOSTDOWN => e
        # Do not disable data collector reporter if additional output_locations have been specified
        events.unregister(self) unless Chef::Config[:data_collector][:output_locations]

        code = e&.response&.code&.to_s || "Exception Code Empty"

        msg = "Error while reporting run start to Data Collector. URL: #{Chef::Config[:data_collector][:server_url]} Exception: #{code} -- #{e.message} "

        if Chef::Config[:data_collector][:raise_on_failure]
          Chef::Log.error(msg)
          raise
        else
          # Make the message non-scary for folks who don't have automate:
          msg << " (This is normal if you do not have Chef Automate)"
          Chef::Log.info(msg)
        end
      end

      # Process sending the configured message to all the extra output locations.
      #
      # @param message [Hash] message to send
      #
      def send_to_output_locations(message)
        return unless Chef::Config[:data_collector][:output_locations]

        Chef::Config[:data_collector][:output_locations].each do |type, locations|
          locations.each do |location|
            send_to_file_location(location, message) if type == :files
            send_to_http_location(location, message) if type == :urls
          end
        end
      end

      # Sends a single message to a file, rendered as JSON.
      #
      # @param file_name [String] the file to write to
      # @param message [Hash] the message to render as JSON
      #
      def send_to_file_location(file_name, message)
        File.open(file_name, "a") do |fh|
          fh.puts Chef::JSONCompat.to_json(message, validate_utf8: false)
        end
      end

      # Sends a single message to a http uri, rendered as JSON.  Maintains a cache of Chef::HTTP
      # objects to use on subsequent requests.
      #
      # @param http_url [String] the configured http uri string endpoint to send to
      # @param message [Hash] the message to render as JSON
      #
      def send_to_http_location(http_url, message)
        @http_output_locations_clients[http_url] ||= setup_http_client(http_url)
        @http_output_locations_clients[http_url].post(nil, message, headers)
      rescue
        # FIXME: we do all kinds of complexity to deal with errors in send_to_data_collector and we just don't care here, which feels like
        # like poor behavior on several different levels, at least its a warn now... (I don't quite understand why it was written this way)
        Chef::Log.warn("Data collector failed to send to URL location #{http_url}. Please check your configured data_collector.output_locations")
      end

      # @return [Boolean] if we've sent a run_start message yet
      def sent_run_start?
        !!@sent_run_start
      end

      # Send the run start message to the configured server or output locations
      #
      def send_run_start
        message = Chef::DataCollector::RunStartMessage.construct_message(self)
        send_to_data_collector(message)
        send_to_output_locations(message)
        @sent_run_start = true
      end

      # Send the run completion message to the configured server or output locations
      #
      # @param status [String] Either "success" or "failed"
      #
      def send_run_completion(status)
        # this is necessary to send a run_start message when we fail before the run_started chef event.
        # we adhere to a contract that run_start + run_completion events happen in pairs.
        send_run_start unless sent_run_start?

        message = Chef::DataCollector::RunEndMessage.construct_message(self, status)
        send_to_data_collector(message)
        send_to_output_locations(message)
      end

      # @return [Hash] HTTP headers for the data collector endpoint
      def headers
        headers = { "Content-Type" => "application/json" }

        unless Chef::Config[:data_collector][:token].nil?
          headers["x-data-collector-token"] = Chef::Config[:data_collector][:token]
          headers["x-data-collector-auth"]  = "version=1.0"
        end

        headers
      end

      # Main logic controlling the data collector being enabled or disabled:
      #
      # * disabled in why-run mode
      # * disabled when `Chef::Config[:data_collector][:mode]` excludes the solo-vs-client mode
      # * disabled if there is no server_url or no output_locations to log to
      # * enabled if there is a configured output_location even without a token
      # * disabled in solo mode if the user did not configure the auth token
      #
      # @return [Boolean] true if the data collector should be enabled
      #
      def should_be_enabled?
        running_mode = ( Chef::Config[:solo_legacy_mode] || Chef::Config[:local_mode] ) ? :solo : :client
        want_mode = Chef::Config[:data_collector][:mode]

        case
        when Chef::Config[:why_run]
          Chef::Log.trace("data collector is disabled for why run mode")
          return false
        when (want_mode != :both) && running_mode != want_mode
          Chef::Log.trace("data collector is configured to only run in #{Chef::Config[:data_collector][:mode]} modes, disabling it")
          return false
        when !(Chef::Config[:data_collector][:server_url] || Chef::Config[:data_collector][:output_locations])
          Chef::Log.trace("Neither data collector URL or output locations have been configured, disabling data collector")
          return false
        when running_mode == :client && Chef::Config[:data_collector][:token]
          Chef::Log.warn("Data collector token authentication is not recommended for client-server mode. " \
                         "Please upgrade #{Chef::Dist::SERVER_PRODUCT} to 12.11 or later and remove the token from your config file " \
                         "to use key based authentication instead")
          return true
        when Chef::Config[:data_collector][:output_locations] && Chef::Config[:data_collector][:output_locations][:files] && !Chef::Config[:data_collector][:output_locations][:files].empty?
          # we can run fine to a file without a token, even in solo mode.
          return true
        when running_mode == :solo && !Chef::Config[:data_collector][:token]
          # we are in solo mode and are not logging to a file, so must have a token
          Chef::Log.trace("Data collector token must be configured to use Chef Automate data collector with #{Chef::Dist::PRODUCT} Solo")
          return false
        else
          return true
        end
      end

    end
  end
end