diff --git a/lib/kubernetes-deploy/common.rb b/lib/kubernetes-deploy/common.rb index e68113311..d4d6873b5 100644 --- a/lib/kubernetes-deploy/common.rb +++ b/lib/kubernetes-deploy/common.rb @@ -15,6 +15,8 @@ require 'kubernetes-deploy/errors' require 'kubernetes-deploy/formatted_logger' require 'kubernetes-deploy/statsd' +require 'kubernetes-deploy/task_config' +require 'kubernetes-deploy/task_config_validator' module KubernetesDeploy MIN_KUBE_VERSION = '1.10.0' diff --git a/lib/kubernetes-deploy/deploy_task.rb b/lib/kubernetes-deploy/deploy_task.rb index 042e63b12..3d07da78f 100644 --- a/lib/kubernetes-deploy/deploy_task.rb +++ b/lib/kubernetes-deploy/deploy_task.rb @@ -109,12 +109,13 @@ def server_version def initialize(namespace:, context:, current_sha:, template_dir:, logger: nil, kubectl_instance: nil, bindings: {}, max_watch_seconds: nil, selector: nil) + @logger = logger || KubernetesDeploy::FormattedLogger.build(namespace, context) + @task_config = KubernetesDeploy::TaskConfig.new(context, namespace, @logger) @namespace = namespace @namespace_tags = [] @context = context @current_sha = current_sha @template_dir = File.expand_path(template_dir) - @logger = logger || KubernetesDeploy::FormattedLogger.build(namespace, context) @kubectl = kubectl_instance @max_watch_seconds = max_watch_seconds @renderer = KubernetesDeploy::Renderer.new( @@ -551,9 +552,7 @@ def confirm_cluster_reachable end end raise FatalDeploymentError, "Failed to reach server for #{@context}" unless success - if kubectl.server_version < Gem::Version.new(MIN_KUBE_VERSION) - @logger.warn(KubernetesDeploy::Errors.server_version_warning(server_version)) - end + TaskConfigValidator.new(@task_config, kubectl, kubeclient_builder, only: [:validate_server_version]).valid? end def confirm_namespace_exists diff --git a/lib/kubernetes-deploy/errors.rb b/lib/kubernetes-deploy/errors.rb index d70d84e9d..cae3ba3f1 100644 --- a/lib/kubernetes-deploy/errors.rb +++ b/lib/kubernetes-deploy/errors.rb @@ -30,12 +30,4 @@ def initialize "kubernetes-deploy will not continue since it is extremely unlikely that this secret should be pruned.") end end - - module Errors - extend self - def server_version_warning(server_version) - "Minimum cluster version requirement of #{MIN_KUBE_VERSION} not met. "\ - "Using #{server_version} could result in unexpected behavior as it is no longer tested against" - end - end end diff --git a/lib/kubernetes-deploy/kubeclient_builder.rb b/lib/kubernetes-deploy/kubeclient_builder.rb index f4529ec1f..1d8d6a7e3 100644 --- a/lib/kubernetes-deploy/kubeclient_builder.rb +++ b/lib/kubernetes-deploy/kubeclient_builder.rb @@ -106,11 +106,11 @@ def build_storage_v1_kubeclient(context) def validate_config_files errors = [] if @kubeconfig_files.empty? - errors << "Kube config file name(s) not set in $KUBECONFIG" + errors << "Kubeconfig file name(s) not set in $KUBECONFIG" else @kubeconfig_files.each do |f| # If any files in the list are not valid, we can't be sure the merged context list is what the user intended - errors << "Kube config not found at #{f}" unless File.file?(f) + errors << "Kubeconfig not found at #{f}" unless File.file?(f) end end errors diff --git a/lib/kubernetes-deploy/restart_task.rb b/lib/kubernetes-deploy/restart_task.rb index 833aa6323..3c483797f 100644 --- a/lib/kubernetes-deploy/restart_task.rb +++ b/lib/kubernetes-deploy/restart_task.rb @@ -22,9 +22,10 @@ def initialize(deployment_name, response) ANNOTATION = "shipit.shopify.io/restart" def initialize(context:, namespace:, logger: nil, max_watch_seconds: nil) + @logger = logger || KubernetesDeploy::FormattedLogger.build(namespace, context) + @task_config = KubernetesDeploy::TaskConfig.new(context, namespace, @logger) @context = context @namespace = namespace - @logger = logger || KubernetesDeploy::FormattedLogger.build(namespace, context) @max_watch_seconds = max_watch_seconds end @@ -40,11 +41,9 @@ def perform!(deployments_names = nil, selector: nil) @logger.reset @logger.phase_heading("Initializing restart") - verify_namespace + verify_config! deployments = identify_target_deployments(deployments_names, selector: selector) - if kubectl.server_version < Gem::Version.new(MIN_KUBE_VERSION) - @logger.warn(KubernetesDeploy::Errors.server_version_warning(kubectl.server_version)) - end + @logger.phase_heading("Triggering restart by touching ENV[RESTARTED_AT]") patch_kubeclient_deployments(deployments) @@ -120,13 +119,6 @@ def build_watchables(kubeclient_resources, started) end end - def verify_namespace - kubeclient.get_namespace(@namespace) - @logger.info("Namespace #{@namespace} found in context #{@context}") - rescue Kubeclient::ResourceNotFoundError - raise NamespaceNotFoundError.new(@namespace, @context) - end - def patch_deployment_with_restart(record) v1beta1_kubeclient.patch_deployment( record.metadata.name, @@ -176,6 +168,15 @@ def build_patch_payload(deployment) } end + def verify_config! + task_config_validator = TaskConfigValidator.new(@task_config, kubectl, kubeclient_builder) + unless task_config_validator.valid? + @logger.summary.add_action("Configuration invalid") + @logger.summary.add_paragraph(task_config_validator.errors.map { |err| "- #{err}" }.join("\n")) + raise KubernetesDeploy::TaskConfigurationError + end + end + def kubeclient @kubeclient ||= kubeclient_builder.build_v1_kubeclient(@context) end diff --git a/lib/kubernetes-deploy/runner_task.rb b/lib/kubernetes-deploy/runner_task.rb index 925583162..ed9a35354 100644 --- a/lib/kubernetes-deploy/runner_task.rb +++ b/lib/kubernetes-deploy/runner_task.rb @@ -17,6 +17,7 @@ class TaskTemplateMissingError < TaskConfigurationError; end def initialize(namespace:, context:, logger: nil, max_watch_seconds: nil) @logger = logger || KubernetesDeploy::FormattedLogger.build(namespace, context) + @task_config = KubernetesDeploy::TaskConfig.new(context, namespace, @logger) @namespace = namespace @context = context @max_watch_seconds = max_watch_seconds @@ -140,9 +141,7 @@ def validate_configuration(task_template, args) raise TaskConfigurationError, "Configuration invalid: #{errors.join(', ')}" end - if kubectl.server_version < Gem::Version.new(MIN_KUBE_VERSION) - @logger.warn(KubernetesDeploy::Errors.server_version_warning(kubectl.server_version)) - end + TaskConfigValidator.new(@task_config, kubectl, kubeclient_builder, only: [:validate_server_version]).valid? end def get_template(template_name) diff --git a/lib/kubernetes-deploy/task_config.rb b/lib/kubernetes-deploy/task_config.rb new file mode 100644 index 000000000..69ba12860 --- /dev/null +++ b/lib/kubernetes-deploy/task_config.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +module KubernetesDeploy + class TaskConfig + attr_reader :context, :namespace + + def initialize(context, namespace, logger = nil) + @context = context + @namespace = namespace + @logger = logger + end + + def logger + @logger ||= KubernetesDeploy::FormattedLogger.build(@namespace, @context) + end + end +end diff --git a/lib/kubernetes-deploy/task_config_validator.rb b/lib/kubernetes-deploy/task_config_validator.rb new file mode 100644 index 000000000..5fc334772 --- /dev/null +++ b/lib/kubernetes-deploy/task_config_validator.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +module KubernetesDeploy + class TaskConfigValidator + DEFAULT_VALIDATIONS = %i( + validate_kubeconfig + validate_context_exists_in_kubeconfig + validate_context_reachable + validate_server_version + validate_namespace_exists + ).freeze + + delegate :context, :namespace, :logger, to: :@task_config + + def initialize(task_config, kubectl, kubeclient_builder, only: nil) + @task_config = task_config + @kubectl = kubectl + @kubeclient_builder = kubeclient_builder + @errors = nil + @validations = only || DEFAULT_VALIDATIONS + end + + def valid? + @errors = [] + @validations.each do |validator_name| + break if @errors.present? + send(validator_name) + end + @errors.empty? + end + + def errors + valid? + @errors + end + + private + + def validate_kubeconfig + @errors += @kubeclient_builder.validate_config_files + end + + def validate_context_exists_in_kubeconfig + unless context.present? + return @errors << "Context can not be blank" + end + + _, err, st = @kubectl.run("config", "get-contexts", context, "-o", "name", + use_namespace: false, use_context: false, log_failure: false) + + unless st.success? + @errors << if err.match("error: context #{context} not found") + "Context #{context} missing from your kubeconfig file(s)" + else + "Something went wrong. #{err} " + end + end + end + + def validate_context_reachable + _, err, st = @kubectl.run("get", "namespaces", "-o", "name", + use_namespace: false, log_failure: false) + + unless st.success? + @errors << "Something went wrong connecting to #{context}. #{err} " + end + end + + def validate_namespace_exists + unless namespace.present? + return @errors << "Namespace can not be blank" + end + + _, err, st = @kubectl.run("get", "namespace", "-o", "name", namespace, + use_namespace: false, log_failure: false) + + unless st.success? + @errors << if err.match("Error from server [(]NotFound[)]: namespace") + "Could not find Namespace: #{namespace} in Context: #{context}" + else + "Could not connect to kubernetes cluster. #{err}" + end + end + end + + def validate_server_version + if @kubectl.server_version < Gem::Version.new(MIN_KUBE_VERSION) + logger.warn(server_version_warning(@kubectl.server_version)) + end + end + + def server_version_warning(server_version) + "Minimum cluster version requirement of #{MIN_KUBE_VERSION} not met. "\ + "Using #{server_version} could result in unexpected behavior as it is no longer tested against" + end + end +end diff --git a/test/integration-serial/serial_deploy_test.rb b/test/integration-serial/serial_deploy_test.rb index 29cb78a0e..b8ca2f1bd 100644 --- a/test/integration-serial/serial_deploy_test.rb +++ b/test/integration-serial/serial_deploy_test.rb @@ -75,7 +75,7 @@ def test_multiple_configuration_files assert_logs_match_all([ 'Result: FAILURE', 'Configuration invalid', - "Kube config not found at #{config_file}", + "Kubeconfig not found at #{config_file}", ], in_order: true) reset_logger @@ -85,7 +85,7 @@ def test_multiple_configuration_files assert_logs_match_all([ 'Result: FAILURE', 'Configuration invalid', - "Kube config file name(s) not set in $KUBECONFIG", + "Kubeconfig file name(s) not set in $KUBECONFIG", ], in_order: true) reset_logger diff --git a/test/integration/restart_task_test.rb b/test/integration/restart_task_test.rb index fdf1f71c6..3ce1214c9 100644 --- a/test/integration/restart_task_test.rb +++ b/test/integration/restart_task_test.rb @@ -174,7 +174,7 @@ def test_restart_not_existing_context assert_restart_failure(restart.perform(%w(web))) assert_logs_match_all([ "Result: FAILURE", - "`walrus` context must be configured in your kubeconfig file(s)", + /- Context walrus missing from your kubeconfig file\(s\)/, ], in_order: true) end @@ -188,7 +188,7 @@ def test_restart_not_existing_namespace assert_restart_failure(restart.perform(%w(web))) assert_logs_match_all([ "Result: FAILURE", - "Namespace `walrus` not found in context `#{TEST_CONTEXT}`", + "- Could not find Namespace: walrus in Context: #{KubeclientHelper::TEST_CONTEXT}", ], in_order: true) end diff --git a/test/integration/task_config_validator_test.rb b/test/integration/task_config_validator_test.rb new file mode 100644 index 000000000..47d106dac --- /dev/null +++ b/test/integration/task_config_validator_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +require 'integration_test_helper' + +class TaskConfigValidatorTest < KubernetesDeploy::IntegrationTest + def test_valid_configuration + assert_predicate(validator(context: KubeclientHelper::TEST_CONTEXT, namespace: 'default'), :valid?) + end + + def test_only_is_respected + assert_predicate(validator(only: []), :valid?) + end + + def test_invalid_kubeconfig + bad_file = "/IM_NOT_A_REAL_FILE.yml" + builder = KubernetesDeploy::KubeclientBuilder.new(kubeconfig: bad_file) + assert_match("Kubeconfig not found at #{bad_file}", + validator(kubeclient_builder: builder, only: [:validate_kubeconfig]).errors.join("\n")) + end + + def test_context_does_not_exists_in_kubeconfig + fake_context = "fake-context" + assert_match(/Context #{fake_context} missing from your kubeconfig file/, + validator(context: fake_context).errors.join("\n")) + end + + def test_context_not_reachable + fake_context = "fake-context" + assert_match(/Something went wrong connecting to #{fake_context}/, + validator(context: fake_context, only: [:validate_context_reachable]).errors.join("\n")) + end + + def test_namespace_does_not_exists + assert_match(/Could not find Namespace: test-namespace in Context: #{KubeclientHelper::TEST_CONTEXT}/, + validator(context: KubeclientHelper::TEST_CONTEXT).errors.join("\n")) + end + + def test_invalid_server_version + old_min_version = KubernetesDeploy::MIN_KUBE_VERSION + new_min_version = "99999" + KubernetesDeploy.const_set(:MIN_KUBE_VERSION, new_min_version) + validator(context: KubeclientHelper::TEST_CONTEXT, namespace: 'default', logger: @logger).valid? + assert_logs_match_all([ + "Minimum cluster version requirement of #{new_min_version} not met.", + ]) + ensure + KubernetesDeploy.const_set(:MIN_KUBE_VERSION, old_min_version) + end + + private + + def validator(context: nil, namespace: nil, logger: nil, kubeclient_builder: nil, only: nil) + context ||= "test-context" + namespace ||= "test-namespace" + config = task_config(context: context, namespace: namespace, logger: logger) + kubectl = KubernetesDeploy::Kubectl.new(namespace: config.namespace, + context: config.context, logger: config.logger, log_failure_by_default: true) + kubeclient_builder ||= KubernetesDeploy::KubeclientBuilder.new + KubernetesDeploy::TaskConfigValidator.new(config, kubectl, kubeclient_builder, only: only) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 48dd2eba8..959bd88da 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -218,6 +218,10 @@ def mock_output_stream end end + def task_config(context: KubeclientHelper::TEST_CONTEXT, namespace: @namespace, logger: @logger) + KubernetesDeploy::TaskConfig.new(context, namespace, logger) + end + private def log_to_real_fds? diff --git a/test/unit/kubernetes-deploy/kubeclient_builder_test.rb b/test/unit/kubernetes-deploy/kubeclient_builder_test.rb index 8a9f19724..4f93dac4d 100644 --- a/test/unit/kubernetes-deploy/kubeclient_builder_test.rb +++ b/test/unit/kubernetes-deploy/kubeclient_builder_test.rb @@ -13,14 +13,14 @@ def test_config_validation_missing_file builder = KubernetesDeploy::KubeclientBuilder.new(kubeconfig: config_file) errors = builder.validate_config_files assert_equal(1, errors.length) - assert_equal(errors.first, "Kube config not found at #{config_file}") + assert_equal(errors.first, "Kubeconfig not found at #{config_file}") end def test_build_runs_config_validation config_file = File.join(__dir__, '../../fixtures/kube-config/unknown_config.yml') kubeclient_builder = KubernetesDeploy::KubeclientBuilder.new(kubeconfig: config_file) - expected_err = /Kube config not found at .*unknown_config.yml/ + expected_err = /Kubeconfig not found at .*unknown_config.yml/ assert_raises_message(KubernetesDeploy::TaskConfigurationError, expected_err) do kubeclient_builder.build_v1_kubeclient('test-context') end @@ -30,12 +30,12 @@ def test_no_config_files_specified builder = KubernetesDeploy::KubeclientBuilder.new(kubeconfig: " : ") errors = builder.validate_config_files assert_equal(1, errors.length) - assert_equal(errors.first, "Kube config file name(s) not set in $KUBECONFIG") + assert_equal(errors.first, "Kubeconfig file name(s) not set in $KUBECONFIG") builder = KubernetesDeploy::KubeclientBuilder.new(kubeconfig: "") errors = builder.validate_config_files assert_equal(1, errors.length) - assert_equal(errors.first, "Kube config file name(s) not set in $KUBECONFIG") + assert_equal(errors.first, "Kubeconfig file name(s) not set in $KUBECONFIG") end def test_multiple_valid_configuration_files diff --git a/test/unit/task_config_test.rb b/test/unit/task_config_test.rb new file mode 100644 index 000000000..af5cad964 --- /dev/null +++ b/test/unit/task_config_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +require 'test_helper' + +class TaskConfigTest < KubernetesDeploy::TestCase + def test_responds_to_namespace + namespace = "test-namespace" + assert_equal(task_config(namespace: namespace).namespace, namespace) + end + + def test_responds_to_context + context = "test-context" + assert_equal(task_config(context: "test-context").context, context) + end + + def test_builds_a_logger_if_none_provided + assert_equal(task_config(logger: nil).logger.class, KubernetesDeploy::FormattedLogger) + end + + def test_uses_provided_logger + logger = KubernetesDeploy::FormattedLogger.build(nil, nil) + assert_equal(task_config(logger: logger).logger, logger) + end +end