diff --git a/CHANGELOG.md b/CHANGELOG.md index f68bc30b4..a4db4a20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## next +## 2.1.11 + +*Enhancements* + +- Add a new option `--selector-as-filter` to command `krane deploy` and `krane global-deploy` [#831](https://github.com/Shopify/krane/pull/831) + ## 2.1.10 *Bug Fixes* diff --git a/README.md b/README.md index 5e29479db..760394479 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Refer to `krane help` for the authoritative set of options. - `--global-timeout=duration`: Raise a timeout error if it takes longer than _duration_ for any resource to deploy. - `--selector`: Instructs krane to only prune resources which match the specified label selector, such as `environment=staging`. If you use this option, all resource templates must specify matching labels. See [Sharing a namespace](#sharing-a-namespace) below. +- `--selector-as-filter`: Instructs krane to only deploy resources that are filtered by the specified labels in `--selector`. The deploy will not fail if not all resources match the labels. This is useful if you only want to deploy a subset of resources within a given YAML file. See [Sharing a namespace](#sharing-a-namespace) below. - `--no-verify-result`: Skip verification that workloads correctly deployed. - `--protected-namespaces=default kube-system kube-public`: Fail validation if a deploy is targeted at a protected namespace. - `--verbose-log-prefix`: Add [context][namespace] to the log prefix @@ -132,6 +133,8 @@ If you need to, you may specify `--no-prune` to disable all pruning behaviour, b If you need to share a namespace with resources which are managed by other tools or indeed other krane deployments, you can supply the `--selector` option, such that only resources with labels matching the selector are considered for pruning. +If you need to share a namespace with different set of resources using the same YAML file, you can supply the `--selector` and `--selector-as-filter` options, such that only the resources that match with the labels will be deployed. In each run of deploy, you can use different labels in `--selector` to deploy a different set of resources. Only the deployed resources in each run are considered for pruning. + ### Using templates All templates must be YAML formatted. @@ -441,6 +444,7 @@ Refer to `krane global-deploy help` for the authoritative set of options. - `--filenames / -f [PATHS]`: Accepts a list of directories and/or filenames to specify the set of directories/files that will be deployed. Use `-` to specify STDIN. - `--no-prune`: Skips pruning of resources that are no longer in your Kubernetes template set. Not recommended, as it allows your namespace to accumulate cruft that is not reflected in your deploy directory. - `--selector`: Instructs krane to only prune resources which match the specified label selector, such as `environment=staging`. By using this option, all resource templates must specify matching labels. See [Sharing a namespace](#sharing-a-namespace) below. +- `--selector-as-filter`: Instructs krane to only deploy resources that are filtered by the specified labels in `--selector`. The deploy will not fail if not all resources match the labels. This is useful if you only want to deploy a subset of resources within a given YAML file. See [Sharing a namespace](#sharing-a-namespace) below. - `--global-timeout=duration`: Raise a timeout error if it takes longer than _duration_ for any resource to deploy. - `--no-verify-result`: Skip verification that resources correctly deployed. diff --git a/lib/krane/cli/deploy_command.rb b/lib/krane/cli/deploy_command.rb index d52f24c79..361d89789 100644 --- a/lib/krane/cli/deploy_command.rb +++ b/lib/krane/cli/deploy_command.rb @@ -25,6 +25,10 @@ class DeployCommand default: true }, "selector" => { type: :string, banner: "'label=value'", desc: "Select workloads by selector(s)" }, + "selector-as-filter" => { type: :boolean, + desc: "Use --selector as a label filter to deploy only a subset "\ + "of the provided resources", + default: false }, "verbose-log-prefix" => { type: :boolean, desc: "Add [context][namespace] to the log prefix", default: false }, "verify-result" => { type: :boolean, default: true, @@ -37,6 +41,11 @@ def self.from_options(namespace, context, options) require 'krane/label_selector' selector = ::Krane::LabelSelector.parse(options[:selector]) if options[:selector] + selector_as_filter = options['selector-as-filter'] + + if selector_as_filter && !selector + raise(Thor::RequiredArgumentMissingError, '--selector must be set when --selector-as-filter is set') + end logger = ::Krane::FormattedLogger.build(namespace, context, verbose_prefix: options['verbose-log-prefix']) @@ -60,6 +69,7 @@ def self.from_options(namespace, context, options) logger: logger, global_timeout: ::Krane::DurationParser.new(options["global-timeout"]).parse!.to_i, selector: selector, + selector_as_filter: selector_as_filter, protected_namespaces: protected_namespaces, ) diff --git a/lib/krane/cli/global_deploy_command.rb b/lib/krane/cli/global_deploy_command.rb index 26ac026c8..186c1603f 100644 --- a/lib/krane/cli/global_deploy_command.rb +++ b/lib/krane/cli/global_deploy_command.rb @@ -16,6 +16,10 @@ class GlobalDeployCommand desc: "Verify workloads correctly deployed" }, "selector" => { type: :string, banner: "'label=value'", required: true, desc: "Select workloads owned by selector(s)" }, + "selector-as-filter" => { type: :boolean, + desc: "Use --selector as a label filter to deploy only a subset "\ + "of the provided resources", + default: false }, "prune" => { type: :boolean, desc: "Enable deletion of resources that match"\ " the provided selector and do not appear in the provided templates", default: true }, @@ -28,6 +32,11 @@ def self.from_options(context, options) require 'krane/duration_parser' selector = ::Krane::LabelSelector.parse(options[:selector]) + selector_as_filter = options['selector-as-filter'] + + if selector_as_filter && !selector + raise(Thor::RequiredArgumentMissingError, '--selector must be set when --selector-as-filter is set') + end filenames = options[:filenames].dup filenames << "-" if options[:stdin] @@ -41,6 +50,7 @@ def self.from_options(context, options) filenames: paths, global_timeout: ::Krane::DurationParser.new(options["global-timeout"]).parse!.to_i, selector: selector, + selector_as_filter: selector_as_filter, ) deploy.run!( diff --git a/lib/krane/deploy_task.rb b/lib/krane/deploy_task.rb index 3518eaf41..973f6fcd9 100644 --- a/lib/krane/deploy_task.rb +++ b/lib/krane/deploy_task.rb @@ -100,12 +100,13 @@ def server_version # @param bindings [Hash] Bindings parsed by Krane::BindingsParser # @param global_timeout [Integer] Timeout in seconds # @param selector [Hash] Selector(s) parsed by Krane::LabelSelector + # @param selector_as_filter [Boolean] Allow selecting a subset of Kubernetes resource templates to deploy # @param filenames [Array] An array of filenames and/or directories containing templates (*required*) # @param protected_namespaces [Array] Array of protected Kubernetes namespaces (defaults # to Krane::DeployTask::PROTECTED_NAMESPACES) # @param render_erb [Boolean] Enable ERB rendering def initialize(namespace:, context:, current_sha: nil, logger: nil, kubectl_instance: nil, bindings: {}, - global_timeout: nil, selector: nil, filenames: [], protected_namespaces: nil, + global_timeout: nil, selector: nil, selector_as_filter: false, filenames: [], protected_namespaces: nil, render_erb: false, kubeconfig: nil) @logger = logger || Krane::FormattedLogger.build(namespace, context) @template_sets = TemplateSets.from_dirs_and_files(paths: filenames, logger: @logger, render_erb: render_erb) @@ -118,6 +119,7 @@ def initialize(namespace:, context:, current_sha: nil, logger: nil, kubectl_inst @kubectl = kubectl_instance @global_timeout = global_timeout @selector = selector + @selector_as_filter = selector_as_filter @protected_namespaces = protected_namespaces || PROTECTED_NAMESPACES @render_erb = render_erb end @@ -273,6 +275,7 @@ def validate_configuration(prune:) confirm_ejson_keys_not_prunable if prune @logger.info("Using resource selector #{@selector}") if @selector + @logger.info("Only deploying resources filtered by labels in selector") if @selector && @selector_as_filter @namespace_tags |= tags_from_namespace_labels @logger.info("All required parameters and files are present") end @@ -295,6 +298,7 @@ def validate_resources(resources) batchable_resources, individuals = partition_dry_run_resources(resources.dup) batch_dry_run_success = kubectl.server_dry_run_enabled? && validate_dry_run(batchable_resources) individuals += batchable_resources unless batch_dry_run_success + resources.select! { |r| r.selected?(@selector) } if @selector_as_filter Krane::Concurrency.split_across_threads(resources) do |r| r.validate_definition(kubectl: kubectl, selector: @selector, dry_run: individuals.include?(r)) end diff --git a/lib/krane/global_deploy_task.rb b/lib/krane/global_deploy_task.rb index 73ddde671..559872424 100644 --- a/lib/krane/global_deploy_task.rb +++ b/lib/krane/global_deploy_task.rb @@ -33,8 +33,10 @@ class GlobalDeployTask # @param context [String] Kubernetes context (*required*) # @param global_timeout [Integer] Timeout in seconds # @param selector [Hash] Selector(s) parsed by Krane::LabelSelector (*required*) + # @param selector_as_filter [Boolean] Allow selecting a subset of Kubernetes resource templates to deploy # @param filenames [Array] An array of filenames and/or directories containing templates (*required*) - def initialize(context:, global_timeout: nil, selector: nil, filenames: [], logger: nil, kubeconfig: nil) + def initialize(context:, global_timeout: nil, selector: nil, selector_as_filter: false, + filenames: [], logger: nil, kubeconfig: nil) template_paths = filenames.map { |path| File.expand_path(path) } @task_config = TaskConfig.new(context, nil, logger, kubeconfig) @@ -42,6 +44,7 @@ def initialize(context:, global_timeout: nil, selector: nil, filenames: [], logg logger: @task_config.logger, render_erb: false) @global_timeout = global_timeout @selector = selector + @selector_as_filter = selector_as_filter end # Runs the task, returning a boolean representing success or failure @@ -130,6 +133,7 @@ def validate_configuration def validate_resources(resources) validate_globals(resources) + resources.select! { |r| r.selected?(@selector) } if @selector_as_filter Concurrency.split_across_threads(resources) do |r| r.validate_definition(kubectl: @kubectl, selector: @selector) end diff --git a/lib/krane/kubernetes_resource.rb b/lib/krane/kubernetes_resource.rb index 5934ecf41..ce4c2690c 100644 --- a/lib/krane/kubernetes_resource.rb +++ b/lib/krane/kubernetes_resource.rb @@ -499,6 +499,10 @@ def global? @global || self.class::GLOBAL end + def selected?(selector) + selector.nil? || selector.to_h <= labels + end + private def validate_timeout_annotation diff --git a/test/exe/deploy_test.rb b/test/exe/deploy_test.rb index 00728c3e1..ce52fb4a5 100644 --- a/test/exe/deploy_test.rb +++ b/test/exe/deploy_test.rb @@ -95,6 +95,28 @@ def test_deploy_fails_without_filename end end + def test_deploy_fails_with_selector_as_filter_but_without_selector + selector = Krane::LabelSelector.new('key' => 'value') + Krane::LabelSelector.expects(:parse).returns(selector) + set_krane_deploy_expectations(new_args: { + filenames: ['/my/file/path'], + selector: selector, + selector_as_filter: true, + }) + flags = '-f /my/file/path --selector key:value --selector-as-filter' + krane_deploy!(flags: flags) + + flags = '-f /my/file/path --selector-as-filter' + krane = Krane::CLI::Krane.new( + [deploy_task_config.namespace, deploy_task_config.context], + flags.split + ) + assert_raises_message(Thor::RequiredArgumentMissingError, + "--selector must be set when --selector-as-filter is set") do + krane.invoke("deploy") + end + end + def test_stdin_flag_deduped_if_specified_multiple_times Dir.mktmpdir do |tmp_path| $stdin.expects("read").returns("").times(2) @@ -140,6 +162,7 @@ def default_options(new_args = {}, run_args = {}) logger: logger, global_timeout: 300, selector: nil, + selector_as_filter: false, protected_namespaces: ["default", "kube-system", "kube-public"], }.merge(new_args), run_args: { diff --git a/test/exe/global_deploy_test.rb b/test/exe/global_deploy_test.rb index 87d0c4f21..9269b67c0 100644 --- a/test/exe/global_deploy_test.rb +++ b/test/exe/global_deploy_test.rb @@ -80,6 +80,28 @@ def test_deploy_fails_without_filename end end + def test_deploy_fails_selector_required + selector = Krane::LabelSelector.new('key' => 'value') + Krane::LabelSelector.expects(:parse).returns(selector) + set_krane_global_deploy_expectations!(new_args: { + filenames: ['/my/file/path'], + selector: "key=value", + selector_as_filter: true, + }) + flags = '-f /my/file/path --selector key:value --selector-as-filter' + krane_global_deploy!(flags: flags) + + flags = '-f /my/file/path --selector-as-filter' + krane = Krane::CLI::Krane.new( + [task_config.context], + flags.split + ) + assert_raises_message(Thor::RequiredArgumentMissingError, + "No value provided for required options '--selector'") do + krane.invoke("global_deploy") + end + end + def test_deploy_parses_selector selector = 'name=web' set_krane_global_deploy_expectations!(new_args: { selector: selector }) @@ -120,6 +142,7 @@ def default_options(new_args = {}, run_args = {}) filenames: ['/tmp'], global_timeout: 300, selector: 'name=web', + selector_as_filter: false, }.merge(new_args), run_args: { verify_result: true, diff --git a/test/fixtures/slow-cloud/web-deploy-3.yml b/test/fixtures/slow-cloud/web-deploy-3.yml new file mode 100644 index 000000000..7569ccd92 --- /dev/null +++ b/test/fixtures/slow-cloud/web-deploy-3.yml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web3 + labels: + name: web3 + branch: staging + app: slow-cloud + annotations: + shipit.shopify.io/restart: "true" + krane.shopify.io/required-rollout: maxUnavailable +spec: + replicas: 2 + selector: + matchLabels: + name: web3 + app: slow-cloud + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 0 + maxUnavailable: 1 + template: + metadata: + labels: + name: web3 + app: slow-cloud + sha: deploy3 + spec: + terminationGracePeriodSeconds: 0 + containers: + - name: app + image: busybox + imagePullPolicy: IfNotPresent + command: ["tail", "-f", "/dev/null"] + ports: + - containerPort: 80 + name: http diff --git a/test/helpers/fixture_deploy_helper.rb b/test/helpers/fixture_deploy_helper.rb index 25ef1802b..31c78279b 100644 --- a/test/helpers/fixture_deploy_helper.rb +++ b/test/helpers/fixture_deploy_helper.rb @@ -89,7 +89,8 @@ def deploy_raw_fixtures(set, wait: true, bindings: {}, subset: nil, render_erb: end def deploy_dirs_without_profiling(dirs, wait: true, prune: true, bindings: {}, - sha: "k#{SecureRandom.hex(6)}", kubectl_instance: nil, global_timeout: nil, selector: nil, + sha: "k#{SecureRandom.hex(6)}", kubectl_instance: nil, global_timeout: nil, + selector: nil, selector_as_filter: false, protected_namespaces: nil, render_erb: false) kubectl_instance = build_kubectl @@ -103,6 +104,7 @@ def deploy_dirs_without_profiling(dirs, wait: true, prune: true, bindings: {}, bindings: bindings, global_timeout: global_timeout, selector: selector, + selector_as_filter: selector_as_filter, protected_namespaces: protected_namespaces, render_erb: render_erb ) diff --git a/test/integration/krane_deploy_test.rb b/test/integration/krane_deploy_test.rb index 2e5b1b905..00305b5aa 100644 --- a/test/integration/krane_deploy_test.rb +++ b/test/integration/krane_deploy_test.rb @@ -166,6 +166,40 @@ def test_selector assert_equal("master", deployments.first.metadata.labels.branch) end + def test_selector_as_filter + # Deploy only the resource matching the selector without validation error + assert_deploy_success(deploy_fixtures("slow-cloud", subset: ['web-deploy-1.yml', 'web-deploy-3.yml'], + selector: Krane::LabelSelector.parse("branch=master"), + selector_as_filter: true)) + assert_logs_match_all([ + "Using resource selector branch=master", + "Only deploying resources filtered by labels in selector", + ], in_order: true) + # Ensure only the selected resource is deployed + deployments = apps_v1_kubeclient.get_deployments(namespace: @namespace) + assert_equal(1, deployments.size) + assert_equal("master", deployments.first.metadata.labels.branch) + + # Deploy another resource with a different selector + assert_deploy_success(deploy_fixtures("slow-cloud", subset: ['web-deploy-1.yml', 'web-deploy-3.yml'], + selector: Krane::LabelSelector.parse("branch=staging"), + selector_as_filter: true)) + assert_logs_match_all([ + "Using resource selector branch=staging", + "Only deploying resources filtered by labels in selector", + ], in_order: true) + # Ensure the not selected resource is not pruned + deployments = apps_v1_kubeclient.get_deployments(namespace: @namespace) + assert_equal(2, deployments.size) + deployments = apps_v1_kubeclient.get_deployments(namespace: @namespace, label_selector: "branch=master") + assert_equal(1, deployments.size) + assert_equal("master", deployments.first.metadata.labels.branch) + # Ensure the selected resource is deployed + deployments = apps_v1_kubeclient.get_deployments(namespace: @namespace, label_selector: "branch=staging") + assert_equal(1, deployments.size) + assert_equal("staging", deployments.first.metadata.labels.branch) + end + def test_mismatched_selector assert_deploy_failure(deploy_fixtures("slow-cloud", subset: %w(web-deploy-1.yml), selector: Krane::LabelSelector.parse("branch=staging")))