├── .gitignore ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── hotdog ├── hotdog.gemspec ├── lib └── hotdog │ ├── application.rb │ ├── commands.rb │ ├── commands │ ├── down.rb │ ├── help.rb │ ├── hosts.rb │ ├── ls.rb │ ├── pssh.rb │ ├── scp.rb │ ├── search.rb │ ├── sftp.rb │ ├── ssh.rb │ ├── tag.rb │ ├── tags.rb │ ├── untag.rb │ ├── up.rb │ └── version.rb │ ├── expression.rb │ ├── expression │ ├── semantics.rb │ └── syntax.rb │ ├── formatters.rb │ ├── formatters │ ├── csv.rb │ ├── json.rb │ ├── ltsv.rb │ ├── plain.rb │ ├── text.rb │ ├── tsv.rb │ └── yaml.rb │ ├── sources.rb │ ├── sources │ └── datadog.rb │ └── version.rb └── spec ├── core ├── application_spec.rb └── commands_spec.rb ├── evaluator ├── glob_expression_spec.rb ├── regexp_expression_spec.rb └── string_expression_spec.rb ├── formatter ├── csv_spec.rb ├── json_spec.rb ├── ltsv_spec.rb ├── plain_spec.rb ├── text_spec.rb ├── tsv_spec.rb └── yaml_spec.rb ├── optimizer ├── binary_expression_spec.rb ├── glob_expression_spec.rb ├── regexp_expression_spec.rb ├── string_expression_spec.rb └── unary_expression_spec.rb ├── optparse ├── down_spec.rb ├── hosts_spec.rb ├── pssh_spec.rb ├── search_spec.rb ├── ssh_spec.rb ├── tags_spec.rb └── up_spec.rb ├── parser └── parser_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.swo 13 | *.swp 14 | *.o 15 | *.a 16 | mkmf.log 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.4.6 4 | - 2.5.5 5 | - 2.6.3 6 | script: 7 | - rspec spec 8 | sudo: false 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org/" 2 | 3 | # Specify your gem's dependencies in hotdog.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Yamashita Yuu 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hotdog 2 | 3 | [![Build Status](https://travis-ci.org/yyuu/hotdog.svg)](https://travis-ci.org/yyuu/hotdog) 4 | 5 | Yet another command-line tools for [Datadog](https://www.datadoghq.com/). 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'hotdog' 13 | ``` 14 | 15 | And then execute: 16 | 17 | ```sh 18 | $ bundle 19 | ``` 20 | 21 | Or install it yourself as: 22 | 23 | ``` 24 | $ gem install hotdog 25 | ``` 26 | 27 | Then, setup API key and application key of Datadog. The keys can be configured in environment variables or configuration file. 28 | 29 | ```sh 30 | export DATADOG_API_KEY="abcdefghijklmnopqrstuvwxyzabcdef" 31 | export DATADOG_APPLICATION_KEY="abcdefghijklmnopqrstuvwxyzabcdefghijklmn" 32 | ``` 33 | 34 | Or, 35 | 36 | ``` 37 | $ mkdir ~/.hotdog 38 | $ cat < ~/.hotdog/config.yml 39 | --- 40 | api_key: abcdefghijklmnopqrstuvwxyzabcdef 41 | application_key: abcdefghijklmnopqrstuvwxyzabcdefghijklmn 42 | EOF 43 | ``` 44 | 45 | ## Usage 46 | 47 | List all registered hosts. 48 | 49 | ```sh 50 | $ hotdog ls 51 | i-02605a79 52 | i-02d78cec 53 | i-03cb56ed 54 | i-03dabcef 55 | i-069e282c 56 | ``` 57 | 58 | List all registered hosts with associated tags and headers. 59 | 60 | ```sh 61 | $ hotdog ls -h -l 62 | host security-group name availability-zone instance-type image region kernel 63 | ---------- -------------- ----------------- ----------------- ------------- ------------ --------- ------------ 64 | i-02605a79 sg-89bfe710 web-staging us-east-1a m3.medium ami-66089cdf us-east-1 aki-89ab75e1 65 | i-02d78cec sg-89bfe710 web-production us-east-1a c3.4xlarge ami-8bb3fc92 us-east-1 aki-89ab75e1 66 | i-03cb56ed sg-89bfe710 web-production us-east-1b c3.4xlarge ami-8bb3fc92 us-east-1 aki-89ab75e1 67 | i-03dabcef sg-89bfe710 worker-production us-east-1a c3.xlarge ami-4032c1c8 us-east-1 aki-89ab75e1 68 | i-069e282c sg-89bfe710 worker-staging us-east-1a t2.micro ami-384c8480 us-east-1 aki-89ab75e1 69 | ``` 70 | 71 | Display hosts with specific attributes. 72 | 73 | ```sh 74 | $ hotdog ls -h -a host -a name 75 | host name 76 | ---------- ----------------- 77 | i-02605a79 web-staging 78 | i-02d78cec web-production 79 | i-03cb56ed web-production 80 | i-03dabcef worker-production 81 | i-069e282c worker-staging 82 | ``` 83 | 84 | Search hosts matching to specified tags and values. 85 | 86 | ```sh 87 | $ hotdog search availability-zone:us-east-1b and 'name:web-*' 88 | i-03cb56ed 89 | ``` 90 | 91 | Login to the matching host using ssh. 92 | 93 | ```sh 94 | $ hotdog ssh availability-zone:us-east-1b and 'name:web-*' -t public_ipv4 -u username 95 | ``` 96 | 97 | 98 | ## Expression 99 | 100 | Acceptable expressions in pseudo BNF. 101 | 102 | ``` 103 | expression: expression0 104 | ; 105 | 106 | expression0: expression1 "and" expression 107 | | expression1 "or" expression 108 | | expression1 "xor" expression 109 | | expression1 110 | ; 111 | 112 | expression1: "not" expression 113 | | expression2 114 | ; 115 | 116 | expression2: expression3 expression 117 | | expression3 118 | ; 119 | 120 | expression3: expression4 "&&" expression 121 | | expression4 "||" expression 122 | | expression4 '&' expression 123 | | expression4 '^' expression 124 | | expression4 '|' expression 125 | | expression4 126 | ; 127 | 128 | expression4: '!' atom 129 | | '~' atom 130 | | '!' expression 131 | | '~' expression 132 | | primary 133 | ; 134 | 135 | primary: '(' expression ')' 136 | | funcall 137 | | tag 138 | ; 139 | 140 | funcall: IDENTIFIER '(' ')' 141 | | IDENTIFIER '(' funcall_args ')' 142 | ; 143 | 144 | funcall_args: funcall_arg ',' funcall_args 145 | | funcall_arg 146 | ; 147 | 148 | funcall_arg: FLOAT 149 | | INTEGER 150 | | STRING 151 | | REGEXP 152 | | primary 153 | ; 154 | 155 | tag: IDENTIFIER separator IDENTIFIER 156 | | IDENTIFIER separator 157 | | separator IDENTIFIER 158 | | IDENTIFIER 159 | | IDENTIFIER 160 | ; 161 | 162 | separator: ':' 163 | | '=' 164 | ; 165 | ``` 166 | 167 | 168 | ## Contributing 169 | 170 | 1. Fork it ( https://github.com/yyuu/hotdog/fork ) 171 | 2. Create your feature branch (`git checkout -b my-new-feature`) 172 | 3. Commit your changes (`git commit -am 'Add some feature'`) 173 | 4. Push to the branch (`git push origin my-new-feature`) 174 | 5. Create a new Pull Request 175 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => [:spec] 7 | -------------------------------------------------------------------------------- /bin/hotdog: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__)) 3 | 4 | ## bundler/setup doesn't work expectedly when installed via gem 5 | #ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __FILE__) 6 | #require "bundler/setup" 7 | 8 | require "hotdog/application" 9 | dog = Hotdog::Application.new 10 | dog.main(ARGV.dup) 11 | -------------------------------------------------------------------------------- /hotdog.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "hotdog/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "hotdog" 8 | spec.version = Hotdog::VERSION 9 | spec.authors = ["Yamashita Yuu"] 10 | spec.email = ["peek824545201@gmail.com"] 11 | spec.summary = %q{Yet another command-line tool for Datadog} 12 | spec.description = %q{Yet another command-line tool for Datadog} 13 | spec.homepage = "https://github.com/yyuu/hotdog" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler" 22 | spec.add_development_dependency "rake" 23 | spec.add_development_dependency "rspec" 24 | 25 | spec.add_dependency "dogapi" 26 | spec.add_dependency "multi_json" 27 | spec.add_dependency "oj" 28 | spec.add_dependency "parallel" 29 | spec.add_dependency "parslet" 30 | spec.add_dependency "sqlite3" 31 | end 32 | -------------------------------------------------------------------------------- /lib/hotdog/application.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "erb" 4 | require "logger" 5 | require "optparse" 6 | require "shellwords" 7 | require "yaml" 8 | require "hotdog/commands" 9 | require "hotdog/formatters" 10 | require "hotdog/sources" 11 | require "hotdog/version" 12 | 13 | module Hotdog 14 | SQLITE_LIMIT_COMPOUND_SELECT = 500 # TODO: get actual value from `sqlite3_limit()`? 15 | 16 | # only datadog is supported as of Sep 5, 2017 17 | SOURCE_DATADOG = 0x01 18 | 19 | # | status | description | 20 | # | -------- | ------------- | 21 | # | 00000000 | pending | 22 | # | 00010000 | running | 23 | # | 00100000 | shutting-down | 24 | # | 00110000 | terminated | 25 | # | 01000000 | stopping | 26 | # | 01010000 | stopped | 27 | STATUS_PENDING = 0b00000000 28 | STATUS_RUNNING = 0b00010000 29 | STATUS_SHUTTING_DOWN = 0b00100000 30 | STATUS_TERMINATED = 0b00110000 31 | STATUS_STOPPING = 0b01000000 32 | STATUS_STOPPED = 0b01010000 33 | 34 | VERBOSITY_NULL = 0 35 | VERBOSITY_INFO = 1 36 | VERBOSITY_DEBUG = 2 37 | VERBOSITY_TRACE = 4 38 | 39 | class Application 40 | def initialize() 41 | @logger = Logger.new(STDERR) 42 | @optparse = OptionParser.new 43 | @optparse.version = Hotdog::VERSION 44 | @options = { 45 | endpoint: nil, 46 | api_key: nil, 47 | application_key: nil, 48 | application: self, 49 | confdir: find_confdir(File.expand_path(".")), 50 | debug: false, 51 | expiry: 3600, 52 | use_fallback: false, 53 | force: false, 54 | format: "text", 55 | headers: false, 56 | source: "datadog", 57 | status: nil, 58 | listing: false, 59 | logger: @logger, 60 | max_time: 5, 61 | offline: false, 62 | print0: false, 63 | print1: true, 64 | print2: false, 65 | primary_tag: nil, 66 | tags: [], 67 | display_search_tags: false, 68 | verbose: false, 69 | verbosity: VERBOSITY_NULL, 70 | }.reject { |key, val| 71 | # reject nil values to declare sensible default later in subcommand 72 | val.nil? 73 | } 74 | @source_provider = nil # will be initialized later in `main()` 75 | define_options 76 | end 77 | attr_reader :logger 78 | attr_reader :options 79 | attr_reader :optparse 80 | attr_reader :source_provider 81 | 82 | def main(argv=[]) 83 | [ 84 | File.join(options[:confdir], "config.yaml"), 85 | File.join(options[:confdir], "config.yml"), 86 | ].each do |config| 87 | if File.file?(config) 88 | begin 89 | loaded = YAML.load(ERB.new(File.read(config)).result) 90 | rescue => error 91 | STDERR.puts("hotdog: failed to load configuration file at #{config.inspect}: #{error}") 92 | exit(1) 93 | end 94 | if Hash === loaded 95 | @options = @options.merge(Hash[loaded.map { |key, value| [Symbol === key ? key : key.to_s.to_sym, value] }]) 96 | end 97 | break 98 | end 99 | end 100 | args = @optparse.order(argv) 101 | 102 | begin 103 | if Hash === @options[:source_alias] 104 | source_name = @options[:source_alias].fetch(@options[:source], @options[:source]) 105 | else 106 | source_name = @options[:source] 107 | end 108 | @source_provider = get_source(source_name) 109 | rescue NameError 110 | STDERR.puts("hotdog: '#{source_name}' is not a valid hotdog source.") 111 | exit(1) 112 | end 113 | 114 | begin 115 | given_command_name = ( args.shift || "help" ) 116 | if Hash === @options[:command_alias] 117 | command_alias = @options[:command_alias].fetch(given_command_name, given_command_name) 118 | if Array === command_alias 119 | command_name, *command_args = command_alias 120 | else 121 | command_name, *command_args = Shellwords.shellsplit(command_alias) 122 | end 123 | else 124 | command_name = given_command_name 125 | command_args = [] 126 | end 127 | begin 128 | command = get_command(command_name) 129 | rescue NameError 130 | STDERR.puts("hotdog: '#{command_name}' is not a hotdog command.") 131 | get_command("help").run(["commands"], options) 132 | exit(1) 133 | end 134 | 135 | @optparse.banner = "Usage: hotdog #{command_name} [options]" 136 | command.define_options(@optparse, @options) 137 | 138 | begin 139 | args = command.parse_options(@optparse, command_args + args) 140 | rescue OptionParser::ParseError => error 141 | STDERR.puts("hotdog: #{error.message}") 142 | command.parse_options(@optparse, ["--help"]) 143 | exit(1) 144 | end 145 | 146 | if options[:format] == "ltsv" 147 | options[:headers] = true 148 | end 149 | 150 | if Hash === @options[:format_alias] 151 | format_name = @options[:format_alias].fetch(@options[:format], @options[:format]) 152 | else 153 | format_name = @options[:format] 154 | end 155 | options[:formatter] = get_formatter(format_name) 156 | 157 | if ( options[:debug] or options[:verbose] ) and ( options[:verbosity] < VERBOSITY_DEBUG ) 158 | options[:verbosity] = VERBOSITY_DEBUG 159 | end 160 | 161 | if VERBOSITY_DEBUG <= options[:verbosity] 162 | options[:logger].level = Logger::DEBUG 163 | else 164 | if VERBOSITY_INFO <= options[:verbosity] 165 | options[:logger].level = Logger::INFO 166 | else 167 | options[:logger].level = Logger::WARN 168 | end 169 | end 170 | 171 | command.run(args, @options) 172 | rescue Interrupt 173 | STDERR.puts("Interrupt") 174 | rescue Errno::EPIPE => error 175 | STDERR.puts(error) 176 | rescue => error 177 | raise # to show error stacktrace 178 | end 179 | end 180 | 181 | def status() 182 | options.fetch(:status, STATUS_RUNNING) 183 | end 184 | 185 | def status_name(status=self.status) 186 | { 187 | STATUS_PENDING => "pending", 188 | STATUS_RUNNING => "running", 189 | STATUS_SHUTTING_DOWN => "shutting-down", 190 | STATUS_TERMINATED => "terminated", 191 | STATUS_STOPPING => "stopping", 192 | STATUS_STOPPED => "stopped", 193 | }.fetch(status, "unknown") 194 | end 195 | 196 | private 197 | def define_options 198 | @optparse.on("--endpoint ENDPOINT", "Datadog API endpoint") do |endpoint| 199 | options[:endpoint] = endpoint 200 | end 201 | @optparse.on("--api-key API_KEY", "Datadog API key") do |api_key| 202 | options[:api_key] = api_key 203 | end 204 | @optparse.on("--application-key APP_KEY", "Datadog application key") do |app_key| 205 | options[:application_key] = app_key 206 | end 207 | @optparse.on("-0", "--null", "Use null character as separator") do |v| 208 | options[:print0] = v 209 | options[:print1] = !v 210 | options[:print2] = !v 211 | end 212 | @optparse.on("-1", "Use newline as separator") do |v| 213 | options[:print0] = !v 214 | options[:print1] = v 215 | options[:print2] = !v 216 | end 217 | @optparse.on("-2", "Use space as separator") do |v| 218 | options[:print0] = !v 219 | options[:print1] = !v 220 | options[:print2] = v 221 | end 222 | @optparse.on("-d", "--[no-]debug", "Enable debug mode") do |v| 223 | options[:debug] = v 224 | end 225 | @optparse.on("--[no-]fixed-string", "Never fallback to alternative expression in case of result is empty. Inversed meaning as '--use-fallback'") do |v| 226 | options[:use_fallback] = !v 227 | end 228 | @optparse.on("--[no-]use-fallback", "Fallback to alternative expressions in case of result is empty. Inversed meaning as '--fixed-string'") do |v| 229 | options[:use_fallback] = v 230 | end 231 | @optparse.on("-f", "--[no-]force", "Enable force mode") do |v| 232 | options[:force] = v 233 | end 234 | @optparse.on("-F FORMAT", "--format FORMAT", "Specify output format") do |format| 235 | options[:format] = format 236 | end 237 | @optparse.on("-h", "--[no-]headers", "Display headeres for each columns") do |v| 238 | options[:headers] = v 239 | end 240 | @optparse.on("--source=SOURCE", "Specify custom host source") do |v| 241 | @options[:source] = v 242 | end 243 | @optparse.on("--status=STATUS", "Specify custom host status") do |v| 244 | case v 245 | when /\A\d\z/i 246 | options[:status] = v.to_i 247 | when /\A(?:all|any)\z/i 248 | options[:status] = nil 249 | when /\A(?:pending)\z/i 250 | options[:status] = STATUS_PENDING 251 | when /\A(?:running)\z/i 252 | options[:status] = STATUS_RUNNING 253 | when /\A(?:shutting-down)\z/i 254 | options[:status] = STATUS_SHUTTING_DOWN 255 | when /\A(?:terminated)\z/i 256 | options[:status] = STATUS_TERMINATED 257 | when /\A(?:stopping)\z/i 258 | options[:status] = STATUS_STOPPING 259 | when /\A(?:stopped)\z/i 260 | options[:status] = STATUS_STOPPED 261 | else 262 | raise(OptionParser::InvalidArgument.new("unknown status: #{v}")) 263 | end 264 | end 265 | @optparse.on("-l", "--[no-]listing", "Use listing format") do |v| 266 | options[:listing] = v 267 | end 268 | @optparse.on("-a TAG", "-t TAG", "--tag TAG", "Use specified tag name/value") do |tag| 269 | options[:tags] += [tag] 270 | end 271 | @optparse.on("--primary-tag TAG", "Use specified tag as the primary tag") do |tag| 272 | options[:primary_tag] = tag 273 | end 274 | @optparse.on("-q", "--[no-]quiet", "Decrease verbosity") do |v| 275 | options[:verbosity] -= 1 276 | end 277 | @optparse.on("-x", "--display-search-tags", "Show tags used in search expression") do |v| 278 | options[:display_search_tags] = v 279 | end 280 | @optparse.on("-V", "-v", "--[no-]verbose", "Increase verbosity") do |v| 281 | options[:verbosity] += 1 282 | end 283 | @optparse.on("--[no-]offline", "Enable offline mode") do |v| 284 | options[:offline] = v 285 | end 286 | end 287 | 288 | def const_name(name) 289 | name.to_s.split(/[^\w]+/).map { |s| s.capitalize }.join 290 | end 291 | 292 | def get_formatter(name) 293 | begin 294 | klass = Hotdog::Formatters.const_get(const_name(name)) 295 | rescue NameError 296 | begin 297 | require "hotdog/formatters/#{name}" 298 | klass = Hotdog::Formatters.const_get(const_name(name)) 299 | rescue LoadError 300 | raise(NameError.new("unknown format: #{name}")) 301 | end 302 | end 303 | klass.new 304 | end 305 | 306 | def get_command(name) 307 | begin 308 | klass = Hotdog::Commands.const_get(const_name(name)) 309 | rescue NameError 310 | begin 311 | require "hotdog/commands/#{name}" 312 | klass = Hotdog::Commands.const_get(const_name(name)) 313 | rescue LoadError 314 | raise(NameError.new("unknown command: #{name}")) 315 | end 316 | end 317 | klass.new(self) 318 | end 319 | 320 | def get_source(name) 321 | begin 322 | klass = Hotdog::Sources.const_get(const_name(name)) 323 | rescue NameError 324 | begin 325 | require "hotdog/sources/#{name}" 326 | klass = Hotdog::Sources.const_get(const_name(name)) 327 | rescue LoadError 328 | raise(NameError.new("unknown source: #{name}")) 329 | end 330 | end 331 | klass.new(self) 332 | end 333 | 334 | def find_library(dirname, name) 335 | load_path = $LOAD_PATH.map { |path| File.join(path, dirname) }.select { |path| File.directory?(path) } 336 | libraries = load_path.flat_map { |path| Dir.glob(File.join(path, "*.rb")) }.select { |file| File.file?(file) } 337 | rbname = "#{name}.rb" 338 | library = libraries.find { |file| File.basename(file) == rbname } 339 | if library 340 | library 341 | else 342 | candidates = libraries.map { |file| [file, File.basename(file).slice(0, name.length)] }.select { |_file, s| s == name } 343 | if candidates.length == 1 344 | candidates.first.first 345 | else 346 | nil 347 | end 348 | end 349 | end 350 | 351 | def find_confdir(path) 352 | if path == "/" 353 | # default 354 | if ENV.has_key?("HOTDOG_CONFDIR") 355 | ENV["HOTDOG_CONFDIR"] 356 | else 357 | File.join(ENV["HOME"], ".hotdog") 358 | end 359 | else 360 | confdir = File.join(path, ".hotdog") 361 | if File.directory?(confdir) 362 | confdir 363 | else 364 | find_confdir(File.dirname(path)) 365 | end 366 | end 367 | end 368 | end 369 | end 370 | 371 | # vim:set ft=ruby : 372 | -------------------------------------------------------------------------------- /lib/hotdog/commands.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "fileutils" 4 | require "sqlite3" 5 | 6 | module Hotdog 7 | module Commands 8 | class BaseCommand 9 | def initialize(application) 10 | @application = application 11 | @source_provider = application.source_provider 12 | @logger = application.logger 13 | @options = application.options 14 | @prepared_statements = {} 15 | @persistent_db_path = File.join(@options.fetch(:confdir, "."), "hotdog.sqlite3") 16 | end 17 | attr_reader :application 18 | attr_reader :logger 19 | attr_reader :options 20 | attr_reader :persistent_db_path 21 | 22 | def run(args=[], options={}) 23 | raise(NotImplementedError) 24 | end 25 | 26 | def execute(q, args=[]) 27 | update_db 28 | execute_db(@db, q, args) 29 | end 30 | 31 | def use_fallback?() 32 | @options[:use_fallback] 33 | end 34 | 35 | def fixed_string?() 36 | # deprecated - superseded by `fallback?` 37 | not @options[:use_fallback] 38 | end 39 | 40 | def reload(options={}) 41 | options = @options.merge(options) 42 | if options[:offline] 43 | logger.info("skip reloading on offline mode.") 44 | else 45 | if @db 46 | close_db(@db) 47 | @db = nil 48 | end 49 | update_db(options) 50 | end 51 | end 52 | 53 | def define_options(optparse, options={}) 54 | # nop 55 | end 56 | 57 | def parse_options(optparse, args=[]) 58 | optparse.parse(args) 59 | end 60 | 61 | private 62 | def default_option(options, key, default_value) 63 | if options.key?(key) 64 | options[key] 65 | else 66 | options[key] = default_value 67 | end 68 | end 69 | 70 | def prepare(db, query) 71 | @prepared_statements[query] ||= db.prepare(query) 72 | end 73 | 74 | def format(result, options={}) 75 | @options[:formatter].format(result, @options.merge(options)) 76 | end 77 | 78 | def glob?(s) 79 | s.index('*') or s.index('?') or s.index('[') or s.index(']') 80 | end 81 | 82 | def get_hosts(host_ids, tags=nil) 83 | tags ||= @options[:tags] 84 | update_db 85 | if host_ids.empty? 86 | [[], []] 87 | else 88 | if 0 < tags.length 89 | fields = tags.map { |tag| 90 | tagname, _tagvalue = split_tag(tag) 91 | tagname 92 | } 93 | get_hosts_fields(host_ids, fields) 94 | else 95 | if @options[:listing] 96 | if @options[:primary_tag] 97 | fields = [ 98 | @options[:primary_tag], 99 | "@host", 100 | ] + get_fields(host_ids).reject { |tagname| tagname == @options[:primary_tag] } 101 | get_hosts_fields(host_ids, fields) 102 | else 103 | fields = [ 104 | "@host", 105 | ] + get_fields(host_ids) 106 | get_hosts_fields(host_ids, fields) 107 | end 108 | else 109 | if @options[:primary_tag] 110 | get_hosts_fields(host_ids, [@options[:primary_tag]]) 111 | else 112 | get_hosts_fields(host_ids, ["@host"]) 113 | end 114 | end 115 | end 116 | end 117 | end 118 | 119 | def get_fields(host_ids) 120 | host_ids = Array(host_ids) 121 | host_ids.each_slice(SQLITE_LIMIT_COMPOUND_SELECT).flat_map { |host_ids| 122 | q = "SELECT DISTINCT tags.name FROM hosts_tags " \ 123 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id " \ 124 | "WHERE hosts_tags.host_id IN (%s) ORDER BY hosts_tags.host_id;" % host_ids.map { "?" }.join(", ") 125 | execute(q, host_ids).map { |row| row.first } 126 | }.uniq 127 | end 128 | 129 | def get_hosts_fields(host_ids, fields, options={}) 130 | host_ids = Array(host_ids) 131 | case fields.length 132 | when 0 133 | [[], fields] 134 | when 1 135 | get_hosts_field(host_ids, fields.first, options) 136 | else 137 | [host_ids.map { |host_id| get_host_fields(host_id, fields, options) }.map { |result, fields| result }, fields] 138 | end 139 | end 140 | 141 | def get_host_fields(host_id, fields, options={}) 142 | field_values = {} 143 | fields.uniq.each_slice(SQLITE_LIMIT_COMPOUND_SELECT - 2).each do |fields| 144 | q = "SELECT LOWER(tags.name), GROUP_CONCAT(tags.value, ',') FROM hosts_tags " \ 145 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id " \ 146 | "WHERE hosts_tags.host_id = ? AND tags.name IN (%s) " \ 147 | "GROUP BY tags.name;" % fields.map { "?" }.join(", ") 148 | 149 | execute(q, [host_id] + fields).each do |row| 150 | field_values[row[0]] = row[1] 151 | end 152 | end 153 | 154 | result = fields.map { |tagname| 155 | tagvalue = field_values.fetch(tagname.downcase, nil) 156 | display_tag(tagname, tagvalue) 157 | } 158 | [result, fields] 159 | end 160 | 161 | def get_hosts_field(host_ids, field, options={}) 162 | host_ids = Array(host_ids) 163 | if /\Ahost\z/i =~ field 164 | result = host_ids.each_slice(SQLITE_LIMIT_COMPOUND_SELECT - 1).flat_map { |host_ids| 165 | execute("SELECT name FROM hosts WHERE id IN (%s) ORDER BY id;" % host_ids.map { "?" }.join(", "), host_ids).map { |row| row.to_a } 166 | } 167 | else 168 | result = host_ids.each_slice(SQLITE_LIMIT_COMPOUND_SELECT - 2).flat_map { |host_ids| 169 | q = "SELECT LOWER(tags.name), GROUP_CONCAT(tags.value, ',') FROM hosts_tags " \ 170 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id " \ 171 | "WHERE hosts_tags.host_id IN (%s) AND tags.name = ? " \ 172 | "GROUP BY hosts_tags.host_id, tags.name ORDER BY hosts_tags.host_id;" % host_ids.map { "?" }.join(", ") 173 | r = execute(q, host_ids + [field]).map { |tagname, tagvalue| 174 | [display_tag(tagname, tagvalue)] 175 | } 176 | if r.empty? 177 | host_ids.map { [nil] } 178 | else 179 | r 180 | end 181 | } 182 | end 183 | [result, [field]] 184 | end 185 | 186 | def display_tag(tagname, tagvalue) 187 | if tagvalue 188 | if tagvalue.empty? 189 | tagname # use `tagname` as `tagvalue` for the tags without any values 190 | else 191 | tagvalue 192 | end 193 | else 194 | nil 195 | end 196 | end 197 | 198 | def close_db(db, options={}) 199 | @prepared_statements.each do |query, statement| 200 | statement.close() 201 | end 202 | @prepared_statements.clear() 203 | db.close() 204 | end 205 | 206 | def open_db(options={}) 207 | options = @options.merge(options) 208 | if @db 209 | @db 210 | else 211 | if options[:force] 212 | @db = nil 213 | else 214 | if options[:offline] 215 | @db = __open_db(options) 216 | else 217 | FileUtils.mkdir_p(File.dirname(persistent_db_path)) 218 | if File.exist?(persistent_db_path) and Time.new <= (File.mtime(persistent_db_path) + options[:expiry]) 219 | @db = __open_db(options) 220 | else 221 | @db = nil 222 | end 223 | end 224 | end 225 | end 226 | end 227 | 228 | def __open_db(options={}) 229 | begin 230 | db = SQLite3::Database.new(persistent_db_path) 231 | db.execute("SELECT hosts_tags.host_id, hosts.source, hosts.status FROM hosts_tags INNER JOIN hosts ON hosts_tags.host_id = hosts.id INNER JOIN tags ON hosts_tags.tag_id = tags.id LIMIT 1;") 232 | db 233 | rescue SQLite3::BusyException # database is locked 234 | sleep(rand) 235 | retry 236 | rescue SQLite3::SQLException 237 | db.close() 238 | nil 239 | end 240 | end 241 | 242 | def update_db(options={}) 243 | options = @options.merge(options) 244 | if open_db(options) 245 | @db 246 | else 247 | if options[:offline] 248 | abort("could not update database on offline mode") 249 | else 250 | memory_db = create_db(SQLite3::Database.new(":memory:"), options) 251 | # backup in-memory db to file 252 | FileUtils.mkdir_p(File.dirname(persistent_db_path)) 253 | db = SQLite3::Database.new(persistent_db_path) 254 | copy_db(memory_db, db) 255 | close_db(memory_db) 256 | $did_reload = true 257 | @db = db 258 | end 259 | end 260 | end 261 | 262 | def create_db(db, options={}) 263 | options = @options.merge(options) 264 | begin 265 | all_tags = @source_provider.get_all_tags() 266 | all_downtimes = @source_provider.get_all_downtimes().flat_map { |downtime| 267 | # find host scopes 268 | Array(downtime["scope"]).select { |scope| scope.start_with?("host:") }.map { |scope| scope.sub(/\Ahost:/, "") } 269 | } 270 | rescue => error 271 | STDERR.puts(error.message) 272 | exit(1) 273 | end 274 | if not all_downtimes.empty? 275 | logger.info("ignore host(s) with scheduled downtimes: #{all_downtimes.inspect}") 276 | end 277 | db.transaction do 278 | execute_db(db, "CREATE TABLE IF NOT EXISTS hosts (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL COLLATE NOCASE, source INTEGER NOT NULL DEFAULT #{@source_provider.id}, status INTEGER NOT NULL DEFAULT #{STATUS_PENDING});") 279 | execute_db(db, "CREATE UNIQUE INDEX IF NOT EXISTS hosts_name ON hosts (name);") 280 | execute_db(db, "CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(200) NOT NULL COLLATE NOCASE, value VARCHAR(200) NOT NULL COLLATE NOCASE);") 281 | execute_db(db, "CREATE UNIQUE INDEX IF NOT EXISTS tags_name_value ON tags (name, value);") 282 | execute_db(db, "CREATE TABLE IF NOT EXISTS hosts_tags (host_id INTEGER NOT NULL, tag_id INTEGER NOT NULL);") 283 | execute_db(db, "CREATE UNIQUE INDEX IF NOT EXISTS hosts_tags_host_id_tag_id ON hosts_tags (host_id, tag_id);") 284 | 285 | execute_db(db, "CREATE TABLE IF NOT EXISTS source_names (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(200) NOT NULL COLLATE NOCASE);") 286 | execute_db(db, "INSERT OR IGNORE INTO source_names (id, name) VALUES (?, ?);", [@source_provider.id, @source_provider.name]) 287 | 288 | execute_db(db, "CREATE TABLE IF NOT EXISTS status_names (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(200) NOT NULL COLLATE NOCASE);") 289 | { 290 | STATUS_PENDING => application.status_name(STATUS_PENDING), 291 | STATUS_RUNNING => application.status_name(STATUS_RUNNING), 292 | STATUS_SHUTTING_DOWN => application.status_name(STATUS_SHUTTING_DOWN), 293 | STATUS_TERMINATED => application.status_name(STATUS_TERMINATED), 294 | STATUS_STOPPING => application.status_name(STATUS_STOPPING), 295 | STATUS_STOPPED => application.status_name(STATUS_STOPPED), 296 | }.each do |status_id, status_name| 297 | execute_db(db, "INSERT OR IGNORE INTO status_names (id, name) VALUES (?, ?);", [status_id, status_name]) 298 | end 299 | 300 | known_tags = all_tags.keys.map { |tag| split_tag(tag) }.uniq 301 | create_tags(db, known_tags) 302 | 303 | known_hosts = all_tags.values.reduce(:+).uniq 304 | create_hosts(db, known_hosts, all_downtimes) 305 | 306 | all_tags.each do |tag, hosts| 307 | associate_tag_hosts(db, tag, hosts) 308 | end 309 | end 310 | 311 | db 312 | end 313 | 314 | def remove_db(db, options={}) 315 | options = @options.merge(options) 316 | if db 317 | close_db(db) 318 | end 319 | if File.exist?(persistent_db_path) 320 | FileUtils.touch(persistent_db_path, mtime: Time.new - options[:expiry]) 321 | end 322 | end 323 | 324 | def execute_db(db, q, args=[]) 325 | begin 326 | logger.debug("execute: #{q} -- #{args.inspect}") 327 | prepare(db, q).execute(args) 328 | rescue SQLite3::BusyException # database is locked 329 | sleep(rand) 330 | retry 331 | rescue 332 | logger.warn("failed: #{q} -- #{args.inspect}") 333 | raise 334 | end 335 | end 336 | 337 | def create_hosts(db, hosts, downtimes) 338 | hosts.each_slice(SQLITE_LIMIT_COMPOUND_SELECT / 3) do |hosts| 339 | q = "INSERT OR IGNORE INTO hosts (name, source, status) VALUES %s;" % hosts.map { "(?, ?, ?)" }.join(", ") 340 | execute_db(db, q, hosts.map { |host| 341 | status = downtimes.include?(host) ? STATUS_STOPPED : STATUS_RUNNING 342 | [host, @source_provider.id, status] 343 | }) 344 | end 345 | 346 | # create virtual `@host` tag 347 | execute_db(db, "INSERT OR IGNORE INTO tags (name, value) SELECT '@host', hosts.name FROM hosts;") 348 | execute_db(db, 349 | "INSERT OR REPLACE INTO hosts_tags (host_id, tag_id) " \ 350 | "SELECT hosts.id, tags.id FROM hosts " \ 351 | "INNER JOIN tags ON tags.name = '@host' AND hosts.name = tags.value;" 352 | ) 353 | 354 | # create virtual `@source` tag 355 | execute_db(db, "INSERT OR IGNORE INTO tags (name, value) SELECT '@source', name FROM source_names;") 356 | execute_db(db, 357 | "INSERT OR REPLACE INTO hosts_tags (host_id, tag_id) " \ 358 | "SELECT hosts.id, tags.id FROM hosts " \ 359 | "INNER JOIN source_names ON hosts.source = source_names.id " \ 360 | "INNER JOIN tags ON tags.name = '@source' AND source_names.name = tags.value;" 361 | ) 362 | 363 | # create virtual `@status` tag 364 | execute_db(db, "INSERT OR IGNORE INTO tags (name, value) SELECT '@status', name FROM status_names;") 365 | execute_db(db, 366 | "INSERT OR REPLACE INTO hosts_tags (host_id, tag_id) " \ 367 | "SELECT hosts.id, tags.id FROM hosts " \ 368 | "INNER JOIN status_names ON hosts.status = status_names.id " \ 369 | "INNER JOIN tags ON tags.name = '@status' AND status_names.name = tags.value;" 370 | ) 371 | end 372 | 373 | def create_tags(db, tags) 374 | tags.each_slice(SQLITE_LIMIT_COMPOUND_SELECT / 2) do |tags| 375 | q = "INSERT OR IGNORE INTO tags (name, value) VALUES %s;" % tags.map { "(?, ?)" }.join(", ") 376 | execute_db(db, q, tags) 377 | end 378 | end 379 | 380 | def associate_tag_hosts(db, tag, hosts) 381 | hosts.each_slice(SQLITE_LIMIT_COMPOUND_SELECT - 2) do |hosts| 382 | begin 383 | q = "INSERT OR REPLACE INTO hosts_tags (host_id, tag_id) " \ 384 | "SELECT host.id, tag.id FROM " \ 385 | "( SELECT id FROM hosts WHERE name IN (%s) ) AS host, " \ 386 | "( SELECT id FROM tags WHERE name = ? AND value = ? LIMIT 1 ) AS tag;" % hosts.map { "?" }.join(", ") 387 | execute_db(db, q, (hosts + split_tag(tag))) 388 | rescue SQLite3::RangeException => error 389 | # FIXME: bulk insert occationally fails even if there are no errors in bind parameters 390 | # `bind_param': bind or column index out of range (SQLite3::RangeException) 391 | logger.warn("bulk insert failed due to #{error.message}. fallback to normal insert.") 392 | hosts.each do |host| 393 | q = "INSERT OR REPLACE INTO hosts_tags (host_id, tag_id) " \ 394 | "SELECT host.id, tag.id FROM " \ 395 | "( SELECT id FROM hosts WHERE name = ? ) AS host, " \ 396 | "( SELECT id FROM tags WHERE name = ? AND value = ? LIMIT 1 ) AS tag;" 397 | execute_db(db, q, [host] + split_tag(tag)) 398 | end 399 | end 400 | end 401 | end 402 | 403 | def disassociate_tag_hosts(db, tag, hosts) 404 | hosts.each_slice(SQLITE_LIMIT_COMPOUND_SELECT - 2) do |hosts| 405 | q = "DELETE FROM hosts_tags " \ 406 | "WHERE tag_id IN ( SELECT id FROM tags WHERE name = ? AND value = ? LIMIT 1 ) AND host_id IN ( SELECT id FROM hosts WHERE name IN (%s) );" % hosts.map { "?" }.join(", ") 407 | execute_db(db, q, split_tag(tag) + hosts) 408 | end 409 | end 410 | 411 | def split_tag(tag) 412 | tagname, tagvalue = tag.split(":", 2) 413 | [rewrite_legacy_tagname(tagname), tagvalue || ""] 414 | end 415 | 416 | def join_tag(tagname, tagvalue) 417 | if tagvalue.to_s.empty? 418 | rewrite_legacy_tagname(tagname) 419 | else 420 | "#{rewrite_legacy_tagname(tagname)}:#{tagvalue}" 421 | end 422 | end 423 | 424 | def rewrite_legacy_tagname(s) 425 | case s 426 | when "host" 427 | # Starting from v0.31.0, hotdog started using _internal_ tags with leading `@` in name. 428 | # 429 | # This workaround is to keep legacy `host` tag for backward compatibility by rewriting 430 | # it as the reference to the internal tag of `@host` without any user action. 431 | "@#{s}" 432 | else 433 | s 434 | end 435 | end 436 | 437 | def copy_db(src, dst) 438 | backup = SQLite3::Backup.new(dst, "main", src, "main") 439 | backup.step(-1) 440 | backup.finish 441 | end 442 | 443 | def with_retry(options={}, &block) 444 | (options[:retry] || 10).times do |i| 445 | begin 446 | return yield 447 | rescue => error 448 | if error_handler = options[:error_handler] 449 | error_handler.call(error) 450 | end 451 | logger.info("#{error.class}: #{error.message}") 452 | error.backtrace.each do |frame| 453 | logger.info("\t#{frame}") 454 | end 455 | wait = [options[:retry_delay] || (1<(error) { reload }) do 57 | if open_db 58 | @db.transaction do 59 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 60 | hosts.each_slice(sqlite_limit_compound_select - 1) do |hosts| 61 | execute_db(@db, "DELETE FROM hosts_tags WHERE tag_id IN ( SELECT id FROM tags WHERE name = '@status' ) AND host_id IN ( SELECT id FROM hosts WHERE name IN (%s) );" % hosts.map { "?" }.join(", "), hosts) 62 | execute_db(@db, "UPDATE hosts SET status = ? WHERE name IN (%s);" % hosts.map { "?" }.join(", "), [STATUS_STOPPING] + hosts) 63 | end 64 | associate_tag_hosts(@db, "@status:#{application.status_name(STATUS_STOPPING)}", hosts) 65 | end 66 | end 67 | end 68 | end 69 | scopes.each do |scope| 70 | with_retry(options) do 71 | @source_provider.schedule_downtime(scope, options) 72 | end 73 | end 74 | end 75 | end 76 | end 77 | end 78 | 79 | # vim:set ft=ruby : 80 | -------------------------------------------------------------------------------- /lib/hotdog/commands/help.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "rbconfig" 4 | 5 | module Hotdog 6 | module Commands 7 | class Help < BaseCommand 8 | def run(args=[], options={}) 9 | commands = command_files.map { |file| File.basename(file, ".rb") }.sort.uniq 10 | if "commands" == args.first 11 | STDOUT.puts("hotdog commands are:") 12 | commands.each do |command| 13 | STDOUT.puts("- #{command}") 14 | end 15 | else 16 | ruby = File.join(RbConfig::CONFIG["bindir"], RbConfig::CONFIG["ruby_install_name"]) 17 | if commands.include?(args.first) 18 | exit(system(ruby, $0, args.first, "--help") ? 0 : 1) 19 | else 20 | exit(system(ruby, $0, "--help") ? 0 : 1) 21 | end 22 | end 23 | end 24 | 25 | private 26 | def load_path() 27 | $LOAD_PATH.map { |path| File.join(path, "hotdog/commands") }.select { |path| File.directory?(path) } 28 | end 29 | 30 | def command_files() 31 | load_path.flat_map { |path| Dir.glob(File.join(path, "*.rb")) }.select { |file| File.file?(file) } 32 | end 33 | end 34 | end 35 | end 36 | 37 | # vim:set ft=ruby : 38 | -------------------------------------------------------------------------------- /lib/hotdog/commands/hosts.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Commands 5 | class Hosts < BaseCommand 6 | def run(args=[], options={}) 7 | if args.empty? 8 | result = execute("SELECT id FROM hosts").to_a.reduce(:+) 9 | show_hosts(result) 10 | else 11 | if args.any? { |host_name| glob?(host_name) } 12 | result = args.flat_map { |host_name| 13 | execute("SELECT id FROM hosts WHERE LOWER(name) GLOB LOWER(?);", [host_name]).to_a.reduce(:+) || [] 14 | } 15 | else 16 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 17 | result = args.each_slice(sqlite_limit_compound_select).flat_map { |args| 18 | execute("SELECT id FROM hosts WHERE name IN (%s);" % args.map { "?" }.join(", "), args).to_a.reduce(:+) || [] 19 | } 20 | end 21 | if result.empty? 22 | STDERR.puts("no match found: #{args.join(" ")}") 23 | exit(1) 24 | else 25 | show_hosts(result) 26 | logger.info("found %d host(s)." % result.length) 27 | if result.length < args.length 28 | STDERR.puts("insufficient result: #{args.join(" ")}") 29 | exit(1) 30 | end 31 | end 32 | end 33 | end 34 | 35 | def show_hosts(hosts) 36 | result, fields = get_hosts(hosts || []) 37 | STDOUT.print(format(result, fields: fields)) 38 | end 39 | end 40 | end 41 | end 42 | 43 | # vim:set ft=ruby : 44 | -------------------------------------------------------------------------------- /lib/hotdog/commands/ls.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "hotdog/commands/hosts" 4 | 5 | module Hotdog 6 | module Commands 7 | class Ls < Hosts 8 | end 9 | end 10 | end 11 | 12 | # vim:set ft=ruby : 13 | -------------------------------------------------------------------------------- /lib/hotdog/commands/pssh.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "json" 4 | require "parallel" 5 | require "parslet" 6 | require "shellwords" 7 | require "tempfile" 8 | require "thread" 9 | require "hotdog/commands/ssh" 10 | 11 | module Hotdog 12 | module Commands 13 | class Pssh < SshAlike 14 | def define_options(optparse, options={}) 15 | super 16 | default_option(options, :show_identifier, true) 17 | optparse.on("--[no-]identifier", "Each output line will be prepended with identifier.") do |identifier| 18 | options[:show_identifier] = identifier 19 | end 20 | optparse.on("--stop-on-error", "Stop execution when a remote command fails (valid only if -P is set)") do |v| 21 | options[:stop_on_error] = v 22 | end 23 | end 24 | 25 | private 26 | def run_main(hosts, options={}) 27 | if STDIN.tty? 28 | infile = nil 29 | else 30 | infile = Tempfile.new() 31 | while cs = STDIN.read(4096) 32 | infile.write(cs) 33 | end 34 | infile.flush 35 | infile.seek(0) 36 | end 37 | begin 38 | hosts_cmdlines = hosts.map { |host| 39 | [host, build_command_string(host, @remote_command, options)] 40 | } 41 | if options[:dry_run] 42 | stats = hosts_cmdlines.map { |host, cmdline| 43 | STDOUT.puts(cmdline) 44 | true 45 | } 46 | else 47 | output_lock = Mutex.new 48 | stats = Parallel.map(hosts_cmdlines.each_with_index.to_a, in_threads: parallelism(hosts)) { |(host, cmdline), i| 49 | identifier = options[:show_identifier] ? host : nil 50 | success = exec_command(identifier, cmdline, index: i, output: true, infile: (infile ? infile.path : nil), output_lock: output_lock) 51 | if !success && options[:stop_on_error] 52 | raise StopException.new 53 | end 54 | success 55 | } 56 | end 57 | if stats.all? 58 | exit(0) 59 | else 60 | exit(1) 61 | end 62 | rescue StopException 63 | logger.info("stopped.") 64 | exit(1) 65 | end 66 | end 67 | 68 | class StopException < StandardError 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/hotdog/commands/scp.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "json" 4 | require "shellwords" 5 | require "hotdog/commands/ssh" 6 | 7 | module Hotdog 8 | module Commands 9 | class Scp < SingularSshAlike 10 | def define_options(optparse, options={}) 11 | program_name = File.basename($0, '.*') 12 | optparse.banner = "Usage: #{program_name} scp [options] pattern -- src @:dst" 13 | super 14 | end 15 | 16 | private 17 | def build_command_string(host, command=nil, options={}) 18 | # replace "@:" by actual hostname 19 | cmdline = Shellwords.shellsplit(options.fetch(:scp_command, "scp")) + build_command_options(options) + Shellwords.split(command).map { |token| token.gsub(/@(?=:)/, host) } 20 | Shellwords.join(cmdline) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/hotdog/commands/search.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "json" 4 | require "parslet" 5 | require "shellwords" 6 | require "hotdog/expression" 7 | 8 | module Hotdog 9 | module Commands 10 | class Search < BaseCommand 11 | def define_options(optparse, options={}) 12 | optparse.on("-n", "--limit LIMIT", "Limit result set to specified size at most", Integer) do |limit| 13 | options[:limit] = limit 14 | end 15 | end 16 | 17 | def parse_options(optparse, args=[]) 18 | if args.index("--") 19 | command_args = args.slice(args.index("--") + 1, args.length) 20 | if command_args.length <= 1 21 | # Use given argument as is if the remote command is specified as a quoted string 22 | # e.g. 'for f in /tmp/foo*; do echo $f; done' 23 | @remote_command = command_args.first 24 | else 25 | @remote_command = Shellwords.shelljoin(command_args) 26 | end 27 | optparse.parse(args.slice(0, args.index("--"))) 28 | else 29 | @remote_command = nil 30 | optparse.parse(args) 31 | end 32 | end 33 | attr_reader :remote_command 34 | 35 | def run(args=[], options={}) 36 | if @remote_command 37 | logger.warn("ignore remote command: #{@remote_command}") 38 | end 39 | expression = rewrite_expression(args.join(" ").strip) 40 | 41 | begin 42 | node = parse(expression) 43 | rescue Parslet::ParseFailed => error 44 | STDERR.puts("syntax error: " + error.cause.ascii_tree) 45 | exit(1) 46 | end 47 | 48 | result0 = evaluate(node, self) 49 | if 0 < result0.length 50 | result, fields = get_hosts_with_search_tags(result0, node) 51 | STDOUT.print(format(result, fields: fields)) 52 | logger.info("found %d host(s)." % result.length) 53 | else 54 | logger.error("no match found: #{expression}") 55 | exit(1) 56 | end 57 | end 58 | 59 | def get_hosts_with_search_tags(result, node) 60 | drilldown = ->(n) { 61 | case 62 | when n[:left] && n[:right] then drilldown.(n[:left]) + drilldown.(n[:right]) 63 | when n[:expression] then drilldown.(n[:expression]) 64 | when n[:tagname] then [n[:tagname]] 65 | else [] 66 | end 67 | } 68 | if options[:display_search_tags] 69 | tagnames = drilldown.call(node).map(&:to_s) 70 | if options[:primary_tag] 71 | tags = [options[:primary_tag]] + tagnames 72 | else 73 | tags = tagnames 74 | end 75 | else 76 | tags = nil 77 | end 78 | get_hosts(result, tags) 79 | end 80 | 81 | def parse(expression) 82 | logger.debug(expression) 83 | parser = Hotdog::Expression::ExpressionParser.new 84 | parser.parse(expression).tap do |parsed| 85 | logger.debug { 86 | begin 87 | JSON.pretty_generate(JSON.load(parsed.to_json)) 88 | rescue JSON::NestingError => error 89 | error.message 90 | end 91 | } 92 | end 93 | end 94 | 95 | def evaluate(data, environment) 96 | node = Hotdog::Expression::ExpressionTransformer.new.apply(data) 97 | if Hotdog::Expression::ExpressionNode === node 98 | optimized = node.optimize.tap do |optimized| 99 | logger.debug { 100 | JSON.pretty_generate(optimized.dump) 101 | } 102 | end 103 | result = optimized.evaluate(environment) 104 | if result.empty? and !$did_reload 105 | $did_reload = true 106 | environment.logger.info("reloading all hosts and tags.") 107 | environment.reload(force: true) 108 | optimized.evaluate(environment) 109 | else 110 | result 111 | end 112 | else 113 | raise("parser error: unknown expression: #{node.inspect}") 114 | end 115 | end 116 | 117 | private 118 | def rewrite_expression(expression) 119 | if expression.strip.empty? 120 | # return everything if given expression is empty 121 | expression = "*" 122 | end 123 | if options[:status] 124 | status_name = application.status_name(options[:status]) 125 | expression = "@status:#{status_name} AND (#{expression})" 126 | end 127 | if options[:limit] 128 | expression = "LIMIT((#{expression}), #{options[:limit]})" 129 | end 130 | expression 131 | end 132 | end 133 | end 134 | end 135 | 136 | # vim:set ft=ruby : 137 | -------------------------------------------------------------------------------- /lib/hotdog/commands/sftp.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "json" 4 | require "shellwords" 5 | require "hotdog/commands/ssh" 6 | 7 | module Hotdog 8 | module Commands 9 | class Sftp < SingularSshAlike 10 | private 11 | def build_command_string(host, command=nil, options={}) 12 | cmdline = Shellwords.shellsplit(options.fetch(:sftp_command, "sftp")) + build_command_options(options) + [host] 13 | if command 14 | logger.warn("ignore remote command: #{command}") 15 | end 16 | Shellwords.join(cmdline) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/hotdog/commands/ssh.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "json" 4 | require "parallel" 5 | require "parslet" 6 | require "shellwords" 7 | require "thread" 8 | require "hotdog/commands/search" 9 | 10 | module Hotdog 11 | module Commands 12 | class SshAlike < Search 13 | def define_options(optparse, options={}) 14 | default_option(options, :ssh_options, {}) 15 | default_option(options, :color, :auto) 16 | default_option(options, :max_parallelism, Parallel.processor_count * 2) 17 | default_option(options, :shuffle, false) 18 | default_option(options, :ssh_config, nil) 19 | # we must not need to run ssh against terminated hosts 20 | default_option(options, :status, STATUS_RUNNING) 21 | optparse.on("-C", "Enable compression.") do |v| 22 | options[:ssh_options]["Compression"] = "yes" 23 | end 24 | optparse.on("-F SSH_CONFIG", "Specifies an alternative per-user SSH configuration file.") do |configfile| 25 | options[:ssh_config] = configfile 26 | end 27 | optparse.on("--dry-run", "Dry run.") do |v| 28 | options[:dry_run] = v 29 | end 30 | optparse.on("-o SSH_OPTION", "Passes this string to ssh command through shell. This option may be given multiple times") do |ssh_option| 31 | ssh_option_key, ssh_option_value = ssh_option.split("=", 2) 32 | options[:ssh_options][ssh_option_key] = ssh_option_value 33 | end 34 | optparse.on("-i SSH_IDENTITY_FILE", "SSH identity file path") do |path| 35 | options[:ssh_options]["IdentityFile"] = path 36 | end 37 | optparse.on("-A", "Enable agent forwarding", TrueClass) do |b| 38 | options[:ssh_options]["ForwardAgent"] = "yes" 39 | end 40 | optparse.on("-p PORT", "Port of the remote host", Integer) do |port| 41 | options[:ssh_options]["Port"] = port 42 | end 43 | optparse.on("-u SSH_USER", "SSH login user name") do |user| 44 | options[:ssh_options]["User"] = user 45 | end 46 | optparse.on("--filter=COMMAND", "Command to filter search result.") do |command| 47 | options[:filter_command] = command 48 | end 49 | optparse.on("-P PARALLELISM", "Max parallelism", Integer) do |n| 50 | options[:max_parallelism] = n 51 | end 52 | optparse.on("--color=WHEN", "--colour=WHEN", "Enable colors") do |color| 53 | options[:color] = color 54 | end 55 | optparse.on("--shuffle", "Shuffle result") do |v| 56 | options[:shuffle] = v 57 | end 58 | end 59 | 60 | def run(args=[], options={}) 61 | expression = rewrite_expression(args.join(" ").strip) 62 | 63 | begin 64 | node = parse(expression) 65 | rescue Parslet::ParseFailed => error 66 | STDERR.puts("syntax error: " + error.cause.ascii_tree) 67 | exit(1) 68 | end 69 | 70 | result0 = evaluate(node, self) 71 | tuples, fields = get_hosts_with_search_tags(result0, node) 72 | tuples = filter_hosts(tuples) 73 | validate_hosts!(tuples, fields) 74 | logger.info("target host(s): #{tuples.map {|tuple| tuple.first }.inspect}") 75 | run_main(tuples.map {|tuple| tuple.first }, options) 76 | end 77 | 78 | private 79 | def rewrite_expression(expression) 80 | expression = super(expression) 81 | if options[:shuffle] 82 | expression = "SHUFFLE((#{expression}))" 83 | end 84 | expression 85 | end 86 | 87 | def parallelism(hosts) 88 | [options[:max_parallelism], hosts.size].compact.min 89 | end 90 | 91 | def filter_hosts(tuples) 92 | if options[:filter_command] 93 | filtered_tuples = Parallel.map(tuples, in_threads: parallelism(tuples)) { |tuple| 94 | cmdline = build_command_string(tuple.first, options[:filter_command], options) 95 | [tuple, exec_command(tuple.first, cmdline, output: false)] 96 | }.select { |_host, stat| 97 | stat 98 | }.map { |tuple, _stat| 99 | tuple 100 | } 101 | if tuples == filtered_tuples 102 | tuples 103 | else 104 | logger.info("filtered host(s): #{(tuples - filtered_tuples).map {|tuple| tuple.first }.inspect}") 105 | filtered_tuples 106 | end 107 | else 108 | tuples 109 | end 110 | end 111 | 112 | def validate_hosts!(tuples, fields) 113 | if tuples.length < 1 114 | logger.error("no match found") 115 | exit(1) 116 | end 117 | end 118 | 119 | def run_main(hosts, options={}) 120 | raise(NotImplementedError) 121 | end 122 | 123 | def build_command_options(options={}) 124 | cmdline = [] 125 | if options[:ssh_config] 126 | cmdline << "-F" << File.expand_path(options[:ssh_config]) 127 | end 128 | cmdline += options[:ssh_options].flat_map { |k, v| ["-o", "#{k}=#{v}"] } 129 | if VERBOSITY_TRACE <= options[:verbosity] 130 | cmdline << "-v" 131 | end 132 | cmdline 133 | end 134 | 135 | def build_command_string(host, command=nil, options={}) 136 | # build ssh command 137 | cmdline = Shellwords.shellsplit(options.fetch(:ssh_command, "ssh")) + build_command_options(options) + [host] 138 | if command 139 | cmdline << "--" << command 140 | end 141 | Shellwords.join(cmdline) 142 | end 143 | 144 | def use_color? 145 | case options[:color] 146 | when :always 147 | true 148 | when :never 149 | false 150 | else 151 | STDOUT.tty? 152 | end 153 | end 154 | 155 | def exec_command(identifier, cmdline, options={}) 156 | output = options.fetch(:output, true) 157 | output_lock = options[:output_lock] || Mutex.new 158 | logger.debug("execute: #{cmdline}") 159 | if use_color? 160 | color = color_code(options[:index]) 161 | else 162 | color = nil 163 | end 164 | if options[:infile] 165 | cmdline = "cat #{Shellwords.shellescape(options[:infile])} | #{cmdline}" 166 | end 167 | cmderr, child_cmderr = IO.pipe 168 | IO.popen(cmdline, in: :close, err: child_cmderr) do |cmdout| 169 | i = 0 170 | each_readable([cmderr, cmdout]) do |readable| 171 | raw = readable.readline 172 | if output 173 | output_lock.synchronize do 174 | if readable == cmdout 175 | STDOUT.puts(prettify_output(raw, i, color, identifier)) 176 | i += 1 177 | else 178 | STDERR.puts(prettify_output(raw, nil, nil, identifier)) 179 | end 180 | end 181 | end 182 | end 183 | end 184 | $?.success? # $? is thread-local variable 185 | end 186 | 187 | private 188 | def each_readable(read_list, timeout=1) 189 | loop do 190 | # we cannot look until IO#eof? since it will block for pipes 191 | # http://ruby-doc.org/core-2.4.0/IO.html#method-i-eof-3F 192 | rs = Array(IO.select(read_list, [], [], timeout)).first 193 | if r = Array(rs).first 194 | begin 195 | yield r 196 | rescue EOFError => error 197 | break 198 | end 199 | end 200 | end 201 | end 202 | 203 | def color_code(index) 204 | if index 205 | color = 31 + (index % 6) 206 | else 207 | color = 36 208 | end 209 | end 210 | 211 | def prettify_output(raw, i, color, identifier) 212 | buf = [] 213 | if identifier 214 | if color 215 | buf << ("\e[0;#{color}m") 216 | end 217 | buf << identifier 218 | buf << ":" 219 | if i 220 | buf << i.to_s 221 | buf << ":" 222 | end 223 | if color 224 | buf << "\e[0m" 225 | end 226 | end 227 | buf << raw 228 | buf.join 229 | end 230 | end 231 | 232 | class SingularSshAlike < SshAlike 233 | def define_options(optparse, options={}) 234 | super 235 | options[:index] = nil 236 | optparse.on("-n", "--index INDEX", "Use this index of host if multiple servers are found", Integer) do |index| 237 | options[:index] = index 238 | end 239 | end 240 | 241 | private 242 | # rewriting `options[:index]` as SLICE expression won't work as expected with hosts' status 243 | # since the result may be filtered again with using the status, 244 | # the filtering needs to be done after the `evaluate()`. 245 | # 246 | # for now we need to keep using `filter_hosts()` in favor of `rewrite_expression() to do 247 | # the filtering based on status filtering. 248 | def filter_hosts(tuples) 249 | tuples = super 250 | if options[:index] and options[:index] < tuples.length 251 | filtered_tuples = tuples.reject.with_index { |tuple, i| i == options[:index] } 252 | logger.warn("filtered host(s): #{filtered_tuples.map { |tuple| tuple.first }.inspect}") 253 | [tuples[options[:index]]] 254 | else 255 | tuples 256 | end 257 | end 258 | 259 | def validate_hosts!(tuples, fields) 260 | super 261 | if tuples.length != 1 262 | result = tuples.each_with_index.map { |tuple, i| [i] + tuple } 263 | STDERR.print(format(result, fields: ["index"] + fields)) 264 | logger.error("found %d candidates. use '-n INDEX' option to select one." % result.length) 265 | exit(1) 266 | end 267 | end 268 | 269 | def run_main(hosts, options={}) 270 | cmdline = build_command_string(hosts.first, @remote_command, options) 271 | logger.debug("execute: #{cmdline}") 272 | if options[:dry_run] 273 | STDOUT.puts(cmdline) 274 | exit(0) 275 | else 276 | exec(cmdline) 277 | end 278 | exit(127) 279 | end 280 | end 281 | 282 | class Ssh < SingularSshAlike 283 | def define_options(optparse, options={}) 284 | super 285 | optparse.on("-D BIND_ADDRESS", "Specifies a local \"dynamic\" application-level port forwarding") do |bind_address| 286 | options[:dynamic_port_forward] = bind_address 287 | end 288 | optparse.on("-L BIND_ADDRESS", "Specifies that the given port on the local (client) host is to be forwarded to the given host and port on the remote side") do |bind_address| 289 | options[:port_forward] = bind_address 290 | end 291 | end 292 | 293 | private 294 | def build_command_options(options={}) 295 | arguments = super 296 | if options[:dynamic_port_forward] 297 | arguments << "-D" << options[:dynamic_port_forward] 298 | end 299 | if options[:port_forward] 300 | arguments << "-L" << options[:port_forward] 301 | end 302 | arguments 303 | end 304 | end 305 | end 306 | end 307 | -------------------------------------------------------------------------------- /lib/hotdog/commands/tag.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Commands 5 | class Tag < BaseCommand 6 | def define_options(optparse, options={}) 7 | default_option(options, :retry, 5) 8 | default_option(options, :tag_source, "user") 9 | default_option(options, :tags, []) 10 | optparse.on("--retry NUM") do |v| 11 | options[:retry] = v.to_i 12 | end 13 | optparse.on("--retry-delay SECONDS") do |v| 14 | options[:retry_delay] = v.to_i 15 | end 16 | optparse.on("--tag-source SOURCE") do |v| 17 | options[:tag_source] = v 18 | end 19 | optparse.on("-a TAG", "-t TAG", "--tag TAG", "Use specified tag name/value") do |v| 20 | options[:tags] << v 21 | end 22 | end 23 | 24 | def run(args=[], options={}) 25 | hosts = args.map { |arg| 26 | arg.sub(/\Ahost:/, "") 27 | } 28 | # Try reloading database after error as a workaround for nested transaction. 29 | with_retry(error_handler: ->(error) { reload }) do 30 | if open_db 31 | @db.transaction do 32 | create_tags(@db, options[:tags]) 33 | options[:tags].each do |tag| 34 | associate_tag_hosts(@db, tag, hosts) 35 | end 36 | end 37 | end 38 | end 39 | hosts.each do |host| 40 | if options[:tags].empty? 41 | # nop; just show current tags 42 | host_tags = with_retry { @source_provider.host_tags(host, source=options[:tag_source]) } 43 | STDOUT.puts host_tags['tags'].inspect 44 | else 45 | # add all as user tags 46 | with_retry(options) do 47 | @source_provider.add_tags(host, options[:tags], source=options[:tag_source]) 48 | end 49 | end 50 | end 51 | end 52 | end 53 | end 54 | end 55 | 56 | # vim:set ft=ruby : 57 | -------------------------------------------------------------------------------- /lib/hotdog/commands/tags.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Commands 5 | class Tags < BaseCommand 6 | def run(args=[], options={}) 7 | if args.empty? 8 | result = execute("SELECT name, value FROM tags").map { |name, value| [join_tag(name, value)] } 9 | show_tags(result) 10 | else 11 | tags = args.map { |tag| split_tag(tag) } 12 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 13 | if tags.all? { |_tagname, tagvalue| tagvalue.empty? } 14 | result = tags.each_slice(sqlite_limit_compound_select).flat_map { |tags| 15 | q = "SELECT value FROM tags " \ 16 | "WHERE %s;" % tags.map { |tagname, _tagvalue| glob?(tagname) ? "LOWER(name) GLOB LOWER(?)" : "name = ?" }.join(" OR ") 17 | execute(q, tags.map { |tagname, _tagvalue| tagname }).map { |value| [value] } 18 | } 19 | else 20 | result = tags.each_slice(sqlite_limit_compound_select / 2).flat_map { |tags| 21 | q = "SELECT value FROM tags " \ 22 | "WHERE %s;" % tags.map { |tagname, tagvalue| (glob?(tagname) or glob?(tagvalue)) ? "( LOWER(name) GLOB LOWER(?) AND LOWER(value) GLOB LOWER(?) )" : "( name = ? AND value = ? )" }.join(" OR ") 23 | execute(q, tags).map { |value| [value] } 24 | } 25 | end 26 | if result.empty? 27 | STDERR.puts("no match found: #{args.join(" ")}") 28 | exit(1) 29 | else 30 | show_tags(result) 31 | logger.info("found %d tag(s)." % result.length) 32 | if result.length < args.length 33 | STDERR.puts("insufficient result: #{args.join(" ")}") 34 | exit(1) 35 | end 36 | end 37 | end 38 | end 39 | 40 | def show_tags(tags) 41 | STDOUT.print(format(tags, fields: ["tag"])) 42 | end 43 | end 44 | end 45 | end 46 | 47 | # vim:set ft=ruby : 48 | -------------------------------------------------------------------------------- /lib/hotdog/commands/untag.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Commands 5 | class Untag < BaseCommand 6 | def define_options(optparse, options={}) 7 | default_option(options, :retry, 5) 8 | default_option(options, :tag_source, "user") 9 | default_option(options, :tags, []) 10 | optparse.on("--retry NUM") do |v| 11 | options[:retry] = v.to_i 12 | end 13 | optparse.on("--retry-delay SECONDS") do |v| 14 | options[:retry_delay] = v.to_i 15 | end 16 | optparse.on("--tag-source SOURCE") do |v| 17 | options[:tag_source] = v 18 | end 19 | optparse.on("-a TAG", "-t TAG", "--tag TAG", "Use specified tag name/value") do |v| 20 | options[:tags] << v 21 | end 22 | end 23 | 24 | def run(args=[], options={}) 25 | hosts = args.map { |arg| 26 | arg.sub(/\Ahost:/, "") 27 | } 28 | 29 | if options[:tags].empty? 30 | # refresh all persistent.db since there is no way to identify user tags 31 | remove_db(@db) 32 | else 33 | # Try reloading database after error as a workaround for nested transaction. 34 | with_retry(error_handler: -> (error) { reload }) do 35 | if open_db 36 | @db.transaction do 37 | options[:tags].each do |tag| 38 | disassociate_tag_hosts(@db, tag, hosts) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | 45 | hosts.each do |host| 46 | if options[:tags].empty? 47 | # delete all user tags 48 | with_retry do 49 | @source_provider.detach_tags(host, source=options[:tag_source]) 50 | end 51 | else 52 | host_tags = with_retry { @source_provider.host_tags(host, source=options[:tag_source]) } 53 | old_tags = host_tags["tags"] 54 | new_tags = old_tags - options[:tags] 55 | if old_tags == new_tags 56 | # nop 57 | else 58 | with_retry do 59 | @source_provider.update_tags(host, new_tags, source=options[:tag_source]) 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | end 67 | end 68 | 69 | # vim:set ft=ruby : 70 | -------------------------------------------------------------------------------- /lib/hotdog/commands/up.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Commands 5 | class Up < BaseCommand 6 | def define_options(optparse, options={}) 7 | default_option(options, :retry, 5) 8 | optparse.on("--retry NUM") do |v| 9 | options[:retry] = v.to_i 10 | end 11 | optparse.on("--retry-delay SECONDS") do |v| 12 | options[:retry_delay] = v.to_i 13 | end 14 | end 15 | 16 | def run(args=[], options={}) 17 | scopes = args.map { |arg| 18 | if arg.index(":").nil? 19 | "host:#{arg}" 20 | else 21 | arg 22 | end 23 | } 24 | all_downtimes = nil 25 | with_retry(options) do 26 | all_downtimes = @source_provider.get_all_downtimes(options) 27 | end 28 | 29 | cancel_downtimes = all_downtimes.select { |downtime| 30 | downtime["active"] and downtime["id"] and scopes.map { |scope| downtime.fetch("scope", []).include?(scope) }.any? 31 | } 32 | 33 | cancel_downtimes.each do |downtime| 34 | with_retry(options) do 35 | @source_provider.cancel_downtime(downtime["id"], options) 36 | end 37 | end 38 | 39 | hosts = scopes.select { |scope| scope.start_with?("host:") }.map { |scope| 40 | scope.slice("host:".length, scope.length) 41 | } 42 | if 0 < hosts.length 43 | # Try reloading database after error as a workaround for nested transaction. 44 | with_retry(error_handler: ->(error) { reload }) do 45 | if open_db 46 | @db.transaction do 47 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 48 | hosts.each_slice(sqlite_limit_compound_select - 1) do |hosts| 49 | execute_db(@db, "DELETE FROM hosts_tags WHERE tag_id IN ( SELECT id FROM tags WHERE name = '@status' ) AND host_id IN ( SELECT id FROM hosts WHERE name IN (%s) );" % hosts.map { "?" }.join(", "), hosts) 50 | execute_db(@db, "UPDATE hosts SET status = ? WHERE name IN (%s);" % hosts.map { "?" }.join(", "), [STATUS_RUNNING] + hosts) 51 | end 52 | associate_tag_hosts(@db, "@status:#{application.status_name(STATUS_RUNNING)}", hosts) 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | end 60 | end 61 | 62 | # vim:set ft=ruby : 63 | -------------------------------------------------------------------------------- /lib/hotdog/commands/version.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "rbconfig" 4 | 5 | module Hotdog 6 | module Commands 7 | class Version < BaseCommand 8 | def run(args=[], options={}) 9 | ruby = File.join(RbConfig::CONFIG["bindir"], RbConfig::CONFIG["ruby_install_name"]) 10 | exit(system(ruby, $0, "--version") ? 0 : 1) 11 | end 12 | end 13 | end 14 | end 15 | 16 | # vim:set ft=ruby : 17 | -------------------------------------------------------------------------------- /lib/hotdog/expression.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "parslet" 4 | 5 | # Monkey patch to prevent `NoMethodError` after some parse error in parselet 6 | module Parslet 7 | class Cause 8 | def cause 9 | self 10 | end 11 | 12 | def backtrace 13 | [] 14 | end 15 | end 16 | end 17 | 18 | require "hotdog/expression/semantics" 19 | require "hotdog/expression/syntax" 20 | 21 | module Hotdog 22 | module Expression 23 | class ExpressionTransformer < Parslet::Transform 24 | rule(float: simple(:float)) { 25 | float.to_f 26 | } 27 | rule(integer: simple(:integer)) { 28 | integer.to_i 29 | } 30 | rule(string: simple(:string)) { 31 | case string 32 | when /\A"(.*)"\z/ 33 | $1 34 | when /\A'(.*)'\z/ 35 | $1 36 | else 37 | string 38 | end 39 | } 40 | rule(regexp: simple(:regexp)) { 41 | case regexp 42 | when /\A\/(.*)\/\z/ 43 | $1 44 | else 45 | regexp 46 | end 47 | } 48 | rule(funcall_args_head: simple(:funcall_args_head), funcall_args_tail: sequence(:funcall_args_tail)) { 49 | [funcall_args_head] + funcall_args_tail 50 | } 51 | rule(funcall_args_head: simple(:funcall_args_head)) { 52 | [funcall_args_head] 53 | } 54 | rule(funcall: simple(:funcall), funcall_args: sequence(:funcall_args)) { 55 | FuncallNode.new(funcall, funcall_args) 56 | } 57 | rule(funcall: simple(:funcall)) { 58 | FuncallNode.new(funcall, []) 59 | } 60 | rule(binary_op: simple(:binary_op), left: simple(:left), right: simple(:right)) { 61 | BinaryExpressionNode.new(binary_op, left, right) 62 | } 63 | rule(unary_op: simple(:unary_op), expression: simple(:expression)) { 64 | UnaryExpressionNode.new(unary_op, expression) 65 | } 66 | rule(tagname_regexp: simple(:tagname_regexp), separator: simple(:separator), tagvalue_regexp: simple(:tagvalue_regexp)) { 67 | if "/@host/" == tagname_regexp or "/host/" == tagname_regexp 68 | RegexpHostNode.new(tagvalue_regexp, separator) 69 | else 70 | RegexpTagNode.new(tagname_regexp, tagvalue_regexp, separator) 71 | end 72 | } 73 | rule(tagname_regexp: simple(:tagname_regexp), separator: simple(:separator)) { 74 | if "/@host/" == tagname_regexp or "/host/" == tagname_regexp 75 | EverythingNode.new() 76 | else 77 | RegexpTagnameNode.new(tagname_regexp, separator) 78 | end 79 | } 80 | rule(tagname_regexp: simple(:tagname_regexp)) { 81 | if "/@host/" == tagname_regexp or "/host/" == tagname_regexp 82 | EverythingNode.new() 83 | else 84 | RegexpHostOrTagNode.new(tagname_regexp) 85 | end 86 | } 87 | rule(tagname_glob: simple(:tagname_glob), separator: simple(:separator), tagvalue_glob: simple(:tagvalue_glob)) { 88 | if "@host" == tagname_glob or "host" == tagname_glob 89 | GlobHostNode.new(tagvalue_glob, separator) 90 | else 91 | GlobTagNode.new(tagname_glob, tagvalue_glob, separator) 92 | end 93 | } 94 | rule(tagname_glob: simple(:tagname_glob), separator: simple(:separator), tagvalue: simple(:tagvalue)) { 95 | if "@host" == tagname_glob or "host" == tagname_glob 96 | GlobHostNode.new(tagvalue, separator) 97 | else 98 | GlobTagNode.new(tagname_glob, tagvalue, separator) 99 | end 100 | } 101 | rule(tagname_glob: simple(:tagname_glob), separator: simple(:separator)) { 102 | if "@host" == tagname_glob or "host" == tagname_glob 103 | EverythingNode.new() 104 | else 105 | GlobTagnameNode.new(tagname_glob, separator) 106 | end 107 | } 108 | rule(tagname_glob: simple(:tagname_glob)) { 109 | if "@host" == tagname_glob or "host" == tagname_glob 110 | EverythingNode.new() 111 | else 112 | GlobHostOrTagNode.new(tagname_glob) 113 | end 114 | } 115 | rule(tagname: simple(:tagname), separator: simple(:separator), tagvalue_glob: simple(:tagvalue_glob)) { 116 | if "@host" == tagname or "host" == tagname 117 | GlobHostNode.new(tagvalue_glob, separator) 118 | else 119 | GlobTagNode.new(tagname, tagvalue_glob, separator) 120 | end 121 | } 122 | rule(tagname: simple(:tagname), separator: simple(:separator), tagvalue: simple(:tagvalue)) { 123 | if "@host" == tagname or "host" == tagname 124 | StringHostNode.new(tagvalue, separator) 125 | else 126 | StringTagNode.new(tagname, tagvalue, separator) 127 | end 128 | } 129 | rule(tagname: simple(:tagname), separator: simple(:separator)) { 130 | if "@host" == tagname or "host" == tagname 131 | EverythingNode.new() 132 | else 133 | StringTagnameNode.new(tagname, separator) 134 | end 135 | } 136 | rule(tagname: simple(:tagname)) { 137 | if "@host" == tagname or "host" == tagname 138 | EverythingNode.new() 139 | else 140 | StringHostOrTagNode.new(tagname) 141 | end 142 | } 143 | rule(separator: simple(:separator), tagvalue_regexp: simple(:tagvalue_regexp)) { 144 | RegexpTagvalueNode.new(tagvalue_regexp, separator) 145 | } 146 | rule(tagvalue_regexp: simple(:tagvalue_regexp)) { 147 | RegexpTagvalueNode.new(tagvalue_regexp) 148 | } 149 | rule(separator: simple(:separator), tagvalue_glob: simple(:tagvalue_glob)) { 150 | GlobTagvalueNode.new(tagvalue_glob, separator) 151 | } 152 | rule(tagvalue_glob: simple(:tagvalue_glob)) { 153 | GlobTagvalueNode.new(tagvalue_glob) 154 | } 155 | rule(separator: simple(:separator), tagvalue: simple(:tagvalue)) { 156 | StringTagvalueNode.new(tagvalue, separator) 157 | } 158 | rule(tagvalue: simple(:tagvalue)) { 159 | StringTagvalueNode.new(tagvalue) 160 | } 161 | end 162 | end 163 | end 164 | 165 | # vim:set ft=ruby : 166 | -------------------------------------------------------------------------------- /lib/hotdog/expression/semantics.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Expression 5 | class ExpressionNode 6 | def evaluate(environment, options={}) 7 | raise(NotImplementedError.new("must be overridden")) 8 | end 9 | 10 | def optimize(options={}) 11 | self.dup 12 | end 13 | 14 | def compact(options={}) 15 | self 16 | end 17 | 18 | def dump(options={}) 19 | {} 20 | end 21 | 22 | def ==(other) 23 | self.dump == other.dump 24 | end 25 | end 26 | 27 | class UnaryExpressionNode < ExpressionNode 28 | attr_reader :op, :expression 29 | 30 | def initialize(op, expression, options={}) 31 | case (op || "not").to_s 32 | when "NOOP", "noop" 33 | @op = :NOOP 34 | when "!", "~", "NOT", "not" 35 | @op = :NOT 36 | else 37 | raise(SyntaxError.new("unknown unary operator: #{op.inspect}")) 38 | end 39 | @expression = expression 40 | @options = {} 41 | end 42 | 43 | def evaluate(environment, options={}) 44 | case @op 45 | when :NOOP 46 | @expression.evaluate(environment, options) 47 | when :NOT 48 | values = @expression.evaluate(environment, options).tap do |values| 49 | environment.logger.debug("expr: #{values.length} value(s)") 50 | end 51 | if values.empty? 52 | EverythingNode.new().evaluate(environment, options).tap do |values| 53 | environment.logger.debug("NOT expr: #{values.length} value(s)") 54 | end 55 | else 56 | # workaround for "too many terms in compound SELECT" 57 | min, max = environment.execute("SELECT MIN(id), MAX(id) FROM hosts LIMIT 1;").first.to_a 58 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 59 | (min / (sqlite_limit_compound_select - 2)).upto(max / (sqlite_limit_compound_select - 2)).flat_map { |i| 60 | range = ((sqlite_limit_compound_select - 2) * i)...((sqlite_limit_compound_select - 2) * (i + 1)) 61 | selected = values.select { |n| range === n } 62 | if 0 < selected.length 63 | q = "SELECT id FROM hosts " \ 64 | "WHERE ? <= id AND id < ? AND id NOT IN (%s);" 65 | environment.execute(q % selected.map { "?" }.join(", "), [range.first, range.last] + selected).map { |row| row.first } 66 | else 67 | [] 68 | end 69 | }.tap do |values| 70 | environment.logger.debug("NOT expr: #{values.length} value(s)") 71 | end 72 | end 73 | else 74 | [] 75 | end 76 | end 77 | 78 | def optimize(options={}) 79 | o_self = compact(options) 80 | if UnaryExpressionNode === o_self 81 | case o_self.op 82 | when :NOT 83 | case o_self.expression 84 | when EverythingNode 85 | NothingNode.new(options) 86 | when NothingNode 87 | EverythingNode.new(options) 88 | else 89 | o_self.optimize1(options) 90 | end 91 | else 92 | o_self.optimize1(options) 93 | end 94 | else 95 | o_self.optimize(options) 96 | end 97 | end 98 | 99 | def compact(options={}) 100 | case op 101 | when :NOOP 102 | expression.compact(options) 103 | else 104 | UnaryExpressionNode.new( 105 | op, 106 | expression.compact(options), 107 | ) 108 | end 109 | end 110 | 111 | def ==(other) 112 | self.class === other and @op == other.op and @expression == other.expression 113 | end 114 | 115 | def dump(options={}) 116 | {unary_op: @op.to_s, expression: @expression.dump(options)} 117 | end 118 | 119 | protected 120 | def optimize1(options={}) 121 | case op 122 | when :NOOP 123 | expression.optimize(options) 124 | when :NOT 125 | if UnaryExpressionNode === expression 126 | case expression.op 127 | when :NOOP 128 | expression.optimize(options) 129 | when :NOT 130 | expression.expression.optimize(options) 131 | else 132 | self.dup 133 | end 134 | else 135 | optimize2(options) 136 | end 137 | else 138 | self.dup 139 | end 140 | end 141 | 142 | def optimize2(options={}) 143 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 144 | case expression 145 | when QueryExpressionNode 146 | q = expression.query 147 | v = expression.values 148 | if q and v.length <= sqlite_limit_compound_select 149 | QueryExpressionNode.new("SELECT id AS host_id FROM hosts EXCEPT #{q.sub(/\s*;\s*\z/, "")};", v) 150 | else 151 | self.dup 152 | end 153 | when TagExpressionNode 154 | q = expression.maybe_query(options) 155 | v = expression.condition_values(options) 156 | if q and v.length <= sqlite_limit_compound_select 157 | QueryExpressionNode.new("SELECT id AS host_id FROM hosts EXCEPT #{q.sub(/\s*;\s*\z/, "")};", v) 158 | else 159 | self.dup 160 | end 161 | else 162 | self.dup 163 | end 164 | end 165 | end 166 | 167 | class BinaryExpressionNode < ExpressionNode 168 | attr_reader :op, :left, :right 169 | 170 | def initialize(op, left, right, options={}) 171 | case (op || "or").to_s 172 | when "&&", "&", "AND", "and" 173 | @op = :AND 174 | when ",", "||", "|", "OR", "or" 175 | @op = :OR 176 | when "^", "XOR", "xor" 177 | @op = :XOR 178 | else 179 | raise(SyntaxError.new("unknown binary operator: #{op.inspect}")) 180 | end 181 | @left = left 182 | @right = right 183 | @options = {} 184 | end 185 | 186 | def evaluate(environment, options={}) 187 | case @op 188 | when :AND 189 | left_values = @left.evaluate(environment, options).tap do |values| 190 | environment.logger.debug("lhs(#{values.length})") 191 | end 192 | if left_values.empty? 193 | [] 194 | else 195 | right_values = @right.evaluate(environment, options).tap do |values| 196 | environment.logger.debug("rhs(#{values.length})") 197 | end 198 | if right_values.empty? 199 | [] 200 | else 201 | # workaround for "too many terms in compound SELECT" 202 | min, max = environment.execute("SELECT MIN(id), MAX(id) FROM hosts LIMIT 1;").first.to_a 203 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 204 | (min / ((sqlite_limit_compound_select - 2) / 2)).upto(max / ((sqlite_limit_compound_select - 2) / 2)).flat_map { |i| 205 | range = (((sqlite_limit_compound_select - 2) / 2) * i)...(((sqlite_limit_compound_select - 2) / 2) * (i + 1)) 206 | left_selected = left_values.select { |n| range === n } 207 | right_selected = right_values.select { |n| range === n } 208 | if 0 < left_selected.length and 0 < right_selected.length 209 | q = "SELECT id FROM hosts " \ 210 | "WHERE ? <= id AND id < ? AND ( id IN (%s) AND id IN (%s) );" 211 | environment.execute(q % [left_selected.map { "?" }.join(", "), right_selected.map { "?" }.join(", ")], [range.first, range.last] + left_selected + right_selected).map { |row| row.first } 212 | else 213 | [] 214 | end 215 | }.tap do |values| 216 | environment.logger.debug("lhs(#{left_values.length}) AND rhs(#{right_values.length}) => #{values.length}") 217 | end 218 | end 219 | end 220 | when :OR 221 | left_values = @left.evaluate(environment, options).tap do |values| 222 | environment.logger.debug("lhs(#{values.length})") 223 | end 224 | right_values = @right.evaluate(environment, options).tap do |values| 225 | environment.logger.debug("rhs(#{values.length})") 226 | end 227 | if left_values.empty? 228 | right_values 229 | else 230 | if right_values.empty? 231 | [] 232 | else 233 | # workaround for "too many terms in compound SELECT" 234 | min, max = environment.execute("SELECT MIN(id), MAX(id) FROM hosts LIMIT 1;").first.to_a 235 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 236 | (min / ((sqlite_limit_compound_select - 2) / 2)).upto(max / ((sqlite_limit_compound_select - 2) / 2)).flat_map { |i| 237 | range = (((sqlite_limit_compound_select - 2) / 2) * i)...(((sqlite_limit_compound_select - 2) / 2) * (i + 1)) 238 | left_selected = left_values.select { |n| range === n } 239 | right_selected = right_values.select { |n| range === n } 240 | if 0 < left_selected.length or 0 < right_selected.length 241 | q = "SELECT id FROM hosts " \ 242 | "WHERE ? <= id AND id < ? AND ( id IN (%s) OR id IN (%s) );" 243 | environment.execute(q % [left_selected.map { "?" }.join(", "), right_selected.map { "?" }.join(", ")], [range.first, range.last] + left_selected + right_selected).map { |row| row.first } 244 | else 245 | [] 246 | end 247 | }.tap do |values| 248 | environment.logger.debug("lhs(#{left_values.length}) OR rhs(#{right_values.length}) => #{values.length}") 249 | end 250 | end 251 | end 252 | when :XOR 253 | left_values = @left.evaluate(environment, options).tap do |values| 254 | environment.logger.debug("lhs(#{values.length})") 255 | end 256 | right_values = @right.evaluate(environment, options).tap do |values| 257 | environment.logger.debug("rhs(#{values.length})") 258 | end 259 | if left_values.empty? 260 | right_values 261 | else 262 | if right_values.empty? 263 | [] 264 | else 265 | # workaround for "too many terms in compound SELECT" 266 | min, max = environment.execute("SELECT MIN(id), MAX(id) FROM hosts LIMIT 1;").first.to_a 267 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 268 | (min / ((sqlite_limit_compound_select - 2) / 4)).upto(max / ((sqlite_limit_compound_select - 2) / 4)).flat_map { |i| 269 | range = (((sqlite_limit_compound_select - 2) / 4) * i)...(((sqlite_limit_compound_select - 2) / 4) * (i + 1)) 270 | left_selected = left_values.select { |n| range === n } 271 | right_selected = right_values.select { |n| range === n } 272 | if 0 < left_selected.length or 0 < right_selected.length 273 | q = "SELECT id FROM hosts " \ 274 | "WHERE ? <= id AND id < ? AND NOT (id IN (%s) AND id IN (%s)) AND ( id IN (%s) OR id IN (%s) );" 275 | lq = left_selected.map { "?" }.join(", ") 276 | rq = right_selected.map { "?" }.join(", ") 277 | environment.execute(q % [lq, rq, lq, rq], [range.first, range.last] + left_selected + right_selected + left_selected + right_selected).map { |row| row.first } 278 | else 279 | [] 280 | end 281 | }.tap do |values| 282 | environment.logger.debug("lhs(#{left_values.length}) XOR rhs(#{right_values.length}) => #{values.length}") 283 | end 284 | end 285 | end 286 | else 287 | [] 288 | end 289 | end 290 | 291 | def optimize(options={}) 292 | o_left = @left.optimize(options) 293 | o_right = @right.optimize(options) 294 | case op 295 | when :AND 296 | case o_left 297 | when EverythingNode 298 | o_right 299 | when NothingNode 300 | o_left 301 | else 302 | if o_left == o_right 303 | o_left 304 | else 305 | BinaryExpressionNode.new( 306 | op, 307 | o_left, 308 | o_right, 309 | ).optimize1(options) 310 | end 311 | end 312 | when :OR 313 | case o_left 314 | when EverythingNode 315 | o_left 316 | when NothingNode 317 | o_right 318 | else 319 | if o_left == o_right 320 | o_left 321 | else 322 | if MultinaryExpressionNode === o_left 323 | if o_left.op == op 324 | o_left.merge(o_right, fallback: self) 325 | else 326 | BinaryExpressionNode.new( 327 | op, 328 | o_left, 329 | o_right, 330 | ).optimize1(options) 331 | end 332 | else 333 | if MultinaryExpressionNode === o_right 334 | if o_right.op == op 335 | o_right.merge(o_left, fallback: self) 336 | else 337 | BinaryExpressionNode.new( 338 | op, 339 | o_left, 340 | o_right, 341 | ).optimize1(options) 342 | end 343 | else 344 | MultinaryExpressionNode.new(op, [o_left, o_right], fallback: self) 345 | end 346 | end 347 | end 348 | end 349 | when :XOR 350 | if o_left == o_right 351 | NothingNode.new(options) 352 | else 353 | BinaryExpressionNode.new( 354 | op, 355 | o_left, 356 | o_right, 357 | ).optimize1(options) 358 | end 359 | else 360 | self.dup 361 | end 362 | end 363 | 364 | def ==(other) 365 | self.class === other and @op == other.op and @left == other.left and @right == other.right 366 | end 367 | 368 | def dump(options={}) 369 | {left: @left.dump(options), binary_op: @op.to_s, right: @right.dump(options)} 370 | end 371 | 372 | protected 373 | def optimize1(options) 374 | if TagExpressionNode === left and TagExpressionNode === right 375 | lq = left.maybe_query(options) 376 | lv = left.condition_values(options) 377 | rq = right.maybe_query(options) 378 | rv = right.condition_values(options) 379 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 380 | if lq and rq and lv.length + rv.length <= sqlite_limit_compound_select 381 | case op 382 | when :AND 383 | q = "#{lq.sub(/\s*;\s*\z/, "")} INTERSECT #{rq.sub(/\s*;\s*\z/, "")};" 384 | QueryExpressionNode.new(q, lv + rv, fallback: self) 385 | when :OR 386 | q = "#{lq.sub(/\s*;\s*\z/, "")} UNION #{rq.sub(/\s*;\s*\z/, "")};" 387 | QueryExpressionNode.new(q, lv + rv, fallback: self) 388 | when :XOR 389 | q = "#{lq.sub(/\s*;\s*\z/, "")} UNION #{rq.sub(/\s*;\s*\z/, "")} " \ 390 | "EXCEPT #{lq.sub(/\s*;\s*\z/, "")} " \ 391 | "INTERSECT #{rq.sub(/\s*;\s*\z/, "")};" 392 | QueryExpressionNode.new(q, lv + rv, fallback: self) 393 | else 394 | self.dup 395 | end 396 | else 397 | self.dup 398 | end 399 | else 400 | self.dup 401 | end 402 | end 403 | end 404 | 405 | class MultinaryExpressionNode < ExpressionNode 406 | attr_reader :op, :expressions 407 | 408 | def initialize(op, expressions, options={}) 409 | case (op || "or").to_s 410 | when ",", "||", "|", "OR", "or" 411 | @op = :OR 412 | else 413 | raise(SyntaxError.new("unknown multinary operator: #{op.inspect}")) 414 | end 415 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 416 | if sqlite_limit_compound_select < expressions.length 417 | raise(ArgumentError.new("expressions limit exceeded: #{expressions.length} for #{sqlite_limit_compound_select}")) 418 | end 419 | @expressions = expressions 420 | @options = options 421 | end 422 | 423 | def merge(other, options={}) 424 | if MultinaryExpressionNode === other and op == other.op 425 | MultinaryExpressionNode.new(op, expressions + other.expressions, options) 426 | else 427 | MultinaryExpressionNode.new(op, expressions + [other], options) 428 | end 429 | end 430 | 431 | def evaluate(environment, options={}) 432 | case @op 433 | when :OR 434 | if expressions.all? { |expression| TagExpressionNode === expression } 435 | values = expressions.group_by { |expression| expression.class }.values.flat_map { |expressions| 436 | query_without_condition = expressions.first.maybe_query_without_condition(options) 437 | if query_without_condition 438 | condition_length = expressions.map { |expression| expression.condition_values(options).length }.max 439 | sqlite_limit_compound_select = options[:sqlite_limit_compound_select] || SQLITE_LIMIT_COMPOUND_SELECT 440 | expressions.each_slice(sqlite_limit_compound_select / condition_length).flat_map { |expressions| 441 | q = query_without_condition.sub(/\s*;\s*\z/, " WHERE #{expressions.map { |expression| "( %s )" % expression.condition(options) }.join(" OR ")};") 442 | environment.execute(q, expressions.flat_map { |expression| expression.condition_values(options) }).map { |row| row.first } 443 | } 444 | else 445 | [] 446 | end 447 | } 448 | else 449 | values = [] 450 | end 451 | else 452 | values = [] 453 | end 454 | if values.empty? 455 | if @options[:fallback] 456 | @options[:fallback].evaluate(environment, options={}) 457 | else 458 | [] 459 | end 460 | else 461 | values 462 | end 463 | end 464 | 465 | def dump(options={}) 466 | {multinary_op: @op.to_s, expressions: expressions.map { |expression| expression.dump(options) }} 467 | end 468 | end 469 | 470 | class QueryExpressionNode < ExpressionNode 471 | def initialize(query, values=[], options={}) 472 | @query = query 473 | @values = values 474 | @options = options 475 | end 476 | attr_reader :query 477 | attr_reader :values 478 | 479 | def evaluate(environment, options={}) 480 | values = environment.execute(@query, @values).map { |row| row.first } 481 | if values.empty? and @options[:fallback] 482 | @options[:fallback].evaluate(environment, options) 483 | else 484 | values 485 | end 486 | end 487 | 488 | def dump(options={}) 489 | data = {query: @query, values: @values} 490 | data[:fallback] = @options[:fallback].dump(options) if @options[:fallback] 491 | data 492 | end 493 | end 494 | 495 | class FuncallNode < ExpressionNode 496 | attr_reader :function, :args 497 | 498 | def initialize(function, args, options={}) 499 | # FIXME: smart argument handling (e.g. arity & type checking) 500 | case function.to_s 501 | when "HEAD", "head" 502 | @function = :HEAD 503 | when "GROUP_BY", "group_by" 504 | @function = :GROUP_BY 505 | when "LIMIT", "limit" 506 | @function = :HEAD 507 | when "ORDER_BY", "order_by" 508 | @function = :ORDER_BY 509 | when "REVERSE", "reverse" 510 | @function = :REVERSE 511 | when "SAMPLE", "sample" 512 | @function = :HEAD 513 | args[0] = FuncallNode.new("SHUFFLE", [args[0]]) 514 | when "SHUFFLE", "shuffle" 515 | @function = :SHUFFLE 516 | when "SLICE", "slice" 517 | @function = :SLICE 518 | when "SORT", "sort" 519 | @function = :ORDER_BY 520 | when "TAIL", "tail" 521 | @function = :TAIL 522 | else 523 | raise(SyntaxError.new("unknown function call: #{function}")) 524 | end 525 | @args = args 526 | @options = options 527 | end 528 | 529 | def dump(options={}) 530 | args = @args.map { |arg| 531 | if ExpressionNode === arg 532 | arg.dump(options) 533 | else 534 | arg 535 | end 536 | } 537 | {funcall: @function.to_s, args: args} 538 | end 539 | 540 | def optimize(options={}) 541 | case function 542 | when :GROUP_BY 543 | o_args = [@args[0].optimize(options)] 544 | if TagExpressionNode === args[1] 545 | # workaround for expressions like `ORDER_BY((environment:development),role)` 546 | o_args << @args[1].tagname 547 | else 548 | o_args << @args[1] 549 | end 550 | when :ORDER_BY 551 | o_args = [@args[0].optimize(options)] 552 | if @args[1] 553 | if TagExpressionNode === @args[1] 554 | # workaround for expressions like `ORDER_BY((environment:development),role)` 555 | o_args << @args[1].tagname 556 | else 557 | o_args << @args[1] 558 | end 559 | end 560 | else 561 | o_args = @args.map { |arg| 562 | if ExpressionNode === arg 563 | arg.optimize(options) 564 | else 565 | arg 566 | end 567 | } 568 | end 569 | FuncallNode.new( 570 | @function, 571 | o_args, 572 | ) 573 | end 574 | 575 | def evaluate(environment, options={}) 576 | case function 577 | when :HEAD 578 | args[0].evaluate(environment, options).take(args[1] || 1) 579 | when :GROUP_BY 580 | intermediate = args[0].evaluate(environment, options) 581 | q = "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags " \ 582 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id " \ 583 | "WHERE tags.name = ? AND hosts_tags.host_id IN (%s) " \ 584 | "GROUP BY tags.value;" % intermediate.map { "?" }.join(", ") 585 | QueryExpressionNode.new(q, [args[1]] + intermediate).evaluate(environment, options) 586 | when :ORDER_BY 587 | intermediate = args[0].evaluate(environment, options) 588 | if args[1] 589 | q = "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags " \ 590 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id " \ 591 | "WHERE tags.name = ? AND hosts_tags.host_id IN (%s) " \ 592 | "ORDER BY tags.value;" % intermediate.map { "?" }.join(", ") 593 | QueryExpressionNode.new(q, [args[1]] + intermediate).evaluate(environment, options) 594 | else 595 | q = "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags " \ 596 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id " \ 597 | "WHERE hosts_tags.host_id IN (%s) " \ 598 | "ORDER BY hosts_tags.host_id;" % intermediate.map { "?" }.join(", ") 599 | QueryExpressionNode.new(q, intermediate).evaluate(environment, options) 600 | end 601 | when :REVERSE 602 | args[0].evaluate(environment, options).reverse() 603 | when :SHUFFLE 604 | args[0].evaluate(environment, options).shuffle() 605 | when :SLICE 606 | args[0].evaluate(environment, options).slice(args[1], args[2] || 1) 607 | when :TAIL 608 | args[0].evaluate(environment, options).last(args[1] || 1) 609 | else 610 | [] 611 | end 612 | end 613 | end 614 | 615 | class EverythingNode < QueryExpressionNode 616 | def initialize(options={}) 617 | super("SELECT id AS host_id FROM hosts;", [], options) 618 | end 619 | end 620 | 621 | class NothingNode < QueryExpressionNode 622 | def initialize(options={}) 623 | super("SELECT NULL AS host_id WHERE host_id NOT NULL;", [], options) 624 | end 625 | end 626 | 627 | class TagExpressionNode < ExpressionNode 628 | def initialize(tagname, tagvalue, separator=nil, options={}) 629 | @tagname = tagname 630 | @tagvalue = tagvalue 631 | @separator = separator 632 | @options = options 633 | end 634 | attr_reader :tagname 635 | attr_reader :tagvalue 636 | attr_reader :separator 637 | 638 | def tagname? 639 | !(tagname.nil? or tagname.to_s.empty?) 640 | end 641 | 642 | def tagvalue? 643 | !(tagvalue.nil? or tagvalue.to_s.empty?) 644 | end 645 | 646 | def separator? 647 | !(separator.nil? or separator.to_s.empty?) 648 | end 649 | 650 | def maybe_query(options={}) 651 | query_without_condition = maybe_query_without_condition(options) 652 | if query_without_condition 653 | query_without_condition.sub(/\s*;\s*\z/, " WHERE #{condition(options)};") 654 | else 655 | nil 656 | end 657 | end 658 | 659 | def maybe_query_without_condition(options={}) 660 | tables = condition_tables(options) 661 | if tables.empty? 662 | nil 663 | else 664 | case tables 665 | when [:hosts] 666 | "SELECT hosts.id AS host_id FROM hosts;" 667 | when [:hosts, :tags] 668 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags INNER JOIN hosts ON hosts_tags.host_id = hosts.id INNER JOIN tags ON hosts_tags.tag_id = tags.id;" 669 | when [:tags] 670 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags INNER JOIN tags ON hosts_tags.tag_id = tags.id;" 671 | else 672 | raise(NotImplementedError.new("unknown tables: #{tables.join(", ")}")) 673 | end 674 | end 675 | end 676 | 677 | def condition(options={}) 678 | raise(NotImplementedError.new("must be overridden")) 679 | end 680 | 681 | def condition_tables(options={}) 682 | raise(NotImplementedError.new("must be overridden")) 683 | end 684 | 685 | def condition_values(options={}) 686 | raise(NotImplementedError.new("must be overridden")) 687 | end 688 | 689 | def evaluate(environment, options={}) 690 | q = maybe_query(options) 691 | if q 692 | values = environment.execute(q, condition_values(options)).map { |row| row.first } 693 | if values.empty? 694 | if options[:did_fallback] 695 | [] 696 | else 697 | if environment.use_fallback? and @options[:fallback] 698 | # avoid optimizing @options[:fallback] to prevent infinite recursion 699 | @options[:fallback].evaluate(environment, options.merge(did_fallback: true)) 700 | else 701 | [] 702 | end 703 | end 704 | else 705 | values 706 | end 707 | else 708 | [] 709 | end 710 | end 711 | 712 | def ==(other) 713 | self.class == other.class and @tagname == other.tagname and @tagvalue == other.tagvalue 714 | end 715 | 716 | def optimize(options={}) 717 | # fallback to glob expression 718 | self.dup.tap do |o_self| 719 | o_self.instance_eval { 720 | @options[:fallback] ||= maybe_fallback(options) 721 | } 722 | end 723 | end 724 | 725 | def to_glob(s) 726 | (s.start_with?("*") ? "" : "*") + s.gsub(/[-.\/_]/, "?") + (s.end_with?("*") ? "" : "*") 727 | end 728 | 729 | def maybe_glob(s) 730 | s ? to_glob(s.to_s) : nil 731 | end 732 | 733 | def dump(options={}) 734 | data = {} 735 | data[:tagname] = tagname.to_s if tagname 736 | data[:separator] = separator.to_s if separator 737 | data[:tagvalue] = tagvalue.to_s if tagvalue 738 | data[:fallback] = @options[:fallback].dump(options) if @options[:fallback] 739 | data 740 | end 741 | 742 | def maybe_fallback(options={}) 743 | nil 744 | end 745 | end 746 | 747 | class AnyHostNode < TagExpressionNode 748 | def initialize(separator=nil, options={}) 749 | super("@host", nil, separator, options) 750 | end 751 | 752 | def condition(options={}) 753 | "1" 754 | end 755 | 756 | def condition_tables(options={}) 757 | [:hosts] 758 | end 759 | 760 | def condition_values(options={}) 761 | [] 762 | end 763 | end 764 | 765 | class StringExpressionNode < TagExpressionNode 766 | end 767 | 768 | class StringHostNode < StringExpressionNode 769 | def initialize(tagvalue, separator=nil, options={}) 770 | super("@host", tagvalue.to_s, separator, options) 771 | end 772 | 773 | def condition(options={}) 774 | "hosts.name = ?" 775 | end 776 | 777 | def condition_tables(options={}) 778 | [:hosts] 779 | end 780 | 781 | def condition_values(options={}) 782 | [tagvalue] 783 | end 784 | 785 | def maybe_fallback(options={}) 786 | fallback = GlobHostNode.new(to_glob(tagvalue), separator) 787 | query = fallback.maybe_query(options) 788 | if query 789 | QueryExpressionNode.new(query, fallback.condition_values(options)) 790 | else 791 | nil 792 | end 793 | end 794 | end 795 | 796 | class StringTagNode < StringExpressionNode 797 | def initialize(tagname, tagvalue, separator=nil, options={}) 798 | super(tagname.to_s, tagvalue.to_s, separator, options) 799 | end 800 | 801 | def condition(options={}) 802 | "tags.name = ? AND tags.value = ?" 803 | end 804 | 805 | def condition_tables(options={}) 806 | [:tags] 807 | end 808 | 809 | def condition_values(options={}) 810 | [tagname, tagvalue] 811 | end 812 | 813 | def maybe_fallback(options={}) 814 | fallback = GlobTagNode.new(to_glob(tagname), to_glob(tagvalue), separator) 815 | query = fallback.maybe_query(options) 816 | if query 817 | QueryExpressionNode.new(query, fallback.condition_values(options)) 818 | else 819 | nil 820 | end 821 | end 822 | end 823 | 824 | class StringTagnameNode < StringExpressionNode 825 | def initialize(tagname, separator=nil, options={}) 826 | super(tagname.to_s, nil, separator, options) 827 | end 828 | 829 | def condition(options={}) 830 | "tags.name = ?" 831 | end 832 | 833 | def condition_tables(options={}) 834 | [:tags] 835 | end 836 | 837 | def condition_values(options={}) 838 | [tagname] 839 | end 840 | 841 | def maybe_fallback(options={}) 842 | fallback = GlobTagnameNode.new(to_glob(tagname), separator) 843 | query = fallback.maybe_query(options) 844 | if query 845 | QueryExpressionNode.new(query, fallback.condition_values(options)) 846 | else 847 | nil 848 | end 849 | end 850 | end 851 | 852 | class StringTagvalueNode < StringExpressionNode 853 | def initialize(tagvalue, separator=nil, options={}) 854 | super(nil, tagvalue.to_s, separator, options) 855 | end 856 | 857 | def condition(options={}) 858 | "hosts.name = ? OR tags.value = ?" 859 | end 860 | 861 | def condition_tables(options={}) 862 | [:hosts, :tags] 863 | end 864 | 865 | def condition_values(options={}) 866 | [tagvalue, tagvalue] 867 | end 868 | 869 | def maybe_fallback(options={}) 870 | fallback = GlobTagvalueNode.new(to_glob(tagvalue), separator) 871 | query = fallback.maybe_query(options) 872 | if query 873 | QueryExpressionNode.new(query, fallback.condition_values(options)) 874 | else 875 | nil 876 | end 877 | end 878 | end 879 | 880 | class StringHostOrTagNode < StringExpressionNode 881 | def initialize(tagname, separator=nil, options={}) 882 | super(tagname.to_s, nil, separator, options) 883 | end 884 | 885 | def condition(options={}) 886 | "hosts.name = ? OR tags.name = ? OR tags.value = ?" 887 | end 888 | 889 | def condition_tables(options={}) 890 | [:hosts, :tags] 891 | end 892 | 893 | def condition_values(options={}) 894 | [tagname, tagname, tagname] 895 | end 896 | 897 | def maybe_fallback(options={}) 898 | fallback = GlobHostOrTagNode.new(to_glob(tagname), separator) 899 | query = fallback.maybe_query(options) 900 | if query 901 | QueryExpressionNode.new(query, fallback.condition_values(options)) 902 | else 903 | nil 904 | end 905 | end 906 | end 907 | 908 | class GlobExpressionNode < TagExpressionNode 909 | def dump(options={}) 910 | data = {} 911 | data[:tagname_glob] = tagname.to_s if tagname 912 | data[:separator] = separator.to_s if separator 913 | data[:tagvalue_glob] = tagvalue.to_s if tagvalue 914 | data[:fallback] = @options[:fallback].dump(options) if @options[:fallback] 915 | data 916 | end 917 | end 918 | 919 | class GlobHostNode < GlobExpressionNode 920 | def initialize(tagvalue, separator=nil, options={}) 921 | super("@host", tagvalue.to_s, separator, options) 922 | end 923 | 924 | def condition(options={}) 925 | "LOWER(hosts.name) GLOB LOWER(?)" 926 | end 927 | 928 | def condition_tables(options={}) 929 | [:hosts] 930 | end 931 | 932 | def condition_values(options={}) 933 | [tagvalue] 934 | end 935 | 936 | def maybe_fallback(options={}) 937 | fallback = GlobHostNode.new(to_glob(tagvalue), separator) 938 | query = fallback.maybe_query(options) 939 | if query 940 | QueryExpressionNode.new(query, fallback.condition_values(options)) 941 | else 942 | nil 943 | end 944 | end 945 | end 946 | 947 | class GlobTagNode < GlobExpressionNode 948 | def initialize(tagname, tagvalue, separator=nil, options={}) 949 | super(tagname.to_s, tagvalue.to_s, separator, options) 950 | end 951 | 952 | def condition(options={}) 953 | "LOWER(tags.name) GLOB LOWER(?) AND LOWER(tags.value) GLOB LOWER(?)" 954 | end 955 | 956 | def condition_tables(options={}) 957 | [:tags] 958 | end 959 | 960 | def condition_values(options={}) 961 | [tagname, tagvalue] 962 | end 963 | 964 | def maybe_fallback(options={}) 965 | fallback = GlobTagNode.new(to_glob(tagname), to_glob(tagvalue), separator) 966 | query = fallback.maybe_query(options) 967 | if query 968 | QueryExpressionNode.new(query, fallback.condition_values(options)) 969 | else 970 | nil 971 | end 972 | end 973 | end 974 | 975 | class GlobTagnameNode < GlobExpressionNode 976 | def initialize(tagname, separator=nil, options={}) 977 | super(tagname.to_s, nil, separator, options) 978 | end 979 | 980 | def condition(options={}) 981 | "LOWER(tags.name) GLOB LOWER(?)" 982 | end 983 | 984 | def condition_tables(options={}) 985 | [:tags] 986 | end 987 | 988 | def condition_values(options={}) 989 | [tagname] 990 | end 991 | 992 | def maybe_fallback(options={}) 993 | fallback = GlobTagnameNode.new(to_glob(tagname), separator) 994 | query = fallback.maybe_query(options) 995 | if query 996 | QueryExpressionNode.new(query, fallback.condition_values(options)) 997 | else 998 | nil 999 | end 1000 | end 1001 | end 1002 | 1003 | class GlobTagvalueNode < GlobExpressionNode 1004 | def initialize(tagvalue, separator=nil, options={}) 1005 | super(nil, tagvalue.to_s, separator, options) 1006 | end 1007 | 1008 | def condition(options={}) 1009 | "LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?)" 1010 | end 1011 | 1012 | def condition_tables(options={}) 1013 | [:hosts, :tags] 1014 | end 1015 | 1016 | def condition_values(options={}) 1017 | [tagvalue, tagvalue] 1018 | end 1019 | 1020 | def maybe_fallback(options={}) 1021 | fallback = GlobTagvalueNode.new(to_glob(tagvalue), separator) 1022 | query = fallback.maybe_query(options) 1023 | if query 1024 | QueryExpressionNode.new(query, fallback.condition_values(options)) 1025 | else 1026 | nil 1027 | end 1028 | end 1029 | end 1030 | 1031 | class GlobHostOrTagNode < GlobExpressionNode 1032 | def initialize(tagname, separator=nil, options={}) 1033 | super(tagname.to_s, nil, separator, options) 1034 | end 1035 | 1036 | def condition(options={}) 1037 | "LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?)" 1038 | end 1039 | 1040 | def condition_tables(options={}) 1041 | [:hosts, :tags] 1042 | end 1043 | 1044 | def condition_values(options={}) 1045 | [tagname, tagname, tagname] 1046 | end 1047 | 1048 | def maybe_fallback(options={}) 1049 | fallback = GlobHostOrTagNode.new(to_glob(tagname), separator) 1050 | query = fallback.maybe_query(options) 1051 | if query 1052 | QueryExpressionNode.new(query, fallback.condition_values(options)) 1053 | else 1054 | nil 1055 | end 1056 | end 1057 | end 1058 | 1059 | class RegexpExpressionNode < TagExpressionNode 1060 | def dump(options={}) 1061 | data = {} 1062 | data[:tagname_regexp] = tagname.to_s if tagname 1063 | data[:separator] = separator.to_s if separator 1064 | data[:tagvalue_regexp] = tagvalue.to_s if tagvalue 1065 | data[:fallback] = @options[:fallback].dump(options) if @options[:fallback] 1066 | data 1067 | end 1068 | end 1069 | 1070 | class RegexpHostNode < RegexpExpressionNode 1071 | def initialize(tagvalue, separator=nil, options={}) 1072 | case tagvalue 1073 | when /\A\/(.*)\/\z/ 1074 | tagvalue = $1 1075 | end 1076 | super("@host", tagvalue, separator, options) 1077 | end 1078 | 1079 | def condition(options={}) 1080 | "hosts.name REGEXP ?" 1081 | end 1082 | 1083 | def condition_tables(options={}) 1084 | [:hosts] 1085 | end 1086 | 1087 | def condition_values(options={}) 1088 | [tagvalue] 1089 | end 1090 | end 1091 | 1092 | class RegexpTagNode < RegexpExpressionNode 1093 | def initialize(tagname, tagvalue, separator=nil, options={}) 1094 | case tagname 1095 | when /\A\/(.*)\/\z/ 1096 | tagname = $1 1097 | end 1098 | case tagvalue 1099 | when /\A\/(.*)\/\z/ 1100 | tagvalue = $1 1101 | end 1102 | super(tagname, tagvalue, separator, options) 1103 | end 1104 | 1105 | def condition(options={}) 1106 | "tags.name REGEXP ? AND tags.value REGEXP ?" 1107 | end 1108 | 1109 | def condition_tables(options={}) 1110 | [:tags] 1111 | end 1112 | 1113 | def condition_values(options={}) 1114 | [tagname, tagvalue] 1115 | end 1116 | end 1117 | 1118 | class RegexpTagnameNode < RegexpExpressionNode 1119 | def initialize(tagname, separator=nil, options={}) 1120 | case tagname 1121 | when /\A\/(.*)\/\z/ 1122 | tagname = $1 1123 | end 1124 | super(tagname.to_s, nil, separator, options) 1125 | end 1126 | 1127 | def condition(options={}) 1128 | "tags.name REGEXP ?" 1129 | end 1130 | 1131 | def condition_tables(options={}) 1132 | [:tags] 1133 | end 1134 | 1135 | def condition_values(options={}) 1136 | [tagname] 1137 | end 1138 | end 1139 | 1140 | class RegexpTagvalueNode < RegexpExpressionNode 1141 | def initialize(tagvalue, separator=nil, options={}) 1142 | case tagvalue 1143 | when /\A\/(.*)\/\z/ 1144 | tagvalue = $1 1145 | end 1146 | super(nil, tagvalue.to_s, separator, options) 1147 | end 1148 | 1149 | def condition(options={}) 1150 | "hosts.name REGEXP ? OR tags.value REGEXP ?" 1151 | end 1152 | 1153 | def condition_tables(options={}) 1154 | [:hosts, :tags] 1155 | end 1156 | 1157 | def condition_values(options={}) 1158 | [tagvalue, tagvalue] 1159 | end 1160 | end 1161 | 1162 | class RegexpHostOrTagNode < RegexpExpressionNode 1163 | def initialize(tagname, separator=nil, options={}) 1164 | super(tagname, nil, separator, options) 1165 | end 1166 | 1167 | def condition(options={}) 1168 | "hosts.name REGEXP ? OR tags.name REGEXP ? OR tags.value REGEXP ?" 1169 | end 1170 | 1171 | def condition_tables(options={}) 1172 | [:hosts, :tags] 1173 | end 1174 | 1175 | def condition_values(options={}) 1176 | [tagname, tagname, tagname] 1177 | end 1178 | end 1179 | end 1180 | end 1181 | 1182 | # vim:set ft=ruby : 1183 | -------------------------------------------------------------------------------- /lib/hotdog/expression/syntax.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "parslet" 4 | 5 | module Hotdog 6 | module Expression 7 | class ExpressionParser < Parslet::Parser 8 | root(:expression) 9 | rule(:expression) { 10 | ( expression0 \ 11 | ) 12 | } 13 | rule(:expression0) { 14 | ( expression1.as(:left) >> spacing.maybe >> binary_op.as(:binary_op) >> spacing.maybe >> expression.as(:right) \ 15 | | expression1 \ 16 | ) 17 | } 18 | rule(:expression1) { 19 | ( unary_op.as(:unary_op) >> spacing >> expression.as(:expression) \ 20 | | unary_op.as(:unary_op) >> spacing.maybe >> str('(') >> spacing.maybe >> expression.as(:expression) >> spacing.maybe >> str(')') \ 21 | | expression2 \ 22 | ) 23 | } 24 | rule(:expression2) { 25 | ( expression3.as(:left) >> spacing.maybe.as(:binary_op) >> expression.as(:right) \ 26 | | expression3 \ 27 | ) 28 | } 29 | rule(:expression3) { 30 | ( expression4.as(:left) >> spacing.maybe >> str('&&').as(:binary_op) >> spacing.maybe >> expression.as(:right) \ 31 | | expression4.as(:left) >> spacing.maybe >> str('||').as(:binary_op) >> spacing.maybe >> expression.as(:right) \ 32 | | expression4.as(:left) >> spacing.maybe >> str('&').as(:binary_op) >> spacing.maybe >> expression.as(:right) \ 33 | | expression4.as(:left) >> spacing.maybe >> str(',').as(:binary_op) >> spacing.maybe >> expression.as(:right) \ 34 | | expression4.as(:left) >> spacing.maybe >> str('^').as(:binary_op) >> spacing.maybe >> expression.as(:right) \ 35 | | expression4.as(:left) >> spacing.maybe >> str('|').as(:binary_op) >> spacing.maybe >> expression.as(:right) \ 36 | | expression4 \ 37 | ) 38 | } 39 | rule(:expression4) { 40 | ( str('!').as(:unary_op) >> spacing.maybe >> primary.as(:expression) >> spacing.maybe \ 41 | | str('~').as(:unary_op) >> spacing.maybe >> primary.as(:expression) >> spacing.maybe \ 42 | | str('!').as(:unary_op) >> spacing.maybe >> expression.as(:expression) \ 43 | | str('~').as(:unary_op) >> spacing.maybe >> expression.as(:expression) \ 44 | | spacing.maybe >> primary >> spacing.maybe \ 45 | ) 46 | } 47 | rule(:binary_op) { 48 | ( str('AND') \ 49 | | str('OR') \ 50 | | str('XOR') \ 51 | | str('and') \ 52 | | str('or') \ 53 | | str('xor') \ 54 | ) 55 | } 56 | rule(:unary_op) { 57 | ( str('NOT') \ 58 | | str('not') \ 59 | ) 60 | } 61 | rule(:funcall) { 62 | ( funcall_identifier.as(:funcall) >> spacing.maybe >> str('(') >> spacing.maybe >> str(')') \ 63 | | funcall_identifier.as(:funcall) >> spacing.maybe >> str('(') >> spacing.maybe >> funcall_args.as(:funcall_args) >> spacing.maybe >> str(')') \ 64 | ) 65 | } 66 | rule(:funcall_identifier) { 67 | ( binary_op.absent? >> unary_op.absent? >> match('[A-Z_a-z]') >> match('[0-9A-Z_a-z]').repeat(0) \ 68 | | binary_op >> match('[0-9A-Z_a-z]').repeat(1) \ 69 | | unary_op >> match('[0-9A-Z_a-z]').repeat(1) \ 70 | ) 71 | } 72 | rule(:funcall_args) { 73 | ( funcall_arg.as(:funcall_args_head) >> spacing.maybe >> str(',') >> spacing.maybe >> funcall_args.as(:funcall_args_tail) \ 74 | | funcall_arg.as(:funcall_args_head) \ 75 | ) 76 | } 77 | rule(:funcall_arg) { 78 | ( float.as(:float) \ 79 | | integer.as(:integer) \ 80 | | string.as(:string) \ 81 | | regexp.as(:regexp) \ 82 | | primary \ 83 | ) 84 | } 85 | rule(:float) { 86 | ( match('[0-9]').repeat(1) >> str('.') >> match('[0-9]').repeat(1) \ 87 | ) 88 | } 89 | rule(:integer) { 90 | ( match('[0-9]').repeat(1) \ 91 | ) 92 | } 93 | rule(:string) { 94 | ( str('"') >> (str('"').absent? >> any).repeat(0) >> str('"') \ 95 | | str("'") >> (str("'").absent? >> any).repeat(0) >> str("'") \ 96 | ) 97 | } 98 | rule(:regexp) { 99 | ( str('/') >> (str('/').absent? >> any).repeat(0) >> str('/') \ 100 | ) 101 | } 102 | rule(:primary) { 103 | ( str('(') >> expression >> str(')') \ 104 | | funcall \ 105 | | tag \ 106 | ) 107 | } 108 | rule(:tag) { 109 | ( tagname_regexp.as(:tagname_regexp) >> separator.as(:separator) >> tagvalue_regexp.as(:tagvalue_regexp) \ 110 | | tagname_regexp.as(:tagname_regexp) >> separator.as(:separator) \ 111 | | tagname_regexp.as(:tagname_regexp) \ 112 | | tagname_glob.as(:tagname_glob) >> separator.as(:separator) >> tagvalue_glob.as(:tagvalue_glob) \ 113 | | tagname_glob.as(:tagname_glob) >> separator.as(:separator) >> tagvalue.as(:tagvalue) \ 114 | | tagname_glob.as(:tagname_glob) >> separator.as(:separator) \ 115 | | tagname_glob.as(:tagname_glob) \ 116 | | tagname.as(:tagname) >> separator.as(:separator) >> tagvalue_glob.as(:tagvalue_glob) \ 117 | | tagname.as(:tagname) >> separator.as(:separator) >> tagvalue.as(:tagvalue) \ 118 | | tagname.as(:tagname) >> separator.as(:separator) \ 119 | | tagname.as(:tagname) \ 120 | | (str('@') >> tagname).as(:tagname) >> separator.as(:separator) >> tagvalue_glob.as(:tagvalue_glob) \ 121 | | (str('@') >> tagname).as(:tagname) >> separator.as(:separator) >> tagvalue.as(:tagvalue) \ 122 | | (str('@') >> tagname).as(:tagname) >> separator.as(:separator) \ 123 | | (str('@') >> tagname).as(:tagname) \ 124 | | separator.as(:separator) >> regexp.as(:tagvalue_regexp) \ 125 | | separator.as(:separator) >> tagvalue_glob.as(:tagvalue_glob) \ 126 | | separator.as(:separator) >> tagvalue.as(:tagvalue) \ 127 | | tagvalue_regexp.as(:tagvalue_regexp) \ 128 | | tagvalue_glob.as(:tagvalue_glob) \ 129 | | tagvalue.as(:tagvalue) \ 130 | ) 131 | } 132 | rule(:tagname_regexp) { 133 | ( regexp \ 134 | ) 135 | } 136 | rule(:tagvalue_regexp) { 137 | ( regexp \ 138 | ) 139 | } 140 | rule(:tagname_glob) { 141 | ( binary_op.absent? >> unary_op.absent? >> tagname.repeat(0) >> (glob_char >> tagname.maybe).repeat(1) \ 142 | | binary_op >> (glob_char >> tagname.maybe).repeat(1) \ 143 | | unary_op >> (glob_char >> tagname.maybe).repeat(1) \ 144 | ) 145 | } 146 | rule(:tagvalue_glob) { 147 | ( binary_op.absent? >> unary_op.absent? >> tagvalue.repeat(0) >> (glob_char >> tagvalue.maybe).repeat(1) \ 148 | | binary_op >> (glob_char >> tagvalue.maybe).repeat(1) \ 149 | | unary_op >> (glob_char >> tagvalue.maybe).repeat(1) \ 150 | ) 151 | } 152 | rule(:tagname) { 153 | ( binary_op.absent? >> unary_op.absent? >> match('[A-Z_a-z]') >> match('[-./0-9A-Z_a-z]').repeat(0) \ 154 | | binary_op >> match('[-./0-9A-Z_a-z]').repeat(1) \ 155 | | unary_op >> match('[-./0-9A-Z_a-z]').repeat(1) \ 156 | ) 157 | } 158 | rule(:tagvalue) { 159 | ( binary_op.absent? >> unary_op.absent? >> match('[-./0-9:A-Z_a-z]').repeat(1) \ 160 | | binary_op >> match('[-./0-9:A-Z_a-z]').repeat(1) \ 161 | | unary_op >> match('[-./0-9:A-Z_a-z]').repeat(1) \ 162 | ) 163 | } 164 | rule(:separator) { 165 | ( str(':') \ 166 | | str('=') \ 167 | ) 168 | } 169 | rule(:glob_char) { 170 | ( str('*') | str('?') | str('[') | str(']') ) 171 | } 172 | rule(:spacing) { 173 | ( match('[\t\n\r ]').repeat(1) \ 174 | ) 175 | } 176 | end 177 | end 178 | end 179 | 180 | # vim:set ft=ruby : 181 | -------------------------------------------------------------------------------- /lib/hotdog/formatters.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Formatters 5 | class BaseFormatter 6 | def format(result, options={}) 7 | raise(NotImplementedError) 8 | end 9 | 10 | private 11 | def prepare(result) 12 | result.map { |row| row.map { |column| column or "" } } 13 | end 14 | 15 | def newline() 16 | "\n" 17 | end 18 | end 19 | end 20 | end 21 | 22 | # vim:set ft=ruby : 23 | -------------------------------------------------------------------------------- /lib/hotdog/formatters/csv.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "csv" 4 | 5 | module Hotdog 6 | module Formatters 7 | class Csv < BaseFormatter 8 | def format(result, options={}) 9 | result = prepare(result) 10 | if options[:headers] and options[:fields] 11 | result.unshift(options[:fields]) 12 | end 13 | CSV.generate { |csv| 14 | result.each do |row| 15 | csv << row 16 | end 17 | } 18 | end 19 | end 20 | end 21 | end 22 | 23 | # vim:set ft=ruby : 24 | -------------------------------------------------------------------------------- /lib/hotdog/formatters/json.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "json" 4 | 5 | module Hotdog 6 | module Formatters 7 | class Json < BaseFormatter 8 | def format(result, options={}) 9 | result = prepare(result) 10 | if options[:headers] and options[:fields] 11 | result.map! do |record| 12 | Hash[options[:fields].zip(record)] 13 | end 14 | JSON.pretty_generate(result) + newline 15 | else 16 | JSON.pretty_generate(result) + newline 17 | end 18 | end 19 | end 20 | end 21 | end 22 | 23 | # vim:set ft=ruby : 24 | -------------------------------------------------------------------------------- /lib/hotdog/formatters/ltsv.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Formatters 5 | class Ltsv < BaseFormatter 6 | def format(result, options={}) 7 | result = prepare(result) 8 | if options[:fields] 9 | result.map { |row| 10 | options[:fields].zip(row).map { |(field, column)| 11 | "#{field}:#{column}" 12 | }.join("\t") 13 | }.join(newline) + newline 14 | else 15 | result.map { |row| 16 | row.join("\t") 17 | }.join(newline) + newline 18 | end 19 | end 20 | end 21 | end 22 | end 23 | 24 | # vim:set ft=ruby : 25 | -------------------------------------------------------------------------------- /lib/hotdog/formatters/plain.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "hotdog/formatters/text" 4 | 5 | module Hotdog 6 | module Formatters 7 | class Plain < Text 8 | end 9 | end 10 | end 11 | 12 | # vim:set ft=ruby : 13 | -------------------------------------------------------------------------------- /lib/hotdog/formatters/text.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Formatters 5 | class Text < BaseFormatter 6 | def format(result, options={}) 7 | if options[:print0] 8 | sep = "\0" 9 | elsif options[:print1] 10 | sep = newline 11 | elsif options[:print2] 12 | sep = " " 13 | else 14 | sep = " " 15 | end 16 | result = prepare(result) 17 | if options[:print1] and options[:headers] and options[:fields] 18 | field_length = (0...result.last.length).map { |field_index| 19 | result.reduce(0) { |length, row| 20 | [length, row[field_index].to_s.length, options[:fields][field_index].to_s.length].max 21 | } 22 | } 23 | header_fields = options[:fields].zip(field_length).map { |field, length| 24 | field.to_s + (" " * (length - field.length)) 25 | } 26 | result = [ 27 | header_fields, 28 | header_fields.map { |field| 29 | "-" * field.length 30 | }, 31 | ] + result.map { |row| 32 | row.zip(field_length).map { |field, length| 33 | padding = length - ((field.nil?) ? 0 : field.to_s.length) 34 | field.to_s + (" " * padding) 35 | } 36 | } 37 | end 38 | s = _format(result, sep, options) 39 | if s.empty? or options[:print0] 40 | s 41 | else 42 | s + newline 43 | end 44 | end 45 | 46 | def _format(result, sep, options={}) 47 | result.map { |row| 48 | row.join(" ") 49 | }.join(sep) 50 | end 51 | end 52 | end 53 | end 54 | 55 | # vim:set ft=ruby : 56 | -------------------------------------------------------------------------------- /lib/hotdog/formatters/tsv.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Formatters 5 | class Tsv < BaseFormatter 6 | def format(result, options={}) 7 | result = prepare(result) 8 | if options[:headers] and options[:fields] 9 | result.unshift(options[:fields]) 10 | end 11 | result.map { |row| 12 | row.join("\t") 13 | }.join(newline) + newline 14 | end 15 | end 16 | end 17 | end 18 | 19 | # vim:set ft=ruby : 20 | -------------------------------------------------------------------------------- /lib/hotdog/formatters/yaml.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "yaml" 4 | 5 | module Hotdog 6 | module Formatters 7 | class Yaml < BaseFormatter 8 | def format(result, options={}) 9 | result = prepare(result) 10 | if options[:headers] and options[:fields] 11 | result.unshift(options[:fields]) 12 | end 13 | result.to_yaml 14 | end 15 | end 16 | end 17 | end 18 | 19 | # vim:set ft=ruby : 20 | -------------------------------------------------------------------------------- /lib/hotdog/sources.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | module Hotdog 4 | module Sources 5 | class BaseSource 6 | def initialize(application) 7 | @application = application 8 | @logger = application.logger 9 | @options = application.options 10 | end 11 | attr_reader :application 12 | attr_reader :logger 13 | attr_reader :options 14 | 15 | def id() #=> Integer 16 | raise(NotImplementedError) 17 | end 18 | 19 | def name() #=> String 20 | raise(NotImplementedError) 21 | end 22 | 23 | def endpoint() #=> String 24 | options[:endpoint] 25 | end 26 | 27 | def api_key() #=> String 28 | options[:api_key] 29 | end 30 | 31 | def application_key() #=> String 32 | options[:application_key] 33 | end 34 | 35 | def schedule_downtime(scope, options={}) 36 | raise(NotImplementedError) 37 | end 38 | 39 | def cancel_downtime(id, options={}) 40 | raise(NotImplementedError) 41 | end 42 | 43 | def get_all_downtimes(options={}) 44 | # 45 | # This should return some `Array>` like follows 46 | # 47 | # ```json 48 | # [ 49 | # { 50 | # "recurrence": null, 51 | # "end": 1533593208, 52 | # "monitor_tags": [ 53 | # "*" 54 | # ], 55 | # "canceled": null, 56 | # "monitor_id": null, 57 | # "org_id": 12345, 58 | # "disabled": false, 59 | # "start": 1533592608, 60 | # "creator_id": 78913, 61 | # "parent_id": null, 62 | # "timezone": "UTC", 63 | # "active": false, 64 | # "scope": [ 65 | # "host:i-abcdef01234567890" 66 | # ], 67 | # "message": null, 68 | # "downtime_type": null, 69 | # "id": 278432422, 70 | # "updater_id": null 71 | # } 72 | # ] 73 | # ``` 74 | # 75 | raise(NotImplementedError) 76 | end 77 | 78 | def get_all_tags(options={}) 79 | # 80 | # This should return some `Hash>` like follows 81 | # 82 | # ```json 83 | # { 84 | # "tagname:tagvalue": [ 85 | # "foo", 86 | # "bar", 87 | # "baz" 88 | # ] 89 | # } 90 | # ``` 91 | # 92 | raise(NotImplementedError) 93 | end 94 | 95 | def get_host_tags(host_name, options={}) 96 | raise(NotImplementedError) 97 | end 98 | 99 | def add_tags(host_name, tags, options={}) 100 | raise(NotImplementedError) 101 | end 102 | 103 | def detach_tags(host_name, options={}) 104 | raise(NotImplementedError) 105 | end 106 | 107 | def update_tags(host_name, tags, options={}) 108 | raise(NotImplementedError) 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/hotdog/sources/datadog.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "dogapi" 4 | require "multi_json" 5 | require "oj" 6 | require "open-uri" 7 | require "uri" 8 | 9 | module Hotdog 10 | module Sources 11 | class Datadog < BaseSource 12 | def initialize(application) 13 | super(application) 14 | options[:endpoint] = ENV.fetch("DATADOG_HOST", "https://app.datadoghq.com") 15 | options[:api_key] = ENV["DATADOG_API_KEY"] 16 | options[:application_key] = ENV["DATADOG_APPLICATION_KEY"] 17 | @dog = nil # lazy initialization 18 | end 19 | 20 | def id() 21 | Hotdog::SOURCE_DATADOG 22 | end 23 | 24 | def name() 25 | "datadog" 26 | end 27 | 28 | def endpoint() 29 | options[:endpoint] 30 | end 31 | 32 | def api_key() 33 | if options[:api_key] 34 | options[:api_key] 35 | else 36 | update_api_key! 37 | if options[:api_key] 38 | options[:api_key] 39 | else 40 | raise("DATADOG_API_KEY is not set") 41 | end 42 | end 43 | end 44 | 45 | def application_key() 46 | if options[:application_key] 47 | options[:application_key] 48 | else 49 | update_application_key! 50 | if options[:application_key] 51 | options[:application_key] 52 | else 53 | raise("DATADOG_APPLICATION_KEY is not set") 54 | end 55 | end 56 | end 57 | 58 | def schedule_downtime(scope, options={}) 59 | code, schedule = dog.schedule_downtime(scope, :start => options[:start].to_i, :end => (options[:start]+options[:downtime]).to_i) 60 | logger.debug("dog.schedule_donwtime(%s, :start => %s, :end => %s) #==> [%s, %s]" % [scope.inspect, options[:start].to_i, (options[:start]+options[:downtime]).to_i, code.inspect, schedule.inspect]) 61 | if code.to_i / 100 != 2 62 | raise("dog.schedule_downtime(%s, ...) returns [%s, %s]" % [scope.inspect, code.inspect, schedule.inspect]) 63 | end 64 | schedule 65 | end 66 | 67 | def cancel_downtime(id, options={}) 68 | code, cancel = dog.cancel_downtime(id) 69 | if code.to_i / 100 != 2 70 | raise("dog.cancel_downtime(%s) returns [%s, %s]" % [id.inspect, code.inspect, cancel.inspect]) 71 | end 72 | cancel 73 | end 74 | 75 | def get_all_downtimes(options={}) 76 | now = Time.new.to_i 77 | Array(datadog_get("/api/v1/downtime")).select { |downtime| 78 | # active downtimes 79 | downtime["active"] and ( downtime["start"].nil? or downtime["start"] < now ) and ( downtime["end"].nil? or now <= downtime["end"] ) and downtime["monitor_id"].nil? 80 | } 81 | end 82 | 83 | def get_all_tags(options={}) 84 | Hash(datadog_get("/api/v1/tags/hosts")).fetch("tags", {}) 85 | end 86 | 87 | def get_host_tags(host_name, options={}) 88 | code, host_tags = dog.host_tags(host_name, options) 89 | if code.to_i / 100 != 2 90 | raise("dog.host_tags(#{host_name.inspect}, #{options.inspect}) returns [#{code.inspect}, #{host_tags.inspect}]") 91 | end 92 | host_tags 93 | end 94 | 95 | def add_tags(host_name, tags, options={}) 96 | code, resp = dog.add_tags(host_name, tags, options) 97 | if code.to_i / 100 != 2 98 | raise("dog.add_tags(#{host_name.inspect}, #{tags.inspect}, #{options.inspect}) returns [#{code.inspect}, #{resp.inspect}]") 99 | end 100 | resp 101 | end 102 | 103 | def detach_tags(host_name, options={}) 104 | code, detach_tags = dog.detach_tags(host_name, options) 105 | if code.to_i / 100 != 2 106 | raise("dog.detach_tags(#{host_name.inspect}, #{options.inspect}) returns [#{code.inspect}, #{detach_tags.inspect}]") 107 | end 108 | detach_tags 109 | end 110 | 111 | def update_tags(host_name, tags, options={}) 112 | code, update_tags = dog.update_tags(host_name, tags, options) 113 | if code.to_i / 100 != 2 114 | raise("dog.update_tags(#{host_name.inspect}, #{tags.inspect}, #{options.inspect}) returns [#{code.inspect}, #{update_tags.inspect}]") 115 | end 116 | update_tags 117 | end 118 | 119 | private 120 | def dog() 121 | @dog ||= Dogapi::Client.new(self.api_key, self.application_key) 122 | end 123 | 124 | def datadog_get(request_path, query=nil) 125 | query ||= URI.encode_www_form(api_key: self.api_key, application_key: self.application_key) 126 | uri = URI.join(self.endpoint, "#{request_path}?#{query}") 127 | begin 128 | response = uri.open("User-Agent" => "hotdog/#{Hotdog::VERSION}") { |fp| fp.read } 129 | MultiJson.load(response) 130 | rescue OpenURI::HTTPError => error 131 | code, _body = error.io.status 132 | raise(RuntimeError.new("datadog: GET #{request_path} returns [#{code.inspect}, ...]")) 133 | end 134 | end 135 | 136 | def update_api_key!() 137 | if options[:api_key_command] 138 | logger.info("api_key_command> #{options[:api_key_command]}") 139 | options[:api_key] = IO.popen(options[:api_key_command]) do |io| 140 | io.read.strip 141 | end 142 | unless $?.success? 143 | raise("failed: #{options[:api_key_command]}") 144 | end 145 | else 146 | update_keys! 147 | end 148 | end 149 | 150 | def update_application_key!() 151 | if options[:application_key_command] 152 | logger.info("application_key_command> #{options[:application_key_command]}") 153 | options[:application_key] = IO.popen(options[:application_key_command]) do |io| 154 | io.read.strip 155 | end 156 | unless $?.success? 157 | raise("failed: #{options[:application_key_command]}") 158 | end 159 | else 160 | update_keys! 161 | end 162 | end 163 | 164 | def update_keys!() 165 | if options[:key_command] 166 | logger.info("key_command> #{options[:key_command]}") 167 | options[:api_key], options[:application_key] = IO.popen(options[:key_command]) do |io| 168 | io.read.strip.split(":", 2) 169 | end 170 | unless $?.success? 171 | raise("failed: #{options[:key_command]}") 172 | end 173 | end 174 | end 175 | end 176 | end 177 | end 178 | 179 | # vim:set ft=ruby : 180 | -------------------------------------------------------------------------------- /lib/hotdog/version.rb: -------------------------------------------------------------------------------- 1 | module Hotdog 2 | VERSION = "1.20191028.2" 3 | end 4 | -------------------------------------------------------------------------------- /spec/core/application_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/hosts" 5 | require "hotdog/commands/search" 6 | require "hotdog/commands/tags" 7 | 8 | describe "application" do 9 | let(:app) { 10 | Hotdog::Application.new 11 | } 12 | 13 | before(:each) do 14 | ENV["DATADOG_API_KEY"] = "DATADOG_API_KEY" 15 | ENV["DATADOG_APPLICATION_KEY"] = "DATADOG_APPLICATION_KEY" 16 | end 17 | 18 | it "generates proper class name from file name" do 19 | expect(app.__send__(:const_name, "csv")).to eq("Csv") 20 | expect(app.__send__(:const_name, "json")).to eq("Json") 21 | expect(app.__send__(:const_name, "pssh")).to eq("Pssh") 22 | expect(app.__send__(:const_name, "parallel-ssh")).to eq("ParallelSsh") 23 | end 24 | 25 | it "returns proper class by name" do 26 | expect(app.__send__(:get_command, "hosts")).to be_a(Hotdog::Commands::Hosts) 27 | expect(app.__send__(:get_command, "search")).to be_a(Hotdog::Commands::Search) 28 | expect(app.__send__(:get_command, "tags")).to be_a(Hotdog::Commands::Tags) 29 | end 30 | 31 | it "raises error if the action is base-command" do 32 | expect { 33 | app.main(["base-command"]) 34 | }.to raise_error(NotImplementedError) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/core/commands_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | 6 | describe "commands" do 7 | let(:cmd) { 8 | Hotdog::Commands::Search.new(Hotdog::Application.new) 9 | } 10 | 11 | before(:each) do 12 | ENV["DATADOG_API_KEY"] = "DATADOG_API_KEY" 13 | ENV["DATADOG_APPLICATION_KEY"] = "DATADOG_APPLICATION_KEY" 14 | end 15 | 16 | it "get empty hosts" do 17 | cmd.options[:listing] = false 18 | cmd.options[:primary_tag] = nil 19 | allow(cmd).to receive(:update_db) 20 | expect(cmd.__send__(:get_hosts, [], [])).to eq([[], []]) 21 | end 22 | 23 | it "get hosts" do 24 | cmd.options[:listing] = false 25 | cmd.options[:primary_tag] = nil 26 | allow(cmd).to receive(:update_db) 27 | allow(cmd).to receive(:execute).with("SELECT id FROM hosts WHERE status = ? AND id IN (?, ?, ?);", [Hotdog::STATUS_RUNNING, 1, 2, 3]) { 28 | [[1], [2], [3]] 29 | } 30 | allow(cmd).to receive(:get_hosts_fields).with([1, 2, 3], ["@host"]) 31 | expect(cmd.__send__(:get_hosts, [1, 2, 3], [])) 32 | end 33 | 34 | it "get hosts with primary tag" do 35 | cmd.options[:listing] = false 36 | cmd.options[:primary_tag] = "foo" 37 | allow(cmd).to receive(:update_db) 38 | allow(cmd).to receive(:execute).with("SELECT id FROM hosts WHERE status = ? AND id IN (?, ?, ?);", [Hotdog::STATUS_RUNNING, 1, 2, 3]) { 39 | [[1], [2], [3]] 40 | } 41 | allow(cmd).to receive(:get_hosts_fields).with([1, 2, 3], ["foo"]) 42 | expect(cmd.__send__(:get_hosts, [1, 2, 3], [])) 43 | end 44 | 45 | it "get hosts with tags" do 46 | cmd.options[:listing] = false 47 | cmd.options[:primary_tag] = nil 48 | allow(cmd).to receive(:update_db) 49 | allow(cmd).to receive(:execute).with("SELECT id FROM hosts WHERE status = ? AND id IN (?, ?, ?);", [Hotdog::STATUS_RUNNING, 1, 2, 3]) { 50 | [[1], [2], [3]] 51 | } 52 | allow(cmd).to receive(:get_hosts_fields).with([1, 2, 3], ["foo", "bar", "baz"]) 53 | expect(cmd.__send__(:get_hosts, [1, 2, 3], ["foo", "bar", "baz"])) 54 | end 55 | 56 | it "get hosts with all tags" do 57 | cmd.options[:listing] = true 58 | cmd.options[:primary_tag] = nil 59 | allow(cmd).to receive(:update_db) 60 | allow(cmd).to receive(:execute).with("SELECT id FROM hosts WHERE status = ? AND id IN (?, ?, ?);", [Hotdog::STATUS_RUNNING, 1, 2, 3]) { 61 | [[1], [2], [3]] 62 | } 63 | q1 = [ 64 | "SELECT DISTINCT tags.name FROM hosts_tags", 65 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 66 | "WHERE hosts_tags.host_id IN (?, ?, ?) ORDER BY hosts_tags.host_id;", 67 | ] 68 | allow(cmd).to receive(:execute).with(q1.join(" "), [1, 2, 3]) { 69 | [["foo"], ["bar"], ["baz"]] 70 | } 71 | allow(cmd).to receive(:get_hosts_fields).with([1, 2, 3], ["@host", "foo", "bar", "baz"]) 72 | expect(cmd.__send__(:get_hosts, [1, 2, 3], [])) 73 | end 74 | 75 | it "get hosts with all tags with primary tag" do 76 | cmd.options[:listing] = true 77 | cmd.options[:primary_tag] = "bar" 78 | allow(cmd).to receive(:update_db) 79 | allow(cmd).to receive(:execute).with("SELECT id FROM hosts WHERE status = ? AND id IN (?, ?, ?);", [Hotdog::STATUS_RUNNING, 1, 2, 3]) { 80 | [[1], [2], [3]] 81 | } 82 | q1 = [ 83 | "SELECT DISTINCT tags.name FROM hosts_tags", 84 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 85 | "WHERE hosts_tags.host_id IN (?, ?, ?) ORDER BY hosts_tags.host_id;", 86 | ] 87 | allow(cmd).to receive(:execute).with(q1.join(" "), [1, 2, 3]) { 88 | [["foo"], ["bar"], ["baz"]] 89 | } 90 | allow(cmd).to receive(:get_hosts_fields).with([1, 2, 3], ["bar", "@host", "foo", "baz"]) 91 | expect(cmd.__send__(:get_hosts, [1, 2, 3], [])) 92 | end 93 | 94 | it "get empty host fields" do 95 | expect(cmd.__send__(:get_hosts_fields, [1, 2, 3], [])).to eq([[], []]) 96 | end 97 | 98 | it "get host fields without host" do 99 | allow(cmd).to receive(:execute).with("SELECT id FROM hosts WHERE status = ? AND id IN (?, ?, ?);", [Hotdog::STATUS_RUNNING, 1, 2, 3]) { 100 | [[1], [2], [3]] 101 | } 102 | q1 = [ 103 | "SELECT LOWER(tags.name), GROUP_CONCAT(tags.value, ',') FROM hosts_tags", 104 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 105 | "WHERE hosts_tags.host_id = ? AND tags.name IN (?, ?, ?)", 106 | "GROUP BY tags.name;", 107 | ] 108 | allow(cmd).to receive(:execute).with(q1.join(" "), [1, "foo", "bar", "baz"]) { 109 | [["foo", "foo1"], ["bar", "bar1"], ["baz", "baz1"]] 110 | } 111 | allow(cmd).to receive(:execute).with(q1.join(" "), [2, "foo", "bar", "baz"]) { 112 | [["foo", "foo2"], ["bar", "bar2"], ["baz", "baz2"]] 113 | } 114 | allow(cmd).to receive(:execute).with(q1.join(" "), [3, "foo", "bar", "baz"]) { 115 | [["foo", "foo3"], ["bar", "bar3"], ["baz", "baz3"]] 116 | } 117 | expect(cmd.__send__(:get_hosts_fields, [1, 2, 3], ["foo", "bar", "baz"])).to eq([[["foo1", "bar1", "baz1"], ["foo2", "bar2", "baz2"], ["foo3", "bar3", "baz3"]], ["foo", "bar", "baz"]]) 118 | end 119 | 120 | it "get host fields with host" do 121 | allow(cmd).to receive(:execute).with("SELECT id FROM hosts WHERE status = ? AND id IN (?, ?, ?);", [Hotdog::STATUS_RUNNING, 1, 2, 3]) { 122 | [[1], [2], [3]] 123 | } 124 | q1 = [ 125 | "SELECT LOWER(tags.name), GROUP_CONCAT(tags.value, ',') FROM hosts_tags", 126 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 127 | "WHERE hosts_tags.host_id = ? AND tags.name IN (?, ?, ?)", 128 | "GROUP BY tags.name;", 129 | ] 130 | allow(cmd).to receive(:execute).with(q1.join(" "), [1, "foo", "bar", "@host"]) { 131 | [["foo", "foo1"], ["bar", "bar1"], ["@host", "host1"]] 132 | } 133 | allow(cmd).to receive(:execute).with(q1.join(" "), [2, "foo", "bar", "@host"]) { 134 | [["foo", "foo2"], ["bar", "bar2"], ["@host", "host2"]] 135 | } 136 | allow(cmd).to receive(:execute).with(q1.join(" "), [3, "foo", "bar", "@host"]) { 137 | [["foo", "foo3"], ["bar", "bar3"], ["@host", "host3"]] 138 | } 139 | expect(cmd.__send__(:get_hosts_fields, [1, 2, 3], ["foo", "bar", "@host"])).to eq([[["foo1", "bar1", "host1"], ["foo2", "bar2", "host2"], ["foo3", "bar3", "host3"]], ["foo", "bar", "@host"]]) 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/evaluator/glob_expression_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | require "parslet" 6 | 7 | describe "tag glob expression" do 8 | let(:cmd) { 9 | Hotdog::Commands::Search.new(Hotdog::Application.new) 10 | } 11 | 12 | before(:each) do 13 | ENV["DATADOG_API_KEY"] = "DATADOG_API_KEY" 14 | ENV["DATADOG_APPLICATION_KEY"] = "DATADOG_APPLICATION_KEY" 15 | end 16 | 17 | it "interprets tag glob with host" do 18 | expr = Hotdog::Expression::GlobHostNode.new("foo*", ":") 19 | q = [ 20 | "SELECT hosts.id AS host_id FROM hosts", 21 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 22 | ] 23 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo*"]) { 24 | [[1], [2], [3]] 25 | } 26 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 27 | expect(expr.dump).to eq({tagname_glob: "@host", separator: ":", tagvalue_glob: "foo*"}) 28 | end 29 | 30 | it "interprets tag glob with tagname and tagvalue" do 31 | expr = Hotdog::Expression::GlobTagNode.new("foo*", "bar*", ":") 32 | q = [ 33 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 34 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 35 | "WHERE LOWER(tags.name) GLOB LOWER(?) AND LOWER(tags.value) GLOB LOWER(?);", 36 | ] 37 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo*", "bar*"]) { 38 | [[1], [2], [3]] 39 | } 40 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 41 | expect(expr.dump).to eq({tagname_glob: "foo*", separator: ":", tagvalue_glob: "bar*"}) 42 | end 43 | 44 | it "interprets tag glob with tagname with separator" do 45 | expr = Hotdog::Expression::GlobTagnameNode.new("foo*", ":") 46 | q = [ 47 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 48 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 49 | "WHERE LOWER(tags.name) GLOB LOWER(?);", 50 | ] 51 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo*"]) { 52 | [[1], [2], [3]] 53 | } 54 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 55 | expect(expr.dump).to eq({tagname_glob: "foo*", separator: ":"}) 56 | end 57 | 58 | it "interprets tag glob with tagname without separator" do 59 | expr = Hotdog::Expression::GlobHostOrTagNode.new("foo*", nil) 60 | q = [ 61 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 62 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 63 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 64 | "WHERE LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?);", 65 | ] 66 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo*", "foo*", "foo*"]) { 67 | [[1], [2], [3]] 68 | } 69 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 70 | expect(expr.dump).to eq({tagname_glob: "foo*"}) 71 | end 72 | 73 | it "interprets tag glob with tagvalue with separator" do 74 | expr = Hotdog::Expression::GlobTagvalueNode.new("foo*", ":") 75 | q = [ 76 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 77 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 78 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 79 | "WHERE LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?);", 80 | ] 81 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo*", "foo*"]) { 82 | [[1], [2], [3]] 83 | } 84 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 85 | expect(expr.dump).to eq({separator: ":", tagvalue_glob: "foo*"}) 86 | end 87 | 88 | it "interprets tag glob with tagvalue without separator" do 89 | expr = Hotdog::Expression::GlobTagvalueNode.new("foo*", nil) 90 | q = [ 91 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 92 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 93 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 94 | "WHERE LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?);", 95 | ] 96 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo*", "foo*"]) { 97 | [[1], [2], [3]] 98 | } 99 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 100 | expect(expr.dump).to eq({tagvalue_glob: "foo*"}) 101 | end 102 | 103 | it "empty tag glob" do 104 | expr = Hotdog::Expression::GlobExpressionNode.new(nil, nil, nil) 105 | expect { 106 | expr.evaluate(cmd) 107 | }.to raise_error(NotImplementedError) 108 | expect(expr.dump).to eq({}) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/evaluator/regexp_expression_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | require "parslet" 6 | 7 | describe "tag regexp expression" do 8 | let(:cmd) { 9 | Hotdog::Commands::Search.new(Hotdog::Application.new) 10 | } 11 | 12 | before(:each) do 13 | ENV["DATADOG_API_KEY"] = "DATADOG_API_KEY" 14 | ENV["DATADOG_APPLICATION_KEY"] = "DATADOG_APPLICATION_KEY" 15 | end 16 | 17 | it "interprets tag regexp with host" do 18 | expr = Hotdog::Expression::RegexpHostNode.new("foo", ":") 19 | q = [ 20 | "SELECT hosts.id AS host_id FROM hosts", 21 | "WHERE hosts.name REGEXP ?;", 22 | ] 23 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo"]) { 24 | [[1], [2], [3]] 25 | } 26 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 27 | expect(expr.dump).to eq({tagname_regexp: "@host", separator: ":", tagvalue_regexp: "foo"}) 28 | end 29 | 30 | it "interprets tag regexp with tagname and tagvalue" do 31 | expr = Hotdog::Expression::RegexpTagNode.new("foo", "bar", ":") 32 | q = [ 33 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 34 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 35 | "WHERE tags.name REGEXP ? AND tags.value REGEXP ?;", 36 | ] 37 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo", "bar"]) { 38 | [[1], [2], [3]] 39 | } 40 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 41 | expect(expr.dump).to eq({tagname_regexp: "foo", separator: ":", tagvalue_regexp: "bar"}) 42 | end 43 | 44 | it "interprets tag regexp with tagname with separator" do 45 | expr = Hotdog::Expression::RegexpTagnameNode.new("foo", ":") 46 | q = [ 47 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 48 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 49 | "WHERE tags.name REGEXP ?;", 50 | ] 51 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo"]) { 52 | [[1], [2], [3]] 53 | } 54 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 55 | expect(expr.dump).to eq({tagname_regexp: "foo", separator: ":"}) 56 | end 57 | 58 | it "interprets tag regexp with tagname without separator" do 59 | expr = Hotdog::Expression::RegexpHostOrTagNode.new("foo", nil) 60 | q = [ 61 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 62 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 63 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 64 | "WHERE hosts.name REGEXP ? OR tags.name REGEXP ? OR tags.value REGEXP ?;", 65 | ] 66 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo", "foo", "foo"]) { 67 | [[1], [2], [3]] 68 | } 69 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 70 | expect(expr.dump).to eq({tagname_regexp: "foo"}) 71 | end 72 | 73 | it "interprets tag regexp with tagvalue with separator" do 74 | expr = Hotdog::Expression::RegexpTagvalueNode.new("foo", ":") 75 | q = [ 76 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 77 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 78 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 79 | "WHERE hosts.name REGEXP ? OR tags.value REGEXP ?;", 80 | ] 81 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo", "foo"]) { 82 | [[1], [2], [3]] 83 | } 84 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 85 | expect(expr.dump).to eq({separator: ":", tagvalue_regexp: "foo"}) 86 | end 87 | 88 | it "interprets tag regexp with tagvalue without separator" do 89 | expr = Hotdog::Expression::RegexpTagvalueNode.new("foo", nil) 90 | q = [ 91 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 92 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 93 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 94 | "WHERE hosts.name REGEXP ? OR tags.value REGEXP ?;", 95 | ] 96 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo", "foo"]) { 97 | [[1], [2], [3]] 98 | } 99 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 100 | expect(expr.dump).to eq({tagvalue_regexp: "foo"}) 101 | end 102 | 103 | it "empty tag regexp" do 104 | expr = Hotdog::Expression::RegexpExpressionNode.new(nil, nil, nil) 105 | expect { 106 | expr.evaluate(cmd) 107 | }.to raise_error(NotImplementedError) 108 | expect(expr.dump).to eq({}) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/evaluator/string_expression_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | require "parslet" 6 | 7 | describe "tag expression" do 8 | let(:cmd) { 9 | Hotdog::Commands::Search.new(Hotdog::Application.new) 10 | } 11 | 12 | before(:each) do 13 | ENV["DATADOG_API_KEY"] = "DATADOG_API_KEY" 14 | ENV["DATADOG_APPLICATION_KEY"] = "DATADOG_APPLICATION_KEY" 15 | end 16 | 17 | it "interprets tag with host" do 18 | expr = Hotdog::Expression::StringHostNode.new("foo", ":") 19 | q = [ 20 | "SELECT hosts.id AS host_id FROM hosts", 21 | "WHERE hosts.name = ?;", 22 | ] 23 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo"]) { 24 | [[1], [2], [3]] 25 | } 26 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 27 | expect(expr.dump).to eq({tagname: "@host", separator: ":", tagvalue: "foo"}) 28 | end 29 | 30 | it "interprets tag with tagname and tagvalue" do 31 | expr = Hotdog::Expression::StringTagNode.new("foo", "bar", ":") 32 | q = [ 33 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 34 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 35 | "WHERE tags.name = ? AND tags.value = ?;", 36 | ] 37 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo", "bar"]) { 38 | [[1], [2], [3]] 39 | } 40 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 41 | expect(expr.dump).to eq({tagname: "foo", separator: ":", tagvalue: "bar"}) 42 | end 43 | 44 | it "interprets tag with tagname with separator" do 45 | expr = Hotdog::Expression::StringTagnameNode.new("foo", ":") 46 | q = [ 47 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 48 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 49 | "WHERE tags.name = ?;", 50 | ] 51 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo"]) { 52 | [[1], [2], [3]] 53 | } 54 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 55 | expect(expr.dump).to eq({tagname: "foo", separator: ":"}) 56 | end 57 | 58 | it "interprets tag with tagname without separator" do 59 | expr = Hotdog::Expression::StringHostOrTagNode.new("foo", nil) 60 | q = [ 61 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 62 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 63 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 64 | "WHERE hosts.name = ? OR tags.name = ? OR tags.value = ?;", 65 | ] 66 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo", "foo", "foo"]) { 67 | [[1], [2], [3]] 68 | } 69 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 70 | expect(expr.dump).to eq({tagname: "foo"}) 71 | end 72 | 73 | it "interprets tag with tagvalue with separator" do 74 | expr = Hotdog::Expression::StringTagvalueNode.new("foo", ":") 75 | q = [ 76 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 77 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 78 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 79 | "WHERE hosts.name = ? OR tags.value = ?;", 80 | ] 81 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo", "foo"]) { 82 | [[1], [2], [3]] 83 | } 84 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 85 | expect(expr.dump).to eq({separator: ":", tagvalue: "foo"}) 86 | end 87 | 88 | it "interprets tag with tagvalue without separator" do 89 | expr = Hotdog::Expression::StringTagvalueNode.new("foo", nil) 90 | q = [ 91 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 92 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 93 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 94 | "WHERE hosts.name = ? OR tags.value = ?;", 95 | ] 96 | allow(cmd).to receive(:execute).with(q.join(" "), ["foo", "foo"]) { 97 | [[1], [2], [3]] 98 | } 99 | expect(expr.evaluate(cmd)).to eq([1, 2, 3]) 100 | expect(expr.dump).to eq({tagvalue: "foo"}) 101 | end 102 | 103 | it "empty tag" do 104 | expr = Hotdog::Expression::StringExpressionNode.new(nil, nil, nil) 105 | expect { 106 | expr.evaluate(cmd) 107 | }.to raise_error(NotImplementedError) 108 | expect(expr.dump).to eq({}) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/formatter/csv_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/formatters" 3 | require "hotdog/formatters/csv" 4 | 5 | describe "csv" do 6 | let(:fmt) { 7 | Hotdog::Formatters::Csv.new 8 | } 9 | 10 | it "generates csv without headers" do 11 | options = { 12 | headers: false, 13 | } 14 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 15 | foo,aaa,1 16 | bar,bbb,2 17 | baz,ccc,3 18 | EOS 19 | end 20 | 21 | it "generates csv with headers" do 22 | options = { 23 | headers: true, 24 | fields: ["key1", "key2", "val1"], 25 | } 26 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 27 | key1,key2,val1 28 | foo,aaa,1 29 | bar,bbb,2 30 | baz,ccc,3 31 | EOS 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/formatter/json_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/formatters" 3 | require "hotdog/formatters/json" 4 | 5 | describe "json" do 6 | let(:fmt) { 7 | Hotdog::Formatters::Json.new 8 | } 9 | 10 | it "generates json without headers" do 11 | options = { 12 | headers: false, 13 | } 14 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 15 | [ 16 | [ 17 | "foo", 18 | "aaa", 19 | 1 20 | ], 21 | [ 22 | "bar", 23 | "bbb", 24 | 2 25 | ], 26 | [ 27 | "baz", 28 | "ccc", 29 | 3 30 | ] 31 | ] 32 | EOS 33 | end 34 | 35 | it "generates json with headers" do 36 | options = { 37 | headers: true, 38 | fields: ["key1", "key2", "val1"], 39 | prettyprint: false, 40 | } 41 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 42 | [ 43 | { 44 | "key1": "foo", 45 | "key2": "aaa", 46 | "val1": 1 47 | }, 48 | { 49 | "key1": "bar", 50 | "key2": "bbb", 51 | "val1": 2 52 | }, 53 | { 54 | "key1": "baz", 55 | "key2": "ccc", 56 | "val1": 3 57 | } 58 | ] 59 | EOS 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/formatter/ltsv_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/formatters" 3 | require "hotdog/formatters/ltsv" 4 | 5 | describe "ltsv" do 6 | let(:fmt) { 7 | Hotdog::Formatters::Ltsv.new 8 | } 9 | 10 | it "generates ltsv without headers" do 11 | options = { 12 | headers: false, 13 | } 14 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 15 | foo\taaa\t1 16 | bar\tbbb\t2 17 | baz\tccc\t3 18 | EOS 19 | end 20 | 21 | it "generates ltsv with headers" do 22 | options = { 23 | headers: true, 24 | fields: ["key1", "key2", "val1"], 25 | } 26 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 27 | key1:foo\tkey2:aaa\tval1:1 28 | key1:bar\tkey2:bbb\tval1:2 29 | key1:baz\tkey2:ccc\tval1:3 30 | EOS 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/formatter/plain_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/formatters" 3 | require "hotdog/formatters/plain" 4 | 5 | describe "plain" do 6 | let(:fmt) { 7 | Hotdog::Formatters::Plain.new 8 | } 9 | 10 | it "generates plain (print0) without headers" do 11 | options = { 12 | headers: false, 13 | print0: true, 14 | print1: false, 15 | print2: false, 16 | } 17 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq("foo aaa 1\0bar bbb 2\0baz ccc 3") 18 | end 19 | 20 | it "generates plain (print0) with headers" do 21 | options = { 22 | headers: true, 23 | fields: ["key1", "key2", "val1"], 24 | print0: true, 25 | print1: false, 26 | print2: false, 27 | } 28 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq("foo aaa 1\0bar bbb 2\0baz ccc 3") 29 | end 30 | 31 | it "generates plain (print1) without headers" do 32 | options = { 33 | headers: false, 34 | print0: false, 35 | print1: true, 36 | print2: false, 37 | } 38 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 39 | foo aaa 1 40 | bar bbb 2 41 | baz ccc 3 42 | EOS 43 | end 44 | 45 | it "generates plain (print1) with headers" do 46 | options = { 47 | headers: true, 48 | fields: ["key1", "key2", "val1"], 49 | print0: false, 50 | print1: true, 51 | print2: false, 52 | } 53 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 54 | key1 key2 val1 55 | ---- ---- ---- 56 | foo aaa 1 57 | bar bbb 2 58 | baz ccc 3 59 | EOS 60 | end 61 | 62 | it "generates plain (space) without headers" do 63 | options = { 64 | headers: false, 65 | print0: false, 66 | print1: false, 67 | print2: true, 68 | } 69 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq("foo aaa 1 bar bbb 2 baz ccc 3\n") 70 | end 71 | 72 | it "generates plain (space) with headers" do 73 | options = { 74 | headers: true, 75 | fields: ["key1", "key2", "val1"], 76 | print0: false, 77 | print1: false, 78 | print2: true, 79 | } 80 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq("foo aaa 1 bar bbb 2 baz ccc 3\n") 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/formatter/text_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/formatters" 3 | require "hotdog/formatters/text" 4 | 5 | describe "text" do 6 | let(:fmt) { 7 | Hotdog::Formatters::Text.new 8 | } 9 | 10 | it "generates text (print0) without headers" do 11 | options = { 12 | headers: false, 13 | print0: true, 14 | print1: false, 15 | print2: false, 16 | } 17 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq("foo aaa 1\0bar bbb 2\0baz ccc 3") 18 | end 19 | 20 | it "generates text (print0) with headers" do 21 | options = { 22 | headers: true, 23 | fields: ["key1", "key2", "val1"], 24 | print0: true, 25 | print1: false, 26 | print2: false, 27 | } 28 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq("foo aaa 1\0bar bbb 2\0baz ccc 3") 29 | end 30 | 31 | it "generates text (print1) without headers" do 32 | options = { 33 | headers: false, 34 | print0: false, 35 | print1: true, 36 | print2: false, 37 | } 38 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 39 | foo aaa 1 40 | bar bbb 2 41 | baz ccc 3 42 | EOS 43 | end 44 | 45 | it "generates text (print1) with headers" do 46 | options = { 47 | headers: true, 48 | fields: ["key1", "key2", "val1"], 49 | print0: false, 50 | print1: true, 51 | print2: false, 52 | } 53 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 54 | key1 key2 val1 55 | ---- ---- ---- 56 | foo aaa 1 57 | bar bbb 2 58 | baz ccc 3 59 | EOS 60 | end 61 | 62 | it "generates text (space) without headers" do 63 | options = { 64 | headers: false, 65 | print0: false, 66 | print1: false, 67 | print2: true, 68 | } 69 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq("foo aaa 1 bar bbb 2 baz ccc 3\n") 70 | end 71 | 72 | it "generates text (space) with headers" do 73 | options = { 74 | headers: true, 75 | fields: ["key1", "key2", "val1"], 76 | print0: false, 77 | print1: false, 78 | print2: true, 79 | } 80 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq("foo aaa 1 bar bbb 2 baz ccc 3\n") 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/formatter/tsv_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/formatters" 3 | require "hotdog/formatters/tsv" 4 | 5 | describe "tsv" do 6 | let(:fmt) { 7 | Hotdog::Formatters::Tsv.new 8 | } 9 | 10 | it "generates tsv without headers" do 11 | options = { 12 | headers: false, 13 | } 14 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 15 | foo\taaa\t1 16 | bar\tbbb\t2 17 | baz\tccc\t3 18 | EOS 19 | end 20 | 21 | it "generates tsv with headers" do 22 | options = { 23 | headers: true, 24 | fields: ["key1", "key2", "val1"], 25 | } 26 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 27 | key1\tkey2\tval1 28 | foo\taaa\t1 29 | bar\tbbb\t2 30 | baz\tccc\t3 31 | EOS 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/formatter/yaml_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/formatters" 3 | require "hotdog/formatters/yaml" 4 | 5 | describe "yaml" do 6 | let(:fmt) { 7 | Hotdog::Formatters::Yaml.new 8 | } 9 | 10 | it "generates yaml without headers" do 11 | options = { 12 | headers: false, 13 | } 14 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 15 | --- 16 | - - foo 17 | - aaa 18 | - 1 19 | - - bar 20 | - bbb 21 | - 2 22 | - - baz 23 | - ccc 24 | - 3 25 | EOS 26 | end 27 | 28 | it "generates yaml with headers" do 29 | options = { 30 | headers: true, 31 | fields: ["key1", "key2", "val1"], 32 | } 33 | expect(fmt.format([["foo", "aaa", 1], ["bar", "bbb", 2], ["baz", "ccc", 3]], options)).to eq(<<-EOS) 34 | --- 35 | - - key1 36 | - key2 37 | - val1 38 | - - foo 39 | - aaa 40 | - 1 41 | - - bar 42 | - bbb 43 | - 2 44 | - - baz 45 | - ccc 46 | - 3 47 | EOS 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/optimizer/binary_expression_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | require "parslet" 6 | 7 | describe "binary expression" do 8 | 3.times do |o| 9 | it "everything AND x should return x (#{o})" do 10 | expr = Hotdog::Expression::BinaryExpressionNode.new("AND", Hotdog::Expression::EverythingNode.new(), Hotdog::Expression::NothingNode.new()) 11 | expect(optimize_n(o+1, expr).dump).to eq({ 12 | query: "SELECT NULL AS host_id WHERE host_id NOT NULL;", 13 | values: [], 14 | }) 15 | end 16 | 17 | it "nothing AND x should return nothing (#{o})" do 18 | expr = Hotdog::Expression::BinaryExpressionNode.new("AND", Hotdog::Expression::NothingNode.new(), Hotdog::Expression::EverythingNode.new()) 19 | expect(optimize_n(o+1, expr).dump).to eq({ 20 | query: "SELECT NULL AS host_id WHERE host_id NOT NULL;", 21 | values: [], 22 | }) 23 | end 24 | 25 | it "everything OR x should return everything (#{o})" do 26 | expr = Hotdog::Expression::BinaryExpressionNode.new("OR", Hotdog::Expression::EverythingNode.new(), Hotdog::Expression::NothingNode.new()) 27 | expect(optimize_n(o+1, expr).dump).to eq({ 28 | query: "SELECT id AS host_id FROM hosts;", 29 | values: [], 30 | }) 31 | end 32 | 33 | it "nothing OR x should return x (#{o})" do 34 | expr = Hotdog::Expression::BinaryExpressionNode.new("OR", Hotdog::Expression::NothingNode.new(), Hotdog::Expression::EverythingNode.new()) 35 | expect(optimize_n(o+1, expr).dump).to eq({ 36 | query: "SELECT id AS host_id FROM hosts;", 37 | values: [], 38 | }) 39 | end 40 | 41 | it "everything XOR everything should return nothing (#{o})" do 42 | expr = Hotdog::Expression::BinaryExpressionNode.new("XOR", Hotdog::Expression::EverythingNode.new(), Hotdog::Expression::EverythingNode.new()) 43 | expect(optimize_n(o+1, expr).dump).to eq({ 44 | query: "SELECT NULL AS host_id WHERE host_id NOT NULL;", 45 | values: [], 46 | }) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/optimizer/glob_expression_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | require "parslet" 6 | 7 | describe "tag glob expression" do 8 | 3.times do |o| 9 | it "interprets tag glob with host (#{o})" do 10 | expr = Hotdog::Expression::GlobHostNode.new("foo*", ":") 11 | expect(optimize_n(o+1, expr).dump).to eq({ 12 | tagname_glob: "@host", 13 | separator: ":", 14 | tagvalue_glob: "foo*", 15 | fallback: { 16 | query: [ 17 | "SELECT hosts.id AS host_id FROM hosts", 18 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 19 | ].join(" "), 20 | values: ["*foo*"], 21 | }, 22 | }) 23 | end 24 | 25 | it "interprets tag glob with tagname and tagvalue (#{o})" do 26 | expr = Hotdog::Expression::GlobTagNode.new("foo*", "bar*", ":") 27 | expect(optimize_n(o+1, expr).dump).to eq({ 28 | tagname_glob: "foo*", 29 | separator: ":", 30 | tagvalue_glob: "bar*", 31 | fallback: { 32 | query: [ 33 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 34 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 35 | "WHERE LOWER(tags.name) GLOB LOWER(?) AND LOWER(tags.value) GLOB LOWER(?);", 36 | ].join(" "), 37 | values: ["*foo*", "*bar*"], 38 | }, 39 | }) 40 | end 41 | 42 | it "interprets tag glob with tagname with separator (#{o})" do 43 | expr = Hotdog::Expression::GlobTagnameNode.new("foo*", ":") 44 | expect(optimize_n(o+1, expr).dump).to eq({ 45 | tagname_glob: "foo*", 46 | separator: ":", 47 | fallback: { 48 | query: [ 49 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 50 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 51 | "WHERE LOWER(tags.name) GLOB LOWER(?);", 52 | ].join(" "), 53 | values: ["*foo*"], 54 | }, 55 | }) 56 | end 57 | 58 | it "interprets tag glob with tagname without separator (#{o})" do 59 | expr = Hotdog::Expression::GlobHostOrTagNode.new("foo*", nil) 60 | expect(optimize_n(o+1, expr).dump).to eq({ 61 | tagname_glob: "foo*", 62 | fallback: { 63 | query: [ 64 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 65 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 66 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 67 | "WHERE LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?);", 68 | ].join(" "), 69 | values: ["*foo*", "*foo*", "*foo*"], 70 | }, 71 | }) 72 | end 73 | 74 | it "interprets tag glob with tagvalue with separator (#{o})" do 75 | expr = Hotdog::Expression::GlobTagvalueNode.new("foo*", ":") 76 | expect(optimize_n(o+1, expr).dump).to eq({ 77 | separator: ":", 78 | tagvalue_glob: "foo*", 79 | fallback: { 80 | query: [ 81 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 82 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 83 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 84 | "WHERE LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?);", 85 | ].join(" "), 86 | values: ["*foo*", "*foo*"], 87 | }, 88 | }) 89 | end 90 | 91 | it "interprets tag glob with tagvalue without separator (#{o})" do 92 | expr = Hotdog::Expression::GlobTagvalueNode.new("foo*", nil) 93 | expect(optimize_n(o+1, expr).dump).to eq({ 94 | tagvalue_glob: "foo*", 95 | fallback: { 96 | query: [ 97 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 98 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 99 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 100 | "WHERE LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?);", 101 | ].join(" "), 102 | values: ["*foo*", "*foo*"], 103 | } 104 | }) 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/optimizer/regexp_expression_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | require "parslet" 6 | 7 | describe "tag regexp expression" do 8 | 3.times do |o| 9 | it "interprets tag regexp with host (#{o})" do 10 | expr = Hotdog::Expression::RegexpHostNode.new("foo", ":") 11 | expect(optimize_n(o+1, expr).dump).to eq({ 12 | tagname_regexp: "@host", 13 | separator: ":", 14 | tagvalue_regexp: "foo", 15 | }) 16 | end 17 | 18 | it "interprets tag regexp with tagname and tagvalue (#{o})" do 19 | expr = Hotdog::Expression::RegexpTagNode.new("foo", "bar", ":") 20 | expect(optimize_n(o+1, expr).dump).to eq({ 21 | tagname_regexp: "foo", 22 | separator: ":", 23 | tagvalue_regexp: "bar", 24 | }) 25 | end 26 | 27 | it "interprets tag regexp with tagname with separator (#{o})" do 28 | expr = Hotdog::Expression::RegexpTagnameNode.new("foo", ":") 29 | expect(optimize_n(o+1, expr).dump).to eq({ 30 | tagname_regexp: "foo", 31 | separator: ":", 32 | }) 33 | end 34 | 35 | it "interprets tag regexp with tagname without separator (#{o})" do 36 | expr = Hotdog::Expression::RegexpHostOrTagNode.new("foo", nil) 37 | expect(optimize_n(o+1, expr).dump).to eq({ 38 | tagname_regexp: "foo", 39 | }) 40 | end 41 | 42 | it "interprets tag regexp with tagvalue with separator (#{o})" do 43 | expr = Hotdog::Expression::RegexpTagvalueNode.new("foo", ":") 44 | expect(optimize_n(o+1, expr).dump).to eq({ 45 | separator: ":", 46 | tagvalue_regexp: "foo", 47 | }) 48 | end 49 | 50 | it "interprets tag regexp with tagvalue without separator (#{o})" do 51 | expr = Hotdog::Expression::RegexpTagvalueNode.new("foo", nil) 52 | expect(optimize_n(o+1, expr).dump).to eq({ 53 | tagvalue_regexp: "foo", 54 | }) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/optimizer/string_expression_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | require "parslet" 6 | 7 | describe "tag expression" do 8 | 3.times do |o| 9 | it "interprets tag with host (#{o})" do 10 | expr = Hotdog::Expression::StringHostNode.new("foo", ":") 11 | expect(optimize_n(o+1, expr).dump).to eq({ 12 | tagname: "@host", 13 | separator: ":", 14 | tagvalue: "foo", 15 | fallback: { 16 | query: [ 17 | "SELECT hosts.id AS host_id FROM hosts", 18 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 19 | ].join(" "), 20 | values: ["*foo*"], 21 | }, 22 | }) 23 | end 24 | 25 | it "interprets tag with tagname and tagvalue (#{o})" do 26 | expr = Hotdog::Expression::StringTagNode.new("foo", "bar", ":") 27 | expect(optimize_n(o+1, expr).dump).to eq({ 28 | tagname: "foo", 29 | separator: ":", 30 | tagvalue: "bar", 31 | fallback: { 32 | query: [ 33 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 34 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 35 | "WHERE LOWER(tags.name) GLOB LOWER(?) AND LOWER(tags.value) GLOB LOWER(?);", 36 | ].join(" "), 37 | values: ["*foo*", "*bar*"], 38 | }, 39 | }) 40 | end 41 | 42 | it "interprets tag with tagname with separator (#{o})" do 43 | expr = Hotdog::Expression::StringTagnameNode.new("foo", ":") 44 | expect(optimize_n(o+1, expr).dump).to eq({ 45 | tagname: "foo", 46 | separator: ":", 47 | fallback: { 48 | query: [ 49 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 50 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 51 | "WHERE LOWER(tags.name) GLOB LOWER(?);", 52 | ].join(" "), 53 | values: ["*foo*"], 54 | } 55 | }) 56 | end 57 | 58 | it "interprets tag with tagname without separator (#{o})" do 59 | expr = Hotdog::Expression::StringHostOrTagNode.new("foo", nil) 60 | expect(optimize_n(o+1, expr).dump).to eq({ 61 | tagname: "foo", 62 | fallback: { 63 | query: [ 64 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 65 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 66 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 67 | "WHERE LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?);", 68 | ].join(" "), 69 | values: ["*foo*", "*foo*", "*foo*"], 70 | }, 71 | }) 72 | end 73 | 74 | it "interprets tag with tagvalue with separator (#{o})" do 75 | expr = Hotdog::Expression::StringTagvalueNode.new("foo", ":") 76 | expect(optimize_n(o+1, expr).dump).to eq({ 77 | tagvalue: "foo", 78 | separator: ":", 79 | fallback: { 80 | query: [ 81 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 82 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 83 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 84 | "WHERE LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?);", 85 | ].join(" "), 86 | values: ["*foo*", "*foo*"], 87 | }, 88 | }) 89 | end 90 | 91 | it "interprets tag with tagvalue without separator (#{o})" do 92 | expr = Hotdog::Expression::StringTagvalueNode.new("foo", nil) 93 | expect(optimize_n(o+1, expr).dump).to eq({ 94 | tagvalue: "foo", 95 | fallback: { 96 | query: [ 97 | "SELECT DISTINCT hosts_tags.host_id FROM hosts_tags", 98 | "INNER JOIN hosts ON hosts_tags.host_id = hosts.id", 99 | "INNER JOIN tags ON hosts_tags.tag_id = tags.id", 100 | "WHERE LOWER(hosts.name) GLOB LOWER(?) OR LOWER(tags.value) GLOB LOWER(?);", 101 | ].join(" "), 102 | values: ["*foo*", "*foo*"], 103 | }, 104 | }) 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/optimizer/unary_expression_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | require "parslet" 6 | 7 | describe "unary expression" do 8 | 3.times do |o| 9 | it "NOT nothing should return everything (#{o})" do 10 | expr = Hotdog::Expression::UnaryExpressionNode.new("NOT", Hotdog::Expression::NothingNode.new()) 11 | expect(optimize_n(o+1, expr).dump).to eq({ 12 | query: "SELECT id AS host_id FROM hosts;", 13 | values: [], 14 | }) 15 | end 16 | 17 | it "NOT everything should return nothing (#{o})" do 18 | expr = Hotdog::Expression::UnaryExpressionNode.new("NOT", Hotdog::Expression::EverythingNode.new()) 19 | expect(optimize_n(o+1, expr).dump).to eq({ 20 | query: "SELECT NULL AS host_id WHERE host_id NOT NULL;", 21 | values: [], 22 | }) 23 | end 24 | 25 | it "NOT NOT nothing should return nothing (#{o})" do 26 | expr = Hotdog::Expression::UnaryExpressionNode.new( 27 | "NOT", 28 | Hotdog::Expression::UnaryExpressionNode.new( 29 | "NOT", 30 | Hotdog::Expression::NothingNode.new(), 31 | ), 32 | ) 33 | expect(optimize_n(o+1, expr).dump).to eq({ 34 | query: "SELECT NULL AS host_id WHERE host_id NOT NULL;", 35 | values: [], 36 | }) 37 | end 38 | 39 | it "NOT NOT everything should return everything (#{o})" do 40 | expr = Hotdog::Expression::UnaryExpressionNode.new( 41 | "NOT", 42 | Hotdog::Expression::UnaryExpressionNode.new( 43 | "NOT", 44 | Hotdog::Expression::EverythingNode.new(), 45 | ), 46 | ) 47 | expect(optimize_n(o+1, expr).dump).to eq({ 48 | query: "SELECT id AS host_id FROM hosts;", 49 | values: [], 50 | }) 51 | end 52 | 53 | it "NOT NOT NOT nothing should return everything (#{o})" do 54 | expr = Hotdog::Expression::UnaryExpressionNode.new( 55 | "NOT", 56 | Hotdog::Expression::UnaryExpressionNode.new( 57 | "NOT", 58 | Hotdog::Expression::UnaryExpressionNode.new( 59 | "NOT", 60 | Hotdog::Expression::NothingNode.new(), 61 | ), 62 | ), 63 | ) 64 | expect(optimize_n(o+1, expr).dump).to eq({ 65 | query: "SELECT id AS host_id FROM hosts;", 66 | values: [], 67 | }) 68 | end 69 | 70 | it "NOT NOT NOT everything should return nothing (#{o})" do 71 | expr = Hotdog::Expression::UnaryExpressionNode.new( 72 | "NOT", 73 | Hotdog::Expression::UnaryExpressionNode.new( 74 | "NOT", 75 | Hotdog::Expression::UnaryExpressionNode.new( 76 | "NOT", 77 | Hotdog::Expression::EverythingNode.new(), 78 | ), 79 | ), 80 | ) 81 | expect(optimize_n(o+1, expr).dump).to eq({ 82 | query: "SELECT NULL AS host_id WHERE host_id NOT NULL;", 83 | values: [], 84 | }) 85 | end 86 | 87 | it "NOT host should return everything except the host (#{o})" do 88 | expr = Hotdog::Expression::UnaryExpressionNode.new( 89 | "NOT", 90 | Hotdog::Expression::StringHostNode.new("foo", ":"), 91 | ) 92 | expect(optimize_n(o+1, expr).dump).to eq({ 93 | query: "SELECT id AS host_id FROM hosts EXCEPT SELECT hosts.id AS host_id FROM hosts WHERE hosts.name = ?;", 94 | values: ["foo"], 95 | }) 96 | end 97 | 98 | it "NOT NOT host should return the host (#{o})" do 99 | expr = Hotdog::Expression::UnaryExpressionNode.new( 100 | "NOT", 101 | Hotdog::Expression::UnaryExpressionNode.new( 102 | "NOT", 103 | Hotdog::Expression::StringHostNode.new("foo", ":"), 104 | ), 105 | ) 106 | expect(optimize_n(o+1, expr).dump).to eq({ 107 | tagname: "@host", 108 | separator: ":", 109 | tagvalue: "foo", 110 | fallback: { 111 | query: [ 112 | "SELECT hosts.id AS host_id FROM hosts", 113 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 114 | ].join(" "), 115 | values: ["*foo*"], 116 | }, 117 | }) 118 | end 119 | 120 | it "NOT NOT NOT host should return everything except the host (#{o})" do 121 | expr = Hotdog::Expression::UnaryExpressionNode.new( 122 | "NOT", 123 | Hotdog::Expression::UnaryExpressionNode.new( 124 | "NOT", 125 | Hotdog::Expression::UnaryExpressionNode.new( 126 | "NOT", 127 | Hotdog::Expression::StringHostNode.new("foo", ":"), 128 | ), 129 | ), 130 | ) 131 | expect(optimize_n(o+1, expr).dump).to eq({ 132 | query: "SELECT id AS host_id FROM hosts EXCEPT SELECT hosts.id AS host_id FROM hosts WHERE hosts.name = ?;", 133 | values: ["foo"], 134 | }) 135 | end 136 | 137 | it "NOOP host should return the host (#{o})" do 138 | expr = Hotdog::Expression::UnaryExpressionNode.new( 139 | "NOOP", 140 | Hotdog::Expression::StringHostNode.new("foo", ":"), 141 | ) 142 | expect(optimize_n(o+1, expr).dump).to eq({ 143 | tagname: "@host", 144 | separator: ":", 145 | tagvalue: "foo", 146 | fallback: { 147 | query: [ 148 | "SELECT hosts.id AS host_id FROM hosts", 149 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 150 | ].join(" "), 151 | values: ["*foo*"], 152 | }, 153 | }) 154 | end 155 | 156 | it "NOOP NOOP host should return the host (#{o})" do 157 | expr = Hotdog::Expression::UnaryExpressionNode.new( 158 | "NOOP", 159 | Hotdog::Expression::UnaryExpressionNode.new( 160 | "NOOP", 161 | Hotdog::Expression::StringHostNode.new("foo", ":"), 162 | ), 163 | ) 164 | expect(optimize_n(o+1, expr).dump).to eq({ 165 | tagname: "@host", 166 | separator: ":", 167 | tagvalue: "foo", 168 | fallback: { 169 | query: [ 170 | "SELECT hosts.id AS host_id FROM hosts", 171 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 172 | ].join(" "), 173 | values: ["*foo*"], 174 | }, 175 | }) 176 | end 177 | 178 | it "NOOP NOOP NOOP host should return the host (#{o})" do 179 | expr = Hotdog::Expression::UnaryExpressionNode.new( 180 | "NOOP", 181 | Hotdog::Expression::UnaryExpressionNode.new( 182 | "NOOP", 183 | Hotdog::Expression::UnaryExpressionNode.new( 184 | "NOOP", 185 | Hotdog::Expression::StringHostNode.new("foo", ":"), 186 | ), 187 | ), 188 | ) 189 | expect(optimize_n(o+1, expr).dump).to eq({ 190 | tagname: "@host", 191 | separator: ":", 192 | tagvalue: "foo", 193 | fallback: { 194 | query: [ 195 | "SELECT hosts.id AS host_id FROM hosts", 196 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 197 | ].join(" "), 198 | values: ["*foo*"], 199 | }, 200 | }) 201 | end 202 | 203 | it "NOT NOOP NOOP host should return everything except the host (#{o})" do 204 | expr = Hotdog::Expression::UnaryExpressionNode.new( 205 | "NOT", 206 | Hotdog::Expression::UnaryExpressionNode.new( 207 | "NOOP", 208 | Hotdog::Expression::UnaryExpressionNode.new( 209 | "NOOP", 210 | Hotdog::Expression::StringHostNode.new("foo", ":"), 211 | ), 212 | ), 213 | ) 214 | expect(optimize_n(o+1, expr).dump).to eq({ 215 | query: "SELECT id AS host_id FROM hosts EXCEPT SELECT hosts.id AS host_id FROM hosts WHERE hosts.name = ?;", 216 | values: ["foo"], 217 | }) 218 | end 219 | 220 | it "NOOP NOT NOOP host should return everything except the host (#{o})" do 221 | expr = Hotdog::Expression::UnaryExpressionNode.new( 222 | "NOOP", 223 | Hotdog::Expression::UnaryExpressionNode.new( 224 | "NOT", 225 | Hotdog::Expression::UnaryExpressionNode.new( 226 | "NOOP", 227 | Hotdog::Expression::StringHostNode.new("foo", ":"), 228 | ), 229 | ), 230 | ) 231 | expect(optimize_n(o+1, expr).dump).to eq({ 232 | query: "SELECT id AS host_id FROM hosts EXCEPT SELECT hosts.id AS host_id FROM hosts WHERE hosts.name = ?;", 233 | values: ["foo"], 234 | }) 235 | end 236 | 237 | it "NOOP NOOP NOT host should return everything except the host (#{o})" do 238 | expr = Hotdog::Expression::UnaryExpressionNode.new( 239 | "NOOP", 240 | Hotdog::Expression::UnaryExpressionNode.new( 241 | "NOOP", 242 | Hotdog::Expression::UnaryExpressionNode.new( 243 | "NOT", 244 | Hotdog::Expression::StringHostNode.new("foo", ":"), 245 | ), 246 | ), 247 | ) 248 | expect(optimize_n(o+1, expr).dump).to eq({ 249 | query: "SELECT id AS host_id FROM hosts EXCEPT SELECT hosts.id AS host_id FROM hosts WHERE hosts.name = ?;", 250 | values: ["foo"], 251 | }) 252 | end 253 | 254 | it "NOOP NOT NOT host should return everything except the host (#{o})" do 255 | expr = Hotdog::Expression::UnaryExpressionNode.new( 256 | "NOT", 257 | Hotdog::Expression::UnaryExpressionNode.new( 258 | "NOT", 259 | Hotdog::Expression::UnaryExpressionNode.new( 260 | "NOOP", 261 | Hotdog::Expression::StringHostNode.new("foo", ":"), 262 | ), 263 | ), 264 | ) 265 | expect(optimize_n(o+1, expr).dump).to eq({ 266 | tagname: "@host", 267 | separator: ":", 268 | tagvalue: "foo", 269 | fallback: { 270 | query: [ 271 | "SELECT hosts.id AS host_id FROM hosts", 272 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 273 | ].join(" "), 274 | values: ["*foo*"], 275 | }, 276 | }) 277 | end 278 | 279 | it "NOT NOOP NOT host should return everything except the host (#{o})" do 280 | expr = Hotdog::Expression::UnaryExpressionNode.new( 281 | "NOT", 282 | Hotdog::Expression::UnaryExpressionNode.new( 283 | "NOOP", 284 | Hotdog::Expression::UnaryExpressionNode.new( 285 | "NOT", 286 | Hotdog::Expression::StringHostNode.new("foo", ":"), 287 | ), 288 | ), 289 | ) 290 | expect(optimize_n(o+1, expr).dump).to eq({ 291 | tagname: "@host", 292 | separator: ":", 293 | tagvalue: "foo", 294 | fallback: { 295 | query: [ 296 | "SELECT hosts.id AS host_id FROM hosts", 297 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 298 | ].join(" "), 299 | values: ["*foo*"], 300 | }, 301 | }) 302 | end 303 | 304 | it "NOT NOT NOOP host should return everything except the host (#{o})" do 305 | expr = Hotdog::Expression::UnaryExpressionNode.new( 306 | "NOT", 307 | Hotdog::Expression::UnaryExpressionNode.new( 308 | "NOT", 309 | Hotdog::Expression::UnaryExpressionNode.new( 310 | "NOOP", 311 | Hotdog::Expression::StringHostNode.new("foo", ":"), 312 | ), 313 | ), 314 | ) 315 | expect(optimize_n(o+1, expr).dump).to eq({ 316 | tagname: "@host", 317 | separator: ":", 318 | tagvalue: "foo", 319 | fallback: { 320 | query: [ 321 | "SELECT hosts.id AS host_id FROM hosts", 322 | "WHERE LOWER(hosts.name) GLOB LOWER(?);", 323 | ].join(" "), 324 | values: ["*foo*"], 325 | }, 326 | }) 327 | end 328 | end 329 | end 330 | -------------------------------------------------------------------------------- /spec/optparse/down_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands/down" 4 | 5 | describe "option parser for down" do 6 | let(:app) { 7 | Hotdog::Application.new 8 | } 9 | 10 | let(:cmd) { 11 | Hotdog::Commands::Down.new(app) 12 | } 13 | 14 | before(:each) do 15 | allow(app).to receive(:get_command).with("down") { 16 | cmd 17 | } 18 | end 19 | 20 | it "cannot handle subcommand options before subcommand" do 21 | expect { 22 | app.main(["--downtime", "86400", "down", "foo", "bar", "baz"]) 23 | }.to raise_error(OptionParser::InvalidOption) 24 | end 25 | 26 | it "can handle subcommand options after subcommand" do 27 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 28 | downtime: 12345, 29 | verbosity: Hotdog::VERBOSITY_NULL, 30 | )) 31 | app.main(["down", "--downtime", "12345", "foo", "bar", "baz"]) 32 | end 33 | 34 | it "can handle common options before subcommand" do 35 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 36 | downtime: 12345, 37 | verbosity: Hotdog::VERBOSITY_INFO, 38 | )) 39 | app.main(["--verbose", "down", "--downtime", "12345", "foo", "bar", "baz"]) 40 | end 41 | 42 | it "can handle common options after subcommand" do 43 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 44 | downtime: 12345, 45 | verbosity: Hotdog::VERBOSITY_INFO, 46 | )) 47 | app.main(["down", "--downtime", "12345", "--verbose", "foo", "bar", "baz"]) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/optparse/hosts_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands/hosts" 4 | 5 | describe "option parser for hosts" do 6 | let(:app) { 7 | Hotdog::Application.new 8 | } 9 | 10 | let(:cmd) { 11 | Hotdog::Commands::Hosts.new(app) 12 | } 13 | 14 | before(:each) do 15 | allow(app).to receive(:get_command).with("hosts") { 16 | cmd 17 | } 18 | end 19 | 20 | it "can handle common options before subcommand" do 21 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 22 | verbosity: Hotdog::VERBOSITY_INFO, 23 | )) 24 | app.main(["--verbose", "hosts", "foo", "bar", "baz"]) 25 | end 26 | 27 | it "can handle common options after subcommand" do 28 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 29 | verbosity: Hotdog::VERBOSITY_INFO, 30 | )) 31 | app.main(["hosts", "--verbose", "foo", "bar", "baz"]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/optparse/pssh_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands/pssh" 4 | 5 | describe "option parser for pssh" do 6 | let(:app) { 7 | Hotdog::Application.new 8 | } 9 | 10 | let(:cmd) { 11 | Hotdog::Commands::Pssh.new(app) 12 | } 13 | 14 | before(:each) do 15 | allow(app).to receive(:get_command).with("pssh") { 16 | cmd 17 | } 18 | end 19 | 20 | it "cannot handle subcommand options before subcommand" do 21 | expect { 22 | app.main(["-P", "42", "pssh", "foo", "bar", "baz"]) 23 | }.to raise_error(OptionParser::InvalidOption) 24 | end 25 | 26 | it "can handle subcommand options after subcommand" do 27 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 28 | max_parallelism: 42, 29 | verbosity: Hotdog::VERBOSITY_NULL, 30 | )) 31 | app.main(["pssh", "-P", "42", "foo", "bar", "baz"]) 32 | end 33 | 34 | it "can handle common options before subcommand" do 35 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 36 | max_parallelism: 42, 37 | verbosity: Hotdog::VERBOSITY_INFO, 38 | )) 39 | app.main(["--verbose", "pssh", "-P", "42", "foo", "bar", "baz"]) 40 | end 41 | 42 | it "can handle common options after subcommand" do 43 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 44 | max_parallelism: 42, 45 | verbosity: Hotdog::VERBOSITY_INFO, 46 | )) 47 | app.main(["pssh", "-P", "42", "--verbose", "foo", "bar", "baz"]) 48 | end 49 | 50 | it "can handle subcommand options with remote command, 1" do 51 | allow(cmd).to receive(:run).with([], a_hash_including( 52 | max_parallelism: 42, 53 | verbosity: Hotdog::VERBOSITY_INFO, 54 | )) 55 | app.main(["pssh", "-P", "42", "--verbose", "--", "foo", "bar", "baz"]) 56 | expect(cmd.remote_command).to eq("foo bar baz") 57 | end 58 | 59 | it "can handle subcommand options with remote command, 2" do 60 | allow(cmd).to receive(:run).with(["foo"], a_hash_including( 61 | max_parallelism: 42, 62 | verbosity: Hotdog::VERBOSITY_INFO, 63 | )) 64 | app.main(["pssh", "-P", "42", "--verbose", "foo", "--", "bar", "baz"]) 65 | expect(cmd.remote_command).to eq("bar baz") 66 | end 67 | 68 | it "can handle subcommand options with remote command, 3" do 69 | allow(cmd).to receive(:run).with(["foo"], a_hash_including( 70 | max_parallelism: 42, 71 | verbosity: Hotdog::VERBOSITY_INFO, 72 | )) 73 | app.main(["pssh", "-P", "42", "--verbose", "foo", "--", "bar", "--", "baz"]) 74 | expect(cmd.remote_command).to eq("bar -- baz") 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/optparse/search_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands/search" 4 | 5 | describe "option parser for search" do 6 | let(:app) { 7 | Hotdog::Application.new 8 | } 9 | 10 | let(:cmd) { 11 | Hotdog::Commands::Search.new(app) 12 | } 13 | 14 | before(:each) do 15 | allow(app).to receive(:get_command).with("search") { 16 | cmd 17 | } 18 | end 19 | 20 | it "can handle common options before subcommand" do 21 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 22 | verbosity: Hotdog::VERBOSITY_INFO, 23 | )) 24 | app.main(["--verbose", "search", "foo", "bar", "baz"]) 25 | end 26 | 27 | it "can handle common options after subcommand" do 28 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 29 | verbosity: Hotdog::VERBOSITY_INFO, 30 | )) 31 | app.main(["search", "--verbose", "foo", "bar", "baz"]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/optparse/ssh_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands/ssh" 4 | 5 | describe "option parser for ssh" do 6 | let(:app) { 7 | Hotdog::Application.new 8 | } 9 | 10 | let(:cmd) { 11 | Hotdog::Commands::Ssh.new(app) 12 | } 13 | 14 | before(:each) do 15 | allow(app).to receive(:get_command).with("ssh") { 16 | cmd 17 | } 18 | end 19 | 20 | it "cannot handle subcommand options before subcommand" do 21 | expect { 22 | app.main(["--index", "42", "ssh", "foo", "bar", "baz"]) 23 | }.to raise_error(OptionParser::InvalidOption) 24 | end 25 | 26 | it "can handle subcommand options after subcommand" do 27 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 28 | index: 42, 29 | verbosity: Hotdog::VERBOSITY_NULL, 30 | )) 31 | app.main(["ssh", "--index", "42", "foo", "bar", "baz"]) 32 | end 33 | 34 | it "can handle common options before subcommand" do 35 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 36 | index: 42, 37 | verbosity: Hotdog::VERBOSITY_INFO, 38 | )) 39 | app.main(["--verbose", "ssh", "--index", "42", "foo", "bar", "baz"]) 40 | end 41 | 42 | it "can handle common options after subcommand" do 43 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 44 | index: 42, 45 | verbosity: Hotdog::VERBOSITY_INFO, 46 | )) 47 | app.main(["ssh", "--index", "42", "--verbose", "foo", "bar", "baz"]) 48 | end 49 | 50 | it "can handle subcommand options with remote command, 1" do 51 | allow(cmd).to receive(:run).with([], a_hash_including( 52 | index: 42, 53 | verbosity: Hotdog::VERBOSITY_INFO, 54 | )) 55 | app.main(["ssh", "--index", "42", "--verbose", "--", "foo", "bar", "baz"]) 56 | expect(cmd.remote_command).to eq("foo bar baz") 57 | end 58 | 59 | it "can handle subcommand options with remote command, 2" do 60 | allow(cmd).to receive(:run).with(["foo"], a_hash_including( 61 | index: 42, 62 | verbosity: Hotdog::VERBOSITY_INFO, 63 | )) 64 | app.main(["ssh", "--index", "42", "--verbose", "foo", "--", "bar", "baz"]) 65 | expect(cmd.remote_command).to eq("bar baz") 66 | end 67 | 68 | it "can handle subcommand options with remote command, 3" do 69 | allow(cmd).to receive(:run).with(["foo"], a_hash_including( 70 | index: 42, 71 | verbosity: Hotdog::VERBOSITY_INFO, 72 | )) 73 | app.main(["ssh", "--index", "42", "--verbose", "foo", "--", "bar", "--", "baz"]) 74 | expect(cmd.remote_command).to eq("bar -- baz") 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/optparse/tags_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands/tags" 4 | 5 | describe "option parser for tags" do 6 | let(:app) { 7 | Hotdog::Application.new 8 | } 9 | 10 | let(:cmd) { 11 | Hotdog::Commands::Tags.new(app) 12 | } 13 | 14 | before(:each) do 15 | allow(app).to receive(:get_command).with("tags") { 16 | cmd 17 | } 18 | end 19 | 20 | it "can handle common options before subcommand" do 21 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 22 | verbosity: Hotdog::VERBOSITY_INFO, 23 | )) 24 | app.main(["--verbose", "tags", "foo", "bar", "baz"]) 25 | end 26 | 27 | it "can handle common options after subcommand" do 28 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 29 | verbosity: Hotdog::VERBOSITY_INFO, 30 | )) 31 | app.main(["tags", "--verbose", "foo", "bar", "baz"]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/optparse/up_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands/up" 4 | 5 | describe "option parser for up" do 6 | let(:app) { 7 | Hotdog::Application.new 8 | } 9 | 10 | let(:cmd) { 11 | Hotdog::Commands::Up.new(app) 12 | } 13 | 14 | before(:each) do 15 | allow(app).to receive(:get_command).with("up") { 16 | cmd 17 | } 18 | end 19 | 20 | it "can handle common options before subcommand" do 21 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 22 | verbosity: Hotdog::VERBOSITY_INFO, 23 | )) 24 | app.main(["--verbose", "up", "foo", "bar", "baz"]) 25 | end 26 | 27 | it "can handle common options after subcommand" do 28 | allow(cmd).to receive(:run).with(["foo", "bar", "baz"], a_hash_including( 29 | verbosity: Hotdog::VERBOSITY_INFO, 30 | )) 31 | app.main(["up", "--verbose", "foo", "bar", "baz"]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/parser/parser_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "hotdog/application" 3 | require "hotdog/commands" 4 | require "hotdog/commands/search" 5 | require "parslet" 6 | 7 | describe "parser" do 8 | let(:cmd) { 9 | Hotdog::Commands::Search.new(Hotdog::Application.new) 10 | } 11 | 12 | before(:each) do 13 | ENV["DATADOG_API_KEY"] = "DATADOG_API_KEY" 14 | ENV["DATADOG_APPLICATION_KEY"] = "DATADOG_APPLICATION_KEY" 15 | end 16 | 17 | it "parses ':foo'" do 18 | expect(cmd.parse(":foo")).to eq({separator: ":", tagvalue: "foo"}) 19 | end 20 | 21 | it "parses ':foo*'" do 22 | expect(cmd.parse(":foo*")).to eq({separator: ":", tagvalue_glob: "foo*"}) 23 | end 24 | 25 | it "parses ':/foo/'" do 26 | expect(cmd.parse(":/foo/")).to eq({separator: ":", tagvalue_regexp: "/foo/"}) 27 | end 28 | 29 | it "parses 'foo'" do 30 | expect(cmd.parse("foo")).to eq({tagname: "foo"}) 31 | end 32 | 33 | it "parses 'foo:bar'" do 34 | expect(cmd.parse("foo:bar")).to eq({tagname: "foo", separator: ":", tagvalue: "bar"}) 35 | end 36 | 37 | it "parses 'foo: bar'" do 38 | expect(cmd.parse("foo:bar")).to eq({tagname: "foo", separator: ":", tagvalue: "bar"}) 39 | end 40 | 41 | it "parses 'foo :bar'" do 42 | expect(cmd.parse("foo:bar")).to eq({tagname: "foo", separator: ":", tagvalue: "bar"}) 43 | end 44 | 45 | it "parses 'foo : bar'" do 46 | expect(cmd.parse("foo:bar")).to eq({tagname: "foo", separator: ":", tagvalue: "bar"}) 47 | end 48 | 49 | it "parses 'foo:bar*'" do 50 | expect(cmd.parse("foo:bar*")).to eq({tagname: "foo", separator: ":", tagvalue_glob: "bar*"}) 51 | end 52 | 53 | it "parses 'foo*'" do 54 | expect(cmd.parse("foo*")).to eq({tagname_glob: "foo*"}) 55 | end 56 | 57 | it "parses 'foo*:bar'" do 58 | expect(cmd.parse("foo*:bar")).to eq({tagname_glob: "foo*", separator: ":", tagvalue: "bar"}) 59 | end 60 | 61 | it "parses 'foo*:bar*'" do 62 | expect(cmd.parse("foo*:bar*")).to eq({tagname_glob: "foo*", separator: ":", tagvalue_glob: "bar*"}) 63 | end 64 | 65 | it "parses '/foo/'" do 66 | expect(cmd.parse("/foo/")).to eq({tagname_regexp: "/foo/"}) 67 | end 68 | 69 | it "parses '/foo/:/bar/'" do 70 | expect(cmd.parse("/foo/:/bar/")).to eq({tagname_regexp: "/foo/", separator: ":", tagvalue_regexp: "/bar/"}) 71 | end 72 | 73 | it "parses '(foo)'" do 74 | expect(cmd.parse("(foo)")).to eq({tagname: "foo"}) 75 | end 76 | 77 | it "parses '( foo )'" do 78 | expect(cmd.parse("( foo )")).to eq({tagname: "foo"}) 79 | end 80 | 81 | it "parses ' ( foo ) '" do 82 | expect(cmd.parse(" ( foo ) ")).to eq({tagname: "foo"}) 83 | end 84 | 85 | it "parses '((foo))'" do 86 | expect(cmd.parse("((foo))")).to eq({tagname: "foo"}) 87 | end 88 | 89 | it "parses '(( foo ))'" do 90 | expect(cmd.parse("(( foo ))")).to eq({tagname: "foo"}) 91 | end 92 | 93 | it "parses ' ( ( foo ) ) '" do 94 | expect(cmd.parse("( ( foo ) )")).to eq({tagname: "foo"}) 95 | end 96 | 97 | it "parses 'tagname with prefix and'" do 98 | expect(cmd.parse("android")).to eq({tagname: "android"}) 99 | end 100 | 101 | it "parses 'tagname with infix and'" do 102 | expect(cmd.parse("islander")).to eq({tagname: "islander"}) 103 | end 104 | 105 | it "parses 'tagname with suffix and'" do 106 | expect(cmd.parse("mainland")).to eq({tagname: "mainland"}) 107 | end 108 | 109 | it "parses 'tagname with prefix or'" do 110 | expect(cmd.parse("oreo")).to eq({tagname: "oreo"}) 111 | end 112 | 113 | it "parses 'tagname with infix or'" do 114 | expect(cmd.parse("category")).to eq({tagname: "category"}) 115 | end 116 | 117 | it "parses 'tagname with suffix or'" do 118 | expect(cmd.parse("imperator")).to eq({tagname: "imperator"}) 119 | end 120 | 121 | it "parses 'tagname with prefix not'" do 122 | expect(cmd.parse("nothing")).to eq({tagname: "nothing"}) 123 | end 124 | 125 | it "parses 'tagname with infix not'" do 126 | expect(cmd.parse("annotation")).to eq({tagname: "annotation"}) 127 | end 128 | 129 | it "parses 'tagname with suffix not'" do 130 | expect(cmd.parse("forgetmenot")).to eq({tagname: "forgetmenot"}) 131 | end 132 | 133 | it "parses 'foo bar'" do 134 | expect(cmd.parse("foo bar")).to eq({left: {tagname: "foo"}, binary_op: nil, right: {tagname: "bar"}}) 135 | end 136 | 137 | it "parses 'foo bar baz'" do 138 | expect(cmd.parse("foo bar baz")).to eq({left: {tagname: "foo"}, binary_op: nil, right: {left: {tagname: "bar"}, binary_op: nil, right: {tagname: "baz"}}}) 139 | end 140 | 141 | it "parses 'not foo'" do 142 | expect(cmd.parse("not foo")).to eq({unary_op: "not", expression: {tagname: "foo"}}) 143 | end 144 | 145 | it "parses '! foo'" do 146 | expect(cmd.parse("! foo")).to eq({unary_op: "!", expression: {tagname: "foo"}}) 147 | end 148 | 149 | it "parses '~ foo'" do 150 | expect(cmd.parse("~ foo")).to eq({unary_op: "~", expression: {tagname: "foo"}}) 151 | end 152 | 153 | it "parses 'not(not foo)'" do 154 | expect(cmd.parse("not(not foo)")).to eq({unary_op: "not", expression: {unary_op: "not", expression: {tagname: "foo"}}}) 155 | end 156 | 157 | it "parses '!(!foo)'" do 158 | expect(cmd.parse("!(!foo)")).to eq({unary_op: "!", expression: {unary_op: "!", expression: {tagname: "foo"}}}) 159 | end 160 | 161 | it "parses '~(~foo)'" do 162 | expect(cmd.parse("~(~foo)")).to eq({unary_op: "~", expression: {unary_op: "~", expression: {tagname: "foo"}}}) 163 | end 164 | 165 | it "parses 'not not foo'" do 166 | expect(cmd.parse("not not foo")).to eq({unary_op: "not", expression: {unary_op: "not", expression: {tagname: "foo"}}}) 167 | end 168 | 169 | it "parses '!!foo'" do 170 | expect(cmd.parse("!! foo")).to eq({unary_op: "!", expression: {unary_op: "!", expression: {tagname: "foo"}}}) 171 | end 172 | 173 | it "parses '! ! foo'" do 174 | expect(cmd.parse("!! foo")).to eq({unary_op: "!", expression: {unary_op: "!", expression: {tagname: "foo"}}}) 175 | end 176 | 177 | it "parses '~~foo'" do 178 | expect(cmd.parse("~~ foo")).to eq({unary_op: "~", expression: {unary_op: "~", expression: {tagname: "foo"}}}) 179 | end 180 | 181 | it "parses '~ ~ foo'" do 182 | expect(cmd.parse("~~ foo")).to eq({unary_op: "~", expression: {unary_op: "~", expression: {tagname: "foo"}}}) 183 | end 184 | 185 | it "parses 'foo and bar'" do 186 | expect(cmd.parse("foo and bar")).to eq({left: {tagname: "foo"}, binary_op: "and", right: {tagname: "bar"}}) 187 | end 188 | 189 | it "parses 'foo and bar and baz'" do 190 | expect(cmd.parse("foo and bar and baz")).to eq({left: {tagname: "foo"}, binary_op: "and", right: {left: {tagname: "bar"}, binary_op: "and", right: {tagname: "baz"}}}) 191 | end 192 | 193 | it "parses 'foo&bar'" do 194 | expect(cmd.parse("foo&bar")).to eq({left: {tagname: "foo"}, binary_op: "&", right: {tagname: "bar"}}) 195 | end 196 | 197 | it "parses 'foo & bar'" do 198 | expect(cmd.parse("foo & bar")).to eq({left: {tagname: "foo"}, binary_op: "&", right: {tagname: "bar"}}) 199 | end 200 | 201 | it "parses 'foo&bar&baz'" do 202 | expect(cmd.parse("foo & bar & baz")).to eq({left: {tagname: "foo"}, binary_op: "&", right: {left: {tagname: "bar"}, binary_op: "&", right: {tagname: "baz"}}}) 203 | end 204 | 205 | it "parses 'foo & bar & baz'" do 206 | expect(cmd.parse("foo & bar & baz")).to eq({left: {tagname: "foo"}, binary_op: "&", right: {left: {tagname: "bar"}, binary_op: "&", right: {tagname: "baz"}}}) 207 | end 208 | 209 | it "parses 'foo&&bar'" do 210 | expect(cmd.parse("foo&&bar")).to eq({left: {tagname: "foo"}, binary_op: "&&", right: {tagname: "bar"}}) 211 | end 212 | 213 | it "parses 'foo && bar'" do 214 | expect(cmd.parse("foo && bar")).to eq({left: {tagname: "foo"}, binary_op: "&&", right: {tagname: "bar"}}) 215 | end 216 | 217 | it "parses 'foo&&bar&&baz'" do 218 | expect(cmd.parse("foo&&bar&&baz")).to eq({left: {tagname: "foo"}, binary_op: "&&", right: {left: {tagname: "bar"}, binary_op: "&&", right: {tagname: "baz"}}}) 219 | end 220 | 221 | it "parses 'foo && bar && baz'" do 222 | expect(cmd.parse("foo && bar && baz")).to eq({left: {tagname: "foo"}, binary_op: "&&", right: {left: {tagname: "bar"}, binary_op: "&&", right: {tagname: "baz"}}}) 223 | end 224 | 225 | it "parses 'foo or bar'" do 226 | expect(cmd.parse("foo or bar")).to eq({left: {tagname: "foo"}, binary_op: "or", right: {tagname: "bar"}}) 227 | end 228 | 229 | it "parses 'foo or bar or baz'" do 230 | expect(cmd.parse("foo or bar or baz")).to eq({left: {tagname: "foo"}, binary_op: "or", right: {left: {tagname: "bar"}, binary_op: "or", right: {tagname: "baz"}}}) 231 | end 232 | 233 | it "parses 'foo|bar'" do 234 | expect(cmd.parse("foo|bar")).to eq({left: {tagname: "foo"}, binary_op: "|", right: {tagname: "bar"}}) 235 | end 236 | 237 | it "parses 'foo | bar'" do 238 | expect(cmd.parse("foo | bar")).to eq({left: {tagname: "foo"}, binary_op: "|", right: {tagname: "bar"}}) 239 | end 240 | 241 | it "parses 'foo|bar|baz'" do 242 | expect(cmd.parse("foo|bar|baz")).to eq({left: {tagname: "foo"}, binary_op: "|", right: {left: {tagname: "bar"}, binary_op: "|", right: {tagname: "baz"}}}) 243 | end 244 | 245 | it "parses 'foo | bar | baz'" do 246 | expect(cmd.parse("foo | bar | baz")).to eq({left: {tagname: "foo"}, binary_op: "|", right: {left: {tagname: "bar"}, binary_op: "|", right: {tagname: "baz"}}}) 247 | end 248 | 249 | it "parses 'foo||bar'" do 250 | expect(cmd.parse("foo||bar")).to eq({left: {tagname: "foo"}, binary_op: "||", right: {tagname: "bar"}}) 251 | end 252 | 253 | it "parses 'foo || bar'" do 254 | expect(cmd.parse("foo || bar")).to eq({left: {tagname: "foo"}, binary_op: "||", right: {tagname: "bar"}}) 255 | end 256 | 257 | it "parses 'foo||bar||baz'" do 258 | expect(cmd.parse("foo||bar||baz")).to eq({left: {tagname: "foo"}, binary_op: "||", right: {left: {tagname: "bar"}, binary_op: "||", right: {tagname: "baz"}}}) 259 | end 260 | 261 | it "parses 'foo || bar || baz'" do 262 | expect(cmd.parse("foo || bar || baz")).to eq({left: {tagname: "foo"}, binary_op: "||", right: {left: {tagname: "bar"}, binary_op: "||", right: {tagname: "baz"}}}) 263 | end 264 | 265 | it "parses '(foo and bar) or baz'" do 266 | expect(cmd.parse("(foo and bar) or baz")).to eq({left: {left: {tagname: "foo"}, binary_op: "and", right: {tagname: "bar"}}, binary_op: "or", right: {tagname: "baz"}}) 267 | end 268 | 269 | it "parses 'foo and (bar or baz)'" do 270 | expect(cmd.parse("foo and (bar or baz)")).to eq({left: {tagname: "foo"}, binary_op: "and", right: {left: {tagname: "bar"}, binary_op: "or", right: {tagname: "baz"}}}) 271 | end 272 | 273 | it "parses 'not foo and bar'" do 274 | expect(cmd.parse("not foo and bar")).to eq({unary_op: "not", expression: {left: {tagname: "foo"}, binary_op: "and", right: {tagname: "bar"}}}) 275 | end 276 | 277 | it "parses '! foo and bar'" do 278 | expect(cmd.parse("! foo and bar")).to eq({left: {unary_op: "!", expression: {tagname: "foo"}}, binary_op: "and", right: {tagname: "bar"}}) 279 | end 280 | 281 | it "parses 'not foo && bar'" do 282 | expect(cmd.parse("not foo && bar")).to eq({unary_op: "not", expression: {left: {tagname: "foo"}, binary_op: "&&", right: {tagname: "bar"}}}) 283 | end 284 | 285 | it "parses '! foo && bar'" do 286 | expect(cmd.parse("! foo && bar")).to eq({left: {unary_op: "!", expression: {tagname: "foo"}}, binary_op: "&&", right: {tagname: "bar"}}) 287 | end 288 | 289 | it "parses 'f(x)'" do 290 | expect(cmd.parse("f(x)")).to eq({funcall: "f", funcall_args: {funcall_args_head: {tagname: "x"}}}) 291 | end 292 | 293 | it "parses 'f(x, \"y\")'" do 294 | expect(cmd.parse("f(x, \"y\")")).to eq({funcall: "f", funcall_args: {funcall_args_head: {tagname: "x"}, funcall_args_tail: {funcall_args_head: {string: "\"y\""}}}}) 295 | end 296 | 297 | it "parses 'f(x, \"y\", /z/)'" do 298 | expect(cmd.parse("f(x, \"y\", /z/)")).to eq({funcall: "f", funcall_args: {funcall_args_head: {tagname: "x"}, funcall_args_tail: {funcall_args_head: {string: "\"y\""}, funcall_args_tail: {funcall_args_head: {regexp: "/z/"}}}}}) 299 | end 300 | 301 | it "parses 'g ( 12345 )'" do 302 | expect(cmd.parse("g ( 12345 )")).to eq({funcall: "g", funcall_args: {funcall_args_head: {integer: "12345"}}}) 303 | end 304 | 305 | it "parses 'g ( 12345 , 3.1415 )'" do 306 | expect(cmd.parse("g ( 12345 , 3.1415 )")).to eq({funcall: "g", funcall_args: {funcall_args_head: {integer: "12345"}, funcall_args_tail: {funcall_args_head: {float: "3.1415"}}}}) 307 | end 308 | 309 | it "parses 'f()'" do 310 | expect(cmd.parse("f()")).to eq({funcall: "f"}) 311 | end 312 | 313 | it "parses 'g(f())'" do 314 | expect(cmd.parse("g(f())")).to eq({funcall: "g", funcall_args: {funcall_args_head: {funcall: "f"}}}) 315 | end 316 | 317 | it "parses 'foo and bar(y)'" do 318 | expect(cmd.parse("foo and bar(y)")).to eq({binary_op: "and", left: {tagname: "foo"}, right: {funcall: "bar", funcall_args: {funcall_args_head: {tagname: "y"}}}}) 319 | end 320 | 321 | it "parses 'foo(x) and bar(y)'" do 322 | expect(cmd.parse("foo(x) and bar(y)")).to eq({binary_op: "and", left: {funcall: "foo", funcall_args: {funcall_args_head: {tagname: "x"}}}, right: {funcall: "bar", funcall_args: {funcall_args_head: {tagname: "y"}}}}) 323 | end 324 | 325 | it "is unable to parse ' '" do 326 | expect { 327 | cmd.parse(" ") 328 | }.to raise_error(Parslet::ParseFailed) 329 | end 330 | 331 | it "is unable to parse '((()))'" do 332 | expect { 333 | cmd.parse("((()))") 334 | }.to raise_error(Parslet::ParseFailed) 335 | end 336 | 337 | it "is unable to parse 'foo and'" do 338 | expect { 339 | cmd.parse("foo and") 340 | }.to raise_error(Parslet::ParseFailed) 341 | end 342 | 343 | it "is unable to parse 'foo &'" do 344 | expect { 345 | cmd.parse("foo &") 346 | }.to raise_error(Parslet::ParseFailed) 347 | end 348 | 349 | it "is unable to parse 'foo &&'" do 350 | expect { 351 | cmd.parse("foo &&") 352 | }.to raise_error(Parslet::ParseFailed) 353 | end 354 | 355 | it "is unable to parse 'and foo'" do 356 | expect { 357 | cmd.parse("and foo") 358 | }.to raise_error(Parslet::ParseFailed) 359 | end 360 | 361 | it "is unable to parse '& foo'" do 362 | expect { 363 | cmd.parse("& foo") 364 | }.to raise_error(Parslet::ParseFailed) 365 | end 366 | 367 | it "is unable to parse '&& foo'" do 368 | expect { 369 | cmd.parse("&& foo") 370 | }.to raise_error(Parslet::ParseFailed) 371 | end 372 | end 373 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | 3 | def optimize_n(n, x, options={}) 4 | n.times.reduce(x) { |x, _| 5 | x.optimize(options) 6 | } 7 | end 8 | --------------------------------------------------------------------------------