From 5780f5dcc024d4f140fe8f6e8dc3f7c4e905a8ec Mon Sep 17 00:00:00 2001 From: elijah Date: Wed, 29 Jun 2016 16:55:06 -0700 Subject: leap cli: move everything we can from leap_cli to leap_platform --- lib/leap/platform.rb | 99 +++++++++ lib/leap_cli/commands/deploy.rb | 4 + lib/leap_cli/config/environment.rb | 180 +++++++++++++++ lib/leap_cli/config/filter.rb | 178 +++++++++++++++ lib/leap_cli/config/manager.rb | 422 +++++++++++++++++++++++++++++++++++ lib/leap_cli/config/node.rb | 78 +++++++ lib/leap_cli/config/object.rb | 428 ++++++++++++++++++++++++++++++++++++ lib/leap_cli/config/object_list.rb | 209 ++++++++++++++++++ lib/leap_cli/config/provider.rb | 22 ++ lib/leap_cli/config/secrets.rb | 87 ++++++++ lib/leap_cli/config/sources.rb | 11 + lib/leap_cli/config/tag.rb | 25 +++ lib/leap_cli/leapfile_extensions.rb | 24 ++ lib/leap_cli/load_libraries.rb | 20 ++ lib/leap_cli/log_filter.rb | 171 ++++++++++++++ lib/leap_cli/macros.rb | 16 -- lib/leap_cli/ssh/backend.rb | 5 +- lib/leap_cli/ssh/remote_command.rb | 3 + lib/leap_cli/ssh/scripts.rb | 8 +- lib/leap_cli/util/secret.rb | 55 +++++ lib/leap_cli/util/x509.rb | 33 +++ 21 files changed, 2058 insertions(+), 20 deletions(-) create mode 100644 lib/leap/platform.rb create mode 100644 lib/leap_cli/config/environment.rb create mode 100644 lib/leap_cli/config/filter.rb create mode 100644 lib/leap_cli/config/manager.rb create mode 100644 lib/leap_cli/config/node.rb create mode 100644 lib/leap_cli/config/object.rb create mode 100644 lib/leap_cli/config/object_list.rb create mode 100644 lib/leap_cli/config/provider.rb create mode 100644 lib/leap_cli/config/secrets.rb create mode 100644 lib/leap_cli/config/sources.rb create mode 100644 lib/leap_cli/config/tag.rb create mode 100644 lib/leap_cli/leapfile_extensions.rb create mode 100644 lib/leap_cli/load_libraries.rb create mode 100644 lib/leap_cli/log_filter.rb delete mode 100644 lib/leap_cli/macros.rb create mode 100644 lib/leap_cli/util/secret.rb create mode 100644 lib/leap_cli/util/x509.rb (limited to 'lib') diff --git a/lib/leap/platform.rb b/lib/leap/platform.rb new file mode 100644 index 00000000..9e6cadd5 --- /dev/null +++ b/lib/leap/platform.rb @@ -0,0 +1,99 @@ +module Leap + + class Platform + class << self + # + # configuration + # + + attr_reader :version + attr_reader :compatible_cli + attr_accessor :facts + attr_accessor :paths + attr_accessor :node_files + attr_accessor :monitor_username + attr_accessor :reserved_usernames + + attr_accessor :hiera_dir + attr_accessor :hiera_path + attr_accessor :files_dir + attr_accessor :leap_dir + attr_accessor :init_path + + attr_accessor :default_puppet_tags + + def define(&block) + # some defaults: + @reserved_usernames = [] + @hiera_dir = '/etc/leap' + @hiera_path = '/etc/leap/hiera.yaml' + @leap_dir = '/srv/leap' + @files_dir = '/srv/leap/files' + @init_path = '/srv/leap/initialized' + @default_puppet_tags = [] + + self.instance_eval(&block) + + @version ||= Gem::Version.new("0.0") + end + + def validate!(cli_version, compatible_platforms, leapfile) + if !compatible_with_cli?(cli_version) || !version_in_range?(compatible_platforms) + raise StandardError, "This leap command (v#{cli_version}) " + + "is not compatible with the platform #{leapfile.platform_directory_path} (v#{version}).\n " + + "You need either leap command #{compatible_cli.first} to #{compatible_cli.last} or " + + "platform version #{compatible_platforms.first} to #{compatible_platforms.last}" + end + end + + def version=(version) + @version = Gem::Version.new(version) + end + + def compatible_cli=(range) + @compatible_cli = range + @minimum_cli_version = Gem::Version.new(range.first) + @maximum_cli_version = Gem::Version.new(range.last) + end + + # + # return true if the cli_version is compatible with this platform. + # + def compatible_with_cli?(cli_version) + cli_version = Gem::Version.new(cli_version) + cli_version >= @minimum_cli_version && cli_version <= @maximum_cli_version + end + + # + # return true if the platform version is within the specified range. + # + def version_in_range?(range) + if range.is_a? String + range = range.split('..') + end + minimum_platform_version = Gem::Version.new(range.first) + maximum_platform_version = Gem::Version.new(range.last) + @version >= minimum_platform_version && @version <= maximum_platform_version + end + + def major_version + if @version.segments.first == 0 + @version.segments[0..1].join('.') + else + @version.segments.first + end + end + + def method_missing(method, *args) + puts + puts "WARNING:" + puts " leap_cli is out of date and does not understand `#{method}`." + puts " called from: #{caller.first}" + puts " please upgrade to a newer leap_cli" + end + + end + + end + +end \ No newline at end of file diff --git a/lib/leap_cli/commands/deploy.rb b/lib/leap_cli/commands/deploy.rb index 165ce588..d26b9905 100644 --- a/lib/leap_cli/commands/deploy.rb +++ b/lib/leap_cli/commands/deploy.rb @@ -89,6 +89,10 @@ module LeapCli end end + if nodes.empty? + return + end + log :synching, "configuration files" do sync_hiera_config(nodes, options) sync_support_files(nodes, options) diff --git a/lib/leap_cli/config/environment.rb b/lib/leap_cli/config/environment.rb new file mode 100644 index 00000000..398fd023 --- /dev/null +++ b/lib/leap_cli/config/environment.rb @@ -0,0 +1,180 @@ +# +# All configurations files can be isolated into separate environments. +# +# Each config json in each environment inherits from the default environment, +# which in term inherits from the "_base_" environment: +# +# _base_ -- base provider in leap_platform +# '- default -- environment in provider dir when no env is set +# '- production -- example environment +# + +module LeapCli; module Config + + class Environment + # the String name of the environment + attr_accessor :name + + # the shared Manager object + attr_accessor :manager + + # hashes of {name => Config::Object} + attr_accessor :services, :tags, :partials + + # a Config::Provider + attr_accessor :provider + + # a Config::Object + attr_accessor :common + + # shared, non-inheritable + def nodes; @@nodes; end + def secrets; @@secrets; end + + def initialize(manager, name, search_dir, parent, options={}) + @@nodes ||= nil + @@secrets ||= nil + + @manager = manager + @name = name + + load_provider_files(search_dir, options) + + if parent + @services.inherit_from! parent.services, self + @tags.inherit_from! parent.tags , self + @partials.inherit_from! parent.partials, self + @common.inherit_from! parent.common + @provider.inherit_from! parent.provider + end + + if @provider + @provider.set_env(name) + @provider.validate! + end + end + + def load_provider_files(search_dir, options) + # + # load empty environment if search_dir doesn't exist + # + if search_dir.nil? || !Dir.exist?(search_dir) + @services = Config::ObjectList.new + @tags = Config::ObjectList.new + @partials = Config::ObjectList.new + @provider = Config::Provider.new + @common = Config::Object.new + return + end + + # + # inheritable + # + if options[:scope] + scope = options[:scope] + @services = load_all_json(Path.named_path([:service_env_config, '*', scope], search_dir), Config::Tag, options) + @tags = load_all_json(Path.named_path([:tag_env_config, '*', scope], search_dir), Config::Tag, options) + @partials = load_all_json(Path.named_path([:service_env_config, '_*', scope], search_dir), Config::Tag, options) + @provider = load_json( Path.named_path([:provider_env_config, scope], search_dir), Config::Provider, options) + @common = load_json( Path.named_path([:common_env_config, scope], search_dir), Config::Object, options) + else + @services = load_all_json(Path.named_path([:service_config, '*'], search_dir), Config::Tag, options) + @tags = load_all_json(Path.named_path([:tag_config, '*'], search_dir), Config::Tag, options) + @partials = load_all_json(Path.named_path([:service_config, '_*'], search_dir), Config::Tag, options) + @provider = load_json( Path.named_path(:provider_config, search_dir), Config::Provider, options) + @common = load_json( Path.named_path(:common_config, search_dir), Config::Object, options) + end + + # remove 'name' from partials, since partials get merged with nodes + @partials.values.each {|partial| partial.delete('name'); } + + # + # shared: currently non-inheritable + # load the first ones we find, and only those. + # + if @@nodes.nil? || @@nodes.empty? + @@nodes = load_all_json(Path.named_path([:node_config, '*'], search_dir), Config::Node, options) + end + if @@secrets.nil? || @@secrets.empty? + @@secrets = load_json(Path.named_path(:secrets_config, search_dir), Config::Secrets, options) + end + end + + # + # Loads a json template file as a Hash (used only when creating a new node .json + # file for the first time). + # + def template(template) + path = Path.named_path([:template_config, template], Path.provider_base) + if File.exist?(path) + return load_json(path, Config::Object) + else + return nil + end + end + + private + + def load_all_json(pattern, object_class, options={}) + results = Config::ObjectList.new + Dir.glob(pattern).each do |filename| + next if options[:no_dots] && File.basename(filename) !~ /^[^\.]*\.json$/ + obj = load_json(filename, object_class) + if obj + name = File.basename(filename).force_encoding('utf-8').sub(/^([^\.]+).*\.json$/,'\1') + obj['name'] ||= name + if options[:env] + obj.environment = options[:env] + end + results[name] = obj + end + end + results + end + + def load_json(filename, object_class, options={}) + if !File.exist?(filename) + return object_class.new(self) + end + + Util::log :loading, filename, 3 + + # + # Read a JSON file, strip out comments. + # + # UTF8 is the default encoding for JSON, but others are allowed: + # https://www.ietf.org/rfc/rfc4627.txt + # + buffer = StringIO.new + File.open(filename, "rb", :encoding => 'UTF-8') do |f| + while (line = f.gets) + next if line =~ /^\s*\/\// + buffer << line + end + end + + # + # force UTF-8 + # + if $ruby_version >= [1,9] + string = buffer.string.force_encoding('utf-8') + else + string = Iconv.conv("UTF-8//IGNORE", "UTF-8", buffer.string) + end + + # parse json + begin + hash = JSON.parse(string, :object_class => Hash, :array_class => Array) || {} + rescue SyntaxError, JSON::ParserError => exc + Util::log 0, :error, 'in file "%s":' % filename + Util::log 0, exc.to_s, :indent => 1 + return nil + end + object = object_class.new(self) + object.deep_merge!(hash) + return object + end + + end # end Environment + +end; end \ No newline at end of file diff --git a/lib/leap_cli/config/filter.rb b/lib/leap_cli/config/filter.rb new file mode 100644 index 00000000..27502577 --- /dev/null +++ b/lib/leap_cli/config/filter.rb @@ -0,0 +1,178 @@ +# +# Many leap_cli commands accept a list of filters to select a subset of nodes for the command to +# be applied to. This class is a helper for manager to run these filters. +# +# Classes other than Manager should not use this class. +# +# Filter rules: +# +# * A filter consists of a list of tokens +# * A token may be a service name, tag name, environment name, or node name. +# * Each token may be optionally prefixed with a plus sign. +# * Multiple tokens with a plus are treated as an OR condition, +# but treated as an AND condition with the plus sign. +# +# For example +# +# * openvpn +development => all nodes with service 'openvpn' AND environment 'development' +# * openvpn seattle => all nodes with service 'openvpn' OR tag 'seattle'. +# +# There can only be one environment specified. Typically, there are also tags +# for each environment name. These name are treated as environments, not tags. +# +module LeapCli + module Config + class Filter + + # + # filter -- array of strings, each one a filter + # options -- hash, possible keys include + # :nopin -- disregard environment pinning + # :local -- if false, disallow local nodes + # + # A nil value in the filters array indicates + # the default environment. This is in order to support + # calls like `manager.filter(environments)` + # + def initialize(filters, options, manager) + @filters = filters.nil? ? [] : filters.dup + @environments = [] + @options = options + @manager = manager + + # split filters by pulling out items that happen + # to be environment names. + if LeapCli.leapfile.environment.nil? || @options[:nopin] + @environments = [] + else + @environments = [LeapCli.leapfile.environment] + end + @filters.select! do |filter| + if filter.nil? + @environments << nil unless @environments.include?(nil) + false + else + filter_text = filter.sub(/^\+/,'') + if is_environment?(filter_text) + if filter_text == LeapCli.leapfile.environment + # silently ignore already pinned environments + elsif (filter =~ /^\+/ || @filters.first == filter) && !@environments.empty? + LeapCli::Util.bail! do + LeapCli.log "Environments are exclusive: no node is in two environments." do + LeapCli.log "Tried to filter on '#{@environments.join('\' AND \'')}' AND '#{filter_text}'" + end + end + else + @environments << filter_text + end + false + else + true + end + end + end + + # don't let the first filter have a + prefix + if @filters[0] =~ /^\+/ + @filters[0] = @filters[0][1..-1] + end + end + + # actually run the filter, returns a filtered list of nodes + def nodes() + if @filters.empty? + return nodes_for_empty_filter + else + return nodes_for_filter + end + end + + private + + def nodes_for_empty_filter + node_list = @manager.nodes + if @environments.any? + node_list = node_list[ @environments.collect{|e|[:environment, env_to_filter(e)]} ] + end + if @options[:local] === false + node_list = node_list[:environment => '!local'] + end + if @options[:disabled] === false + node_list = node_list[:environment => '!disabled'] + end + node_list + end + + def nodes_for_filter + node_list = Config::ObjectList.new + @filters.each do |filter| + if filter =~ /^\+/ + keep_list = nodes_for_name(filter[1..-1]) + node_list.delete_if do |name, node| + if keep_list[name] + false + else + true + end + end + else + node_list.merge!(nodes_for_name(filter)) + end + end + node_list + end + + private + + # + # returns a set of nodes corresponding to a single name, + # where name could be a node name, service name, or tag name. + # + # For services and tags, we only include nodes for the + # environments that are active + # + def nodes_for_name(name) + if node = @manager.nodes[name] + return Config::ObjectList.new(node) + elsif @environments.empty? + if @manager.services[name] + return @manager.env('_all_').services[name].node_list + elsif @manager.tags[name] + return @manager.env('_all_').tags[name].node_list + else + LeapCli.log :warning, "filter '#{name}' does not match any node names, tags, services, or environments." + return Config::ObjectList.new + end + else + node_list = Config::ObjectList.new + if @manager.services[name] + @environments.each do |env| + node_list.merge!(@manager.env(env).services[name].node_list) + end + elsif @manager.tags[name] + @environments.each do |env| + node_list.merge!(@manager.env(env).tags[name].node_list) + end + else + LeapCli.log :warning, "filter '#{name}' does not match any node names, tags, services, or environments." + end + return node_list + end + end + + # + # when pinning, we use the name 'default' to specify nodes + # without an environment set, but when filtering, we need to filter + # on :environment => nil. + # + def env_to_filter(environment) + environment == 'default' ? nil : environment + end + + def is_environment?(text) + text == 'default' || @manager.environment_names.include?(text) + end + + end + end +end diff --git a/lib/leap_cli/config/manager.rb b/lib/leap_cli/config/manager.rb new file mode 100644 index 00000000..aea1d322 --- /dev/null +++ b/lib/leap_cli/config/manager.rb @@ -0,0 +1,422 @@ +# encoding: utf-8 + +require 'json/pure' + +if $ruby_version < [1,9] + require 'iconv' +end + +module LeapCli + module Config + + # + # A class to manage all the objects in all the configuration files. + # + class Manager + + def initialize + @environments = {} # hash of `Environment` objects, keyed by name. + Config::Object.send(:include, LeapCli::Macro) + end + + ## + ## ATTRIBUTES + ## + + # + # returns the Hash of the contents of facts.json + # + def facts + @facts ||= begin + content = Util.read_file(:facts) + if !content || content.empty? + content = "{}" + end + JSON.parse(content) + rescue SyntaxError, JSON::ParserError => exc + Util::bail! "Could not parse facts.json -- #{exc}" + end + end + + # + # returns an Array of all the environments defined for this provider. + # the returned array includes nil (for the default environment) + # + def environment_names + @environment_names ||= begin + [nil] + (env.tags.field('environment') + env.nodes.field('environment')).compact.uniq + end + end + + # + # Returns the appropriate environment variable + # + def env(env=nil) + @environments[env || 'default'] + end + + # + # The default accessors + # + # For these defaults, use 'default' environment, or whatever + # environment is pinned. + # + # I think it might be an error that these are ever used + # and I would like to get rid of them. + # + def services; env(default_environment).services; end + def tags; env(default_environment).tags; end + def partials; env(default_environment).partials; end + def provider; env(default_environment).provider; end + def common; env(default_environment).common; end + def secrets; env(default_environment).secrets; end + def nodes; env(default_environment).nodes; end + def template(*args) + self.env.template(*args) + end + + def default_environment + LeapCli.leapfile.environment + end + + ## + ## IMPORT EXPORT + ## + + def add_environment(args) + if args[:inherit] + parent = @environments[args.delete(:inherit)] + else + parent = nil + end + @environments[args[:name]] = Environment.new( + self, + args.delete(:name), + args.delete(:dir), + parent, + args + ) + end + + # + # load .json configuration files + # + def load(options = {}) + @provider_dir = Path.provider + + # load base + add_environment(name: '_base_', dir: Path.provider_base) + + # load provider + Util::assert_files_exist!(Path.named_path(:provider_config, @provider_dir)) + add_environment(name: 'default', dir: @provider_dir, + inherit: '_base_', no_dots: true) + + # create a special '_all_' environment, used for tracking + # the union of all the environments + add_environment(name: '_all_', inherit: 'default') + + # load environments + environment_names.each do |ename| + if ename + LeapCli.log 3, :loading, '%s environment...' % ename + add_environment(name: ename, dir: @provider_dir, + inherit: 'default', scope: ename) + end + end + + # apply inheritance + env.nodes.each do |name, node| + Util::assert! name =~ /^[0-9a-z-]+$/, "Illegal character(s) used in node name '#{name}'" + env.nodes[name] = apply_inheritance(node) + end + + # do some node-list post-processing + cleanup_node_lists(options) + + # apply control files + env.nodes.each do |name, node| + control_files(node).each do |file| + begin + node.eval_file file + rescue ConfigError => exc + if options[:continue_on_error] + exc.log + else + raise exc + end + end + end + end + end + + # + # save compiled hiera .yaml files + # + # if a node_list is specified, only update those .yaml files. + # otherwise, update all files, destroying files that are no longer used. + # + def export_nodes(node_list=nil) + updated_hiera = [] + updated_files = [] + existing_hiera = nil + existing_files = nil + + unless node_list + node_list = env.nodes + existing_hiera = Dir.glob(Path.named_path([:hiera, '*'], @provider_dir)) + existing_files = Dir.glob(Path.named_path([:node_files_dir, '*'], @provider_dir)) + end + + node_list.each_node do |node| + filepath = Path.named_path([:node_files_dir, node.name], @provider_dir) + hierapath = Path.named_path([:hiera, node.name], @provider_dir) + Util::write_file!(hierapath, node.dump_yaml) + updated_files << filepath + updated_hiera << hierapath + end + + if @disabled_nodes + # make disabled nodes appear as if they are still active + @disabled_nodes.each_node do |node| + updated_files << Path.named_path([:node_files_dir, node.name], @provider_dir) + updated_hiera << Path.named_path([:hiera, node.name], @provider_dir) + end + end + + # remove files that are no longer needed + if existing_hiera + (existing_hiera - updated_hiera).each do |filepath| + Util::remove_file!(filepath) + end + end + if existing_files + (existing_files - updated_files).each do |filepath| + Util::remove_directory!(filepath) + end + end + end + + def export_secrets(clean_unused_secrets = false) + if env.secrets.any? + Util.write_file!([:secrets_config, @provider_dir], env.secrets.dump_json(clean_unused_secrets) + "\n") + end + end + + ## + ## FILTERING + ## + + # + # returns a node list consisting only of nodes that satisfy the filter criteria. + # + # filter: condition [condition] [condition] [+condition] + # condition: [node_name | service_name | tag_name | environment_name] + # + # if conditions is prefixed with +, then it works like an AND. Otherwise, it works like an OR. + # + # args: + # filter -- array of filter terms, one per item + # + # options: + # :local -- if :local is false and the filter is empty, then local nodes are excluded. + # :nopin -- if true, ignore environment pinning + # + def filter(filters=nil, options={}) + Filter.new(filters, options, self).nodes() + end + + # + # same as filter(), but exits if there is no matching nodes + # + def filter!(filters, options={}) + node_list = filter(filters, options) + Util::assert! node_list.any?, "Could not match any nodes from '#{filters.join ' '}'" + return node_list + end + + # + # returns a single Config::Object that corresponds to a Node. + # + def node(name) + if name =~ /\./ + # probably got a fqdn, since periods are not allowed in node names. + # so, take the part before the first period as the node name + name = name.split('.').first + end + env.nodes[name] + end + + # + # returns a single node that is disabled + # + def disabled_node(name) + @disabled_nodes[name] + end + + # + # yields each node, in sorted order + # + def each_node(&block) + env.nodes.each_node(&block) + end + + def reload_node!(node) + env.nodes[node.name] = apply_inheritance!(node) + end + + ## + ## CONNECTIONS + ## + + class ConnectionList < Array + def add(data={}) + self << { + "from" => data[:from], + "to" => data[:to], + "port" => data[:port] + } + end + end + + def connections + @connections ||= ConnectionList.new + end + + ## + ## PRIVATE + ## + + private + + # + # makes a node inherit options from appropriate the common, service, and tag json files. + # + def apply_inheritance(node, throw_exceptions=false) + new_node = Config::Node.new(nil) + node_env = guess_node_env(node) + new_node.set_environment(node_env, new_node) + + # inherit from common + new_node.deep_merge!(node_env.common) + + # inherit from services + if node['services'] + node['services'].to_a.each do |node_service| + service = node_env.services[node_service] + if service.nil? + msg = 'in node "%s": the service "%s" does not exist.' % [node['name'], node_service] + LeapCli.log 0, :error, msg + raise LeapCli::ConfigError.new(node, "error " + msg) if throw_exceptions + else + new_node.deep_merge!(service) + end + end + end + + # inherit from tags + if node.vagrant? + node['tags'] = (node['tags'] || []).to_a + ['local'] + end + if node['tags'] + node['tags'].to_a.each do |node_tag| + tag = node_env.tags[node_tag] + if tag.nil? + msg = 'in node "%s": the tag "%s" does not exist.' % [node['name'], node_tag] + log 0, :error, msg + raise LeapCli::ConfigError.new(node, "error " + msg) if throw_exceptions + else + new_node.deep_merge!(tag) + end + end + end + + # inherit from node + new_node.deep_merge!(node) + return new_node + end + + def apply_inheritance!(node) + apply_inheritance(node, true) + end + + # + # Guess the environment of the node from the tag names. + # + # Technically, this is wrong: a tag that sets the environment might not be + # named the same as the environment. This code assumes that it is. + # + # Unfortunately, it is a chicken and egg problem. We need to know the nodes + # likely environment in order to apply the inheritance that will actually + # determine the node's properties. + # + def guess_node_env(node) + if node.vagrant? + return self.env("local") + else + environment = self.env(default_environment) + if node['tags'] + node['tags'].to_a.each do |tag| + if self.environment_names.include?(tag) + environment = self.env(tag) + end + end + end + return environment + end + end + + # + # does some final clean at the end of loading nodes. + # this includes removing disabled nodes, and populating + # the services[x].node_list and tags[x].node_list + # + def cleanup_node_lists(options) + @disabled_nodes = Config::ObjectList.new + env.nodes.each do |name, node| + if node.enabled || options[:include_disabled] + if node['services'] + node['services'].to_a.each do |node_service| + env(node.environment).services[node_service].node_list.add(node.name, node) + env('_all_').services[node_service].node_list.add(node.name, node) + end + end + if node['tags'] + node['tags'].to_a.each do |node_tag| + env(node.environment).tags[node_tag].node_list.add(node.name, node) + env('_all_').tags[node_tag].node_list.add(node.name, node) + end + end + elsif !options[:include_disabled] + LeapCli.log 2, :skipping, "disabled node #{name}." + env.nodes.delete(name) + @disabled_nodes[name] = node + end + end + end + + # + # returns a list of 'control' files for this node. + # a control file is like a service or a tag JSON file, but it contains + # raw ruby code that gets evaluated in the context of the node. + # Yes, this entirely breaks our functional programming model + # for JSON generation. + # + def control_files(node) + files = [] + [Path.provider_base, @provider_dir].each do |provider_dir| + [['services', :service_config], ['tags', :tag_config]].each do |attribute, path_sym| + node[attribute].each do |attr_value| + path = Path.named_path([path_sym, "#{attr_value}.rb"], provider_dir).sub(/\.json$/,'') + if File.exist?(path) + files << path + end + end + end + end + return files + end + + end + end +end diff --git a/lib/leap_cli/config/node.rb b/lib/leap_cli/config/node.rb new file mode 100644 index 00000000..f8ec0527 --- /dev/null +++ b/lib/leap_cli/config/node.rb @@ -0,0 +1,78 @@ +# +# Configuration for a 'node' (a server in the provider's infrastructure) +# + +require 'ipaddr' + +module LeapCli; module Config + + class Node < Object + attr_accessor :file_paths + + def initialize(environment=nil) + super(environment) + @node = self + @file_paths = [] + end + + # + # returns true if this node has an ip address in the range of the vagrant network + # + def vagrant? + begin + vagrant_range = IPAddr.new LeapCli.leapfile.vagrant_network + rescue ArgumentError => exc + Util::bail! { Util::log :invalid, "ip address '#{@node.ip_address}' vagrant.network" } + end + + begin + ip_address = IPAddr.new @node.get('ip_address') + rescue ArgumentError => exc + Util::log :warning, "invalid ip address '#{@node.get('ip_address')}' for node '#{@node.name}'" + end + return vagrant_range.include?(ip_address) + end + + # + # Return a hash table representation of ourselves, with the key equal to the @node.name, + # and the value equal to the fields specified in *keys. + # + # Also, the result is flattened to a single hash, so a key of 'a.b' becomes 'a_b' + # + # compare to Object#pick(*keys). This method is the sames as Config::ObjectList#pick_fields, + # but works on a single node. + # + # Example: + # + # node.pick('domain.internal') => + # + # { + # 'node1': { + # 'domain_internal': 'node1.example.i' + # } + # } + # + def pick_fields(*keys) + {@node.name => self.pick(*keys)} + end + + # + # can be overridden by the platform. + # returns a list of node names that should be tested before this node + # + def test_dependencies + [] + end + + # returns a string list of supported ssh host key algorithms for this node. + # or an empty string if it could not be determined + def supported_ssh_host_key_algorithms + require 'leap_cli/ssh' + @host_key_algo ||= LeapCli::SSH::Key.supported_host_key_algorithms( + Util.read_file([:node_ssh_pub_key, @node.name]) + ) + end + + end + +end; end diff --git a/lib/leap_cli/config/object.rb b/lib/leap_cli/config/object.rb new file mode 100644 index 00000000..b117c2f0 --- /dev/null +++ b/lib/leap_cli/config/object.rb @@ -0,0 +1,428 @@ +# encoding: utf-8 + +require 'erb' +require 'json/pure' # pure ruby implementation is required for our sorted trick to work. + +if $ruby_version < [1,9] + $KCODE = 'UTF8' +end +require 'ya2yaml' # pure ruby yaml + +module LeapCli + module Config + + # + # This class represents the configuration for a single node, service, or tag. + # Also, all the nested hashes are also of this type. + # + # It is called 'object' because it corresponds to an Object in JSON. + # + class Object < Hash + + attr_reader :env + attr_reader :node + + def initialize(environment=nil, node=nil) + raise ArgumentError unless environment.nil? || environment.is_a?(Config::Environment) + @env = environment + # an object that is a node as @node equal to self, otherwise all the + # child objects point back to the top level node. + @node = node || self + end + + def manager + @env.manager + end + + # + # TODO: deprecate node.global() + # + def global + @env + end + + def environment=(e) + self.store('environment', e) + end + + def environment + self['environment'] + end + + def duplicate(env) + new_object = self.deep_dup + new_object.set_environment(env, new_object) + end + + # + # export YAML + # + # We use pure ruby yaml exporter ya2yaml instead of SYCK or PSYCH because it + # allows us greater compatibility regardless of installed ruby version and + # greater control over how the yaml is exported (sorted keys, in particular). + # + def dump_yaml + evaluate(@node) + sorted_ya2yaml(:syck_compatible => true) + end + + # + # export JSON + # + def dump_json(options={}) + evaluate(@node) + if options[:format] == :compact + return self.to_json + else + excluded = {} + if options[:exclude] + options[:exclude].each do |key| + excluded[key] = self[key] + self.delete(key) + end + end + json_str = JSON.sorted_generate(self) + if excluded.any? + self.merge!(excluded) + end + return json_str + end + end + + def evaluate(context=@node) + evaluate_everything(context) + late_evaluate_everything(context) + end + + ## + ## FETCHING VALUES + ## + + def [](key) + get(key) + end + + # Overrride some default methods in Hash that are likely to + # be used as attributes. + alias_method :hkey, :key + def key; get('key'); end + + # + # make hash addressable like an object (e.g. obj['name'] available as obj.name) + # + def method_missing(method, *args, &block) + get!(method) + end + + def get(key) + begin + get!(key) + rescue NoMethodError + nil + end + end + + # override behavior of #default() from Hash + def default + get!('default') + end + + # + # Like a normal Hash#[], except: + # + # (1) lazily eval dynamic values when we encounter them. (i.e. strings that start with "= ") + # + # (2) support for nested references in a single string (e.g. ['a.b'] is the same as ['a']['b']) + # the dot path is always absolute, starting at the top-most object. + # + def get!(key) + key = key.to_s + if self.has_key?(key) + fetch_value(key) + elsif key =~ /\./ + # for keys with with '.' in them, we start from the root object (@node). + keys = key.split('.') + value = self.get!(keys.first) + if value.is_a? Config::Object + value.get!(keys[1..-1].join('.')) + else + value + end + else + raise NoMethodError.new(key, "No method '#{key}' for #{self.class}") + end + end + + ## + ## COPYING + ## + + # + # A deep (recursive) merge with another Config::Object. + # + # If prefer_self is set to true, the value from self will be picked when there is a conflict + # that cannot be merged. + # + # Merging rules: + # + # - If a value is a hash, we recursively merge it. + # - If the value is simple, like a string, the new one overwrites the value. + # - If the value is an array: + # - If both old and new values are arrays, the new one replaces the old. + # - If one of the values is simple but the other is an array, the simple is added to the array. + # + def deep_merge!(object, prefer_self=false) + object.each do |key,new_value| + if self.has_key?('+'+key) + mode = :add + old_value = self.fetch '+'+key, nil + self.delete('+'+key) + elsif self.has_key?('-'+key) + mode = :subtract + old_value = self.fetch '-'+key, nil + self.delete('-'+key) + elsif self.has_key?('!'+key) + mode = :replace + old_value = self.fetch '!'+key, nil + self.delete('!'+key) + else + mode = :normal + old_value = self.fetch key, nil + end + + # clean up boolean + new_value = true if new_value == "true" + new_value = false if new_value == "false" + old_value = true if old_value == "true" + old_value = false if old_value == "false" + + # force replace? + if mode == :replace && prefer_self + value = old_value + + # merge hashes + elsif old_value.is_a?(Hash) || new_value.is_a?(Hash) + value = Config::Object.new(@env, @node) + old_value.is_a?(Hash) ? value.deep_merge!(old_value) : (value[key] = old_value if !old_value.nil?) + new_value.is_a?(Hash) ? value.deep_merge!(new_value, prefer_self) : (value[key] = new_value if !new_value.nil?) + + # merge nil + elsif new_value.nil? + value = old_value + elsif old_value.nil? + value = new_value + + # merge arrays when one value is not an array + elsif old_value.is_a?(Array) && !new_value.is_a?(Array) + (value = (old_value.dup << new_value).compact.uniq).delete('REQUIRED') + elsif new_value.is_a?(Array) && !old_value.is_a?(Array) + (value = (new_value.dup << old_value).compact.uniq).delete('REQUIRED') + + # merge two arrays + elsif old_value.is_a?(Array) && new_value.is_a?(Array) + if mode == :add + value = (old_value + new_value).sort.uniq + elsif mode == :subtract + value = new_value - old_value + elsif prefer_self + value = old_value + else + value = new_value + end + + # catch errors + elsif type_mismatch?(old_value, new_value) + raise 'Type mismatch. Cannot merge %s (%s) with %s (%s). Key is "%s", name is "%s".' % [ + old_value.inspect, old_value.class, + new_value.inspect, new_value.class, + key, self.class + ] + + # merge simple strings & numbers + else + if prefer_self + value = old_value + else + value = new_value + end + end + + # save value + self[key] = value + end + self + end + + def set_environment(env, node) + @env = env + @node = node + self.each do |key, value| + if value.is_a?(Config::Object) + value.set_environment(env, node) + end + end + end + + # + # like a reverse deep merge + # (self takes precedence) + # + def inherit_from!(object) + self.deep_merge!(object, true) + end + + # + # Make a copy of ourselves, except only including the specified keys. + # + # Also, the result is flattened to a single hash, so a key of 'a.b' becomes 'a_b' + # + def pick(*keys) + keys.map(&:to_s).inject(self.class.new(@manager)) do |hsh, key| + value = self.get(key) + if !value.nil? + hsh[key.gsub('.','_')] = value + end + hsh + end + end + + def eval_file(filename) + evaluate_ruby(filename, File.read(filename)) + end + + protected + + # + # walks the object tree, eval'ing all the attributes that are dynamic ruby (e.g. value starts with '= ') + # + def evaluate_everything(context) + keys.each do |key| + obj = fetch_value(key, context) + if is_required_value_not_set?(obj) + Util::log 0, :warning, "required property \"#{key}\" is not set in node \"#{node.name}\"." + elsif obj.is_a? Config::Object + obj.evaluate_everything(context) + end + end + end + + # + # some keys need to be evaluated 'late', after all the other keys have been evaluated. + # + def late_evaluate_everything(context) + if @late_eval_list + @late_eval_list.each do |key, value| + self[key] = context.evaluate_ruby(key, value) + if is_required_value_not_set?(self[key]) + Util::log 0, :warning, "required property \"#{key}\" is not set in node \"#{node.name}\"." + end + end + end + values.each do |obj| + if obj.is_a? Config::Object + obj.late_evaluate_everything(context) + end + end + end + + # + # evaluates the string `value` as ruby in the context of self. + # (`key` is just passed for debugging purposes) + # + def evaluate_ruby(key, value) + self.instance_eval(value, key, 1) + rescue ConfigError => exc + raise exc # pass through + rescue SystemStackError => exc + Util::log 0, :error, "while evaluating node '#{self.name}'" + Util::log 0, "offending key: #{key}", :indent => 1 + Util::log 0, "offending string: #{value}", :indent => 1 + Util::log 0, "STACK OVERFLOW, BAILING OUT. There must be an eval loop of death (variables with circular dependencies).", :indent => 1 + raise SystemExit.new(1) + rescue FileMissing => exc + Util::bail! do + if exc.options[:missing] + Util::log :missing, exc.options[:missing].gsub('$node', self.name).gsub('$file', exc.path) + else + Util::log :error, "while evaluating node '#{self.name}'" + Util::log "offending key: #{key}", :indent => 1 + Util::log "offending string: #{value}", :indent => 1 + Util::log "error message: no file '#{exc}'", :indent => 1 + end + raise exc if DEBUG + end + rescue AssertionFailed => exc + Util.bail! do + Util::log :failed, "assertion while evaluating node '#{self.name}'" + Util::log 'assertion: %s' % exc.assertion, :indent => 1 + Util::log "offending key: #{key}", :indent => 1 + raise exc if DEBUG + end + rescue SyntaxError, StandardError => exc + Util::bail! do + Util::log :error, "while evaluating node '#{self.name}'" + Util::log "offending key: #{key}", :indent => 1 + Util::log "offending string: #{value}", :indent => 1 + Util::log "error message: #{exc.inspect}", :indent => 1 + raise exc if DEBUG + end + end + + private + + # + # fetches the value for the key, evaluating the value as ruby if it begins with '=' + # + def fetch_value(key, context=@node) + value = fetch(key, nil) + if value.is_a?(String) && value =~ /^=/ + if value =~ /^=> (.*)$/ + value = evaluate_later(key, $1) + elsif value =~ /^= (.*)$/ + value = context.evaluate_ruby(key, $1) + end + self[key] = value + end + return value + end + + def evaluate_later(key, value) + @late_eval_list ||= [] + @late_eval_list << [key, value] + '' + end + + # + # when merging, we raise an error if this method returns true for the two values. + # + def type_mismatch?(old_value, new_value) + if old_value.is_a?(Boolean) && new_value.is_a?(Boolean) + # note: FalseClass and TrueClass are different classes + # so we can't do old_value.class == new_value.class + return false + elsif old_value.is_a?(String) && old_value =~ /^=/ + # pass through macros, since we don't know what the type will eventually be. + return false + elsif new_value.is_a?(String) && new_value =~ /^=/ + return false + elsif old_value.class == new_value.class + return false + else + return true + end + end + + # + # returns true if the value has not been changed and the default is "REQUIRED" + # + def is_required_value_not_set?(value) + if value.is_a? Array + value == ["REQUIRED"] + else + value == "REQUIRED" + end + end + + end # class + end # module +end # module \ No newline at end of file diff --git a/lib/leap_cli/config/object_list.rb b/lib/leap_cli/config/object_list.rb new file mode 100644 index 00000000..f9299a61 --- /dev/null +++ b/lib/leap_cli/config/object_list.rb @@ -0,0 +1,209 @@ +require 'tsort' + +module LeapCli + module Config + # + # A list of Config::Object instances (internally stored as a hash) + # + class ObjectList < Hash + include TSort + + def initialize(config=nil) + if config + self.add(config['name'], config) + end + end + + # + # If the key is a string, the Config::Object it references is returned. + # + # If the key is a hash, we treat it as a condition and filter all the Config::Objects using the condition. + # A new ObjectList is returned. + # + # Examples: + # + # nodes['vpn1'] + # node named 'vpn1' + # + # nodes[:public_dns => true] + # all nodes with public dns + # + # nodes[:services => 'openvpn', 'location.country_code' => 'US'] + # all nodes with services containing 'openvpn' OR country code of US + # + # Sometimes, you want to do an OR condition with multiple conditions + # for the same field. Since hash keys must be unique, you can use + # an array representation instead: + # + # nodes[[:services, 'openvpn'], [:services, 'tor']] + # nodes with openvpn OR tor service + # + # nodes[:services => 'openvpn'][:tags => 'production'] + # nodes with openvpn AND are production + # + def [](key) + if key.is_a?(Hash) || key.is_a?(Array) + filter(key) + else + super key.to_s + end + end + + def exclude(node) + list = self.dup + list.delete(node.name) + return list + end + + def each_node(&block) + self.keys.sort.each do |node_name| + yield self[node_name] + end + end + + # + # filters this object list, producing a new list. + # filter is an array or a hash. see [] + # + def filter(filter) + results = Config::ObjectList.new + filter.each do |field, match_value| + field = field.is_a?(Symbol) ? field.to_s : field + match_value = match_value.is_a?(Symbol) ? match_value.to_s : match_value + if match_value.is_a?(String) && match_value =~ /^!/ + operator = :not_equal + match_value = match_value.sub(/^!/, '') + else + operator = :equal + end + each do |name, config| + value = config[field] + if value.is_a? Array + if operator == :equal && value.include?(match_value) + results[name] = config + elsif operator == :not_equal && !value.include?(match_value) + results[name] = config + end + else + if operator == :equal && value == match_value + results[name] = config + elsif operator == :not_equal && value != match_value + results[name] = config + end + end + end + end + results + end + + def add(name, object) + self[name] = object + end + + # + # converts the hash of configs into an array of hashes, with ONLY the specified fields + # + def fields(*fields) + result = [] + keys.sort.each do |name| + result << self[name].pick(*fields) + end + result + end + + # + # like fields(), but returns an array of values instead of an array of hashes. + # + def field(field) + field = field.to_s + result = [] + keys.sort.each do |name| + result << self[name].get(field) + end + result + end + + # + # pick_fields(field1, field2, ...) + # + # generates a Hash from the object list, but with only the fields that are picked. + # + # If there are more than one field, then the result is a Hash of Hashes. + # If there is just one field, it is a simple map to the value. + # + # For example: + # + # "neighbors" = "= nodes_like_me[:services => :couchdb].pick_fields('domain.full', 'ip_address')" + # + # generates this: + # + # neighbors: + # couch1: + # domain_full: couch1.bitmask.net + # ip_address: "10.5.5.44" + # couch2: + # domain_full: couch2.bitmask.net + # ip_address: "10.5.5.52" + # + # But this: + # + # "neighbors": "= nodes_like_me[:services => :couchdb].pick_fields('domain.full')" + # + # will generate this: + # + # neighbors: + # couch1: couch1.bitmask.net + # couch2: couch2.bitmask.net + # + def pick_fields(*fields) + self.values.inject({}) do |hsh, node| + value = self[node.name].pick(*fields) + if fields.size == 1 + value = value.values.first + end + hsh[node.name] = value + hsh + end + end + + # + # Applies inherit_from! to all objects. + # + # 'env' specifies what environment should be for + # each object in the list. + # + def inherit_from!(object_list, env) + object_list.each do |name, object| + if self[name] + self[name].inherit_from!(object) + else + self[name] = object.duplicate(env) + end + end + end + + # + # topographical sort based on test dependency + # + def tsort_each_node(&block) + self.each_key(&block) + end + + def tsort_each_child(node_name, &block) + if self[node_name] + self[node_name].test_dependencies.each do |test_me_first| + if self[test_me_first] # TODO: in the future, allow for ability to optionally pull in all dependencies. + # not just the ones that pass the node filter. + yield(test_me_first) + end + end + end + end + + def names_in_test_dependency_order + self.tsort + end + + end + end +end diff --git a/lib/leap_cli/config/provider.rb b/lib/leap_cli/config/provider.rb new file mode 100644 index 00000000..0d8bc1f3 --- /dev/null +++ b/lib/leap_cli/config/provider.rb @@ -0,0 +1,22 @@ +# +# Configuration class for provider.json +# + +module LeapCli; module Config + class Provider < Object + attr_reader :environment + def set_env(e) + if e == 'default' + @environment = nil + else + @environment = e + end + end + def provider + self + end + def validate! + # nothing here yet :( + end + end +end; end diff --git a/lib/leap_cli/config/secrets.rb b/lib/leap_cli/config/secrets.rb new file mode 100644 index 00000000..ca851c74 --- /dev/null +++ b/lib/leap_cli/config/secrets.rb @@ -0,0 +1,87 @@ +# encoding: utf-8 +# +# A class for the secrets.json file +# + +module LeapCli; module Config + + class Secrets < Object + attr_reader :node_list + + def initialize(manager=nil) + super(manager) + @discovered_keys = {} + end + + # we can't use fetch() or get(), since those already have special meanings + def retrieve(key, environment) + environment ||= 'default' + self.fetch(environment, {})[key.to_s] + end + + def set(*args, &block) + if block_given? + set_with_block(*args, &block) + else + set_without_block(*args) + end + end + + # searches over all keys matching the regexp, checking to see if the value + # has been already used by any of them. + def taken?(regexp, value, environment) + self.keys.grep(regexp).each do |key| + return true if self.retrieve(key, environment) == value + end + return false + end + + def set_without_block(key, value, environment) + set_with_block(key, environment) {value} + end + + def set_with_block(key, environment, &block) + environment ||= 'default' + key = key.to_s + @discovered_keys[environment] ||= {} + @discovered_keys[environment][key] = true + self[environment] ||= {} + self[environment][key] ||= yield + end + + # + # if clean is true, then only secrets that have been discovered + # during this run will be exported. + # + # if environment is also pinned, then we will clean those secrets + # just for that environment. + # + # the clean argument should only be used when all nodes have + # been processed, otherwise secrets that are actually in use will + # get mistakenly removed. + # + def dump_json(clean=false) + pinned_env = LeapCli.leapfile.environment + if clean + self.each_key do |environment| + if pinned_env.nil? || pinned_env == environment + env = self[environment] + if env.nil? + raise StandardError.new("secrets.json file seems corrupted. No such environment '#{environment}'") + end + env.each_key do |key| + unless @discovered_keys[environment] && @discovered_keys[environment][key] + self[environment].delete(key) + end + end + if self[environment].empty? + self.delete(environment) + end + end + end + end + super() + end + end + +end; end diff --git a/lib/leap_cli/config/sources.rb b/lib/leap_cli/config/sources.rb new file mode 100644 index 00000000..aee860de --- /dev/null +++ b/lib/leap_cli/config/sources.rb @@ -0,0 +1,11 @@ +# encoding: utf-8 +# +# A class for the sources.json file +# + +module LeapCli + module Config + class Sources < Object + end + end +end diff --git a/lib/leap_cli/config/tag.rb b/lib/leap_cli/config/tag.rb new file mode 100644 index 00000000..6bd8d1e9 --- /dev/null +++ b/lib/leap_cli/config/tag.rb @@ -0,0 +1,25 @@ +# +# +# A class for node services or node tags. +# +# + +module LeapCli; module Config + + class Tag < Object + attr_reader :node_list + + def initialize(environment=nil) + super(environment) + @node_list = Config::ObjectList.new + end + + # don't copy the node list pointer when this object is dup'ed. + def initialize_copy(orig) + super + @node_list = Config::ObjectList.new + end + + end + +end; end diff --git a/lib/leap_cli/leapfile_extensions.rb b/lib/leap_cli/leapfile_extensions.rb new file mode 100644 index 00000000..cba321f4 --- /dev/null +++ b/lib/leap_cli/leapfile_extensions.rb @@ -0,0 +1,24 @@ +module LeapCli + class Leapfile + attr_reader :custom_vagrant_vm_line + attr_reader :leap_version + attr_reader :log + attr_reader :vagrant_basebox + + def vagrant_network + @vagrant_network ||= '10.5.5.0/24' + end + + private + + PRIVATE_IP_RANGES = /(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/ + + def validate + Util::assert! vagrant_network =~ PRIVATE_IP_RANGES do + Util::log 0, :error, "in #{file}: vagrant_network is not a local private network" + end + return true + end + + end +end diff --git a/lib/leap_cli/load_libraries.rb b/lib/leap_cli/load_libraries.rb new file mode 100644 index 00000000..19f4edb5 --- /dev/null +++ b/lib/leap_cli/load_libraries.rb @@ -0,0 +1,20 @@ +# +# load the commonly needed leap_cli libraries that live in the platform. +# +# loaded by leap_cli's bootstrap.rb +# + +require 'leap_cli/log_filter' + +require 'leap_cli/config/object' +require 'leap_cli/config/node' +require 'leap_cli/config/tag' +require 'leap_cli/config/provider' +require 'leap_cli/config/secrets' +require 'leap_cli/config/object_list' +require 'leap_cli/config/filter' +require 'leap_cli/config/environment' +require 'leap_cli/config/manager' + +require 'leap_cli/util/secret' +require 'leap_cli/util/x509' diff --git a/lib/leap_cli/log_filter.rb b/lib/leap_cli/log_filter.rb new file mode 100644 index 00000000..0d745cc2 --- /dev/null +++ b/lib/leap_cli/log_filter.rb @@ -0,0 +1,171 @@ +# +# A module to hide, modify, and colorize log entries. +# + +module LeapCli + module LogFilter + # + # options for formatters: + # + # :match => regexp for matching a log line + # :color => what color the line should be + # :style => what style the line should be + # :priority => what order the formatters are applied in. higher numbers first. + # :match_level => only apply filter at the specified log level + # :level => make this line visible at this log level or higher + # :replace => replace the matched text + # :prepend => insert text at start of message + # :append => append text to end of message + # :exit => force the exit code to be this (does not interrupt program, just + # ensures a specific exit code when the program eventually exits) + # + FORMATTERS = [ + # TRACE + { :match => /command finished/, :color => :white, :style => :dim, :match_level => 3, :priority => -10 }, + { :match => /executing locally/, :color => :yellow, :match_level => 3, :priority => -20 }, + + # DEBUG + #{ :match => /executing .*/, :color => :green, :match_level => 2, :priority => -10, :timestamp => true }, + #{ :match => /.*/, :color => :yellow, :match_level => 2, :priority => -30 }, + { :match => /^transaction:/, :level => 3 }, + + # INFO + { :match => /.*out\] (fatal:|ERROR:).*/, :color => :red, :match_level => 1, :priority => -10 }, + { :match => /Permission denied/, :color => :red, :match_level => 1, :priority => -20 }, + { :match => /sh: .+: command not found/, :color => :magenta, :match_level => 1, :priority => -30 }, + + # IMPORTANT + { :match => /^(E|e)rr ::/, :color => :red, :match_level => 0, :priority => -10, :exit => 1}, + { :match => /^ERROR:/, :color => :red, :priority => -10, :exit => 1}, + #{ :match => /.*/, :color => :blue, :match_level => 0, :priority => -20 }, + + # CLEANUP + #{ :match => /\s+$/, :replace => '', :priority => 0}, + + # DEBIAN PACKAGES + { :match => /^(Hit|Ign) /, :color => :green, :priority => -20}, + { :match => /^Err /, :color => :red, :priority => -20}, + { :match => /^W(ARNING)?: /, :color => :yellow, :priority => -20}, + { :match => /^E: /, :color => :red, :priority => -20}, + { :match => /already the newest version/, :color => :green, :priority => -20}, + { :match => /WARNING: The following packages cannot be authenticated!/, :color => :red, :level => 0, :priority => -10}, + + # PUPPET + { :match => /^(W|w)arning: Not collecting exported resources without storeconfigs/, :level => 2, :color => :yellow, :priority => -10}, + { :match => /^(W|w)arning: Found multiple default providers for vcsrepo:/, :level => 2, :color => :yellow, :priority => -10}, + { :match => /^(W|w)arning: .*is deprecated.*$/, :level => 2, :color => :yellow, :priority => -10}, + { :match => /^(W|w)arning: Scope.*$/, :level => 2, :color => :yellow, :priority => -10}, + #{ :match => /^(N|n)otice:/, :level => 1, :color => :cyan, :priority => -20}, + #{ :match => /^(N|n)otice:.*executed successfully$/, :level => 2, :color => :cyan, :priority => -15}, + { :match => /^(W|w)arning:/, :level => 0, :color => :yellow, :priority => -20}, + { :match => /^Duplicate declaration:/, :level => 0, :color => :red, :priority => -20}, + #{ :match => /Finished catalog run/, :level => 0, :color => :green, :priority => -10}, + { :match => /^APPLY COMPLETE \(changes made\)/, :level => 0, :color => :green, :style => :bold, :priority => -10}, + { :match => /^APPLY COMPLETE \(no changes\)/, :level => 0, :color => :green, :style => :bold, :priority => -10}, + + # PUPPET FATAL ERRORS + { :match => /^(E|e)rr(or|):/, :level => 0, :color => :red, :priority => -1, :exit => 1}, + { :match => /^Wrapped exception:/, :level => 0, :color => :red, :priority => -1, :exit => 1}, + { :match => /^Failed to parse template/, :level => 0, :color => :red, :priority => -1, :exit => 1}, + { :match => /^Execution of.*returned/, :level => 0, :color => :red, :priority => -1, :exit => 1}, + { :match => /^Parameter matches failed:/, :level => 0, :color => :red, :priority => -1, :exit => 1}, + { :match => /^Syntax error/, :level => 0, :color => :red, :priority => -1, :exit => 1}, + { :match => /^Cannot reassign variable/, :level => 0, :color => :red, :priority => -1, :exit => 1}, + { :match => /^Could not find template/, :level => 0, :color => :red, :priority => -1, :exit => 1}, + { :match => /^APPLY COMPLETE.*fail/, :level => 0, :color => :red, :style => :bold, :priority => -1, :exit => 1}, + + # TESTS + { :match => /^PASS: /, :color => :green, :priority => -20}, + { :match => /^(FAIL|ERROR): /, :color => :red, :priority => -20}, + { :match => /^(SKIP|WARN): /, :color => :yellow, :priority => -20}, + { :match => /\d+ tests: \d+ passes, \d+ skips, 0 warnings, 0 failures, 0 errors/, + :color => :green, :style => :bold, :priority => -20 }, + { :match => /\d+ tests: \d+ passes, \d+ skips, [1-9][0-9]* warnings, 0 failures, 0 errors/, + :color => :yellow, :style => :bold, :priority => -20 }, + { :match => /\d+ tests: \d+ passes, \d+ skips, \d+ warnings, \d+ failures, [1-9][0-9]* errors/, + :color => :red, :style => :bold, :priority => -20 }, + { :match => /\d+ tests: \d+ passes, \d+ skips, \d+ warnings, [1-9][0-9]* failures, \d+ errors/, + :color => :red, :style => :bold, :priority => -20 }, + + # LOG SUPPRESSION + { :match => /^(W|w)arning: You cannot collect without storeconfigs being set/, :level => 2, :priority => 10}, + { :match => /^(W|w)arning: You cannot collect exported resources without storeconfigs being set/, :level => 2, :priority => 10} + ] + + SORTED_FORMATTERS = FORMATTERS.sort_by { |i| -(i[:priority] || i[:prio] || 0) } + + # + # same as normal formatters, but only applies to the title, not the message. + # + TITLE_FORMATTERS = [ + # red + { :match => /error/, :color => :red, :style => :bold }, + { :match => /fatal_error/, :replace => 'fatal error:', :color => :red, :style => :bold }, + { :match => /removed/, :color => :red, :style => :bold }, + { :match => /failed/, :replace => 'FAILED', :color => :red, :style => :bold }, + { :match => /bail/, :replace => 'bailing out', :color => :red, :style => :bold }, + { :match => /invalid/, :color => :red, :style => :bold }, + + # yellow + { :match => /warning/, :replace => 'warning:', :color => :yellow, :style => :bold }, + { :match => /missing/, :color => :yellow, :style => :bold }, + { :match => /skipping/, :color => :yellow, :style => :bold }, + + # green + { :match => /created/, :color => :green, :style => :bold }, + { :match => /completed/, :color => :green, :style => :bold }, + { :match => /ran/, :color => :green, :style => :bold }, + + # cyan + { :match => /note/, :replace => 'NOTE:', :color => :cyan, :style => :bold }, + + # magenta + { :match => /nochange/, :replace => 'no change', :color => :magenta }, + { :match => /loading/, :color => :magenta }, + ] + + def self.apply_message_filters(message) + return self.apply_filters(SORTED_FORMATTERS, message) + end + + def self.apply_title_filters(title) + return self.apply_filters(TITLE_FORMATTERS, title) + end + + private + + def self.apply_filters(formatters, message) + level = LeapCli.logger.log_level + result = {} + formatters.each do |formatter| + if (formatter[:match_level] == level || formatter[:match_level].nil?) + if message =~ formatter[:match] + # puts "applying formatter #{formatter.inspect}" + result[:level] = formatter[:level] if formatter[:level] + result[:color] = formatter[:color] if formatter[:color] + result[:style] = formatter[:style] || formatter[:attribute] # (support original cap colors) + + message.gsub!(formatter[:match], formatter[:replace]) if formatter[:replace] + message.replace(formatter[:prepend] + message) unless formatter[:prepend].nil? + message.replace(message + formatter[:append]) unless formatter[:append].nil? + message.replace(Time.now.strftime('%Y-%m-%d %T') + ' ' + message) if formatter[:timestamp] + + if formatter[:exit] + LeapCli::Util.exit_status(formatter[:exit]) + end + + # stop formatting, unless formatter was just for string replacement + break unless formatter[:replace] + end + end + end + + if result[:color] == :hide + return [nil, {}] + else + return [message, result] + end + end + + end +end diff --git a/lib/leap_cli/macros.rb b/lib/leap_cli/macros.rb deleted file mode 100644 index fdb9a94e..00000000 --- a/lib/leap_cli/macros.rb +++ /dev/null @@ -1,16 +0,0 @@ -# -# MACROS -# -# The methods in these files are available in the context of a .json configuration file. -# (The module LeapCli::Macro is included in Config::Object) -# - -require_relative 'macros/core' -require_relative 'macros/files' -require_relative 'macros/haproxy' -require_relative 'macros/hosts' -require_relative 'macros/keys' -require_relative 'macros/nodes' -require_relative 'macros/secrets' -require_relative 'macros/stunnel' -require_relative 'macros/provider' diff --git a/lib/leap_cli/ssh/backend.rb b/lib/leap_cli/ssh/backend.rb index f42379cc..67c6ec9a 100644 --- a/lib/leap_cli/ssh/backend.rb +++ b/lib/leap_cli/ssh/backend.rb @@ -94,7 +94,7 @@ module LeapCli # some prewritten servers-side scripts def scripts - @scripts ||= LeapCli::SSH::Scripts.new(self, @host) + @scripts ||= LeapCli::SSH::Scripts.new(self, @host.hostname) end private @@ -139,6 +139,9 @@ module LeapCli @logger.log(:failed, args.join(' '), host: @host.hostname) do @logger.log("Connection timed out") end + if @options[:raise_error] + raise LeapCli::SSH::TimeoutError, exc.to_s + end else raise end diff --git a/lib/leap_cli/ssh/remote_command.rb b/lib/leap_cli/ssh/remote_command.rb index fe9a344a..3ba86740 100644 --- a/lib/leap_cli/ssh/remote_command.rb +++ b/lib/leap_cli/ssh/remote_command.rb @@ -21,6 +21,9 @@ module LeapCli class ExecuteError < StandardError end + class TimeoutError < ExecuteError + end + # override default runner mode class CustomCoordinator < SSHKit::Coordinator private diff --git a/lib/leap_cli/ssh/scripts.rb b/lib/leap_cli/ssh/scripts.rb index 3d8b6570..feefdd46 100644 --- a/lib/leap_cli/ssh/scripts.rb +++ b/lib/leap_cli/ssh/scripts.rb @@ -15,7 +15,7 @@ module LeapCli REQUIRED_PACKAGES = "puppet rsync lsb-release locales" attr_reader :ssh, :host - def initialize(backend, host) + def initialize(backend, hostname) @ssh = backend @host = host end @@ -48,6 +48,8 @@ module LeapCli def check_for_no_deploy begin ssh.stream "test ! -f /etc/leap/no-deploy", :raise_error => true, :log_output => false + rescue SSH::TimeoutError + raise rescue SSH::ExecuteError ssh.log :warning, "can't continue because file /etc/leap/no-deploy exists", :host => host raise # will skip further action on this node @@ -59,7 +61,7 @@ module LeapCli # def debug output = ssh.capture "#{Leap::Platform.leap_dir}/bin/debug.sh" - ssh.log(output, :wrap => true, :host => host.hostname, :color => :cyan) + ssh.log(output, :wrap => true, :host => host, :color => :cyan) end # @@ -69,7 +71,7 @@ module LeapCli cmd = "(test -s /var/log/leap/deploy-summary.log && tail -n #{lines} /var/log/leap/deploy-summary.log) || (test -s /var/log/leap/deploy-summary.log.1 && tail -n #{lines} /var/log/leap/deploy-summary.log.1) || (echo 'no history')" history = ssh.capture(cmd, :log_output => false) if history - ssh.log host.hostname, :color => :cyan, :style => :bold do + ssh.log host, :color => :cyan, :style => :bold do ssh.log history, :wrap => true end end diff --git a/lib/leap_cli/util/secret.rb b/lib/leap_cli/util/secret.rb new file mode 100644 index 00000000..749b9595 --- /dev/null +++ b/lib/leap_cli/util/secret.rb @@ -0,0 +1,55 @@ +# encoding: utf-8 +# +# A simple secret generator +# +# Uses OpenSSL random number generator instead of Ruby's rand function +# +autoload :OpenSSL, 'openssl' + +module LeapCli; module Util + class Secret + CHARS = (('A'..'Z').to_a + ('a'..'z').to_a + ('0'..'9').to_a) - "i1loO06G".split(//u) + HEX = (0..9).to_a + ('a'..'f').to_a + + # + # generate a secret with with no ambiguous characters. + # + # +length+ is in chars + # + # Only alphanumerics are allowed, in order to make these passwords work + # for REST url calls and to allow you to easily copy and paste them. + # + def self.generate(length = 16) + seed + OpenSSL::Random.random_bytes(length).bytes.to_a.collect { |byte| + CHARS[ byte % CHARS.length ] + }.join + end + + # + # generates a hex secret, instead of an alphanumeric on. + # + # length is in bits + # + def self.generate_hex(length = 128) + seed + OpenSSL::Random.random_bytes(length/4).bytes.to_a.collect { |byte| + HEX[ byte % HEX.length ] + }.join + end + + private + + def self.seed + @pid ||= 0 + pid = $$ + if @pid != pid + now = Time.now + ary = [now.to_i, now.nsec, @pid, pid] + OpenSSL::Random.seed(ary.to_s) + @pid = pid + end + end + + end +end; end diff --git a/lib/leap_cli/util/x509.rb b/lib/leap_cli/util/x509.rb new file mode 100644 index 00000000..787fdfac --- /dev/null +++ b/lib/leap_cli/util/x509.rb @@ -0,0 +1,33 @@ +autoload :OpenSSL, 'openssl' +autoload :CertificateAuthority, 'certificate_authority' + +require 'digest' +require 'digest/md5' +require 'digest/sha1' + +module LeapCli; module X509 + extend self + + # + # returns a fingerprint of a x509 certificate + # + def fingerprint(digest, cert_file) + if cert_file.is_a? String + cert = OpenSSL::X509::Certificate.new(Util.read_file!(cert_file)) + elsif cert_file.is_a? OpenSSL::X509::Certificate + cert = cert_file + elsif cert_file.is_a? CertificateAuthority::Certificate + cert = cert_file.openssl_body + end + digester = case digest + when "MD5" then Digest::MD5.new + when "SHA1" then Digest::SHA1.new + when "SHA256" then Digest::SHA256.new + when "SHA384" then Digest::SHA384.new + when "SHA512" then Digest::SHA512.new + end + digester.hexdigest(cert.to_der) + end + + +end; end -- cgit v1.2.3