# Author:: Adam Jacob () # Author:: Christopher Brown () # Author:: Christopher Walters () # Author:: Tim Hinderliter () # Copyright:: Copyright 2008-2018, Chef Software Inc. # License:: Apache License, Version 2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # require "forwardable" require "chef/config" require "chef/nil_argument" require "chef/mixin/params_validate" require "chef/mixin/from_file" require "chef/mixin/deep_merge" require "chef/dsl/include_attribute" require "chef/dsl/universal" require "chef/environment" require "chef/server_api" require "chef/run_list" require "chef/node/attribute" require "chef/mash" require "chef/json_compat" require "chef/search/query" require "chef/whitelist" require "chef/blacklist" class Chef class Node extend Forwardable def_delegators :attributes, :keys, :each_key, :each_value, :key?, :has_key? def_delegators :attributes, :rm, :rm_default, :rm_normal, :rm_override def_delegators :attributes, :default!, :normal!, :override!, :force_default!, :force_override! def_delegators :attributes, :default_unless, :normal_unless, :override_unless, :set_unless def_delegators :attributes, :read, :read!, :write, :write!, :unlink, :unlink! attr_accessor :recipe_list, :run_state attr_reader :logger # RunContext will set itself as run_context via this setter when # initialized. This is needed so DSL::IncludeAttribute (in particular, # #include_recipe) can access the run_context to determine if an attributes # file has been seen yet. #-- # TODO: This is a pretty ugly way to solve that problem. attr_accessor :run_context include Chef::Mixin::FromFile include Chef::DSL::IncludeAttribute include Chef::DSL::Universal include Chef::Mixin::ParamsValidate NULL_ARG = Object.new # Create a new Chef::Node object. def initialize(chef_server_rest: nil, logger: nil) @chef_server_rest = chef_server_rest @name = nil @logger = logger || Chef::Log.with_child(subsystem: "node") @chef_environment = "_default" @primary_runlist = Chef::RunList.new @override_runlist = Chef::RunList.new @policy_name = nil @policy_group = nil @attributes = Chef::Node::Attribute.new({}, {}, {}, {}, self) @run_state = {} end # after the run_context has been set on the node, go through the cookbook_collection # and setup the node[:cookbooks] attribute so that it is published in the node object def set_cookbook_attribute return unless run_context.cookbook_collection run_context.cookbook_collection.each do |cookbook_name, cookbook| automatic_attrs[:cookbooks][cookbook_name][:version] = cookbook.version end end # Used by DSL def node self end def chef_server_rest # for saving node data we use validate_utf8: false which will not # raise an exception on bad utf8 data, but will replace the bad # characters and render valid JSON. @chef_server_rest ||= Chef::ServerAPI.new( Chef::Config[:chef_server_url], client_name: Chef::Config[:node_name], signing_key_filename: Chef::Config[:client_key], validate_utf8: false ) end # Set the name of this Node, or return the current name. def name(arg = nil) if !arg.nil? validate( { name: arg }, { name: { kind_of: String, cannot_be: :blank, regex: /^[\-[:alnum:]_:.]+$/ }, }) @name = arg else @name end end def chef_environment(arg = nil) set_or_return( :chef_environment, arg, { regex: /^[\-[:alnum:]_]+$/, kind_of: String } ) end def chef_environment=(environment) chef_environment(environment) end alias :environment :chef_environment # The `policy_name` for this node. Setting this to a non-nil value will # enable policyfile mode when `chef-client` is run. If set in the config # file or in node json, running `chef-client` will update this value. # # @see Chef::PolicyBuilder::Dynamic # @see Chef::PolicyBuilder::Policyfile # # @param arg [String] the new policy_name value # @return [String] the current policy_name, or the one you just set def policy_name(arg = NULL_ARG) return @policy_name if arg.equal?(NULL_ARG) validate({ policy_name: arg }, { policy_name: { kind_of: [ String, NilClass ], regex: /^[\-:.[:alnum:]_]+$/ } }) @policy_name = arg end # A "non-DSL-style" setter for `policy_name` # # @see #policy_name def policy_name=(policy_name) policy_name(policy_name) end # The `policy_group` for this node. Setting this to a non-nil value will # enable policyfile mode when `chef-client` is run. If set in the config # file or in node json, running `chef-client` will update this value. # # @see Chef::PolicyBuilder::Dynamic # @see Chef::PolicyBuilder::Policyfile # # @param arg [String] the new policy_group value # @return [String] the current policy_group, or the one you just set def policy_group(arg = NULL_ARG) return @policy_group if arg.equal?(NULL_ARG) validate({ policy_group: arg }, { policy_group: { kind_of: [ String, NilClass ], regex: /^[\-:.[:alnum:]_]+$/ } }) @policy_group = arg end # A "non-DSL-style" setter for `policy_group` # # @see #policy_group def policy_group=(policy_group) policy_group(policy_group) end def attributes @attributes end alias :attribute :attributes alias :construct_attributes :attributes # Return an attribute of this node. Returns nil if the attribute is not found. def [](attrib) attributes[attrib] end # Set a normal attribute of this node, but auto-vivify any Mashes that # might be missing def normal attributes.normal end # Set a default of this node, but auto-vivify any Mashes that might # be missing def default attributes.default end # Set an override attribute of this node, but auto-vivify any Mashes that # might be missing def override attributes.override end alias :override_attrs :override alias :default_attrs :default alias :normal_attrs :normal def override_attrs=(new_values) attributes.override = new_values end def default_attrs=(new_values) attributes.default = new_values end def normal_attrs=(new_values) attributes.normal = new_values end def automatic_attrs attributes.automatic end def automatic_attrs=(new_values) attributes.automatic = new_values end # Return true if this Node has a given attribute, false if not. Takes either a symbol or # a string. # # Only works on the top level. Preferred way is to use the normal [] style # lookup and call attribute?() def attribute?(attrib) attributes.attribute?(attrib) end # Yield each key of the top level to the block. def each(&block) attributes.each(&block) end # Iterates over each attribute, passing the attribute and value to the block. def each_attribute(&block) attributes.each_attribute(&block) end # Only works for attribute fetches, setting is no longer supported # XXX: this should be deprecated def method_missing(method, *args, &block) attributes.public_send(method, *args, &block) end # Fix respond_to + method so that it works with method_missing delegation def respond_to_missing?(method, include_private = false) attributes.respond_to?(method, false) end # Returns true if this Node expects a given recipe, false if not. # # First, the run list is consulted to see whether the recipe is # explicitly included. If it's not there, it looks in # `node[:recipes]`, which is populated when the run_list is expanded # # NOTE: It's used by cookbook authors def recipe?(recipe_name) run_list.include?(recipe_name) || Array(self[:recipes]).include?(recipe_name) end # used by include_recipe to add recipes to the expanded run_list to be # saved back to the node and be searchable def loaded_recipe(cookbook, recipe) fully_qualified_recipe = "#{cookbook}::#{recipe}" automatic_attrs[:recipes] << fully_qualified_recipe unless Array(self[:recipes]).include?(fully_qualified_recipe) end # Returns true if this Node expects a given role, false if not. # # @param role_name [String] Role to check for # @return [Boolean] def role?(role_name) Array(self[:roles]).include?(role_name) end def primary_runlist @primary_runlist end attr_writer :override_runlist def override_runlist(*args) args.length > 0 ? @override_runlist.reset!(args) : @override_runlist end def select_run_list @override_runlist.empty? ? @primary_runlist : @override_runlist end # Returns an Array of roles and recipes, in the order they will be applied. # If you call it with arguments, they will become the new list of roles and recipes. def run_list(*args) rl = select_run_list args.length > 0 ? rl.reset!(args) : rl end def run_list=(list) rl = select_run_list rl = list end # Returns true if this Node expects a given role, false if not. def run_list?(item) run_list.detect { |r| r == item } ? true : false end # Consume data from ohai and Attributes provided as JSON on the command line. def consume_external_attrs(ohai_data, json_cli_attrs) # FIXME(log): should be trace logger.debug("Extracting run list from JSON attributes provided on command line") consume_attributes(json_cli_attrs) self.automatic_attrs = ohai_data platform, version = Chef::Platform.find_platform_and_version(self) # FIXME(log): should be trace logger.debug("Platform is #{platform} version #{version}") automatic[:platform] = platform automatic[:platform_version] = version automatic[:chef_guid] = Chef::Config[:chef_guid] automatic[:name] = name automatic[:chef_environment] = chef_environment end def consume_ohai_data(ohai_data) self.automatic_attrs = Chef::Mixin::DeepMerge.merge(automatic_attrs, ohai_data) end # Consumes the combined run_list and other attributes in +attrs+ def consume_attributes(attrs) normal_attrs_to_merge = consume_run_list(attrs) normal_attrs_to_merge = consume_chef_environment(normal_attrs_to_merge) # FIXME(log): should be trace logger.debug("Applying attributes from json file") self.normal_attrs = Chef::Mixin::DeepMerge.merge(normal_attrs, normal_attrs_to_merge) tags # make sure they're defined end # Lazy initializer for tags attribute def tags normal[:tags] = Array(normal[:tags]) normal[:tags] end def tag(*args) args.each do |tag| tags.push(tag.to_s) unless tags.include? tag.to_s end tags end # Extracts the run list from +attrs+ and applies it. Returns the remaining attributes def consume_run_list(attrs) attrs = attrs ? attrs.dup : {} if new_run_list = attrs.delete("recipes") || attrs.delete("run_list") if attrs.key?("recipes") || attrs.key?("run_list") raise Chef::Exceptions::AmbiguousRunlistSpecification, "please set the node's run list using the 'run_list' attribute only." end logger.info("Setting the run_list to #{new_run_list} from CLI options") run_list(new_run_list) end attrs end # chef_environment when set in -j JSON will take precedence over # -E ENVIRONMENT. Ideally, IMO, the order of precedence should be (lowest to # highest): # config_file # -j JSON # -E ENVIRONMENT # so that users could reuse their JSON and override the chef_environment # configured within it with -E ENVIRONMENT. Because command line options are # merged with Chef::Config there is currently no way to distinguish between # an environment set via config from an environment set via command line. def consume_chef_environment(attrs) attrs = attrs ? attrs.dup : {} if env = attrs.delete("chef_environment") chef_environment(env) end attrs end # Clear defaults and overrides, so that any deleted attributes # between runs are still gone. def reset_defaults_and_overrides default.clear override.clear end # Expands the node's run list and sets the default and override # attributes. Also applies stored attributes (from json provided # on the command line) # # Returns the fully-expanded list of recipes, a RunListExpansion. # #-- # TODO: timh/cw, 5-14-2010: Should this method exist? Should we # instead modify default_attrs and override_attrs whenever our # run_list is mutated? Or perhaps do something smarter like # on-demand generation of default_attrs and override_attrs, # invalidated only when run_list is mutated? def expand!(data_source = "server") expansion = run_list.expand(chef_environment, data_source) raise Chef::Exceptions::MissingRole, expansion if expansion.errors? tags # make sure they're defined automatic_attrs[:recipes] = expansion.recipes.with_duplicate_names automatic_attrs[:expanded_run_list] = expansion.recipes.with_fully_qualified_names_and_version_constraints automatic_attrs[:roles] = expansion.roles apply_expansion_attributes(expansion) automatic_attrs[:chef_environment] = chef_environment expansion end # Apply the default and overrides attributes from the expansion # passed in, which came from roles. def apply_expansion_attributes(expansion) loaded_environment = if chef_environment == "_default" Chef::Environment.new.tap { |e| e.name("_default") } else Chef::Environment.load(chef_environment) end attributes.env_default = loaded_environment.default_attributes attributes.env_override = loaded_environment.override_attributes attribute.role_default = expansion.default_attrs attributes.role_override = expansion.override_attrs end # Transform the node to a Hash def to_hash index_hash = attributes.to_hash index_hash["chef_type"] = "node" index_hash["name"] = name index_hash["chef_environment"] = chef_environment index_hash["recipe"] = run_list.recipe_names if run_list.recipe_names.length > 0 index_hash["role"] = run_list.role_names if run_list.role_names.length > 0 index_hash["run_list"] = run_list.run_list_items index_hash end def display_hash display = {} display["name"] = name display["chef_environment"] = chef_environment display["automatic"] = attributes.automatic.to_hash display["normal"] = attributes.normal.to_hash display["default"] = attributes.combined_default.to_hash display["override"] = attributes.combined_override.to_hash display["run_list"] = run_list.run_list_items display end # Serialize this object as a hash def to_json(*a) Chef::JSONCompat.to_json(for_json, *a) end def for_json result = { "name" => name, "chef_environment" => chef_environment, "json_class" => self.class.name, "automatic" => attributes.automatic.to_hash, "normal" => attributes.normal.to_hash, "chef_type" => "node", "default" => attributes.combined_default.to_hash, "override" => attributes.combined_override.to_hash, # Render correctly for run_list items so malformed json does not result "run_list" => @primary_runlist.run_list.map { |item| item.to_s }, } # Chef Server rejects node JSON with extra keys; prior to 12.3, # "policy_name" and "policy_group" are unknown; after 12.3 they are # optional, therefore only including them in the JSON if present # maximizes compatibility for most people. unless policy_group.nil? && policy_name.nil? result["policy_name"] = policy_name result["policy_group"] = policy_group end result end def update_from!(o) run_list.reset!(o.run_list) self.automatic_attrs = o.automatic_attrs self.normal_attrs = o.normal_attrs self.override_attrs = o.override_attrs self.default_attrs = o.default_attrs chef_environment(o.chef_environment) self end def self.from_hash(o) return o if o.kind_of? Chef::Node node = new node.name(o["name"]) node.policy_name = o["policy_name"] if o.key?("policy_name") node.policy_group = o["policy_group"] if o.key?("policy_group") unless node.policy_group.nil? node.chef_environment(o["policy_group"]) else node.chef_environment(o["chef_environment"]) end if o.key?("attributes") node.normal_attrs = o["attributes"] end node.automatic_attrs = Mash.new(o["automatic"]) if o.key?("automatic") node.normal_attrs = Mash.new(o["normal"]) if o.key?("normal") node.default_attrs = Mash.new(o["default"]) if o.key?("default") node.override_attrs = Mash.new(o["override"]) if o.key?("override") if o.key?("run_list") node.run_list.reset!(o["run_list"]) elsif o.key?("recipes") o["recipes"].each { |r| node.recipes << r } end node end def self.list_by_environment(environment, inflate = false) if inflate response = Hash.new Chef::Search::Query.new.search(:node, "chef_environment:#{environment}") { |n| response[n.name] = n unless n.nil? } response else Chef::ServerAPI.new(Chef::Config[:chef_server_url]).get("environments/#{environment}/nodes") end end def self.list(inflate = false) if inflate response = Hash.new Chef::Search::Query.new.search(:node) do |n| n = Chef::Node.from_hash(n) response[n.name] = n unless n.nil? end response else Chef::ServerAPI.new(Chef::Config[:chef_server_url]).get("nodes") end end def self.find_or_create(node_name) load(node_name) rescue Net::HTTPClientException => e raise unless e.response.code == "404" node = build(node_name) node.create end def self.build(node_name) node = new node.name(node_name) node.chef_environment(Chef::Config[:environment]) unless Chef::Config[:environment].nil? || Chef::Config[:environment].chomp.empty? node end # Load a node by name def self.load(name) from_hash(Chef::ServerAPI.new(Chef::Config[:chef_server_url]).get("nodes/#{name}")) end # Remove this node via the REST API def destroy chef_server_rest.delete("nodes/#{name}") end # Save this node via the REST API def save # Try PUT. If the node doesn't yet exist, PUT will return 404, # so then POST to create. begin if Chef::Config[:why_run] logger.warn("In why-run mode, so NOT performing node save.") else chef_server_rest.put("nodes/#{name}", data_for_save) end rescue Net::HTTPClientException => e if e.response.code == "404" chef_server_rest.post("nodes", data_for_save) else raise end end self end # Create the node via the REST API def create chef_server_rest.post("nodes", data_for_save) self rescue Net::HTTPClientException => e # Chef Server before 12.3 rejects node JSON with 'policy_name' or # 'policy_group' keys, but 'policy_name' will be detected first. # Backcompat can be removed in 13.0 if e.response.code == "400" && e.response.body.include?("Invalid key policy_name") chef_server_rest.post("nodes", data_for_save_without_policyfile_attrs) else raise end end def to_s "node[#{name}]" end def ==(other) if other.kind_of?(self.class) name == other.name else false end end def <=>(other) name <=> other.name end private def save_without_policyfile_attrs trimmed_data = data_for_save_without_policyfile_attrs chef_server_rest.put("nodes/#{name}", trimmed_data) rescue Net::HTTPClientException => e raise e unless e.response.code == "404" chef_server_rest.post("nodes", trimmed_data) end def data_for_save_without_policyfile_attrs data_for_save.tap do |trimmed_data| trimmed_data.delete("policy_name") trimmed_data.delete("policy_group") end end def data_for_save data = for_json %w{automatic default normal override}.each do |level| whitelist_config_option = "#{level}_attribute_whitelist".to_sym whitelist = Chef::Config[whitelist_config_option] unless whitelist.nil? # nil => save everything logger.info("Whitelisting #{level} node attributes for save.") data[level] = Chef::Whitelist.filter(data[level], whitelist) end blacklist_config_option = "#{level}_attribute_blacklist".to_sym blacklist = Chef::Config[blacklist_config_option] unless blacklist.nil? # nil => remove nothing logger.info("Blacklisting #{level} node attributes for save") data[level] = Chef::Blacklist.filter(data[level], blacklist) end end data end end end