EGI-FCTF/rOCCI-cli

View on GitHub
lib/occi/cli/occi_opts.rb

Summary

Maintainability
F
3 days
Test Coverage
require 'optparse'
require 'uri'
require 'erb'

# load all parts of OcciOpts
Dir[File.join(File.dirname(__FILE__), 'occi_opts', '*.rb')].each { |file| require file.gsub('.rb', '') }

module Occi::Cli

  class OcciOpts

    AUTH_METHODS = [:x509, :token, :basic, :digest, :oauth2, :none].freeze
    MEDIA_TYPES = ["application/occi+json", "text/plain,text/occi", "text/plain", "text/occi"].freeze
    ACTIONS = [:list, :describe, :create, :delete, :trigger, :link, :unlink, :discover, :update].freeze
    LOG_OUTPUTS = [:stdout, :stderr].freeze
    LOG_LEVELS = [:debug, :error, :fatal, :info, :unknown, :warn].freeze
    ENTITY_TYPES = [:resource, :link].freeze

    REQ_CREATE_ATTRS = ["occi.core.title"].freeze

    def self.parse(args, test_env = false)

      @@quiet = test_env

      options = Hashie::Mash.new
      set_defaults(options)

      opts = OptionParser.new do |opts|
        opts.banner = %{Usage: occi [OPTIONS]}

        opts.separator ""
        opts.separator "Options:"

        opts.on("-e",
                "--endpoint URI",
                String,
                "OCCI server URI, defaults to #{options.endpoint.inspect}") do |endpoint|
          options.endpoint = URI(endpoint).to_s
        end

        opts.on("-n",
                "--auth METHOD",
                AUTH_METHODS,
                "Authentication method, only: [#{AUTH_METHODS.join('|')}], defaults " \
                "to #{options.auth.type.inspect}") do |auth|
          options.auth.type = auth.to_s
        end

        opts.on("-k",
                "--timeout SEC",
                Integer,
                "Default timeout for all HTTP connections, in seconds") do |timeout|
          raise "Timeout has to be a number larger than 0!" if timeout < 1
          options.timeout = timeout
        end

        opts.on("-u",
                "--username USER",
                String,
                "Username for basic or digest authentication, defaults to " \
                "#{options.auth.username.inspect}") do |username|
          options.auth.username = username
        end

        opts.on("-p",
                "--password PASSWORD",
                String,
                "Password for basic, digest and x509 authentication") do |password|
          options.auth.password = password
          options.auth.user_cert_password = password
        end

        opts.on("-c",
                "--ca-path PATH",
                String,
                "Path to CA certificates directory, defaults to #{options.auth.ca_path.inspect}") do |ca_path|
          raise ArgumentError, "Path specified in --ca-path is not a directory!" unless File.directory? ca_path
          raise ArgumentError, "Path specified in --ca-path is not readable!" unless File.readable? ca_path

          options.auth.ca_path = ca_path
        end

        opts.on("-f",
                "--ca-file PATH",
                String,
                "Path to CA certificates in a file") do |ca_file|
          raise ArgumentError, "File specified in --ca-file is not a file!" unless File.file? ca_file
          raise ArgumentError, "File specified in --ca-file is not readable!" unless File.readable? ca_file

          options.auth.ca_file = ca_file
        end

        opts.on("-s",
                "--skip-ca-check",
                "Skip server certificate verification [NOT recommended]") do
          silence_warnings { OpenSSL::SSL.const_set(:VERIFY_PEER, OpenSSL::SSL::VERIFY_NONE) }
        end

        opts.on("-F",
                "--filter CATEGORY",
                String,
                "Category type identifier to filter categories from model, must " \
                "be used together with the -m option") do |filter|
          options.filter = filter
        end

        opts.on("-x",
                "--user-cred FILE",
                String,
                "Path to user's x509 credentials, defaults to #{options.auth.user_cert.inspect}") do |user_cred|
          raise ArgumentError, "File specified in --user-cred is not a file!" unless File.file? user_cred
          raise ArgumentError, "File specified in --user-cred is not readable!" unless File.readable? user_cred

          options.auth.user_cert = user_cred
        end

        opts.on("-X",
                "--voms",
                "Using VOMS credentials; modifies behavior of the X509 authN module") do |voms|

          options.auth.voms = true
        end

        opts.on("-q",
                "--token TOKEN",
                String,
                "A pre-generated token to be used for authentication purposes") do |token|
          raise ArgumentError, "Token cannot be blank!" if token.blank?

          options.auth.token = token
        end

        opts.on("-y",
                "--media-type MEDIA_TYPE",
                MEDIA_TYPES,
                "Media type for client <-> server communication, only: [#{MEDIA_TYPES.join('|')}], " \
                "defaults to #{options.media_type.inspect}") do |media_type|
          options.media_type = media_type
        end

        opts.on("-r",
                "--resource RESOURCE",
                String,
                "Term, identifier or URI of a resource to be queried, required") do |resource|
          options.resource = resource
        end

        opts.on("-t",
                "--attribute ATTR",
                Array,
                "An \"attribute='value'\" pair, mandatory attrs for creating new resource instances: " \
                "[#{REQ_CREATE_ATTRS.join(', ')}]") do |attributes|
          options.attributes ||= Occi::Core::Attributes.new

          attributes.each do |attribute|
            key, value = Occi::Cli::OcciOpts::Helper.parse_attribute(attribute)
            options.attributes[key] = value
          end
        end

        opts.on("-T",
                "--context CTX_VAR",
                Array,
                "A \"context_variable='value'\" pair for new 'compute' resource instances, " \
                "only: [#{Occi::Cli::OcciOpts::Helper::ALLOWED_CONTEXT_VARS.join(', ')}]") do |context|
          options.context_vars ||= {}

          context.each do |ctx|
            key, value = Occi::Cli::OcciOpts::Helper.parse_context_variable(ctx)
            options.context_vars[key] = value
          end
        end

        opts.on("-a",
                "--action ACTION",
                ACTIONS,
                "Action to be performed on a resource instance, required") do |action|
          options.action = action
        end

        opts.on("-M",
                "--mixin IDENTIFIER",
                Array,
                "Identifier of a mixin, formatted as SCHEME#TERM or SHORT_SCHEME#TERM") do |mixins|
          options.mixins ||= Occi::Core::Mixins.new

          mixins.each do |mixin|
            options.mixins << Occi::Cli::OcciOpts::Helper.parse_mixin(mixin)
          end
        end

        opts.on("-j",
                "--link URI",
                Array,
                "URI of an instance to be linked with the given resource, applicable only for action 'link'") do |links|
          options.links ||= []

          links.each do |link|
            link_relative_path = URI(link).path
            raise ArgumentError, "Specified link URI is not valid!" unless link_relative_path.start_with? '/'
            options.links << link_relative_path
          end
        end

        opts.on("-g",
                "--trigger-action ACTION",
                String,
                "Action to be triggered on the resource, formatted as SCHEME#TERM or TERM") do |trigger_action|
          options.trigger_action = Occi::Cli::OcciOpts::Helper.parse_action(trigger_action)
        end

        opts.on("-i",
                "--entity-type TYPE",
                ENTITY_TYPES,
                "Entity types to perform discovery on, only: [#{ENTITY_TYPES.join('|')}]") do |entity_type|
          options.entity_type = entity_type
        end

        opts.on("-l",
                "--log-to OUTPUT",
                LOG_OUTPUTS,
                "Log to the specified device, only: [#{LOG_OUTPUTS.join('|')}], defaults to 'stderr'") do |log_to|
          options.log.out = STDOUT if log_to.to_s == "stdout"
        end

        opts.on("-o",
                "--output-format FORMAT",
                Occi::Cli::ResourceOutputFactory.allowed_formats,
                "Output format, only: [#{Occi::Cli::ResourceOutputFactory.allowed_formats.join('|')}], " \
                "defaults to #{options.output_format.to_s.inspect}") do |output_format|
          options.output_format = output_format
        end

        opts.on("-b",
                "--log-level LEVEL",
                LOG_LEVELS,
                "Set the specified logging level, only: [#{LOG_LEVELS.join('|')}]") do |log_level|
          unless options.log.level == Occi::Cli::Log::DEBUG
            options.log.level = Occi::Cli::Log.const_get(log_level.to_s.upcase)
          end
        end

        opts.on("-w",
                "--wait-for-active TIMEOUT",
                Integer,
                "Wait for TIMEOUT seconds for the created resource to become 'active' before returning, " \
                "defaults to 0 seconds (will be interpreted as 'disabled')") do |wait_for_active|
          options.wait_for_active = wait_for_active
        end

        opts.on_tail("-z",
                     "--examples",
                     "Show usage examples") do |examples|
          if examples
            if @@quiet
              exit true
            else
              file = "#{File.expand_path('..', __FILE__)}/occi_opts/cli_examples.erb"
              template = ERB.new(File.new(file).read, nil, '-')

              puts template.result(binding)
              exit! true
            end
          end
        end

        opts.on_tail("-m",
                     "--dump-model",
                     "Contact the endpoint and dump its model") do |dump_model|
          options.dump_model = dump_model
        end

        opts.on_tail("-d",
                     "--debug",
                     "Enable debugging messages") do |debug|
          options.debug = debug
          options.log.level = Occi::Cli::Log::DEBUG
        end

        opts.on_tail("-h",
                     "--help",
                     "Show this message") do
          if @@quiet
            exit true
          else
            puts opts
            exit! true
          end
        end

        opts.on_tail("-v",
                     "--version",
                     "Show version") do
          if @@quiet
            exit true
          else
            if options.debug
              puts "CLI:  #{Occi::Cli::VERSION}"
              puts "API:  #{Occi::Api::VERSION}"
              puts "Core: #{Occi::VERSION}"
            else
              puts Occi::Cli::VERSION
            end
            exit! true
          end
        end
      end

      begin
        opts.parse!(args)
      rescue Exception => ex
        if @@quiet
          exit false
        else
          puts ex.message.capitalize
          puts opts
          exit!
        end
      end

      check_restrictions options, opts

      options
    end

    private

    def self.set_defaults(options)
      options.debug = false

      options.log = {}
      options.log.out = STDERR
      options.log.level = Occi::Cli::Log::ERROR

      options.filter = nil
      options.dump_model = false

      options.endpoint = "http://localhost:3000"
      options.timeout = nil
      options.wait_for_active = 0

      options.auth = {}
      options.auth.type = "none"
      options.auth.user_cert = "#{ENV['HOME']}/.globus/usercred.pem"
      options.auth.ca_path = "/etc/grid-security/certificates"
      options.auth.username = "anonymous"
      options.auth.ca_file = nil
      options.auth.voms = nil

      options.output_format = :plain

      options.mixins = Occi::Core::Mixins.new
      options.links = nil
      options.attributes = Occi::Core::Attributes.new
      options.context_vars = nil

      # TODO: change media type back to occi+json after the rOCCI-server update
      #options.media_type = "application/occi+json"
      options.media_type = "text/plain,text/occi"

      options
    end

    def self.check_restrictions(options, opts)
      check_incompatible_args(options, opts)

      return if options.dump_model

      mandatory = get_mandatory_args(options)
      check_hash(options, mandatory, opts)

      check_attributes(options.attributes, REQ_CREATE_ATTRS, opts) if options.action == :create
    end

    def self.check_incompatible_args(options, opts)
      if !options.dump_model && options.filter
        if @@quiet
          exit false
        else
          puts "You cannot use '--filter' without '--dump-model'!"
          puts opts
          exit!
        end
      end

      if options.voms && options.auth.type != "x509"
        if @@quiet
          exit false
        else
          puts "You cannot use '--voms' without '--auth x509'!"
          puts opts
          exit!
        end
      end
    end

    def self.get_mandatory_args(options)
      mandatory = []

      if options.action == :trigger
        mandatory << :trigger_action
      end

      if options.action == :create
        if options.mixins.blank? && options.resource == 'compute'
          mandatory << :links
        end

        mandatory << :attributes
      end

      if options.action == :link
        mandatory << :links
      end

      if options.action == :discover
        mandatory.concat [:entity_type]
      else
        mandatory.concat [:resource, :action]
      end

      if options.action == :update
        mandatory << :mixins
      end

      mandatory
    end

    def self.check_hash(hash, mandatory, opts)
      unless hash.is_a?(Hash)
        hash = hash.marshal_dump
      end

      missing = mandatory.select { |param| hash[param].blank? }
      report_missing missing, opts
    end

    def self.check_attributes(attributes, mandatory, opts)
      missing = []
      attributes = Occi::Core::Attributes.new(attributes)

      mandatory.each do |attribute|
        begin
          attributes[attribute]
          raise Occi::Errors::AttributeMissingError,
                "Attribute #{attribute.inspect} is empty!" unless attributes[attribute]
        rescue Occi::Errors::AttributeMissingError
          missing << attribute
        end
      end

      report_missing missing, opts
    end

    def self.report_missing(missing, opts)
      unless missing.empty?
        if @@quiet
          exit false
        else
          puts "Missing required arguments: #{missing.join(', ')}"
          puts opts
          exit!
        end
      end
    end
  end

end