kennel 1.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cache that reads everything from a single file
4
+ # to avoid doing multiple disk reads while iterating all definitions
5
+ # it also replaces updated keys and has an overall expiry to not keep deleted things forever
6
+ module Kennel
7
+ class FileCache
8
+ def initialize(file, cache_version)
9
+ @file = file
10
+ @cache_version = cache_version
11
+ @now = Time.now.to_i
12
+ @expires = @now + (30 * 24 * 60 * 60) # 1 month
13
+ end
14
+
15
+ def open
16
+ load_data
17
+ expire_old_data
18
+ yield self
19
+ ensure
20
+ persist
21
+ end
22
+
23
+ def fetch(key, key_version)
24
+ old_value, old_version = @data[key]
25
+ return old_value if old_version == [key_version, @cache_version]
26
+
27
+ new_value = yield
28
+ @data[key] = [new_value, [key_version, @cache_version], @expires]
29
+ new_value
30
+ end
31
+
32
+ private
33
+
34
+ def load_data
35
+ @data =
36
+ begin
37
+ Marshal.load(File.read(@file)) # rubocop:disable Security/MarshalLoad
38
+ rescue StandardError
39
+ {}
40
+ end
41
+ end
42
+
43
+ def persist
44
+ dir = File.dirname(@file)
45
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
46
+ File.write(@file, Marshal.dump(@data))
47
+ end
48
+
49
+ def expire_old_data
50
+ @data.reject! { |_, (_, _, ex)| ex < @now }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+ # Not used in here, but in our templated repo ... so keeping it around for now.
3
+ module Kennel
4
+ class GithubReporter
5
+ MAX_COMMENT_SIZE = 65536
6
+ TRUNCATED_MSG = "\n```\n... (truncated)" # finish the code block so it look nice
7
+
8
+ class << self
9
+ def report(token, &block)
10
+ return yield unless token
11
+ new(token, Utils.capture_sh("git rev-parse HEAD").strip).report(&block)
12
+ end
13
+ end
14
+
15
+ def initialize(token, git_sha)
16
+ @token = token
17
+ @git_sha = git_sha
18
+ origin = ENV["PROJECT_REPOSITORY"] || Utils.capture_sh("git remote -v").split("\n").first
19
+ @repo_part = origin[%r{github\.com[:/](.+?)(\.git|$)}, 1] || raise("no origin found")
20
+ end
21
+
22
+ def report(&block)
23
+ output = Utils.strip_shell_control(Utils.tee_output(&block).strip)
24
+ rescue StandardError
25
+ output = "Error:\n#{$ERROR_INFO.message}"
26
+ raise
27
+ ensure
28
+ comment "```\n#{output || "Error"}\n```"
29
+ end
30
+
31
+ # https://developer.github.com/v3/repos/comments/#create-a-commit-comment
32
+ def comment(body)
33
+ # truncate to maximum allowed comment size for github to avoid 422
34
+ if body.bytesize > MAX_COMMENT_SIZE
35
+ body = body.byteslice(0, MAX_COMMENT_SIZE - TRUNCATED_MSG.bytesize) + TRUNCATED_MSG
36
+ end
37
+
38
+ post "commits/#{@git_sha}/comments", body: body
39
+ end
40
+
41
+ private
42
+
43
+ def post(path, data)
44
+ url = "https://api.github.com/repos/#{@repo_part}/#{path}"
45
+ response = Faraday.post(url, data.to_json, authorization: "token #{@token}")
46
+ raise "failed to POST to github:\n#{url} -> #{response.status}\n#{response.body}" unless response.status == 201
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kennel
4
+ class Importer
5
+ TITLES = [:name, :title].freeze
6
+ SORT_ORDER = [*TITLES, :id, :kennel_id, :type, :tags, :query, *Syncer::TRACKING_FIELDS, :template_variables].freeze
7
+
8
+ def initialize(api)
9
+ @api = api
10
+ end
11
+
12
+ def import(resource, id)
13
+ if ["screen", "dash"].include?(resource)
14
+ raise ArgumentError, "resource 'screen' and 'dash' are deprecated, use 'dashboard'"
15
+ end
16
+
17
+ model =
18
+ begin
19
+ Kennel::Models.const_get(resource.capitalize)
20
+ rescue NameError
21
+ raise ArgumentError, "#{resource} is not supported"
22
+ end
23
+
24
+ data = @api.show(model.api_resource, id)
25
+ id = data.fetch(:id) # keep native value
26
+ model.normalize({}, data) # removes id
27
+ data[:id] = id
28
+
29
+ title_field = TITLES.detect { |f| data[f] }
30
+ title = data.fetch(title_field)
31
+ title.tr!(Kennel::Models::Record::LOCK, "") # avoid double lock icon
32
+
33
+ # calculate or reuse kennel_id
34
+ # TODO: this is copy-pasted from syncer, need to find a nice way to reuse it
35
+ tracking_field = Syncer::TRACKING_FIELDS.detect { |f| data[f] }
36
+ data[:kennel_id] =
37
+ if tracking_field && data[tracking_field].sub!(/\n?-- Managed by kennel (\S+:\S+).*/, "")
38
+ $1.split(":").last
39
+ else
40
+ Kennel::Utils.parameterize(title)
41
+ end
42
+
43
+ case resource
44
+ when "monitor"
45
+ # flatten monitor options so they are all on the base
46
+ data.merge!(data.delete(:options))
47
+ data.merge!(data.delete(:thresholds) || {})
48
+ [:notify_no_data, :notify_audit].each { |k| data.delete(k) if data[k] } # monitor uses true by default
49
+ data = data.slice(*model.instance_methods)
50
+
51
+ # make query use critical method if it matches
52
+ critical = data[:critical]
53
+ query = data[:query]
54
+ if query && critical
55
+ query.sub!(/([><=]) (#{Regexp.escape(critical.to_f.to_s)}|#{Regexp.escape(critical.to_i.to_s)})$/, "\\1 \#{critical}")
56
+ end
57
+
58
+ data[:type] = "query alert" if data[:type] == "metric alert"
59
+ when "dashboard"
60
+ widgets = data[:widgets]&.flat_map { |widget| widget.dig(:definition, :widgets) || [widget] }
61
+ widgets&.each { |widget| dry_up_query!(widget) }
62
+ end
63
+
64
+ # simplify template_variables to array of string when possible
65
+ if vars = data[:template_variables]
66
+ vars.map! { |v| v[:default] == "*" && v[:prefix] == v[:name] ? v[:name] : v }
67
+ end
68
+
69
+ pretty = pretty_print(data).lstrip.gsub("\\#", "#")
70
+ <<~RUBY
71
+ #{model.name}.new(
72
+ self,
73
+ #{pretty}
74
+ )
75
+ RUBY
76
+ end
77
+
78
+ private
79
+
80
+ # reduce duplication in imports by using dry `q: :metadata` when possible
81
+ def dry_up_query!(widget)
82
+ (widget.dig(:definition, :requests) || []).each do |request|
83
+ next unless request.is_a?(Hash)
84
+ next unless metadata = request[:metadata]
85
+ next unless query = request[:q]&.dup
86
+ metadata.each do |m|
87
+ next unless exp = m[:expression]
88
+ query.sub!(exp, "")
89
+ end
90
+ request[:q] = :metadata if query.delete(", ") == ""
91
+ end
92
+ end
93
+
94
+ def pretty_print(hash)
95
+ sort_widgets hash
96
+
97
+ sort_hash(hash).map do |k, v|
98
+ pretty_value =
99
+ if v.is_a?(Hash) || (v.is_a?(Array) && !v.all? { |e| e.is_a?(String) })
100
+ # update answer here when changing https://stackoverflow.com/questions/8842546/best-way-to-pretty-print-a-hash
101
+ # (exclude last indent gsub)
102
+ pretty = JSON.pretty_generate(v)
103
+ .gsub(": null", ": nil")
104
+ .gsub(/(^\s*)"([a-zA-Z][a-zA-Z\d_]*)":/, "\\1\\2:") # "foo": 1 -> foo: 1
105
+ .gsub(/: \[\n\s+\]/, ": []") # empty arrays on a single line
106
+ .gsub(/^/, " ") # indent
107
+ .gsub('q: "metadata"', "q: :metadata") # bring symbols back
108
+
109
+ "\n#{pretty}\n "
110
+ elsif k == :message
111
+ "\n <<~TEXT\n#{v.each_line.map { |l| l.strip.empty? ? "\n" : " #{l}" }.join}\n \#{super()}\n TEXT\n "
112
+ elsif k == :tags
113
+ " super() + #{v.inspect} "
114
+ else
115
+ " #{v.inspect} "
116
+ end
117
+ " #{k}: -> {#{pretty_value}}"
118
+ end.join(",\n")
119
+ end
120
+
121
+ # sort dashboard widgets + nesting
122
+ def sort_widgets(outer)
123
+ outer[:widgets]&.each do |widgets|
124
+ definition = widgets[:definition]
125
+ definition.replace sort_hash(definition)
126
+ sort_widgets definition
127
+ end
128
+ end
129
+
130
+ # important to the front and rest deterministic
131
+ def sort_hash(hash)
132
+ Hash[hash.sort_by { |k, _| [SORT_ORDER.index(k) || 999, k] }]
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ require "hashdiff"
3
+
4
+ module Kennel
5
+ module Models
6
+ class Base
7
+ extend SubclassTracking
8
+ include SettingsAsMethods
9
+
10
+ SETTING_OVERRIDABLE_METHODS = [:name, :kennel_id].freeze
11
+
12
+ def kennel_id
13
+ name = self.class.name
14
+ if name.start_with?("Kennel::")
15
+ raise_with_location ArgumentError, "Set :kennel_id"
16
+ end
17
+ @kennel_id ||= Utils.snake_case name
18
+ end
19
+
20
+ def name
21
+ self.class.name
22
+ end
23
+
24
+ def to_json # rubocop:disable Lint/ToJSON
25
+ raise NotImplementedError, "Use as_json"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+ module Kennel
3
+ module Models
4
+ class Dashboard < Record
5
+ include TemplateVariables
6
+ include OptionalValidations
7
+
8
+ API_LIST_INCOMPLETE = true
9
+ DASHBOARD_DEFAULTS = { template_variables: [] }.freeze
10
+ READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [
11
+ :author_handle, :author_name, :modified_at, :url, :is_read_only, :notify_list
12
+ ]
13
+ REQUEST_DEFAULTS = {
14
+ style: { line_width: "normal", palette: "dog_classic", line_type: "solid" }
15
+ }.freeze
16
+ WIDGET_DEFAULTS = {
17
+ "timeseries" => { show_legend: false, legend_size: "0" },
18
+ "note" => { background_color: "white", font_size: "14", show_tick: false, tick_edge: "left", tick_pos: "50%", text_align: "left" }
19
+ }.freeze
20
+ SUPPORTED_DEFINITION_OPTIONS = [:events, :markers, :precision].freeze
21
+
22
+ DEFAULTS = {
23
+ template_variable_presets: nil
24
+ }.freeze
25
+
26
+ settings :title, :description, :definitions, :widgets, :layout_type, :template_variable_presets
27
+
28
+ defaults(
29
+ description: -> { "" },
30
+ definitions: -> { [] },
31
+ widgets: -> { [] },
32
+ template_variable_presets: -> { DEFAULTS.fetch(:template_variable_presets) },
33
+ id: -> { nil }
34
+ )
35
+
36
+ class << self
37
+ def api_resource
38
+ "dashboard"
39
+ end
40
+
41
+ def normalize(expected, actual)
42
+ super
43
+
44
+ ignore_default(expected, actual, DEFAULTS)
45
+
46
+ widgets_pairs(expected, actual).each do |pair|
47
+ # conditional_formats ordering is randomly changed by datadog, compare a stable ordering
48
+ pair.each do |widgets|
49
+ widgets.each do |widget|
50
+ if formats = widget.dig(:definition, :conditional_formats)
51
+ widget[:definition][:conditional_formats] = formats.sort_by(&:hash)
52
+ end
53
+ end
54
+ end
55
+
56
+ ignore_widget_defaults pair
57
+
58
+ ignore_request_defaults(*pair)
59
+
60
+ # ids are kinda random so we always discard them
61
+ pair.each { |widgets| widgets.each { |w| w.delete(:id) } }
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def ignore_widget_defaults(pair)
68
+ pair.map(&:size).max.times do |i|
69
+ types = pair.map { |w| w.dig(i, :definition, :type) }.uniq
70
+ next unless types.size == 1
71
+ next unless defaults = WIDGET_DEFAULTS[types.first]
72
+ ignore_defaults(pair[0], pair[1], defaults, nesting: :definition)
73
+ end
74
+ end
75
+
76
+ # discard styles/conditional_formats/aggregator if nothing would change when we applied (both are default or nil)
77
+ def ignore_request_defaults(expected, actual)
78
+ [expected.size, actual.size].max.times do |i|
79
+ a_r = actual.dig(i, :definition, :requests) || []
80
+ e_r = expected.dig(i, :definition, :requests) || []
81
+ ignore_defaults e_r, a_r, REQUEST_DEFAULTS
82
+ end
83
+ end
84
+
85
+ def ignore_defaults(expected, actual, defaults, nesting: nil)
86
+ [expected.size, actual.size].max.times do |i|
87
+ e = expected.dig(i, *nesting) || {}
88
+ a = actual.dig(i, *nesting) || {}
89
+ ignore_default(e, a, defaults)
90
+ end
91
+ end
92
+
93
+ # expand nested widgets into expected/actual pairs for default resolution
94
+ # [a, e] -> [[a-w, e-w], [a-w1-w1, e-w1-w1], ...]
95
+ def widgets_pairs(*pair)
96
+ result = [pair.map { |d| d[:widgets] || [] }]
97
+ slots = result[0].map(&:size).max
98
+ slots.times do |i|
99
+ nested = pair.map { |d| d.dig(:widgets, i, :definition, :widgets) || [] }
100
+ result << nested if nested.any?(&:any?)
101
+ end
102
+ result
103
+ end
104
+ end
105
+
106
+ def as_json
107
+ return @json if @json
108
+ all_widgets = render_definitions + widgets
109
+ expand_q all_widgets
110
+
111
+ @json = {
112
+ layout_type: layout_type,
113
+ title: "#{title}#{LOCK}",
114
+ description: description,
115
+ template_variables: render_template_variables,
116
+ template_variable_presets: template_variable_presets,
117
+ widgets: all_widgets
118
+ }
119
+
120
+ @json[:id] = id if id
121
+
122
+ validate_json(@json) if validate
123
+
124
+ @json
125
+ end
126
+
127
+ def url(id)
128
+ Utils.path_to_url "/dashboard/#{id}"
129
+ end
130
+
131
+ def self.parse_url(url)
132
+ url[/\/dashboard\/([a-z\d-]+)/, 1]
133
+ end
134
+
135
+ def resolve_linked_tracking_ids!(id_map, **args)
136
+ widgets = as_json[:widgets].flat_map { |w| [w, *w.dig(:definition, :widgets) || []] }
137
+ widgets.each do |widget|
138
+ next unless definition = widget[:definition]
139
+ case definition[:type]
140
+ when "uptime"
141
+ if ids = definition[:monitor_ids]
142
+ definition[:monitor_ids] = ids.map do |id|
143
+ tracking_id?(id) ? resolve_link(id, :monitor, id_map, **args) : id
144
+ end
145
+ end
146
+ when "alert_graph"
147
+ if (id = definition[:alert_id]) && tracking_id?(id)
148
+ definition[:alert_id] = resolve_link(id, :monitor, id_map, **args).to_s
149
+ end
150
+ when "slo"
151
+ if (id = definition[:slo_id]) && tracking_id?(id)
152
+ definition[:slo_id] = resolve_link(id, :slo, id_map, **args).to_s
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def tracking_id?(id)
161
+ id.is_a?(String) && id.include?(":")
162
+ end
163
+
164
+ # creates queries from metadata to avoid having to keep q and expression in sync
165
+ #
166
+ # {q: :metadata, metadata: [{expression: "sum:bar", alias_name: "foo"}, ...], }
167
+ # -> {q: "sum:bar, ...", metadata: ..., }
168
+ def expand_q(widgets)
169
+ widgets = widgets.flat_map { |w| w.dig(:definition, :widgets) || w } # expand groups
170
+ widgets.each do |w|
171
+ w.dig(:definition, :requests)&.each do |request|
172
+ next unless request.is_a?(Hash) && request[:q] == :metadata
173
+ request[:q] = request.fetch(:metadata).map { |m| m.fetch(:expression) }.join(", ")
174
+ end
175
+ end
176
+ end
177
+
178
+ def validate_json(data)
179
+ super
180
+
181
+ validate_template_variables data, :widgets
182
+
183
+ # Avoid diff from datadog presets sorting.
184
+ presets = data[:template_variable_presets]
185
+ invalid! "template_variable_presets must be sorted by name" if presets && presets != presets.sort_by { |p| p[:name] }
186
+ end
187
+
188
+ def render_definitions
189
+ definitions.map do |title, type, display_type, queries, options = {}, ignored = nil|
190
+ # validate inputs
191
+ if ignored || (!title || !type || !queries || !options.is_a?(Hash))
192
+ raise ArgumentError, "Expected exactly 5 arguments for each definition (title, type, display_type, queries, options)"
193
+ end
194
+ if (SUPPORTED_DEFINITION_OPTIONS | options.keys) != SUPPORTED_DEFINITION_OPTIONS
195
+ raise ArgumentError, "Supported options are: #{SUPPORTED_DEFINITION_OPTIONS.map(&:inspect).join(", ")}"
196
+ end
197
+
198
+ # build definition
199
+ requests = Array(queries).map do |q|
200
+ request = { q: q }
201
+ request[:display_type] = display_type if display_type
202
+ request
203
+ end
204
+ { definition: { title: title, type: type, requests: requests, **options } }
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end