kennel 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/kennel.rb +70 -0
- data/lib/kennel/api.rb +43 -0
- data/lib/kennel/file_cache.rb +35 -0
- data/lib/kennel/github_reporter.rb +30 -0
- data/lib/kennel/models/base.rb +94 -0
- data/lib/kennel/models/dash.rb +86 -0
- data/lib/kennel/models/monitor.rb +159 -0
- data/lib/kennel/models/project.rb +18 -0
- data/lib/kennel/models/team.rb +9 -0
- data/lib/kennel/progress.rb +34 -0
- data/lib/kennel/subclass_tracking.rb +19 -0
- data/lib/kennel/syncer.rb +176 -0
- data/lib/kennel/utils.rb +58 -0
- data/lib/kennel/version.rb +4 -0
- metadata +86 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 9b0f8c0885f03a37f8cd2c60d8aadb1596953267
|
4
|
+
data.tar.gz: 91428cc5840c0530f25103c5d94569badbd5c832
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c8399a1eaa4f9f59e16e60c781f20bf9d682f49df8ba43d37bd5d103125922d97cb6371aa583212ac498759ea331084cd26371bb951d4a955244a8779862bcac
|
7
|
+
data.tar.gz: bfa9c9132785e9a01066cfc1c2c9fcfec66a41648636f0551bfd3f8ec94e0036a3c4b5883fcc227973b5c1f89d5d100c12d78597c51ad4f1808189d290e927bf
|
data/lib/kennel.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "faraday"
|
3
|
+
require "json"
|
4
|
+
require "English"
|
5
|
+
|
6
|
+
require "kennel/utils"
|
7
|
+
require "kennel/progress"
|
8
|
+
require "kennel/syncer"
|
9
|
+
require "kennel/api"
|
10
|
+
require "kennel/github_reporter"
|
11
|
+
require "kennel/subclass_tracking"
|
12
|
+
require "kennel/file_cache"
|
13
|
+
|
14
|
+
require "kennel/models/base"
|
15
|
+
require "kennel/models/monitor"
|
16
|
+
require "kennel/models/dash"
|
17
|
+
require "kennel/models/project"
|
18
|
+
require "kennel/models/team"
|
19
|
+
|
20
|
+
module Kennel
|
21
|
+
class << self
|
22
|
+
def generate
|
23
|
+
FileUtils.rm_rf("generated")
|
24
|
+
generated.each do |part|
|
25
|
+
path = "generated/#{part.tracking_id.sub(":", "/")}.json"
|
26
|
+
FileUtils.mkdir_p(File.dirname(path))
|
27
|
+
File.write(path, JSON.pretty_generate(part.as_json) << "\n")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def plan
|
32
|
+
syncer.plan
|
33
|
+
end
|
34
|
+
|
35
|
+
def update
|
36
|
+
syncer.plan
|
37
|
+
syncer.update if syncer.confirm
|
38
|
+
end
|
39
|
+
|
40
|
+
def report_plan_to_github
|
41
|
+
reporter = GithubReporter.new(ENV.fetch("GITHUB_TOKEN"))
|
42
|
+
reporter.report { plan }
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def syncer
|
48
|
+
@syncer ||= Syncer.new(api, generated)
|
49
|
+
end
|
50
|
+
|
51
|
+
def api
|
52
|
+
@api ||= Api.new(ENV.fetch("DATADOG_APP_KEY"), ENV.fetch("DATADOG_API_KEY"))
|
53
|
+
end
|
54
|
+
|
55
|
+
def generated
|
56
|
+
@generated ||= begin
|
57
|
+
Progress.progress "Generating" do
|
58
|
+
load_all
|
59
|
+
Models::Project.recursive_subclasses.flat_map do |project_class|
|
60
|
+
project_class.new.parts
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def load_all
|
67
|
+
Dir["{parts,teams,projects}/**/*.rb"].each { |f| require "./#{f}" }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/kennel/api.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
class Api
|
4
|
+
def initialize(app_key, api_key)
|
5
|
+
@app_key = app_key
|
6
|
+
@api_key = api_key
|
7
|
+
@client = Faraday.new(url: "https://app.datadoghq.com")
|
8
|
+
end
|
9
|
+
|
10
|
+
def show(api_resource, id)
|
11
|
+
request :get, "/api/v1/#{api_resource}/#{id}"
|
12
|
+
end
|
13
|
+
|
14
|
+
def list(api_resource, params)
|
15
|
+
request :get, "/api/v1/#{api_resource}", params: params
|
16
|
+
end
|
17
|
+
|
18
|
+
def create(api_resource, attributes)
|
19
|
+
request :post, "/api/v1/#{api_resource}", body: attributes
|
20
|
+
end
|
21
|
+
|
22
|
+
def update(api_resource, id, attributes)
|
23
|
+
request :put, "/api/v1/#{api_resource}/#{id}", body: attributes
|
24
|
+
end
|
25
|
+
|
26
|
+
def delete(api_resource, id)
|
27
|
+
request :delete, "/api/v1/#{api_resource}/#{id}"
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def request(method, path, body: nil, params: {})
|
33
|
+
params = params.merge(application_key: @app_key, api_key: @api_key)
|
34
|
+
query = Faraday::FlatParamsEncoder.encode(params)
|
35
|
+
response = @client.send(method, "#{path}?#{query}") do |request|
|
36
|
+
request.body = JSON.generate(body) if body
|
37
|
+
request.headers["Content-type"] = "application/json"
|
38
|
+
end
|
39
|
+
raise "Error #{method} #{path} -> #{response.status}:\n#{response.body}" unless response.success?
|
40
|
+
JSON.parse(response.body, symbolize_names: true)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# cache that reads everything from a single file
|
4
|
+
# to avoid doing multiple disk reads while interating 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)
|
9
|
+
@file = file
|
10
|
+
@data =
|
11
|
+
begin
|
12
|
+
Marshal.load(File.read(@file)) # rubocop:disable Security/MarshalLoad
|
13
|
+
rescue StandardError
|
14
|
+
{}
|
15
|
+
end
|
16
|
+
@now = Time.now.to_i
|
17
|
+
@expires = @now + (30 * 24 * 60 * 60) # 1 month
|
18
|
+
@data.reject! { |_, (_, _, ex)| ex < @now } # expire old data
|
19
|
+
end
|
20
|
+
|
21
|
+
def fetch(key, version)
|
22
|
+
old_value, old_version = @data[key]
|
23
|
+
return old_value if old_version == version
|
24
|
+
|
25
|
+
new_value = yield
|
26
|
+
@data[key] = [new_value, version, @expires]
|
27
|
+
new_value
|
28
|
+
end
|
29
|
+
|
30
|
+
def persist
|
31
|
+
FileUtils.mkdir_p(File.dirname(@file))
|
32
|
+
File.write(@file, Marshal.dump(@data))
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
class GithubReporter
|
4
|
+
def initialize(token)
|
5
|
+
@token = token
|
6
|
+
@git_sha = Utils.capture_sh("git rev-parse HEAD").strip
|
7
|
+
origin = ENV["PROJECT_REPOSITORY"] || Utils.capture_sh("git remote -v").split("\n").first
|
8
|
+
@repo_part = origin[%r{github\.com[:/](.+?)(\.git|$)}, 1] || raise("no origin found")
|
9
|
+
end
|
10
|
+
|
11
|
+
def report
|
12
|
+
output = Utils.strip_shell_control(Utils.capture_stdout { yield }.strip)
|
13
|
+
ensure
|
14
|
+
comment "```\n#{output || "Error"}\n```"
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
# https://developer.github.com/v3/repos/comments/#create-a-commit-comment
|
20
|
+
def comment(body)
|
21
|
+
post "commits/#{@git_sha}/comments", body: body
|
22
|
+
end
|
23
|
+
|
24
|
+
def post(path, data)
|
25
|
+
url = "https://api.github.com/repos/#{@repo_part}/#{path}"
|
26
|
+
response = Faraday.post(url, data.to_json, authorization: "token #{@token}")
|
27
|
+
raise "failed to POST to github:\n#{url} -> #{response.status}\n#{response.body}" unless response.status == 201
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "hashdiff"
|
3
|
+
|
4
|
+
module Kennel
|
5
|
+
module Models
|
6
|
+
class Base
|
7
|
+
LOCK = "\u{1F512}"
|
8
|
+
READONLY_ATTRIBUTES = [
|
9
|
+
:deleted, :matching_downtimes, :id, :created, :created_at, :creator, :org_id, :modified,
|
10
|
+
:overall_state_modified, :overall_state, :api_resource
|
11
|
+
].freeze
|
12
|
+
|
13
|
+
class << self
|
14
|
+
include SubclassTracking
|
15
|
+
|
16
|
+
def settings(*names)
|
17
|
+
duplicates = (@set & names)
|
18
|
+
if duplicates.any?
|
19
|
+
raise ArgumentError, "Settings #{duplicates.map(&:inspect).join(", ")} are already defined"
|
20
|
+
end
|
21
|
+
|
22
|
+
@set.concat names
|
23
|
+
names.each do |name|
|
24
|
+
next if method_defined?(name)
|
25
|
+
define_method name do
|
26
|
+
raise ArgumentError, "Trying to call #{name} for #{self.class} but it was never set or passed as option"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def defaults(options)
|
32
|
+
options.each do |name, block|
|
33
|
+
validate_setting_exists name
|
34
|
+
define_method name, &block
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def inherited(child)
|
39
|
+
super
|
40
|
+
child.instance_variable_set(:@set, (@set || []).dup)
|
41
|
+
end
|
42
|
+
|
43
|
+
def validate_setting_exists(name)
|
44
|
+
return if !@set || @set.include?(name)
|
45
|
+
supported = @set.map(&:inspect)
|
46
|
+
raise ArgumentError, "Unsupported setting #{name.inspect}, supported settings are #{supported.join(", ")}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def initialize(options = {})
|
51
|
+
validate_options(options)
|
52
|
+
|
53
|
+
options.each do |name, block|
|
54
|
+
self.class.validate_setting_exists name
|
55
|
+
define_singleton_method name, &block
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def kennel_id
|
60
|
+
@kennel_id ||= Utils.snake_case self.class.name
|
61
|
+
end
|
62
|
+
|
63
|
+
def name
|
64
|
+
self.class.name
|
65
|
+
end
|
66
|
+
|
67
|
+
def diff(actual)
|
68
|
+
expected = as_json
|
69
|
+
expected.delete(:id)
|
70
|
+
|
71
|
+
READONLY_ATTRIBUTES.each { |k| actual.delete k }
|
72
|
+
|
73
|
+
diff = HashDiff.diff(actual, expected, use_lcs: false)
|
74
|
+
diff if diff.any?
|
75
|
+
end
|
76
|
+
|
77
|
+
def tracking_id
|
78
|
+
"#{project.kennel_id}:#{kennel_id}"
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def validate_options(options)
|
84
|
+
unless options.is_a?(Hash)
|
85
|
+
raise ArgumentError, "Expected #{self.class.name}.new options to be a Hash, got a #{options.class}"
|
86
|
+
end
|
87
|
+
options.each do |k, v|
|
88
|
+
next if v.class == Proc
|
89
|
+
raise ArgumentError, "Expected #{self.class.name}.new option :#{k} to be Proc, for example `#{k}: -> { 12 }`"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Models
|
4
|
+
class Dash < Base
|
5
|
+
settings :id, :title, :description, :graphs, :kennel_id, :graphs, :definitions, :template_variables
|
6
|
+
|
7
|
+
defaults(
|
8
|
+
id: -> { nil },
|
9
|
+
description: -> { "" },
|
10
|
+
definitions: -> { [] },
|
11
|
+
graphs: -> { [] },
|
12
|
+
template_variables: -> { [] }
|
13
|
+
)
|
14
|
+
|
15
|
+
attr_reader :project
|
16
|
+
|
17
|
+
def initialize(project, *args)
|
18
|
+
@project = project
|
19
|
+
super(*args)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.api_resource
|
23
|
+
"dash"
|
24
|
+
end
|
25
|
+
|
26
|
+
def as_json
|
27
|
+
return @json if @json
|
28
|
+
@json = {
|
29
|
+
id: id,
|
30
|
+
title: "#{title}#{LOCK}",
|
31
|
+
description: description,
|
32
|
+
read_only: false,
|
33
|
+
template_variables: render_template_variables,
|
34
|
+
graphs: render_graphs
|
35
|
+
}
|
36
|
+
|
37
|
+
validate_json(@json)
|
38
|
+
|
39
|
+
@json
|
40
|
+
end
|
41
|
+
|
42
|
+
def diff(actual)
|
43
|
+
actual.delete :resource
|
44
|
+
actual.delete :created_by
|
45
|
+
actual[:template_variables] ||= []
|
46
|
+
actual[:graphs].each do |g|
|
47
|
+
g[:definition].delete(:status)
|
48
|
+
g[:definition][:requests].each { |r| r.delete(:aggregator) }
|
49
|
+
end
|
50
|
+
super
|
51
|
+
end
|
52
|
+
|
53
|
+
def url(id)
|
54
|
+
Utils.path_to_url "/dash/#{id}"
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def validate_json(data)
|
60
|
+
variables = data.fetch(:template_variables).map { |v| "$#{v.fetch(:name)}" }
|
61
|
+
queries = data[:graphs].flat_map { |g| g[:definition][:requests].map { |r| r.fetch(:q) } }
|
62
|
+
bad = queries.grep_v(/(#{variables.map { |v| Regexp.escape(v) }.join("|")})\b/)
|
63
|
+
if bad.any?
|
64
|
+
raise "#{tracking_id} queries #{bad.join(", ")} must use the template variables #{variables.join(", ")}"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def render_template_variables
|
69
|
+
template_variables.map do |v|
|
70
|
+
v.is_a?(String) ? { default: "*", prefix: v, name: v } : v
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def render_graphs
|
75
|
+
all = definitions.map do |title, viz, type, query|
|
76
|
+
{ title: title, definition: { viz: viz, requests: [{ q: query, type: type }] } }
|
77
|
+
end + graphs
|
78
|
+
|
79
|
+
all.each do |g|
|
80
|
+
g[:definition][:requests].each { |r| r[:conditional_formats] ||= [] }
|
81
|
+
g[:definition][:autoscale] = true unless g[:definition].key?(:autoscale)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Models
|
4
|
+
class Monitor < Base
|
5
|
+
RENOTIFY_INTERVALS = [0, 10, 20, 30, 40, 50, 60, 90, 120, 180, 240, 300, 360, 720, 1440].freeze # minutes
|
6
|
+
QUERY_INTERVALS = ["1m", "5m", "10m", "15m", "30m", "1h", "2h", "4h", "24h"].freeze
|
7
|
+
METRIC_TYPES = ["query alert", "metric alert"].freeze
|
8
|
+
|
9
|
+
settings(
|
10
|
+
:query, :name, :message, :escalation_message, :critical, :kennel_id, :type, :renotify_interval, :warning,
|
11
|
+
:ok, :id, :no_data_timeframe, :notify_no_data, :tags, :multi
|
12
|
+
)
|
13
|
+
defaults(
|
14
|
+
message: -> { "" },
|
15
|
+
escalation_message: -> { "" },
|
16
|
+
type: -> { "metric alert" }, # TODO: can we auto-determine that ?
|
17
|
+
renotify_interval: -> { 120 },
|
18
|
+
warning: -> { nil },
|
19
|
+
ok: -> { nil },
|
20
|
+
id: -> { nil },
|
21
|
+
notify_no_data: -> { true },
|
22
|
+
no_data_timeframe: -> { notify_no_data ? 60 : nil },
|
23
|
+
tags: -> { [] },
|
24
|
+
multi: -> { !METRIC_TYPES.include?(type) || query.include?(" by ") }
|
25
|
+
)
|
26
|
+
|
27
|
+
attr_reader :project
|
28
|
+
|
29
|
+
def initialize(project, *args)
|
30
|
+
@project = project
|
31
|
+
super(*args)
|
32
|
+
end
|
33
|
+
|
34
|
+
def kennel_id
|
35
|
+
if self.class == Kennel::Models::Monitor
|
36
|
+
raise "Need to set :kennel_id when defining monitors from Kennel::Models::Monitor"
|
37
|
+
end
|
38
|
+
super
|
39
|
+
end
|
40
|
+
|
41
|
+
def as_json
|
42
|
+
return @as_json if @as_json
|
43
|
+
data = {
|
44
|
+
name: "#{name}#{LOCK}",
|
45
|
+
type: type,
|
46
|
+
query: query,
|
47
|
+
message: <<~TEXT.strip,
|
48
|
+
#{message}
|
49
|
+
|
50
|
+
@slack-#{project.team.slack}
|
51
|
+
TEXT
|
52
|
+
tags: @project.tags + tags,
|
53
|
+
multi: multi,
|
54
|
+
options: {
|
55
|
+
timeout_h: 0,
|
56
|
+
notify_no_data: notify_no_data,
|
57
|
+
no_data_timeframe: no_data_timeframe,
|
58
|
+
notify_audit: true,
|
59
|
+
require_full_window: true,
|
60
|
+
new_host_delay: 300,
|
61
|
+
include_tags: true,
|
62
|
+
escalation_message: Utils.presence(escalation_message.strip),
|
63
|
+
evaluation_delay: nil,
|
64
|
+
locked: false, # setting this to true prevents any edit and breaks updates when using replace workflow
|
65
|
+
renotify_interval: renotify_interval || 0,
|
66
|
+
thresholds: {
|
67
|
+
critical: critical
|
68
|
+
}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
|
72
|
+
options = data[:options]
|
73
|
+
thresholds = options[:thresholds]
|
74
|
+
|
75
|
+
data[:id] = id if id
|
76
|
+
|
77
|
+
# warning and ok are optional
|
78
|
+
thresholds[:warning] = warning if warning
|
79
|
+
thresholds[:ok] = ok if ok
|
80
|
+
|
81
|
+
# metric and query values are stored as float by datadog
|
82
|
+
if ["metric alert", "query alert"].include? data.fetch(:type)
|
83
|
+
thresholds.each { |k, v| thresholds[k] = Float(v) }
|
84
|
+
end
|
85
|
+
|
86
|
+
validate_json(data)
|
87
|
+
|
88
|
+
@as_json = data
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.api_resource
|
92
|
+
"monitor"
|
93
|
+
end
|
94
|
+
|
95
|
+
def url(id)
|
96
|
+
Utils.path_to_url "/monitors##{id}/edit"
|
97
|
+
end
|
98
|
+
|
99
|
+
def diff(actual)
|
100
|
+
options = actual.fetch(:options)
|
101
|
+
options.delete(:silenced) # we do not manage silenced, so ignore it when diffing
|
102
|
+
options[:escalation_message] ||= nil # unset field is not returned and would break the diff
|
103
|
+
|
104
|
+
# fields are not returned when set to true
|
105
|
+
if ["service check", "event alert"].include?(actual[:type])
|
106
|
+
options[:include_tags] = true unless options.key?(:include_tags)
|
107
|
+
options[:require_full_window] = true unless options.key?(:require_full_window)
|
108
|
+
end
|
109
|
+
|
110
|
+
# setting 0 results in thresholds not getting returned from the api
|
111
|
+
if actual[:type] == "event alert"
|
112
|
+
options[:thresholds] ||= { critical: 0 }
|
113
|
+
end
|
114
|
+
|
115
|
+
# fields are not returned when set to true
|
116
|
+
if actual[:type] == "service check"
|
117
|
+
options[:thresholds][:critical] ||= 1
|
118
|
+
options[:thresholds][:warning] ||= 1
|
119
|
+
options[:thresholds][:ok] ||= 1
|
120
|
+
end
|
121
|
+
|
122
|
+
# nil or "" are not returned from the api
|
123
|
+
options[:evaluation_delay] ||= nil
|
124
|
+
|
125
|
+
# datadog uses these types randomly
|
126
|
+
actual[:type] = type if METRIC_TYPES.include?(actual[:type])
|
127
|
+
|
128
|
+
super
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
def validate_json(data)
|
134
|
+
type = data.fetch(:type)
|
135
|
+
|
136
|
+
if type == "service check" && [ok, warning, critical].compact.map(&:class).uniq != [Integer]
|
137
|
+
raise "#{tracking_id} :ok, :warning and :critical must be integers for service check type"
|
138
|
+
end
|
139
|
+
|
140
|
+
if query_value = data.fetch(:query)[/\s*[<>]\s*(\d+(\.\d+)?)\s*$/, 1]
|
141
|
+
if Float(query_value) != Float(data.dig(:options, :thresholds, :critical))
|
142
|
+
raise "#{tracking_id} critical and value used in query must match"
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
unless RENOTIFY_INTERVALS.include? data.dig(:options, :renotify_interval)
|
147
|
+
raise "#{tracking_id} renotify_interval must be one of #{RENOTIFY_INTERVALS.join(", ")}"
|
148
|
+
end
|
149
|
+
|
150
|
+
if ["metric alert", "query alert"].include?(type)
|
151
|
+
interval = data.fetch(:query)[/\(last_(\S+?)\)/, 1]
|
152
|
+
unless QUERY_INTERVALS.include?(interval)
|
153
|
+
raise "#{tracking_id} query interval was #{interval}, but must be one of #{QUERY_INTERVALS.join(", ")}"
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Models
|
4
|
+
class Project < Base
|
5
|
+
settings :team, :parts, :tags
|
6
|
+
defaults(
|
7
|
+
tags: -> { ["service:#{kennel_id}"] }
|
8
|
+
)
|
9
|
+
|
10
|
+
def self.file_location
|
11
|
+
@file_location ||= begin
|
12
|
+
method_in_file = instance_methods(false).first
|
13
|
+
instance_method(method_in_file).source_location.first.sub("#{Bundler.root}/", "")
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "benchmark"
|
3
|
+
|
4
|
+
module Kennel
|
5
|
+
class Progress
|
6
|
+
# print what we are doing and a spinner until it is done ... then show how long it took
|
7
|
+
def self.progress(name)
|
8
|
+
print "#{name} ... "
|
9
|
+
|
10
|
+
animation = "-\\|/"
|
11
|
+
count = 0
|
12
|
+
stop = false
|
13
|
+
result = nil
|
14
|
+
|
15
|
+
spinner = Thread.new do
|
16
|
+
loop do
|
17
|
+
break if stop
|
18
|
+
print animation[count % animation.size]
|
19
|
+
sleep 0.2
|
20
|
+
print "\b"
|
21
|
+
count += 1
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
time = Benchmark.realtime { result = yield }
|
26
|
+
|
27
|
+
stop = true
|
28
|
+
spinner.join
|
29
|
+
print "#{time.round(2)}s\n"
|
30
|
+
|
31
|
+
result
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module SubclassTracking
|
4
|
+
def recursive_subclasses
|
5
|
+
subclasses + subclasses.flat_map(&:recursive_subclasses)
|
6
|
+
end
|
7
|
+
|
8
|
+
def subclasses
|
9
|
+
@subclasses ||= []
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def inherited(child)
|
15
|
+
super
|
16
|
+
subclasses << child
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
class Syncer
|
4
|
+
CACHE_FILE = "tmp/cache/details" # keep in sync with .travis.yml caching
|
5
|
+
|
6
|
+
def initialize(api, expected)
|
7
|
+
@api = api
|
8
|
+
@expected = expected
|
9
|
+
@expected.each { |e| add_tracking_id e }
|
10
|
+
calculate_diff
|
11
|
+
end
|
12
|
+
|
13
|
+
def plan
|
14
|
+
puts "Plan:"
|
15
|
+
if noop?
|
16
|
+
puts Utils.color(:green, "Nothing to do.")
|
17
|
+
else
|
18
|
+
print_plan "Create", @create, :green
|
19
|
+
print_plan "Update", @update, :yellow
|
20
|
+
print_plan "Delete", @delete, :red
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def confirm
|
25
|
+
!STDIN.tty? || Utils.ask("Execute Plan ?") unless noop?
|
26
|
+
end
|
27
|
+
|
28
|
+
def update
|
29
|
+
@create.each do |_, e|
|
30
|
+
reply = @api.create e.class.api_resource, e.as_json
|
31
|
+
puts "Created #{e.class.api_resource} #{tracking_id(e.as_json)} #{e.url(reply.fetch(:id))}"
|
32
|
+
end
|
33
|
+
|
34
|
+
@update.each do |id, e|
|
35
|
+
@api.update e.class.api_resource, id, e.as_json
|
36
|
+
puts "Updated #{e.class.api_resource} #{tracking_id(e.as_json)} #{e.url(id)}"
|
37
|
+
end
|
38
|
+
|
39
|
+
@delete.each do |id, _, a|
|
40
|
+
@api.delete a.fetch(:api_resource), id
|
41
|
+
puts "Deleted #{a.fetch(:api_resource)} #{tracking_id(a)} #{id}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def noop?
|
48
|
+
@create.empty? && @update.empty? && @delete.empty?
|
49
|
+
end
|
50
|
+
|
51
|
+
def calculate_diff
|
52
|
+
@update = []
|
53
|
+
@delete = []
|
54
|
+
|
55
|
+
actual = Progress.progress "Downloading definitions" do
|
56
|
+
download_definitions
|
57
|
+
end
|
58
|
+
|
59
|
+
Progress.progress "Diffing" do
|
60
|
+
details_cache do |cache|
|
61
|
+
actual.each do |a|
|
62
|
+
id = a.fetch(:id)
|
63
|
+
|
64
|
+
if e = delete_matching_expected(a)
|
65
|
+
fill_details(a, cache) if a[:api_resource] == "dash"
|
66
|
+
if diff = e.diff(a)
|
67
|
+
@update << [id, e, a, diff]
|
68
|
+
end
|
69
|
+
elsif tracking_id(a) # was previously managed
|
70
|
+
@delete << [id, nil, a]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
ensure_all_ids_found
|
76
|
+
@create = @expected.map { |e| [nil, e] }
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Hack to get diff to work until we can mass-fetch definitions
|
81
|
+
def fill_details(a, cache)
|
82
|
+
args = [a.fetch(:api_resource), a.fetch(:id)]
|
83
|
+
full = cache.fetch(args, a.fetch(:modified)) do
|
84
|
+
@api.show(*args).fetch(a.fetch(:api_resource).to_sym)
|
85
|
+
end
|
86
|
+
a.merge!(full)
|
87
|
+
end
|
88
|
+
|
89
|
+
def details_cache
|
90
|
+
cache = FileCache.new CACHE_FILE
|
91
|
+
yield cache
|
92
|
+
cache.persist
|
93
|
+
end
|
94
|
+
|
95
|
+
def download_definitions
|
96
|
+
api_resources = Models::Base.subclasses.map do |m|
|
97
|
+
next unless m.respond_to?(:api_resource)
|
98
|
+
m.api_resource
|
99
|
+
end
|
100
|
+
|
101
|
+
api_resources.compact.uniq.flat_map do |api_resource|
|
102
|
+
# lookup monitors without adding unnecessary downtime information
|
103
|
+
results = @api.list(api_resource, with_downtimes: false)
|
104
|
+
if results.is_a?(Hash) # dashes
|
105
|
+
results = results.fetch("#{api_resource}es".to_sym) # dash reply is returned under dashes
|
106
|
+
results.each { |r| r[:id] = Integer(r.fetch(:id)) }
|
107
|
+
end
|
108
|
+
results.each { |c| c[:api_resource] = api_resource }
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def ensure_all_ids_found
|
113
|
+
@expected.each do |e|
|
114
|
+
next unless id = e.id
|
115
|
+
raise "Unable to find existing #{e.class.api_resource} with id #{id}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def delete_matching_expected(a)
|
120
|
+
# index list by all the thing we look up by: tracking id and actual id
|
121
|
+
@lookup_map ||= @expected.each_with_object({}) do |e, all|
|
122
|
+
keys = [tracking_id(e.as_json)]
|
123
|
+
keys << "#{e.class.api_resource}:#{e.id}" if e.id
|
124
|
+
keys.compact.each do |key|
|
125
|
+
raise "Lookup #{key} is duplicated" if all[key]
|
126
|
+
all[key] = e
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
e = @lookup_map["#{a.fetch(:api_resource)}:#{a.fetch(:id)}"] || @lookup_map[tracking_id(a)]
|
131
|
+
@expected.delete(e) if e
|
132
|
+
end
|
133
|
+
|
134
|
+
def print_plan(step, list, color)
|
135
|
+
return if list.empty?
|
136
|
+
list.each do |_, e, a, diff|
|
137
|
+
puts Utils.color(color, "#{step} #{tracking_id(e&.as_json || a)}")
|
138
|
+
print_diff(diff) if diff # only for update
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def print_diff(diff)
|
143
|
+
diff.each do |type, field, old, new|
|
144
|
+
if type == "+"
|
145
|
+
temp = new.inspect
|
146
|
+
new = old.inspect
|
147
|
+
old = temp
|
148
|
+
else # ~ and -
|
149
|
+
old = old.inspect
|
150
|
+
new = new.inspect
|
151
|
+
end
|
152
|
+
|
153
|
+
if (old + new).size > 100
|
154
|
+
puts " #{type}#{field}"
|
155
|
+
puts " #{old} ->"
|
156
|
+
puts " #{new}"
|
157
|
+
else
|
158
|
+
puts " #{type}#{field} #{old} -> #{new}"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def add_tracking_id(e)
|
164
|
+
e.as_json[tracking_field(e.as_json)] +=
|
165
|
+
"\n-- Managed by kennel #{e.tracking_id} in #{e.project.class.file_location}, do not modify manually"
|
166
|
+
end
|
167
|
+
|
168
|
+
def tracking_id(a)
|
169
|
+
a[tracking_field(a)][/-- Managed by kennel (\S+:\S+)/, 1]
|
170
|
+
end
|
171
|
+
|
172
|
+
def tracking_field(a)
|
173
|
+
a[:message] ? :message : :description
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
data/lib/kennel/utils.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Utils
|
4
|
+
class << self
|
5
|
+
def snake_case(string)
|
6
|
+
string.gsub(/::/, "_") # Foo::Bar -> foo_bar
|
7
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') # FOOBar -> foo_bar
|
8
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2') # fooBar -> foo_bar
|
9
|
+
.downcase
|
10
|
+
end
|
11
|
+
|
12
|
+
def presence(value)
|
13
|
+
value.empty? ? nil : value
|
14
|
+
end
|
15
|
+
|
16
|
+
def ask(question)
|
17
|
+
printf color(:red, "#{question} - press 'y' to continue: ")
|
18
|
+
begin
|
19
|
+
STDIN.gets.chomp == "y"
|
20
|
+
rescue Interrupt # do not show a backtrace if user decides to Ctrl+C here
|
21
|
+
printf "\n"
|
22
|
+
exit 1
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def color(color, text)
|
27
|
+
code = { red: 31, green: 32, yellow: 33 }.fetch(color)
|
28
|
+
"\e[#{code}m#{text}\e[0m"
|
29
|
+
end
|
30
|
+
|
31
|
+
def strip_shell_control(text)
|
32
|
+
text.gsub(/\e\[\d+m(.*?)\e\[0m/, "\\1").tr("\b", "")
|
33
|
+
end
|
34
|
+
|
35
|
+
def capture_stdout
|
36
|
+
$stdout = StringIO.new
|
37
|
+
yield
|
38
|
+
$stdout.string
|
39
|
+
ensure
|
40
|
+
$stdout = STDOUT
|
41
|
+
end
|
42
|
+
|
43
|
+
def capture_sh(command)
|
44
|
+
result = `#{command} 2>&1`
|
45
|
+
raise "Command failed:\n#{command}\n#{result}" unless $CHILD_STATUS.success?
|
46
|
+
result
|
47
|
+
end
|
48
|
+
|
49
|
+
def path_to_url(path)
|
50
|
+
if subdomain = ENV["DATADOG_SUBDOMAIN"]
|
51
|
+
"https://#{subdomain}.datadoghq.com#{path}"
|
52
|
+
else
|
53
|
+
path
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
metadata
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: kennel
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Michael Grosser
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-12-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: faraday
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: hashdiff
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
description:
|
42
|
+
email: michael@grosser.it
|
43
|
+
executables: []
|
44
|
+
extensions: []
|
45
|
+
extra_rdoc_files: []
|
46
|
+
files:
|
47
|
+
- lib/kennel.rb
|
48
|
+
- lib/kennel/api.rb
|
49
|
+
- lib/kennel/file_cache.rb
|
50
|
+
- lib/kennel/github_reporter.rb
|
51
|
+
- lib/kennel/models/base.rb
|
52
|
+
- lib/kennel/models/dash.rb
|
53
|
+
- lib/kennel/models/monitor.rb
|
54
|
+
- lib/kennel/models/project.rb
|
55
|
+
- lib/kennel/models/team.rb
|
56
|
+
- lib/kennel/progress.rb
|
57
|
+
- lib/kennel/subclass_tracking.rb
|
58
|
+
- lib/kennel/syncer.rb
|
59
|
+
- lib/kennel/utils.rb
|
60
|
+
- lib/kennel/version.rb
|
61
|
+
homepage: https://github.com/grosser/kennel
|
62
|
+
licenses:
|
63
|
+
- MIT
|
64
|
+
metadata: {}
|
65
|
+
post_install_message:
|
66
|
+
rdoc_options: []
|
67
|
+
require_paths:
|
68
|
+
- lib
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: 2.4.2
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
75
|
+
requirements:
|
76
|
+
- - ">="
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: '0'
|
79
|
+
requirements: []
|
80
|
+
rubyforge_project:
|
81
|
+
rubygems_version: 2.6.13
|
82
|
+
signing_key:
|
83
|
+
specification_version: 4
|
84
|
+
summary: Keep datadog monitors/dashboards/etc in version control, avoid chaotic management
|
85
|
+
via UI
|
86
|
+
test_files: []
|