├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── actions.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── Rakefile ├── Readme.md ├── kennel.gemspec ├── lib ├── kennel.rb └── kennel │ ├── api.rb │ ├── attribute_differ.rb │ ├── console.rb │ ├── file_cache.rb │ ├── filter.rb │ ├── github_reporter.rb │ ├── id_map.rb │ ├── importer.rb │ ├── models │ ├── base.rb │ ├── dashboard.rb │ ├── monitor.rb │ ├── project.rb │ ├── record.rb │ ├── slo.rb │ ├── synthetic_test.rb │ └── team.rb │ ├── optional_validations.rb │ ├── parts_serializer.rb │ ├── progress.rb │ ├── projects_provider.rb │ ├── settings_as_methods.rb │ ├── string_utils.rb │ ├── subclass_tracking.rb │ ├── syncer.rb │ ├── syncer │ ├── matched_expected.rb │ ├── plan.rb │ ├── plan_printer.rb │ ├── resolver.rb │ └── types.rb │ ├── tags_validation.rb │ ├── tasks.rb │ ├── template_variables.rb │ ├── unmuted_alerts.rb │ ├── utils.rb │ └── version.rb ├── template ├── .env.example ├── .gitattributes ├── .gitignore ├── .ruby-version ├── .travis.yml ├── Gemfile ├── Rakefile ├── Readme.md ├── github │ ├── cage.jpg │ └── screen.png ├── parts │ ├── dashes │ │ └── .gitkeep │ └── monitors │ │ └── .gitkeep ├── projects │ └── .gitkeep └── teams │ └── .gitkeep └── test ├── coverage_test.rb ├── integration.rb ├── integration_helper.rb ├── kennel ├── api_test.rb ├── attribute_differ_test.rb ├── console_test.rb ├── file_cache_test.rb ├── filter_test.rb ├── github_reporter_test.rb ├── id_map_test.rb ├── importer_test.rb ├── models │ ├── base_test.rb │ ├── dashboard_test.rb │ ├── monitor_test.rb │ ├── project_test.rb │ ├── record_test.rb │ ├── slo_test.rb │ ├── synthetic_test_test.rb │ └── team_test.rb ├── optional_validations_test.rb ├── parts_serializer_test.rb ├── progress_test.rb ├── projects_provider_test.rb ├── settings_as_methods_test.rb ├── string_utils_test.rb ├── subclass_tracking_test.rb ├── syncer │ ├── matched_expected_test.rb │ ├── plan_printer_test.rb │ ├── plan_test.rb │ ├── resolver_test.rb │ └── types_test.rb ├── syncer_test.rb ├── tags_validation_test.rb ├── tasks_test.rb ├── template_variables_test.rb ├── unmuted_alerts_test.rb ├── utils_test.rb └── version_test.rb ├── kennel_test.rb ├── misc_test.rb ├── readme_test.rb └── test_helper.rb /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Thank you for your contribution! 2 | 3 | ## Checklist 4 | - [ ] Verified against local install of kennel (using `path:` in Gemfile) 5 | - [ ] Added tests 6 | - [ ] Updated Readme.md (if user facing behavior changed) 7 | -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | # in order of most likely failure so `rake` fails fast 13 | task: [ test, integration, rubocop, readme ] 14 | name: rake ${{ matrix.task }} 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ${{ matrix.ruby }} 20 | bundler-cache: true 21 | - run: bundle exec rake ${{ matrix.task }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | template/Gemfile.lock 2 | tmp/ 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Keep these alphabetical 2 | AllCops: 3 | CacheRootDirectory: tmp 4 | NewCops: enable 5 | SuggestExtensions: false 6 | Exclude: 7 | - vendor/**/* 8 | - template/vendor/**/* 9 | - projects/**/* 10 | 11 | Layout/LineLength: 12 | Enabled: false 13 | 14 | Bundler/OrderedGems: 15 | Enabled: false 16 | 17 | Layout/EmptyLineAfterMagicComment: 18 | Enabled: false 19 | 20 | Layout/FirstHashElementIndentation: 21 | EnforcedStyle: consistent 22 | 23 | Layout/MultilineMethodCallIndentation: 24 | EnforcedStyle: indented 25 | 26 | Metrics: 27 | Enabled: false 28 | 29 | Style/Alias: 30 | EnforcedStyle: prefer_alias_method 31 | 32 | Style/ClassVars: 33 | Enabled: false 34 | 35 | Style/ConditionalAssignment: 36 | Enabled: false 37 | 38 | Style/Documentation: 39 | Enabled: false 40 | 41 | Style/DoubleNegation: 42 | Enabled: false 43 | 44 | Style/EmptyMethod: 45 | EnforcedStyle: expanded 46 | 47 | Style/FormatString: 48 | EnforcedStyle: percent 49 | 50 | Style/GuardClause: 51 | Enabled: false 52 | 53 | Style/IfUnlessModifier: 54 | Enabled: false 55 | 56 | Style/Lambda: 57 | EnforcedStyle: literal 58 | 59 | Style/NumericLiterals: 60 | Enabled: false 61 | 62 | Style/SingleLineBlockParams: 63 | Enabled: false 64 | 65 | Style/StringLiterals: 66 | EnforcedStyle: double_quotes 67 | 68 | Style/StringLiteralsInInterpolation: 69 | EnforcedStyle: double_quotes 70 | 71 | Style/SymbolArray: 72 | Enabled: false 73 | 74 | Style/WordArray: 75 | Enabled: false 76 | 77 | Style/MultilineBlockChain: 78 | Enabled: false 79 | 80 | Naming/MethodParameterName: 81 | Enabled: false 82 | 83 | Style/RegexpLiteral: 84 | Enabled: false 85 | 86 | Style/NumericPredicate: 87 | EnforcedStyle: comparison 88 | 89 | Style/SpecialGlobalVars: 90 | Enabled: false 91 | 92 | Style/PerlBackrefs: 93 | Enabled: false 94 | 95 | Layout/EmptyLineAfterGuardClause: 96 | Enabled: false 97 | 98 | Style/FormatStringToken: 99 | EnforcedStyle: unannotated 100 | 101 | Style/EmptyElse: 102 | Enabled: false 103 | 104 | # suggests ugly "\n#{<<~MSG.gsub(/^/, " ")}" 105 | Style/StringConcatenation: 106 | Enabled: false 107 | 108 | # using this a bunch in tests 109 | Style/GlobalStdStream: 110 | Enabled: false 111 | 112 | # new style looks sus and breaks old editors 113 | Naming/BlockForwarding: 114 | Enabled: false 115 | 116 | # TODO: fix 117 | Lint/ConstantDefinitionInBlock: 118 | Enabled: false 119 | 120 | # ignore_404 is fine 121 | Naming/VariableNumber: 122 | Enabled: false 123 | 124 | # omitting keys breaks old editors and looks funky 125 | Style/HashSyntax: 126 | Enabled: false 127 | 128 | # ENV["FOO"] is just fine 129 | Style/FetchEnvVar: 130 | Enabled: false 131 | 132 | # returning nil is more explicit 133 | Style/NilLambda: 134 | Enabled: false 135 | 136 | # makes structure more readable 137 | Style/SoleNestedConditional: 138 | Enabled: false 139 | 140 | # should be fine 141 | Lint/FloatComparison: 142 | Enabled: false 143 | 144 | # suggestion breaks the code 145 | Style/EvalWithLocation: 146 | Enabled: false 147 | 148 | # either way works fine 149 | Layout/LineContinuationLeadingSpace: 150 | Enabled: false 151 | 152 | # can be more readalbe by aligning everything to the front 153 | Layout/LineEndStringConcatenationIndentation: 154 | Enabled: false 155 | 156 | # often more expressive to have multiple loops 157 | Style/CombinableLoops: 158 | Enabled: false 159 | 160 | # on purpose 161 | Lint/EmptyBlock: 162 | Enabled: false 163 | 164 | # often makes logic more readable 165 | Style/RedundantParentheses: 166 | Enabled: false 167 | 168 | # often more readable 169 | Style/ArgumentsForwarding: 170 | Enabled: false 171 | 172 | # sometimes necessary 173 | Style/SafeNavigationChainLength: 174 | Enabled: false 175 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | template/.ruby-version -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | 4 | gemspec 5 | 6 | ruby File.read(".ruby-version").strip 7 | 8 | gem "bump" 9 | gem "rake" 10 | gem "maxitest" 11 | gem "single_cov" 12 | gem "webmock" 13 | gem "mocha" 14 | gem "rubocop" 15 | gem "forking_test_runner" 16 | gem "pry-byebug" 17 | gem "bootsnap" 18 | gem "base64" 19 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | kennel (1.163.2) 5 | diff-lcs (~> 1.5) 6 | faraday (~> 1.8) 7 | hashdiff (~> 1.0) 8 | net-http-persistent (~> 4.0) 9 | zeitwerk (~> 2.4) 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | addressable (2.8.7) 15 | public_suffix (>= 2.0.2, < 7.0) 16 | ast (2.4.3) 17 | base64 (0.2.0) 18 | bigdecimal (3.1.9) 19 | bootsnap (1.18.4) 20 | msgpack (~> 1.2) 21 | bump (0.10.0) 22 | byebug (11.1.3) 23 | coderay (1.1.3) 24 | connection_pool (2.5.0) 25 | crack (1.0.0) 26 | bigdecimal 27 | rexml 28 | diff-lcs (1.6.0) 29 | faraday (1.10.4) 30 | faraday-em_http (~> 1.0) 31 | faraday-em_synchrony (~> 1.0) 32 | faraday-excon (~> 1.1) 33 | faraday-httpclient (~> 1.0) 34 | faraday-multipart (~> 1.0) 35 | faraday-net_http (~> 1.0) 36 | faraday-net_http_persistent (~> 1.0) 37 | faraday-patron (~> 1.0) 38 | faraday-rack (~> 1.0) 39 | faraday-retry (~> 1.0) 40 | ruby2_keywords (>= 0.0.4) 41 | faraday-em_http (1.0.0) 42 | faraday-em_synchrony (1.0.0) 43 | faraday-excon (1.1.0) 44 | faraday-httpclient (1.0.1) 45 | faraday-multipart (1.1.0) 46 | multipart-post (~> 2.0) 47 | faraday-net_http (1.0.2) 48 | faraday-net_http_persistent (1.2.0) 49 | faraday-patron (1.0.0) 50 | faraday-rack (1.0.0) 51 | faraday-retry (1.0.3) 52 | forking_test_runner (1.15.0) 53 | parallel_tests (>= 1.3.7) 54 | hashdiff (1.1.2) 55 | json (2.10.2) 56 | language_server-protocol (3.17.0.4) 57 | lint_roller (1.1.0) 58 | maxitest (5.8.0) 59 | minitest (>= 5.14.0, < 5.26.0) 60 | method_source (1.1.0) 61 | minitest (5.25.5) 62 | mocha (2.7.1) 63 | ruby2_keywords (>= 0.0.5) 64 | msgpack (1.8.0) 65 | multipart-post (2.4.1) 66 | net-http-persistent (4.0.5) 67 | connection_pool (~> 2.2) 68 | parallel (1.26.3) 69 | parallel_tests (5.1.0) 70 | parallel 71 | parser (3.3.7.2) 72 | ast (~> 2.4.1) 73 | racc 74 | pry (0.14.2) 75 | coderay (~> 1.1) 76 | method_source (~> 1.0) 77 | pry-byebug (3.10.1) 78 | byebug (~> 11.0) 79 | pry (>= 0.13, < 0.15) 80 | public_suffix (6.0.1) 81 | racc (1.8.1) 82 | rainbow (3.1.1) 83 | rake (13.2.1) 84 | regexp_parser (2.10.0) 85 | rexml (3.4.1) 86 | rubocop (1.74.0) 87 | json (~> 2.3) 88 | language_server-protocol (~> 3.17.0.2) 89 | lint_roller (~> 1.1.0) 90 | parallel (~> 1.10) 91 | parser (>= 3.3.0.2) 92 | rainbow (>= 2.2.2, < 4.0) 93 | regexp_parser (>= 2.9.3, < 3.0) 94 | rubocop-ast (>= 1.38.0, < 2.0) 95 | ruby-progressbar (~> 1.7) 96 | unicode-display_width (>= 2.4.0, < 4.0) 97 | rubocop-ast (1.41.0) 98 | parser (>= 3.3.7.2) 99 | ruby-progressbar (1.13.0) 100 | ruby2_keywords (0.0.5) 101 | single_cov (1.11.0) 102 | unicode-display_width (3.1.4) 103 | unicode-emoji (~> 4.0, >= 4.0.4) 104 | unicode-emoji (4.0.4) 105 | webmock (3.25.1) 106 | addressable (>= 2.8.0) 107 | crack (>= 0.3.2) 108 | hashdiff (>= 0.4.0, < 2.0.0) 109 | zeitwerk (2.7.2) 110 | 111 | PLATFORMS 112 | ruby 113 | 114 | DEPENDENCIES 115 | base64 116 | bootsnap 117 | bump 118 | forking_test_runner 119 | kennel! 120 | maxitest 121 | mocha 122 | pry-byebug 123 | rake 124 | rubocop 125 | single_cov 126 | webmock 127 | 128 | RUBY VERSION 129 | ruby 3.3.6p108 130 | 131 | BUNDLED WITH 132 | 2.6.6 133 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "bundler/setup" 3 | require "bundler/gem_tasks" 4 | require "bump/tasks" 5 | require "json" 6 | 7 | require "rubocop/rake_task" 8 | RuboCop::RakeTask.new 9 | 10 | desc "Run tests" 11 | task :test do 12 | sh "forking-test-runner test --merge-coverage --quiet" 13 | end 14 | 15 | desc "Run integration tests" 16 | task :integration do 17 | sh "ruby test/integration.rb" 18 | end 19 | 20 | desc "Turn template folder into a play area" 21 | task :play do 22 | require "./test/integration_helper" 23 | include IntegrationHelper 24 | report_fake_metric 25 | Dir.chdir "template" do 26 | with_test_keys_in_dotenv do 27 | with_local_kennel do 28 | exit! # do not run ensure blocks that clean things up 29 | end 30 | end 31 | end 32 | end 33 | 34 | desc "Keep readmes in sync" 35 | task :readme do 36 | readme = File.read("Readme.md") 37 | raise "Unable to find ONLY IN" unless readme.gsub!(//m, "\\1") 38 | raise "Unable to find NOT IN" unless readme.gsub!(/.*?\n/m, "") 39 | raise "Unable to find images" unless readme.gsub!("template/", "") 40 | File.write("template/Readme.md", readme) 41 | sh "git diff HEAD --exit-code -- template/Readme.md" 42 | end 43 | 44 | # make sure we always run what travis runs 45 | require "yaml" 46 | ci = YAML.load_file(".github/workflows/actions.yml").dig("jobs", "build", "strategy", "matrix", "task") 47 | raise if ci.empty? 48 | task default: [*ci, "rubocop"] 49 | -------------------------------------------------------------------------------- /kennel.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | name = "kennel" 3 | $LOAD_PATH << File.expand_path("lib", __dir__) 4 | require "#{name}/version" 5 | 6 | Gem::Specification.new name, Kennel::VERSION do |s| 7 | s.summary = "Keep datadog monitors/dashboards/etc in version control, avoid chaotic management via UI" 8 | s.authors = ["Michael Grosser"] 9 | s.email = "michael@grosser.it" 10 | s.homepage = "https://github.com/grosser/#{name}" 11 | s.files = `git ls-files lib Readme.md template/Readme.md`.split("\n") 12 | s.license = "MIT" 13 | s.required_ruby_version = ">= #{File.read(".ruby-version").strip[0..2]}.0" 14 | s.add_dependency "diff-lcs", "~> 1.5" 15 | s.add_dependency "faraday", "~> 1.8" 16 | s.add_dependency "hashdiff", "~> 1.0" 17 | s.add_dependency "net-http-persistent", "~> 4.0" 18 | s.add_dependency "zeitwerk", "~> 2.4" 19 | end 20 | -------------------------------------------------------------------------------- /lib/kennel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "faraday" 3 | require "json" 4 | require "zeitwerk" 5 | require "English" 6 | 7 | require "kennel/version" 8 | require "kennel/console" 9 | require "kennel/string_utils" 10 | require "kennel/utils" 11 | require "kennel/progress" 12 | require "kennel/filter" 13 | require "kennel/parts_serializer" 14 | require "kennel/projects_provider" 15 | require "kennel/attribute_differ" 16 | require "kennel/tags_validation" 17 | require "kennel/syncer" 18 | require "kennel/id_map" 19 | require "kennel/api" 20 | require "kennel/github_reporter" 21 | require "kennel/subclass_tracking" 22 | require "kennel/settings_as_methods" 23 | require "kennel/file_cache" 24 | require "kennel/template_variables" 25 | require "kennel/optional_validations" 26 | require "kennel/unmuted_alerts" 27 | 28 | require "kennel/models/base" 29 | require "kennel/models/record" 30 | 31 | # records 32 | require "kennel/models/dashboard" 33 | require "kennel/models/monitor" 34 | require "kennel/models/slo" 35 | require "kennel/models/synthetic_test" 36 | 37 | # settings 38 | require "kennel/models/project" 39 | require "kennel/models/team" 40 | 41 | # need to define early since we autoload the teams/ folder into it 42 | module Teams 43 | end 44 | 45 | module Kennel 46 | UnresolvableIdError = Class.new(StandardError) 47 | DisallowedUpdateError = Class.new(StandardError) 48 | GenerationAbortedError = Class.new(StandardError) 49 | 50 | class << self 51 | attr_accessor :in, :out, :err 52 | end 53 | 54 | self.in = $stdin 55 | self.out = $stdout 56 | self.err = $stderr 57 | 58 | class Engine 59 | attr_accessor :strict_imports 60 | 61 | def initialize 62 | @strict_imports = true 63 | end 64 | 65 | # start generation and download in parallel to make planning faster 66 | def preload 67 | Utils.parallel([:generated, :definitions]) { |m| send m, plain: true } 68 | end 69 | 70 | def generate 71 | parts = generated 72 | PartsSerializer.new(filter: filter).write(parts) if ENV["STORE"] != "false" # quicker when debugging 73 | parts 74 | end 75 | 76 | def plan 77 | syncer.print_plan 78 | syncer.plan 79 | end 80 | 81 | def update 82 | syncer.print_plan 83 | syncer.update if syncer.confirm 84 | end 85 | 86 | private 87 | 88 | def filter 89 | @filter ||= Filter.new 90 | end 91 | 92 | def syncer 93 | @syncer ||= begin 94 | preload 95 | Syncer.new( 96 | api, generated, definitions, 97 | filter: filter, 98 | strict_imports: strict_imports 99 | ) 100 | end 101 | end 102 | 103 | def api 104 | @api ||= Api.new 105 | end 106 | 107 | def generated(**kwargs) 108 | @generated ||= begin 109 | projects = Progress.progress "Loading projects", **kwargs do 110 | projects = ProjectsProvider.new(filter: filter).projects 111 | filter.filter_projects projects 112 | end 113 | 114 | parts = Progress.progress "Finding parts", **kwargs do 115 | parts = Utils.parallel(projects, &:validated_parts).flatten(1) 116 | parts = filter.filter_parts parts 117 | validate_unique_tracking_ids(parts) 118 | parts 119 | end 120 | 121 | Progress.progress "Building json" do 122 | # trigger json caching here so it counts into generating 123 | Utils.parallel(parts, &:build) 124 | end 125 | 126 | OptionalValidations.valid?(parts) || raise(GenerationAbortedError) 127 | 128 | parts 129 | end 130 | end 131 | 132 | # performance: this takes ~100ms on large codebases, tried rewriting with Set or Hash but it was slower 133 | def validate_unique_tracking_ids(parts) 134 | bad = parts.group_by(&:tracking_id).select { |_, same| same.size > 1 } 135 | return if bad.empty? 136 | raise <<~ERROR 137 | #{bad.map { |tracking_id, same| "#{tracking_id} is defined #{same.size} times" }.join("\n")} 138 | 139 | use a different `kennel_id` when defining multiple projects/monitors/dashboards to avoid this conflict 140 | ERROR 141 | end 142 | 143 | def definitions(**kwargs) 144 | @definitions ||= Progress.progress("Downloading definitions", **kwargs) do 145 | Utils.parallel(Models::Record.subclasses) do |klass| 146 | api.list(klass.api_resource, with_downtimes: false) # lookup monitors without adding unnecessary downtime information 147 | end.flatten(1) 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/kennel/api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # encapsulates knowledge around how the api works 3 | # especially 1-off weirdness that should not leak into other parts of the code 4 | module Kennel 5 | class Api 6 | CACHE_FILE = ENV.fetch("KENNEL_API_CACHE_FILE", "tmp/cache/details") 7 | 8 | RateLimitParams = Data.define(:limit, :period, :remaining, :reset, :name) 9 | 10 | def self.with_tracking(api_resource, reply) 11 | klass = Models::Record.api_resource_map[api_resource] 12 | return reply unless klass # do not blow up on unknown models 13 | 14 | reply.merge( 15 | klass: klass, 16 | tracking_id: klass.parse_tracking_id(reply) 17 | ) 18 | end 19 | 20 | def initialize(app_key = nil, api_key = nil) 21 | @app_key = app_key || ENV.fetch("DATADOG_APP_KEY") 22 | @api_key = api_key || ENV.fetch("DATADOG_API_KEY") 23 | url = Utils.path_to_url("") 24 | @client = Faraday.new(url: url) { |c| c.adapter :net_http_persistent } 25 | end 26 | 27 | def show(api_resource, id, params = {}) 28 | response = request :get, "/api/v1/#{api_resource}/#{id}", params: params 29 | response = response.fetch(:data) if api_resource == "slo" 30 | response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests" 31 | self.class.with_tracking(api_resource, response) 32 | end 33 | 34 | def list(api_resource, params = {}) 35 | with_pagination api_resource == "slo", params do |paginated_params| 36 | response = request :get, "/api/v1/#{api_resource}", params: paginated_params 37 | response = response.fetch(:dashboards) if api_resource == "dashboard" 38 | response = response.fetch(:data) if api_resource == "slo" 39 | if api_resource == "synthetics/tests" 40 | response = response.fetch(:tests) 41 | response.each { |r| r[:id] = r.delete(:public_id) } 42 | end 43 | 44 | # ignore monitor synthetics create and that inherit the kennel_id, we do not directly manage them 45 | response.reject! { |m| m[:type] == "synthetics alert" } if api_resource == "monitor" 46 | 47 | response.map { |r| self.class.with_tracking(api_resource, r) } 48 | end 49 | end 50 | 51 | def create(api_resource, attributes) 52 | response = request :post, "/api/v1/#{api_resource}", body: attributes 53 | response = response.fetch(:data).first if api_resource == "slo" 54 | response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests" 55 | self.class.with_tracking(api_resource, response) 56 | end 57 | 58 | def update(api_resource, id, attributes) 59 | response = request :put, "/api/v1/#{api_resource}/#{id}", body: attributes 60 | response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests" 61 | self.class.with_tracking(api_resource, response) 62 | end 63 | 64 | # - force=true to not dead-lock on dependent monitors+slos 65 | # external dependency on kennel managed resources is their problem, we don't block on it 66 | # (?force=true did not work, force for dashboard is not documented but does not blow up) 67 | def delete(api_resource, id) 68 | if api_resource == "synthetics/tests" 69 | # https://docs.datadoghq.com/api/latest/synthetics/#delete-tests 70 | request :post, "/api/v1/#{api_resource}/delete", body: { public_ids: [id] }, ignore_404: true 71 | else 72 | request :delete, "/api/v1/#{api_resource}/#{id}", params: { force: "true" }, ignore_404: true 73 | end 74 | end 75 | 76 | def fill_details!(api_resource, list) 77 | details_cache do |cache| 78 | Utils.parallel(list) { |a| fill_detail!(api_resource, a, cache) } 79 | end 80 | end 81 | 82 | private 83 | 84 | def with_pagination(enabled, params) 85 | return yield params unless enabled 86 | raise ArgumentError if params[:limit] || params[:offset] 87 | limit = 1000 88 | offset = 0 89 | all = [] 90 | 91 | loop do 92 | response = yield params.merge(limit: limit, offset: offset) 93 | all.concat response 94 | return all if response.size < limit 95 | offset += limit 96 | end 97 | end 98 | 99 | # Make diff work even though we cannot mass-fetch definitions 100 | def fill_detail!(api_resource, a, cache) 101 | args = [api_resource, a.fetch(:id)] 102 | full = cache.fetch(args, a.fetch(:modified_at)) { show(*args) } 103 | a.merge!(full) 104 | end 105 | 106 | def details_cache(&block) 107 | cache = FileCache.new CACHE_FILE, Kennel::VERSION 108 | cache.open(&block) 109 | end 110 | 111 | def request(method, path, body: nil, params: {}, ignore_404: false) 112 | path = "#{path}?#{Faraday::FlatParamsEncoder.encode(params)}" if params.any? 113 | with_cache ENV["FORCE_GET_CACHE"] && method == :get, path do 114 | response = nil 115 | tries = 2 116 | 117 | tries.times do |i| 118 | response = Utils.retry Faraday::ConnectionFailed, Faraday::TimeoutError, times: 2 do 119 | @client.send(method, path) do |request| 120 | request.body = JSON.generate(body) if body 121 | request.headers["Content-type"] = "application/json" 122 | request.headers["DD-API-KEY"] = @api_key 123 | request.headers["DD-APPLICATION-KEY"] = @app_key 124 | end 125 | end 126 | 127 | rate_limit = RateLimitParams.new( 128 | limit: response.headers["x-ratelimit-limit"], 129 | period: response.headers["x-ratelimit-period"], 130 | remaining: response.headers["x-ratelimit-remaining"], 131 | reset: response.headers["x-ratelimit-reset"], 132 | name: response.headers["x-ratelimit-name"] 133 | ) 134 | 135 | if response.status == 429 136 | message = "Datadog rate limit #{rate_limit.name.inspect} hit" 137 | message += " (#{rate_limit.limit} requests per #{rate_limit.period} seconds)" 138 | message += "; sleeping #{rate_limit.reset} seconds before trying again" 139 | Kennel.err.puts message 140 | sleep rate_limit.reset.to_f 141 | redo 142 | end 143 | 144 | break if i == tries - 1 || method != :get || response.status < 500 145 | Kennel.err.puts "Retrying on server error #{response.status} for #{path}" 146 | end 147 | 148 | if !response.success? && (response.status != 404 || !ignore_404) 149 | message = "Error #{response.status} during #{method.upcase} #{path}\n" 150 | message << "request:\n#{JSON.pretty_generate(body)}\nresponse:\n" if body 151 | message << response.body.encode(message.encoding, invalid: :replace, undef: :replace) 152 | raise message 153 | end 154 | 155 | if response.body.empty? 156 | {} 157 | else 158 | JSON.parse(response.body, symbolize_names: true) 159 | end 160 | end 161 | end 162 | 163 | # allow caching all requests to speedup/benchmark logic that includes repeated requests 164 | def with_cache(enabled, key) 165 | return yield unless enabled 166 | dir = "tmp/cache" 167 | FileUtils.mkdir_p(dir) unless File.directory?(dir) 168 | file = "#{dir}/#{key.delete("/?=")}" # TODO: encode nicely 169 | if File.exist?(file) 170 | Marshal.load(File.read(file)) # rubocop:disable Security/MarshalLoad 171 | else 172 | result = yield 173 | File.write(file, Marshal.dump(result)) 174 | result 175 | end 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/kennel/attribute_differ.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "diff/lcs" 4 | 5 | module Kennel 6 | class AttributeDiffer 7 | def initialize 8 | # min '2' because: -1 makes no sense, 0 does not work with * 2 math, 1 says '1 lines' 9 | @max_diff_lines = [Integer(ENV.fetch("MAX_DIFF_LINES", "50")), 2].max 10 | super 11 | end 12 | 13 | def format(type, field, old, new = nil) 14 | multiline = false 15 | if type == "+" 16 | temp = pretty_inspect(new) 17 | new = pretty_inspect(old) 18 | old = temp 19 | elsif old.is_a?(String) && new.is_a?(String) && (old.include?("\n") || new.include?("\n")) 20 | multiline = true 21 | else # ~ and - 22 | old = pretty_inspect(old) 23 | new = pretty_inspect(new) 24 | end 25 | 26 | message = 27 | if multiline 28 | " #{type}#{field}\n" + 29 | multiline_diff(old, new).map { |l| " #{l}" }.join("\n") 30 | elsif (old + new).size > 100 31 | " #{type}#{field}\n" \ 32 | " #{old} ->\n" \ 33 | " #{new}" 34 | else 35 | " #{type}#{field} #{old} -> #{new}" 36 | end 37 | 38 | truncate(message) 39 | end 40 | 41 | private 42 | 43 | # display diff for multi-line strings 44 | # must stay readable when color is off too 45 | def multiline_diff(old, new) 46 | Diff::LCS.sdiff(old.split("\n", -1), new.split("\n", -1)).flat_map do |diff| 47 | case diff.action 48 | when "-" 49 | Console.color(:red, "- #{diff.old_element}") 50 | when "+" 51 | Console.color(:green, "+ #{diff.new_element}") 52 | when "!" 53 | [ 54 | Console.color(:red, "- #{diff.old_element}"), 55 | Console.color(:green, "+ #{diff.new_element}") 56 | ] 57 | else 58 | " #{diff.old_element}" 59 | end 60 | end 61 | end 62 | 63 | def truncate(message) 64 | warning = Console.color( 65 | :magenta, 66 | " (Diff for this item truncated after #{@max_diff_lines} lines. " \ 67 | "Rerun with MAX_DIFF_LINES=#{@max_diff_lines * 2} to see more)" 68 | ) 69 | StringUtils.truncate_lines(message, to: @max_diff_lines, warning: warning) 70 | end 71 | 72 | # TODO: use awesome-print or similar, but it has too many monkey-patches 73 | # https://github.com/amazing-print/amazing_print/issues/36 74 | def pretty_inspect(object) 75 | string = object.inspect.dup 76 | string.gsub!(/:([a-z_]+)=>/, "\\1: ") 77 | 10.times do 78 | string.gsub!(/{(\S.*?\S)}/, "{ \\1 }") || break 79 | end 80 | string 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/kennel/console.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module Console 4 | COLORS = { red: 31, green: 32, yellow: 33, cyan: 36, magenta: 35, default: 0 }.freeze 5 | 6 | class TeeIO < IO 7 | def initialize(ios) 8 | super(0) # called with fake file descriptor 0, so we can call super and get a proper class 9 | @ios = ios 10 | end 11 | 12 | def write(string) 13 | @ios.each { |io| io.write string } 14 | end 15 | end 16 | 17 | class << self 18 | def tty? 19 | !ENV["CI"] && (Kennel.in.tty? || Kennel.err.tty?) 20 | end 21 | 22 | def ask?(question) 23 | Kennel.err.printf color(:red, "#{question} - press 'y' to continue: ", force: true) 24 | begin 25 | Kennel.in.gets.chomp == "y" 26 | rescue Interrupt # do not show a backtrace if user decides to Ctrl+C here 27 | Kennel.err.print "\n" 28 | exit 1 29 | end 30 | end 31 | 32 | def color(color, text, force: false) 33 | return text unless force || Kennel.out.tty? 34 | 35 | "\e[#{COLORS.fetch(color)}m#{text}\e[0m" 36 | end 37 | 38 | def capture_stdout 39 | old = Kennel.out 40 | Kennel.out = StringIO.new 41 | yield 42 | Kennel.out.string 43 | ensure 44 | Kennel.out = old 45 | end 46 | 47 | def capture_stderr 48 | old = Kennel.err 49 | Kennel.err = StringIO.new 50 | yield 51 | Kennel.err.string 52 | ensure 53 | Kennel.err = old 54 | end 55 | 56 | def tee_output 57 | old_stdout = Kennel.out 58 | old_stderr = Kennel.err 59 | capture = StringIO.new 60 | Kennel.out = TeeIO.new([capture, Kennel.out]) 61 | Kennel.err = TeeIO.new([capture, Kennel.err]) 62 | yield 63 | capture.string 64 | ensure 65 | Kennel.out = old_stdout 66 | Kennel.err = old_stderr 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/kennel/file_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tempfile" 4 | 5 | # cache that reads everything from a single file 6 | # - avoids doing multiple disk reads while iterating all definitions 7 | # - has a global expiry to not keep deleted resources forever 8 | module Kennel 9 | class FileCache 10 | def initialize(file, cache_version) 11 | @file = file 12 | @cache_version = cache_version 13 | @now = Time.now.to_i 14 | @expires = @now + (30 * 24 * 60 * 60) # 1 month 15 | end 16 | 17 | def open 18 | @data = load_data || {} 19 | begin 20 | expire_old_data 21 | yield self 22 | ensure 23 | persist 24 | end 25 | end 26 | 27 | def fetch(key, key_version) 28 | old_value, old_version = @data[key] 29 | expected_version = [key_version, @cache_version] 30 | return old_value if old_version == expected_version 31 | 32 | new_value = yield 33 | @data[key] = [new_value, expected_version, @expires] 34 | new_value 35 | end 36 | 37 | private 38 | 39 | def load_data 40 | Marshal.load(File.read(@file)) # rubocop:disable Security/MarshalLoad 41 | rescue Errno::ENOENT, TypeError, ArgumentError 42 | nil 43 | end 44 | 45 | def persist 46 | dir = File.dirname(@file) 47 | FileUtils.mkdir_p(dir) unless File.directory?(dir) 48 | 49 | Tempfile.create "kennel-file-cache", dir do |tmp| 50 | Marshal.dump @data, tmp 51 | tmp.flush 52 | File.rename tmp.path, @file 53 | end 54 | end 55 | 56 | # keep the cache small to make loading it fast (5MB ~= 100ms) 57 | # - delete expired keys 58 | # - delete what would be deleted anyway when updating 59 | def expire_old_data 60 | @data.reject! do |(_api_resource, _id), (_value, (_key_version, cache_version), expires)| 61 | expires < @now || cache_version != @cache_version 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/kennel/filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kennel 4 | class Filter 5 | attr_reader :project_filter 6 | 7 | def initialize 8 | # read early so we fail fast on invalid user input 9 | @tracking_id_filter = read_tracking_id_filter_from_env 10 | @project_filter = read_project_filter_from_env 11 | end 12 | 13 | def filter_projects(projects) 14 | filter_resources(projects, :kennel_id, project_filter, "projects", "PROJECT") 15 | end 16 | 17 | def filter_parts(parts) 18 | filter_resources(parts, :tracking_id, tracking_id_filter, "resources", "TRACKING_ID") 19 | end 20 | 21 | def filtering? 22 | !project_filter.nil? 23 | end 24 | 25 | def matches_project_id?(project_id) 26 | !filtering? || project_filter.include?(project_id) 27 | end 28 | 29 | def matches_tracking_id?(tracking_id) 30 | return true unless filtering? 31 | return tracking_id_filter.include?(tracking_id) if tracking_id_filter 32 | 33 | project_id = tracking_id.split(":").first 34 | project_filter.include?(project_id) 35 | end 36 | 37 | def tracking_id_for_path(tracking_id) 38 | return tracking_id unless tracking_id.end_with?(".json") 39 | tracking_id.sub("generated/", "").sub(".json", "").sub("/", ":") 40 | end 41 | 42 | private 43 | 44 | attr_reader :tracking_id_filter 45 | 46 | # needs to be called after read_tracking_id_filter_from_env 47 | def read_project_filter_from_env 48 | project_names = ENV["PROJECT"]&.split(",")&.sort&.uniq 49 | tracking_project_names = tracking_id_filter&.map { |id| id.split(":", 2).first }&.sort&.uniq 50 | if project_names && tracking_project_names && project_names != tracking_project_names 51 | # avoid everything being filtered out 52 | raise "do not set a different PROJECT= when using TRACKING_ID=" 53 | end 54 | (project_names || tracking_project_names) 55 | end 56 | 57 | def read_tracking_id_filter_from_env 58 | return unless (tracking_id = ENV["TRACKING_ID"]) 59 | tracking_id.split(",").map do |id| 60 | # allow users to paste the generated/ path of an objects to update it without manually converting 61 | tracking_id_for_path(id) 62 | end.sort.uniq 63 | end 64 | 65 | def filter_resources(resources, by, expected, name, env) 66 | return resources unless expected 67 | 68 | expected = expected.uniq 69 | before = resources.dup 70 | resources = resources.select { |p| expected.include?(p.send(by)) } 71 | keeping = resources.uniq(&by).size 72 | return resources if keeping == expected.size 73 | 74 | raise <<~MSG.rstrip 75 | #{env}=#{expected.join(",")} matched #{keeping} #{name}, try any of these: 76 | #{before.map(&by).sort.uniq.join("\n")} 77 | MSG 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/kennel/github_reporter.rb: -------------------------------------------------------------------------------- 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).report(&block) 12 | end 13 | end 14 | 15 | def initialize(token, ref: "HEAD") 16 | @token = token 17 | commit = Utils.capture_sh("git show #{ref}") 18 | @sha = commit[/^Merge: \S+ (\S+)/, 1] || commit[/\Acommit (\S+)/, 1] || raise("Unable to find commit") 19 | @pr = 20 | commit[/^\s+.*\(#(\d+)\)/, 1] || # from squash 21 | commit[/^\s+Merge pull request #(\d+)/, 1] # from merge with unmodified commit message 22 | @repo_part = ENV["GITHUB_REPOSITORY"] || begin 23 | origin = ENV["PROJECT_REPOSITORY"] || Utils.capture_sh("git remote -v").split("\n").first 24 | origin[%r{github\.com[:/](\S+?)(\.git|$)}, 1] || raise("no origin found in #{origin}") 25 | end 26 | end 27 | 28 | def report(&block) 29 | output = Console.tee_output(&block).strip 30 | rescue StandardError 31 | output = "Error:\n#{$ERROR_INFO.message}" 32 | raise 33 | ensure 34 | comment "```\n#{output || "Error"}\n```" 35 | end 36 | 37 | # https://developer.github.com/v3/repos/comments/#create-a-commit-comment 38 | def comment(body) 39 | # truncate to maximum allowed comment size for github to avoid 422 40 | if body.bytesize > MAX_COMMENT_SIZE 41 | body = body.byteslice(0, MAX_COMMENT_SIZE - TRUNCATED_MSG.bytesize) + TRUNCATED_MSG 42 | end 43 | 44 | path = (@pr ? "/repos/#{@repo_part}/issues/#{@pr}/comments" : "/repos/#{@repo_part}/commits/#{@sha}/comments") 45 | post path, body: body 46 | end 47 | 48 | private 49 | 50 | def post(path, data) 51 | url = "https://api.github.com#{path}" 52 | response = Faraday.post(url, data.to_json, authorization: "token #{@token}") 53 | raise "failed to POST to github:\n#{url} -> #{response.status}\n#{response.body}" unless response.status == 201 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/kennel/id_map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | class IdMap 4 | NEW = :new # will be created during this run 5 | 6 | def initialize 7 | @map = Hash.new { |h, k| h[k] = {} } 8 | end 9 | 10 | def get(type, tracking_id) 11 | @map[type][tracking_id] 12 | end 13 | 14 | def set(type, tracking_id, id) 15 | @map[type][tracking_id] = id 16 | end 17 | 18 | def new?(type, tracking_id) 19 | @map[type][tracking_id] == NEW 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/kennel/importer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kennel 4 | class Importer 5 | # bring important fields to the top 6 | SORT_ORDER = [ 7 | *Kennel::Models::Record::TITLE_FIELDS, :id, :kennel_id, :type, :tags, :query, :sli_specification, 8 | *Models::Record.subclasses.flat_map { |k| k::TRACKING_FIELDS }, 9 | :template_variables 10 | ].freeze 11 | 12 | def initialize(api) 13 | @api = api 14 | end 15 | 16 | def import(resource, id) 17 | model = 18 | Kennel::Models::Record.subclasses.detect { |c| c.api_resource == resource } || 19 | raise(ArgumentError, "#{resource} is not supported") 20 | 21 | data = @api.show(model.api_resource, id) 22 | 23 | id = data.fetch(:id) # keep native value 24 | model.normalize({}, data) # removes id 25 | data[:id] = id 26 | 27 | # title will have the lock symbol we need to remove when re-importing 28 | title_field = Kennel::Models::Record::TITLE_FIELDS.detect { |f| data[f] } 29 | title = data.fetch(title_field) 30 | title.tr!(Kennel::Models::Record::LOCK, "") 31 | 32 | # calculate or reuse kennel_id 33 | data[:kennel_id] = 34 | if (tracking_id = model.parse_tracking_id(data)) 35 | model.remove_tracking_id(data) 36 | tracking_id.split(":").last 37 | else 38 | Kennel::StringUtils.parameterize(title) 39 | end 40 | 41 | case resource 42 | when "monitor" 43 | raise "Import the synthetic test page and not the monitor" if data[:type] == "synthetics alert" 44 | 45 | # flatten monitor options so they are all on the base which is how Monitor builds them 46 | data.merge!(data.delete(:options)) 47 | data.merge!(data.delete(:thresholds) || {}) 48 | 49 | # clean up values that are the default 50 | if !!data[:notify_no_data] == !Models::Monitor::SKIP_NOTIFY_NO_DATA_TYPES.include?(data[:type]) 51 | data.delete(:notify_no_data) 52 | end 53 | data.delete(:notify_audit) unless data[:notify_audit] # Monitor uses false by default 54 | 55 | # keep all values that are settable 56 | data = data.slice(*model.instance_methods) 57 | 58 | # make query use critical method if it matches 59 | critical = data[:critical] 60 | query = data[:query] 61 | if query && critical 62 | query.sub!(/([><=]) (#{Regexp.escape(critical.to_f.to_s)}|#{Regexp.escape(critical.to_i.to_s)})$/, "\\1 \#{critical}") 63 | end 64 | 65 | # using float in query is not allowed, so convert here 66 | data[:critical] = data[:critical].to_i if data[:type] == "event alert" 67 | 68 | data[:type] = "query alert" if data[:type] == "metric alert" 69 | 70 | link_composite_monitors(data) 71 | when "dashboard" 72 | widgets = data[:widgets]&.flat_map { |widget| widget.dig(:definition, :widgets) || [widget] } 73 | widgets&.each do |widget| 74 | convert_widget_to_compact_format!(widget) 75 | dry_up_widget_metadata!(widget) 76 | (widget.dig(:definition, :markers) || []).each { |m| m[:label]&.delete! " " } 77 | end 78 | when "synthetics/tests" 79 | data[:locations] = :all if data[:locations].sort == Kennel::Models::SyntheticTest::LOCATIONS.sort 80 | else 81 | # noop 82 | end 83 | 84 | data.delete(:tags) if data[:tags] == [] # do not create super + [] call 85 | 86 | # simplify template_variables to array of string when possible 87 | if (vars = data[:template_variables]) 88 | vars.map! { |v| v[:default] == "*" && v[:prefix] == v[:name] ? v[:name] : v } 89 | end 90 | 91 | pretty = pretty_print(data).lstrip.gsub("\\#", "#") 92 | <<~RUBY 93 | #{model.name}.new( 94 | self, 95 | #{pretty} 96 | ) 97 | RUBY 98 | end 99 | 100 | private 101 | 102 | def link_composite_monitors(data) 103 | if data[:type] == "composite" 104 | data[:query].gsub!(/\d+/) do |id| 105 | object = @api.show("monitor", id) 106 | tracking_id = Kennel::Models::Monitor.parse_tracking_id(object) 107 | tracking_id ? "%{#{tracking_id}}" : id 108 | rescue StandardError # monitor not found 109 | id # keep the id 110 | end 111 | end 112 | end 113 | 114 | # reduce duplication in imports by using dry `q: :metadata` when possible 115 | def dry_up_widget_metadata!(widget) 116 | (widget.dig(:definition, :requests) || []).each do |request| 117 | next unless request.is_a?(Hash) 118 | next unless (metadata = request[:metadata]) 119 | next unless (query = request[:q]&.dup) 120 | metadata.each do |m| 121 | next unless (exp = m[:expression]) 122 | query.sub!(exp, "") 123 | end 124 | request[:q] = :metadata if query.delete(", ") == "" 125 | end 126 | end 127 | 128 | # new api format is very verbose, so use old dry format when possible 129 | # dd randomly chooses query0 or query1 130 | def convert_widget_to_compact_format!(widget) 131 | (widget.dig(:definition, :requests) || []).each do |request| 132 | next unless request.is_a?(Hash) 133 | next if request[:formulas] && ![[{ formula: "query1" }], [{ formula: "query0" }]].include?(request[:formulas]) 134 | next if request[:queries]&.size != 1 135 | next if request[:queries].any? { |q| q[:data_source] != "metrics" } 136 | next if widget.dig(:definition, :type) != request[:response_format] 137 | request.delete(:formulas) 138 | request.delete(:response_format) 139 | request[:q] = request.delete(:queries).first.fetch(:query) 140 | end 141 | end 142 | 143 | def pretty_print(hash) 144 | sort_widgets hash 145 | 146 | sort_hash(hash).map do |k, v| 147 | pretty_value = 148 | if v.is_a?(Hash) || (v.is_a?(Array) && !v.all? { |e| e.is_a?(String) }) 149 | # update answer here when changing https://stackoverflow.com/questions/8842546/best-way-to-pretty-print-a-hash 150 | # (exclude last indent gsub) 151 | pretty = JSON.pretty_generate(v) 152 | .gsub(": null", ": nil") 153 | .gsub(/(^\s*)"([a-zA-Z][a-zA-Z\d_]*)":/, "\\1\\2:") # "foo": 1 -> foo: 1 154 | .gsub(/: \[\n\s+\]/, ": []") # empty arrays on a single line 155 | .gsub(/: \{\n\s+\}/, ": {}") # empty hash on a single line 156 | .gsub('q: "metadata"', "q: :metadata") # bring symbols back 157 | .gsub(/^/, " ") # indent 158 | pretty = convert_strings_to_heredoc(pretty) 159 | 160 | "\n#{pretty}\n " 161 | elsif [:message, :description].include?(k) 162 | "\n <<~TEXT\n#{v.to_s.each_line.map { |l| l.strip.empty? ? "\n" : " #{l}" }.join}\n \#{super()}\n TEXT\n " 163 | elsif k == :tags 164 | " super() + #{v.inspect} " 165 | else 166 | " #{v.inspect} " 167 | end 168 | " #{k}: -> {#{pretty_value}}" 169 | end.join(",\n") 170 | end 171 | 172 | def convert_strings_to_heredoc(text) 173 | text.gsub(/^( *)([^" ]+ *)"([^"]+\\n[^"]+)"(,)?\n/) do 174 | indent = $1 175 | prefix = $2 176 | string = $3 177 | comma = $4 178 | <<~CODE.gsub(/ +$/, "") 179 | #{indent}#{prefix}<<~TXT#{comma} 180 | #{indent} #{string.gsub("\\n", "\n#{indent} ").rstrip} 181 | #{indent}TXT 182 | CODE 183 | end 184 | end 185 | 186 | # sort dashboard widgets + nesting 187 | def sort_widgets(outer) 188 | outer[:widgets]&.each do |widgets| 189 | definition = widgets[:definition] 190 | definition.replace sort_hash(definition) 191 | sort_widgets definition 192 | end 193 | end 194 | 195 | # important to the front and rest deterministic 196 | def sort_hash(hash) 197 | hash.sort_by { |k, _| [SORT_ORDER.index(k) || 999, k] }.to_h 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /lib/kennel/models/base.rb: -------------------------------------------------------------------------------- 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 | @kennel_id ||= StringUtils.snake_case kennel_id_base 14 | end 15 | 16 | def name 17 | self.class.name 18 | end 19 | 20 | def to_json # rubocop:disable Lint/ToJSON 21 | raise NotImplementedError, "Use as_json" 22 | end 23 | 24 | private 25 | 26 | # hook to allow overwriting id generation to remove custom module scopes 27 | def kennel_id_base 28 | name = self.class.name 29 | if name.start_with?("Kennel::") # core objects would always generate the same id 30 | raise_with_location ArgumentError, "Set :kennel_id in #{name}" 31 | end 32 | name 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/kennel/models/project.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module Models 4 | class Project < Base 5 | settings :team, :parts, :tags, :mention, :name, :kennel_id 6 | defaults( 7 | tags: -> { team.tags }, 8 | mention: -> { team.mention } 9 | ) 10 | 11 | def self.file_location 12 | return @file_location if defined?(@file_location) 13 | if (location = instance_methods(false).first) 14 | @file_location = instance_method(location).source_location.first.sub("#{Bundler.root}/", "") 15 | else 16 | @file_location = nil 17 | end 18 | end 19 | 20 | def validated_parts 21 | all = parts 22 | unless all.is_a?(Array) && all.all? { |part| part.is_a?(Record) } 23 | raise "Project #{kennel_id} #parts must return an array of Records" 24 | end 25 | 26 | validate_parts(all) 27 | all 28 | end 29 | 30 | private 31 | 32 | # hook for users to add custom validations via `prepend` 33 | def validate_parts(parts) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/kennel/models/record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module Models 4 | class Record < Base 5 | include OptionalValidations 6 | 7 | # Apart from if you just don't like the default for some reason, 8 | # overriding MARKER_TEXT allows for namespacing within the same 9 | # Datadog account. If you run one Kennel setup with marker text 10 | # A and another with marker text B (assuming that A isn't a 11 | # substring of B and vice versa), then the two Kennel setups will 12 | # operate independently of each other, not trampling over each 13 | # other's objects. 14 | # 15 | # This could be useful for allowing multiple products / projects 16 | # / teams to share a Datadog account but otherwise largely 17 | # operate independently of each other. In particular, it can be 18 | # useful for running a "dev" or "staging" instance of Kennel 19 | # in the same account as, but mostly isolated from, a "production" 20 | # instance. 21 | MARKER_TEXT = ENV.fetch("KENNEL_MARKER_TEXT", "Managed by kennel") 22 | 23 | LOCK = "\u{1F512}" 24 | TRACKING_FIELDS = [:message, :description].freeze 25 | READONLY_ATTRIBUTES = [ 26 | :deleted, :id, :created, :created_at, :creator, :org_id, :modified, :modified_at, 27 | :klass, :tracking_id # added by syncer.rb 28 | ].freeze 29 | TITLE_FIELDS = [:name, :title].freeze # possible fields that could have the title 30 | ALLOWED_KENNEL_ID_CHARS = "a-zA-Z_\\d.-" 31 | ALLOWED_KENNEL_ID_SEGMENT = /[#{ALLOWED_KENNEL_ID_CHARS}]+/ 32 | ALLOWED_KENNEL_ID_FULL = "#{ALLOWED_KENNEL_ID_SEGMENT}:#{ALLOWED_KENNEL_ID_SEGMENT}".freeze 33 | ALLOWED_KENNEL_ID_REGEX = /\A#{ALLOWED_KENNEL_ID_FULL}\z/ 34 | 35 | settings :id, :kennel_id 36 | 37 | defaults(id: nil) 38 | 39 | class << self 40 | def parse_any_url(url) 41 | subclasses.detect do |s| 42 | if (id = s.parse_url(url)) 43 | break s.api_resource, id 44 | end 45 | end 46 | end 47 | 48 | def api_resource_map 49 | subclasses.to_h { |s| [s.api_resource, s] } 50 | end 51 | 52 | def parse_tracking_id(a) 53 | a[self::TRACKING_FIELD].to_s[/-- #{Regexp.escape(MARKER_TEXT)} (#{ALLOWED_KENNEL_ID_FULL})/, 1] 54 | end 55 | 56 | # TODO: combine with parse into a single method or a single regex 57 | def remove_tracking_id(a) 58 | value = a[self::TRACKING_FIELD] 59 | a[self::TRACKING_FIELD] = 60 | value.dup.sub!(/\n?-- #{Regexp.escape(MARKER_TEXT)} .*/, "") || 61 | raise("did not find tracking id in #{value}") 62 | end 63 | 64 | private 65 | 66 | def normalize(_expected, actual) 67 | self::READONLY_ATTRIBUTES.each { |k| actual.delete k } 68 | end 69 | 70 | def ignore_default(expected, actual, defaults) 71 | definitions = [actual, expected] 72 | defaults.each do |key, default| 73 | if definitions.all? { |r| !r.key?(key) || r[key] == default } 74 | actual.delete(key) 75 | expected.delete(key) 76 | end 77 | end 78 | end 79 | end 80 | 81 | attr_reader :project, :as_json 82 | 83 | def initialize(project, ...) 84 | raise ArgumentError, "First argument must be a project, not #{project.class}" unless project.is_a?(Project) 85 | @project = project 86 | super(...) 87 | end 88 | 89 | def diff(actual) 90 | expected = as_json 91 | expected.delete(:id) 92 | 93 | self.class.send(:normalize, expected, actual) 94 | 95 | return [] if actual == expected # Hashdiff is slow, this is fast 96 | 97 | # strict: ignore Integer vs Float 98 | # similarity: show diff when not 100% similar 99 | # use_lcs: saner output 100 | result = Hashdiff.diff(actual, expected, use_lcs: false, strict: false, similarity: 1) 101 | raise "Empty diff detected: guard condition failed" if result.empty? 102 | result 103 | end 104 | 105 | def tracking_id 106 | @tracking_id ||= begin 107 | id = "#{project.kennel_id}:#{kennel_id}" 108 | unless id.match?(ALLOWED_KENNEL_ID_REGEX) # <-> parse_tracking_id 109 | raise "Bad kennel/tracking id: #{id.inspect} must match #{ALLOWED_KENNEL_ID_REGEX}" 110 | end 111 | id 112 | end 113 | end 114 | 115 | def resolve_linked_tracking_ids!(*) 116 | end 117 | 118 | def add_tracking_id 119 | json = as_json 120 | if self.class.parse_tracking_id(json) 121 | raise "#{safe_tracking_id} Remove \"-- #{MARKER_TEXT}\" line from #{self.class::TRACKING_FIELD} to copy a resource" 122 | end 123 | json[self.class::TRACKING_FIELD] = 124 | "#{json[self.class::TRACKING_FIELD]}\n" \ 125 | "-- #{MARKER_TEXT} #{tracking_id} in #{project.class.file_location}, do not modify manually".lstrip 126 | end 127 | 128 | def remove_tracking_id 129 | self.class.remove_tracking_id(as_json) 130 | end 131 | 132 | def build_json 133 | { 134 | id: id 135 | }.compact 136 | end 137 | 138 | def build 139 | @as_json = build_json 140 | (id = @as_json.delete(:id)) && @as_json[:id] = id 141 | validate_json(@as_json) 142 | @as_json 143 | end 144 | 145 | # Can raise DisallowedUpdateError 146 | def validate_update!(_diffs) 147 | end 148 | 149 | def invalid_update!(field, old_value, new_value) 150 | raise DisallowedUpdateError, "#{safe_tracking_id} Datadog does not allow update of #{field} (#{old_value.inspect} -> #{new_value.inspect})" 151 | end 152 | 153 | # For use during error handling 154 | def safe_tracking_id 155 | tracking_id 156 | rescue StandardError 157 | "" 158 | end 159 | 160 | private 161 | 162 | def validate_json(data) 163 | bad = Kennel::Utils.all_keys(data).grep_v(Symbol).sort.uniq 164 | return if bad.empty? 165 | invalid!( 166 | OptionalValidations::UNIGNORABLE, 167 | "Only use Symbols as hash keys to avoid permanent diffs when updating.\n" \ 168 | "Change these keys to be symbols (usually 'foo' => 1 --> 'foo': 1)\n" \ 169 | "#{bad.map(&:inspect).join("\n")}" 170 | ) 171 | end 172 | 173 | def resolve(value, type, id_map, force:) 174 | return value unless tracking_id?(value) 175 | resolve_link(value, type, id_map, force: force) 176 | end 177 | 178 | def tracking_id?(id) 179 | id.is_a?(String) && id.include?(":") 180 | end 181 | 182 | def resolve_link(sought_tracking_id, sought_type, id_map, force:) 183 | if id_map.new?(sought_type.to_s, sought_tracking_id) 184 | if force 185 | raise UnresolvableIdError, <<~MESSAGE 186 | #{tracking_id} #{sought_type} #{sought_tracking_id} was referenced but is also created by the current run. 187 | It could not be created because of a circular dependency. Try creating only some of the resources. 188 | MESSAGE 189 | else 190 | nil # will be re-resolved after the linked object was created 191 | end 192 | elsif (id = id_map.get(sought_type.to_s, sought_tracking_id)) 193 | id 194 | else 195 | raise UnresolvableIdError, <<~MESSAGE 196 | #{tracking_id} Unable to find #{sought_type} #{sought_tracking_id} 197 | This is either because it doesn't exist, and isn't being created by the current run; 198 | or it does exist, but is being deleted. 199 | MESSAGE 200 | end 201 | end 202 | 203 | def raise_with_location(error, message) 204 | super(error, "#{message} for project #{project.kennel_id}") 205 | end 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /lib/kennel/models/slo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module Models 4 | class Slo < Record 5 | include TagsValidation 6 | 7 | READONLY_ATTRIBUTES = [ 8 | *superclass::READONLY_ATTRIBUTES, 9 | :type_id, :monitor_tags, :target_threshold, :timeframe, :warning_threshold 10 | ].freeze 11 | TRACKING_FIELD = :description 12 | DEFAULTS = { 13 | description: nil, 14 | query: nil, 15 | groups: nil, 16 | monitor_ids: [], 17 | thresholds: [] 18 | }.freeze 19 | 20 | settings :type, :description, :thresholds, :query, :tags, :monitor_ids, :monitor_tags, :name, :groups, :sli_specification 21 | 22 | defaults( 23 | tags: -> { @project.tags }, 24 | query: -> { DEFAULTS.fetch(:query) }, 25 | description: -> { DEFAULTS.fetch(:description) }, 26 | monitor_ids: -> { DEFAULTS.fetch(:monitor_ids) }, 27 | thresholds: -> { DEFAULTS.fetch(:thresholds) }, 28 | groups: -> { DEFAULTS.fetch(:groups) } 29 | ) 30 | 31 | def build_json 32 | data = super.merge( 33 | name: "#{name}#{LOCK}", 34 | description: description, 35 | thresholds: thresholds, 36 | monitor_ids: monitor_ids, 37 | tags: tags, 38 | type: type 39 | ) 40 | 41 | if type == "time_slice" 42 | data[:sli_specification] = sli_specification 43 | elsif (v = query) 44 | data[:query] = v 45 | end 46 | 47 | if (v = groups) 48 | data[:groups] = v 49 | end 50 | 51 | data 52 | end 53 | 54 | def self.api_resource 55 | "slo" 56 | end 57 | 58 | def self.url(id) 59 | Utils.path_to_url "/slo?slo_id=#{id}" 60 | end 61 | 62 | def self.parse_url(url) 63 | url[/[?&]slo_id=([a-z\d]{10,})/, 1] || url[/\/slo\/([a-z\d]{10,})\/edit(\?|$)/, 1] 64 | end 65 | 66 | def resolve_linked_tracking_ids!(id_map, **args) 67 | return unless (ids = as_json[:monitor_ids]) # ignore_default can remove it 68 | as_json[:monitor_ids] = ids.map do |id| 69 | resolve(id, :monitor, id_map, **args) || id 70 | end 71 | end 72 | 73 | def self.normalize(expected, actual) 74 | super 75 | 76 | # remove readonly values 77 | actual[:thresholds]&.each do |threshold| 78 | threshold.delete(:warning_display) 79 | threshold.delete(:target_display) 80 | end 81 | 82 | # tags come in a semi-random order and order is never updated 83 | expected[:tags]&.sort! 84 | actual[:tags].sort! 85 | 86 | ignore_default(expected, actual, DEFAULTS) 87 | end 88 | 89 | private 90 | 91 | def validate_json(data) 92 | super 93 | 94 | # datadog does not allow uppercase tags for slos 95 | bad_tags = data[:tags].grep(/[A-Z]/) 96 | if bad_tags.any? 97 | invalid! :tags_are_upper_case, "Tags must not be upper case (bad tags: #{bad_tags.sort.inspect})" 98 | end 99 | 100 | # prevent "Invalid payload: The target is incorrect: target must be a positive number between (0.0, 100.0)" 101 | data[:thresholds]&.each do |threshold| 102 | target = threshold.fetch(:target) 103 | if !target || target <= 0 || target >= 100 104 | invalid! :threshold_target_invalid, "SLO threshold target must be > 0 and < 100" 105 | end 106 | end 107 | 108 | # warning must be <= critical 109 | if data[:thresholds].any? { |t| t[:warning] && t[:warning].to_f <= t[:critical].to_f } 110 | invalid! :warning_must_be_gt_critical, "Threshold warning must be greater-than critical value" 111 | end 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/kennel/models/synthetic_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module Models 4 | class SyntheticTest < Record 5 | include TagsValidation 6 | 7 | TRACKING_FIELD = :message 8 | DEFAULTS = {}.freeze 9 | READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [:status, :monitor_id] 10 | LOCATIONS = ["aws:ca-central-1", "aws:eu-north-1", "aws:eu-west-1", "aws:eu-west-3", "aws:eu-west-2", "aws:ap-south-1", "aws:us-west-2", "aws:us-west-1", "aws:sa-east-1", "aws:us-east-2", "aws:ap-northeast-1", "aws:ap-northeast-2", "aws:eu-central-1", "aws:ap-southeast-2", "aws:ap-southeast-1"].freeze 11 | 12 | settings :tags, :config, :message, :subtype, :type, :name, :locations, :options 13 | 14 | defaults( 15 | tags: -> { @project.tags }, 16 | message: -> { "\n\n#{project.mention}" } 17 | ) 18 | 19 | def build_json 20 | locations = locations() 21 | 22 | super.merge( 23 | message: message, 24 | tags: tags, 25 | config: config, 26 | type: type, 27 | subtype: subtype, 28 | options: options, 29 | name: "#{name}#{LOCK}", 30 | locations: locations == :all ? LOCATIONS : locations 31 | ) 32 | end 33 | 34 | def self.api_resource 35 | "synthetics/tests" 36 | end 37 | 38 | def self.url(id) 39 | Utils.path_to_url "/synthetics/details/#{id}" 40 | end 41 | 42 | def self.parse_url(url) 43 | url[/\/synthetics\/details\/([a-z\d-]{11,})/, 1] # id format is 1ab-2ab-3ab 44 | end 45 | 46 | def self.normalize(expected, actual) 47 | super 48 | 49 | # tags come in a semi-random order and order is never updated 50 | expected[:tags] = expected[:tags]&.sort 51 | actual[:tags] = actual[:tags]&.sort 52 | 53 | expected[:locations] = expected[:locations]&.sort 54 | actual[:locations] = actual[:locations]&.sort 55 | 56 | ignore_default(expected, actual, DEFAULTS) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/kennel/models/team.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module Models 4 | class Team < Base 5 | settings :mention, :tags, :renotify_interval, :kennel_id 6 | defaults( 7 | tags: -> { ["team:#{kennel_id.sub(/^teams_/, "").tr("_", "-")}"] }, 8 | renotify_interval: -> { 0 } 9 | ) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/kennel/optional_validations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module OptionalValidations 4 | ValidationMessage = Struct.new(:tag, :text) 5 | 6 | UNIGNORABLE = :unignorable 7 | UNUSED_IGNORES = :unused_ignores 8 | 9 | def self.included(base) 10 | base.settings :ignored_errors 11 | base.defaults(ignored_errors: -> { [] }) 12 | base.attr_reader :validation_errors 13 | end 14 | 15 | def initialize(...) 16 | super 17 | @validation_errors = [] 18 | end 19 | 20 | def invalid!(tag, message) 21 | validation_errors << ValidationMessage.new(tag, message) 22 | end 23 | 24 | def self.valid?(parts) 25 | parts_with_errors = parts.map { |p| [p, filter_validation_errors(p)] } 26 | return true if parts_with_errors.all? { |_, errors| errors.empty? } 27 | 28 | # print errors in order 29 | example_tag = nil 30 | Kennel.err.puts 31 | parts_with_errors.sort_by! { |p, _| p.safe_tracking_id } 32 | parts_with_errors.each do |part, errors| 33 | errors.each do |err| 34 | Kennel.err.puts "#{part.safe_tracking_id} [#{err.tag.inspect}] #{err.text.gsub("\n", " ")}" 35 | example_tag = err.tag unless err.tag == :unignorable 36 | end 37 | end 38 | Kennel.err.puts 39 | 40 | if example_tag 41 | Kennel.err.puts <<~MESSAGE 42 | If a particular error cannot be fixed, it can be marked as ignored via `ignored_errors`, e.g.: 43 | Kennel::Models::Monitor.new( 44 | ..., 45 | ignored_errors: [#{example_tag.inspect}] 46 | ) 47 | 48 | MESSAGE 49 | end 50 | 51 | false 52 | end 53 | 54 | def self.filter_validation_errors(part) 55 | errors = part.validation_errors 56 | ignored_tags = part.ignored_errors 57 | 58 | if errors.empty? # 95% case, so keep it fast 59 | if ignored_tags.empty? || ignored_tags.include?(UNUSED_IGNORES) 60 | [] 61 | else 62 | # tell users to remove the whole line and not just an element 63 | [ 64 | ValidationMessage.new( 65 | UNUSED_IGNORES, 66 | "`ignored_errors` is non-empty, but there are no errors to ignore. Remove `ignored_errors`" 67 | ) 68 | ] 69 | end 70 | else 71 | reported_errors = 72 | if ENV["NO_IGNORED_ERRORS"] # let users see what errors are suppressed 73 | errors 74 | else 75 | errors.select { |err| err.tag == UNIGNORABLE || !ignored_tags.include?(err.tag) } 76 | end 77 | 78 | # let users know when they can remove an ignore ... unless they don't care (for example for a generic monitor) 79 | unless ignored_tags.include?(UNUSED_IGNORES) 80 | unused_ignored_tags = ignored_tags - errors.map(&:tag) 81 | if unused_ignored_tags.any? 82 | reported_errors << ValidationMessage.new( 83 | UNUSED_IGNORES, 84 | "Unused ignores #{unused_ignored_tags.map(&:inspect).sort.uniq.join(" ")}. Remove these from `ignored_errors`" 85 | ) 86 | end 87 | end 88 | 89 | reported_errors 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/kennel/parts_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kennel 4 | class PartsSerializer 5 | def initialize(filter:) 6 | @filter = filter 7 | end 8 | 9 | def write(parts) 10 | Progress.progress "Storing" do 11 | existing = existing_files_and_folders 12 | used = write_changed(parts) 13 | FileUtils.rm_rf(existing - used) 14 | end 15 | end 16 | 17 | private 18 | 19 | attr_reader :filter 20 | 21 | def write_changed(parts) 22 | used = [] 23 | 24 | Utils.parallel(parts, max: 2) do |part| 25 | path = path_for_tracking_id(part.tracking_id) 26 | 27 | used << File.dirname(path) # we have 1 level of sub folders, so this is enough 28 | used << path 29 | 30 | content = part.as_json.merge(api_resource: part.class.api_resource) 31 | write_file_if_necessary(path, content) 32 | end 33 | 34 | used 35 | end 36 | 37 | def existing_files_and_folders 38 | paths = Dir["generated/**/*"] 39 | 40 | # when filtering we only need the files we are going to write 41 | if filter.filtering? 42 | paths.select! do |path| 43 | tracking_id = filter.tracking_id_for_path(path) 44 | filter.matches_tracking_id?(tracking_id) 45 | end 46 | end 47 | 48 | paths 49 | end 50 | 51 | def path_for_tracking_id(tracking_id) 52 | "generated/#{tracking_id.tr("/", ":").sub(":", "/")}.json" 53 | end 54 | 55 | def write_file_if_necessary(path, content) 56 | # NOTE: always generating is faster than JSON.load-ing and comparing 57 | content = JSON.pretty_generate(content) << "\n" 58 | 59 | # 99% case 60 | begin 61 | return if File.read(path) == content 62 | rescue Errno::ENOENT 63 | FileUtils.mkdir_p(File.dirname(path)) 64 | end 65 | 66 | # slow 1% case 67 | File.write(path, content) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/kennel/progress.rb: -------------------------------------------------------------------------------- 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, interval: 0.2, plain: false, &block) 8 | return progress_no_tty(name, &block) if plain || !Kennel.err.tty? 9 | 10 | Kennel.err.print "#{name} ... " 11 | 12 | stop = false 13 | result = nil 14 | 15 | spinner = Thread.new do 16 | animation = "-\\|/" 17 | count = 0 18 | loop do 19 | break if stop 20 | Kennel.err.print animation[count % animation.size] 21 | sleep interval 22 | Kennel.err.print "\b" 23 | count += 1 24 | end 25 | end 26 | 27 | time = Benchmark.realtime { result = block.call } 28 | 29 | stop = true 30 | begin 31 | spinner.run # wake thread, so it stops itself 32 | rescue ThreadError 33 | # thread was already dead, but we can't check with .alive? since it's a race condition 34 | end 35 | spinner.join 36 | Kennel.err.print "#{time.round(2)}s\n" 37 | 38 | result 39 | ensure 40 | stop = true # make thread stop without killing it 41 | end 42 | 43 | class << self 44 | private 45 | 46 | def progress_no_tty(name) 47 | Kennel.err.puts "#{name} ..." 48 | result = nil 49 | time = Benchmark.realtime { result = yield } 50 | Kennel.err.puts "#{name} ... #{time.round(2)}s" 51 | result 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/kennel/projects_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | class ProjectsProvider 4 | class AutoloadFailed < StandardError 5 | end 6 | 7 | def initialize(filter:) 8 | @filter = filter 9 | end 10 | 11 | # @return [Array] 12 | # All projects in the system. This is a slow operation. 13 | # Use `projects` to get all projects in the system. 14 | def all_projects 15 | load_all 16 | loaded_projects.map(&:new) 17 | end 18 | 19 | # @return [Array] 20 | # All projects in the system. This is a slow operation. 21 | 22 | def projects 23 | load_all 24 | loaded_projects.map(&:new) 25 | end 26 | 27 | private 28 | 29 | def loaded_projects 30 | Models::Project.recursive_subclasses 31 | end 32 | 33 | # load_all's purpose is to "require" all the .rb files under './projects', 34 | # while allowing them to resolve reference to ./teams and ./parts via autoload 35 | def load_all 36 | # Zeitwerk rejects second and subsequent calls. 37 | # Even if we skip over the Zeitwerk part, the nature of 'require' is 38 | # one-way: ruby does not provide a mechanism to *un*require things. 39 | return if defined?(@@load_all) && @@load_all 40 | @@load_all = true 41 | 42 | loader = Zeitwerk::Loader.new 43 | Dir.exist?("teams") && loader.push_dir("teams", namespace: Teams) 44 | Dir.exist?("parts") && loader.push_dir("parts") 45 | 46 | if (autoload = ENV["AUTOLOAD_PROJECTS"]) && autoload != "false" 47 | loader.push_dir("projects") 48 | loader.setup 49 | 50 | if (projects = @filter.project_filter) 51 | projects_path = "#{File.expand_path("projects")}/" 52 | known_paths = loader.all_expected_cpaths.keys.select! { |path| path.start_with?(projects_path) } 53 | 54 | projects.each do |project| 55 | search = project_search project 56 | 57 | # sort by name and nesting level to pick the best candidate 58 | found = known_paths.grep(search).sort.sort_by { |path| path.count("/") } 59 | 60 | if found.any? 61 | require found.first 62 | assert_project_loaded search, found 63 | elsif autoload != "abort" 64 | Kennel.err.puts( 65 | "No projects/ file matching #{search} found, falling back to slow loading of all projects instead" 66 | ) 67 | loader.eager_load 68 | break 69 | else 70 | raise AutoloadFailed, "No projects/ file matching #{search} found" 71 | end 72 | end 73 | else 74 | # all projects needed 75 | loader.eager_load 76 | end 77 | else 78 | # old style without autoload to be removed eventually 79 | loader.setup 80 | loader.eager_load # TODO: this should not be needed but we see hanging CI processes when it's not added 81 | # TODO: also auto-load projects and update expected path too 82 | # but to do that we need to stop the pattern of having a class at the bottom of the project structure 83 | # and change to Module::Project + Module::Support 84 | # we need the extra sort so foo/bar.rb is loaded before foo/bar/baz.rb 85 | Dir["projects/**/*.rb"].sort.each { |f| require "./#{f}" } # rubocop:disable Lint/RedundantDirGlobSort 86 | end 87 | rescue NameError => e 88 | message = e.message 89 | raise unless (klass = message[/uninitialized constant (.*)/, 1]) 90 | 91 | # inverse of zeitwerk lib/zeitwerk/inflector.rb 92 | project_path = klass.gsub("::", "/").gsub(/([a-z])([A-Z])/, "\\1_\\2").downcase + ".rb" 93 | expected_path = (project_path.start_with?("teams/") ? project_path : "parts/#{project_path}") 94 | 95 | # TODO: prefer to raise a new exception with the old backtrace attacked 96 | e.define_singleton_method(:message) do 97 | "\n" + <<~MSG.gsub(/^/, " ") 98 | #{message} 99 | Unable to load #{klass} from #{expected_path} 100 | - Option 1: rename the constant or the file it lives in, to make them match 101 | - Option 2: Use `require` or `require_relative` to load the constant 102 | MSG 103 | end 104 | 105 | raise 106 | end 107 | 108 | # - support PROJECT being used for nested folders, to allow teams to easily group their projects 109 | # - support PROJECT.rb but also PROJECT/base.rb or PROJECT/project.rb 110 | def project_search(project) 111 | suffixes = ["base.rb", "project.rb"] 112 | project_match = Regexp.escape(project.tr("-", "_")).gsub("_", "[-_/]") 113 | /\/#{project_match}(\.rb|#{suffixes.map { |s| Regexp.escape "/#{s}" }.join("|")})$/ 114 | end 115 | 116 | def assert_project_loaded(search, paths) 117 | return if loaded_projects.any? 118 | paths = paths.map { |path| path.sub("#{Dir.pwd}/", "") } 119 | raise( 120 | AutoloadFailed, 121 | <<~MSG 122 | No project found in loaded files! 123 | Ensure the project file you want to load is first in the list, 124 | list is sorted alphabetically and by nesting level. 125 | 126 | Loaded: 127 | #{paths.first} 128 | After finding: 129 | #{paths.join("\n")} 130 | With regex: 131 | #{search} 132 | MSG 133 | ) 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/kennel/settings_as_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module SettingsAsMethods 4 | SETTING_OVERRIDABLE_METHODS = [].freeze 5 | 6 | AS_PROCS = ->(options) do 7 | # Fragile; depends on the fact that in both locations where AS_PROCS is 8 | # used, we're 2 frames away from the user code. 9 | file, line, = caller(2..2).first.split(":", 3) 10 | 11 | options.transform_values do |v| 12 | if v.instance_of?(Proc) 13 | v 14 | else 15 | eval "-> { v }", nil, file, line.to_i 16 | end 17 | end 18 | end 19 | 20 | def self.included(base) 21 | base.extend ClassMethods 22 | base.instance_variable_set(:@settings, []) 23 | end 24 | 25 | module ClassMethods 26 | def settings(*names) 27 | duplicates = (@settings & names) 28 | if duplicates.any? 29 | raise ArgumentError, "Settings #{duplicates.map(&:inspect).join(", ")} are already defined" 30 | end 31 | 32 | overrides = ((instance_methods - self::SETTING_OVERRIDABLE_METHODS) & names) 33 | if overrides.any? 34 | raise ArgumentError, "Settings #{overrides.map(&:inspect).join(", ")} are already used as methods" 35 | end 36 | 37 | @settings.concat names 38 | 39 | names.each do |name| 40 | next if method_defined?(name) 41 | define_method name do 42 | raise_with_location ArgumentError, "'#{name}' on #{self.class} was not set or passed as option" 43 | end 44 | end 45 | end 46 | 47 | def defaults(options) 48 | AS_PROCS.call(options).each do |name, block| 49 | validate_setting_exist name 50 | define_method name, &block 51 | end 52 | end 53 | 54 | private 55 | 56 | def validate_setting_exist(name) 57 | return if @settings.include?(name) 58 | supported = @settings.map(&:inspect) 59 | raise ArgumentError, "Unsupported setting #{name.inspect}, supported settings are #{supported.join(", ")}" 60 | end 61 | 62 | def inherited(child) 63 | super 64 | child.instance_variable_set(:@settings, (@settings || []).dup) 65 | end 66 | end 67 | 68 | def initialize(options = {}) 69 | super() 70 | 71 | unless options.is_a?(Hash) 72 | raise ArgumentError, "Expected #{self.class.name}.new options to be a Hash, got a #{options.class}" 73 | end 74 | 75 | AS_PROCS.call(options).each do |name, block| 76 | self.class.send :validate_setting_exist, name 77 | define_singleton_method name, &block 78 | end 79 | 80 | # need expand_path so it works wih rake and when run individually 81 | pwd = /^#{Regexp.escape(Dir.pwd)}\// 82 | @invocation_location = caller.detect do |l| 83 | if (found = File.expand_path(l).sub!(pwd, "")) 84 | break found 85 | end 86 | end 87 | end 88 | 89 | def raise_with_location(error, message) 90 | message = message.dup 91 | message << " on #{@invocation_location}" if @invocation_location 92 | raise error, message 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/kennel/string_utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module StringUtils 4 | class << self 5 | def snake_case(string) 6 | string 7 | .gsub("::", "_") # Foo::Bar -> foo_bar 8 | .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') # FOOBar -> foo_bar 9 | .gsub(/([a-z\d])([A-Z])/, '\1_\2') # fooBar -> foo_bar 10 | .tr("-", "_") # foo-bar -> foo_bar 11 | .downcase 12 | end 13 | 14 | # for child projects, not used internally 15 | def title_case(string) 16 | string.split(/[\s_]/).map(&:capitalize) * " " 17 | end 18 | 19 | # simplified version of https://apidock.com/rails/ActiveSupport/Inflector/parameterize 20 | def parameterize(string) 21 | string 22 | .downcase 23 | .gsub(/[^a-z0-9\-_]+/, "-") # remove unsupported 24 | .gsub(/-{2,}/, "-") # remove duplicates 25 | .gsub(/^-|-$/, "") # remove leading/trailing 26 | end 27 | 28 | def truncate_lines(text, to:, warning:) 29 | lines = text.split("\n", to + 1) 30 | lines[-1] = warning if lines.size > to 31 | lines.join("\n") 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/kennel/subclass_tracking.rb: -------------------------------------------------------------------------------- 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 20 | -------------------------------------------------------------------------------- /lib/kennel/syncer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "syncer/matched_expected" 4 | require_relative "syncer/plan" 5 | require_relative "syncer/plan_printer" 6 | require_relative "syncer/resolver" 7 | require_relative "syncer/types" 8 | 9 | module Kennel 10 | class Syncer 11 | DELETE_ORDER = ["dashboard", "slo", "monitor", "synthetics/tests"].freeze # dashboards references monitors + slos, slos reference monitors 12 | LINE_UP = "\e[1A\033[K" # go up and clear 13 | 14 | attr_reader :plan 15 | 16 | def initialize(api, expected, actual, filter:, strict_imports: true) 17 | @api = api 18 | @strict_imports = strict_imports 19 | @filter = filter 20 | 21 | @resolver = Resolver.new(expected: expected, filter: filter) 22 | @plan = Plan.new(*calculate_changes(expected: expected, actual: actual)) 23 | validate_changes 24 | end 25 | 26 | def print_plan 27 | PlanPrinter.new.print(plan) 28 | end 29 | 30 | def confirm 31 | return false if plan.empty? 32 | return true unless Console.tty? 33 | Console.ask?("Execute Plan ?") 34 | end 35 | 36 | def update 37 | changes = [] 38 | 39 | plan.deletes.each do |item| 40 | message = "#{item.api_resource} #{item.tracking_id} #{item.id}" 41 | Kennel.out.puts "Deleting #{message}" 42 | @api.delete item.api_resource, item.id 43 | changes << item.change 44 | Kennel.out.puts "#{LINE_UP}Deleted #{message}" 45 | end 46 | 47 | planned_actions = plan.creates + plan.updates 48 | 49 | # slos need to be updated first in case their timeframes changed 50 | # because datadog validates that update+create of slo alerts match an existing timeframe 51 | planned_actions.sort_by! { |item| item.expected.is_a?(Models::Slo) ? 0 : 1 } 52 | 53 | resolver.each_resolved(planned_actions) do |item| 54 | if item.is_a?(Types::PlannedCreate) 55 | message = "#{item.api_resource} #{item.tracking_id}" 56 | Kennel.out.puts "Creating #{message}" 57 | reply = @api.create item.api_resource, item.expected.as_json 58 | id = reply.fetch(:id) 59 | changes << item.change(id) 60 | resolver.add_actual [reply] # allow resolving ids we could previously not resolve 61 | Kennel.out.puts "#{LINE_UP}Created #{message} #{item.url(id)}" 62 | else 63 | message = "#{item.api_resource} #{item.tracking_id} #{item.url}" 64 | Kennel.out.puts "Updating #{message}" 65 | @api.update item.api_resource, item.id, item.expected.as_json 66 | changes << item.change 67 | Kennel.out.puts "#{LINE_UP}Updated #{message}" 68 | end 69 | rescue StandardError 70 | raise unless Console.tty? 71 | Kennel.err.puts $!.message 72 | Kennel.err.puts $!.backtrace 73 | raise unless Console.ask?("Continue with error ?") 74 | end 75 | 76 | plan.changes = changes 77 | plan 78 | end 79 | 80 | private 81 | 82 | attr_reader :filter, :resolver 83 | 84 | def calculate_changes(expected:, actual:) 85 | Progress.progress "Diffing" do 86 | resolver.add_actual actual 87 | filter_actual! actual 88 | resolver.resolve_as_much_as_possible(expected) # resolve as many dependencies as possible to reduce the diff 89 | 90 | # see which expected match the actual 91 | matching, unmatched_expected, unmatched_actual = MatchedExpected.partition(expected, actual) 92 | unmatched_actual.select! { |a| a.fetch(:tracking_id) } # ignore items that were never managed by kennel 93 | 94 | convert_replace_into_update!(matching, unmatched_actual, unmatched_expected) 95 | 96 | validate_expected_id_not_missing unmatched_expected 97 | fill_details! matching # need details to diff later 98 | 99 | # update matching if needed 100 | updates = matching.map do |e, a| 101 | # Refuse to "adopt" existing items into kennel while running with a filter (i.e. on a branch). 102 | # Without this, we'd adopt an item, then the next CI run would delete it 103 | # (instead of "unadopting" it). 104 | e.add_tracking_id unless filter.filtering? && a.fetch(:tracking_id).nil? 105 | id = a.fetch(:id) 106 | diff = e.diff(a) 107 | a[:id] = id 108 | Types::PlannedUpdate.new(e, a, diff) if diff.any? 109 | end.compact 110 | 111 | # delete previously managed 112 | deletes = unmatched_actual.map { |a| Types::PlannedDelete.new(a) } 113 | 114 | # unmatched expected need to be created 115 | unmatched_expected.each(&:add_tracking_id) 116 | creates = unmatched_expected.map { |e| Types::PlannedCreate.new(e) } 117 | 118 | # order to avoid deadlocks 119 | deletes.sort_by! { |item| DELETE_ORDER.index item.api_resource } 120 | updates.sort_by! { |item| DELETE_ORDER.index item.api_resource } # slo needs to come before slo alert 121 | 122 | [creates, updates, deletes] 123 | end 124 | end 125 | 126 | # if there is a new item that has the same name or title as an "to be deleted" item, 127 | # update it instead to avoid old urls from becoming invalid 128 | # - careful with unmatched_actual being huge since it has all api resources 129 | # - don't do it when a monitor type is changing since that would block the update 130 | def convert_replace_into_update!(matching, unmatched_actual, unmatched_expected) 131 | unmatched_expected.reject! do |e| 132 | e_field, e_value = Kennel::Models::Record::TITLE_FIELDS.detect do |field| 133 | next unless (value = e.as_json[field]) 134 | break [field, value] 135 | end 136 | raise unless e_field # uncovered: should never happen ... 137 | e_monitor_type = e.as_json[:type] 138 | 139 | actual = unmatched_actual.detect do |a| 140 | a[:klass] == e.class && a[e_field] == e_value && a[:type] == e_monitor_type 141 | end 142 | next false unless actual # keep in unmatched 143 | 144 | # add as update and remove from unmatched 145 | unmatched_actual.delete(actual) 146 | actual[:tracking_id] = e.tracking_id 147 | matching << [e, actual] 148 | true 149 | end 150 | end 151 | 152 | # fill details of things we need to compare 153 | def fill_details!(details_needed) 154 | details_needed = details_needed.map { |e, a| a if e && e.class.api_resource == "dashboard" }.compact 155 | @api.fill_details! "dashboard", details_needed 156 | end 157 | 158 | def validate_expected_id_not_missing(expected) 159 | expected.each do |e| 160 | next unless (id = e.id) 161 | resource = e.class.api_resource 162 | if @strict_imports 163 | raise "Unable to find existing #{resource} with id #{id}\nIf the #{resource} was deleted, remove the `id: -> { #{id} }` line." 164 | else 165 | message = "Warning: #{resource} #{e.tracking_id} specifies id #{id}, but no such #{resource} exists. 'id' will be ignored. Remove the `id: -> { #{id} }` line." 166 | Kennel.err.puts Console.color(:yellow, message) 167 | end 168 | end 169 | end 170 | 171 | # We've already validated the desired objects ('generated') in isolation. 172 | # Now that we have made the plan, we can perform some more validation. 173 | def validate_changes 174 | @plan.updates.each do |item| 175 | item.expected.validate_update!(item.diff) 176 | end 177 | end 178 | 179 | def filter_actual!(actual) 180 | return unless filter.filtering? # minor optimization 181 | 182 | actual.select! do |a| 183 | tracking_id = a.fetch(:tracking_id) 184 | tracking_id.nil? || filter.matches_tracking_id?(tracking_id) 185 | end 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /lib/kennel/syncer/matched_expected.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kennel 4 | class Syncer 5 | module MatchedExpected 6 | class << self 7 | def partition(expected, actual) 8 | lookup_map = matching_expected_lookup_map(expected) 9 | unmatched_expected = Set.new(expected) # for efficient deletion 10 | unmatched_actual = [] 11 | matched = [] 12 | actual.each do |a| 13 | e = matching_expected(a, lookup_map) 14 | if e && unmatched_expected.delete?(e) 15 | matched << [e, a] 16 | else 17 | unmatched_actual << a 18 | end 19 | end.compact 20 | [matched, unmatched_expected.to_a, unmatched_actual] 21 | end 22 | 23 | private 24 | 25 | # index list by all the thing we look up by: tracking id and actual id 26 | def matching_expected_lookup_map(expected) 27 | expected.each_with_object({}) do |e, all| 28 | keys = [e.tracking_id] 29 | keys << "#{e.class.api_resource}:#{e.id}" if e.id 30 | keys.compact.each do |key| 31 | raise "Lookup #{key} is duplicated" if all[key] 32 | all[key] = e 33 | end 34 | end 35 | end 36 | 37 | def matching_expected(a, map) 38 | klass = a.fetch(:klass) 39 | map["#{klass.api_resource}:#{a.fetch(:id)}"] || map[a.fetch(:tracking_id)] 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/kennel/syncer/plan.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | class Syncer 4 | Plan = Struct.new(:creates, :updates, :deletes) do 5 | attr_writer :changes 6 | 7 | def changes 8 | @changes || (deletes + creates + updates).map(&:change) # roughly ordered in the way that update works 9 | end 10 | 11 | def empty? 12 | (creates + updates + deletes).empty? 13 | end 14 | end 15 | 16 | Change = Struct.new(:type, :api_resource, :tracking_id, :id) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/kennel/syncer/plan_printer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kennel 4 | class Syncer 5 | class PlanPrinter 6 | def initialize 7 | @attribute_differ = AttributeDiffer.new 8 | end 9 | 10 | def print(plan) 11 | Kennel.out.puts "Plan:" 12 | if plan.empty? 13 | Kennel.out.puts Console.color(:green, "Nothing to do") 14 | else 15 | print_changes "Create", plan.creates, :green 16 | print_changes "Update", plan.updates, :yellow 17 | print_changes "Delete", plan.deletes, :red 18 | end 19 | end 20 | 21 | private 22 | 23 | def print_changes(step, list, color) 24 | return if list.empty? 25 | 26 | use_groups = ENV.key?("GITHUB_STEP_SUMMARY") 27 | 28 | list.each do |item| 29 | # No trailing newline 30 | Kennel.out.print "::group::" if item.class::TYPE == :update && use_groups 31 | 32 | Kennel.out.puts Console.color(color, "#{step} #{item.api_resource} #{item.tracking_id}") 33 | if item.class::TYPE == :update 34 | item.diff.each { |args| Kennel.out.puts @attribute_differ.format(*args) } # only for update 35 | end 36 | 37 | Kennel.out.puts "::endgroup::" if item.class::TYPE == :update && use_groups 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/kennel/syncer/resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../id_map" 4 | 5 | module Kennel 6 | class Syncer 7 | class Resolver 8 | def initialize(expected:, filter:) 9 | @id_map = IdMap.new 10 | @filter = filter 11 | 12 | # mark everything as new 13 | expected.each do |e| 14 | id_map.set(e.class.api_resource, e.tracking_id, IdMap::NEW) 15 | if e.class.api_resource == "synthetics/tests" 16 | id_map.set(Kennel::Models::Monitor.api_resource, e.tracking_id, IdMap::NEW) 17 | end 18 | end 19 | end 20 | 21 | def add_actual(actual) 22 | # override resources that exist with their id 23 | actual.each do |a| 24 | # ignore when not managed by kennel 25 | next unless (tracking_id = a.fetch(:tracking_id)) 26 | 27 | # ignore when deleted from the codebase 28 | # (when running with filters we cannot see the other resources in the codebase) 29 | api_resource = a.fetch(:klass).api_resource 30 | next if !id_map.get(api_resource, tracking_id) && filter.matches_tracking_id?(tracking_id) 31 | 32 | id_map.set(api_resource, tracking_id, a.fetch(:id)) 33 | if a.fetch(:klass).api_resource == "synthetics/tests" 34 | id_map.set(Kennel::Models::Monitor.api_resource, tracking_id, a.fetch(:monitor_id)) 35 | end 36 | end 37 | end 38 | 39 | def resolve_as_much_as_possible(expected) 40 | expected.each do |e| 41 | e.resolve_linked_tracking_ids!(id_map, force: false) 42 | end 43 | end 44 | 45 | # loop over items until everything is resolved or crash when we get stuck 46 | # this solves cases like composite monitors depending on each other or monitor->monitor slo->slo monitor chains 47 | def each_resolved(list) 48 | list = list.dup 49 | loop do 50 | return if list.empty? 51 | list.reject! do |item| 52 | if resolved?(item.expected) 53 | yield item 54 | true 55 | else 56 | false 57 | end 58 | end || 59 | assert_resolved(list[0].expected) # resolve something or show a circular dependency error 60 | end 61 | end 62 | 63 | private 64 | 65 | attr_reader :id_map, :filter 66 | 67 | # TODO: optimize by storing an instance variable if already resolved 68 | def resolved?(e) 69 | assert_resolved e 70 | true 71 | rescue UnresolvableIdError 72 | false 73 | end 74 | 75 | # raises UnresolvableIdError when not resolved 76 | def assert_resolved(e) 77 | e.resolve_linked_tracking_ids!(id_map, force: true) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/kennel/syncer/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kennel 4 | class Syncer 5 | module Types 6 | class PlannedChange 7 | def initialize(klass, tracking_id) 8 | @klass = klass 9 | @tracking_id = tracking_id 10 | end 11 | 12 | def api_resource 13 | klass.api_resource 14 | end 15 | 16 | def url(id = nil) 17 | klass.url(id || self.id) 18 | end 19 | 20 | def change(id = nil) 21 | Change.new(self.class::TYPE, api_resource, tracking_id, id) 22 | end 23 | 24 | attr_reader :klass, :tracking_id 25 | end 26 | 27 | class PlannedCreate < PlannedChange 28 | TYPE = :create 29 | 30 | def initialize(expected) 31 | super(expected.class, expected.tracking_id) 32 | @expected = expected 33 | end 34 | 35 | attr_reader :expected 36 | end 37 | 38 | class PlannedUpdate < PlannedChange 39 | TYPE = :update 40 | 41 | def initialize(expected, actual, diff) 42 | super(expected.class, expected.tracking_id) 43 | @expected = expected 44 | @actual = actual 45 | @diff = diff 46 | @id = actual.fetch(:id) 47 | end 48 | 49 | def change 50 | super(id) 51 | end 52 | 53 | attr_reader :expected, :actual, :diff, :id 54 | end 55 | 56 | class PlannedDelete < PlannedChange 57 | TYPE = :delete 58 | 59 | def initialize(actual) 60 | super(actual.fetch(:klass), actual.fetch(:tracking_id)) 61 | @actual = actual 62 | @id = actual.fetch(:id) 63 | end 64 | 65 | def change 66 | super(id) 67 | end 68 | 69 | attr_reader :actual, :id 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/kennel/tags_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module TagsValidation 4 | def validate_json(data) 5 | super 6 | 7 | # ideally we'd avoid duplicated tags, but that happens regularly when importing existing monitors 8 | data[:tags] = data[:tags].uniq 9 | 10 | # keep tags clean (TODO: reduce this list) 11 | bad_tags = data[:tags].grep(/[^A-Za-z:_0-9.\/*@!#-]/) 12 | invalid! :tags_invalid, "Only use A-Za-z:_0-9./*@!#- in tags (bad tags: #{bad_tags.sort.inspect})" if bad_tags.any? 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/kennel/tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "English" 3 | require "kennel" 4 | require "kennel/unmuted_alerts" 5 | require "kennel/importer" 6 | require "json" 7 | 8 | module Kennel 9 | module Tasks 10 | class << self 11 | def kennel 12 | @kennel ||= Kennel::Engine.new 13 | end 14 | 15 | def abort(message = nil) 16 | Kennel.err.puts message if message 17 | raise SystemExit.new(1), message 18 | end 19 | 20 | def load_environment 21 | @load_environment ||= begin 22 | require "kennel" 23 | gem "dotenv" 24 | require "dotenv" 25 | source = ".env" 26 | 27 | # warn when users have things like DATADOG_TOKEN already set and it will not be loaded from .env 28 | unless ENV["KENNEL_SILENCE_UPDATED_ENV"] 29 | updated = Dotenv.parse(source).select { |k, v| ENV[k] && ENV[k] != v } 30 | warn "Environment variables #{updated.keys.join(", ")} need to be unset to be sourced from #{source}" if updated.any? 31 | end 32 | 33 | Dotenv.load(source) 34 | true 35 | end 36 | end 37 | 38 | def ci 39 | load_environment 40 | 41 | if on_default_branch? && git_push? 42 | Kennel::Tasks.kennel.strict_imports = false 43 | Kennel::Tasks.kennel.update 44 | else 45 | Kennel::Tasks.kennel.plan # show plan in CI logs 46 | end 47 | end 48 | 49 | def on_default_branch? 50 | branch = (ENV["TRAVIS_BRANCH"] || ENV["GITHUB_REF"]).to_s.sub(/^refs\/heads\//, "") 51 | (branch == (ENV["DEFAULT_BRANCH"] || "master")) 52 | end 53 | 54 | def git_push? 55 | (ENV["TRAVIS_PULL_REQUEST"] == "false" || ENV["GITHUB_EVENT_NAME"] == "push") 56 | end 57 | end 58 | end 59 | end 60 | 61 | namespace :kennel do 62 | desc "Ensure there are no uncommited changes that would be hidden from PR reviewers" 63 | task no_diff: :generate do 64 | result = `git status --porcelain generated/`.strip 65 | Kennel::Tasks.abort "Diff found:\n#{result}\nrun `rake generate` and commit the diff to fix" unless result == "" 66 | Kennel::Tasks.abort "Error during diffing" unless $CHILD_STATUS.success? 67 | end 68 | 69 | # ideally do this on every run, but it's slow (~1.5s) and brittle (might not find all + might find false-positives) 70 | # https://help.datadoghq.com/hc/en-us/requests/254114 for automatic validation 71 | desc "Verify that all used monitor mentions are valid" 72 | task validate_mentions: :environment do 73 | known = Kennel::Api.new 74 | .send(:request, :get, "/monitor/notifications") 75 | .fetch(:handles) 76 | .values 77 | .flatten(1) 78 | .map { |v| v.fetch(:value) } 79 | 80 | known += ENV["KNOWN"].to_s.split(",") 81 | 82 | bad = [] 83 | Dir["generated/**/*.json"].each do |f| 84 | next unless (message = JSON.parse(File.read(f))["message"]) 85 | used = message 86 | .scan(/(?:^|\s)(@[^\s{,'"]+)/) 87 | .flatten(1) 88 | .grep(/^@.*@|^@.*-/) # ignore @here etc handles ... datadog uses @foo@bar.com for emails and @foo-bar for integrations 89 | (used - known).each { |v| bad << [f, v] } 90 | end 91 | 92 | if bad.any? 93 | url = Kennel::Utils.path_to_url "/account/settings" 94 | Kennel.err.puts "Invalid mentions found, either ignore them by adding to `KNOWN` env var or add them via #{url}" 95 | bad.each { |f, v| Kennel.err.puts "Invalid mention #{v} in monitor message of #{f}" } 96 | Kennel::Tasks.abort ENV["KNOWN_WARNING"] 97 | end 98 | end 99 | 100 | desc "generate local definitions" 101 | task generate: :environment do 102 | Kennel::Tasks.kennel.generate 103 | end 104 | 105 | # also generate parts so users see and commit updated generated automatically 106 | # (generate must run after plan to enable parallel .download+.generate inside of .plan) 107 | desc "show planned datadog changes (scope with PROJECT=name)" 108 | task plan: :environment do 109 | Kennel::Tasks.kennel.preload 110 | Kennel::Tasks.kennel.generate unless ENV["KENNEL_NO_GENERATE"] 111 | Kennel::Tasks.kennel.plan 112 | end 113 | 114 | desc "update datadog (scope with PROJECT=name)" 115 | task update_datadog: :environment do 116 | Kennel::Tasks.kennel.preload 117 | Kennel::Tasks.kennel.generate unless ENV["KENNEL_NO_GENERATE"] 118 | Kennel::Tasks.kennel.update 119 | end 120 | 121 | desc "update on push to the default branch, otherwise show plan" 122 | task :ci do 123 | Kennel::Tasks.ci 124 | end 125 | 126 | desc "show unmuted alerts filtered by TAG, for example TAG=team:foo" 127 | task alerts: :environment do 128 | tag = ENV["TAG"] || Kennel::Tasks.abort("Call with TAG=foo:bar") 129 | Kennel::UnmutedAlerts.print(Kennel::Api.new, tag) 130 | end 131 | 132 | desc "show monitors with no data by TAG, for example TAG=team:foo [THRESHOLD_DAYS=7] [FORMAT=json]" 133 | task nodata: :environment do 134 | tag = ENV["TAG"] || Kennel::Tasks.abort("Call with TAG=foo:bar") 135 | monitors = Kennel::Api.new.list("monitor", monitor_tags: tag, group_states: "no data") 136 | monitors.select! { |m| m[:overall_state] == "No Data" } 137 | monitors.reject! { |m| m[:tags].include? "nodata:ignore" } 138 | if monitors.any? 139 | Kennel.err.puts <<~TEXT 140 | To ignore monitors with expected nodata, tag it with "nodata:ignore" 141 | 142 | TEXT 143 | end 144 | 145 | now = Time.now 146 | monitors.each do |m| 147 | m[:days_in_no_data] = 148 | if m[:overall_state_modified] 149 | since = Date.parse(m[:overall_state_modified]).to_time 150 | ((now - since) / (24 * 60 * 60)).to_i 151 | else 152 | 999 153 | end 154 | end 155 | 156 | if (threshold = ENV["THRESHOLD_DAYS"]) 157 | monitors.select! { |m| m[:days_in_no_data] > Integer(threshold) } 158 | end 159 | 160 | monitors.each { |m| m[:url] = Kennel::Utils.path_to_url("/monitors/#{m[:id]}") } 161 | 162 | if ENV["FORMAT"] == "json" 163 | report = monitors.map do |m| 164 | match = m[:message].to_s.match(/-- #{Regexp.escape(Kennel::Models::Record::MARKER_TEXT)} (\S+:\S+) in (\S+), /) || [] 165 | m.slice(:url, :name, :tags, :days_in_no_data).merge( 166 | kennel_tracking_id: match[1], 167 | kennel_source: match[2] 168 | ) 169 | end 170 | 171 | Kennel.out.puts JSON.pretty_generate(report) 172 | else 173 | monitors.each do |m| 174 | Kennel.out.puts m[:name] 175 | Kennel.out.puts Kennel::Utils.path_to_url("/monitors/#{m[:id]}") 176 | Kennel.out.puts "No data since #{m[:days_in_no_data]}d" 177 | Kennel.out.puts 178 | end 179 | end 180 | end 181 | 182 | desc "Convert existing resources to copy-pasteable definitions to import existing resources (call with URL= or call with RESOURCE= and ID=)" 183 | task import: :environment do 184 | if (id = ENV["ID"]) && (resource = ENV["RESOURCE"]) 185 | id = Integer(id) if id =~ /^\d+$/ # dashboards can have alphanumeric ids 186 | elsif (url = ENV["URL"]) 187 | resource, id = Kennel::Models::Record.parse_any_url(url) || Kennel::Tasks.abort("Unable to parse url") 188 | else 189 | possible_resources = Kennel::Models::Record.subclasses.map(&:api_resource) 190 | Kennel::Tasks.abort("Call with URL= or call with RESOURCE=#{possible_resources.join(" or ")} and ID=") 191 | end 192 | 193 | Kennel.out.puts Kennel::Importer.new(Kennel::Api.new).import(resource, id) 194 | end 195 | 196 | desc "Dump ALL of datadog config as raw json ... useful for grep/search [TYPE=slo|monitor|dashboard]" 197 | task dump: :environment do 198 | resources = 199 | if (type = ENV["TYPE"]) 200 | [type] 201 | else 202 | Kennel::Models::Record.api_resource_map.keys 203 | end 204 | api = Kennel::Api.new 205 | list = nil 206 | first = true 207 | 208 | Kennel.out.puts "[" 209 | resources.each do |resource| 210 | Kennel::Progress.progress("Downloading #{resource}") do 211 | list = api.list(resource) 212 | api.fill_details!(resource, list) if resource == "dashboard" 213 | end 214 | list.each do |r| 215 | r[:api_resource] = resource 216 | if first 217 | first = false 218 | else 219 | Kennel.out.puts "," 220 | end 221 | Kennel.out.print JSON.pretty_generate(r) 222 | end 223 | end 224 | Kennel.out.puts "\n]" 225 | end 226 | 227 | desc "Find items from dump by pattern DUMP= PATTERN= [URLS=true]" 228 | task dump_grep: :environment do 229 | file = ENV.fetch("DUMP") 230 | pattern = Regexp.new ENV.fetch("PATTERN") 231 | items = File.read(file)[2..-2].gsub("},\n{", "}--SPLIT--{").split("--SPLIT--") 232 | models = Kennel::Models::Record.api_resource_map 233 | found = items.grep(pattern) 234 | exit 1 if found.empty? 235 | found.each do |resource| 236 | if ENV["URLS"] 237 | parsed = JSON.parse(resource) 238 | url = models[parsed.fetch("api_resource")].url(parsed.fetch("id")) 239 | title = parsed["title"] || parsed["name"] 240 | Kennel.out.puts "#{url} # #{title}" 241 | else 242 | Kennel.out.puts resource 243 | end 244 | end 245 | end 246 | 247 | desc "Resolve given id to kennel tracking-id RESOURCE= ID=" 248 | task tracking_id: "kennel:environment" do 249 | resource = ENV.fetch("RESOURCE") 250 | id = ENV.fetch("ID") 251 | klass = 252 | Kennel::Models::Record.subclasses.detect { |s| s.api_resource == resource } || 253 | raise("resource #{resource} not know") 254 | object = Kennel::Api.new.show(resource, id) 255 | Kennel.out.puts klass.parse_tracking_id(object) 256 | end 257 | 258 | task :environment do 259 | Kennel::Tasks.load_environment 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /lib/kennel/template_variables.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module TemplateVariables 4 | def self.included(base) 5 | base.settings :template_variables 6 | base.defaults(template_variables: -> { [] }) 7 | end 8 | 9 | private 10 | 11 | def render_template_variables 12 | (template_variables || []).map do |v| 13 | v.is_a?(String) ? { default: "*", prefix: v, name: v } : v 14 | end 15 | end 16 | 17 | # check for queries that do not use the variables and would be misleading 18 | # TODO: do the same check for apm_query and their group_by 19 | def validate_json(data) 20 | super 21 | 22 | variables = (data[:template_variables] || []).map { |v| "$#{v.fetch(:name)}" } 23 | return if variables.empty? 24 | 25 | queries = data[:widgets].flat_map do |widget| 26 | ([widget] + (widget.dig(:definition, :widgets) || [])).flat_map { |w| widget_queries(w) } 27 | end.compact 28 | 29 | matches = variables.map { |v| Regexp.new "#{Regexp.escape(v)}\\b" } 30 | queries.reject! { |q| matches.all? { |m| q.match? m } } 31 | return if queries.empty? 32 | 33 | invalid!( 34 | :queries_must_use_template_variables, 35 | "queries #{queries.join(", ")} must use the template variables #{variables.join(", ")}" 36 | ) 37 | end 38 | 39 | def widget_queries(widget) 40 | requests = widget.dig(:definition, :requests) || [] 41 | return requests.values.map { |r| r[:q] } if requests.is_a?(Hash) # hostmap widgets have hash requests 42 | requests.flat_map { |r| r[:q] || r[:queries]&.map { |q| q[:query] } } # old format with q: or queries: [{query:}] 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/kennel/unmuted_alerts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "kennel" 3 | 4 | # Show Alerts that are not muted and their alerting scopes 5 | module Kennel 6 | class UnmutedAlerts 7 | COLORS = { 8 | "Alert" => :red, 9 | "Warn" => :yellow, 10 | "No Data" => :cyan 11 | }.freeze 12 | 13 | class << self 14 | def print(api, tag) 15 | monitors = filtered_monitors(api, tag) 16 | if monitors.empty? 17 | Kennel.out.puts "No unmuted alerts found" 18 | else 19 | monitors.each do |m| 20 | Kennel.out.puts m[:name] 21 | Kennel.out.puts Utils.path_to_url("/monitors/#{m[:id]}") 22 | m[:state][:groups].each do |g| 23 | color = COLORS[g[:status]] || :default 24 | since = "\t#{time_since(g[:last_triggered_ts])}" 25 | Kennel.out.puts "#{Kennel::Console.color(color, g[:status])}\t#{g[:name]}#{since}" 26 | end 27 | Kennel.out.puts 28 | end 29 | end 30 | end 31 | 32 | private 33 | 34 | # sort pod3 before pod11 35 | def sort_groups!(monitor) 36 | groups = monitor[:state][:groups].values 37 | groups.sort_by! { |g| g[:name].to_s.split(",").map { |w| Utils.natural_order(w) } } 38 | monitor[:state][:groups] = groups 39 | end 40 | 41 | def time_since(t) 42 | diff = Time.now.to_i - Integer(t) 43 | "%02d:%02d:%02d" % [diff / 3600, diff / 60 % 60, diff % 60] 44 | end 45 | 46 | def filtered_monitors(api, tag) 47 | # Download all monitors with given tag 48 | monitors = Progress.progress("Downloading") do 49 | api.list("monitor", monitor_tags: tag, group_states: "all", with_downtimes: "true") 50 | end 51 | 52 | raise "No monitors for #{tag} found, check your spelling" if monitors.empty? 53 | 54 | # only keep monitors that are alerting 55 | monitors.reject! { |m| m[:overall_state] == "OK" } 56 | 57 | # only keep monitors that are not completely silenced 58 | monitors.reject! { |m| m[:options][:silenced].key?(:*) } 59 | 60 | # only keep groups that are alerting 61 | monitors.each { |m| m[:state][:groups].reject! { |_, g| ["OK", "Ignored"].include?(g[:status]) } } 62 | 63 | # only keep alerting groups that are not silenced 64 | monitors.each do |m| 65 | silenced = m[:options][:silenced].keys.map { |k| k.to_s.split(",") } 66 | m[:state][:groups].select! do |k, _| 67 | scope = k.to_s.split(",") 68 | silenced.none? { |s| (s - scope).empty? } 69 | end 70 | end 71 | 72 | # only keep monitors that are not covered by a downtime 73 | monitors.each do |m| 74 | next unless m[:matching_downtimes] 75 | downtime_groups = m[:matching_downtimes].select { |d| d[:active] }.flat_map { |d| d[:groups] } 76 | m[:state][:groups].reject! do |k, _| 77 | downtime_groups.include?(k.to_s) 78 | end 79 | end 80 | 81 | # only keep monitors with alerting groups 82 | monitors.select! { |m| m[:state][:groups].any? } 83 | 84 | # sort group alerts 85 | monitors.each { |m| sort_groups!(m) } 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/kennel/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | module Utils 4 | class << self 5 | def presence(value) 6 | value.nil? || value.empty? ? nil : value 7 | end 8 | 9 | def capture_sh(command) 10 | result = `#{command} 2>&1` 11 | raise "Command failed:\n#{command}\n#{result}" unless $CHILD_STATUS.success? 12 | result 13 | end 14 | 15 | def path_to_url(path) 16 | subdomain = (ENV["DATADOG_SUBDOMAIN"] || "app") 17 | "https://#{subdomain}.datadoghq.com#{path}" 18 | end 19 | 20 | def parallel(items, max: 10) 21 | threads = [items.size, max].min 22 | work = items.each_with_index.to_a 23 | done = Array.new(items.size) 24 | workers = Array.new(threads).map do 25 | Thread.new do 26 | loop do 27 | item, i = work.pop 28 | break unless i 29 | done[i] = 30 | begin 31 | yield item 32 | rescue Exception => e # rubocop:disable Lint/RescueException 33 | work.clear 34 | e 35 | end 36 | end 37 | end 38 | end 39 | workers.each(&:join) 40 | done.each { |d| raise d if d.is_a?(Exception) } 41 | end 42 | 43 | def natural_order(name) 44 | name.split(/(\d+)/).each_with_index.map { |x, i| i.odd? ? x.to_i : x } 45 | end 46 | 47 | def retry(*errors, times:) 48 | yield 49 | rescue *errors => e 50 | times -= 1 51 | raise if times < 0 52 | Kennel.err.puts "Error #{e}, #{times} retries left" 53 | retry 54 | end 55 | 56 | # https://stackoverflow.com/questions/20235206/ruby-get-all-keys-in-a-hash-including-sub-keys/53876255#53876255 57 | def all_keys(items) 58 | case items 59 | when Hash then items.keys + items.values.flat_map { |v| all_keys(v) } 60 | when Array then items.flat_map { |i| all_keys(i) } 61 | else [] 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/kennel/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Kennel 3 | VERSION = "1.163.2" 4 | end 5 | -------------------------------------------------------------------------------- /template/.env.example: -------------------------------------------------------------------------------- 1 | DATADOG_APP_KEY= 2 | DATADOG_API_KEY= 3 | DATADOG_SUBDOMAIN=app 4 | -------------------------------------------------------------------------------- /template/.gitattributes: -------------------------------------------------------------------------------- 1 | generated/* linguist-generated 2 | -------------------------------------------------------------------------------- /template/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | tmp 3 | -------------------------------------------------------------------------------- /template/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.6 2 | -------------------------------------------------------------------------------- /template/.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | if: type = push 3 | cache: 4 | bundler: true 5 | directories: 6 | - tmp/cache 7 | before_install: gem install bundler --no-ri --no-rdoc 8 | script: bundle exec rake $TASK 9 | 10 | matrix: 11 | include: 12 | # Ensure there is no diff between generated and actual 13 | - env: TASK="kennel:no_diff" 14 | # Ensure all used mentions actually exist (can remove false-positives with KNOWN="@foo[,@bar,...]") 15 | - env: TASK="kennel:validate_mentions" 16 | # automated planing for PRs + updating for merges 17 | # fill this out with datadog credentials 18 | # - env: 19 | # - TASK="kennel:ci --trace" 20 | # - DATADOG_SUBDOMAIN=app 21 | # - secure: "FILL-IN" # travis encrypt DATADOG_API_KEY=? 22 | # - secure: "FILL-IN" # travis encrypt DATADOG_APP_KEY=? 23 | -------------------------------------------------------------------------------- /template/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | 4 | ruby File.read(".ruby-version").strip 5 | 6 | gem "kennel" 7 | gem "dotenv" 8 | gem "rake" 9 | gem "bootsnap" 10 | -------------------------------------------------------------------------------- /template/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "bundler/setup" 3 | 4 | require "bootsnap" 5 | Bootsnap.setup( 6 | cache_dir: "tmp/bootsnap", # Path to your cache 7 | development_mode: true, # do not pre-compile 8 | load_path_cache: true, # optimizes the LOAD_PATH with a cache 9 | compile_cache_iseq: true # compiles Ruby code into ISeq cache .. breaks coverage reporting 10 | ) 11 | 12 | require "kennel/tasks" 13 | 14 | task generate: "kennel:generate" 15 | task plan: "kennel:plan" 16 | task default: "kennel:no_diff" 17 | -------------------------------------------------------------------------------- /template/github/cage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grosser/kennel/c63a7f0885d742d17e4dc9b8b42c66d077b7894a/template/github/cage.jpg -------------------------------------------------------------------------------- /template/github/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grosser/kennel/c63a7f0885d742d17e4dc9b8b42c66d077b7894a/template/github/screen.png -------------------------------------------------------------------------------- /template/parts/dashes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grosser/kennel/c63a7f0885d742d17e4dc9b8b42c66d077b7894a/template/parts/dashes/.gitkeep -------------------------------------------------------------------------------- /template/parts/monitors/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grosser/kennel/c63a7f0885d742d17e4dc9b8b42c66d077b7894a/template/parts/monitors/.gitkeep -------------------------------------------------------------------------------- /template/projects/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grosser/kennel/c63a7f0885d742d17e4dc9b8b42c66d077b7894a/template/projects/.gitkeep -------------------------------------------------------------------------------- /template/teams/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grosser/kennel/c63a7f0885d742d17e4dc9b8b42c66d077b7894a/template/teams/.gitkeep -------------------------------------------------------------------------------- /test/coverage_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "test_helper" 3 | 4 | SingleCov.not_covered! 5 | 6 | describe "Coverage" do 7 | it "does not let users add new untested code" do 8 | SingleCov.assert_used 9 | end 10 | 11 | it "does not let users add new untested files" do 12 | SingleCov.assert_tested 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/integration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "bundler/setup" 3 | 4 | require "maxitest/global_must" 5 | require "maxitest/autorun" 6 | require "maxitest/timeout" 7 | 8 | require "tmpdir" 9 | require "kennel/utils" 10 | require "English" 11 | 12 | require "./test/integration_helper" 13 | 14 | Maxitest.timeout = 30 15 | 16 | describe "Integration" do 17 | include IntegrationHelper 18 | 19 | def sh(script) 20 | result = `#{script}` 21 | raise "Failed:\n#{script}\n#{result}" unless $CHILD_STATUS.success? 22 | result 23 | end 24 | 25 | around do |test| 26 | Dir.mktmpdir do |dir| 27 | FileUtils.cp_r("template", dir) 28 | Dir.chdir("#{dir}/template") do 29 | with_test_keys_in_dotenv do 30 | with_local_kennel do 31 | sh "bundle install --quiet" 32 | test.call 33 | end 34 | end 35 | end 36 | end 37 | end 38 | 39 | it "has an empty diff" do 40 | # result = sh "echo y | bundle exec rake kennel:update_datadog" # Uncomment this to apply know good diff 41 | result = sh "bundle exec rake plan 2>&1" 42 | result.gsub!(/\d\.\d+s/, "0.00s") 43 | progress, plan = result.split("Plan:\n") 44 | progress.split("\n").sort.join("\n").must_equal <<~TXT.rstrip 45 | Building json ... 46 | Building json ... 0.00s 47 | Diffing ... 48 | Diffing ... 0.00s 49 | Downloading definitions ... 50 | Downloading definitions ... 0.00s 51 | Finding parts ... 52 | Finding parts ... 0.00s 53 | Loading projects ... 54 | Loading projects ... 0.00s 55 | Storing ... 56 | Storing ... 0.00s 57 | TXT 58 | plan.must_equal "Nothing to do\n" 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/integration_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "base64" 3 | require "faraday" 4 | 5 | module IntegrationHelper 6 | # obfuscated keys so it is harder to find them 7 | TEST_KEYS = Base64.decode64("REFUQURPR19BUElfS0VZPThkMDU5MmY4YmE5MDhiNWE2MmRmN2MwMGM3MGUy\nNmYwCkRBVEFET0dfQVBQX0tFWT05YjNkYWQxMzQyMmY5ZGJjMWU1NDY3YTk0\nMTdmNWYxNzk4ZjJmZTcw\n") 8 | 9 | def with_test_keys_in_dotenv 10 | File.write(".env", TEST_KEYS) 11 | Bundler.with_unbundled_env do 12 | # we need to make sure we use the test credentials 13 | # so delete real credentials in the users env 14 | ENV.delete "DATADOG_API_KEY" 15 | ENV.delete "DATADOG_APP_KEY" 16 | yield 17 | end 18 | ensure 19 | FileUtils.rm_f(".env") 20 | end 21 | 22 | def with_local_kennel 23 | old = File.read("Gemfile") 24 | local = old.sub!('"kennel"', "'kennel', path: '#{File.dirname(__dir__)}'") || raise(".sub! failed") 25 | example = "projects/example.rb" 26 | File.write("Gemfile", local) 27 | File.write(example, <<~RUBY) 28 | module Teams 29 | class MyTeam < Kennel::Models::Team 30 | defaults( 31 | mention: -> { "@slack-my-alerts" } 32 | ) 33 | end 34 | end 35 | 36 | class MyProject < Kennel::Models::Project 37 | defaults( 38 | team: -> { Teams::MyTeam.new }, # use existing team or create new one in teams/ 39 | parts: -> { 40 | [ 41 | Kennel::Models::Monitor.new( 42 | self, 43 | type: -> { "query alert" }, 44 | kennel_id: -> { "load-too-high" }, # make up a unique name 45 | name: -> { "My Kennel Test Monitor" }, # nice descriptive name that will show up in alerts 46 | message: -> { 47 | <<~TEXT 48 | Foobar will be slow and that could cause Barfoo to go down. 49 | Add capacity or debug why it is suddenly slow. 50 | \#{super()} 51 | TEXT 52 | }, 53 | query: -> { "avg(last_5m):avg:system.load.5{hostgroup:api} by {pod} > \#{critical}" }, 54 | critical: -> { 20 } 55 | ), 56 | Kennel::Models::Dashboard.new( 57 | self, 58 | title: -> { "My Kennel Test Dashboard" }, 59 | kennel_id: -> { "another-dashboard" }, # make up a unique name 60 | description: -> { "Overview of bar" }, 61 | template_variables: -> { ["environment"] }, 62 | layout_type: -> { "ordered" }, 63 | widgets: -> { 64 | [ 65 | { 66 | definition: { 67 | title: "Graph name", 68 | type: "timeseries", 69 | requests: [ 70 | { 71 | q: "sum:mystats.foobar{$environment}", 72 | display_type: "area" 73 | } 74 | ] 75 | } 76 | } 77 | ] 78 | } 79 | ) 80 | ] 81 | } 82 | ) 83 | end 84 | RUBY 85 | yield 86 | ensure 87 | File.write("Gemfile", old) 88 | FileUtils.rm_f(example) 89 | end 90 | 91 | # we need something to build our test dashboards on 92 | # NOTE: due to a bug newly create metrics do not show up in the UI, 93 | # force it by modifying the url https://app.datadoghq.com/metric/explorer?exp_metric=test.metric 94 | def report_fake_metric 95 | api_key = TEST_KEYS[/DATADOG_API_KEY=(.*)/, 1] || raise 96 | payload = { 97 | series: [ 98 | { 99 | metric: "test.metric", 100 | points: [["$currenttime", 20]], 101 | type: "rate", 102 | interval: 20, 103 | host: "test.example.com", 104 | tags: ["environment:test"] 105 | } 106 | ] 107 | } 108 | response = Faraday.post "https://api.datadoghq.com/api/v1/series?api_key=#{api_key}", payload.to_json 109 | raise "Error reporting fake metric #{response}" unless response.success? 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/kennel/attribute_differ_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::AttributeDiffer do 7 | let(:printer) { Kennel::AttributeDiffer.new } 8 | 9 | before do 10 | Kennel.out.stubs(:tty?).returns(false) 11 | end 12 | 13 | describe "#format" do 14 | it "prints addition" do 15 | printer.format("+", "foo", "a", "b").must_equal " +foo \"b\" -> \"a\"" 16 | end 17 | 18 | it "formats simple change" do 19 | printer.format("~", "foo", "a", "b").must_equal " ~foo \"a\" -> \"b\"" 20 | end 21 | 22 | it "prints complex change" do 23 | printer.format("~", "foo", [1], [2]).must_equal " ~foo [1] -> [2]" 24 | end 25 | 26 | it "formats large change" do 27 | printer.format("~", "foo", "a" * 100, "b" * 100).must_equal <<~DIFF.gsub(/^/, " ").rstrip 28 | ~foo 29 | "#{"a" * 100}" -> 30 | "#{"b" * 100}" 31 | DIFF 32 | end 33 | 34 | describe "diff limit" do 35 | it "limits the size of diffs" do 36 | output = printer.format("~", "foo", 100.times.map(&:to_s).join("\n"), "") 37 | output.must_include "- 48\n" 38 | output.wont_include "- 49\n" 39 | output.must_include "(Diff for this item truncated after 50 lines. Rerun with MAX_DIFF_LINES=100 to see more)" 40 | end 41 | 42 | it "can configure the diff size limit" do 43 | with_env MAX_DIFF_LINES: "20" do 44 | output = printer.format("~", "foo", 100.times.map(&:to_s).join("\n"), "") 45 | output.must_include "- 18\n" 46 | output.wont_include "- 19\n" 47 | output.must_include "(Diff for this item truncated after 20 lines. Rerun with MAX_DIFF_LINES=40 to see more)" 48 | end 49 | end 50 | end 51 | end 52 | 53 | describe "#multiline_diff" do 54 | def call(a, b) 55 | printer.send(:multiline_diff, a, b) 56 | end 57 | 58 | it "can replace" do 59 | call("a", "b").must_equal ["- a", "+ b"] 60 | end 61 | 62 | it "can add" do 63 | call("", "b").must_equal ["+ b"] 64 | end 65 | 66 | it "can remove" do 67 | call("a", "").must_equal ["- a"] 68 | end 69 | 70 | it "can keep" do 71 | call("a", "a").must_equal [" a"] 72 | end 73 | 74 | it "shows newlines" do 75 | call("\na", "a\n\n").must_equal ["- ", " a", "+ ", "+ "] 76 | end 77 | end 78 | 79 | describe "#pretty_inspect" do 80 | it "shows hashes that rubocop likes" do 81 | printer.send(:pretty_inspect, foo: "bar", bar: 1).must_equal "{ foo: \"bar\", bar: 1 }" 82 | end 83 | 84 | it "supports nesting" do 85 | printer.send(:pretty_inspect, [{ foo: { bar: "bar" } }]).must_equal "[{ foo: { bar: \"bar\" } }]" 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/kennel/console_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::Console do 7 | describe ".color" do 8 | it "colors (with a tty)" do 9 | Kennel.out.stubs(:tty?).returns(true) 10 | Kennel::Console.color(:red, "FOO").must_equal "\e[31mFOO\e[0m" 11 | end 12 | 13 | it "does not color (without a tty)" do 14 | Kennel.out.stubs(:tty?).returns(false) 15 | Kennel::Console.color(:red, "FOO").must_equal "FOO" 16 | end 17 | 18 | it "colors (without a tty, but with force)" do 19 | Kennel.out.stubs(:tty?).returns(false) 20 | Kennel::Console.color(:red, "FOO", force: true).must_equal "\e[31mFOO\e[0m" 21 | end 22 | 23 | it "refuses unknown color" do 24 | Kennel.out.stubs(:tty?).returns(true) 25 | assert_raises(KeyError) { Kennel::Console.color(:sdffsd, "FOO") } 26 | end 27 | end 28 | 29 | describe ".capture_stdout" do 30 | it "captures" do 31 | Kennel::Console.capture_stdout { Kennel.out.puts "hello" }.must_equal "hello\n" 32 | end 33 | end 34 | 35 | describe ".capture_stderr" do 36 | it "captures" do 37 | Kennel::Console.capture_stderr { Kennel.err.puts "hello" }.must_equal "hello\n" 38 | end 39 | end 40 | 41 | describe ".tee_output" do 42 | it "captures and prints" do 43 | Kennel::Console.capture_stderr do 44 | Kennel::Console.capture_stdout do 45 | Kennel::Console.tee_output do 46 | Kennel.out.puts "hello" 47 | Kennel.err.puts "error" 48 | Kennel.out.puts "world" 49 | end.must_equal "hello\nerror\nworld\n" 50 | end.must_equal "hello\nworld\n" 51 | end.must_equal "error\n" 52 | end 53 | end 54 | 55 | describe ".ask?" do 56 | capture_all 57 | 58 | it "is true on yes" do 59 | Kennel.in.expects(:gets).returns("y\n") 60 | assert Kennel::Console.ask?("foo") 61 | stderr.string.must_equal "\e[31mfoo - press 'y' to continue: \e[0m" 62 | end 63 | 64 | it "is false on no" do 65 | Kennel.in.expects(:gets).returns("n\n") 66 | refute Kennel::Console.ask?("foo") 67 | end 68 | 69 | it "is false on enter" do 70 | Kennel.in.expects(:gets).returns("\n") 71 | refute Kennel::Console.ask?("foo") 72 | end 73 | 74 | it "does not print a backtrace when user decides to stop with Ctrl+C" do 75 | Kennel.in.expects(:gets).raises(Interrupt) 76 | Kennel::Console.expects(:exit).with(1) 77 | refute Kennel::Console.ask?("foo") 78 | 79 | # newline is important or prompt will look weird 80 | stderr.string.must_equal "\e[31mfoo - press 'y' to continue: \e[0m\n" 81 | end 82 | end 83 | 84 | describe ".tty?" do 85 | it "is true in a tty" do 86 | Kennel.in.stubs(:tty?).returns(true) 87 | Kennel::Console.tty?.must_equal true 88 | end 89 | 90 | it "is false in non tty" do 91 | Kennel.in.stubs(:tty?).returns(false) 92 | Kennel.err.stubs(:tty?).returns(false) 93 | Kennel::Console.tty?.must_equal false 94 | end 95 | 96 | it "is false in CI" do 97 | with_env CI: "1" do 98 | Kennel::Console.tty?.must_equal false 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/kennel/file_cache_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::FileCache do 7 | in_temp_dir 8 | 9 | let(:cache) { Kennel::FileCache.new("foo", "1") } 10 | 11 | describe "#open" do 12 | it "ignores missing files" do 13 | cache.open { |c| c.instance_variable_get(:@data).must_equal({}) } 14 | end 15 | 16 | it "ignores broken" do 17 | File.write("foo", "Whoops") 18 | cache.open { |c| c.instance_variable_get(:@data).must_equal({}) } 19 | end 20 | 21 | it "removes expired" do 22 | t = Time.now.to_i - 1 23 | File.write("foo", Marshal.dump(a: [1, [2, "1"], t])) 24 | cache.open { |c| c.instance_variable_get(:@data).must_equal({}) } 25 | end 26 | 27 | it "removes old versions" do 28 | t = Time.now.to_i + 123 29 | File.write("foo", Marshal.dump(a: [1, [2, "0"], t])) 30 | cache.open { |c| c.instance_variable_get(:@data).must_equal({}) } 31 | end 32 | 33 | it "keeps fresh" do 34 | t = Time.now.to_i + 123 35 | File.write("foo", Marshal.dump(a: [1, [2, "1"], t])) 36 | cache.open { |c| c.instance_variable_get(:@data).must_equal(a: [1, [2, "1"], t]) } 37 | end 38 | 39 | it "persists changes" do 40 | cache.open do |c| 41 | c.fetch(:a, 3) { 4 }.must_equal 4 42 | end 43 | data = Marshal.load(File.read("foo")) # rubocop:disable Security/MarshalLoad 44 | data.must_equal(a: [4, [3, "1"], cache.instance_variable_get(:@expires)]) 45 | end 46 | 47 | it "avoids writing cross volume by writing tempfile locally" do 48 | File.expects(:rename).with do |a, b| 49 | File.dirname(a).must_equal File.dirname(b) 50 | end 51 | cache.open do |c| 52 | c.fetch(:a, 3) { 4 }.must_equal 4 53 | end 54 | end 55 | 56 | it "can use nested file" do 57 | cache = Kennel::FileCache.new("foo/bar", "1") 58 | cache.open do |c| 59 | c.fetch(:a, 3) { 4 }.must_equal 4 60 | end 61 | Marshal.load(File.read("foo/bar")) # rubocop:disable Security/MarshalLoad 62 | end 63 | end 64 | 65 | describe "#fetch" do 66 | it "returns old" do 67 | File.write("foo", Marshal.dump(a: [1, [2, "1"], Time.now.to_i + 123])) 68 | cache.open do |c| 69 | c.fetch(:a, 2) { raise }.must_equal 1 70 | end 71 | end 72 | 73 | it "stores new when old is missing" do 74 | cache.open do |c| 75 | c.fetch(:a, 2) { 3 }.must_equal 3 76 | c.fetch(:a, 2) { 4 }.must_equal 3 77 | end 78 | end 79 | 80 | it "stores new when old is outdated" do 81 | File.write("foo", Marshal.dump(a: [1, 2, Time.now.to_i + 123])) 82 | cache.open do |c| 83 | c.fetch(:a, 3) { 4 }.must_equal 4 84 | c.fetch(:a, 3) { 5 }.must_equal 4 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/kennel/filter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::Filter do 7 | describe "#initialize" do 8 | it "can translate file path to tracking id" do 9 | f = with_env("TRACKING_ID" => "generated/foo/bar.json") { Kennel::Filter.new } 10 | assert f.matches_tracking_id?("foo:bar") 11 | refute f.matches_tracking_id?("foo:baz") 12 | refute f.matches_tracking_id?("bar:bar") 13 | end 14 | 15 | context "without project, without tracking_id" do 16 | with_env("PROJECT" => nil, "TRACKING_ID" => nil) 17 | 18 | it "works" do 19 | f = Kennel::Filter.new 20 | refute f.filtering? 21 | assert f.matches_project_id?("a") 22 | assert f.matches_tracking_id?("a:b") 23 | end 24 | end 25 | 26 | context "with project, without tracking_id" do 27 | it "works" do 28 | with_env("PROJECT" => "foo,bar", "TRACKING_ID" => nil) do 29 | f = Kennel::Filter.new 30 | assert f.filtering? 31 | assert f.matches_project_id?("foo") 32 | assert f.matches_project_id?("bar") 33 | refute f.matches_project_id?("x") 34 | assert f.matches_tracking_id?("foo:x") 35 | refute f.matches_tracking_id?("x:foo") 36 | end 37 | end 38 | end 39 | 40 | context "with project, with tracking_id" do 41 | it "works when they agree" do 42 | f = with_env("PROJECT" => "foo,bar", "TRACKING_ID" => "foo:x,bar:y") do 43 | Kennel::Filter.new 44 | end 45 | assert f.filtering? 46 | assert f.matches_project_id?("foo") 47 | assert f.matches_project_id?("bar") 48 | refute f.matches_project_id?("z") 49 | assert f.matches_tracking_id?("foo:x") 50 | refute f.matches_tracking_id?("foo:y") 51 | assert f.matches_tracking_id?("bar:y") 52 | refute f.matches_tracking_id?("bar:z") 53 | refute f.matches_tracking_id?("z:z") 54 | end 55 | 56 | it "raises when they disagree" do 57 | e = assert_raises(RuntimeError) do 58 | with_env("PROJECT" => "foo,bar", "TRACKING_ID" => "foo:x,baz:y") do 59 | Kennel::Filter.new 60 | end 61 | end 62 | e.message.must_include("do not set a different PROJECT= when using TRACKING_ID=") 63 | end 64 | end 65 | 66 | context "without project, with tracking_id" do 67 | it "works" do 68 | with_env("PROJECT" => nil, "TRACKING_ID" => "foo:x,bar:y") do 69 | f = Kennel::Filter.new 70 | assert f.filtering? 71 | assert f.matches_project_id?("foo") 72 | assert f.matches_project_id?("bar") 73 | refute f.matches_project_id?("z") 74 | assert f.matches_tracking_id?("foo:x") 75 | refute f.matches_tracking_id?("foo:y") 76 | assert f.matches_tracking_id?("bar:y") 77 | refute f.matches_tracking_id?("bar:z") 78 | refute f.matches_tracking_id?("z:z") 79 | end 80 | end 81 | end 82 | end 83 | 84 | # test logic that filter_parts and filter_projects share 85 | describe "#filter_projects!" do 86 | it "filters nothing when not active" do 87 | Kennel::Filter.new.filter_projects([1]).must_equal [1] 88 | end 89 | 90 | it "filters by project id" do 91 | projects = [stub("P1", kennel_id: "a"), stub("P2", kennel_id: "b")] 92 | with_env PROJECT: "a" do 93 | projects = Kennel::Filter.new.filter_projects(projects) 94 | end 95 | projects.map(&:kennel_id).must_equal ["a"] 96 | end 97 | end 98 | 99 | describe "#filter_parts" do 100 | it "filters nothing when not active" do 101 | Kennel::Filter.new.filter_parts([1]).must_equal [1] 102 | end 103 | 104 | it "filters by project id" do 105 | parts = [stub("P1", tracking_id: "a"), stub("P2", tracking_id: "b")] 106 | with_env TRACKING_ID: "a" do 107 | parts = Kennel::Filter.new.filter_parts(parts) 108 | end 109 | parts.map(&:tracking_id).must_equal ["a"] 110 | end 111 | end 112 | 113 | describe "#filter_resources!" do 114 | let(:struct) { Struct.new(:some_property) } 115 | let(:foo) { struct.new("foo") } 116 | let(:bar) { struct.new("bar") } 117 | let(:baz) { struct.new("baz") } 118 | let(:another_foo) { struct.new("foo") } 119 | let(:things) { [foo, bar, another_foo] } 120 | 121 | def run_filter(allow) 122 | Kennel::Filter.new.send(:filter_resources, things, :some_property, allow, "things", "SOME_PROPERTY_ENV_VAR") 123 | end 124 | 125 | it "is a no-op if the filter is unset" do 126 | run_filter(nil).must_equal(things) 127 | end 128 | 129 | it "filters (1 spec, 1 match)" do 130 | run_filter(["bar"]).must_equal([bar]) 131 | end 132 | 133 | it "filters (1 spec, > 1 match)" do 134 | run_filter(["foo"]).must_equal([foo, another_foo]) 135 | end 136 | 137 | it "filters (repeated spec, 1 match)" do 138 | run_filter(["bar", "bar"]).must_equal([bar]) 139 | end 140 | 141 | it "filters (repeated spec, > 1 match)" do 142 | run_filter(["foo", "foo"]).must_equal([foo, another_foo]) 143 | end 144 | 145 | it "filters (> 1 spec, 1 match each)" do 146 | things << baz 147 | run_filter(["bar", "baz"]).must_equal([bar, baz]) 148 | end 149 | 150 | it "filters (> 1 spec, > 1 match for some)" do 151 | things << baz 152 | run_filter(["foo", "bar"]).must_equal([foo, bar, another_foo]) 153 | end 154 | 155 | it "raises if nothing matched (1 spec)" do 156 | e = assert_raises(RuntimeError) { run_filter ["baz"] } 157 | e.message.must_include("SOME_PROPERTY_ENV_VAR") 158 | e.message.must_include("things") 159 | end 160 | 161 | it "raises if nothing matched (> 1 spec)" do 162 | e = assert_raises(RuntimeError) { run_filter ["foo", "baz"] } 163 | e.message.must_include("SOME_PROPERTY_ENV_VAR") 164 | e.message.must_include("things") 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /test/kennel/github_reporter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::GithubReporter do 7 | let(:remote_response) { +"origin git@github.com:foo/bar.git (fetch)" } 8 | let(:show_response) { +"commit abcd" } 9 | 10 | before do 11 | @git_remote = Kennel::Utils.expects(:capture_sh).with("git remote -v").returns(remote_response) 12 | Kennel::Utils.expects(:capture_sh).with("git show HEAD").returns(show_response) 13 | end 14 | 15 | describe ".report" do 16 | it "does not report when no token was given" do 17 | Kennel::Utils.unstub(:capture_sh) 18 | Kennel::Utils.expects(:capture_sh).never 19 | a = nil 20 | Kennel::GithubReporter.report(nil) { a = 1 } 21 | a.must_equal 1 22 | end 23 | 24 | it "reports when token was given" do 25 | request = stub_request(:post, "https://api.github.com/repos/foo/bar/commits/abcd/comments").to_return(status: 201) 26 | a = nil 27 | Kennel::GithubReporter.report("foo") { a = 1 } 28 | a.must_equal 1 29 | assert_requested request 30 | end 31 | end 32 | 33 | describe "#report" do 34 | let(:reporter) { Kennel::GithubReporter.new("TOKEN") } 35 | 36 | it "reports success" do 37 | request = stub_request(:post, "https://api.github.com/repos/foo/bar/commits/abcd/comments") 38 | .with(body: { body: "```\nHELLOOOO\n```" }.to_json) 39 | .to_return(status: 201) 40 | Kennel::Console.capture_stdout { reporter.report { Kennel.out.puts "HELLOOOO" } }.must_equal "HELLOOOO\n" 41 | assert_requested request 42 | end 43 | 44 | it "truncates long comments" do 45 | msg = "a" * 2 * Kennel::GithubReporter::MAX_COMMENT_SIZE 46 | body = nil 47 | request = stub_request(:post, "https://api.github.com/repos/foo/bar/commits/abcd/comments") 48 | .with { |r| body = JSON.parse(r.body).fetch("body") } 49 | .to_return(status: 201) 50 | Kennel::Console.capture_stdout { reporter.report { Kennel.out.puts msg } } 51 | assert_requested request 52 | body.bytesize.must_equal Kennel::GithubReporter::MAX_COMMENT_SIZE 53 | body.must_match(/\A```.*#{Regexp.escape(Kennel::GithubReporter::TRUNCATED_MSG)}\z/m) 54 | end 55 | 56 | it "can parse https remote" do 57 | remote_response.replace("origin https://github.com/foo/bar.git (fetch)") 58 | reporter.instance_variable_get(:@repo_part).must_equal "foo/bar" 59 | end 60 | 61 | it "can create PR comments for squash" do 62 | show_response << "\n foo (#123)" 63 | request = stub_request(:post, "https://api.github.com/repos/foo/bar/issues/123/comments").to_return(status: 201) 64 | Kennel::Console.capture_stdout { reporter.report { Kennel.out.puts "HEY" } } 65 | assert_requested request 66 | end 67 | 68 | it "can create PR comments for merge" do 69 | show_response << "\n Merge pull request #123" 70 | request = stub_request(:post, "https://api.github.com/repos/foo/bar/issues/123/comments").to_return(status: 201) 71 | Kennel::Console.capture_stdout { reporter.report { Kennel.out.puts "HEY" } } 72 | assert_requested request 73 | end 74 | 75 | it "can create merge comments" do 76 | show_response.replace "commit: nope\nMerge: foo abcd" 77 | request = stub_request(:post, "https://api.github.com/repos/foo/bar/commits/abcd/comments").to_return(status: 201) 78 | Kennel::Console.capture_stdout { reporter.report { Kennel.out.puts "HEY" } } 79 | assert_requested request 80 | end 81 | 82 | it "can parse remote from env via custom var" do 83 | @git_remote.never 84 | 85 | with_env PROJECT_REPOSITORY: "git@github.com:bar/baz" do 86 | reporter.instance_variable_get(:@repo_part).must_equal "bar/baz" 87 | end 88 | end 89 | 90 | it "can take remote from env as github actions provides it" do 91 | @git_remote.never 92 | 93 | with_env GITHUB_REPOSITORY: "bar/baz" do 94 | reporter.instance_variable_get(:@repo_part).must_equal "bar/baz" 95 | end 96 | end 97 | 98 | it "shows user errors" do 99 | request = stub_request(:post, "https://api.github.com/repos/foo/bar/commits/abcd/comments") 100 | .with(body: { body: "```\nError:\nwhoops\n```" }.to_json) 101 | .to_return(status: 201) 102 | e = assert_raises(RuntimeError) { reporter.report { raise "whoops" } } 103 | e.message.must_equal "whoops" 104 | assert_requested request 105 | end 106 | 107 | it "shows api errors" do 108 | request = stub_request(:post, "https://api.github.com/repos/foo/bar/commits/abcd/comments") 109 | .to_return(status: 301, body: "Nope") 110 | e = assert_raises(RuntimeError) { reporter.report {} } 111 | e.message.must_equal <<~TEXT.strip 112 | failed to POST to github: 113 | https://api.github.com/repos/foo/bar/commits/abcd/comments -> 301 114 | Nope 115 | TEXT 116 | assert_requested request 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/kennel/id_map_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::IdMap do 7 | it "stores ids" do 8 | id_map = Kennel::IdMap.new 9 | id_map.set("monitor", "a:b", 1) 10 | id_map.get("monitor", "a:b").must_equal 1 11 | assert_nil id_map.get("monitor", "a:c") 12 | end 13 | 14 | it "stores ids by type" do 15 | id_map = Kennel::IdMap.new 16 | 17 | id_map.set("monitor", "a:b", 1) 18 | id_map.set("slo", "a:b", "2") 19 | 20 | id_map.get("monitor", "a:b").must_equal 1 21 | id_map.get("slo", "a:b").must_equal "2" 22 | end 23 | 24 | it "stores new values" do 25 | id_map = Kennel::IdMap.new 26 | id_map.set("monitor", "a:b", 1) 27 | id_map.set("monitor", "a:c", Kennel::IdMap::NEW) 28 | 29 | id_map.new?("monitor", "a:b").must_equal false 30 | id_map.new?("monitor", "a:c").must_equal true 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/kennel/models/base_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::Models::Base do 7 | define_test_classes 8 | 9 | class TestBase < Kennel::Models::Base 10 | settings :foo, :bar, :unset 11 | defaults( 12 | foo: -> { "foo" }, 13 | bar: -> { "bar" } 14 | ) 15 | end 16 | 17 | describe "#kennel_id" do 18 | it "snake-cases to work as file/tag" do 19 | TestBase.new.kennel_id.must_equal "test_base" 20 | end 21 | 22 | it "does not allow using generic names" do 23 | e = assert_raises ArgumentError do 24 | Kennel::Models::Monitor.new(TestProject.new).kennel_id 25 | end 26 | message = e.message 27 | assert message.sub!(/ \S+?:\d+/, " file.rb:123") 28 | message.must_equal "Set :kennel_id in Kennel::Models::Monitor for project test_project on file.rb:123:in `initialize'" 29 | end 30 | 31 | it "does not allow using generic names for projects" do 32 | e = assert_raises ArgumentError do 33 | Kennel::Models::Project.new.kennel_id 34 | end 35 | message = e.message 36 | assert message.sub!(/\S+?:\d+/, "file.rb:123") 37 | message.must_equal "Set :kennel_id in Kennel::Models::Project on file.rb:123:in `new'" 38 | end 39 | 40 | it "does not allow using generic names" do 41 | e = assert_raises ArgumentError do 42 | Kennel::Models::Monitor.new(TestProject.new, name: -> { "My Bad monitor" }).kennel_id 43 | end 44 | message = e.message 45 | assert message.sub!(/ \S+?:\d+/, " file.rb:123") 46 | message.must_equal "Set :kennel_id in Kennel::Models::Monitor for project test_project on file.rb:123:in `initialize'" 47 | end 48 | end 49 | 50 | describe "#name" do 51 | it "is readable for nice names in the UI" do 52 | TestBase.new.name.must_equal "TestBase" 53 | end 54 | end 55 | 56 | describe ".to_json" do 57 | it "blows up when used by accident instead of rendering unexpected json" do 58 | assert_raises(NotImplementedError) { TestBase.new.to_json } 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/kennel/models/project_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::Models::Project do 7 | define_test_classes 8 | 9 | describe ".file_location" do 10 | let(:plain_project_class) do 11 | Class.new(Kennel::Models::Project) do 12 | def self.to_s 13 | "PlainProject" # to make debugging less confusing 14 | end 15 | end 16 | end 17 | 18 | it "finds the file" do 19 | TestProject.file_location.must_equal "test/test_helper.rb" 20 | end 21 | 22 | it "cannot detect if there are no methods" do 23 | Class.new(Kennel::Models::Project).file_location.must_be_nil 24 | end 25 | 26 | it "detects the file location when defaults-plain is used" do 27 | project_class = plain_project_class 28 | eval <<~EVAL, nil, "dir/foo.rb", 1 29 | project_class.instance_eval do 30 | defaults(name: 'bar') 31 | end 32 | EVAL 33 | project_class.file_location.must_equal("dir/foo.rb") 34 | end 35 | 36 | it "detects the file location when defaults-proc is used" do 37 | project_class = plain_project_class 38 | eval <<~EVAL, nil, "dir/foo.rb", 1 39 | project_class.instance_eval do 40 | defaults(name: -> { 'bar' }) 41 | end 42 | EVAL 43 | project_class.file_location.must_equal("dir/foo.rb") 44 | end 45 | 46 | it "detects the file location when a custom method is used" do 47 | project_class = plain_project_class 48 | eval <<~EVAL, nil, "dir/foo.rb", 1 49 | project_class.define_method(:my_method) { } 50 | EVAL 51 | project_class.file_location.must_equal("dir/foo.rb") 52 | end 53 | 54 | it "caches failure" do 55 | c = Class.new(Kennel::Models::Project) 56 | c.expects(:instance_methods).times(1).returns [] 57 | 2.times { c.file_location.must_be_nil } 58 | end 59 | 60 | it "caches success" do 61 | c = TestProject 62 | c.remove_instance_variable(:@file_location) rescue false # rubocop:disable Style/RescueModifier 63 | original = c.instance_methods(false) 64 | c.expects(:instance_methods).times(1).returns original 65 | 2.times { c.file_location.must_equal "test/test_helper.rb" } 66 | end 67 | end 68 | 69 | describe "#tags" do 70 | it "uses team" do 71 | TestProject.new.tags.must_equal ["team:test-team"] 72 | end 73 | end 74 | 75 | describe "#mention" do 76 | it "uses teams mention" do 77 | TestProject.new.mention.must_equal "@slack-foo" 78 | end 79 | end 80 | 81 | describe "#validated_parts" do 82 | it "returns parts" do 83 | TestProject.new.validated_parts.size.must_equal 0 84 | end 85 | 86 | it "raises an error if parts did not return an array" do 87 | bad_project = TestProject.new(parts: -> { 88 | Kennel::Models::Monitor.new(self) 89 | }) 90 | assert_raises(RuntimeError) { bad_project.validated_parts } 91 | .message.must_equal "Project test_project #parts must return an array of Records" 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/kennel/models/slo_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::Models::Slo do 7 | define_test_classes 8 | 9 | class TestSlo < Kennel::Models::Slo 10 | end 11 | 12 | def slo(options = {}) 13 | Kennel::Models::Slo.new( 14 | options.delete(:project) || project, 15 | { 16 | type: -> { "metric" }, 17 | name: -> { "Foo" }, 18 | kennel_id: -> { "m1" } 19 | }.merge(options) 20 | ) 21 | end 22 | 23 | let(:project) { TestProject.new } 24 | let(:id_map) { Kennel::IdMap.new } 25 | let(:expected_basic_json) do 26 | { 27 | name: "Foo\u{1F512}", 28 | description: nil, 29 | thresholds: [], 30 | monitor_ids: [], 31 | tags: ["team:test-team"], 32 | type: "metric" 33 | } 34 | end 35 | 36 | describe "#initialize" do 37 | it "stores project" do 38 | TestSlo.new(project).project.must_equal project 39 | end 40 | 41 | it "stores options" do 42 | TestSlo.new(project, name: -> { "XXX" }).name.must_equal "XXX" 43 | end 44 | end 45 | 46 | describe "#build_json" do 47 | it "creates a basic json" do 48 | assert_json_equal( 49 | slo.build_json, 50 | expected_basic_json 51 | ) 52 | end 53 | 54 | it "sets query for metrics" do 55 | expected_basic_json[:query] = "foo" 56 | assert_json_equal( 57 | slo(query: -> { "foo" }).build_json, 58 | expected_basic_json 59 | ) 60 | end 61 | 62 | it "sets id when updating by id" do 63 | expected_basic_json[:id] = 123 64 | assert_json_equal( 65 | slo(id: -> { 123 }).build_json, 66 | expected_basic_json 67 | ) 68 | end 69 | 70 | it "sets groups when given" do 71 | expected_basic_json[:groups] = ["foo"] 72 | assert_json_equal( 73 | slo(groups: -> { ["foo"] }).build_json, 74 | expected_basic_json 75 | ) 76 | end 77 | 78 | it "includes sliSpecification for time slices" do 79 | sli_spec = { 80 | time_slice: { 81 | query: { 82 | formulas: [{ formula: "query1" }], 83 | queries: [ 84 | { 85 | data_source: "metrics", 86 | name: "query1", 87 | query: "ewma_7(avg:system_cpu{} by {env})" 88 | } 89 | ] 90 | }, 91 | query_interval_seconds: 300, 92 | comparator: "<=", 93 | threshold: 0.1, 94 | no_data_strategy: "COUNT_AS_UPTIME" 95 | } 96 | } 97 | 98 | expected_basic_json[:sli_specification] = sli_spec 99 | expected_basic_json[:type] = "time_slice" 100 | assert_json_equal( 101 | slo(type: "time_slice", sli_specification: sli_spec).build_json, 102 | expected_basic_json 103 | ) 104 | end 105 | end 106 | 107 | describe "#resolve_linked_tracking_ids!" do 108 | it "ignores empty caused by ignore_default" do 109 | slo = slo(monitor_ids: -> { nil }) 110 | slo.build 111 | slo.resolve_linked_tracking_ids!(id_map, force: false) 112 | refute slo.as_json[:monitor_ids] 113 | end 114 | 115 | it "does nothing for hardcoded ids" do 116 | slo = slo(monitor_ids: -> { [123] }) 117 | slo.build 118 | slo.resolve_linked_tracking_ids!(id_map, force: false) 119 | slo.as_json[:monitor_ids].must_equal [123] 120 | end 121 | 122 | it "resolves relative ids" do 123 | slo = slo(monitor_ids: -> { ["#{project.kennel_id}:mon"] }) 124 | slo.build 125 | id_map.set("monitor", "#{project.kennel_id}:mon", 123) 126 | slo.resolve_linked_tracking_ids!(id_map, force: false) 127 | slo.as_json[:monitor_ids].must_equal [123] 128 | end 129 | 130 | it "does not resolve missing ids so they can resolve when monitor was created" do 131 | slo = slo(monitor_ids: -> { ["#{project.kennel_id}:mon"] }) 132 | slo.build 133 | id_map.set("monitor", "#{project.kennel_id}:mon", Kennel::IdMap::NEW) 134 | slo.resolve_linked_tracking_ids!(id_map, force: false) 135 | slo.as_json[:monitor_ids].must_equal ["test_project:mon"] 136 | end 137 | 138 | it "fails with typos" do 139 | slo = slo(monitor_ids: -> { ["#{project.kennel_id}:mon"] }) 140 | slo.build 141 | assert_raises Kennel::UnresolvableIdError do 142 | slo.resolve_linked_tracking_ids!(id_map, force: false) 143 | end 144 | end 145 | end 146 | 147 | describe "#validate_json" do 148 | it "is valid with no thresholds" do 149 | validation_errors_from(slo).must_equal [] 150 | end 151 | 152 | describe :threshold_target_invalid do 153 | it "is valid with good target" do 154 | validation_errors_from(slo(thresholds: [{ target: 99 }])).must_equal [] 155 | end 156 | 157 | it "is invalid with bad target" do 158 | validation_errors_from(slo(thresholds: [{ target: 0 }])).must_equal ["SLO threshold target must be > 0 and < 100"] 159 | end 160 | end 161 | 162 | describe :warning_must_be_gt_critical do 163 | it "is valid when warning not set" do 164 | validation_errors_from(slo(thresholds: [{ critical: 99, target: 99.9 }])).must_equal [] 165 | end 166 | 167 | it "is invalid if warning < critical" do 168 | validation_errors_from(slo(thresholds: [{ warning: 0, critical: 99, target: 99.9 }])) 169 | .must_equal ["Threshold warning must be greater-than critical value"] 170 | end 171 | 172 | it "is invalid if warning == critical" do 173 | validation_errors_from(slo(thresholds: [{ warning: 99, critical: 99, target: 99.9 }])) 174 | .must_equal ["Threshold warning must be greater-than critical value"] 175 | end 176 | end 177 | 178 | describe :tags_are_upper_case do 179 | it "is valid with regular tags" do 180 | validation_errors_from(slo(tags: ["foo:bar"])).must_equal [] 181 | end 182 | 183 | it "is invalid with upcase tags" do 184 | validation_errors_from(slo(tags: ["foo:BAR"])) 185 | .must_equal ["Tags must not be upper case (bad tags: [\"foo:BAR\"])"] 186 | end 187 | end 188 | end 189 | 190 | describe ".url" do 191 | it "shows path" do 192 | Kennel::Models::Slo.url(111).must_equal "https://app.datadoghq.com/slo?slo_id=111" 193 | end 194 | end 195 | 196 | describe ".api_resource" do 197 | it "is set" do 198 | Kennel::Models::Slo.api_resource.must_equal "slo" 199 | end 200 | end 201 | 202 | describe ".parse_url" do 203 | def call(url) 204 | Kennel::Models::Slo.parse_url(url) 205 | end 206 | 207 | it "parses url with slo_id" do 208 | url = "https://app.datadoghq.com/slo/manage?query=team%3A%28foo%29&slo_id=123abc456def123&timeframe=7d" 209 | call(url).must_equal "123abc456def123" 210 | end 211 | 212 | it "parses edit url" do 213 | url = "https://zendesk.datadoghq.com/slo/123abc456def123/edit" 214 | call(url).must_equal "123abc456def123" 215 | end 216 | 217 | # not sure where to get that url from 218 | it "does not parses url with alert because that is importing a monitor" do 219 | url = "https://app.datadoghq.com/slo/123abc456def123/edit/alerts/789" 220 | call(url).must_be_nil 221 | end 222 | 223 | it "fails to parse other" do 224 | url = "https://app.datadoghq.com/dashboard/bet-foo-bar?from_ts=1585064592575&to_ts=1585068192575&live=true" 225 | call(url).must_be_nil 226 | end 227 | end 228 | 229 | describe ".normalize" do 230 | it "works with empty" do 231 | Kennel::Models::Slo.normalize({ tags: [] }, tags: []) 232 | end 233 | 234 | it "compares tags sorted" do 235 | expected = { tags: ["a", "b", "c"] } 236 | actual = { tags: ["b", "c", "a"] } 237 | Kennel::Models::Slo.normalize(expected, actual) 238 | expected.must_equal tags: ["a", "b", "c"] 239 | actual.must_equal tags: ["a", "b", "c"] 240 | end 241 | 242 | it "ignores defaults" do 243 | expected = { tags: [] } 244 | actual = { monitor_ids: [], tags: [] } 245 | Kennel::Models::Slo.normalize(expected, actual) 246 | expected.must_equal(tags: []) 247 | actual.must_equal(tags: []) 248 | end 249 | 250 | it "ignores readonly display values" do 251 | expected = { thresholds: [{ warning: 1.0 }], tags: [] } 252 | actual = { thresholds: [{ warning: 1.0, warning_display: "1.00" }], tags: [] } 253 | Kennel::Models::Slo.normalize(expected, actual) 254 | expected.must_equal(thresholds: [{ warning: 1.0 }], tags: []) 255 | actual.must_equal expected 256 | end 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /test/kennel/models/synthetic_test_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::Models::SyntheticTest do 7 | define_test_classes 8 | 9 | class TestSynth < Kennel::Models::SyntheticTest 10 | end 11 | 12 | def synthetic(options = {}) 13 | Kennel::Models::SyntheticTest.new( 14 | options.delete(:project) || project, 15 | { 16 | kennel_id: -> { "m1" }, 17 | locations: -> { ["l1"] }, 18 | message: -> { "hey" }, 19 | config: -> { {} }, 20 | type: -> { "api" }, 21 | subtype: -> { "http" }, 22 | options: -> { {} }, 23 | name: -> { "foo" } 24 | }.merge(options) 25 | ) 26 | end 27 | 28 | let(:project) { TestProject.new } 29 | let(:expected_json) do 30 | { 31 | message: "hey", 32 | tags: [ 33 | "team:test-team" 34 | ], 35 | config: {}, 36 | type: "api", 37 | subtype: "http", 38 | options: {}, 39 | name: "foo\u{1F512}", 40 | locations: ["l1"] 41 | } 42 | end 43 | 44 | describe "#build_json" do 45 | it "builds" do 46 | assert_json_equal synthetic.build_json, expected_json 47 | end 48 | 49 | it "can add id" do 50 | synthetic(id: -> { 123 }).build_json[:id].must_equal 123 51 | end 52 | 53 | it "can add all locations" do 54 | synthetic(locations: -> { :all }).build_json[:locations].size.must_be :>, 5 55 | end 56 | 57 | it "can use super" do 58 | synthetic(message: -> { super() }).build_json[:message].must_equal "\n\n@slack-foo" 59 | end 60 | end 61 | 62 | describe ".api_resource" do 63 | it "is set" do 64 | Kennel::Models::SyntheticTest.api_resource.must_equal "synthetics/tests" 65 | end 66 | end 67 | 68 | describe ".url" do 69 | it "builds" do 70 | Kennel::Models::SyntheticTest.url("foo").must_equal "https://app.datadoghq.com/synthetics/details/foo" 71 | end 72 | end 73 | 74 | describe ".parse_url" do 75 | it "extracts" do 76 | Kennel::Models::SyntheticTest.parse_url("https://foo.com/synthetics/details/foo-bar-baz").must_equal "foo-bar-baz" 77 | end 78 | end 79 | 80 | describe ".normalize" do 81 | it "sorts tags" do 82 | a = { tags: ["c", "a", "b"].freeze } 83 | e = { tags: ["b", "c", "a"].freeze } 84 | Kennel::Models::SyntheticTest.normalize(a, e) 85 | e[:tags].must_equal ["a", "b", "c"] 86 | a[:tags].must_equal ["a", "b", "c"] 87 | end 88 | 89 | it "sorts locations" do 90 | a = { locations: ["c", "a", "b"].freeze } 91 | e = { locations: ["b", "c", "a"].freeze } 92 | Kennel::Models::SyntheticTest.normalize(a, e) 93 | e[:locations].must_equal ["a", "b", "c"] 94 | a[:locations].must_equal ["a", "b", "c"] 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/kennel/models/team_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::Models::Team do 7 | define_test_classes 8 | 9 | describe "#tags" do 10 | it "is a nice searchable name" do 11 | TestTeam.new.tags.must_equal ["team:test-team"] 12 | end 13 | 14 | it "does not prefix teams with folder name if it is teams too" do 15 | Teams::MyTeam.new.tags.must_equal ["team:my-team"] 16 | end 17 | end 18 | 19 | describe "#renotify_interval" do 20 | it "is set to datadogs default" do 21 | Teams::MyTeam.new.renotify_interval.must_equal 0 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/kennel/optional_validations_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::OptionalValidations do 7 | define_test_classes 8 | 9 | let(:errors) { [] } 10 | 11 | let(:item) do 12 | item = Object.new 13 | item.extend Kennel::OptionalValidations 14 | copy_of_errors = errors 15 | item.define_singleton_method(:invalid!) do |_tag, err| 16 | copy_of_errors << err 17 | end 18 | item 19 | end 20 | 21 | it "adds settings" do 22 | record = Kennel::Models::Record.new(TestProject.new, kennel_id: -> { "test" }, ignored_errors: -> { [:foo] }) 23 | record.ignored_errors.must_equal [:foo] 24 | end 25 | 26 | describe ".valid?" do 27 | capture_all 28 | 29 | def good 30 | part = mock 31 | part.stubs(:validation_errors).returns([]) 32 | part.stubs(:ignored_errors).returns([]) 33 | part 34 | end 35 | 36 | def bad(id, errors) 37 | part = mock 38 | part.stubs(:safe_tracking_id).returns(id) 39 | part.stubs(:validation_errors).returns(errors) 40 | part.stubs(:ignored_errors).returns([]) 41 | part 42 | end 43 | 44 | it "runs with no parts" do 45 | assert(Kennel::OptionalValidations.valid?([])) 46 | stdout.string.must_equal "" 47 | stderr.string.must_equal "" 48 | end 49 | 50 | it "runs with only good parts" do 51 | assert(Kennel::OptionalValidations.valid?([good, good, good])) 52 | stdout.string.must_equal "" 53 | stderr.string.must_equal "" 54 | end 55 | 56 | context "with errors" do 57 | it "runs with a bad part" do 58 | parts = [ 59 | bad( 60 | "foo", 61 | [ 62 | Kennel::OptionalValidations::ValidationMessage.new(:data, "your data is bad"), 63 | Kennel::OptionalValidations::ValidationMessage.new(:you, "and you should feel bad") 64 | ] 65 | ) 66 | ] 67 | refute Kennel::OptionalValidations.valid?(parts) 68 | stdout.string.must_equal "" 69 | stderr.string.must_equal <<~TEXT 70 | 71 | foo [:data] your data is bad 72 | foo [:you] and you should feel bad 73 | 74 | If a particular error cannot be fixed, it can be marked as ignored via `ignored_errors`, e.g.: 75 | Kennel::Models::Monitor.new( 76 | ..., 77 | ignored_errors: [:you] 78 | ) 79 | 80 | TEXT 81 | end 82 | 83 | it "uses the last non-ignorable tag as the example" do 84 | parts = [ 85 | bad( 86 | "foo", 87 | [ 88 | Kennel::OptionalValidations::ValidationMessage.new(:data, "your data is bad"), 89 | Kennel::OptionalValidations::ValidationMessage.new(:unignorable, "and you should feel bad") 90 | ] 91 | ) 92 | ] 93 | 94 | refute Kennel::OptionalValidations.valid?(parts) 95 | 96 | stderr.string.must_include "foo [:unignorable] and you should feel bad" 97 | stderr.string.must_include "ignored_errors: [:data]" 98 | end 99 | 100 | it "skips the ignored_errors advice is all the errors are unignorable" do 101 | parts = [ 102 | bad( 103 | "foo", 104 | [ 105 | Kennel::OptionalValidations::ValidationMessage.new(:unignorable, "your data is bad"), 106 | Kennel::OptionalValidations::ValidationMessage.new(:unignorable, "and you should feel bad") 107 | ] 108 | ) 109 | ] 110 | 111 | refute Kennel::OptionalValidations.valid?(parts) 112 | 113 | refute_includes stderr.string, "If a particular error cannot be fixed" 114 | end 115 | end 116 | end 117 | 118 | describe ".filter_validation_errors" do 119 | let(:ignored_errors) { [] } 120 | 121 | let(:item) do 122 | Kennel::Models::Record.new(TestProject.new, kennel_id: -> { "test" }, ignored_errors: ignored_errors) 123 | end 124 | 125 | context "no validation errors" do 126 | it "passes if ignored_errors is empty" do 127 | item.build 128 | Kennel::OptionalValidations.send(:filter_validation_errors, item).must_equal [] 129 | end 130 | 131 | context "when ignored_errors is not empty" do 132 | before { ignored_errors << :foo } 133 | 134 | it "fails" do 135 | item.build 136 | errs = Kennel::OptionalValidations.send(:filter_validation_errors, item) 137 | errs.length.must_equal 1 138 | errs[0].tag.must_equal :unused_ignores 139 | errs[0].text.must_include "there are no errors to ignore" 140 | end 141 | 142 | it "can ignore failures" do 143 | ignored_errors << :unused_ignores 144 | item.build 145 | errs = Kennel::OptionalValidations.send(:filter_validation_errors, item) 146 | errs.must_equal [] 147 | end 148 | end 149 | end 150 | 151 | context "some validation errors" do 152 | before do 153 | item.define_singleton_method(:validate_json) do |_json| 154 | invalid! :x, "Bad juju" 155 | invalid! :y, "Worse juju" 156 | end 157 | end 158 | 159 | it "shows the error" do 160 | item.build 161 | errs = Kennel::OptionalValidations.send(:filter_validation_errors, item) 162 | errs.length.must_equal 2 163 | errs[0].tag.must_equal :x 164 | errs[1].tag.must_equal :y 165 | end 166 | 167 | it "can ignore errors" do 168 | ignored_errors << :x 169 | ignored_errors << :y 170 | item.build 171 | Kennel::OptionalValidations.send(:filter_validation_errors, item).must_equal [] 172 | end 173 | 174 | it "cannot ignore unignorable errors" do 175 | item.define_singleton_method(:validate_json) do |_json| 176 | invalid! :unignorable, "This is serious" 177 | end 178 | 179 | ignored_errors << :unignorable 180 | 181 | item.build 182 | errs = Kennel::OptionalValidations.send(:filter_validation_errors, item) 183 | errs.length.must_equal 1 184 | errs[0].tag.must_equal :unignorable 185 | end 186 | 187 | it "reports non-ignored errors" do 188 | ignored_errors << :x 189 | item.build 190 | errs = Kennel::OptionalValidations.send(:filter_validation_errors, item) 191 | errs.length.must_equal 1 192 | errs[0].tag.must_equal :y 193 | end 194 | 195 | context "when an ignored error didn't happen" do 196 | before do 197 | ignored_errors << :x 198 | ignored_errors << :y 199 | ignored_errors << :zzz 200 | end 201 | 202 | it "complains" do 203 | item.build 204 | errs = Kennel::OptionalValidations.send(:filter_validation_errors, item) 205 | errs.length.must_equal 1 206 | errs[0].tag.must_equal :unused_ignores 207 | errs[0].text.must_include ":zzz" 208 | end 209 | 210 | it "does not complain if that was ignored" do 211 | ignored_errors << :unused_ignores 212 | item.build 213 | errs = Kennel::OptionalValidations.send(:filter_validation_errors, item) 214 | errs.must_equal [] 215 | end 216 | end 217 | 218 | it "reports ignored errors if NO_IGNORED_ERRORS is set" do 219 | with_env(NO_IGNORED_ERRORS: "any value") do 220 | ignored_errors << :x 221 | ignored_errors << :y 222 | item.build 223 | errs = Kennel::OptionalValidations.send(:filter_validation_errors, item) 224 | errs.length.must_equal 2 225 | errs[0].tag.must_equal :x 226 | errs[1].tag.must_equal :y 227 | end 228 | end 229 | end 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /test/kennel/parts_serializer_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::PartsSerializer do 7 | def write(file, content) 8 | folder = File.dirname(file) 9 | FileUtils.mkdir_p folder 10 | File.write file, content 11 | end 12 | 13 | def make_project(kennel_id, monitor_kennel_ids) 14 | Kennel::Models::Project.new( 15 | team: Kennel::Models::Team.new( 16 | kennel_id: "team-id", 17 | mention: "@slack-whatever" 18 | ), 19 | name: kennel_id, 20 | kennel_id: kennel_id, 21 | parts: -> { 22 | monitor_kennel_ids.map do |id| 23 | Kennel::Models::Monitor.new( 24 | self, 25 | type: "query alert", 26 | kennel_id: id, 27 | query: "avg(last_5m) > 123", 28 | critical: 123 29 | ) 30 | end 31 | } 32 | ) 33 | end 34 | 35 | let(:project_filter) { nil } 36 | let(:tracking_id_filter) { nil } 37 | let(:filter) do 38 | p_arg = Kennel::Utils.presence(project_filter)&.join(",") 39 | t_arg = Kennel::Utils.presence(tracking_id_filter)&.join(",") 40 | with_env(PROJECT: p_arg, TRACKING_ID: t_arg) { Kennel::Filter.new } 41 | end 42 | 43 | capture_all 44 | in_temp_dir 45 | 46 | describe "#write" do 47 | it "saves formatted json" do 48 | parts = make_project("temp_project", ["foo"]).validated_parts.each(&:build) 49 | Kennel::PartsSerializer.new(filter: filter).write(parts) 50 | content = File.read("generated/temp_project/foo.json") 51 | assert content.start_with?("{\n") # pretty generated 52 | json = JSON.parse(content, symbolize_names: true) 53 | json[:query].must_equal "avg(last_5m) > 123" 54 | end 55 | 56 | it "keeps same" do 57 | parts = make_project("temp_project", ["foo"]).validated_parts.each(&:build) 58 | Kennel::PartsSerializer.new(filter: filter).write(parts) 59 | 60 | old = Time.now - 10 61 | FileUtils.touch "generated/temp_project/foo.json", mtime: old 62 | 63 | Kennel::PartsSerializer.new(filter: filter).write(parts) 64 | 65 | File.mtime("generated/temp_project/foo.json").must_equal old 66 | end 67 | 68 | it "overrides different" do 69 | parts = make_project("temp_project", ["foo"]).validated_parts.each(&:build) 70 | Kennel::PartsSerializer.new(filter: filter).write(parts) 71 | 72 | old = Time.now - 10 73 | File.write "generated/temp_project/foo.json", "x" 74 | File.utime(old, old, "generated/temp_project/foo.json") 75 | 76 | Kennel::PartsSerializer.new(filter: filter).write(parts) 77 | 78 | File.mtime("generated/temp_project/foo.json").wont_equal old 79 | end 80 | 81 | it "cleans up old stuff" do 82 | write "generated/old_project/some_file.json", "whatever" 83 | write "generated/temp_project/some_file.json", "whatever" 84 | Dir.mkdir "generated/old_empty_project" 85 | write "generated/stray_file_not_in_a_subfolder.json", "whatever" 86 | 87 | parts = make_project("temp_project", ["foo"]).validated_parts.each(&:build) 88 | Kennel::PartsSerializer.new(filter: filter).write(parts) 89 | 90 | Dir["generated/**/*"].must_equal [ 91 | "generated/temp_project", 92 | "generated/temp_project/foo.json" 93 | ] 94 | end 95 | 96 | describe "project filtering" do 97 | # The filtering only applies to the _cleanup_, not to the _write_. 98 | # This is because filtering of what parts to write is handled by 99 | # Kennel.generated 100 | let(:project_filter) { ["included1", "included2"] } 101 | 102 | it "filters the cleanup" do 103 | write "generated/included1/old_part.json", "whatever" 104 | write "generated/included2/old_part.json", "whatever" 105 | write "generated/excluded/old_part.json", "whatever" 106 | Dir.mkdir "generated/old_empty_project" 107 | write "generated/stray_file_not_in_a_subfolder.json", "whatever" 108 | 109 | parts = [ 110 | *make_project("included1", ["foo1"]).validated_parts.each(&:build), 111 | *make_project("included2", ["foo2"]).validated_parts.each(&:build) 112 | ] 113 | Kennel::PartsSerializer.new(filter: filter).write(parts) 114 | 115 | Dir["generated/**/*"].must_equal %w[ 116 | generated/excluded 117 | generated/excluded/old_part.json 118 | generated/included1 119 | generated/included1/foo1.json 120 | generated/included2 121 | generated/included2/foo2.json 122 | generated/old_empty_project 123 | generated/stray_file_not_in_a_subfolder.json 124 | ] 125 | end 126 | end 127 | 128 | describe "tracking_id filtering" do 129 | # The filtering only applies to the _cleanup_, not to the _write_. 130 | # This is because filtering of what parts to write is handled by 131 | # Kennel.generated 132 | # 133 | # For tracking_id filtering, this means that we never clean up. 134 | let(:project_filter) { ["included1", "included2"] } 135 | let(:tracking_id_filter) { ["included1:foo1", "included2:foo2"] } 136 | 137 | it "does not clean up" do 138 | write "generated/included1/included1:old_part.json", "whatever" 139 | write "generated/included1/old_part.json", "whatever" 140 | write "generated/included2/old_part.json", "whatever" 141 | write "generated/excluded/old_part.json", "whatever" 142 | Dir.mkdir "generated/old_empty_project" 143 | write "generated/stray_file_not_in_a_subfolder.json", "whatever" 144 | 145 | parts = [ 146 | *make_project("included1", ["foo1"]).validated_parts.each(&:build), 147 | *make_project("included2", ["foo2"]).validated_parts.each(&:build) 148 | ] 149 | Kennel::PartsSerializer.new(filter: filter).write(parts) 150 | 151 | Dir["generated/**/*"].must_equal %w[ 152 | generated/excluded 153 | generated/excluded/old_part.json 154 | generated/included1 155 | generated/included1/foo1.json 156 | generated/included1/included1:old_part.json 157 | generated/included1/old_part.json 158 | generated/included2 159 | generated/included2/foo2.json 160 | generated/included2/old_part.json 161 | generated/old_empty_project 162 | generated/stray_file_not_in_a_subfolder.json 163 | ] 164 | end 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /test/kennel/progress_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::Progress do 7 | capture_all 8 | 9 | describe ".progress" do 10 | it "shows animated progress with tty" do 11 | Kennel.err.stubs(:tty?).returns(true) 12 | result = Kennel::Progress.progress("foo", interval: 0.01) do 13 | sleep 0.10 # make progress print multiple times 14 | 123 15 | end 16 | result.must_equal 123 17 | stderr.string.must_include "|\b/\b-\b\\\b|\b" 18 | stderr.string.sub(/-.*?0/, "0").gsub(/\d\.\d+/, "1.11").must_equal "foo ... 1.11s\n" 19 | end 20 | 21 | it "shows plain progress without tty" do 22 | Kennel.err.stubs(:tty?).returns(false) 23 | result = Kennel::Progress.progress("foo", interval: 0.01) do 24 | sleep 0.10 # if there were a tty, this would make it print the spinner 25 | 123 26 | end 27 | result.must_equal 123 28 | stderr.string.gsub(/\d\.\d+/, "1.11").must_equal "foo ...\nfoo ... 1.11s\n" 29 | end 30 | 31 | it "shows plain progress with plain" do 32 | Kennel.err.stubs(:tty?).never 33 | result = Kennel::Progress.progress("foo", interval: 0.01, plain: true) do 34 | sleep 0.10 # if there were a tty, this would make it print the spinner 35 | 123 36 | end 37 | result.must_equal 123 38 | stderr.string.gsub(/\d\.\d+/, "1.11").must_equal "foo ...\nfoo ... 1.11s\n" 39 | end 40 | 41 | it "stops immediately when block finishes" do 42 | Benchmark.realtime do 43 | Kennel::Progress.progress("foo", interval: 1) do 44 | sleep 0.01 # make it do at least 1 loop 45 | 123 46 | end.must_equal 123 47 | end.must_be :<, 0.1 48 | end 49 | 50 | it "stops when worker crashed" do 51 | assert_raises NotImplementedError do 52 | Kennel::Progress.progress("foo") do 53 | sleep 0.01 # make progress print 54 | raise NotImplementedError 55 | end 56 | end 57 | final = stderr.string 58 | sleep 0.01 59 | stderr.string.must_equal final, "progress was not stopped" 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/kennel/projects_provider_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | require "parallel" 4 | 5 | SingleCov.covered! uncovered: 26 # when using in_isolated_process coverage is not recorded 6 | 7 | describe Kennel::ProjectsProvider do 8 | def write(file, content) 9 | folder = File.dirname(file) 10 | FileUtils.mkdir_p folder 11 | File.write file, content 12 | end 13 | 14 | def projects 15 | Kennel::ProjectsProvider.new(filter: Kennel::Filter.new).projects 16 | end 17 | 18 | in_temp_dir 19 | capture_all 20 | without_cached_projects 21 | 22 | let(:kennel) { Kennel::Engine.new } 23 | 24 | after do 25 | Kennel::Models::Project.recursive_subclasses.each do |klass| 26 | if defined?(klass.name.to_sym) 27 | path = klass.name.split("::") 28 | path[0...-1].inject(Object) { |mod, name| mod.const_get(name) }.send(:remove_const, path.last.to_sym) 29 | end 30 | end 31 | Kennel::Models::Project.subclasses.delete_if { true } 32 | end 33 | 34 | it "loads projects" do 35 | write "teams/my_team.rb", <<~TEAM 36 | class Teams::MyTeam < Kennel::Models::Team 37 | defaults( 38 | mention: "@slack-some-channel", 39 | ) 40 | end 41 | TEAM 42 | 43 | write "projects/project1.rb", <<~RUBY 44 | class Project1 < Kennel::Models::Project 45 | defaults( 46 | team: Teams::MyTeam.new, 47 | kennel_id: 'p1', 48 | parts: [], 49 | ) 50 | end 51 | RUBY 52 | 53 | projects.map(&:name).must_equal ["Project1"] 54 | end 55 | 56 | it "avoids loading twice" do 57 | write "projects/project1.rb", <<~RUBY 58 | class Project1 < Kennel::Models::Project 59 | defaults( 60 | team: Kennel::Models::Team.new, 61 | kennel_id: 'p1', 62 | parts: [], 63 | ) 64 | end 65 | RUBY 66 | 67 | Zeitwerk::Loader.any_instance.expects(:setup).times(1) 68 | Zeitwerk::Loader.any_instance.expects(:eager_load).times(1) 69 | 70 | 2.times do 71 | projects.map(&:name).must_equal ["Project1"] 72 | end 73 | end 74 | 75 | it "shows helpful autoload errors for parts" do 76 | write "projects/a.rb", <<~RUBY 77 | class TestProject3 < Kennel::Models::Project 78 | FooBar::BazFoo 79 | end 80 | RUBY 81 | e = assert_raises(NameError) { kennel.generate } 82 | e.message.must_equal("\n" + <<~MSG.gsub(/^/, " ")) 83 | uninitialized constant TestProject3::FooBar 84 | Unable to load TestProject3::FooBar from parts/test_project3/foo_bar.rb 85 | - Option 1: rename the constant or the file it lives in, to make them match 86 | - Option 2: Use `require` or `require_relative` to load the constant 87 | MSG 88 | end 89 | 90 | it "shows helpful autoload errors for teams" do 91 | write "projects/a.rb", <<~RUBY 92 | class TestProject4 < Kennel::Models::Project 93 | Teams::BazFoo 94 | end 95 | RUBY 96 | e = assert_raises(NameError) { kennel.generate } 97 | e.message.must_equal("\n" + <<~MSG.gsub(/^/, " ")) 98 | uninitialized constant Teams::BazFoo 99 | Unable to load Teams::BazFoo from teams/baz_foo.rb 100 | - Option 1: rename the constant or the file it lives in, to make them match 101 | - Option 2: Use `require` or `require_relative` to load the constant 102 | MSG 103 | end 104 | 105 | it "shows unparseable NameError" do 106 | write "projects/a.rb", <<~RUBY 107 | class TestProject5 < Kennel::Models::Project 108 | raise NameError, "wut" 109 | end 110 | RUBY 111 | e = assert_raises(NameError) { kennel.generate } 112 | e.message.must_equal <<~MSG.rstrip 113 | wut 114 | MSG 115 | end 116 | 117 | describe "autoload" do 118 | def in_isolated_process(&block) 119 | Parallel.flat_map([0], in_processes: 1, &block) 120 | end 121 | 122 | with_env AUTOLOAD_PROJECTS: "abort" 123 | 124 | before do 125 | 2.times do |i| 126 | write "projects/project#{i}.rb", <<~RUBY 127 | class Project#{i} < Kennel::Models::Project 128 | end 129 | RUBY 130 | end 131 | end 132 | 133 | it "can load a single project" do 134 | in_isolated_process do 135 | with_env PROJECT: "project1" do 136 | projects.map(&:name) 137 | end 138 | end.must_equal ["Project1"] 139 | end 140 | 141 | it "can load a single tracking id" do 142 | in_isolated_process do 143 | with_env TRACKING_ID: "project1:foo" do 144 | projects.map(&:name) 145 | end 146 | end.must_equal ["Project1"] 147 | end 148 | 149 | it "can load a single project that has it's own folder" do 150 | in_isolated_process do 151 | write "projects/projecta/project.rb", <<~RUBY 152 | module Projecta 153 | class Project < Kennel::Models::Project 154 | end 155 | end 156 | RUBY 157 | 158 | with_env PROJECT: "projecta" do 159 | projects.map(&:name) 160 | end 161 | end.must_include "Projecta::Project" 162 | end 163 | 164 | it "can load a arbitrary nesting" do 165 | in_isolated_process do 166 | write "projects/projecta/b/c.rb", <<~RUBY 167 | module Projecta 168 | module B 169 | class C < Kennel::Models::Project 170 | end 171 | end 172 | end 173 | RUBY 174 | 175 | with_env PROJECT: "projecta_b_c" do 176 | projects.map(&:name) 177 | end 178 | end.must_include "Projecta::B::C" 179 | end 180 | 181 | it "can load with - in name that is not in the filesystem" do 182 | in_isolated_process do 183 | write "projects/projecta/c.rb", <<~RUBY 184 | module Projecta 185 | class C < Kennel::Models::Project 186 | end 187 | end 188 | RUBY 189 | 190 | with_env PROJECT: "projecta-c" do 191 | projects.map(&:name) 192 | end 193 | end.must_include "Projecta::C" 194 | end 195 | 196 | it "refuses to autoload a too specific file to not shadow other files" do 197 | in_isolated_process do 198 | write "projects/projecta/b_c.rb", <<~RUBY 199 | module Projecta 200 | class BC < Kennel::Models::Project 201 | end 202 | end 203 | RUBY 204 | 205 | with_env PROJECT: "c" do 206 | assert_raises Kennel::ProjectsProvider::AutoloadFailed do 207 | projects.map(&:name) 208 | end 209 | end 210 | end 211 | end 212 | 213 | it "warns when autoloading a single project did not work and it fell back to loading all" do 214 | in_isolated_process do 215 | with_env PROJECT: "projectx", AUTOLOAD_PROJECTS: "1" do 216 | Kennel.err.expects(:puts) 217 | projects.map(&:name) 218 | end 219 | end.must_include "Project1" 220 | end 221 | 222 | it "explains when not finding a project after autoloading" do 223 | in_isolated_process do 224 | write "projects/projecta/b/c.rb", <<~RUBY 225 | module Projecta 226 | end 227 | RUBY 228 | 229 | with_env PROJECT: "projecta_b_c" do 230 | assert_raises Kennel::ProjectsProvider::AutoloadFailed do 231 | projects 232 | end 233 | end 234 | end 235 | end 236 | 237 | it "can load all project" do 238 | in_isolated_process do 239 | projects.map(&:name) 240 | end.must_equal ["Project0", "Project1"] 241 | end 242 | 243 | it "can load multiple projects nesting" do 244 | loaded = in_isolated_process do 245 | write "projects/projecta/b/c.rb", <<~RUBY 246 | module Projecta 247 | module B 248 | class C < Kennel::Models::Project 249 | end 250 | end 251 | end 252 | RUBY 253 | 254 | write "projects/projecta/b/d.rb", <<~RUBY 255 | module Projecta 256 | module B 257 | class D < Kennel::Models::Project 258 | end 259 | end 260 | end 261 | RUBY 262 | 263 | with_env PROJECT: "projecta_b_c,projecta_b_d" do 264 | projects.map(&:name) 265 | end 266 | end 267 | loaded.must_include "Projecta::B::C" 268 | loaded.must_include "Projecta::B::D" 269 | end 270 | end 271 | end 272 | -------------------------------------------------------------------------------- /test/kennel/settings_as_methods_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::SettingsAsMethods do 7 | define_test_classes 8 | 9 | class TestSetting 10 | include Kennel::SettingsAsMethods 11 | settings :foo, :bar, :override, :unset 12 | defaults( 13 | bar: -> { "bar" }, 14 | override: -> { "parent" } 15 | ) 16 | end 17 | 18 | class TestSettingMethod < TestSetting 19 | SETTING_OVERRIDABLE_METHODS = [:name].freeze 20 | settings :name 21 | end 22 | 23 | class ChildTestSetting < TestSetting 24 | settings :baz 25 | defaults( 26 | foo: -> { "foo-child" }, 27 | override: -> { "child-#{super()}" } 28 | ) 29 | end 30 | 31 | describe "#initialize" do 32 | it "can set options" do 33 | TestSetting.new(foo: -> { 111 }).foo.must_equal 111 34 | end 35 | 36 | it "fails when setting unsupported options" do 37 | e = assert_raises(ArgumentError) { TestSetting.new(nope: -> { 111 }) } 38 | e.message.must_equal "Unsupported setting :nope, supported settings are :foo, :bar, :override, :unset" 39 | end 40 | 41 | it "fails nicely when given non-hash" do 42 | e = assert_raises(ArgumentError) { TestSetting.new("FOOO") } 43 | e.message.must_equal "Expected TestSetting.new options to be a Hash, got a String" 44 | end 45 | 46 | it "accepts non-procs (constructor)" do 47 | item = TestSetting.new(foo: 12345) 48 | item.foo.must_equal(12345) 49 | end 50 | 51 | it "accepts non-procs (defaults)" do 52 | klass = Class.new(TestSetting) do 53 | defaults(foo: 12345) 54 | end 55 | item = klass.new 56 | item.foo.must_equal(12345) 57 | end 58 | 59 | it "stores invocation_location" do 60 | model = Kennel::Models::Monitor.new(TestProject.new) 61 | location = model.instance_variable_get(:@invocation_location).sub(/:\d+/, ":123") 62 | location.must_equal "lib/kennel/optional_validations.rb:123:in `initialize'" 63 | end 64 | 65 | it "stores invocation_location from first outside project line" do 66 | Kennel::Models::Monitor.any_instance.expects(:caller).returns( 67 | [ 68 | "/foo/bar.rb", 69 | "#{Dir.pwd}/baz.rb" 70 | ] 71 | ) 72 | model = Kennel::Models::Monitor.new(TestProject.new) 73 | location = model.instance_variable_get(:@invocation_location).sub(/:\d+/, ":123") 74 | location.must_equal "baz.rb" 75 | end 76 | end 77 | 78 | describe ".defaults" do 79 | it "returns defaults" do 80 | ChildTestSetting.new.foo.must_equal "foo-child" 81 | end 82 | 83 | it "inherits" do 84 | ChildTestSetting.new.bar.must_equal "bar" 85 | end 86 | 87 | it "can override" do 88 | ChildTestSetting.new.foo.must_equal "foo-child" 89 | end 90 | 91 | it "can call super" do 92 | ChildTestSetting.new.override.must_equal "child-parent" 93 | end 94 | 95 | it "explains when user forgets to set an option" do 96 | e = assert_raises(ArgumentError) { TestSetting.new.unset } 97 | e.message.must_include "'unset' on TestSetting" 98 | end 99 | 100 | it "does not crash when location was unable to be stored" do 101 | s = TestSetting.new 102 | s.instance_variable_set(:@invocation_location, nil) 103 | e = assert_raises(ArgumentError) { s.unset } 104 | e.message.must_equal "'unset' on TestSetting was not set or passed as option" 105 | end 106 | 107 | it "explains when user forgets to set an option" do 108 | e = assert_raises(ArgumentError) { TestSetting.new.unset } 109 | e.message.must_include "'unset' on TestSetting" 110 | end 111 | 112 | it "cannot set unknown settings on base" do 113 | e = assert_raises(ArgumentError) { TestSetting.defaults(baz: -> {}) } 114 | e.message.must_include "Unsupported setting :baz, supported settings are :foo, :bar, :override, :unset" 115 | end 116 | 117 | it "cannot set unknown settings on child" do 118 | e = assert_raises(ArgumentError) { ChildTestSetting.defaults(nope: -> {}) } 119 | e.message.must_include "Unsupported setting :nope, supported settings are :foo, :bar, :override, :unset, :baz" 120 | end 121 | end 122 | 123 | describe ".settings" do 124 | it "fails when already defined to avoid confusion and typos" do 125 | e = assert_raises(ArgumentError) { TestSetting.settings :foo } 126 | e.message.must_equal "Settings :foo are already defined" 127 | end 128 | 129 | it "does not allow overwriting base methods" do 130 | e = assert_raises(ArgumentError) { TestSetting.settings(:inspect) } 131 | e.message.must_equal "Settings :inspect are already used as methods" 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/kennel/string_utils_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::StringUtils do 7 | describe ".snake_case" do 8 | it "converts namespaced classes" do 9 | Kennel::StringUtils.snake_case("Foo::Bar").must_equal "foo_bar" 10 | end 11 | 12 | it "converts classes with all-caps" do 13 | Kennel::StringUtils.snake_case("Foo2BarBAZ").must_equal "foo2_bar_baz" 14 | end 15 | 16 | it "converts dashes for external users" do 17 | Kennel::StringUtils.snake_case("fo-o-bar").must_equal "fo_o_bar" 18 | end 19 | end 20 | 21 | describe ".title_case" do 22 | it "converts snake case" do 23 | Kennel::StringUtils.title_case("foo_bar").must_equal "Foo Bar" 24 | end 25 | end 26 | 27 | describe ".parameterize" do 28 | { 29 | "--" => "", 30 | "aøb" => "a-b", 31 | "" => "", 32 | "a1_Bc" => "a1_bc", 33 | "øabcøødefø" => "abc-def" 34 | }.each do |from, to| 35 | it "coverts #{from} to #{to}" do 36 | Kennel::StringUtils.parameterize(from).must_equal to 37 | end 38 | end 39 | end 40 | 41 | describe ".truncate_lines" do 42 | def call(text) 43 | Kennel::StringUtils.truncate_lines(text, to: 2, warning: "SNIP!") 44 | end 45 | 46 | it "leaves short alone" do 47 | call("a\nb").must_equal "a\nb" 48 | end 49 | 50 | it "truncates long" do 51 | call("a\nb\nc").must_equal "a\nb\nSNIP!" 52 | end 53 | 54 | it "keeps sequential newlines" do 55 | call("a\n\nb\nc").must_equal "a\n\nSNIP!" 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/kennel/subclass_tracking_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::SubclassTracking do 7 | define_test_classes 8 | 9 | describe ".recursive_subclasses" do 10 | it "registers all created projects and subclasses" do 11 | Kennel::Models::Project.recursive_subclasses.must_equal [TestProject, SubTestProject] 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/kennel/syncer/matched_expected_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../test_helper" 4 | 5 | # Covered by syncer_test.rb 6 | SingleCov.covered! 7 | 8 | describe Kennel::Syncer::MatchedExpected do 9 | let(:monitor) { Kennel::Models::Monitor } 10 | let(:dashboard) { Kennel::Models::Dashboard } 11 | 12 | let(:expected_class) { Struct.new(:tracking_id, :class, :id) } # rubocop:disable Lint/StructNewOverride 13 | 14 | let(:expected) { [] } 15 | let(:actual) { [] } 16 | 17 | let(:result) { Kennel::Syncer::MatchedExpected.partition(expected, actual) } 18 | 19 | let(:matched) { result[0] } 20 | let(:unmatched_expected) { result[1] } 21 | let(:unmatched_actual) { result[2] } 22 | 23 | def make_expected(tracking_id, klass, id) 24 | expected_class.new(tracking_id, klass, id) 25 | end 26 | 27 | def make_actual(tracking_id, klass, id) 28 | { tracking_id: tracking_id, klass: klass, id: id } 29 | end 30 | 31 | describe "basic matching" do 32 | it "create" do 33 | e = make_expected("foo:bar", monitor, nil) 34 | expected << e 35 | 36 | matched.must_be_empty 37 | unmatched_expected.must_equal [e] 38 | unmatched_actual.must_be_empty 39 | end 40 | 41 | it "update" do 42 | e = make_expected("foo:bar", monitor, nil) 43 | a = make_actual("foo:bar", monitor, 999) 44 | expected << e 45 | actual << a 46 | 47 | matched.must_equal [[e, a]] 48 | unmatched_expected.must_be_empty 49 | unmatched_actual.must_be_empty 50 | end 51 | 52 | it "delete" do 53 | a = make_actual("foo:bar", monitor, 999) 54 | actual << a 55 | 56 | matched.must_be_empty 57 | unmatched_expected.must_be_empty 58 | unmatched_actual.must_equal [a] 59 | end 60 | end 61 | 62 | describe "expected with id" do 63 | it "can import" do 64 | e = make_expected("foo:bar", monitor, 999) 65 | a = make_actual(nil, monitor, 999) 66 | expected << e 67 | actual << a 68 | 69 | matched.must_equal [[e, a]] 70 | unmatched_expected.must_be_empty 71 | unmatched_actual.must_be_empty 72 | end 73 | 74 | it "ignores id if no match" do 75 | e = make_expected("foo:bar", monitor, 999) 76 | expected << e 77 | 78 | matched.must_be_empty 79 | unmatched_expected.must_equal [e] 80 | unmatched_actual.must_be_empty 81 | end 82 | 83 | it "matches on id" do 84 | e = make_expected("foo:bar", monitor, 999) 85 | a = make_actual(nil, monitor, 777) 86 | b = make_actual(nil, monitor, 999) 87 | expected << e 88 | actual << a 89 | actual << b 90 | 91 | matched.must_equal [[e, b]] 92 | unmatched_expected.must_be_empty 93 | unmatched_actual.must_equal [a] 94 | end 95 | 96 | it "matches on api_resource" do 97 | e = make_expected("foo:bar", monitor, 999) 98 | a = make_actual(nil, dashboard, 999) 99 | b = make_actual(nil, monitor, 999) 100 | expected << e 101 | actual << a 102 | actual << b 103 | 104 | matched.must_equal [[e, b]] 105 | unmatched_expected.must_be_empty 106 | unmatched_actual.must_equal [a] 107 | end 108 | end 109 | 110 | describe "duplicate tracking ids / import ids" do 111 | it "raises on duplicate tracking_id in expected" do 112 | expected << make_expected("foo:bar", monitor, nil) 113 | expected << make_expected("foo:bar", dashboard, nil) 114 | 115 | assert_raises(RuntimeError) { result }.message.must_equal "Lookup foo:bar is duplicated" 116 | end 117 | 118 | it "raises on duplicate id in expected" do 119 | expected << make_expected("foo:bar", monitor, 999) 120 | expected << make_expected("foo:baz", monitor, 999) 121 | 122 | assert_raises(RuntimeError) { result }.message.must_equal "Lookup monitor:999 is duplicated" 123 | end 124 | 125 | it "does not raise on duplicate tracking_id in actual" do 126 | actual << make_actual("foo:bar", monitor, nil) 127 | actual << make_actual("foo:bar", dashboard, nil) 128 | 129 | result 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /test/kennel/syncer/plan_printer_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../test_helper" 4 | 5 | # Covered by syncer_test.rb 6 | SingleCov.covered! uncovered: 15 7 | -------------------------------------------------------------------------------- /test/kennel/syncer/plan_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../test_helper" 4 | 5 | # Covered by syncer_test.rb 6 | SingleCov.covered! uncovered: 2 7 | -------------------------------------------------------------------------------- /test/kennel/syncer/resolver_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../test_helper" 4 | 5 | # Covered by syncer_test.rb 6 | SingleCov.covered! uncovered: 27 7 | -------------------------------------------------------------------------------- /test/kennel/syncer/types_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../test_helper" 4 | 5 | # Covered by syncer_test.rb 6 | SingleCov.covered! uncovered: 17 7 | -------------------------------------------------------------------------------- /test/kennel/tags_validation_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::TagsValidation do 7 | define_test_classes 8 | 9 | describe "#validate_json" do 10 | let(:tags) { ["team:bar"] } 11 | let(:dashboard) do 12 | local_tags = tags 13 | Kennel::Models::Dashboard.new( 14 | TestProject.new, 15 | kennel_id: -> { "test" }, 16 | tags: -> { local_tags }, 17 | layout_type: "foo", 18 | title: "bar" 19 | ) 20 | end 21 | 22 | def call 23 | tags = dashboard.build[:tags] 24 | [tags, dashboard.validation_errors.map(&:tag)] 25 | end 26 | 27 | it "is valid" do 28 | call.must_equal [["team:bar"], []] 29 | end 30 | 31 | it "dedupes" do 32 | tags << "team:bar" 33 | call.must_equal [["team:bar"], []] 34 | end 35 | 36 | it "fails on invalid" do 37 | tags << "team:B A R" 38 | call[1].must_equal [:tags_invalid] 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/kennel/template_variables_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::TemplateVariables do 7 | define_test_classes 8 | 9 | it "adds settings" do 10 | Kennel::Models::Dashboard.new(TestProject.new, kennel_id: -> { "test" }, template_variables: -> { ["xxx"] }).template_variables.must_equal ["xxx"] 11 | end 12 | 13 | describe "#render_template_variables" do 14 | def var(value) 15 | Kennel::Models::Dashboard.new(TestProject.new, kennel_id: -> { "test" }, template_variables: -> { value }).send(:render_template_variables) 16 | end 17 | 18 | it "leaves empty alone" do 19 | var([]).must_equal [] 20 | end 21 | 22 | it "expands simple" do 23 | var(["xxx"]).must_equal [{ default: "*", prefix: "xxx", name: "xxx" }] 24 | end 25 | 26 | it "leaves complicated" do 27 | var([{ foo: "bar" }]).must_equal [{ foo: "bar" }] 28 | end 29 | end 30 | 31 | describe "#validate_json" do 32 | let(:errors) { item.validation_errors } 33 | let(:error_tags) { errors.map(&:tag) } 34 | let(:item) { Kennel::Models::Dashboard.new(TestProject.new) } 35 | 36 | def validate(variable_names, widgets) 37 | data = { 38 | tags: [], 39 | template_variables: variable_names.map { |v| { name: v } }, 40 | widgets: widgets 41 | } 42 | item.send(:validate_json, data) 43 | end 44 | 45 | it "is valid when empty" do 46 | validate [], [] 47 | error_tags.must_equal [] 48 | end 49 | 50 | it "is valid when vars are empty" do 51 | validate [], [{ definition: { requests: [{ q: "x" }] } }] 52 | error_tags.must_equal [] 53 | end 54 | 55 | it "is valid when vars are used" do 56 | validate ["a"], [{ definition: { requests: [{ q: "$a" }] } }] 57 | error_tags.must_equal [] 58 | end 59 | 60 | it "is invalid when vars are not used" do 61 | validate ["a"], [{ definition: { requests: [{ q: "$b" }] } }] 62 | error_tags.must_equal [:queries_must_use_template_variables] 63 | end 64 | 65 | it "is invalid when some vars are not used" do 66 | validate ["a", "b"], [{ definition: { requests: [{ q: "$b" }] } }] 67 | error_tags.must_equal [:queries_must_use_template_variables] 68 | end 69 | 70 | it "is valid when all vars are used" do 71 | validate ["a", "b"], [{ definition: { requests: [{ q: "$a,$b" }] } }] 72 | error_tags.must_equal [] 73 | end 74 | 75 | it "is invalid when nested vars are not used" do 76 | validate ["a"], [{ definition: { widgets: [{ definition: { requests: [{ q: "$b" }] } }] } }] 77 | error_tags.must_equal [:queries_must_use_template_variables] 78 | end 79 | 80 | it "works with hostmap widgets" do 81 | validate ["a"], [{ definition: { requests: { fill: { q: "x" } } } }] 82 | error_tags.must_equal [:queries_must_use_template_variables] 83 | end 84 | 85 | it "works with new api format" do 86 | validate ["a"], [{ definition: { requests: [{ queries: [{ query: "x" }] }] } }] 87 | error_tags.must_equal [:queries_must_use_template_variables] 88 | end 89 | 90 | it "still calls existing validations" do 91 | validate [], [{ "definition" => { requests: [{ q: "x" }] } }] 92 | error_tags.must_equal [:unignorable] 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/kennel/unmuted_alerts_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | require "stringio" 4 | 5 | SingleCov.covered! 6 | 7 | describe Kennel::UnmutedAlerts do 8 | capture_all 9 | 10 | let(:tag) { "team:compute" } 11 | let(:monitors) { [monitor] } 12 | let(:triggered) { (Time.now - 10).to_i } 13 | let(:monitor) do 14 | { 15 | id: 12345, 16 | tags: [tag], 17 | name: "monitor_name", 18 | state: { 19 | groups: { 20 | "pod:pod10": { status: "Alert", name: "pod:pod10", last_triggered_ts: triggered }, 21 | "pod:pod3": { status: "Foo", name: "pod:pod3", last_triggered_ts: triggered }, 22 | "pod:pod3,project:foo,team:bar": { 23 | status: "Alert", name: "pod:pod3,project:foo,team:bar", last_triggered_ts: triggered 24 | } 25 | } 26 | }, 27 | overall_state: "Alert", 28 | options: { silenced: { "pod:pod10": nil } } 29 | } 30 | end 31 | 32 | before do 33 | time = Time.now 34 | Time.stubs(:now).returns(time) 35 | end 36 | 37 | it "can display all colors" do 38 | Kennel::UnmutedAlerts::COLORS.each_value do |color| 39 | Kennel::Console::COLORS.fetch(color) 40 | end 41 | end 42 | 43 | describe "#print" do 44 | it "prints alerts" do 45 | Kennel::UnmutedAlerts.send(:sort_groups!, monitor) 46 | Kennel::UnmutedAlerts.expects(:filtered_monitors).returns([monitor]) 47 | out = Kennel::Console.capture_stdout do 48 | Kennel.out.stubs(:tty?).returns(true) 49 | Kennel::UnmutedAlerts.send(:print, nil, tag) 50 | end 51 | out.must_equal <<~TEXT 52 | monitor_name 53 | https://app.datadoghq.com/monitors/12345 54 | \e[0mFoo\e[0m\tpod:pod3\t00:00:10 55 | \e[31mAlert\e[0m\tpod:pod3,project:foo,team:bar\t00:00:10 56 | \e[31mAlert\e[0m\tpod:pod10\t00:00:10 57 | 58 | TEXT 59 | end 60 | 61 | it "does not print alerts when there are no monitors" do 62 | Kennel::UnmutedAlerts.expects(:filtered_monitors).returns([]) 63 | out = Kennel::Console.capture_stdout do 64 | Kennel::UnmutedAlerts.send(:print, nil, tag) 65 | end 66 | out.must_equal "No unmuted alerts found\n" 67 | end 68 | end 69 | 70 | describe "#sort_groups!" do 71 | it "sorts naturally" do 72 | sorted = Kennel::UnmutedAlerts.send(:sort_groups!, monitor) 73 | sorted.map { |g| g[:name] }.must_equal ["pod:pod3", "pod:pod3,project:foo,team:bar", "pod:pod10"] 74 | end 75 | end 76 | 77 | describe "#filtered_monitors" do 78 | let(:api) { Kennel::Api.new("app", "api") } 79 | 80 | def result 81 | stub_datadog_request(:get, "monitor", "&monitor_tags=#{tag}&group_states=all&with_downtimes=true").to_return(body: monitors.to_json) 82 | Kennel::UnmutedAlerts.send(:filtered_monitors, api, tag) 83 | end 84 | 85 | it "does not filter unmuted alerts" do 86 | result.size.must_equal 1 87 | end 88 | 89 | # see https://help.datadoghq.com/hc/requests/174099 90 | # overall_state is not reliable, but the only tool we have to shut datadog up, so we have to use it :( 91 | it "removes monitors that claim to be OK since even though it is not reliable for No-Data" do 92 | monitor[:overall_state] = "OK" 93 | result.size.must_equal 0 94 | end 95 | 96 | it "removes monitors that are Ignored since that just means recovered" do 97 | monitor[:state][:groups].each_value { |v| v[:status] = "Ignored" } 98 | result.size.must_equal 0 99 | end 100 | 101 | it "removes completely muted alerts" do 102 | monitor[:options] = { silenced: { "*": "foo" } } 103 | result.size.must_equal 0 104 | end 105 | 106 | it "removes monitors that are silenced via partial silences" do 107 | monitor[:options] = { silenced: { "pod:pod10": "foo", "pod:pod3": "foo" } } 108 | result.size.must_equal 0 109 | end 110 | 111 | it "removes monitors that are covered by matching downtimes" do 112 | groups = ["pod:pod3", "pod:pod3,project:foo,team:bar"] 113 | monitor[:matching_downtimes] = [ 114 | { end: nil, start: triggered - 10, groups: groups, active: true, scope: ["pod:pod3"] }, 115 | { end: nil, start: triggered - 10, groups: [], active: true, scope: ["pod:pod7"] } 116 | ] 117 | result.size.must_equal 0 118 | end 119 | 120 | it "alerts users when no monitor has selected tag" do 121 | monitors.pop 122 | e = assert_raises(RuntimeError) { result } 123 | e.message.must_equal "No monitors for team:compute found, check your spelling" 124 | end 125 | 126 | it "removes groups that match multi-key silence" do 127 | monitor[:options] = { silenced: { "project:foo,team:bar": "foo" } } 128 | result.first[:state][:groups].size.must_equal 2 129 | end 130 | 131 | it "only keeps alerting groups in monitor" do 132 | result.first[:state][:groups].size.must_equal 2 133 | end 134 | end 135 | 136 | describe "#time_since" do 137 | before do 138 | time = Time.now 139 | Time.stubs(:now).returns(time) 140 | end 141 | 142 | it "builds for 0" do 143 | Kennel::UnmutedAlerts.send(:time_since, Time.now.to_i).must_equal "00:00:00" 144 | end 145 | 146 | it "builds for full" do 147 | diff = (99 * 60 * 60) + (59 * 60) + 59 148 | Kennel::UnmutedAlerts.send(:time_since, Time.now.to_i - diff).must_equal "99:59:59" 149 | end 150 | 151 | it "can overflow" do 152 | diff = (100 * 60 * 60) + 123 153 | Kennel::UnmutedAlerts.send(:time_since, Time.now.to_i - diff).must_equal "100:02:03" 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /test/kennel/utils_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.covered! 5 | 6 | describe Kennel::Utils do 7 | describe ".presence" do 8 | it "returns regular values" do 9 | Kennel::Utils.presence("a").must_equal "a" 10 | end 11 | 12 | it "does not return empty values" do 13 | Kennel::Utils.presence("").must_be_nil 14 | end 15 | end 16 | 17 | describe ".capture_sh" do 18 | it "captures" do 19 | Kennel::Utils.capture_sh("echo 111").must_equal "111\n" 20 | end 21 | 22 | it "fails on failure" do 23 | e = assert_raises(RuntimeError) { Kennel::Utils.capture_sh("whooops") } 24 | e.message.must_include "whooops" 25 | end 26 | end 27 | 28 | describe ".path_to_url" do 29 | it "shows app." do 30 | Kennel::Utils.path_to_url("/111").must_equal "https://app.datadoghq.com/111" 31 | end 32 | 33 | it "shows full url" do 34 | with_env DATADOG_SUBDOMAIN: "foobar" do 35 | Kennel::Utils.path_to_url("/111").must_equal "https://foobar.datadoghq.com/111" 36 | end 37 | end 38 | end 39 | 40 | describe ".parallel" do 41 | it "executes in parallel" do 42 | Benchmark.realtime do 43 | Kennel::Utils.parallel([1, 2, 3, 4, 5]) do |i| 44 | sleep 0.1 45 | i * 2 46 | end.must_equal [2, 4, 6, 8, 10] 47 | end.must_be :<, 0.2 48 | end 49 | 50 | it "raises runtime errors" do 51 | assert_raises ArgumentError do 52 | Kennel::Utils.parallel([1, 2, 3, 4, 5]) do 53 | raise ArgumentError 54 | end 55 | end 56 | end 57 | 58 | it "raises exceptions" do 59 | assert_raises Interrupt do 60 | Kennel::Utils.parallel([1, 2, 3, 4, 5]) do 61 | raise Interrupt 62 | end 63 | end 64 | end 65 | 66 | it "finishes fast when exception happens" do 67 | called = [] 68 | all = [1, 2, 3, 4, 5] 69 | assert_raises ArgumentError do 70 | Kennel::Utils.parallel(all, max: 2) do |i| 71 | called << i 72 | raise ArgumentError 73 | end 74 | end 75 | called.size.must_be :<, all.size 76 | end 77 | end 78 | 79 | describe ".natural_order" do 80 | def sort(list) 81 | list.sort_by { |x| Kennel::Utils.natural_order(x) } 82 | end 83 | 84 | it "sorts naturally" do 85 | sort(["a11", "a1", "a22", "b1", "a12", "a9"]).must_equal ["a1", "a9", "a11", "a12", "a22", "b1"] 86 | end 87 | 88 | it "sorts pure numbers" do 89 | sort(["11", "1", "22", "12", "9"]).must_equal ["1", "9", "11", "12", "22"] 90 | end 91 | 92 | it "sorts pure words" do 93 | sort(["bb", "ab", "aa", "a", "b"]).must_equal ["a", "aa", "ab", "b", "bb"] 94 | end 95 | end 96 | 97 | describe ".retry" do 98 | it "succeeds" do 99 | Kennel.err.expects(:puts).never 100 | Kennel::Utils.retry(RuntimeError, times: 2) { :a }.must_equal :a 101 | end 102 | 103 | it "retries and raises on persistent error" do 104 | Kennel.err.expects(:puts).times(2) 105 | call = [] 106 | assert_raises(RuntimeError) do 107 | Kennel::Utils.retry(RuntimeError, times: 2) do 108 | call << :a 109 | raise 110 | end 111 | end 112 | call.must_equal [:a, :a, :a] 113 | end 114 | 115 | it "can succeed after retrying" do 116 | Kennel.err.expects(:puts).times(2) 117 | call = [] 118 | Kennel::Utils.retry(RuntimeError, times: 2) do 119 | call << :a 120 | raise if call.size <= 2 121 | call 122 | end.must_equal [:a, :a, :a] 123 | end 124 | end 125 | 126 | describe ".all_keys" do 127 | it "finds keys for hash" do 128 | Kennel::Utils.all_keys(foo: 1).must_equal [:foo] 129 | end 130 | 131 | it "finds keys for hash in array" do 132 | Kennel::Utils.all_keys([{ foo: 1 }]).must_equal [:foo] 133 | end 134 | 135 | it "finds keys for multiple" do 136 | Kennel::Utils.all_keys([{ foo: 1 }, [[[{ bar: 2 }]]]]).must_equal [:foo, :bar] 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /test/kennel/version_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "../test_helper" 3 | 4 | SingleCov.not_covered! # loaded as part of the Gemfile, so we cannot cover it 5 | 6 | describe Kennel::VERSION do 7 | it "has a VERSION" do 8 | Kennel::VERSION.must_match(/^[.\da-z]+$/) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/kennel_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "test_helper" 3 | require "tmpdir" 4 | 5 | SingleCov.covered! 6 | 7 | describe Kennel do 8 | define_test_classes 9 | 10 | def write(file, content) 11 | folder = File.dirname(file) 12 | FileUtils.mkdir_p folder 13 | File.write file, content 14 | end 15 | 16 | let(:models_count) { 4 } 17 | let(:kennel) { Kennel::Engine.new } 18 | 19 | capture_all 20 | without_cached_projects 21 | in_temp_dir 22 | enable_api 23 | 24 | before do 25 | write "projects/simple.rb", <<~RUBY 26 | class TempProject < Kennel::Models::Project 27 | defaults( 28 | team: -> { TestTeam.new }, 29 | parts: -> { [ 30 | Kennel::Models::Monitor.new( 31 | self, 32 | type: -> { "query alert" }, 33 | kennel_id: -> { 'foo' }, 34 | query: -> { "avg(last_5m) > \#{critical}" }, 35 | critical: -> { 1 } 36 | ) 37 | ] } 38 | ) 39 | end 40 | RUBY 41 | end 42 | 43 | # we need to clean up so new definitions of TempProject trigger subclass addition 44 | # and leftover classes do not break other tests 45 | after do 46 | Kennel::Models::Project.subclasses.delete_if { |c| c.name.match?(/TestProject\d|TempProject/) } 47 | Object.send(:remove_const, :TempProject) if defined?(TempProject) 48 | Object.send(:remove_const, :TempProject2) if defined?(TempProject2) 49 | Object.send(:remove_const, :TempProject3) if defined?(TempProject3) 50 | end 51 | 52 | describe ".preload" do 53 | it "prepares" do 54 | Kennel::Api.any_instance.expects(:list).times(models_count).returns([]) 55 | kennel.preload 56 | stdout.string.must_equal "" 57 | end 58 | end 59 | 60 | describe ".generate" do 61 | it "stores if requested" do 62 | writer = "some writer".dup 63 | 64 | Kennel::PartsSerializer.stubs(:new).returns(writer) 65 | 66 | writer.stubs(:write).with do |parts| 67 | parts.map(&:tracking_id) == ["temp_project:foo"] 68 | end.once 69 | 70 | with_env(STORE: nil) { kennel.generate } 71 | end 72 | 73 | it "does not store if requested" do 74 | writer = "some writer".dup 75 | Kennel::PartsSerializer.stubs(:new).returns(writer) 76 | writer.stubs(:write).never 77 | 78 | with_env(STORE: "false") { kennel.generate } 79 | end 80 | 81 | it "complains when duplicates would be written" do 82 | write "projects/a.rb", <<~RUBY 83 | class TestProject2 < Kennel::Models::Project 84 | defaults(parts: -> { Array.new(2).map { Kennel::Models::Monitor.new(self, kennel_id: -> {"bar"}) } }) 85 | end 86 | RUBY 87 | e = assert_raises(RuntimeError) { kennel.generate } 88 | e.message.must_equal <<~ERROR 89 | test_project2:bar is defined 2 times 90 | 91 | use a different `kennel_id` when defining multiple projects/monitors/dashboards to avoid this conflict 92 | ERROR 93 | end 94 | end 95 | 96 | describe ".plan" do 97 | it "plans" do 98 | stdout.stubs(:tty?).returns(true) 99 | Kennel::Api.any_instance.expects(:list).times(models_count).returns([]) 100 | kennel.plan 101 | stdout.string.must_include "Plan:\n\e[32mCreate monitor temp_project:foo\e[0m\n" 102 | end 103 | end 104 | 105 | describe ".update" do 106 | before do 107 | Kennel.in.expects(:tty?).returns(true) 108 | Kennel.err.stubs(:tty?).returns(true) 109 | end 110 | 111 | it "update" do 112 | Kennel::Api.any_instance.expects(:list).times(models_count).returns([]) 113 | Kennel.in.expects(:gets).returns("y\n") # proceed ? ... yes! 114 | Kennel::Api.any_instance.expects(:create).returns(Kennel::Api.with_tracking("monitor", id: 123)) 115 | 116 | kennel.update 117 | 118 | stderr.string.must_include "press 'y' to continue" 119 | stdout.string.must_include "Created monitor temp_project:foo https://app.datadoghq.com/monitors/123" 120 | end 121 | 122 | it "does not update when user does not confirm" do 123 | Kennel::Api.any_instance.expects(:list).times(models_count).returns([]) 124 | Kennel.in.expects(:gets).returns("n\n") # proceed ? ... no! 125 | stdout.expects(:tty?).returns(true) 126 | stderr.expects(:tty?).returns(true) 127 | 128 | kennel.update 129 | 130 | stderr.string.must_match(/press 'y' to continue: \e\[0m\z/m) # nothing after 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/misc_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "test_helper" 3 | require "tmpdir" 4 | 5 | SingleCov.not_covered! 6 | 7 | describe "Misc" do 8 | it "does not hardcode zendesk anywhere" do 9 | Dir["{lib,template}/**/*.rb"].grep_v("/vendor/").each do |file| 10 | refute File.read(file).include?("zendesk"), "#{file} should not reference zendesk" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/readme_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "test_helper" 3 | require "tmpdir" 4 | 5 | SingleCov.not_covered! 6 | 7 | describe "Readme.md" do 8 | def rake_tasks(content) 9 | content.scan(/(rake [^\[\s`]+)/).flatten(1).sort.uniq 10 | end 11 | 12 | let(:readme) { "Readme.md" } 13 | let(:ruby_block_start) { "```Ruby" } 14 | 15 | it "has working code blocks" do 16 | lines = File.readlines(readme) 17 | 18 | # code blocks with line number so when the eval fails we get a usable error 19 | code_blocks = lines 20 | .each_with_index.map { |_, n| n } # we only care for line numbers 21 | .select { |l| lines[l].include?("```") } # ignore start or end of block 22 | .each_slice(2) # group by blocks 23 | .select { |start, _| lines[start].include?(ruby_block_start) } # only ruby code blocks 24 | .map { |start, stop| [lines[(start + 1)...stop].join, start + 2] } # grab block of code 25 | 26 | code_blocks.reject! { |block, _| block.match?(/^\s+\.\.\./) } # ignore broken blocks 27 | 28 | code_blocks.each { |block, line| eval(block, nil, readme, line) } # rubocop:disable Security/Eval 29 | 30 | Kennel::Models::Project.recursive_subclasses.each { |p| p.new.parts.each(&:as_json) } 31 | end 32 | 33 | it "has language selected for all code blocks so 'working' test above is reliable" do 34 | code_blocks_starts = File.read(readme).scan(/```.*/).each_slice(2).map(&:first).map(&:strip) 35 | code_blocks_starts.uniq.sort.must_equal ["```Bash", ruby_block_start] 36 | end 37 | 38 | it "documents all public rake tasks" do 39 | documented = rake_tasks(File.read("Readme.md")) 40 | documented -= ["rake play"] # in parent repo 41 | 42 | output = `cd template && rake -T` 43 | .gsub("kennel:plan", "plan") # alias in template/Rakefile 44 | .gsub("kennel:generate", "generate") # alias in template/Rakefile 45 | available = rake_tasks(output) 46 | available -= ["rake kennel:no_diff"] # in template/.travis.yml 47 | available -= ["rake kennel:ci"] # in template/.travis.yml 48 | 49 | assert available == documented, <<~MSG 50 | Documented and available rake tasks are not the same: 51 | #{documented} 52 | #{available} 53 | ~#{(Set.new(documented) ^ Set.new(available)).to_a} 54 | MSG 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "bundler/setup" 3 | 4 | require "single_cov" 5 | SingleCov.setup :minitest 6 | 7 | # make CI and local behave the same 8 | ENV.delete("CI") 9 | ENV.delete("GITHUB_REPOSITORY") 10 | 11 | require "maxitest/global_must" 12 | require "maxitest/autorun" 13 | require "maxitest/timeout" 14 | require "webmock/minitest" 15 | require "mocha/minitest" 16 | 17 | $LOAD_PATH.unshift "lib" 18 | 19 | require "kennel" 20 | 21 | Minitest::Test.class_eval do 22 | def self.define_test_classes 23 | eval <<~RUBY, nil, "test/test_helper.rb", __LINE__ + 1 24 | class TestProject < Kennel::Models::Project 25 | defaults( 26 | team: -> { TestTeam.new }, 27 | parts: -> { [] } 28 | ) 29 | end 30 | 31 | class SubTestProject < TestProject 32 | end 33 | 34 | class TestTeam < Kennel::Models::Team 35 | defaults(mention: -> { "@slack-foo" }) 36 | end 37 | 38 | module Teams 39 | class MyTeam < Kennel::Models::Team 40 | defaults(mention: -> { "@slack-my" }) 41 | end 42 | end 43 | RUBY 44 | end 45 | 46 | def self.without_cached_projects 47 | after do 48 | Kennel::ProjectsProvider.remove_class_variable(:@@load_all) if Kennel::ProjectsProvider.class_variable_defined?(:@@load_all) 49 | end 50 | end 51 | 52 | def with_env(hash) 53 | old = ENV.to_h 54 | hash.each { |k, v| ENV[k.to_s] = v } 55 | yield 56 | ensure 57 | ENV.replace(old) 58 | end 59 | 60 | def self.with_env(hash) 61 | around { |t| with_env(hash, &t) } 62 | end 63 | 64 | def self.capture_all 65 | let(:stdout) { StringIO.new } 66 | let(:stderr) { StringIO.new } 67 | 68 | around do |t| 69 | old = [Kennel.in, Kennel.out, Kennel.err] 70 | File.open(File::NULL) do |dev_null| 71 | Kennel.in = dev_null 72 | Kennel.out = stdout 73 | Kennel.err = stderr 74 | t.call 75 | end 76 | ensure 77 | Kennel.in, Kennel.out, Kennel.err = old 78 | end 79 | end 80 | 81 | def self.in_temp_dir(&block) 82 | around do |t| 83 | Dir.mktmpdir do |dir| 84 | Dir.chdir(dir) do 85 | instance_eval(&block) if block 86 | t.call 87 | end 88 | end 89 | end 90 | end 91 | 92 | def deep_dup(value) 93 | Marshal.load(Marshal.dump(value)) 94 | end 95 | 96 | def self.enable_api 97 | around { |t| enable_api(&t) } 98 | end 99 | 100 | def enable_api(&block) 101 | with_env("DATADOG_APP_KEY" => "x", "DATADOG_API_KEY" => "y", &block) 102 | end 103 | 104 | def stub_datadog_request(method, path, extra = "") 105 | stub_request(method, "https://app.datadoghq.com/api/v1/#{path}?#{extra}") 106 | end 107 | 108 | def with_sorted_hash_keys(value) 109 | case value 110 | when Hash 111 | value.entries.sort_by(&:first).to_h.transform_values { |v| with_sorted_hash_keys(v) } 112 | when Array 113 | value.map { |v| with_sorted_hash_keys(v) } 114 | else 115 | value 116 | end 117 | end 118 | 119 | # generate readables diffs when things are not equal 120 | def assert_json_equal(a, b) 121 | a = with_sorted_hash_keys(a) 122 | b = with_sorted_hash_keys(b) 123 | JSON.pretty_generate(a).must_equal JSON.pretty_generate(b) 124 | end 125 | 126 | def validation_errors_from(part) 127 | part.build 128 | part.validation_errors.map(&:text) 129 | end 130 | 131 | def validation_error_from(part) 132 | errors = validation_errors_from(part) 133 | errors.length.must_equal(1, "Expected 1 error, got #{errors.inspect}") 134 | errors.first 135 | end 136 | end 137 | --------------------------------------------------------------------------------