datadog_backup 0.9.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,73 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '../lib')
5
+
6
+ require 'logger'
7
+ require 'optparse'
8
+
9
+ require 'datadog_backup'
10
+ require 'dogapi'
11
+
12
+ $stdout.sync = $stderr.sync = true
13
+ LOGGER = Logger.new($stderr) unless defined?(LOGGER)
14
+ LOGGER.level = Logger::INFO
15
+
16
+ ##
17
+ # Default parameters
18
+ @options = {
19
+ action: nil,
20
+ datadog_api_key: ENV.fetch('DATADOG_API_KEY'),
21
+ datadog_app_key: ENV.fetch('DATADOG_APP_KEY'),
22
+ backup_dir: File.join(ENV.fetch('PWD'), 'backup'),
23
+ diff_format: :color,
24
+ resources: [DatadogBackup::Dashboards, DatadogBackup::Monitors],
25
+ output_format: :yaml,
26
+ logger: LOGGER
27
+ }
28
+
29
+ def prereqs
30
+ ARGV << '--help' if ARGV.empty?
31
+
32
+ options = OptionParser.new do |opts|
33
+ opts.banner = "Usage: #{File.basename($PROGRAM_NAME)} <backup|diffs>"
34
+ opts.separator ''
35
+ opts.on_tail('-h', '--help', 'Show this message') do
36
+ puts opts
37
+ exit 0
38
+ end
39
+ opts.on('--debug', 'debug logging') do
40
+ LOGGER.level = Logger::DEBUG
41
+ end
42
+ opts.on('--backup-dir PATH', '`backup` by default') do |path|
43
+ @options[:backup_dir] = path
44
+ end
45
+ opts.on('--monitors-only') do
46
+ @options[:resources] = [DatadogBackup::Monitors]
47
+ end
48
+ opts.on('--dashboards-only') do
49
+ @options[:resources] = [DatadogBackup::Dashboards]
50
+ end
51
+ opts.on('--json', 'format backups as JSON instead of YAML. Does not impact `diffs` nor `restore`, but do not mix.') do
52
+ @options[:output_format] = :json
53
+ end
54
+ opts.on('--no-color', 'removes colored output from diff format') do
55
+ @options[:diff_format] = nil
56
+ end
57
+ opts.on('--diff-format FORMAT', 'one of `color`, `html_simple`, `html`') do |format|
58
+ @options[:diff_format] = format.to_sym
59
+ end
60
+ end
61
+ options.parse!
62
+
63
+ @options[:action] = ARGV.first
64
+ if %w[backup diffs restore].include?(@options[:action])
65
+ else
66
+ puts options
67
+ exit 0
68
+ end
69
+ end
70
+
71
+ prereqs
72
+
73
+ DatadogBackup::Cli.new(@options).run!
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'datadog_backup/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'datadog_backup'
9
+ spec.version = DatadogBackup::VERSION
10
+ spec.authors = ['Kamran Farhadi', 'Jim Park']
11
+ spec.email = ['kamranf@scribd.com', 'jimp@scribd.com']
12
+ spec.summary = 'A utility to backup and restore Datadog accounts'
13
+ spec.description = 'A utility to backup and restore Datadog accounts'
14
+ spec.homepage = 'https://github.com/scribd/datadog_backup'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0")
18
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
+ spec.test_files = spec.files.grep(%r{^spec/})
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_dependency 'amazing_print', '1.2.1'
23
+ spec.add_dependency 'concurrent-ruby', '1.1.6'
24
+ spec.add_dependency 'concurrent-ruby-edge', '0.6.0'
25
+ spec.add_dependency 'deepsort', '0.4.5'
26
+ spec.add_dependency 'diffy', '3.3.0'
27
+ spec.add_dependency 'dogapi', '1.41.0'
28
+
29
+ spec.add_development_dependency 'bundler'
30
+ spec.add_development_dependency 'pry'
31
+ end
@@ -0,0 +1,31 @@
1
+ name: backup
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "0 * * * *"
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ backup:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v2
14
+ - name: Set up Ruby 2.7.1
15
+ uses: actions/setup-ruby@v1
16
+ with:
17
+ ruby-version: 2.7.1
18
+ - name: perform backup
19
+ env:
20
+ DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
21
+ DATADOG_APP_KEY: ${{ secrets.DATADOG_APP_KEY }}
22
+ run: |
23
+ gem install --no-document bundler
24
+ bundle install --jobs 4 --retry 3
25
+ bundle exec datadog_backup backup
26
+ - name: commit changes
27
+ uses: stefanzweifel/git-auto-commit-action@v4
28
+ with:
29
+ commit_message: "Changes as of run: ${{ github.run_id }}"
30
+ file_pattern: backup/
31
+ repository: .
@@ -0,0 +1 @@
1
+ .bundle/
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'datadog_backup', :source => 'https://scribd.jfrog.io/artifactory/gems-local'
@@ -0,0 +1,17 @@
1
+ # Example-datadog-backups
2
+ Dashboards and monitors are backed up here on an hourly basis.
3
+
4
+ Github Actions uses the [datadog_backup gem](https://github.com/scribd/datadog_backup) in order to sync the latest copy of what's in Datadog.
5
+
6
+ ## Performing edits to Datadog monitors and dashboards
7
+ 1. Do it in the Datadog UI
8
+ 2. At the top of the hour, the changes will be recorded here.
9
+ 3. If you're in a rush, click on "Run workflow" from the Github Actions workflow menus
10
+
11
+ ## Performing bulk edits to Datadog monitors and dashboards
12
+ 1. Clone this repo
13
+ 2. `bundle install`
14
+ 3. `bundle exec datadog_backup backup` to download the latest changes in Datadog.
15
+ 4. Make your changes locally.
16
+ 5. `bundle exec datadog_backup restore` to apply your changes.
17
+ 6. Review each change and apply it (r), or download the latest copy from Datadog (d).
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'concurrent-edge'
5
+
6
+ require 'dogapi'
7
+
8
+ require_relative 'datadog_backup/local_filesystem'
9
+ require_relative 'datadog_backup/options'
10
+
11
+ require_relative 'datadog_backup/cli'
12
+ require_relative 'datadog_backup/core'
13
+ require_relative 'datadog_backup/dashboards'
14
+ require_relative 'datadog_backup/monitors'
15
+ require_relative 'datadog_backup/thread_pool'
16
+ require_relative 'datadog_backup/version'
17
+
18
+ module DatadogBackup
19
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'logger'
5
+ require 'amazing_print'
6
+
7
+ module DatadogBackup
8
+ class Cli
9
+ include ::DatadogBackup::Options
10
+
11
+ def all_diff_futures
12
+ logger.info("Starting diffs on #{::DatadogBackup::ThreadPool::TPOOL.max_length} threads")
13
+ any_resource_instance
14
+ .all_file_ids_for_selected_resources
15
+ .map do |id|
16
+ Concurrent::Promises.future_on(::DatadogBackup::ThreadPool::TPOOL, id) do |id|
17
+ [id, getdiff(id)]
18
+ end
19
+ end
20
+ end
21
+
22
+ def any_resource_instance
23
+ resource_instances.first
24
+ end
25
+
26
+ def backup
27
+ resource_instances.each(&:purge)
28
+ resource_instances.each(&:backup)
29
+ any_resource_instance.all_files
30
+ end
31
+
32
+ def initialize_client
33
+ @options[:client] ||= Dogapi::Client.new(
34
+ datadog_api_key,
35
+ datadog_app_key
36
+ )
37
+ end
38
+
39
+ def definitive_resource_instance(id)
40
+ matching_resource_instance(any_resource_instance.class_from_id(id))
41
+ end
42
+
43
+ def diffs
44
+ futures = all_diff_futures
45
+ ::DatadogBackup::ThreadPool.watcher(logger).join
46
+
47
+ format_diff_output(
48
+ Concurrent::Promises
49
+ .zip(*futures)
50
+ .value!
51
+ .reject { |_k, v| v.nil? }
52
+ )
53
+ end
54
+
55
+ def getdiff(id)
56
+ result = definitive_resource_instance(id).diff(id)
57
+ case result
58
+ when ''
59
+ nil
60
+ when "\n"
61
+ nil
62
+ when '<div class="diff"></div>'
63
+ nil
64
+ else
65
+ result
66
+ end
67
+ end
68
+
69
+ def format_diff_output(diff_output)
70
+ case diff_format
71
+ when nil, :color
72
+ diff_output.map do |id, diff|
73
+ " ---\n id: #{id}\n#{diff}"
74
+ end.join("\n")
75
+ when :html
76
+ '<html><head><style>' +
77
+ Diffy::CSS +
78
+ '</style></head><body>' +
79
+ diff_output.map do |id, diff|
80
+ "<br><br> ---<br><strong> id: #{id}</strong><br>" + diff
81
+ end.join('<br>') +
82
+ '</body></html>'
83
+ else
84
+ raise 'Unexpected diff_format.'
85
+ end
86
+ end
87
+
88
+ def initialize(options)
89
+ @options = options
90
+ initialize_client
91
+ end
92
+
93
+ def matching_resource_instance(klass)
94
+ resource_instances.select { |resource_instance| resource_instance.class == klass }.first
95
+ end
96
+
97
+ def resource_instances
98
+ @resource_instances ||= resources.map do |resource|
99
+ resource.new(@options)
100
+ end
101
+ end
102
+
103
+ def restore
104
+ futures = all_diff_futures
105
+ watcher = ::DatadogBackup::ThreadPool.watcher(logger)
106
+
107
+ futures.each do |future|
108
+ id, diff = *future.value!
109
+ next unless diff
110
+
111
+ puts '--------------------------------------------------------------------------------'
112
+ puts format_diff_output([id, diff])
113
+ puts '(r)estore to Datadog, overwrite local changes and (d)ownload, (s)kip, or (q)uit?'
114
+ response = $stdin.gets.chomp
115
+ case response
116
+ when 'q'
117
+ exit
118
+ when 'r'
119
+ puts "Restoring #{id} to Datadog."
120
+ definitive_resource_instance(id).update_with_200(id, definitive_resource_instance(id).load_from_file_by_id(id))
121
+ when 'd'
122
+ puts "Downloading #{id} from Datadog."
123
+ definitive_resource_instance(id).get_and_write_file(id)
124
+ when 's'
125
+ next
126
+ else
127
+ puts 'Invalid response, please try again.'
128
+ response = $stdin.gets.chomp
129
+ end
130
+ end
131
+
132
+ watcher.join if watcher.status
133
+ end
134
+
135
+ def run!
136
+ puts(send(action.to_sym))
137
+ rescue SystemExit, Interrupt
138
+ ::DatadogBackup::ThreadPool.shutdown(logger)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'diffy'
4
+ require 'deepsort'
5
+
6
+ module DatadogBackup
7
+ class Core
8
+ include ::DatadogBackup::LocalFilesystem
9
+ include ::DatadogBackup::Options
10
+
11
+ def api_service
12
+ raise 'subclass is expected to implement #api_service'
13
+ end
14
+
15
+ def api_version
16
+ raise 'subclass is expected to implement #api_version'
17
+ end
18
+
19
+ def api_resource_name
20
+ raise 'subclass is expected to implement #api_resource_name'
21
+ end
22
+
23
+ def backup
24
+ raise 'subclass is expected to implement #backup'
25
+ end
26
+
27
+ # Calls out to Datadog and checks for a '200' response
28
+ def client_with_200(method, *id)
29
+ max_retries = 6
30
+ retries ||= 0
31
+
32
+ response = client.send(method, *id)
33
+
34
+ # logger.debug response
35
+ raise "Method #{method} failed with error #{response}" unless response[0] == '200'
36
+
37
+ response[1]
38
+ rescue ::Net::OpenTimeout => e
39
+ if (retries += 1) <= max_retries
40
+ sleep(0.1 * retries**5) # 0.1, 3.2, 24.3, 102.4 seconds per retry
41
+ retry
42
+ else
43
+ raise "Method #{method} failed with error #{e.message}"
44
+ end
45
+ end
46
+
47
+ # Returns the diffy diff.
48
+ # Optionally, supply an array of keys to remove from comparison
49
+ def diff(id, banlist = [])
50
+ current = except(get_by_id(id), banlist).deep_sort.to_yaml
51
+ filesystem = except(load_from_file_by_id(id), banlist).deep_sort.to_yaml
52
+ result = ::Diffy::Diff.new(current, filesystem, include_plus_and_minus_in_html: true).to_s(diff_format)
53
+ logger.debug("Compared ID #{id} and found #{result}")
54
+ result
55
+ end
56
+
57
+ # Returns a hash with banlist elements removed
58
+ def except(hash, banlist)
59
+ hash.tap do # tap returns self
60
+ banlist.each do |key|
61
+ hash.delete(key) # delete returns the value at the deleted key, hence the tap wrapper
62
+ end
63
+ end
64
+ end
65
+
66
+ def get_and_write_file(id)
67
+ write_file(dump(get_by_id(id)), filename(id))
68
+ end
69
+
70
+ def get_by_id(_id)
71
+ raise 'subclass is expected to implement #get_by_id(id)'
72
+ end
73
+
74
+ def initialize(options)
75
+ @options = options
76
+ ::FileUtils.mkdir_p(mydir)
77
+ end
78
+
79
+ def myclass
80
+ self.class.to_s.split(':').last.downcase
81
+ end
82
+
83
+ def restore
84
+ raise 'subclass is expected to implement #restore'
85
+ end
86
+
87
+ def update(id, body)
88
+ api_service.request(Net::HTTP::Put, "/api/#{api_version}/#{api_resource_name}/#{id}", nil, body, true)
89
+ end
90
+
91
+ # Calls out to Datadog and checks for a '200' response
92
+ def update_with_200(id, body)
93
+ max_retries = 6
94
+ retries ||= 0
95
+
96
+ response = update(id, body)
97
+
98
+ # logger.debug response
99
+ raise "Update failed with error #{response}" unless response[0] == '200'
100
+
101
+ logger.warn "Successfully restored #{id} to datadog."
102
+
103
+ response[1]
104
+ rescue ::Net::OpenTimeout => e
105
+ if (retries += 1) <= max_retries
106
+ sleep(0.1 * retries**5) # 0.1, 3.2, 24.3, 102.4 seconds per retry
107
+ retry
108
+ else
109
+ raise "Update failed with error #{e.message}"
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatadogBackup
4
+ class Dashboards < Core
5
+ BANLIST = %w[modified_at url]
6
+
7
+ def all_boards
8
+ client_with_200(:get_all_boards).fetch('dashboards')
9
+ end
10
+
11
+ def api_service
12
+ # The underlying class from Dogapi that talks to datadog
13
+ client.instance_variable_get(:@dashboard_service)
14
+ end
15
+
16
+ def api_version
17
+ 'v1'
18
+ end
19
+
20
+ def api_resource_name
21
+ 'dashboard'
22
+ end
23
+
24
+ def backup
25
+ logger.info("Starting diffs on #{::DatadogBackup::ThreadPool::TPOOL.max_length} threads")
26
+
27
+ futures = all_boards.map do |board|
28
+ Concurrent::Promises.future_on(::DatadogBackup::ThreadPool::TPOOL, board) do |board|
29
+ id = board['id']
30
+ get_and_write_file(id)
31
+ end
32
+ end
33
+
34
+ watcher = ::DatadogBackup::ThreadPool.watcher(logger)
35
+ watcher.join if watcher.status
36
+
37
+ Concurrent::Promises.zip(*futures).value!
38
+ end
39
+
40
+ def diff(id)
41
+ super(id, BANLIST)
42
+ end
43
+
44
+ def get_by_id(id)
45
+ except(client_with_200(:get_board, id), BANLIST)
46
+ end
47
+
48
+ def restore!; end
49
+ end
50
+ end