diff options
author | John McCrae <john.mccrae@progress.com> | 2022-04-07 08:46:41 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-04-07 08:46:41 -0700 |
commit | bf652837e634a0271d726a7b3dfe6f50e8e04cbc (patch) | |
tree | 80e424184d748bda1b8bba69d473d1344064d1b1 /lib/chef | |
parent | 09ef2bba5251b018ea477f5158b55918382b0e79 (diff) | |
parent | f61168f78a52f28c170feb3c6a65061d91e7cb25 (diff) | |
download | chef-bf652837e634a0271d726a7b3dfe6f50e8e04cbc.tar.gz |
Merge branch 'main' into jfm/platform_updates
Diffstat (limited to 'lib/chef')
-rw-r--r-- | lib/chef/api_client_v1.rb | 10 | ||||
-rw-r--r-- | lib/chef/client.rb | 114 | ||||
-rw-r--r-- | lib/chef/dsl/rest_resource.rb | 70 | ||||
-rw-r--r-- | lib/chef/dsl/secret.rb | 114 | ||||
-rw-r--r-- | lib/chef/event_dispatch/base.rb | 3 | ||||
-rw-r--r-- | lib/chef/exceptions.rb | 8 | ||||
-rw-r--r-- | lib/chef/http/authenticator.rb | 100 | ||||
-rw-r--r-- | lib/chef/node/attribute.rb | 23 | ||||
-rw-r--r-- | lib/chef/node/mixin/deep_merge_cache.rb | 8 | ||||
-rw-r--r-- | lib/chef/resource.rb | 16 | ||||
-rw-r--r-- | lib/chef/resource/_rest_resource.rb | 386 | ||||
-rw-r--r-- | lib/chef/resource/chef_client_config.rb | 5 | ||||
-rw-r--r-- | lib/chef/resource/plist.rb | 9 | ||||
-rw-r--r-- | lib/chef/resource/support/client.erb | 1 | ||||
-rw-r--r-- | lib/chef/run_context.rb | 16 | ||||
-rw-r--r-- | lib/chef/version.rb | 2 |
16 files changed, 828 insertions, 57 deletions
diff --git a/lib/chef/api_client_v1.rb b/lib/chef/api_client_v1.rb index 6178cb91c3..51c915218e 100644 --- a/lib/chef/api_client_v1.rb +++ b/lib/chef/api_client_v1.rb @@ -64,6 +64,10 @@ class Chef @chef_rest_v1 ||= Chef::ServerAPI.new(Chef::Config[:chef_server_url], { api_version: "1", inflate_json_class: false }) end + def chef_rest_v1_with_validator + @chef_rest_v1_with_validator ||= Chef::ServerAPI.new(Chef::Config[:chef_server_url], { client_name: Chef::Config[:validation_client_name], signing_key_filename: Chef::Config[:validation_key], api_version: "1", inflate_json_class: false }) + end + def self.http_api Chef::ServerAPI.new(Chef::Config[:chef_server_url], { api_version: "1", inflate_json_class: false }) end @@ -293,7 +297,11 @@ class Chef payload[:public_key] = public_key unless public_key.nil? payload[:create_key] = create_key unless create_key.nil? - new_client = chef_rest_v1.post("clients", payload) + new_client = if Chef::Config[:migrate_key_to_keystore] == true + chef_rest_v1_with_validator.post("clients", payload) + else + chef_rest_v1.post("clients", payload) + end # get the private_key out of the chef_key hash if it exists if new_client["chef_key"] diff --git a/lib/chef/client.rb b/lib/chef/client.rb index 5ec15fb582..07798798b9 100644 --- a/lib/chef/client.rb +++ b/lib/chef/client.rb @@ -64,6 +64,10 @@ class Chef # The main object in a Chef run. Preps a Chef::Node and Chef::RunContext, # syncs cookbooks if necessary, and triggers convergence. class Client + CRYPT_EXPORTABLE = 0x00000001 + + attr_reader :local_context + extend Chef::Mixin::Deprecation extend Forwardable @@ -229,7 +233,7 @@ class Chef start_profiling runlock = RunLock.new(Chef::Config.lockfile) - # TODO: feels like acquire should have its own block arg for this + # TODO feels like acquire should have its own block arg for this runlock.acquire # don't add code that may fail before entering this section to be sure to release lock begin @@ -637,16 +641,19 @@ class Chef # @api private # def register(client_name = node_name, config = Chef::Config) - if Chef::HTTP::Authenticator.detect_certificate_key(client_name) - if File.exists?(config[:client_key]) - logger.warn("WARNING - Client key #{client_name} is present on disk, ignoring that in favor of key stored in CertStore") - end - events.skipping_registration(client_name, config) - logger.trace("Client key #{client_name} is present in certificate repository - skipping registration") - config[:client_key] = "Cert:\\LocalMachine\\My\\chef-#{client_name}" - elsif !config[:client_key] + if !config[:client_key] events.skipping_registration(client_name, config) logger.trace("Client key is unspecified - skipping registration") + elsif ::Chef::Config[:migrate_key_to_keystore] == true && ChefUtils.windows? + cert_name = "chef-#{client_name}" + result = check_certstore_for_key(cert_name) + if result.rassoc("#{cert_name}") + logger.trace("Client key #{config[:client_key]} is present in Certificate Store - skipping registration") + else + create_new_key_and_register(cert_name) + logger.trace("New client keys created in the Certificate Store - skipping registration") + end + events.skipping_registration(client_name, config) elsif File.exists?(config[:client_key]) events.skipping_registration(client_name, config) logger.trace("Client key #{config[:client_key]} is present - skipping registration") @@ -665,6 +672,94 @@ class Chef raise end + # In the brave new world of No Certs On Disk, we want to put the pem file into Keychain or the Certstore + # But is it already there? + def check_certstore_for_key(cert_name) + require "win32-certstore" + win32certstore = ::Win32::Certstore.open("MY") + win32certstore.search("#{cert_name}") + end + + def generate_pfx_package(cert_name, date) + self.class.generate_pfx_package(cert_name, date) + end + + def self.generate_pfx_package(cert_name, date) + require "openssl" unless defined?(OpenSSL) + + key = OpenSSL::PKey::RSA.new(2048) + public_key = key.public_key + + subject = "CN=#{cert_name}" + + cert = OpenSSL::X509::Certificate.new + cert.subject = cert.issuer = OpenSSL::X509::Name.parse(subject) + cert.not_before = Time.now + cert.not_after = Time.parse(date) + cert.public_key = public_key + cert.serial = 0x0 + cert.version = 2 + + ef = OpenSSL::X509::ExtensionFactory.new + ef.subject_certificate = cert + ef.issuer_certificate = cert + cert.extensions = [ + ef.create_extension("subjectKeyIdentifier", "hash"), + ef.create_extension("keyUsage", "digitalSignature,keyEncipherment", true), + ] + cert.add_extension(ef.create_ext_from_string("extendedKeyUsage=critical,serverAuth,clientAuth")) + + cert.sign key, OpenSSL::Digest.new("SHA256") + password = ::Chef::HTTP::Authenticator.get_cert_password + pfx = OpenSSL::PKCS12.create(password, subject, key, cert) + pfx + end + + def create_new_key_and_register(cert_name) + require "time" unless defined?(Time) + autoload :URI, "uri" + + # KeyMigration.instance.key_migrated = true + + node = Chef::Config[:node_name] + d = Time.now + if d.month == 10 || d.month == 11 || d.month == 12 + end_date = Time.new(d.year + 1, d.month - 9, d.day, d.hour, d.min, d.sec).utc.iso8601 + else + end_date = Time.new(d.year, d.month + 3, d.day, d.hour, d.min, d.sec).utc.iso8601 + end + + payload = { + name: node, + clientname: node, + public_key: "", + expiration_date: end_date, + } + + new_pfx = generate_pfx_package(cert_name, end_date) + payload[:public_key] = new_pfx.certificate.public_key.to_pem + base_url = "#{Chef::Config[:chef_server_url]}" + client = Chef::ServerAPI.new(base_url, client_name: Chef::Config[:validation_client_name], signing_key_filename: Chef::Config[:validation_key]) + client.post(base_url + "/clients", payload) + Chef::Log.trace("Updated client data: #{client.inspect}") + import_pfx_to_store(new_pfx) + end + + def import_pfx_to_store(new_pfx) + self.class.import_pfx_to_store(new_pfx) + end + + def self.import_pfx_to_store(new_pfx) + password = ::Chef::HTTP::Authenticator.get_cert_password + require "win32-certstore" + tempfile = Tempfile.new("#{Chef::Config[:node_name]}.pfx") + File.open(tempfile, "wb") { |f| f.print new_pfx.to_der } + + store = ::Win32::Certstore.open("MY") + store.add_pfx(tempfile, password, CRYPT_EXPORTABLE) + tempfile.unlink + end + # # Converges all compiled resources. # @@ -929,3 +1024,4 @@ end require_relative "cookbook_loader" require_relative "cookbook_version" require_relative "cookbook/synchronizer" + diff --git a/lib/chef/dsl/rest_resource.rb b/lib/chef/dsl/rest_resource.rb new file mode 100644 index 0000000000..96eba24eac --- /dev/null +++ b/lib/chef/dsl/rest_resource.rb @@ -0,0 +1,70 @@ +# +# Copyright:: Copyright 2008-2016, Chef, 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 "chef/constants" unless defined?(NOT_PASSED) + +class Chef + module DSL + module RestResource + def rest_property_map(rest_property_map = NOT_PASSED) + if rest_property_map != NOT_PASSED + rest_property_map = rest_property_map.to_h { |k| [k.to_sym, k] } if rest_property_map.is_a? Array + + @rest_property_map = rest_property_map + end + @rest_property_map + end + + # URL to collection + def rest_api_collection(rest_api_collection = NOT_PASSED) + @rest_api_collection = rest_api_collection if rest_api_collection != NOT_PASSED + @rest_api_collection + end + + # RFC6570-Templated URL to document + def rest_api_document(rest_api_document = NOT_PASSED, first_element_only: false) + if rest_api_document != NOT_PASSED + @rest_api_document = rest_api_document + @rest_api_document_first_element_only = first_element_only + end + @rest_api_document + end + + # Explicit REST document identity mapping + def rest_identity_map(rest_identity_map = NOT_PASSED) + @rest_identity_map = rest_identity_map if rest_identity_map != NOT_PASSED + @rest_identity_map + end + + # Mark up properties for POST only, not PATCH/PUT + def rest_post_only_properties(rest_post_only_properties = NOT_PASSED) + if rest_post_only_properties != NOT_PASSED + @rest_post_only_properties = Array(rest_post_only_properties).map(&:to_sym) + end + @rest_post_only_properties || [] + end + + def rest_api_document_first_element_only(rest_api_document_first_element_only = NOT_PASSED) + if rest_api_document_first_element_only != NOT_PASSED + @rest_api_document_first_element_only = rest_api_document_first_element_only + end + @rest_api_document_first_element_only + end + + end + end +end diff --git a/lib/chef/dsl/secret.rb b/lib/chef/dsl/secret.rb index 9d616ff35f..2377ae36fe 100644 --- a/lib/chef/dsl/secret.rb +++ b/lib/chef/dsl/secret.rb @@ -21,6 +21,118 @@ class Chef module DSL module Secret + # + # This allows you to set the default secret service that is used when + # fetching secrets. + # + # @example + # + # default_secret_service :hashi_vault + # val1 = secret(name: "test1", config: { region: "us-west-1" }) + # + # @example + # + # default_secret_service #=> nil + # default_secret_service :hashi_vault + # default_secret_service #=> :hashi_vault + # + # @param [Symbol] service default secret service to use when fetching secrets + # @return [Symbol, nil] default secret service to use when fetching secrets + # + def default_secret_service(service = nil) + return run_context.default_secret_service if service.nil? + raise Chef::Exceptions::Secret::InvalidFetcherService.new("Unsupported secret service: #{service.inspect}", Chef::SecretFetcher::SECRET_FETCHERS) unless Chef::SecretFetcher::SECRET_FETCHERS.include?(service) + + run_context.default_secret_service = service + end + + # + # This allows you to set the secret service for the scope of the block + # passed into this method. + # + # @example + # + # with_secret_service :hashi_vault do + # val1 = secret(name: "test1", config: { region: "us-west-1" }) + # val2 = secret(name: "test2", config: { region: "us-west-1" }) + # end + # + # @example Combine with #with_secret_config + # + # with_secret_service :hashi_vault do + # with_secret_config region: "us-west-1" do + # val1 = secret(name: "test1") + # val2 = secret(name: "test2") + # end + # end + # + # @param [Symbol] service The default secret service to use when fetching secrets + # + def with_secret_service(service) + raise ArgumentError, "You must pass a block to #with_secret_service" unless block_given? + + begin + old_service = default_secret_service + # Use "public" API for input validation + default_secret_service(service) + yield + ensure + # Use "private" API so we can set back to nil + run_context.default_secret_service = old_service + end + end + + # + # This allows you to set the default secret config that is used when + # fetching secrets. + # + # @example + # + # default_secret_config region: "us-west-1" + # val1 = secret(name: "test1", service: :hashi_vault) + # + # @example + # + # default_secret_config #=> {} + # default_secret_service region: "us-west-1" + # default_secret_service #=> { region: "us-west-1" } + # + # @param [Hash<Symbol,Object>] config The default configuration options to apply when fetching secrets + # @return [Hash<Symbol,Object>] + # + def default_secret_config(**config) + return run_context.default_secret_config if config.empty? + + run_context.default_secret_config = config + end + + # + # This allows you to set the secret config for the scope of the block + # passed into this method. + # + # @example + # + # with_secret_config region: "us-west-1" do + # val1 = secret(name: "test1", service: :hashi_vault) + # val2 = secret(name: "test2", service: :hashi_vault) + # end + # + # @param [Hash<Symbol,Object>] config The default configuration options to use when fetching secrets + # + def with_secret_config(**config) + raise ArgumentError, "You must pass a block to #with_secret_config" unless block_given? + + begin + old_config = default_secret_config + # Use "public" API for input validation + default_secret_config(**config) + yield + ensure + # Use "private" API so we can set back to nil + run_context.default_secret_config = old_config + end + end + # Helper method which looks up a secret using the given service and configuration, # and returns the retrieved secret value. # This DSL providers a wrapper around [Chef::SecretFetcher] @@ -49,7 +161,7 @@ class Chef # # value = secret(name: "test1", service: :aws_secrets_manager, version: "v1", config: { region: "us-west-1" }) # log "My secret is #{value}" - def secret(name: nil, version: nil, service: nil, config: {}) + def secret(name: nil, version: nil, service: default_secret_service, config: default_secret_config) Chef::Log.warn <<~EOM.gsub("\n", " ") The secrets Chef Infra language helper is currently in beta. If you have feedback or you would like to be part of the future design of this helper e-mail us at secrets_management_beta@progress.com" diff --git a/lib/chef/event_dispatch/base.rb b/lib/chef/event_dispatch/base.rb index a973c31612..669c1d6286 100644 --- a/lib/chef/event_dispatch/base.rb +++ b/lib/chef/event_dispatch/base.rb @@ -273,6 +273,9 @@ class Chef # Called if the converge phase fails def converge_failed(exception); end + # Called when migrating from a pem on disk to a pem stored in Keychain or Windows Certstore + def key_migration_status(key_migrated = false); end + # TODO: need events for notification resolve? # def notifications_resolved # end diff --git a/lib/chef/exceptions.rb b/lib/chef/exceptions.rb index ffdbdcbaba..c60b7fc888 100644 --- a/lib/chef/exceptions.rb +++ b/lib/chef/exceptions.rb @@ -561,5 +561,13 @@ class Chef super "before subscription from #{notification.resource} resource cannot be setup to #{notification.notifying_resource} resource, which has already fired while in unified mode" end end + + class RestError < RuntimeError; end + + class RestTargetError < RestError; end + + class RestTimeout < RestError; end + + class RestOperationFailed < RestError; end end end diff --git a/lib/chef/http/authenticator.rb b/lib/chef/http/authenticator.rb index 26413c283b..3b9d49f42a 100644 --- a/lib/chef/http/authenticator.rb +++ b/lib/chef/http/authenticator.rb @@ -100,6 +100,18 @@ class Chef self.class.retrieve_certificate_key(client_name) end + def get_cert_password + self.class.get_cert_password + end + + def encrypt_pfx_pass + self.class.encrypt_pfx_pass + end + + def decrypt_pfx_pass + self.class.decrypt_pfx_pass + end + # Detects if a private key exists in a certificate repository like Keychain (macOS) or Certificate Store (Windows) # # @param client_name - we're using the node name to store and retrieve any keys @@ -115,7 +127,7 @@ class Chef def self.check_certstore_for_key(client_name) powershell_code = <<~CODE - $cert = Get-ChildItem -path cert:\\LocalMachine\\My -Recurse -Force | Where-Object { $_.Subject -Match "#{client_name}" } -ErrorAction Stop + $cert = Get-ChildItem -path cert:\\LocalMachine\\My -Recurse -Force | Where-Object { $_.Subject -Match "chef-#{client_name}" } -ErrorAction Stop if (($cert.HasPrivateKey -eq $true) -and ($cert.PrivateKey.Key.ExportPolicy -ne "NonExportable")) { return $true } @@ -129,17 +141,16 @@ class Chef def load_signing_key(key_file, raw_key = nil) results = retrieve_certificate_key(Chef::Config[:node_name]) - if key_file == nil? && raw_key == nil? - puts "\nNo key detected\n" - elsif !!results - # results variable can be 1 of 2 values - "False" or the contents of a key. + if !!results @raw_key = results + elsif key_file == nil? && raw_key == nil? + puts "\nNo key detected\n" elsif !!key_file @raw_key = IO.read(key_file).strip elsif !!raw_key @raw_key = raw_key.strip else - return nil + return end # Pass in '' as the passphrase to avoid OpenSSL prompting on the TTY if # given an encrypted key. This also helps if using a single file for @@ -154,7 +165,6 @@ class Chef raise Chef::Exceptions::InvalidPrivateKey, msg end - # takes no parameters. Checks for the password in the registry and returns it if there, otherwise returns false def self.get_cert_password @win32registry = Chef::Win32::Registry.new path = "HKEY_LOCAL_MACHINE\\Software\\Progress\\Authentication" @@ -166,60 +176,86 @@ class Chef present.each do |secret| if secret[:name] == "PfxPass" - return secret[:data] + password = decrypt_pfx_pass(secret[:data]) + return password end end - # if we make it this far, that means there is no valid password in the Registry. Fail out to correct that. raise Chef::Exceptions::Win32RegKeyMissing rescue Chef::Exceptions::Win32RegKeyMissing # if we don't have a password, log that and generate one - Chef::Log.warn "Authentication Hive and value not present in registry, creating it now" + Chef::Log.warn "Authentication Hive and values not present in registry, creating them now" new_path = "HKEY_LOCAL_MACHINE\\Software\\Progress\\Authentication" unless @win32registry.key_exists?(new_path) @win32registry.create_key(new_path, true) end - password = SOME_CHARS.sample(1 + rand(SOME_CHARS.count)).join[0...14] - values = { name: "PfxPass", type: :string, data: password } + size = 14 + password = SOME_CHARS.sample(size).join + encrypted_pass = encrypt_pfx_pass(password) + values = { name: "PfxPass", type: :string, data: encrypted_pass } @win32registry.set_value(new_path, values) password end + def self.encrypt_pfx_pass(password) + powershell_code = <<~CODE + $encrypted_string = ConvertTo-SecureString "#{password}" -AsPlainText -Force + $secure_string = ConvertFrom-SecureString $encrypted_string + return $secure_string + CODE + powershell_exec!(powershell_code).result + end + + def self.decrypt_pfx_pass(password) + powershell_code = <<~CODE + $secure_string = "#{password}" | ConvertTo-SecureString + $string = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR((($secure_string)))) + return $string + CODE + powershell_exec!(powershell_code).result + end + def self.retrieve_certificate_key(client_name) require "openssl" unless defined?(OpenSSL) if ChefUtils.windows? - password = get_cert_password - return false unless password if check_certstore_for_key(client_name) - powershell_code = <<~CODE - Try { - $my_pwd = ConvertTo-SecureString -String "#{password}" -Force -AsPlainText; - $cert = Get-ChildItem -path cert:\\LocalMachine\\My -Recurse | Where-Object { $_.Subject -match "#{client_name}" } -ErrorAction Stop; - $tempfile = [System.IO.Path]::GetTempPath() + "export_pfx.pfx"; - Export-PfxCertificate -Cert $cert -Password $my_pwd -FilePath $tempfile; - } - Catch { - return $false - } - CODE - my_result = powershell_exec!(powershell_code).result - - if !!my_result - pkcs = OpenSSL::PKCS12.new(File.binread(my_result["PSPath"].split("::")[1]), password) - ::File.delete(my_result["PSPath"].split("::")[1]) - return OpenSSL::PKey::RSA.new(pkcs.key.to_pem) - end + ps_blob = powershell_exec!(get_the_key_ps(client_name, password)).result + file_path = ps_blob["PSPath"].split("::")[1] + pkcs = OpenSSL::PKCS12.new(File.binread(file_path), password) + + # We test the pfx we just extracted the private key from + # if that cert is expiring in 7 days or less we generate a new pfx/p12 object + # then we post the new public key from that to the client endpoint on + # chef server. + # is_certificate_expiring(pkcs) + File.delete(file_path) + + return pkcs.key.private_to_pem end end false end + def self.get_the_key_ps(client_name, password) + powershell_code = <<~CODE + Try { + $my_pwd = ConvertTo-SecureString -String "#{password}" -Force -AsPlainText; + $cert = Get-ChildItem -path cert:\\LocalMachine\\My -Recurse | Where-Object { $_.Subject -match "chef-#{client_name}$" } -ErrorAction Stop; + $tempfile = [System.IO.Path]::GetTempPath() + "export_pfx.pfx"; + Export-PfxCertificate -Cert $cert -Password $my_pwd -FilePath $tempfile; + } + Catch { + return $false + } + CODE + end + def authentication_headers(method, url, json_body = nil, headers = nil) request_params = { http_method: method, diff --git a/lib/chef/node/attribute.rb b/lib/chef/node/attribute.rb index 6a8e72004b..d235ce4faa 100644 --- a/lib/chef/node/attribute.rb +++ b/lib/chef/node/attribute.rb @@ -452,17 +452,34 @@ class Chef # method-style access to attributes (has to come after the prepended ImmutablizeHash) def read(*path) - merged_attributes.read(*path) + if path[0].nil? + Chef::Log.warn "Calling node.read() without any path argument is very slow, probably a bug, and should be avoided" + merged_attributes.read(*path) # re-merges everything, slow edge case + else + self[path[0]] unless path[0].nil? # force deep_merge_cache key construction if necessary + deep_merge_cache.read(*path) + end end alias :dig :read def read!(*path) - merged_attributes.read!(*path) + if path[0].nil? + Chef::Log.warn "Calling node.read!() without any path argument is very slow, probably a bug, and should be avoided" + merged_attributes.read!(*path) # re-merges everything, slow edge case + else + self[path[0]] unless path[0].nil? # force deep_merge_cache key construction if necessary + deep_merge_cache.read!(*path) + end end def exist?(*path) - merged_attributes.exist?(*path) + if path[0].nil? + true + else + self[path[0]] unless path[0].nil? # force deep_merge_cache key construction if necessary + deep_merge_cache.exist?(*path) + end end def write(level, *args, &block) diff --git a/lib/chef/node/mixin/deep_merge_cache.rb b/lib/chef/node/mixin/deep_merge_cache.rb index 8978d77ea0..be16197850 100644 --- a/lib/chef/node/mixin/deep_merge_cache.rb +++ b/lib/chef/node/mixin/deep_merge_cache.rb @@ -30,7 +30,7 @@ class Chef @merged_attributes = nil @combined_override = nil @combined_default = nil - @deep_merge_cache = {} + @deep_merge_cache = Chef::Node::ImmutableMash.new end # Invalidate a key in the deep_merge_cache. If called with nil, or no arg, this will invalidate @@ -39,9 +39,9 @@ class Chef # must invalidate the entire cache and re-deep-merge the entire node object. def reset_cache(path = nil) if path.nil? - deep_merge_cache.clear + deep_merge_cache.regular_clear else - deep_merge_cache.delete(path.to_s) + deep_merge_cache.regular_delete(path.to_s) end end @@ -53,7 +53,7 @@ class Chef deep_merge_cache[key.to_s] else # save all the work of computing node[key] - deep_merge_cache[key.to_s] = merged_attributes(key) + deep_merge_cache.internal_set(key.to_s, merged_attributes(key)) end ret = ret.call while ret.is_a?(::Chef::DelayedEvaluator) ret diff --git a/lib/chef/resource.rb b/lib/chef/resource.rb index 7f65f28fdd..d6c5fe7cdf 100644 --- a/lib/chef/resource.rb +++ b/lib/chef/resource.rb @@ -1498,10 +1498,18 @@ class Chef # @param partial [String] the code fragment to eval against the class # def self.use(partial) - dirname = ::File.dirname(partial) - basename = ::File.basename(partial, ".rb") - basename = basename[1..] if basename.start_with?("_") - class_eval IO.read(::File.expand_path("#{dirname}/_#{basename}.rb", ::File.dirname(caller_locations.first.path))) + if partial =~ /^core::(.*)/ + partial = $1 + dirname = ::File.dirname(partial) + basename = ::File.basename(partial, ".rb") + basename = basename[1..] if basename.start_with?("_") + class_eval IO.read(::File.expand_path("resource/#{dirname}/_#{basename}.rb", __dir__)) + else + dirname = ::File.dirname(partial) + basename = ::File.basename(partial, ".rb") + basename = basename[1..] if basename.start_with?("_") + class_eval IO.read(::File.expand_path("#{dirname}/_#{basename}.rb", ::File.dirname(caller_locations.first.path))) + end end # The cookbook in which this Resource was defined (if any). diff --git a/lib/chef/resource/_rest_resource.rb b/lib/chef/resource/_rest_resource.rb new file mode 100644 index 0000000000..f14e586eb2 --- /dev/null +++ b/lib/chef/resource/_rest_resource.rb @@ -0,0 +1,386 @@ +# +# Copyright:: Copyright 2008-2016, Chef, 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 "rest-client" unless defined?(RestClient) +require "jmespath" unless defined?(JMESPath) +require "chef/dsl/rest_resource" unless defined?(Chef::DSL::RestResource) + +extend Chef::DSL::RestResource + +action_class do + def load_current_resource + @current_resource = new_resource.class.new(new_resource.name) + + required_properties.each do |name| + requested = new_resource.send(name) + current_resource.send(name, requested) + end + + return @current_resource if rest_get_all.data.empty? + + resource_data = rest_get.data rescue nil + return @current_resource if resource_data.nil? || resource_data.empty? + + @resource_exists = true + + # Map JSON contents to defined properties + current_resource.class.rest_property_map.each do |property, match_instruction| + property_value = json_to_property(match_instruction, property, resource_data) + current_resource.send(property, property_value) unless property_value.nil? + end + + current_resource + end +end + +action :configure do + if resource_exists? + converge_if_changed do + data = {} + + new_resource.class.rest_property_map.each do |property, match_instruction| + # Skip "creation-only" properties on modifications + next if new_resource.class.rest_post_only_properties.include?(property) + + deep_merge! data, property_to_json(property, match_instruction) + end + + deep_compact!(data) + + rest_patch(data) + end + else + converge_by "creating resource" do + data = {} + + new_resource.class.rest_property_map.each do |property, match_instruction| + deep_merge! data, property_to_json(property, match_instruction) + end + + deep_compact!(data) + + rest_post(data) + end + end +end + +action :delete do + if resource_exists? + converge_by "deleting resource" do + rest_delete + end + else + logger.debug format("REST resource %<name>s of type %<type>s does not exist. Skipping.", + type: new_resource.name, name: id_property) + end +end + +action_class do + # Override this for postprocessing device-specifics (paging, data conversion) + def rest_postprocess(response) + response + end + + # Override this for error handling of device-specifics (readable error messages) + def rest_errorhandler(error_obj) + error_obj + end + + private + + def resource_exists? + @resource_exists + end + + def required_properties + current_resource.class.properties.select { |_, v| v.required? }.except(:name).keys + end + + # Return changed value or nil for delta current->new + def changed_value(property) + new_value = new_resource.send(property) + return new_value if current_resource.nil? + + current_value = current_resource.send(property) + + return current_value if required_properties.include? property + + new_value == current_value ? nil : new_value + end + + def id_property + current_resource.class.identity_attr + end + + # Map properties to their current values + def property_map + map = {} + + current_resource.class.state_properties.each do |property| + name = property.options[:name] + + map[name] = current_resource.send(name) + end + + map[id_property] = current_resource.send(id_property) + + map + end + + # Map part of a JSON (Hash) to resource property via JMESPath or user-supplied function + def json_to_property(match_instruction, property, resource_data) + case match_instruction + when String + JMESPath.search(match_instruction, resource_data) + when Symbol + function = "#{property}_from_json".to_sym + raise "#{new_resource.name} missing #{function} method" unless self.class.protected_method_defined?(function) + + send(function, resource_data) || {} + else + raise TypeError, "Did not expect match type #{match_instruction.class}" + end + end + + # Map resource contents into a JSON (Hash) via JMESPath-like syntax or user-supplied function + def property_to_json(property, match_instruction) + case match_instruction + when String + bury(match_instruction, changed_value(property)) + when Symbol + function = "#{property}_to_json".to_sym + raise "#{new_resource.name} missing #{function} method" unless self.class.protected_method_defined?(function) + + value = new_resource.send(property) + changed_value(property).nil? ? {} : send(function, value) + else + raise TypeError, "Did not expect match type #{match_instruction.class}" + end + end + + def rest_url_collection + current_resource.class.rest_api_collection + end + + # Resource document URL after RFC 6570 template evaluation via properties substitution + def rest_url_document + template = ::Addressable::Template.new(current_resource.class.rest_api_document) + template.expand(property_map).to_s + end + + # Convenience method for conditional requires + def conditionally_require_on_setting(property, dependent_properties) + dependent_properties = Array(dependent_properties) + + requirements.assert(:configure) do |a| + a.assertion do + # Needs to be set and truthy to require dependent properties + if new_resource.send(property) + dependent_properties.all? { |dep_prop| new_resource.property_is_set?(dep_prop) } + else + true + end + end + + message = format("Setting property :%<property>s requires properties :%<properties>s to be set as well on resource %<resource_name>s", + property: property, + properties: dependent_properties.join(", :"), + resource_name: current_resource.to_s) + + a.failure_message message + end + end + + # Generic REST helpers + + def rest_get_all + response = api_connection.get(rest_url_collection) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_get + response = api_connection.get(rest_url_document) + + response = rest_postprocess(response) + + first_only = current_resource.class.rest_api_document_first_element_only + first_only && response.is_a?(Array) ? response.first : response + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_post(data) + data.merge! rest_identity_values + + response = api_connection.post(rest_url_collection, data: data) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_put(data) + data.merge! rest_identity_values + + response = api_connection.put(rest_url_collection, data: data) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_patch(data) + response = api_connection.patch(rest_url_document, data: data) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + def rest_delete + response = api_connection.delete(rest_url_document) + + rest_postprocess(response) + rescue RestClient::Exception => e + rest_errorhandler(e) + end + + # REST parameter mapping + + # Return number of parameters needed to identify a resource (pre- and post-creation) + def rest_arity + rest_identity_map.keys.count + end + + # Return mapping of template placeholders to property value of identity parameters + def rest_identity_values + data = {} + + rest_identity_map.each do |rfc_template, property| + property_value = new_resource.send(property) + data.merge! bury(rfc_template, property_value) + end + + data + end + + def rest_identity_map + rest_identity_explicit || rest_identity_implicit + end + + # Accept direct mapping like { "svm.name" => :name } for specifying the x-ary identity of a resource + def rest_identity_explicit + current_resource.class.rest_identity_map + end + + # Parse document URL for RFC 6570 templates and map them to resource properties. + # + # Examples: + # Query based: "/api/protocols/san/igroups?name={name}&svm.name={svm}": { "name" => :name, "svm.name" => :svm } + # Path based: "/api/v1/{address}": { "address" => :address } + # + def rest_identity_implicit + template_url = current_resource.class.rest_api_document + + rfc_template = ::Addressable::Template.new(template_url) + rfc_template_vars = rfc_template.variables + + # Shortcut for 0-ary resources + return {} if rfc_template_vars.empty? + + if query_based_selection? + uri_query = URI.parse(template_url).query + + if CGI.parse(uri_query).values.any?(&:empty?) + raise "Need explicit identity mapping, as URL does not contain query parameters for all templates" + end + + path_variables = CGI.parse(uri_query).keys + elsif path_based_selection? + path_variables = rfc_template_vars + else + # There is also + raise "Unknown type of resource selection. Document URL does not seem to be path- or query-based?" + end + + identity_map = {} + path_variables.each_with_index do |v, i| + next if rfc_template_vars[i].nil? # Not mapped to property, assume metaparameter + + identity_map[v] = rfc_template_vars[i].to_sym + end + + identity_map + end + + def query_based_selection? + template_url = current_resource.class.rest_api_document + + # Will throw exception on presence of RFC 6570 templates + URI.parse(template_url) + true + rescue URI::InvalidURIError => _e + false + end + + def path_based_selection? + !query_based_selection? + end + + def api_connection + Chef.run_context.transport.connection + end + + # Remove all empty keys (recusively) from Hash. + # @see https://stackoverflow.com/questions/56457020/#answer-56458673 + def deep_compact!(hsh) + raise TypeError unless hsh.is_a? Hash + + hsh.each do |_, v| + deep_compact!(v) if v.is_a? Hash + end.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } + end + + # Deep merge two hashes + # @see https://stackoverflow.com/questions/41109599#answer-41109737 + def deep_merge!(hsh1, hsh2) + raise TypeError unless hsh1.is_a?(Hash) && hsh2.is_a?(Hash) + + hsh1.merge!(hsh2) { |_, v1, v2| deep_merge!(v1, v2) } + end + + # Create nested hashes from JMESPath syntax. + def bury(path, value) + raise TypeError unless path.is_a?(String) + + arr = path.split(".") + ret = {} + + if arr.count == 1 + ret[arr.first] = value + + ret + else + partial_path = arr[0..-2].join(".") + + bury(partial_path, bury(arr.last, value)) + end + end +end diff --git a/lib/chef/resource/chef_client_config.rb b/lib/chef/resource/chef_client_config.rb index a9b1fa825e..916dbffe7e 100644 --- a/lib/chef/resource/chef_client_config.rb +++ b/lib/chef/resource/chef_client_config.rb @@ -208,6 +208,10 @@ class Chef description: %q(An array of hashes that contain a report handler class and the arguments to pass to that class on initialization. The hash should include `class` and `argument` keys where `class` is a String and `argument` is an array of quoted String values. For example: `[{'class' => 'MyHandler', %w('"argument1"', '"argument2"')}]`), default: [] + property :rubygems_url, [String, Array], + description: "The location to source rubygems. It can be set to a string or array of strings for URIs to set as rubygems sources. This allows individuals to setup an internal mirror of rubygems for “airgapped” environments.", + introduced: "17.11" + property :exception_handlers, Array, description: %q(An array of hashes that contain a exception handler class and the arguments to pass to that class on initialization. The hash should include `class` and `argument` keys where `class` is a String and `argument` is an array of quoted String values. For example: `[{'class' => 'MyHandler', %w('"argument1"', '"argument2"')}]`), default: [] @@ -295,6 +299,7 @@ class Chef policy_group: new_resource.policy_group, policy_name: new_resource.policy_name, report_handlers: format_handler(new_resource.report_handlers), + rubygems_url: new_resource.rubygems_url, ssl_verify_mode: new_resource.ssl_verify_mode, start_handlers: format_handler(new_resource.start_handlers), additional_config: new_resource.additional_config, diff --git a/lib/chef/resource/plist.rb b/lib/chef/resource/plist.rb index 6783ae5f51..d316d81b2a 100644 --- a/lib/chef/resource/plist.rb +++ b/lib/chef/resource/plist.rb @@ -84,7 +84,7 @@ class Chef converge_if_changed :path do converge_by "create new plist: '#{new_resource.path}'" do file new_resource.path do - content {}.to_plist + content({}.to_plist) owner new_resource.owner group new_resource.group mode new_resource.mode if property_is_set?(:mode) @@ -188,7 +188,12 @@ class Chef sep = " " arg = case subcommand.to_s when "add" - type_to_commandline_string(value) + if value.is_a?(Hash) + sep = ":" + value.map { |k, v| "#{k} #{type_to_commandline_string(v)}" } + else + type_to_commandline_string(value) + end when "set" if value.is_a?(Hash) sep = ":" diff --git a/lib/chef/resource/support/client.erb b/lib/chef/resource/support/client.erb index 8e96ca49e2..724adc9325 100644 --- a/lib/chef/resource/support/client.erb +++ b/lib/chef/resource/support/client.erb @@ -18,6 +18,7 @@ @pid_file @policy_group @policy_name + @rubygems_url @ssl_verify_mode @policy_persist_run_list).each do |prop| -%> <% next if instance_variable_get(prop).nil? || instance_variable_get(prop).empty? -%> diff --git a/lib/chef/run_context.rb b/lib/chef/run_context.rb index 94f8a316e0..ce4d545aa4 100644 --- a/lib/chef/run_context.rb +++ b/lib/chef/run_context.rb @@ -145,6 +145,16 @@ class Chef # attr_accessor :input_collection + # + # @return [Symbol, nil] + # + attr_accessor :default_secret_service + + # + # @return [Hash<Symbol,Object>] + # + attr_accessor :default_secret_config + # Pointer back to the Chef::Runner that created this # attr_accessor :runner @@ -222,6 +232,8 @@ class Chef @input_collection = Chef::Compliance::InputCollection.new(events) @waiver_collection = Chef::Compliance::WaiverCollection.new(events) @profile_collection = Chef::Compliance::ProfileCollection.new(events) + @default_secret_service = nil + @default_secret_config = {} initialize_child_state end @@ -693,6 +705,10 @@ class Chef cookbook_collection cookbook_collection= cookbook_compiler + default_secret_config + default_secret_config= + default_secret_service + default_secret_service= definitions events events= diff --git a/lib/chef/version.rb b/lib/chef/version.rb index c471d743aa..53603b9d1c 100644 --- a/lib/chef/version.rb +++ b/lib/chef/version.rb @@ -23,7 +23,7 @@ require_relative "version_string" class Chef CHEF_ROOT = File.expand_path("..", __dir__) - VERSION = Chef::VersionString.new("18.0.76") + VERSION = Chef::VersionString.new("18.0.87") end # |