From e8665ab39f43b19823326c150fa7e2d1dfd315a8 Mon Sep 17 00:00:00 2001 From: AJ Foster <2789166+aj-foster@users.noreply.github.com> Date: Wed, 6 Aug 2025 10:47:07 -0400 Subject: [PATCH] Add field selector option to Kubernetes strategy This option allows filtering pods by status. --- CHANGELOG.md | 1 + lib/strategy/kubernetes.ex | 39 +++++++++++++++---- .../vcr_cassettes/kubernetes_pods.json | 34 +++++++++++++++- test/kubernetes_test.exs | 28 +++++++++++++ 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9acce..b4b0c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Add `kubernetes_use_cached_resources` option to Kubernetes strategy +- Add `kubernetes_field_selector` option to `Cluster.Strategy.Kubernetes` to enable filtering by pod status ## 3.4.1 diff --git a/lib/strategy/kubernetes.ex b/lib/strategy/kubernetes.ex index ae83791..647249a 100644 --- a/lib/strategy/kubernetes.ex +++ b/lib/strategy/kubernetes.ex @@ -7,6 +7,8 @@ defmodule Cluster.Strategy.Kubernetes do This clustering strategy works by fetching information of endpoints or pods, which are filtered by given Kubernetes namespace and label. + > #### Note {: .info} + > > This strategy requires a service account with the ability to list endpoints or pods. If you want > to avoid that, you could use one of the DNS-based strategies instead. > @@ -22,6 +24,7 @@ defmodule Cluster.Strategy.Kubernetes do + `` would be the value configured by `:kubernetes_node_basename` option. + `` would be the value which is controlled by following options: - `:kubernetes_namespace` + - `:kubernetes_field_selector` - `:kubernetes_selector` - `:kubernetes_service_name` - `:kubernetes_ip_lookup_mode` @@ -37,13 +40,32 @@ defmodule Cluster.Strategy.Kubernetes do ## Getting `` - ### `:kubernetes_namespace` and `:kubernetes_selector` option + This strategy uses the Kubernetes API to fetch information about endpoints or pods. The + following options configure the API request and how the responses are used. + + ### `:kubernetes_namespace` option + + This option is used to filter endpoints or pods by namespace. It is required in cases when the + namespace is not determined by the service account. If not provided, it defaults to the + namespace of the service account from `\#{config[:kubernetes_service_account_path]}/namespace`. + + ### `:kubernetes_selector` option + + This option is a **label selector** used to filter endpoints or pods by label. It is + **required** and should be provided in the format of a label selector, such as + `"app.kubernetes.io/name=my-app"`. For more information on label selectors, see the + [Kubernetes Documentation](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#list-and-watch-filtering). + + ### `:kubernetes_field_selector` option - These two options configure how to filter required endpoints or pods. + This option is a **field selector** used to filter endpoints or pods by specific fields. It is + optional and can be used to filter pods by their status, such as `"status.phase=Running"`. + If not provided, no filters are applied. For more information on field selectors, see the + [Kubernetes Documentation](https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/). ### `:kubernetes_ip_lookup_mode` option - These option configures where to lookup the required IP. + This option configures where to lookup the required IP. Available values: @@ -226,6 +248,7 @@ defmodule Cluster.Strategy.Kubernetes do mode: :ip, kubernetes_node_basename: "myapp", kubernetes_selector: "app=myapp", + kubernetes_field_selector: "status.phase=Running", kubernetes_namespace: "my_namespace", polling_interval: 10_000 ] @@ -365,7 +388,8 @@ defmodule Cluster.Strategy.Kubernetes do app_name = Keyword.fetch!(config, :kubernetes_node_basename) cluster_name = Keyword.get(config, :kubernetes_cluster_name, "cluster") service_name = Keyword.get(config, :kubernetes_service_name) - selector = Keyword.fetch!(config, :kubernetes_selector) + field_selector = Keyword.get(config, :kubernetes_field_selector) + label_selector = Keyword.fetch!(config, :kubernetes_selector) ip_lookup_mode = Keyword.get(config, :kubernetes_ip_lookup_mode, :endpoints) use_cache = Keyword.get(config, :kubernetes_use_cached_resources, false) @@ -388,10 +412,11 @@ defmodule Cluster.Strategy.Kubernetes do end cond do - app_name != nil and selector != nil -> + app_name != nil and label_selector != nil -> query_params = [] - |> apply_param(:labelSelector, selector) + |> apply_param(:fieldSelector, field_selector) + |> apply_param(:labelSelector, label_selector) |> apply_param(:resourceVersion, resource_version) |> URI.encode_query(:rfc3986) @@ -442,7 +467,7 @@ defmodule Cluster.Strategy.Kubernetes do [] - selector == nil -> + label_selector == nil -> warn( topology, "kubernetes strategy is selected, but :kubernetes_selector is not configured!" diff --git a/test/fixtures/vcr_cassettes/kubernetes_pods.json b/test/fixtures/vcr_cassettes/kubernetes_pods.json index 6dcebe9..b9553d9 100644 --- a/test/fixtures/vcr_cassettes/kubernetes_pods.json +++ b/test/fixtures/vcr_cassettes/kubernetes_pods.json @@ -31,6 +31,38 @@ "type": "ok" } }, + { + "request": { + "body": "", + "headers": { + "authorization": "***" + }, + "method": "get", + "options": { + "httpc_options": [], + "http_options": { + "ssl": "[verify: :verify_none]" + } + }, + "request_body": "", + "url": "https://cluster.localhost./api/v1/namespaces/__libcluster_test/pods?labelSelector=app=test_selector&fieldSelector=status.phase%3DRunning" + }, + "response": { + "binary": false, + "body": "{\"kind\":\"PodList\",\"apiVersion\":\"v1\",\"metadata\":{\"selfLink\":\"SELFLINK_PLACEHOLDER\",\"resourceVersion\":\"17042410\"},\"items\":[{\"metadata\":{\"name\":\"development-development\",\"namespace\":\"airatel-service-localization\",\"selfLink\":\"SELFLINK_PLACEHOLDER\",\"uid\":\"7e3faf1e-0294-11e8-bcad-42010a9c01cc\",\"resourceVersion\":\"17037787\",\"creationTimestamp\":\"2018-01-26T12:29:03Z\",\"labels\":{\"app\":\"development\",\"chart\":\"CHART_PLACEHOLDER\"}},\"spec\": { \"hostname\": \"my-hostname-0\" },\"status\":{\"podIP\": \"10.48.33.137\"}}]}\n", + "headers": { + "date": "Fri, 26 Jan 2018 13:18:46 GMT", + "content-length": "877", + "content-type": "application/json" + }, + "status_code": [ + "HTTP/1.1", + 200, + "OK" + ], + "type": "ok" + } + }, { "request": { "body": "", @@ -63,4 +95,4 @@ "type": "ok" } } -] +] \ No newline at end of file diff --git a/test/kubernetes_test.exs b/test/kubernetes_test.exs index 512ad6d..699f487 100644 --- a/test/kubernetes_test.exs +++ b/test/kubernetes_test.exs @@ -312,5 +312,33 @@ defmodule Cluster.Strategy.KubernetesTest do end) end end + + test "works with pods and field selector" do + use_cassette "kubernetes_pods", custom: true do + capture_log(fn -> + start_supervised!({Kubernetes, + [ + %Cluster.Strategy.State{ + topology: :name, + config: [ + kubernetes_node_basename: "test_basename", + kubernetes_selector: "app=test_selector", + # If you want to run the test freshly, you'll need to create a DNS Entry + kubernetes_master: "cluster.localhost.", + kubernetes_ip_lookup_mode: :pods, + kubernetes_field_selector: "status.phase=Running", + kubernetes_service_account_path: + Path.join([__DIR__, "fixtures", "kubernetes", "service_account"]) + ], + connect: {Nodes, :connect, [self()]}, + disconnect: {Nodes, :disconnect, [self()]}, + list_nodes: {Nodes, :list_nodes, [[]]} + } + ]}) + + assert_receive {:connect, :"test_basename@10.48.33.137"}, 5_000 + end) + end + end end end