diff --git a/README.md b/README.md index 9c3269e..486ee65 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ floaty get centos-7-x86_64=2 debian-7-x86_64 windows-10=3 --token mytokenstring If you do not wish to continuely specify various config options with the cli, you can have a dotfile in your home directory for some defaults. For example: +#### Basic configuration + ```yaml -#file at /Users/me/.vmfloaty.yml +# file at /Users/me/.vmfloaty.yml url: 'https://vmpooler.mycompany.net/api/v1' user: 'brian' token: 'tokenstring' @@ -77,6 +79,66 @@ token: 'tokenstring' Now vmfloaty will use those config files if no flag was specified. +#### Configuring multiple services + +Most commands allow you to specify a `--service ` option to allow the use of multiple vmpooler instances. This can be useful when you'd rather not specify a `--url` or `--token` by hand for alternate services. + +To configure multiple services, you can set up your `~/.vmfloaty.yml` config file like this: + +```yaml +# file at /Users/me/.vmfloaty.yml +user: 'brian' +services: + main: + url: 'https://vmpooler.mycompany.net/api/v1' + token: 'tokenstring' + alternate: + url: 'https://vmpooler.alternate.net/api/v1' + token: 'alternate-tokenstring' +``` + +- If you run `floaty` without a `--service ` option, vmfloaty will use the first configured service by default. + With the config file above, the default would be to use the 'main' vmpooler instance. +- If keys are missing for a configured service, vmfloaty will attempt to fall back to the top-level values. + With the config file above, 'brian' will be used as the username for both configured services, since neither specifies a username. + +Examples using the above configuration: + +List available vm types from our main vmpooler instance: +```sh +floaty list --service main +# or, since the first configured service is used by default: +floaty list +``` + +List available vm types from our alternate vmpooler instance: +```sh +floaty list --service alternate +``` + +#### Using a Nonstandard Pooler service + +vmfloaty is capable of working with Puppet's [nonstandard pooler](https://github.com/puppetlabs/nspooler) in addition to the default vmpooler API. To add a nonstandard pooler service, specify an API `type` value in your service configuration, like this: + +```yaml +# file at /Users/me/.vmfloaty.yml +user: 'brian' +services: + vm: + url: 'https://vmpooler.mycompany.net/api/v1' + token: 'tokenstring' + ns: + url: 'https://nspooler.mycompany.net/api/v1' + token: 'nspooler-tokenstring' + type: 'nonstandard' # <-- 'type' is necessary for any non-vmpooler service +``` + +With this configuration, you could list available OS types from nspooler like this: + +```sh +floaty list --service ns +``` + #### Valid config keys Here are the keys that vmfloaty currently supports: @@ -89,6 +151,8 @@ Here are the keys that vmfloaty currently supports: + String - url + String +- services + + Map ### Tab Completion diff --git a/lib/vmfloaty.rb b/lib/vmfloaty.rb index e0fbce4..5056d4f 100644 --- a/lib/vmfloaty.rb +++ b/lib/vmfloaty.rb @@ -5,11 +5,13 @@ require 'commander' require 'colorize' require 'json' require 'pp' +require 'uri' require 'vmfloaty/auth' require 'vmfloaty/pooler' require 'vmfloaty/version' require 'vmfloaty/conf' require 'vmfloaty/utils' +require 'vmfloaty/service' require 'vmfloaty/ssh' class Vmfloaty @@ -17,27 +19,26 @@ class Vmfloaty def run program :version, Vmfloaty::VERSION - program :description, 'A CLI helper tool for Puppet Labs vmpooler to help you stay afloat' + program :description, 'A CLI helper tool for Puppet Labs VM poolers to help you stay afloat' config = Conf.read_config command :get do |c| c.syntax = 'floaty get os_type0 os_type1=x ox_type2=y [options]' c.summary = 'Gets a vm or vms based on the os argument' - c.description = 'A command to retrieve vms from vmpooler. Can either be a single vm, or multiple with the `=` syntax.' + c.description = 'A command to retrieve vms from a pooler service. Can either be a single vm, or multiple with the `=` syntax.' c.example 'Gets a few vms', 'floaty get centos=3 debian --user brian --url http://vmpooler.example.com' c.option '--verbose', 'Enables verbose output' + c.option '--service STRING', String, 'Configured pooler service name' c.option '--user STRING', String, 'User to authenticate with' - c.option '--url STRING', String, 'URL of vmpooler' - c.option '--token STRING', String, 'Token for vmpooler' + c.option '--url STRING', String, 'URL of pooler service' + c.option '--token STRING', String, 'Token for pooler service' c.option '--notoken', 'Makes a request without a token' c.option '--force', 'Forces vmfloaty to get requested vms' c.action do |args, options| verbose = options.verbose || config['verbose'] - token = options.token || config['token'] - user = options.user ||= config['user'] - url = options.url ||= config['url'] - no_token = options.notoken + service = Service.new(options, config) + use_token = !options.notoken force = options.force if args.empty? @@ -48,98 +49,51 @@ class Vmfloaty os_types = Utils.generate_os_hash(args) max_pool_request = 5 - large_pool_requests = os_types.select{|k,v| v > max_pool_request} + large_pool_requests = os_types.select{|_,v| v > max_pool_request} if ! large_pool_requests.empty? and ! force STDERR.puts "Requesting vms over #{max_pool_request} requires a --force flag." STDERR.puts "Try again with `floaty get --force`" exit 1 end - unless os_types.empty? - if no_token - begin - response = Pooler.retrieve(verbose, os_types, nil, url) - rescue MissingParamError - STDERR.puts e - STDERR.puts "See `floaty get --help` for more information on how to get VMs." - rescue AuthError => e - STDERR.puts e - exit 1 - end - puts Utils.format_hosts(response) - exit 0 - else - unless token - puts "No token found. Retrieving a token..." - if !user - STDERR.puts "You did not provide a user to authenticate to vmpooler with" - exit 1 - end - pass = password "Enter your vmpooler password please:", '*' - begin - token = Auth.get_token(verbose, url, user, pass) - rescue TokenError => e - STDERR.puts e - exit 1 - end - - puts "\nToken retrieved!" - puts token - end - - begin - response = Pooler.retrieve(verbose, os_types, token, url) - rescue MissingParamError - STDERR.puts e - STDERR.puts "See `floaty get --help` for more information on how to get VMs." - rescue AuthError => e - STDERR.puts e - exit 1 - end - puts Utils.format_hosts(response) - exit 0 - end - else + if os_types.empty? STDERR.puts "No operating systems provided to obtain. See `floaty get --help` for more information on how to get VMs." exit 1 end + + response = service.retrieve(verbose, os_types, use_token) + puts Utils.format_hosts(response) end end command :list do |c| c.syntax = 'floaty list [options]' c.summary = 'Shows a list of available vms from the pooler or vms obtained with a token' - c.description = 'List will either show all vm templates available in vmpooler, or with the --active flag it will list vms obtained with a vmpooler token.' + c.description = 'List will either show all vm templates available in pooler service, or with the --active flag it will list vms obtained with a pooler service token.' c.example 'Filter the list on centos', 'floaty list centos --url http://vmpooler.example.com' c.option '--verbose', 'Enables verbose output' + c.option '--service STRING', String, 'Configured pooler service name' c.option '--active', 'Prints information about active vms for a given token' - c.option '--token STRING', String, 'Token for vmpooler' - c.option '--url STRING', String, 'URL of vmpooler' + c.option '--token STRING', String, 'Token for pooler service' + c.option '--url STRING', String, 'URL of pooler service' c.action do |args, options| verbose = options.verbose || config['verbose'] + service = Service.new(options, config) filter = args[0] - url = options.url ||= config['url'] - token = options.token || config['token'] - active = options.active - if active + if options.active # list active vms - begin - running_vms = Utils.get_all_token_vms(verbose, url, token) - rescue TokenError => e - STDERR.puts e - exit 1 - rescue Exception => e - STDERR.puts e - exit 1 - end - - if ! running_vms.nil? - Utils.prettyprint_hosts(running_vms, verbose, url) + running_vms = service.list_active(verbose) + host = URI.parse(service.url).host + if running_vms.empty? + puts "You have no running VMs on #{host}" + else + puts "Your VMs on #{host}:" + Utils.pretty_print_hosts(verbose, service, running_vms) end else # list available vms from pooler - os_list = Pooler.list(verbose, url, filter) + os_list = service.list(verbose, filter) puts os_list end end @@ -148,139 +102,74 @@ class Vmfloaty command :query do |c| c.syntax = 'floaty query hostname [options]' c.summary = 'Get information about a given vm' - c.description = 'Given a hostname from vmpooler, vmfloaty with query vmpooler to get various details about the vm.' + c.description = 'Given a hostname from the pooler service, vmfloaty with query the service to get various details about the vm.' c.example 'Get information about a sample host', 'floaty query hostname --url http://vmpooler.example.com' c.option '--verbose', 'Enables verbose output' - c.option '--url STRING', String, 'URL of vmpooler' + c.option '--service STRING', String, 'Configured pooler service name' + c.option '--url STRING', String, 'URL of pooler service' c.action do |args, options| verbose = options.verbose || config['verbose'] - url = options.url ||= config['url'] + service = Service.new(options, config) hostname = args[0] - query_req = Pooler.query(verbose, url, hostname) + query_req = service.query(verbose, hostname) pp query_req end end command :modify do |c| c.syntax = 'floaty modify hostname [options]' - c.summary = 'Modify a vms tags, time to live, and disk space' - c.description = 'This command makes modifications to the virtual machines state in vmpooler. You can either append tags to the vm, increase how long it stays active for, or increase the amount of disk space.' + c.summary = 'Modify a VM\'s tags, time to live, disk space, or reservation reason' + c.description = 'This command makes modifications to the virtual machines state in the pooler service. You can either append tags to the vm, increase how long it stays active for, or increase the amount of disk space.' c.example 'Modifies myhost1 to have a TTL of 12 hours and adds a custom tag', 'floaty modify myhost1 --lifetime 12 --url https://myurl --token mytokenstring --tags \'{"tag":"myvalue"}\'' c.option '--verbose', 'Enables verbose output' - c.option '--url STRING', String, 'URL of vmpooler' - c.option '--token STRING', String, 'Token for vmpooler' - c.option '--lifetime INT', Integer, 'VM TTL (Integer, in hours)' - c.option '--disk INT', Integer, 'Increases VM disk space (Integer, in gb)' - c.option '--tags STRING', String, 'free-form VM tagging (json)' + c.option '--service STRING', String, 'Configured pooler service name' + c.option '--url STRING', String, 'URL of pooler service' + c.option '--token STRING', String, 'Token for pooler service' + c.option '--lifetime INT', Integer, 'VM TTL (Integer, in hours) [vmpooler only]' + c.option '--disk INT', Integer, 'Increases VM disk space (Integer, in gb) [vmpooler only]' + c.option '--tags STRING', String, 'free-form VM tagging (json) [vmpooler only]' + c.option '--reason STRING', String, 'VM reservation reason [nspooler only]' c.option '--all', 'Modifies all vms acquired by a token' c.action do |args, options| verbose = options.verbose || config['verbose'] - url = options.url ||= config['url'] + service = Service.new(options, config) hostname = args[0] - lifetime = options.lifetime - disk = options.disk - tags = JSON.parse(options.tags) if options.tags - token = options.token || config['token'] modify_all = options.all - running_vms = nil - - if modify_all - begin - running_vms = Utils.get_all_token_vms(verbose, url, token) - rescue Exception => e - STDERR.puts e - end - elsif hostname.include? "," - running_vms = hostname.split(",") + if hostname.nil? and !modify_all + STDERR.puts "ERROR: Provide a hostname or specify --all." + exit 1 end + running_vms = modify_all ? service.list_active(verbose) : hostname.split(",") - if lifetime || tags - # all vms - if !running_vms.nil? + tags = options.tags ? JSON.parse(options.tags) : nil + modify_hash = { + lifetime: options.lifetime, + disk: options.disk, + tags: tags, + reason: options.reason + } + modify_hash.delete_if { |_, value| value.nil? } + + unless modify_hash.empty? + ok = true + modified_hash = {} + running_vms.each do |vm| begin - modify_hash = {} - modify_flag = true - - running_vms.each do |vm| - modify_hash[vm] = Pooler.modify(verbose, url, vm, token, lifetime, tags) - end - - modify_hash.each do |hostname,status| - if status == false - STDERR.puts "Could not modify #{hostname}." - modify_flag = false - end - end - - if modify_flag - puts "Successfully modified all vms. Use `floaty list --active` to see the results." - end - rescue Exception => e + modified_hash[vm] = service.modify(verbose, vm, modify_hash) + rescue ModifyError => e STDERR.puts e - exit 1 - end - else - # Single Vm - begin - modify_req = Pooler.modify(verbose, url, hostname, token, lifetime, tags) - rescue TokenError => e - STDERR.puts e - exit 1 - end - - if modify_req["ok"] - puts "Successfully modified vm #{hostname}." - else - STDERR.puts "Could not modify given host #{hostname} at #{url}." - puts modify_req - exit 1 + ok = false end end - end - - if disk - # all vms - if !running_vms.nil? - begin - modify_hash = {} - modify_flag = true - - running_vms.each do |vm| - modify_hash[vm] = Pooler.disk(verbose, url, vm, token, disk) - end - - modify_hash.each do |hostname,status| - if status == false - STDERR.puts "Could not update disk space on #{hostname}." - modify_flag = false - end - end - - if modify_flag - puts "Successfully made request to update disk space on all vms." - end - rescue Exception => e - STDERR.puts e - exit 1 - end - else - # single vm - begin - disk_req = Pooler.disk(verbose, url, hostname, token, disk) - rescue TokenError => e - STDERR.puts e - exit 1 - end - - if disk_req["ok"] - puts "Successfully made request to update disk space of vm #{hostname}." + if ok + if modify_all + puts "Successfully modified all VMs." else - STDERR.puts "Could not modify given host #{hostname} at #{url}." - puts disk_req - exit 1 + puts "Successfully modified VM #{hostname}." end + puts "Use `floaty list --active` to see the results." end end end @@ -289,95 +178,99 @@ class Vmfloaty command :delete do |c| c.syntax = 'floaty delete hostname,hostname2 [options]' c.summary = 'Schedules the deletion of a host or hosts' - c.description = 'Given a comma separated list of hostnames, or --all for all vms, vmfloaty makes a request to vmpooler to schedule the deletion of those vms.' + c.description = 'Given a comma separated list of hostnames, or --all for all vms, vmfloaty makes a request to the pooler service to schedule the deletion of those vms.' c.example 'Schedules the deletion of a host or hosts', 'floaty delete myhost1,myhost2 --url http://vmpooler.example.com' c.option '--verbose', 'Enables verbose output' + c.option '--service STRING', String, 'Configured pooler service name' c.option '--all', 'Deletes all vms acquired by a token' c.option '-f', 'Does not prompt user when deleting all vms' - c.option '--token STRING', String, 'Token for vmpooler' - c.option '--url STRING', String, 'URL of vmpooler' + c.option '--token STRING', String, 'Token for pooler service' + c.option '--url STRING', String, 'URL of pooler service' c.action do |args, options| verbose = options.verbose || config['verbose'] + service = Service.new(options, config) hostnames = args[0] - token = options.token || config['token'] - url = options.url ||= config['url'] delete_all = options.all force = options.f + failures = [] + successes = [] + if delete_all - # get vms with token - begin - running_vms = Utils.get_all_token_vms(verbose, url, token) - rescue TokenError => e - STDERR.puts e - exit 1 - rescue Exception => e - STDERR.puts e - exit 1 - end - - if ! running_vms.nil? - Utils.prettyprint_hosts(running_vms, verbose, url) - # query y/n + running_vms = service.list_active(verbose) + if running_vms.empty? + STDERR.puts "You have no running VMs." + else + Utils.pretty_print_hosts(verbose, service, running_vms) + # Confirm deletion puts - - if force - ans = true - else - ans = agree("Delete all VMs associated with token #{token}? [y/N]") + confirmed = true + unless force + confirmed = agree('Delete all these VMs? [y/N]') end - - if ans - # delete vms - puts "Scheduling all vms for for deletion" - response = Pooler.delete(verbose, url, running_vms, token) - response.each do |host,vals| - if vals['ok'] == false - STDERR.puts "There was a problem with your request for vm #{host}." - STDERR.puts vals + if confirmed + response = service.delete(verbose, running_vms) + response.each do |hostname, result| + if result['ok'] + successes << hostname + else + failures << hostname end end end end - - exit 0 - end - - if hostnames.nil? + elsif hostnames || args + hostnames = hostnames.split(',') + results = service.delete(verbose, hostnames) + results.each do |hostname, result| + if result['ok'] + successes << hostname + else + failures << hostname + end + end + else STDERR.puts "You did not provide any hosts to delete" exit 1 - else - hosts = hostnames.split(',') - begin - Pooler.delete(verbose, url, hosts, token) - rescue TokenError => e - STDERR.puts e - exit 1 - end - - puts "Schedulered vmpooler to delete vms #{hosts}." - exit 0 end + + unless failures.empty? + STDERR.puts 'Unable to delete the following VMs:' + failures.each do |hostname| + STDERR.puts "- #{hostname}" + end + STDERR.puts 'Check `floaty list --active`; Do you need to specify a different service?' + end + + unless successes.empty? + puts unless failures.empty? + puts 'Scheduled the following VMs for deletion:' + successes.each do |hostname| + puts "- #{hostname}" + end + end + + exit 1 unless failures.empty? end end command :snapshot do |c| c.syntax = 'floaty snapshot hostname [options]' c.summary = 'Takes a snapshot of a given vm' - c.description = 'Will request a snapshot be taken of the given hostname in vmpooler. This command is known to take a while depending on how much load is on vmpooler.' + c.description = 'Will request a snapshot be taken of the given hostname in the pooler service. This command is known to take a while depending on how much load is on the pooler service.' c.example 'Takes a snapshot for a given host', 'floaty snapshot myvm.example.com --url http://vmpooler.example.com --token a9znth9dn01t416hrguu56ze37t790bl' c.option '--verbose', 'Enables verbose output' - c.option '--url STRING', String, 'URL of vmpooler' - c.option '--token STRING', String, 'Token for vmpooler' + c.option '--service STRING', String, 'Configured pooler service name' + c.option '--url STRING', String, 'URL of pooler service' + c.option '--token STRING', String, 'Token for pooler service' c.action do |args, options| verbose = options.verbose || config['verbose'] - url = options.url ||= config['url'] + service = Service.new(options, config) hostname = args[0] - token = options.token ||= config['token'] begin - snapshot_req = Pooler.snapshot(verbose, url, hostname, token) - rescue TokenError => e + snapshot_req = service.snapshot(verbose, hostname) + rescue TokenError, ModifyError => e STDERR.puts e exit 1 end @@ -390,17 +283,17 @@ class Vmfloaty command :revert do |c| c.syntax = 'floaty revert hostname snapshot [options]' c.summary = 'Reverts a vm to a specified snapshot' - c.description = 'Given a snapshot SHA, vmfloaty will request a revert to vmpooler to go back to a previous snapshot.' + c.description = 'Given a snapshot SHA, vmfloaty will request a revert to the pooler service to go back to a previous snapshot.' c.example 'Reverts to a snapshot for a given host', 'floaty revert myvm.example.com n4eb4kdtp7rwv4x158366vd9jhac8btq --url http://vmpooler.example.com --token a9znth9dn01t416hrguu56ze37t790bl' c.option '--verbose', 'Enables verbose output' - c.option '--url STRING', String, 'URL of vmpooler' - c.option '--token STRING', String, 'Token for vmpooler' + c.option '--service STRING', String, 'Configured pooler service name' + c.option '--url STRING', String, 'URL of pooler service' + c.option '--token STRING', String, 'Token for pooler service' c.option '--snapshot STRING', String, 'SHA of snapshot' c.action do |args, options| verbose = options.verbose || config['verbose'] - url = options.url ||= config['url'] + service = Service.new(options, config) hostname = args[0] - token = options.token || config['token'] snapshot_sha = args[1] || options.snapshot if args[1] && options.snapshot @@ -408,8 +301,8 @@ class Vmfloaty end begin - revert_req = Pooler.revert(verbose, url, hostname, token, snapshot_sha) - rescue TokenError => e + revert_req = service.revert(verbose, hostname, snapshot_sha) + rescue TokenError, ModifyError => e STDERR.puts e exit 1 end @@ -420,42 +313,37 @@ class Vmfloaty command :status do |c| c.syntax = 'floaty status [options]' - c.summary = 'Prints the status of pools in vmpooler' - c.description = 'Makes a request to vmpooler to request the information about vm pools and how many are ready to be used, what pools are empty, etc.' - c.example 'Gets the current vmpooler status', 'floaty status --url http://vmpooler.example.com' + c.summary = 'Prints the status of pools in the pooler service' + c.description = 'Makes a request to the pooler service to request the information about vm pools and how many are ready to be used, what pools are empty, etc.' + c.example 'Gets the current pooler service status', 'floaty status --url http://vmpooler.example.com' c.option '--verbose', 'Enables verbose output' - c.option '--url STRING', String, 'URL of vmpooler' + c.option '--service STRING', String, 'Configured pooler service name' + c.option '--url STRING', String, 'URL of pooler service' c.option '--json', 'Prints status in JSON format' - c.action do |args, options| + c.action do |_, options| verbose = options.verbose || config['verbose'] - url = options.url ||= config['url'] - - status = Pooler.status(verbose, url) - message = status['status']['message'] - pools = status['pools'] - + service = Service.new(options, config) if options.json - pp status + pp service.status(verbose) else - Utils.prettyprint_status(status, message, pools, verbose) + Utils.pretty_print_status(verbose, service) end - - exit status['status']['ok'] end end command :summary do |c| c.syntax = 'floaty summary [options]' - c.summary = 'Prints a summary of vmpooler' - c.description = 'Gives a very detailed summary of information related to vmpooler.' - c.example 'Gets the current day summary of vmpooler', 'floaty summary --url http://vmpooler.example.com' + c.summary = 'Prints a summary of a pooler service' + c.description = 'Gives a very detailed summary of information related to the pooler service.' + c.example 'Gets the current day summary of the pooler service', 'floaty summary --url http://vmpooler.example.com' c.option '--verbose', 'Enables verbose output' - c.option '--url STRING', String, 'URL of vmpooler' - c.action do |args, options| + c.option '--service STRING', String, 'Configured pooler service name' + c.option '--url STRING', String, 'URL of pooler service' + c.action do |_, options| verbose = options.verbose || config['verbose'] - url = options.url ||= config['url'] + service = Service.new(options, config) - summary = Pooler.summary(verbose, url) + summary = service.summary(verbose) pp summary exit 0 end @@ -464,54 +352,45 @@ class Vmfloaty command :token do |c| c.syntax = 'floaty token [options]' c.summary = 'Retrieves or deletes a token or checks token status' - c.description = 'This command is used to manage your vmpooler token. Through the various options, you are able to get a new token, delete an existing token, and request a tokens status.' + c.description = 'This command is used to manage your pooler service token. Through the various options, you are able to get a new token, delete an existing token, and request a tokens status.' c.example 'Gets a token from the pooler', 'floaty token get' c.option '--verbose', 'Enables verbose output' - c.option '--url STRING', String, 'URL of vmpooler' + c.option '--service STRING', String, 'Configured pooler service name' + c.option '--url STRING', String, 'URL of pooler service' c.option '--user STRING', String, 'User to authenticate with' - c.option '--token STRING', String, 'Token for vmpooler' + c.option '--token STRING', String, 'Token for pooler service' c.action do |args, options| verbose = options.verbose || config['verbose'] + service = Service.new(options, config) action = args.first - url = options.url ||= config['url'] - token = args[1] ||= options.token ||= config['token'] - user = options.user ||= config['user'] - case action - when "get" - pass = password "Enter your vmpooler password please:", '*' - begin - token = Auth.get_token(verbose, url, user, pass) - rescue TokenError => e - STDERR.puts e - exit 1 + begin + case action + when 'get' + token = service.get_new_token(verbose) + puts token + when 'delete' + result = service.delete_token(verbose, options.token) + puts result + when 'status' + token_value = options.token + if token_value.nil? + token_value = args[1] + end + status = service.token_status(verbose, token_value) + puts status + when nil + STDERR.puts 'No action provided' + exit 1 + else + STDERR.puts "Unknown action: #{action}" + exit 1 end - puts token - exit 0 - when "delete" - pass = password "Enter your vmpooler password please:", '*' - begin - result = Auth.delete_token(verbose, url, user, pass, token) - rescue TokenError => e - STDERR.puts e - exit 1 - end - puts result - exit 0 - when "status" - begin - status = Auth.token_status(verbose, url, token) - rescue TokenError => e - STDERR.puts e - exit 1 - end - puts status - exit 0 - when nil - STDERR.puts "No action provided" - else - STDERR.puts "Unknown action: #{action}" + rescue TokenError => e + STDERR.puts e + exit 1 end + exit 0 end end @@ -521,16 +400,15 @@ class Vmfloaty c.description = 'This command simply will grab a vm template that was requested, and then ssh the user into the machine all at once.' c.example 'SSHs into a centos vm', 'floaty ssh centos7 --url https://vmpooler.example.com' c.option '--verbose', 'Enables verbose output' - c.option '--url STRING', String, 'URL of vmpooler' + c.option '--service STRING', String, 'Configured pooler service name' + c.option '--url STRING', String, 'URL of pooler service' c.option '--user STRING', String, 'User to authenticate with' - c.option '--token STRING', String, 'Token for vmpooler' + c.option '--token STRING', String, 'Token for pooler service' c.option '--notoken', 'Makes a request without a token' c.action do |args, options| verbose = options.verbose || config['verbose'] - url = options.url ||= config['url'] - token = options.token ||= config['token'] - user = options.user ||= config['user'] - no_token = options.notoken + service = Service.new(options, config) + use_token = !options.notoken if args.empty? STDERR.puts "No operating systems provided to obtain. See `floaty ssh --help` for more information on how to get VMs." @@ -539,25 +417,11 @@ class Vmfloaty host_os = args.first - if !no_token && !token - puts "No token found. Retrieving a token..." - if !user - STDERR.puts "You did not provide a user to authenticate to vmpooler with" - exit 1 - end - pass = password "Enter your vmpooler password please:", '*' - begin - token = Auth.get_token(verbose, url, user, pass) - rescue TokenError => e - STDERR.puts e - STDERR.puts 'Could not get token...requesting vm without a token anyway...' - else - puts "\nToken retrieved!" - puts token - end + if args.length > 1 + STDERR.puts "Can't ssh to multiple hosts; Using #{host_os} only..." end - Ssh.ssh(verbose, host_os, token, url) + service.ssh(verbose, host_os, use_token) exit 0 end end @@ -574,7 +438,7 @@ class Vmfloaty EOF c.example 'Gets path to bash tab completion script', 'floaty completion --shell bash' c.option '--shell STRING', String, 'Shell to request completion script for' - c.action do |args, options| + c.action do |_, options| shell = (options.shell || 'bash').downcase.strip completion_file = File.expand_path(File.join('..', '..', 'extras', 'completions', "floaty.#{shell}"), __FILE__) diff --git a/lib/vmfloaty/errors.rb b/lib/vmfloaty/errors.rb index aa3b2b8..221fa14 100644 --- a/lib/vmfloaty/errors.rb +++ b/lib/vmfloaty/errors.rb @@ -15,3 +15,9 @@ class MissingParamError < StandardError super end end + +class ModifyError < StandardError + def initialize(msg="Could not modify VM") + super + end +end diff --git a/lib/vmfloaty/nonstandard_pooler.rb b/lib/vmfloaty/nonstandard_pooler.rb new file mode 100644 index 0000000..7384db8 --- /dev/null +++ b/lib/vmfloaty/nonstandard_pooler.rb @@ -0,0 +1,135 @@ +require 'vmfloaty/errors' +require 'vmfloaty/http' +require 'faraday' +require 'json' + +class NonstandardPooler + def self.list(verbose, url, os_filter = nil) + conn = Http.get_conn(verbose, url) + + response = conn.get 'status' + response_body = JSON.parse(response.body) + os_list = response_body.keys.sort + os_list.delete 'ok' + + os_filter ? os_list.select { |i| i[/#{os_filter}/] } : os_list + end + + def self.list_active(verbose, url, token) + status = Auth.token_status(verbose, url, token) + status['reserved_hosts'] || [] + end + + def self.retrieve(verbose, os_type, token, url) + conn = Http.get_conn(verbose, url) + conn.headers['X-AUTH-TOKEN'] = token if token + + os_string = '' + os_type.each do |os, num| + num.times do |_i| + os_string << os + '+' + end + end + + os_string = os_string.chomp('+') + + if os_string.empty? + raise MissingParamError, 'No operating systems provided to obtain.' + end + + response = conn.post "host/#{os_string}" + + res_body = JSON.parse(response.body) + + if res_body['ok'] + res_body + elsif response.status == 401 + raise AuthError, "HTTP #{response.status}: The token provided could not authenticate to the pooler.\n#{res_body}" + else + raise "HTTP #{response.status}: Failed to obtain VMs from the pooler at #{url}/host/#{os_string}. #{res_body}" + end + end + + def self.modify(verbose, url, hostname, token, modify_hash) + if token.nil? + raise TokenError, 'Token provided was nil; Request cannot be made to modify VM' + end + + modify_hash.each do |key, value| + unless [:reason, :reserved_for_reason].include? key + raise ModifyError, "Configured service type does not support modification of #{key}" + end + end + + if modify_hash[:reason] + # "reason" is easier to type than "reserved_for_reason", but nspooler needs the latter + modify_hash[:reserved_for_reason] = modify_hash.delete :reason + end + + conn = Http.get_conn(verbose, url) + conn.headers['X-AUTH-TOKEN'] = token + + response = conn.put do |req| + req.url "host/#{hostname}" + req.body = modify_hash.to_json + end + + response.body.empty? ? {} : JSON.parse(response.body) + end + + def self.disk(verbose, url, hostname, token, disk) + raise ModifyError, 'Configured service type does not support modification of disk space' + end + + def self.snapshot(verbose, url, hostname, token) + raise ModifyError, 'Configured service type does not support snapshots' + end + + def self.revert(verbose, url, hostname, token, snapshot_sha) + raise ModifyError, 'Configured service type does not support snapshots' + end + + def self.delete(verbose, url, hosts, token) + if token.nil? + raise TokenError, 'Token provided was nil; Request cannot be made to delete VM' + end + + conn = Http.get_conn(verbose, url) + + conn.headers['X-AUTH-TOKEN'] = token if token + + response_body = {} + + unless hosts.is_a? Array + hosts = hosts.split(',') + end + hosts.each do |host| + response = conn.delete "host/#{host}" + res_body = JSON.parse(response.body) + response_body[host] = res_body + end + + response_body + end + + def self.status(verbose, url) + conn = Http.get_conn(verbose, url) + + response = conn.get '/status' + JSON.parse(response.body) + end + + def self.summary(verbose, url) + conn = Http.get_conn(verbose, url) + + response = conn.get '/summary' + JSON.parse(response.body) + end + + def self.query(verbose, url, hostname) + conn = Http.get_conn(verbose, url) + + response = conn.get "host/#{hostname}" + JSON.parse(response.body) + end +end diff --git a/lib/vmfloaty/pooler.rb b/lib/vmfloaty/pooler.rb index 50820b0..e81b4cd 100644 --- a/lib/vmfloaty/pooler.rb +++ b/lib/vmfloaty/pooler.rb @@ -19,6 +19,15 @@ class Pooler hosts end + def self.list_active(verbose, url, token) + status = Auth.token_status(verbose, url, token) + vms = [] + if status[token] && status[token]['vms'] + vms = status[token]['vms']['running'] + end + vms + end + def self.retrieve(verbose, os_type, token, url) # NOTE: # Developers can use `Utils.generate_os_hash` to @@ -54,25 +63,28 @@ class Pooler end end - def self.modify(verbose, url, hostname, token, lifetime, tags) + def self.modify(verbose, url, hostname, token, modify_hash) if token.nil? raise TokenError, "Token provided was nil. Request cannot be made to modify vm" end - modify_body = {} - if lifetime - modify_body['lifetime'] = lifetime - end - if tags - modify_body['tags'] = tags + modify_hash.keys.each do |key| + unless [:tags, :lifetime, :disk].include? key + raise ModifyError, "Configured service type does not support modification of #{key}." + end end conn = Http.get_conn(verbose, url) conn.headers['X-AUTH-TOKEN'] = token + if modify_hash['disk'] + disk(verbose, url, hostname, token, modify_hash['disk']) + modify_hash.delete 'disk' + end + response = conn.put do |req| req.url "vm/#{hostname}" - req.body = modify_body.to_json + req.body = modify_hash.to_json end res_body = JSON.parse(response.body) diff --git a/lib/vmfloaty/service.rb b/lib/vmfloaty/service.rb new file mode 100644 index 0000000..b2a2333 --- /dev/null +++ b/lib/vmfloaty/service.rb @@ -0,0 +1,133 @@ +require 'commander/user_interaction' +require 'commander/command' +require 'vmfloaty/utils' +require 'vmfloaty/ssh' + +class Service + + attr_reader :config + + def initialize(options, config_hash = {}) + options ||= Commander::Command::Options.new + @config = Utils.get_service_config config_hash, options + @service_object = Utils.get_service_object @config['type'] + end + + def method_missing(m, *args, &block) + if @service_object.respond_to? m + @service_object.send(m, *args, &block) + else + super + end + end + + def url + @config['url'] + end + + def type + @service_object.name + end + + def user + unless @config['user'] + puts "Enter your pooler service username:" + @config['user'] = STDIN.gets.chomp + end + @config['user'] + end + + def token + unless @config['token'] + puts "No token found. Retrieving a token..." + @config['token'] = get_new_token(nil) + end + @config['token'] + end + + def get_new_token(verbose) + username = user + pass = Commander::UI::password "Enter your pooler service password:", '*' + Auth.get_token(verbose, url, username, pass) + end + + def delete_token(verbose, token_value = @config['token']) + username = user + pass = Commander::UI::password "Enter your pooler service password:", '*' + Auth.delete_token(verbose, url, username, pass, token_value) + end + + def token_status(verbose, token_value) + token_value ||= @config['token'] + Auth.token_status(verbose, url, token_value) + end + + def list(verbose, os_filter = nil) + @service_object.list verbose, url, os_filter + end + + def list_active(verbose) + @service_object.list_active verbose, url, token + end + + def retrieve(verbose, os_types, use_token = true) + puts 'Requesting a vm without a token...' unless use_token + token_value = use_token ? token : nil + @service_object.retrieve verbose, os_types, token_value, url + end + + def ssh(verbose, host_os, use_token = true) + token_value = nil + if use_token + begin + token_value = token || get_new_token(verbose) + rescue TokenError => e + STDERR.puts e + STDERR.puts 'Could not get token... requesting vm without a token anyway...' + end + end + Ssh.ssh(verbose, host_os, token_value, url) + end + + def pretty_print_running(verbose, hostnames = []) + if hostnames.empty? + puts "You have no running VMs." + else + puts "Running VMs:" + @service_object.pretty_print_hosts(verbose, hostnames, url) + end + end + + def query(verbose, hostname) + @service_object.query verbose, url, hostname + end + + def modify(verbose, hostname, modify_hash) + @service_object.modify verbose, url, hostname, token, modify_hash + end + + def delete(verbose, hosts) + @service_object.delete verbose, url, hosts, token + end + + def status(verbose) + @service_object.status verbose, url + end + + def summary(verbose) + @service_object.summary verbose, url + end + + def snapshot(verbose, hostname) + @service_object.snapshot verbose, url, hostname, token + end + + def revert(verbose, hostname, snapshot_sha) + @service_object.revert verbose, url, hostname, token, snapshot_sha + end + + def disk(verbose, hostname, disk) + @service_object.disk(verbose, url, hostname, token, disk) + end + +end \ No newline at end of file diff --git a/lib/vmfloaty/utils.rb b/lib/vmfloaty/utils.rb index 31c6fd0..fe55511 100644 --- a/lib/vmfloaty/utils.rb +++ b/lib/vmfloaty/utils.rb @@ -1,27 +1,62 @@ - require 'vmfloaty/pooler' +require 'vmfloaty/nonstandard_pooler' class Utils # TODO: Takes the json response body from an HTTP GET # request and "pretty prints" it - def self.format_hosts(hostname_hash) - host_hash = {} + def self.format_hosts(response_body) + # vmpooler response body example when `floaty get` arguments are `ubuntu-1610-x86_64=2 centos-7-x86_64`: + # { + # "ok": true, + # "domain": "delivery.mycompany.net", + # "ubuntu-1610-x86_64": { + # "hostname": ["gdoy8q3nckuob0i", "ctnktsd0u11p9tm"] + # }, + # "centos-7-x86_64": { + # "hostname": "dlgietfmgeegry2" + # } + # } - hostname_hash.delete("ok") - domain = hostname_hash["domain"] - hostname_hash.each do |type, hosts| - if type != "domain" - if hosts["hostname"].kind_of?(Array) - hosts["hostname"].map!{|host| host + "." + domain } + # nonstandard pooler response body example when `floaty get` arguments are `solaris-11-sparc=2 ubuntu-16.04-power8`: + # { + # "ok": true, + # "solaris-10-sparc": { + # "hostname": ["sol10-10.delivery.mycompany.net", "sol10-11.delivery.mycompany.net"] + # }, + # "ubuntu-16.04-power8": { + # "hostname": "power8-ubuntu1604-6.delivery.mycompany.net" + # } + # } + + unless response_body.delete('ok') + raise ArgumentError, "Bad GET response passed to format_hosts: #{response_body.to_json}" + end + + hostnames = [] + + # vmpooler reports the domain separately from the hostname + domain = response_body.delete('domain') + + if domain + # vmpooler output + response_body.each do |os, hosts| + if hosts['hostname'].kind_of?(Array) + hosts['hostname'].map!{ |host| hostnames << host + "." + domain + " (#{os})"} else - hosts["hostname"] = hosts["hostname"] + "." + domain + hostnames << hosts["hostname"] + ".#{domain} (#{os})" + end + end + else + response_body.each do |os, hosts| + if hosts['hostname'].kind_of?(Array) + hosts['hostname'].map!{ |host| hostnames << host + " (#{os})" } + else + hostnames << hosts['hostname'] + " (#{os})" end - - host_hash[type] = hosts["hostname"] end end - host_hash.to_json + hostnames.map { |hostname| puts "- #{hostname}" } end def self.generate_os_hash(os_args) @@ -46,72 +81,84 @@ class Utils os_types end - def self.get_vm_info(hosts, verbose, url) - vms = {} - hosts.each do |host| - vm_info = Pooler.query(verbose, url, host) - if vm_info['ok'] - vms[host] = {} - vms[host]['domain'] = vm_info[host]['domain'] - vms[host]['template'] = vm_info[host]['template'] - vms[host]['lifetime'] = vm_info[host]['lifetime'] - vms[host]['running'] = vm_info[host]['running'] - vms[host]['tags'] = vm_info[host]['tags'] - end - end - vms - end - - def self.prettyprint_hosts(hosts, verbose, url) - puts "Running VMs:" - vm_info = get_vm_info(hosts, verbose, url) - vm_info.each do |vm,info| - domain = info['domain'] - template = info['template'] - lifetime = info['lifetime'] - running = info['running'] - tags = info['tags'] || {} - - tag_pairs = tags.map {|key,value| "#{key}: #{value}" } - duration = "#{running}/#{lifetime} hours" - metadata = [template, duration, *tag_pairs] - - puts "- #{vm}.#{domain} (#{metadata.join(", ")})" - end - end - - def self.get_all_token_vms(verbose, url, token) - # get vms with token - status = Auth.token_status(verbose, url, token) - - vms = status[token]['vms'] - if vms.nil? - raise "You have no running vms" - end - - running_vms = vms['running'] - running_vms - end - - def self.prettyprint_status(status, message, pools, verbose) - pools.select! {|name,pool| pool['ready'] < pool['max']} if ! verbose - - width = pools.keys.map(&:length).max - pools.each do |name,pool| + def self.pretty_print_hosts(verbose, service, hostnames = []) + hostnames = [hostnames] unless hostnames.is_a? Array + hostnames.each do |hostname| begin - max = pool['max'] - ready = pool['ready'] - pending = pool['pending'] - missing = max - ready - pending - char = 'o' - puts "#{name.ljust(width)} #{(char*ready).green}#{(char*pending).yellow}#{(char*missing).red}" + response = service.query(verbose, hostname) + host_data = response[hostname] + + case service.type + when 'Pooler' + tag_pairs = [] + unless host_data['tags'].nil? + tag_pairs = host_data['tags'].map {|key, value| "#{key}: #{value}"} + end + duration = "#{host_data['running']}/#{host_data['lifetime']} hours" + metadata = [host_data['template'], duration, *tag_pairs] + puts "- #{hostname}.#{host_data['domain']} (#{metadata.join(", ")})" + when 'NonstandardPooler' + line = "- #{host_data['fqdn']} (#{host_data['os_triple']}" + line += ", #{host_data['hours_left_on_reservation']}h remaining" + unless host_data['reserved_for_reason'].empty? + line += ", reason: #{host_data['reserved_for_reason']}" + end + line += ')' + puts line + else + raise "Invalid service type #{service.type}" + end rescue => e - puts "#{name.ljust(width)} #{e.red}" + STDERR.puts("Something went wrong while trying to gather information on #{hostname}:") + STDERR.puts(e) end end + end - puts - puts message.colorize(status['status']['ok'] ? :default : :red) + def self.pretty_print_status(verbose, service) + status_response = service.status(verbose) + + case service.type + when 'Pooler' + message = status_response['status']['message'] + pools = status_response['pools'] + pools.select! {|_, pool| pool['ready'] < pool['max']} unless verbose + + width = pools.keys.map(&:length).max + pools.each do |name, pool| + begin + max = pool['max'] + ready = pool['ready'] + pending = pool['pending'] + missing = max - ready - pending + char = 'o' + puts "#{name.ljust(width)} #{(char*ready).green}#{(char*pending).yellow}#{(char*missing).red}" + rescue => e + puts "#{name.ljust(width)} #{e.red}" + end + end + puts message.colorize(status_response['status']['ok'] ? :default : :red) + when 'NonstandardPooler' + pools = status_response + pools.delete 'ok' + pools.select! {|_, pool| pool['available_hosts'] < pool['total_hosts']} unless verbose + + width = pools.keys.map(&:length).max + pools.each do |name, pool| + begin + max = pool['total_hosts'] + ready = pool['available_hosts'] + pending = pool['pending'] || 0 # not available for nspooler + missing = max - ready - pending + char = 'o' + puts "#{name.ljust(width)} #{(char*ready).green}#{(char*pending).yellow}#{(char*missing).red}" + rescue => e + puts "#{name.ljust(width)} #{e.red}" + end + end + else + raise "Invalid service type #{service.type}" + end end # Adapted from ActiveSupport @@ -121,4 +168,47 @@ class Utils str.gsub(/^[ \t]{#{min_indent_size}}/, '') end + + def self.get_service_object(type = '') + nspooler_strings = ['ns', 'nspooler', 'nonstandard', 'nonstandard_pooler'] + if nspooler_strings.include? type.downcase + NonstandardPooler + else + Pooler + end + end + + def self.get_service_config(config, options) + # The top-level url, user, and token values in the config file are treated as defaults + service_config = { + 'url' => config['url'], + 'user' => config['user'], + 'token' => config['token'], + 'type' => config['type'] || 'vmpooler' + } + + if config['services'] + if options.service.nil? + # If the user did not specify a service name at the command line, but configured services do exist, + # use the first configured service in the list by default. + _, values = config['services'].first + service_config.merge! values + else + # If the user provided a service name at the command line, use that service if posible, or fail + if config['services'][options.service] + # If the service is configured but some values are missing, use the top-level defaults to fill them in + service_config.merge! config['services'][options.service] + else + raise ArgumentError, "Could not find a configured service named '#{options.service}' in ~/.vmfloaty.yml" + end + end + end + + # Prioritize an explicitly specified url, user, or token if the user provided one + service_config['url'] = options.url unless options.url.nil? + service_config['token'] = options.token unless options.token.nil? + service_config['user'] = options.user unless options.user.nil? + + service_config + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index decf5e7..982b1ed 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,13 @@ require 'vmfloaty' require 'webmock/rspec' +# Mock Commander Options object to allow pre-population with values +class MockOptions < Commander::Command::Options + def initialize(values = {}) + @table = values + end +end + RSpec.configure do |config| config.color = true config.tty = true diff --git a/spec/vmfloaty/nonstandard_pooler_spec.rb b/spec/vmfloaty/nonstandard_pooler_spec.rb new file mode 100644 index 0000000..02ab9d9 --- /dev/null +++ b/spec/vmfloaty/nonstandard_pooler_spec.rb @@ -0,0 +1,325 @@ +require 'spec_helper' +require 'vmfloaty/utils' +require 'vmfloaty/errors' +require 'vmfloaty/nonstandard_pooler' + +describe NonstandardPooler do + before :each do + @nspooler_url = 'https://nspooler.example.com' + @post_request_headers = { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Faraday v0.9.2', + 'X-Auth-Token' => 'token-value' + } + @get_request_headers = { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Faraday v0.9.2', + 'X-Auth-Token' => 'token-value' + } + @get_request_headers_notoken = @get_request_headers.tap do |headers| + headers.delete('X-Auth-Token') + end + + end + + describe '#list' do + before :each do + @status_response_body = <<-BODY +{ + "ok": true, + "solaris-10-sparc": { + "total_hosts": 11, + "available_hosts": 11 + }, + "ubuntu-16.04-power8": { + "total_hosts": 10, + "available_hosts": 10 + }, + "aix-7.2-power": { + "total_hosts": 5, + "available_hosts": 4 + } +} + BODY + end + + it 'returns an array with operating systems from the pooler' do + stub_request(:get, "#{@nspooler_url}/status") + .to_return(status: 200, body: @status_response_body, headers: {}) + + list = NonstandardPooler.list(false, @nspooler_url, nil) + expect(list).to be_an_instance_of Array + end + + it 'filters operating systems based on the filter param' do + stub_request(:get, "#{@nspooler_url}/status") + .to_return(status: 200, body: @status_response_body, headers: {}) + + list = NonstandardPooler.list(false, @nspooler_url, 'aix') + expect(list).to be_an_instance_of Array + expect(list.size).to equal 1 + end + + it 'returns nothing if the filter does not match' do + stub_request(:get, "#{@nspooler_url}/status") + .to_return(status: 199, body: @status_response_body, headers: {}) + + list = NonstandardPooler.list(false, @nspooler_url, 'windows') + expect(list).to be_an_instance_of Array + expect(list.size).to equal 0 + end + end + + describe '#list_active' do + before :each do + @token_status_body_active = <<-BODY +{ + "ok": true, + "user": "first.last", + "created": "2017-09-18 01:25:41 +0000", + "last_accessed": "2017-09-21 19:46:25 +0000", + "reserved_hosts": ["sol10-9", "sol10-11"] +} +BODY + @token_status_body_empty = <<-BODY +{ + "ok": true, + "user": "first.last", + "created": "2017-09-18 01:25:41 +0000", + "last_accessed": "2017-09-21 19:46:25 +0000", + "reserved_hosts": [] +} +BODY + end + + it 'prints an output of fqdn, template, and duration' do + allow(Auth).to receive(:token_status) + .with(false, @nspooler_url, 'token-value') + .and_return(JSON.parse(@token_status_body_active)) + + list = NonstandardPooler.list_active(false, @nspooler_url, 'token-value') + expect(list).to eql ['sol10-9', 'sol10-11'] + end + end + + describe '#retrieve' do + before :each do + @retrieve_response_body_single = <<-BODY +{ + "ok": true, + "solaris-11-sparc": { + "hostname": "sol11-4.delivery.puppetlabs.net" + } +} +BODY + @retrieve_response_body_many = <<-BODY +{ + "ok": true, + "solaris-10-sparc": { + "hostname": [ + "sol10-9.delivery.puppetlabs.net", + "sol10-10.delivery.puppetlabs.net" + ] + }, + "aix-7.1-power": { + "hostname": "pe-aix-71-ci-acceptance.delivery.puppetlabs.net" + } +} +BODY + end + + it 'raises an AuthError if the token is invalid' do + stub_request(:post, "#{@nspooler_url}/host/solaris-11-sparc") + .with(headers: @post_request_headers) + .to_return(status: 401, body: '{"ok":false,"reason": "token: token-value does not exist"}', headers: {}) + + vm_hash = { 'solaris-11-sparc' => 1 } + expect { NonstandardPooler.retrieve(false, vm_hash, 'token-value', @nspooler_url) }.to raise_error(AuthError) + end + + it 'retrieves a single vm with a token' do + stub_request(:post, "#{@nspooler_url}/host/solaris-11-sparc") + .with(headers: @post_request_headers) + .to_return(status: 200, body: @retrieve_response_body_single, headers: {}) + + vm_hash = { 'solaris-11-sparc' => 1 } + vm_req = NonstandardPooler.retrieve(false, vm_hash, 'token-value', @nspooler_url) + expect(vm_req).to be_an_instance_of Hash + expect(vm_req['ok']).to equal true + expect(vm_req['solaris-11-sparc']['hostname']).to eq 'sol11-4.delivery.puppetlabs.net' + end + + it 'retrieves a multiple vms with a token' do + stub_request(:post,"#{@nspooler_url}/host/aix-7.1-power+solaris-10-sparc+solaris-10-sparc") + .with(headers: @post_request_headers) + .to_return(status: 200, body: @retrieve_response_body_many, headers: {}) + + vm_hash = { 'aix-7.1-power' => 1, 'solaris-10-sparc' => 2 } + vm_req = NonstandardPooler.retrieve(false, vm_hash, 'token-value', @nspooler_url) + expect(vm_req).to be_an_instance_of Hash + expect(vm_req['ok']).to equal true + expect(vm_req['solaris-10-sparc']['hostname']).to be_an_instance_of Array + expect(vm_req['solaris-10-sparc']['hostname']).to eq ['sol10-9.delivery.puppetlabs.net', 'sol10-10.delivery.puppetlabs.net'] + expect(vm_req['aix-7.1-power']['hostname']).to eq 'pe-aix-71-ci-acceptance.delivery.puppetlabs.net' + end + end + + describe '#modify' do + before :each do + @modify_response_body_success = '{"ok":true}' + end + + it 'raises an error if the user tries to modify an unsupported attribute' do + stub_request(:put, "https://nspooler.example.com/host/myfakehost"). + with(body: {"{}"=>true}, + headers: {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type'=>'application/x-www-form-urlencoded', 'User-Agent'=>'Faraday v0.9.2', 'X-Auth-Token'=>'token-value'}). + to_return(status: 200, body: "", headers: {}) + details = { lifetime: 12 } + expect { NonstandardPooler.modify(false, @nspooler_url, 'myfakehost', 'token-value', details) } + .to raise_error(ModifyError) + end + + it 'modifies the reason of a vm' do + modify_request_body = { '{"reserved_for_reason":"testing"}' => true } + stub_request(:put, "#{@nspooler_url}/host/myfakehost") + .with(body: modify_request_body, + headers: @post_request_headers) + .to_return(status: 200, body: '{"ok": true}', headers: {}) + + modify_hash = { reason: "testing" } + modify_req = NonstandardPooler.modify(false, @nspooler_url, 'myfakehost', 'token-value', modify_hash) + expect(modify_req['ok']).to be true + end + end + + describe '#status' do + before :each do + @status_response_body = '{"capacity":{"current":716,"total":717,"percent": 99.9},"status":{"ok":true,"message":"Battle station fully armed and operational."}}' + # TODO: make this report stuff like 'broken' + @status_response_body = <<-BODY +{ + "ok": true, + "solaris-10-sparc": { + "total_hosts": 11, + "available_hosts": 10 + }, + "ubuntu-16.04-power8": { + "total_hosts": 10, + "available_hosts": 10 + }, + "aix-7.2-power": { + "total_hosts": 5, + "available_hosts": 4 + } +} +BODY + end + + it 'prints the status' do + stub_request(:get, "#{@nspooler_url}/status") + .with(headers: @get_request_headers) + .to_return(status: 200, body: @status_response_body, headers: {}) + + status = NonstandardPooler.status(false, @nspooler_url) + expect(status).to be_an_instance_of Hash + end + end + + describe '#summary' do + before :each do + @status_response_body = <<-BODY +{ + "ok": true, + "total": 57, + "available": 39, + "in_use": 16, + "resetting": 2, + "broken": 0 +} +BODY + end + + it 'prints the summary' do + stub_request(:get, "#{@nspooler_url}/summary") + .with(headers: @get_request_headers) + .to_return(status: 200, body: @status_response_body, headers: {}) + + summary = NonstandardPooler.summary(false, @nspooler_url) + expect(summary).to be_an_instance_of Hash + end + end + + describe '#query' do + before :each do + @query_response_body = <<-BODY +{ + "ok": true, + "sol10-11": { + "fqdn": "sol10-11.delivery.puppetlabs.net", + "os_triple": "solaris-10-sparc", + "reserved_by_user": "first.last", + "reserved_for_reason": "testing", + "hours_left_on_reservation": 29.12 + } +} +BODY + end + + it 'makes a query about a vm' do + stub_request(:get, "#{@nspooler_url}/host/sol10-11") + .with(headers: @get_request_headers_notoken) + .to_return(status: 200, body: @query_response_body, headers: {}) + + query_req = NonstandardPooler.query(false, @nspooler_url, 'sol10-11') + expect(query_req).to be_an_instance_of Hash + end + end + + describe '#delete' do + before :each do + @delete_response_success = '{"ok": true}' + @delete_response_failure = '{"ok": false, "failure": "ERROR: fakehost does not exist"}' + end + + it 'deletes a single existing vm' do + stub_request(:delete, "#{@nspooler_url}/host/sol11-7") + .with(headers: @post_request_headers) + .to_return(status: 200, body: @delete_response_success, headers: {}) + + request = NonstandardPooler.delete(false, @nspooler_url, 'sol11-7', 'token-value') + expect(request['sol11-7']['ok']).to be true + end + + it 'does not delete a nonexistant vm' do + stub_request(:delete, "#{@nspooler_url}/host/fakehost") + .with(headers: @post_request_headers) + .to_return(status: 401, body: @delete_response_failure, headers: {}) + + request = NonstandardPooler.delete(false, @nspooler_url, 'fakehost', 'token-value') + expect(request['fakehost']['ok']).to be false + end + end + + describe '#snapshot' do + it 'logs an error explaining that snapshots are not supported' do + expect { NonstandardPooler.snapshot(false, @nspooler_url, 'myfakehost', 'token-value') } + .to raise_error(ModifyError) + end + end + + describe '#revert' do + it 'logs an error explaining that snapshots are not supported' do + expect { NonstandardPooler.revert(false, @nspooler_url, 'myfakehost', 'token-value', 'snapshot-sha') } + .to raise_error(ModifyError) + end + end + + describe '#disk' do + it 'logs an error explaining that disk modification is not supported' do + expect { NonstandardPooler.disk(false, @nspooler_url, 'myfakehost', 'token-value', 'diskname') } + .to raise_error(ModifyError) + end + end +end diff --git a/spec/vmfloaty/pooler_spec.rb b/spec/vmfloaty/pooler_spec.rb index d3d994d..557149a 100644 --- a/spec/vmfloaty/pooler_spec.rb +++ b/spec/vmfloaty/pooler_spec.rb @@ -91,16 +91,17 @@ describe Pooler do end it "raises a TokenError if token provided is nil" do - expect{ Pooler.modify(false, @vmpooler_url, 'myfakehost', nil, 12, nil) }.to raise_error(TokenError) + expect{ Pooler.modify(false, @vmpooler_url, 'myfakehost', nil, {}) }.to raise_error(TokenError) end it "modifies the TTL of a vm" do + modify_hash = { :lifetime => 12 } stub_request(:put, "#{@vmpooler_url}/vm/fq6qlpjlsskycq6"). - with(:body => {"{\"lifetime\":12}"=>true}, + with(:body => {'{"lifetime":12}'=>true}, :headers => {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type'=>'application/x-www-form-urlencoded', 'User-Agent'=>'Faraday v0.9.2', 'X-Auth-Token'=>'mytokenfile'}). to_return(:status => 200, :body => @modify_response_body_success, :headers => {}) - modify_req = Pooler.modify(false, @vmpooler_url, 'fq6qlpjlsskycq6', 'mytokenfile', 12, nil) + modify_req = Pooler.modify(false, @vmpooler_url, 'fq6qlpjlsskycq6', 'mytokenfile', modify_hash) expect(modify_req["ok"]).to be true end end diff --git a/spec/vmfloaty/service_spec.rb b/spec/vmfloaty/service_spec.rb new file mode 100644 index 0000000..78ba671 --- /dev/null +++ b/spec/vmfloaty/service_spec.rb @@ -0,0 +1,79 @@ +require_relative '../../lib/vmfloaty/service' + +describe Service do + + describe '#initialize' do + it 'store configuration options' do + options = MockOptions.new({}) + config = {'url' => 'http://example.url'} + service = Service.new(options, config) + expect(service.config).to include config + end + end + + describe '#get_new_token' do + it 'prompts the user for their password and retrieves a token' do + config = { 'user' => 'first.last', 'url' => 'http://default.url' } + service = Service.new(MockOptions.new, config) + allow(STDOUT).to receive(:puts).with('Enter your pooler service password:') + allow(Commander::UI).to(receive(:password) + .with('Enter your pooler service password:', '*') + .and_return('hunter2')) + allow(Auth).to(receive(:get_token) + .with(nil, config['url'], config['user'], 'hunter2') + .and_return('token-value')) + expect(service.get_new_token(nil)).to eql 'token-value' + end + + it 'prompts the user for their username and password if the username is unknown' do + config = { 'url' => 'http://default.url' } + service = Service.new(MockOptions.new({}), config) + allow(STDOUT).to receive(:puts).with 'Enter your pooler service username:' + allow(STDOUT).to receive(:puts).with "\n" + allow(STDIN).to receive(:gets).and_return('first.last') + allow(Commander::UI).to(receive(:password) + .with('Enter your pooler service password:', '*') + .and_return('hunter2')) + allow(Auth).to(receive(:get_token) + .with(nil, config['url'], 'first.last', 'hunter2') + .and_return('token-value')) + expect(service.get_new_token(nil)).to eql 'token-value' + end + end + + describe '#delete_token' do + it 'deletes a token' do + service = Service.new(MockOptions.new,{'user' => 'first.last', 'url' => 'http://default.url'}) + allow(Commander::UI).to(receive(:password) + .with('Enter your pooler service password:', '*') + .and_return('hunter2')) + allow(Auth).to(receive(:delete_token) + .with(nil, 'http://default.url', 'first.last', 'hunter2', 'token-value') + .and_return('ok' => true)) + expect(service.delete_token(nil, 'token-value')).to eql({'ok' => true}) + end + end + + describe '#token_status' do + it 'reports the status of a token' do + config = { + 'user' => 'first.last', + 'url' => 'http://default.url' + } + options = MockOptions.new('token' => 'token-value') + service = Service.new(options, config) + status = { + 'ok' => true, + 'user' => config['user'], + 'created' => '2017-09-22 02:04:18 +0000', + 'last_accessed' => '2017-09-22 02:04:28 +0000', + 'reserved_hosts' => [] + } + allow(Auth).to(receive(:token_status) + .with(nil, config['url'], 'token-value') + .and_return(status)) + expect(service.token_status(nil, 'token-value')).to eql(status) + end + end + +end diff --git a/spec/vmfloaty/utils_spec.rb b/spec/vmfloaty/utils_spec.rb index c30d378..47822ca 100644 --- a/spec/vmfloaty/utils_spec.rb +++ b/spec/vmfloaty/utils_spec.rb @@ -1,18 +1,115 @@ require 'spec_helper' require 'json' +require 'commander/command' require_relative '../../lib/vmfloaty/utils' describe Utils do - describe "#get_hosts" do + describe "#format_hosts" do before :each do - @hostname_hash = "{\"ok\":true,\"debian-7-i386\":{\"hostname\":[\"sc0o4xqtodlul5w\",\"4m4dkhqiufnjmxy\"]},\"debian-7-x86_64\":{\"hostname\":\"zb91y9qbrbf6d3q\"},\"domain\":\"company.com\"}" - @format_hash = "{\"debian-7-i386\":[\"sc0o4xqtodlul5w.company.com\",\"4m4dkhqiufnjmxy.company.com\"],\"debian-7-x86_64\":\"zb91y9qbrbf6d3q.company.com\"}" + @vmpooler_response_body ='{ + "ok": true, + "domain": "delivery.mycompany.net", + "ubuntu-1610-x86_64": { + "hostname": ["gdoy8q3nckuob0i", "ctnktsd0u11p9tm"] + }, + "centos-7-x86_64": { + "hostname": "dlgietfmgeegry2" + } + }' + @nonstandard_response_body = '{ + "ok": true, + "solaris-10-sparc": { + "hostname": ["sol10-10.delivery.mycompany.net", "sol10-11.delivery.mycompany.net"] + }, + "ubuntu-16.04-power8": { + "hostname": "power8-ubuntu16.04-6.delivery.mycompany.net" + } + }' + @vmpooler_output = <<-OUT +- gdoy8q3nckuob0i.delivery.mycompany.net (ubuntu-1610-x86_64) +- ctnktsd0u11p9tm.delivery.mycompany.net (ubuntu-1610-x86_64) +- dlgietfmgeegry2.delivery.mycompany.net (centos-7-x86_64) + OUT + @nonstandard_output = <<-OUT +- sol10-10.delivery.mycompany.net (solaris-10-sparc) +- sol10-11.delivery.mycompany.net (solaris-10-sparc) +- power8-ubuntu16.04-6.delivery.mycompany.net (ubuntu-16.04-power8) + OUT end - it "formats a hostname hash into os, hostnames, and domain name" do + it "formats a hostname hash from vmpooler into a list that includes the os" do + expect { Utils.format_hosts(JSON.parse(@vmpooler_response_body)) }.to output( @vmpooler_output).to_stdout_from_any_process + end - expect(Utils.format_hosts(JSON.parse(@hostname_hash))).to eq @format_hash + it "formats a hostname hash from the nonstandard pooler into a list that includes the os" do + expect { Utils.format_hosts(JSON.parse(@nonstandard_response_body)) }.to output(@nonstandard_output).to_stdout_from_any_process + end + end + + describe "#get_service_object" do + it "assumes vmpooler by default" do + expect(Utils.get_service_object).to be Pooler + end + + it "uses nspooler when told explicitly" do + expect(Utils.get_service_object "nspooler").to be NonstandardPooler + end + end + + describe "#get_service_config" do + before :each do + @default_config = { + "url" => "http://default.url", + "user" => "first.last.default", + "token" => "default-token", + } + @services_config = { + "services" => { + "vm" => { + "url" => "http://vmpooler.url", + "user" => "first.last.vmpooler", + "token" => "vmpooler-token" + }, + "ns" => { + "url" => "http://nspooler.url", + "user" => "first.last.nspooler", + "token" => "nspooler-token" + } + } + } + end + + it "returns the first service configured under 'services' as the default if available" do + config = @default_config.merge @services_config + options = MockOptions.new({}) + expect(Utils.get_service_config(config, options)).to include @services_config['services']['vm'] + end + + it "allows selection by configured service key" do + config = @default_config.merge @services_config + options = MockOptions.new({:service => "ns"}) + expect(Utils.get_service_config(config, options)).to include @services_config['services']['ns'] + end + + it "uses top-level service config values as defaults when configured service values are missing" do + config = @default_config.merge @services_config + config["services"]['vm'].delete 'url' + options = MockOptions.new({:service => "vm"}) + expect(Utils.get_service_config(config, options)['url']).to eq 'http://default.url' + end + + it "raises an error if passed a service name that hasn't been configured" do + config = @default_config.merge @services_config + options = MockOptions.new({:service => "none"}) + expect { Utils.get_service_config(config, options) }.to raise_error ArgumentError + end + + it "prioritizes values passed as command line options over configuration options" do + config = @default_config + options = MockOptions.new({:url => "http://alternate.url", :token => "alternate-token"}) + expected = config.merge({"url" => "http://alternate.url", "token" => "alternate-token"}) + expect(Utils.get_service_config(config, options)).to include expected end end @@ -32,60 +129,97 @@ describe Utils do end end - describe '#prettyprint_hosts' do - let(:host_without_tags) { 'mcpy42eqjxli9g2' } - let(:host_with_tags) { 'aiydvzpg23r415q' } + describe '#pretty_print_hosts' do let(:url) { 'http://pooler.example.com' } - let(:host_info_with_tags) do - { - host_with_tags => { - "template" => "redhat-7-x86_64", - "lifetime" => 48, - "running" => 7.67, - "tags" => { - "user" => "bob", - "role" => "agent" + it 'prints a vmpooler output with host fqdn, template and duration info' do + hostname = 'mcpy42eqjxli9g2' + response_body = { hostname => { + 'template' => 'ubuntu-1604-x86_64', + 'lifetime' => 12, + 'running' => 9.66, + 'state' => 'running', + 'ip' => '127.0.0.1', + 'domain' => 'delivery.mycompany.net' + }} + output = "- mcpy42eqjxli9g2.delivery.mycompany.net (ubuntu-1604-x86_64, 9.66/12 hours)" + + expect(Utils).to receive(:puts).with(output) + + service = Service.new(MockOptions.new, {'url' => url}) + allow(service).to receive(:query) + .with(nil, hostname) + .and_return(response_body) + + Utils.pretty_print_hosts(nil, service, hostname) + end + + it 'prints a vmpooler output with host fqdn, template, duration info, and tags when supplied' do + hostname = 'aiydvzpg23r415q' + response_body = { hostname => { + 'template' => 'redhat-7-x86_64', + 'lifetime' => 48, + 'running' => 7.67, + 'state' => 'running', + 'tags' => { + 'user' => 'bob', + 'role' => 'agent' }, - "domain" => "delivery.puppetlabs.net" - } - } + 'ip' => '127.0.0.1', + 'domain' => 'delivery.mycompany.net' + }} + output = "- aiydvzpg23r415q.delivery.mycompany.net (redhat-7-x86_64, 7.67/48 hours, user: bob, role: agent)" + + expect(Utils).to receive(:puts).with(output) + + service = Service.new(MockOptions.new, {'url' => url}) + allow(service).to receive(:query) + .with(nil, hostname) + .and_return(response_body) + + Utils.pretty_print_hosts(nil, service, hostname) end - let(:host_info_without_tags) do - { - host_without_tags => { - "template" => "ubuntu-1604-x86_64", - "lifetime" => 12, - "running" => 9.66, - "domain" => "delivery.puppetlabs.net" - } - } + it 'prints a nonstandard pooler output with host, template, and time remaining' do + hostname = "sol11-9.delivery.mycompany.net" + response_body = { hostname => { + 'fqdn' => hostname, + 'os_triple' => 'solaris-11-sparc', + 'reserved_by_user' => 'first.last', + 'reserved_for_reason' => '', + 'hours_left_on_reservation' => 35.89 + }} + output = "- sol11-9.delivery.mycompany.net (solaris-11-sparc, 35.89h remaining)" + + expect(Utils).to receive(:puts).with(output) + + service = Service.new(MockOptions.new, {'url' => url, 'type' => 'ns'}) + allow(service).to receive(:query) + .with(nil, hostname) + .and_return(response_body) + + Utils.pretty_print_hosts(nil, service, hostname) end - let(:output_with_tags) { "- #{host_with_tags}.delivery.puppetlabs.net (redhat-7-x86_64, 7.67/48 hours, user: bob, role: agent)" } - let(:output_without_tags) { "- #{host_without_tags}.delivery.puppetlabs.net (ubuntu-1604-x86_64, 9.66/12 hours)" } + it 'prints a nonstandard pooler output with host, template, time remaining, and reason' do + hostname = 'sol11-9.delivery.mycompany.net' + response_body = { hostname => { + 'fqdn' => hostname, + 'os_triple' => 'solaris-11-sparc', + 'reserved_by_user' => 'first.last', + 'reserved_for_reason' => 'testing', + 'hours_left_on_reservation' => 35.89 + }} + output = "- sol11-9.delivery.mycompany.net (solaris-11-sparc, 35.89h remaining, reason: testing)" - it 'prints an output with host fqdn, template and duration info' do - allow(Utils).to receive(:get_vm_info). - with(host_without_tags, false, url). - and_return(host_info_without_tags) + expect(Utils).to receive(:puts).with(output) - expect(Utils).to receive(:puts).with("Running VMs:") - expect(Utils).to receive(:puts).with(output_without_tags) + service = Service.new(MockOptions.new, {'url' => url, 'type' => 'ns'}) + allow(service).to receive(:query) + .with(nil, hostname) + .and_return(response_body) - Utils.prettyprint_hosts(host_without_tags, false, url) - end - - it 'prints an output with host fqdn, template, duration info, and tags when supplied' do - allow(Utils).to receive(:get_vm_info). - with(host_with_tags, false, url). - and_return(host_info_with_tags) - - expect(Utils).to receive(:puts).with("Running VMs:") - expect(Utils).to receive(:puts).with(output_with_tags) - - Utils.prettyprint_hosts(host_with_tags, false, url) + Utils.pretty_print_hosts(nil, service, hostname) end end end