require 'hygroscope' module Hygroscope class Cli < Thor include Thor::Actions def initialize(*args) super(*args) end no_commands do def say_fail(message) say_status('error', message, :red) abort end def colorize_status(status) case status.downcase when /failed$/ set_color(status, :red) when /progress$/ set_color(status, :yellow) when /complete$/ set_color(status, :green) end end def countdown(text, time = 5) print "#{text} " time.downto(0) do |i| $stdout.write("\b") $stdout.write(i) $stdout.flush sleep 1 end end def check_path say_fail('Hygroscope must be run from the top level of a hygroscopic directory.') unless File.directory?(File.join(Dir.pwd, 'template')) && File.directory?(File.join(Dir.pwd, 'paramsets')) end def hygro_path Dir.pwd end def hygro_name File.basename(Dir.pwd) end def template_path File.join(hygro_path, 'template') end def say_paramset(name) begin p = Hygroscope::ParamSet.new(name) rescue Hygroscope::ParamSetNotFoundError say_fail("Paramset #{name} does not exist!") end say "Parameters for '#{File.basename(Dir.pwd)}' paramset '#{p.name}':", :yellow print_table p.parameters, indent: 2 say "\nTo edit existing parameters, use the 'create' command with the --ask flag." end def say_paramset_list files = Dir.glob(File.join(hygro_path, 'paramsets', '*.{yml,yaml}')) say_fail("No saved paramsets for #{hygro_name}.") if files.empty? say "Saved paramsets for '#{hygro_name}':", :yellow files.map do |f| say ' ' + File.basename(f, File.extname(f)) end say "\nTo list parameters in a set, use the --name option." end end # NOTE: Profiles cannot assume roles, see: # https://github.com/aws/aws-sdk-ruby/issues/910 class_option :profile, aliases: '-p', type: :string, default: 'default', desc: 'AWS account credentials profile name' class_option :region, aliases: '-r', type: :string, default: 'us-east-1', desc: 'AWS region' desc 'prepare', 'Prepare to create or update a stack by generating the template, assembling parameters, and managing payload upload', hide: true def prepare(action = 'create') # Generate the template t = Hygroscope::Template.new(template_path, options[:region], options[:profile]) # If the paramset exists load it, otherwise instantiate an empty one p = Hygroscope::ParamSet.new(options[:paramset]) if options[:paramset] # User provided a paramset, so load it and determine which parameters # are set and which need to be prompted. paramset_keys = p.parameters.keys template_keys = t.parameters.keys # Reject any keys in paramset that are not requested by template rejected_keys = paramset_keys - template_keys say_status('info', "Keys in paramset not requested by template: #{rejected_keys.join(', ')}", :blue) unless rejected_keys.empty? # Prompt for any key that is missing. If "ask" option was passed, # prompt for every key. missing = options[:ask] ? template_keys : template_keys - paramset_keys else # No paramset provided, so every parameter is missing! missing = if t.parameters.empty? {} else t.parameters.keys end end options[:existing].each do |existing| # User specified an existing stack from which to pull outputs and # translate into parameters. Load the existing stack. e = Hygroscope::Stack.new(existing, options[:region], options[:profile]) say_status('info', "Populating parameters from #{existing} stack", :blue) # Fill any template parameter that matches an output from the existing # stack, overwriting values from the paramset object. The user will # be prompted to change these if they were not in the paramset or the # --ask option was passed. e.describe.outputs.each do |o| p.set(o.output_key, o.output_value) if t.parameters.keys.include?(o.output_key) end end if options[:existing].is_a?(Array) # If this is an update and ask was not specified, set any missing # parameters to use_previous_value if action == 'update' && !options[:ask] missing.each do |key| p.set(key, nil, use_previous_value: true) unless p.get(key) end else # Prompt for each missing parameter and save it in the paramset object missing.each do |key| # Do not prompt for keys prefixed with the "Hygroscope" reserved word. # These parameters are populated internally without user input. next if key =~ /^Hygroscope/ type = t.parameters[key]['Type'] default = p.get(key) ? p.get(key) : t.parameters[key]['Default'] || '' description = t.parameters[key]['Description'] || false values = t.parameters[key]['AllowedValues'] || false no_echo = t.parameters[key]['NoEcho'] || false # Thor conveniently provides some nice logic for formatting, # allowing defaults, and validating user input ask_opts = {} ask_opts[:default] = default unless default.to_s.empty? ask_opts[:limited_to] = values if values ask_opts[:echo] = false if no_echo puts say("#{description} (#{type})") if description # Make sure user enters a value # TODO: Better input validation answer = '' answer = ask(key, :cyan, ask_opts) until answer != '' # Save answer to paramset object p.set(key, answer) # Add a line break say if no_echo end # Offer to save paramset if it was modified # Filter out keys beginning with "Hygroscope" since they are not visible # to the user and may be modified on each invocation. unless missing.reject { |k| k =~ /^Hygroscope/ }.empty? puts if yes?('Save changes to paramset?') unless options[:paramset] p.name = ask('Paramset name', :cyan, default: options[:name]) end p.save! end end end # Upload payload payload_path = File.join(Dir.pwd, 'payload') if File.directory?(payload_path) payload = Hygroscope::Payload.new(payload_path, options[:region], options[:profile]) payload.prefix = options[:name] payload.upload! p.set('HygroscopePayloadBucket', payload.bucket) if missing.include?('HygroscopePayloadBucket') p.set('HygroscopePayloadKey', payload.key) if missing.include?('HygroscopePayloadKey') p.set('HygroscopePayloadSignedUrl', payload.generate_url) if missing.include?('HygroscopePayloadSignedUrl') say_status('ok', 'Payload uploaded to:', :green) say_status('', "s3://#{payload.bucket}/#{payload.key}") end [t, p] end desc 'create', "Create a new stack.\nUse the --name option to launch more than one stack from the same template.\nCommand prompts for parameters unless --paramset is specified.\nUse --existing to set parameters from an existing stack's outputs." method_option :name, aliases: '-n', default: File.basename(Dir.pwd), desc: 'Name of stack' method_option :paramset, aliases: '-s', required: false, desc: 'Name of saved paramset to use (optional)' method_option :existing, aliases: '-e', type: :array, required: false, desc: 'Name of one or more existing stacks from which to retrieve outputs as parameters (optional)' method_option :ask, aliases: '-a', type: :boolean, default: false, desc: 'Still prompt for parameters even when using a paramset' method_option :tags, aliases: '-t', type: :array, required: false, default: [], desc: 'One or more parameters to apply as tags to all stack resources' method_option :capabilities, aliases: '-c', type: :array, required: false, default: [], desc: 'One or more capabilities to pass to CloudFormation (i.e. CAPABILITY_IAM)' method_option :compress, type: :boolean, default: false, desc: 'Whether to compress the CloudFormation template slightly at the expense of readability by removing extra spaces' def create check_path validate # Prepare task takes care of shared logic between "create" and "update" template, paramset = prepare('create') stack = Hygroscope::Stack.new(options[:name], options[:region], options[:profile]) stack.parameters = paramset.parameters stack.template = options[:compress] ? template.compress : template.process stack.capabilities = options[:capabilities] stack.timeout = 60 stack.tags['X-Hygroscope-Template'] = hygro_name options[:tags].each do |tag| if paramset.get(tag) stack.tags[tag] = paramset.get(tag) else say_status('info', "Skipping tag #{tag} because it does not exist", :blue) end end stack.create! status end desc 'update', "Update an existing stack.\nUse the --name option to specify the stack name if it differs from the template name.\nBy default parameters are set to existing values, unless --paramset or --ask is specified." method_option :name, aliases: '-n', default: File.basename(Dir.pwd), desc: 'Name of stack' method_option :paramset, aliases: '-s', required: false, desc: 'Name of saved paramset to use (optional)' method_option :existing, aliases: '-e', type: :array, required: false, desc: 'Name of one or more existing stacks from which to retrieve outputs as parameters (optional)' method_option :ask, aliases: '-a', type: :boolean, default: false, desc: 'Still prompt for parameters even when using a paramset' method_option :capabilities, aliases: '-c', type: :array, required: false, default: [], desc: 'One or more capabilities to pass to CloudFormation (i.e. CAPABILITY_NAMED_IAM)' method_option :compress, type: :boolean, default: false, desc: 'Whether to compress the CloudFormation template slightly at the expense of readability by removing extra spaces' def update # TODO: Make re-uploading the payload optional check_path validate # Prepare task takes care of shared logic between "create" and "update" template, paramset = prepare('update') stack = Hygroscope::Stack.new(options[:name], options[:region], options[:profile]) stack.parameters = paramset.parameters stack.template = options[:compress] ? template.compress : template.process stack.capabilities = options[:capabilities] stack.timeout = 60 stack.update! status end desc 'delete', 'Delete a running stack after asking for confirmation.' method_option :name, aliases: '-n', default: File.basename(Dir.pwd), desc: 'Name of stack' method_option :force, aliases: '-f', type: :boolean, default: false, desc: 'Delete without asking for confirmation' def delete check_path abort unless options[:force] || yes?("Really delete stack #{options[:name]} [y/N]?") say('Deleting stack!') stack = Hygroscope::Stack.new(options[:name], options[:region], options[:profile]) stack.delete! status end desc 'status', 'View status of stack create/update/delete action.\nUse the --name option to change which stack is reported upon.' method_option :name, aliases: '-n', default: File.basename(Dir.pwd), desc: 'Name of stack' def status check_path stack = Hygroscope::Stack.new(options[:name], options[:region], options[:profile]) # Query and display the status of the stack and its resources. Refresh # every 10 seconds until the user aborts or an error is encountered. loop do begin s = stack.describe system('clear') || system('cls') header = { 'Name:' => s.stack_name, 'Created:' => s.creation_time, 'Status:' => colorize_status(s.stack_status) } print_table header puts # Fancy acrobatics to fit output to terminal width. If the terminal # window is too small, fallback to something appropriate for ~80 chars term_width = `stty size 2>/dev/null`.split[1].to_i || `tput cols 2>/dev/null`.to_i type_width = term_width < 80 ? 30 : term_width - 50 output_width = term_width < 80 ? 54 : term_width - 31 # Header row puts set_color(format(' %-28s %-*s %-18s ', 'Resource', type_width, 'Type', 'Status'), :white, :on_blue) resources = stack.list_resources resources.each do |r| puts format(' %-28s %-*s %-18s ', r[:name][0..26], type_width, r[:type][0..type_width], colorize_status(r[:status])) end if s.stack_status.downcase =~ /complete$/ # If the stack is complete display any available outputs and stop refreshing puts puts set_color(format(' %-28s %-*s ', 'Output', output_width, 'Value'), :white, :on_yellow) s.outputs.each do |o| puts format(' %-28s %-*s ', o.output_key, output_width, o.output_value) end puts "\nMore information: https://console.aws.amazon.com/cloudformation/home" break elsif s.stack_status.downcase =~ /failed$/ # If the stack failed to create, stop refreshing puts "\nMore information: https://console.aws.amazon.com/cloudformation/home" break else puts "\nMore information: https://console.aws.amazon.com/cloudformation/home" countdown('Updating in', 9) puts end rescue Aws::CloudFormation::Errors::ValidationError say_fail('Stack not found') rescue Interrupt abort end end end desc 'generate', "Generate and display JSON output from template files.\n" \ "To validate that the template is well-formed use the 'validate' command." method_option :color, aliases: '-c', type: :boolean, default: true, desc: 'Colorize JSON output' def generate check_path t = Hygroscope::Template.new(template_path, options[:region], options[:profile]) if options[:color] require 'json_color' puts JsonColor.colorize(t.process) else puts t.process end end desc 'validate', "Generate JSON from template files and validate that it is well-formed.\nThis utilzies the CloudFormation API to validate the template but does not detect logical errors." def validate check_path begin t = Hygroscope::Template.new(template_path, options[:region], options[:profile]) t.validate rescue Aws::CloudFormation::Errors::ValidationError => e say_fail("Validation error: #{e.message}") rescue Hygroscope::TemplateYamlParseError => e say_fail("YAML parsing error: #{e.message}") rescue => e say_fail(e.message) else say_status('ok', 'Template is valid', :green) end end desc 'paramset', "List saved paramsets.\nIf --name is passed, shows all parameters in the named set." method_option :name, aliases: '-n', required: false, desc: 'Name of a paramset' def paramset if options[:name] say_paramset(options[:name]) else say_paramset_list end end end end