From 85c49992be7c3f99bac79730ef6d163e81da198a Mon Sep 17 00:00:00 2001 From: Alexander Olofsson Date: Mon, 25 Nov 2024 17:03:14 +0100 Subject: [PATCH] Started work on a kubectl_wait type For #45 --- lib/puppet/provider/kubectl_wait/kubectl.rb | 67 +++++++ lib/puppet/type/kubectl_wait.rb | 177 ++++++++++++++++++ .../kubectl_wait_resource/kubectl_spec.rb | 56 ++++++ spec/unit/puppet/type/kubectl_wait_spec.rb | 157 ++++++++++++++++ 4 files changed, 457 insertions(+) create mode 100644 lib/puppet/provider/kubectl_wait/kubectl.rb create mode 100644 lib/puppet/type/kubectl_wait.rb create mode 100644 spec/unit/puppet/provider/kubectl_wait_resource/kubectl_spec.rb create mode 100644 spec/unit/puppet/type/kubectl_wait_spec.rb diff --git a/lib/puppet/provider/kubectl_wait/kubectl.rb b/lib/puppet/provider/kubectl_wait/kubectl.rb new file mode 100644 index 0000000..2366225 --- /dev/null +++ b/lib/puppet/provider/kubectl_wait/kubectl.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require File.expand_path('../../util/k8s', __dir__) + +# Applies resources as data in a Kubernetes cluster +Puppet::Type.type(:kubectl_wait).provide(:kubectl) do + commands kubectl: 'kubectl' + + def run + kubectl_wait + end + + private + + def wait_for + if resource[:delete] + ['--for', 'delete'] + elsif resource[:condition] + case resource[:condition] + when String + ['--for', ['condition', resource[:condition]].join('=')] + when Hash + ['--for', ['condition', resource[:condition].keys.first, resource[:condition].values.first].join('=')] + end + elsif resource[:json] + case resource[:json] + when String + ['--for', ['jsonpath', "'{#{resource[:json].keys.first}}'"].join('=')] + when Hash + ['--for', ['jsonpath', ["'{#{resource[:json].keys.first}}'", resource[:json].values.first].join('=')]] + end + end + end + + def wait_timeout + ['--timeout', resource[:timeout] || '30s'] + end + + def resource_kind + if resource[:api_version].include? '/' + group, version = resource[:api_version].split('/') + [resource[:kind], version, group].join('.') + else + resource[:kind] + end + end + + def kubectl_wait + kubectl_cmd 'wait', resource_kind, resource[:resource_name], *wait_timeout, *wait_for + rescue StandardError => e + raise Puppet::Error, "#{e.class}: #{e}" + end + + def kubectl_cmd(*args) + params = [] + if resource[:namespace] + params << '--namespace' + params << resource[:namespace] + end + if resource[:kubeconfig] + params << '--kubeconfig' + params << resource[:kubeconfig] + end + + kubectl(*params, *args) + end +end diff --git a/lib/puppet/type/kubectl_wait.rb b/lib/puppet/type/kubectl_wait.rb new file mode 100644 index 0000000..02df85c --- /dev/null +++ b/lib/puppet/type/kubectl_wait.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'puppet/parameter/boolean' + +Puppet::Type.newtype(:kubectl_wait) do + desc <<-DOC + Example: + + To wait for the cluster DNS to be ready; + + kubectl_wait { "coredns": + namespace => 'kube-system', + kubeconfig => '/root/.kube/config', + + api_version => 'apps/v1, + kind => 'Deployment', + + condition => 'Available', + } + DOC + + # XXX Better way to separate name from Puppet namevar handling? + newparam(:resource_name) do + desc 'The name of the resource' + + validate do |value| + raise Puppet::Error, 'Resource name must be valid' unless value.match? %r{^([a-z0-9][a-z0-9.:-]{0,251}[a-z0-9]|[a-z0-9])$} + end + end + + newparam(:name, namevar: true) do + desc 'The Puppet name of the instance' + end + + newparam(:namespace) do + desc 'The namespace the resource is contained in' + + validate do |value| + raise Puppet::Error, 'Namespace must be valid' unless value.match? %r{^[a-z0-9.-]{0,253}$} + end + end + + newparam(:kubeconfig) do + desc 'The kubeconfig file to use for handling the resource' + + validate do |value| + raise Puppet::Error, 'Kubeconfig path must be fully qualified' unless Puppet::Util.absolute_path?(value) + end + end + + newparam(:api_version) do + desc 'The apiVersion of the resource' + + defaultto('v1') + end + newparam(:kind) do + desc 'The kind of the resource' + end + + newparam(:timeout) do + desc 'The duration to wait for, as a go duration - i.e. 1h30m20s (default 30s)' + + defaultto('30s') + + validate do |value| + raise Puppet::Error, 'Not a valid go duration' unless value.is_a?(String) && %r{^(-?[0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$}.match?(value) + end + end + newparam(:refreshonly, boolean: true, parent: Puppet::Parameter::Boolean) do + desc 'Should the wait only trigger on refresh' + + defaultto(:true) + end + + newparam(:condition) do + desc <<~DESC + A resource condition to await + + Either a String or a Hash[String,Data,1,1] + + E.g. + + condition => 'Available' + + condition => { + 'Ready' => false, + } + DESC + + validate do |value| + raise Puppet::Error, 'Condition must be either a string or single-entry hash' unless value.is_a?(String) || (value.is_a?(Hash) && value.size == 1) + end + end + newparam(:delete, boolean: true, parent: Puppet::Parameter::Boolean) do + desc 'Await resource deletion' + + validate do |value| + raise Puppet::Error, 'Delete must be true' unless value == :true + end + end + newparam(:json) do + desc <<~DESC + JSONPath expression to await + + Must be a String or Hash[String,Data,1,1] + + E.g. + + json => '.status.loadBalancer.ingress' + + json => { + '.status.phase' => 'Running' + } + DESC + + validate do |value| + raise Puppet::Error, 'JSON must be either a string or single-entry hash' unless value.is_a?(String) || (value.is_a?(Hash) && value.size == 1) + end + end + + newproperty(:awaited, boolean: true, parent: Puppet::Parameter::Boolean) do + desc 'Placeholder to trigger wait' + + defaultto(:true) + + def retrieve + if self[:refreshonly] == :true + should + else + :not_awaited + end + end + + def sync + provider.run + end + end + + validate do + self[:resource_name] = self[:name] if self[:resource_name].nil? + + raise Puppet::Error, 'API version is required' unless self[:api_version] + raise Puppet::Error, 'Kind is required' unless self[:kind] + puts "Calling wait with conditions: #{[self[:condition], self[:delete], self[:json]].compact.inspect}" + raise Puppet::Error, 'One - and only one - of condition/delete/json must be specified' unless [self[:condition], self[:delete], self[:json]].compact.size == 1 + + end + + def refresh + property(:awaited).sync + end + + autorequire(:kubeconfig) do + [self[:kubeconfig]] + end + autorequire(:service) do + ['kube-apiserver'] + end + autorequire(:exec) do + ['k8s apiserver wait online'] + end + autorequire(:file) do + [ + self[:kubeconfig], + ] + end + autorequire(:k8s__binary) do + ['kubectl'] + end + + def nice_name + return self[:name] unless self[:namespace] + + "#{self[:namespace]}/#{self[:name]}" + end +end + diff --git a/spec/unit/puppet/provider/kubectl_wait_resource/kubectl_spec.rb b/spec/unit/puppet/provider/kubectl_wait_resource/kubectl_spec.rb new file mode 100644 index 0000000..bc93688 --- /dev/null +++ b/spec/unit/puppet/provider/kubectl_wait_resource/kubectl_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +kubectl_provider = Puppet::Type.type(:kubectl_wait).provider(:kubectl) + +RSpec.describe kubectl_provider do + describe 'kubectl provider' do + include PuppetlabsSpec::Files + let(:tmpfile) do + tmpfilename('kubeconfig_test') + end + + let(:name) { 'coredns' } + let(:resource_properties) do + { + name: name, + namespace: 'kube-system', + + api_version: 'apps/v1', + kind: 'Deployment', + + condition: 'Available' + } + end + + let(:kubectl_params) do + [ + '--namespace', + 'kube-system', + 'wait', + 'Deployment.v1.apps', + name, + '--timeout', + '30s', + '--for', + 'condition=Available' + ] + end + + let(:resource) { Puppet::Type::Kubectl_wait.new(resource_properties) } + let(:provider) { kubectl_provider.new(resource) } + + before do + resource.provider = provider + + allow(kubectl_provider).to receive(:suitable?).and_return(true) + end + + it 'runs a correct wait command' do + expect(provider).to receive(:kubectl).with(*kubectl_params) + + provider.run + end + end +end diff --git a/spec/unit/puppet/type/kubectl_wait_spec.rb b/spec/unit/puppet/type/kubectl_wait_spec.rb new file mode 100644 index 0000000..7179dc6 --- /dev/null +++ b/spec/unit/puppet/type/kubectl_wait_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet' + +describe Puppet::Type.type(:kubectl_wait) do + let(:resource) do + Puppet::Type.type(:kubectl_wait).new( + name: 'coredns', + namespace: 'kube-system', + + api_version: 'apps/v1', + kind: 'Deployment', + + condition: 'Available' + ) + end + + context 'resource defaults' do + it { expect(resource[:kubeconfig]).to be_nil } + it { expect(resource[:refreshonly]).to be true } + end + + %w[ + simplename + default-token-6mqpl + metrics-server-7cb45bbfd5-gz4t6 + ].each do |name| + it 'accepts valid names' do + expect { resource[:resource_name] = name }.not_to raise_error + end + end + + [ + 'CamelCasedName', + 'name-with space', + 'snake_cased_name', + 'fqdn.like/name', + ].each do |name| + it 'rejects invalid names' do + expect { resource[:resource_name] = name }.to raise_error(Puppet::ResourceError, %r{Resource name must be valid}) + end + end + + %w[ + default + kube-system + some-ridiculously-long-name-thats-still-inside-of-the-limitations-kubernetes-has + ].each do |name| + it 'accepts valid namespaces' do + expect { resource[:namespace] = name }.not_to raise_error + end + end + + [ + 'CamelCasedName', + 'name-with space', + 'snake_cased_name', + 'fqdn.like/name', + ].each do |name| + it 'rejects invalid namespaces' do + expect { resource[:namespace] = name }.to raise_error(Puppet::Error, %r{Namespace must be valid}) + end + end + + it 'rejects too long namespaces' do + expect { resource[:namespace] = 'x' * 254 }.to raise_error(Puppet::Error, %r{Namespace must be valid}) + end + + it 'verify resource[:kubeconfig] is absolute filepath' do + expect { resource[:kubeconfig] = 'relative/file' }.to raise_error(Puppet::Error, %r{Kubeconfig path must be fully qualified}) + end + + [ + '300ms', + '-1.5h', + '2h45m', + '1h10m10s', + '1µs', + '1us', + ].each do |value| + it 'accepts valid tiemouts' do + expect { resource[:timeout] = value }.not_to raise_error + end + end + + [ + [nil], + [nil, nil], + { 'foo' => 'bar' }, + {}, + '', + 's', + '.5s', + 'blah', + '199', + 600, + 1_000, + ].each do |value| + it 'rejects invalid timeouts' do + expect { resource[:timeout] = value }.to raise_error(Puppet::Error, %r{Not a valid go duration}) + end + end + + it 'verify resource[:condition] is a string or single-element hash' do + expect { resource[:condition] = [] }.to raise_error(Puppet::Error, %r{Condition must be}) + expect { resource[:condition] = 5 }.to raise_error(Puppet::Error, %r{Condition must be}) + expect { resource[:condition] = { a: 1, b: 2 } }.to raise_error(Puppet::Error, %r{Condition must be}) + expect { resource[:condition] = 'Ready' }.not_to raise_error + expect { resource[:condition] = { 'Ready' => false } }.not_to raise_error + end + + it 'verify resource[:delete] is the boolean true' do + expect { resource[:delete] = [] }.to raise_error(Puppet::Error, %r{Delete must be}) + expect { resource[:delete] = false }.to raise_error(Puppet::Error, %r{Delete must be}) + expect { resource[:delete] = :false }.to raise_error(Puppet::Error, %r{Delete must be}) + expect { resource[:delete] = :true }.not_to raise_error + end + + it 'verify resource[:json] is a string or single-element hash' do + expect { resource[:json] = [] }.to raise_error(Puppet::Error, %r{JSON must be}) + expect { resource[:json] = 5 }.to raise_error(Puppet::Error, %r{JSON must be}) + expect { resource[:json] = { a: 1, b: 2 } }.to raise_error(Puppet::Error, %r{JSON must be}) + expect { resource[:json] = '.status.loadBalancer.ingress' }.not_to raise_error + expect { resource[:json] = { '.status.phase' => 'Running' } }.not_to raise_error + end + + describe 'file autorequire' do + let(:file_resource) { Puppet::Type.type(:file).new(name: '/root/.kube/config') } + let(:kubectl_wait_resource) do + described_class.new( + name: 'blah', + namespace: 'default', + api_version: 'v1', + kind: 'ConfigMap', + kubeconfig: '/root/.kube/config', + condition: 'Available' + ) + end + + let(:auto_req) do + catalog = Puppet::Resource::Catalog.new + catalog.add_resource file_resource + catalog.add_resource kubectl_wait_resource + + kubectl_wait_resource.autorequire + end + + it 'creates relationship' do + expect(auto_req.size).to be 1 + end + + it 'links to file resource' do + expect(auto_req[0].target).to eql kubectl_wait_resource + end + end +end