This is an automated email from the ASF dual-hosted git repository.

amaechler pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new f2f26f5c15d feat: Add Kubernetes node-label dynamic config provider 
(#20404)
f2f26f5c15d is described below

commit f2f26f5c15d4f4c0ac6e9a40383b7cb746559de3
Author: Andreas Maechler <[email protected]>
AuthorDate: Tue Sep 29 05:20:28 2026 -0600

    feat: Add Kubernetes node-label dynamic config provider (#20404)
    
    Adds K8sNodeLabelDynamicConfigProvider to druid-kubernetes-extensions,
    registered as the "k8sNodeLabel" DynamicConfigProvider subtype. It resolves
    configuration values from labels on the Kubernetes node a process runs on,
    which lets a spec express configuration that depends on where a task is
    scheduled rather than on the spec itself.
    
    The motivating case is Kafka's client.rack. An ingestion task is a 
short-lived
    pod that can land in any zone, and the downward API exposes the labels of 
the
    pod but not of the node, so the zone has to be read from the API server 
where
    the task ends up. The provider is generic, though: any property can be 
filled
    from any node label.
    
    The pod names its own node through a downward-API environment variable
    (HOST_NODE_NAME by default) and its service account needs a ClusterRole
    granting get on nodes. The node is read with the extension's existing
    io.kubernetes client: ClientBuilder.cluster() already supplies the endpoint
    from the environment, the trust material from the projected cluster CA, and 
a
    token source that re-reads the projected token as the kubelet rotates it.
---
 docs/development/extensions-core/kubernetes.md     |   2 +
 docs/operations/dynamic-config-provider.md         |  97 ++++-
 .../druid/k8s/config/ApiNodeLabelReader.java       | 126 ++++++
 .../druid/k8s/config/CachingNodeLabelReader.java   |  62 +++
 .../config/K8sNodeLabelDynamicConfigProvider.java  | 159 ++++++++
 .../apache/druid/k8s/config/NodeLabelReader.java   |  37 ++
 .../druid/k8s/discovery/K8sDiscoveryModule.java    |  12 +-
 .../K8sNodeLabelDynamicConfigProviderTest.java     | 438 +++++++++++++++++++++
 website/.spelling                                  |   6 +
 9 files changed, 936 insertions(+), 3 deletions(-)

diff --git a/docs/development/extensions-core/kubernetes.md 
b/docs/development/extensions-core/kubernetes.md
index 776545ff21b..46d4a1b1e8e 100644
--- a/docs/development/extensions-core/kubernetes.md
+++ b/docs/development/extensions-core/kubernetes.md
@@ -26,6 +26,8 @@ Consider this an [EXPERIMENTAL](../experimental.md) feature 
mostly because it ha
 
 Apache&circledR; Druid extension to enable using the Kubernetes&circledR; API 
Server for node discovery and leader election. This extension allows you to 
deploy a Druid cluster on Kubernetes without Zookeeper and run multiple Druid 
clusters within the same Kubernetes cluster. For more information, see the 
`clusterIdentifier` configuration below.
 
+This extension also provides the `k8sNodeLabel` [dynamic config 
provider](../../operations/dynamic-config-provider.md#kubernetes-node-label-dynamic-config-provider),
 which resolves configuration values from the labels of the Kubernetes node a 
Druid process runs on. You can load the extension for that provider alone. The 
discovery and leader election bindings above are only instantiated when 
`druid.discovery.type` is set to `k8s`.
+
 
 ## Configuration
 
diff --git a/docs/operations/dynamic-config-provider.md 
b/docs/operations/dynamic-config-provider.md
index 92b9a47494d..f9193b43127 100644
--- a/docs/operations/dynamic-config-provider.md
+++ b/docs/operations/dynamic-config-provider.md
@@ -76,4 +76,99 @@ When you define the consumer properties in the supervisor 
spec, use the dynamic
       },
 ...
 ```
-When connecting to Kafka, Druid replaces the environment variables with their 
corresponding values.
\ No newline at end of file
+When connecting to Kafka, Druid replaces the environment variables with their 
corresponding values.
+
+## Kubernetes node label dynamic config provider
+
+The 
[`druid-kubernetes-extensions`](../development/extensions-core/kubernetes.md) 
extension provides a dynamic config provider 
(`K8sNodeLabelDynamicConfigProvider`) that reads configuration values from the 
labels of the Kubernetes node that the Druid process runs on. Use it for values 
that depend on Kubernetes runtime information, for example where a process was 
scheduled, which cannot be written into a spec ahead of time.
+
+The Kubernetes node label dynamic config provider uses the following syntax:
+
+```json
+druid.dynamic.config.provider={"type": "k8sNodeLabel","labels":{"property1": 
"example.com/label-one","property2": "example.com/label-two"}}
+```
+
+|Field|Type|Description|Required|
+|-----|----|-----------|--------|
+|`type`|String|dynamic config provider type|Yes: `k8sNodeLabel`|
+|`labels`|Map|configuration keys to resolve, each mapped to the name of the 
node label that holds its value|Yes|
+|`nodeNameVariable`|String|environment variable that holds the name of the 
node the process runs on|No (default: `HOST_NODE_NAME`)|
+
+You can use it anywhere Druid accepts a dynamic config provider, for example 
Kafka [`consumerProperties`](../ingestion/kafka-ingestion.md), Iceberg 
[`catalogProperties`](../development/extensions-contrib/iceberg.md), and Schema 
Registry [`config` and `headers`](../ingestion/data-formats.md).
+
+### Prerequisites
+
+Include `druid-kubernetes-extensions` in the [extensions load 
list](../configuration/extensions.md#loading-extensions) of every service that 
resolves the spec. For a supervisor spec, that is the Overlord and the Peon 
services.
+
+Each pod must know the node it runs on. Expose the node name from the downward 
API as the environment variable named by `nodeNameVariable`, or set 
`nodeNameVariable` to a variable your pod template already provides:
+
+```yaml
+env:
+  - name: HOST_NODE_NAME
+    valueFrom:
+      fieldRef:
+        fieldPath: spec.nodeName
+```
+
+Nodes are cluster-scoped, so the service account the Druid pods run as needs a 
ClusterRole granting `get` on `nodes`:
+
+```yaml
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: druid-node-reader
+rules:
+- apiGroups:
+  - ""
+  resources:
+  - nodes
+  verbs:
+  - get
+---
+kind: ClusterRoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: druid-node-reader
+subjects:
+- kind: ServiceAccount
+  name: default        # the service account your Druid pods use
+  namespace: druid     # their namespace
+roleRef:
+  kind: ClusterRole
+  name: druid-node-reader
+  apiGroup: rbac.authorization.k8s.io
+```
+
+### Behavior
+
+- If the node name variable is unset, the API server cannot be reached or 
refuses the request, or the node does not carry the label, Druid logs a warning 
and omits the key. The consumer then applies its own default. A label with an 
empty value counts as missing. As with other dynamic config providers, a 
resolved value takes precedence over the same key specified directly.
+- Druid reads the node once per process and keeps its labels until the process 
restarts. A failed lookup is not kept, so it is retried the next time the 
configuration is resolved.
+
+### Examples
+
+Kafka consumers fetch from the nearest replica when `client.rack` matches a 
broker's `broker.rack` 
([KIP-392](https://cwiki.apache.org/confluence/display/KAFKA/KIP-392%3A+Allow+consumers+to+fetch+from+closest+replica)).
 On EKS, `topology.k8s.aws/zone-id` holds the zone ID that MSK uses for 
`broker.rack`; on GKE, `topology.kubernetes.io/zone` holds the zone name that 
Google Cloud Managed Service for Apache Kafka uses:
+
+```json
+"consumerProperties": {
+  "bootstrap.servers": "localhost:9092",
+  "druid.dynamic.config.provider": {
+    "type": "k8sNodeLabel",
+    "labels": { "client.rack": "topology.k8s.aws/zone-id" }
+  }
+}
+```
+
+An Iceberg REST catalog can read from S3 in the task's own region by taking 
`client.region` from the node's region label:
+
+```json
+"icebergCatalog": {
+  "type": "rest",
+  "catalogUri": "https://iceberg-rest.example.com";,
+  "catalogProperties": {
+    "druid.dynamic.config.provider": {
+      "type": "k8sNodeLabel",
+      "labels": { "client.region": "topology.kubernetes.io/region" }
+    }
+  }
+}
+```
diff --git 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/ApiNodeLabelReader.java
 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/ApiNodeLabelReader.java
new file mode 100644
index 00000000000..dcb0545d79c
--- /dev/null
+++ 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/ApiNodeLabelReader.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.druid.k8s.config;
+
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.openapi.models.V1Node;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.util.ClientBuilder;
+import org.apache.druid.java.util.common.logger.Logger;
+
+import javax.annotation.Nullable;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Reads the labels of a Kubernetes node from the in-cluster API server.
+ * <p>
+ * {@link ClientBuilder#cluster()} supplies the endpoint from the environment, 
the trust material from the projected
+ * cluster CA, and a token source that re-reads the projected token as the 
kubelet rotates it.
+ */
+class ApiNodeLabelReader implements NodeLabelReader
+{
+  private static final Logger log = new Logger(ApiNodeLabelReader.class);
+
+  // A missing label is survivable, a slow task start is not.
+  private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+  private final CoreV1Api api;
+
+  ApiNodeLabelReader(ApiClient apiClient, Duration timeout)
+  {
+    apiClient.setHttpClient(
+        apiClient.getHttpClient()
+                 .newBuilder()
+                 // Bounds the whole call. A read timeout alone lets a stalled 
response body run on far longer.
+                 .callTimeout(timeout)
+                 // Keeps the bearer token from reaching any host but the API 
server.
+                 .followRedirects(false)
+                 .followSslRedirects(false)
+                 .build()
+    );
+    this.api = new CoreV1Api(apiClient);
+  }
+
+  /**
+   * Returns the labels of the named node. Returns null if the read failed, or 
if the client could not be built,
+   * which is the case outside Kubernetes.
+   */
+  @Nullable
+  static Map<String, String> readFromCluster(String nodeName)
+  {
+    final ApiClient apiClient;
+    try {
+      apiClient = ClientBuilder.cluster().build();
+    }
+    catch (Exception e) {
+      // Missing credentials or endpoint is configuration rather than a bug, 
so the cause is named without a trace.
+      log.warn("Could not build a Kubernetes API client [%s], so the labels of 
node [%s] cannot be read.", e, nodeName);
+      return null;
+    }
+
+    try {
+      return new ApiNodeLabelReader(apiClient, TIMEOUT).readLabels(nodeName);
+    }
+    finally {
+      // OkHttp is not closeable, and this client made its one call, so drop 
the pooled socket now rather than
+      // leave it reachable from the shared connection pool until it idles out.
+      apiClient.getHttpClient().connectionPool().evictAll();
+    }
+  }
+
+  @Nullable
+  @Override
+  public Map<String, String> readLabels(String nodeName)
+  {
+    try {
+      final V1Node node = api.readNode(nodeName, null);
+      final V1ObjectMeta metadata = node == null ? null : node.getMetadata();
+      if (metadata == null) {
+        // Not a node, so a failure. An empty map here would be cached as if 
the node had no labels.
+        log.warn("Kubernetes API server returned no object metadata for node 
[%s].", nodeName);
+        return null;
+      }
+
+      final Map<String, String> labels = metadata.getLabels();
+      return labels == null ? Collections.emptyMap() : 
Collections.unmodifiableMap(new HashMap<>(labels));
+    }
+    catch (Exception e) {
+      // An ApiException without a status code is a connection failure or the 
call timeout, not an answer.
+      if (e instanceof ApiException apiException && apiException.getCode() != 
0) {
+        log.warn(
+            "Kubernetes API server answered [%d] when asked for node [%s]: %s."
+            + " Reading a node requires a ClusterRole granting 'get' on 
'nodes'.",
+            apiException.getCode(),
+            nodeName,
+            apiException.getResponseBody()
+        );
+      } else {
+        log.warn(e, "Could not read the labels of node [%s] from the 
Kubernetes API server.", nodeName);
+      }
+      return null;
+    }
+  }
+}
diff --git 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/CachingNodeLabelReader.java
 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/CachingNodeLabelReader.java
new file mode 100644
index 00000000000..056e8a8fd05
--- /dev/null
+++ 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/CachingNodeLabelReader.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.druid.k8s.config;
+
+import javax.annotation.Nullable;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Remembers what a delegate read, so that a node is asked about once per 
process.
+ * <p>
+ * Druid builds a {@link org.apache.druid.metadata.DynamicConfigProvider} for 
every use and a task builds several
+ * consumers, so without this each of them would call the API server again. A 
failed read is not remembered, so a
+ * transient error is retried by the next caller.
+ */
+class CachingNodeLabelReader implements NodeLabelReader
+{
+  private final NodeLabelReader delegate;
+  private final ConcurrentHashMap<String, Map<String, String>> labelsByNode = 
new ConcurrentHashMap<>();
+
+  CachingNodeLabelReader(NodeLabelReader delegate)
+  {
+    this.delegate = delegate;
+  }
+
+  @Nullable
+  @Override
+  public Map<String, String> readLabels(String nodeName)
+  {
+    final Map<String, String> cached = labelsByNode.get(nodeName);
+    if (cached != null) {
+      return cached;
+    }
+
+    // Not computeIfAbsent: the delegate blocks on the network, and a mapping 
function must not hold the map's bin
+    // lock for that long. Two callers racing here cost one extra request and 
agree on the result.
+    final Map<String, String> read = delegate.readLabels(nodeName);
+    if (read == null) {
+      return null;
+    }
+    labelsByNode.put(nodeName, read);
+    return read;
+  }
+}
diff --git 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/K8sNodeLabelDynamicConfigProvider.java
 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/K8sNodeLabelDynamicConfigProvider.java
new file mode 100644
index 00000000000..fffbe07d8be
--- /dev/null
+++ 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/K8sNodeLabelDynamicConfigProvider.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.druid.k8s.config;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import org.apache.druid.java.util.common.logger.Logger;
+import org.apache.druid.metadata.DynamicConfigProvider;
+
+import javax.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * Resolves configuration from the labels of the Kubernetes node that this 
process runs on. For example, a Kafka
+ * supervisor can set {@code client.rack} from the node's zone label, which is 
not known until the task is scheduled.
+ * <p>
+ * {@link #getConfig()} reads the node the first time it runs and keeps the 
answer for the life of the process, so a
+ * node relabelled later is not picked up until restart.
+ */
+public class K8sNodeLabelDynamicConfigProvider implements 
DynamicConfigProvider<String>
+{
+  private static final Logger log = new 
Logger(K8sNodeLabelDynamicConfigProvider.class);
+
+  @VisibleForTesting
+  static final String DEFAULT_NODE_NAME_VARIABLE = "HOST_NODE_NAME";
+
+  /**
+   * Shared so that the labels of this process's node are read once, however 
many providers are built.
+   */
+  private static final NodeLabelReader IN_CLUSTER_READER =
+      new CachingNodeLabelReader(ApiNodeLabelReader::readFromCluster);
+
+  private final ImmutableMap<String, String> labels;
+  private final String nodeNameVariable;
+  private final NodeLabelReader nodeLabelReader;
+  private final Function<String, String> env;
+
+  @JsonCreator
+  public K8sNodeLabelDynamicConfigProvider(
+      @JsonProperty("labels") Map<String, String> labels,
+      @JsonProperty("nodeNameVariable") @Nullable String nodeNameVariable
+  )
+  {
+    this(labels, nodeNameVariable, IN_CLUSTER_READER, System::getenv);
+  }
+
+  @VisibleForTesting
+  K8sNodeLabelDynamicConfigProvider(
+      Map<String, String> labels,
+      @Nullable String nodeNameVariable,
+      NodeLabelReader nodeLabelReader,
+      Function<String, String> env
+  )
+  {
+    this.labels = ImmutableMap.copyOf(Preconditions.checkNotNull(labels, 
"labels"));
+    this.nodeNameVariable = nodeNameVariable == null ? 
DEFAULT_NODE_NAME_VARIABLE : nodeNameVariable;
+    this.nodeLabelReader = nodeLabelReader;
+    this.env = env;
+  }
+
+  @JsonProperty("labels")
+  public Map<String, String> getLabels()
+  {
+    return labels;
+  }
+
+  @JsonProperty("nodeNameVariable")
+  public String getNodeNameVariable()
+  {
+    return nodeNameVariable;
+  }
+
+  @Override
+  public Map<String, String> getConfig()
+  {
+    final String nodeName = env.apply(nodeNameVariable);
+    if (nodeName == null || nodeName.isBlank()) {
+      log.warn(
+          "Environment variable [%s] is not set, so the node this process runs 
on is unknown. Skipping %s.",
+          nodeNameVariable,
+          labels.keySet()
+      );
+      return Collections.emptyMap();
+    }
+
+    final Map<String, String> nodeLabels = 
nodeLabelReader.readLabels(nodeName);
+    if (nodeLabels == null) {
+      // The reader has already said why.
+      return Collections.emptyMap();
+    }
+
+    final Map<String, String> config = new HashMap<>();
+    for (Map.Entry<String, String> entry : labels.entrySet()) {
+      final String value = nodeLabels.get(entry.getValue());
+      // Kubernetes allows a label with an empty value, which marks the node 
without saying anything about it.
+      if (value == null || value.isEmpty()) {
+        log.warn("Node [%s] has no value for label [%s], skipping key [%s].", 
nodeName, entry.getValue(), entry.getKey());
+        continue;
+      }
+      config.put(entry.getKey(), value);
+    }
+    return config;
+  }
+
+  @Override
+  public String toString()
+  {
+    return "K8sNodeLabelDynamicConfigProvider{" +
+        "labels=" + labels +
+        ", nodeNameVariable='" + nodeNameVariable + '\'' +
+        '}';
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+
+    K8sNodeLabelDynamicConfigProvider that = 
(K8sNodeLabelDynamicConfigProvider) o;
+
+    return Objects.equals(labels, that.labels) && 
Objects.equals(nodeNameVariable, that.nodeNameVariable);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(labels, nodeNameVariable);
+  }
+}
diff --git 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/NodeLabelReader.java
 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/NodeLabelReader.java
new file mode 100644
index 00000000000..66a41b8a030
--- /dev/null
+++ 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/config/NodeLabelReader.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.druid.k8s.config;
+
+import javax.annotation.Nullable;
+
+import java.util.Map;
+
+/**
+ * Reads the labels of a single Kubernetes node.
+ */
+@FunctionalInterface
+interface NodeLabelReader
+{
+  /**
+   * Returns every label of the named node, or null if the labels could not be 
read.
+   */
+  @Nullable
+  Map<String, String> readLabels(String nodeName);
+}
diff --git 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/K8sDiscoveryModule.java
 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/K8sDiscoveryModule.java
index b18f03d7f5b..7f80c830952 100644
--- 
a/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/K8sDiscoveryModule.java
+++ 
b/extensions-core/kubernetes-extensions/src/main/java/org/apache/druid/k8s/discovery/K8sDiscoveryModule.java
@@ -20,6 +20,9 @@
 package org.apache.druid.k8s.discovery;
 
 import com.fasterxml.jackson.databind.Module;
+import com.fasterxml.jackson.databind.jsontype.NamedType;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.google.common.collect.ImmutableList;
 import com.google.inject.Binder;
 import com.google.inject.Key;
 import io.kubernetes.client.openapi.ApiClient;
@@ -33,9 +36,9 @@ import org.apache.druid.guice.JsonConfigProvider;
 import org.apache.druid.guice.LazySingleton;
 import org.apache.druid.guice.PolyBind;
 import org.apache.druid.initialization.DruidModule;
+import org.apache.druid.k8s.config.K8sNodeLabelDynamicConfigProvider;
 
 import java.io.IOException;
-import java.util.Collections;
 import java.util.List;
 
 public class K8sDiscoveryModule implements DruidModule
@@ -45,7 +48,12 @@ public class K8sDiscoveryModule implements DruidModule
   @Override
   public List<? extends Module> getJacksonModules()
   {
-    return Collections.emptyList();
+    return ImmutableList.of(
+        new SimpleModule("K8sDynamicConfigProviderModule")
+            .registerSubtypes(
+                new NamedType(K8sNodeLabelDynamicConfigProvider.class, 
"k8sNodeLabel")
+            )
+    );
   }
 
   @Override
diff --git 
a/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/config/K8sNodeLabelDynamicConfigProviderTest.java
 
b/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/config/K8sNodeLabelDynamicConfigProviderTest.java
new file mode 100644
index 00000000000..14e0de3331e
--- /dev/null
+++ 
b/extensions-core/kubernetes-extensions/src/test/java/org/apache/druid/k8s/config/K8sNodeLabelDynamicConfigProviderTest.java
@@ -0,0 +1,438 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+package org.apache.druid.k8s.config;
+
+import com.fasterxml.jackson.databind.Module;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableMap;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import io.kubernetes.client.openapi.ApiClient;
+import org.apache.druid.jackson.DefaultObjectMapper;
+import org.apache.druid.k8s.discovery.K8sDiscoveryModule;
+import org.apache.druid.metadata.DynamicConfigProvider;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class K8sNodeLabelDynamicConfigProviderTest
+{
+  private static final String NODE_NAME = 
"ip-10-0-0-1.us-west-2.compute.internal";
+  private static final String ZONE_ID_LABEL = "topology.k8s.aws/zone-id";
+  private static final String ZONE_NAME_LABEL = "topology.kubernetes.io/zone";
+  private static final String CLIENT_RACK = "client.rack";
+  private static final String TOKEN = "test-token";
+  private static final String DEFAULT_VARIABLE = 
K8sNodeLabelDynamicConfigProvider.DEFAULT_NODE_NAME_VARIABLE;
+
+  private static final Map<String, String> RACK_FROM_ZONE_ID = 
ImmutableMap.of(CLIENT_RACK, ZONE_ID_LABEL);
+
+  private static final String LABELED_NODE =
+      "{\"metadata\":{\"name\":\"node\",\"labels\":{\"" + ZONE_ID_LABEL + 
"\":\"usw2-az1\","
+      + 
"\"kubernetes.io/os\":\"linux\",\"node-role.kubernetes.io/worker\":\"\"}}}";
+  private static final String UNLABELED_NODE = 
"{\"metadata\":{\"labels\":{\"kubernetes.io/os\":\"linux\"}}}";
+
+  private final AtomicInteger requestCount = new AtomicInteger();
+  private final List<ApiClient> apiClients = new ArrayList<>();
+  private final CountDownLatch teardown = new CountDownLatch(1);
+
+  private HttpServer server;
+  private URI apiServerUri;
+  private volatile String requestPath;
+  private volatile String authorizationHeader;
+
+  @BeforeEach
+  public void setUp() throws IOException
+  {
+    server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+    apiServerUri = URI.create("http://127.0.0.1:"; + 
server.getAddress().getPort());
+  }
+
+  @AfterEach
+  public void tearDown()
+  {
+    // Lets a stalling handler finish so that stop() does not join a sleeping 
thread.
+    teardown.countDown();
+    server.stop(0);
+    apiClients.forEach(c -> c.getHttpClient().connectionPool().evictAll());
+  }
+
+  @Test
+  public void testFullNodeResponse()
+  {
+    // The whole node object, which is what the API server sends when nothing 
trims the representation.
+    serve(200, 
"{\"kind\":\"Node\",\"apiVersion\":\"v1\",\"metadata\":{\"name\":\"node\",\"resourceVersion\":\"123\",\"creationTimestamp\":\"2024-01-01T00:00:00Z\",\"labels\":{\"topology.k8s.aws/zone-id\":\"usw2-az1\",\"kubernetes.io/os\":\"linux\"}},\"spec\":{\"podCIDR\":\"10.0.0.0/24\",\"providerID\":\"aws:///us-west-2a/i-0abc\"},\"status\":{\"capacity\":{\"cpu\":\"8\",\"memory\":\"32Gi\"},\"images\":[{\"names\":[\"apache/druid:latest\"],\"sizeBytes\":123456789}],\"conditions\":[{\"typ
 [...]
+
+    Assertions.assertEquals(ImmutableMap.of(CLIENT_RACK, "usw2-az1"), 
provider(RACK_FROM_ZONE_ID).getConfig());
+  }
+
+  @Test
+  public void testNodeIsReadWithBearerToken()
+  {
+    serve(200, LABELED_NODE);
+
+    provider(RACK_FROM_ZONE_ID).getConfig();
+
+    Assertions.assertEquals("/api/v1/nodes/" + NODE_NAME, requestPath);
+    Assertions.assertEquals("Bearer " + TOKEN, authorizationHeader);
+  }
+
+  @Test
+  public void testLabelAbsent()
+  {
+    serve(200, UNLABELED_NODE);
+
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID).getConfig());
+  }
+
+  @Test
+  public void testOnlyTheLabelsTheNodeHasAreReturned()
+  {
+    serve(200, LABELED_NODE);
+
+    Assertions.assertEquals(
+        ImmutableMap.of(CLIENT_RACK, "usw2-az1"),
+        provider(ImmutableMap.of(CLIENT_RACK, ZONE_ID_LABEL, "some.property", 
ZONE_NAME_LABEL)).getConfig()
+    );
+  }
+
+  @Test
+  public void testEmptyLabelValueIsSkipped()
+  {
+    serve(200, LABELED_NODE);
+
+    Assertions.assertEquals(
+        Collections.emptyMap(),
+        provider(ImmutableMap.of("some.property", 
"node-role.kubernetes.io/worker")).getConfig()
+    );
+  }
+
+  @Test
+  public void testForbidden()
+  {
+    serve(403, "{\"kind\":\"Status\",\"code\":403}");
+
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID).getConfig());
+  }
+
+  @Test
+  public void testUnparseableResponse()
+  {
+    serve(200, "not json");
+
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID).getConfig());
+  }
+
+  @Test
+  public void testResponseWithoutMetadataIsTreatedAsAFailure()
+  {
+    serve(200, "{\"kind\":\"Node\",\"apiVersion\":\"v1\"}");
+    final NodeLabelReader reader = new 
CachingNodeLabelReader(reader(Duration.ofSeconds(5)));
+
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID, reader).getConfig());
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID, reader).getConfig());
+
+    // A 200 that does not describe a node is a failure, not an answer, so it 
must not be cached.
+    Assertions.assertEquals(2, requestCount.get());
+  }
+
+  /**
+   * A server that answers and then stalls mid-body. The client's own read 
timeout would eventually catch this, but
+   * only after its default, so the reader bounds the whole call instead. The 
elapsed assertion is what holds that
+   * bound in place: without it the test still passes, just ten seconds later.
+   */
+  @Test
+  @Timeout(value = 30_000, unit = TimeUnit.MILLISECONDS)
+  public void testStalledResponseBodyTimesOut()
+  {
+    server.createContext("/", exchange -> {
+      requestCount.incrementAndGet();
+      exchange.sendResponseHeaders(200, LABELED_NODE.length());
+      exchange.getResponseBody().write("{".getBytes(StandardCharsets.UTF_8));
+      exchange.getResponseBody().flush();
+      awaitTeardown();
+    });
+    server.start();
+
+    // Built before the clock starts so that class loading is not counted 
against the bound.
+    final K8sNodeLabelDynamicConfigProvider provider = 
provider(RACK_FROM_ZONE_ID, reader(Duration.ofMillis(200)));
+
+    final long startMillis = System.currentTimeMillis();
+    Assertions.assertEquals(Collections.emptyMap(), provider.getConfig());
+    final long elapsedMillis = System.currentTimeMillis() - startMillis;
+
+    Assertions.assertEquals(1, requestCount.get());
+    Assertions.assertTrue(elapsedMillis < 3000, "gave up only after " + 
elapsedMillis + " ms");
+  }
+
+  @Test
+  public void testNodeWithNoLabelsAtAll()
+  {
+    serve(200, "{\"metadata\":{\"name\":\"node\"}}");
+    final NodeLabelReader reader = new 
CachingNodeLabelReader(reader(Duration.ofSeconds(5)));
+
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID, reader).getConfig());
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID, reader).getConfig());
+
+    // A node carrying no labels is an answer, so it is cached like any other.
+    Assertions.assertEquals(1, requestCount.get());
+  }
+
+  @Test
+  public void testEmptyResponseBody()
+  {
+    serve(200, "");
+
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID).getConfig());
+  }
+
+  @Test
+  public void testRedirectIsNotFollowed()
+  {
+    server.createContext("/", exchange -> {
+      requestCount.incrementAndGet();
+      exchange.getResponseHeaders().set("Location", 
"http://example.invalid/api/v1/nodes/"; + NODE_NAME);
+      exchange.sendResponseHeaders(302, -1);
+      exchange.close();
+    });
+    server.start();
+
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID).getConfig());
+
+    // Following it would carry the bearer token to whatever host the redirect 
names.
+    Assertions.assertEquals(1, requestCount.get());
+  }
+
+  @Test
+  public void testNodeNameVariableUnsetOrBlank()
+  {
+    serve(200, LABELED_NODE);
+    final NodeLabelReader reader = reader(Duration.ofSeconds(5));
+
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID, DEFAULT_VARIABLE, null, reader).getConfig());
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID, DEFAULT_VARIABLE, "  ", reader).getConfig());
+    Assertions.assertEquals(0, requestCount.get());
+  }
+
+  @Test
+  public void testCustomNodeNameVariable()
+  {
+    serve(200, LABELED_NODE);
+
+    Assertions.assertEquals(
+        ImmutableMap.of(CLIENT_RACK, "usw2-az1"),
+        provider(RACK_FROM_ZONE_ID, "NODE_NAME", NODE_NAME, 
reader(Duration.ofSeconds(5))).getConfig()
+    );
+  }
+
+  @Test
+  public void testSuccessfulLookupIsCached()
+  {
+    serve(200, LABELED_NODE);
+    final NodeLabelReader reader = new 
CachingNodeLabelReader(reader(Duration.ofSeconds(5)));
+
+    final Map<String, String> expected = ImmutableMap.of(CLIENT_RACK, 
"usw2-az1");
+    Assertions.assertEquals(expected, provider(RACK_FROM_ZONE_ID, 
reader).getConfig());
+    Assertions.assertEquals(expected, provider(RACK_FROM_ZONE_ID, 
reader).getConfig());
+
+    Assertions.assertEquals(1, requestCount.get());
+  }
+
+  @Test
+  public void testNodeWithoutTheLabelIsStillCached()
+  {
+    serve(200, UNLABELED_NODE);
+    final NodeLabelReader reader = new 
CachingNodeLabelReader(reader(Duration.ofSeconds(5)));
+
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID, reader).getConfig());
+    Assertions.assertEquals(Collections.emptyMap(), 
provider(RACK_FROM_ZONE_ID, reader).getConfig());
+
+    // The node answered; it simply has no such label. That is an answer, so 
it is not asked again.
+    Assertions.assertEquals(1, requestCount.get());
+  }
+
+  @Test
+  public void testFailedLookupIsNotCached()
+  {
+    serve(403, "{\"kind\":\"Status\",\"code\":403}");
+    final NodeLabelReader reader = new 
CachingNodeLabelReader(reader(Duration.ofSeconds(5)));
+
+    provider(RACK_FROM_ZONE_ID, reader).getConfig();
+    provider(RACK_FROM_ZONE_ID, reader).getConfig();
+
+    Assertions.assertEquals(2, requestCount.get());
+  }
+
+  @Test
+  public void testSerde() throws Exception
+  {
+    final ObjectMapper mapper = new DefaultObjectMapper();
+    for (Module module : new K8sDiscoveryModule().getJacksonModules()) {
+      mapper.registerModule(module);
+    }
+
+    final DynamicConfigProvider<?> defaulted = mapper.readValue(
+        
"{\"type\":\"k8sNodeLabel\",\"labels\":{\"client.rack\":\"topology.k8s.aws/zone-id\"}}",
+        DynamicConfigProvider.class
+    );
+    Assertions.assertEquals(new 
K8sNodeLabelDynamicConfigProvider(RACK_FROM_ZONE_ID, null), defaulted);
+    Assertions.assertEquals(
+        DEFAULT_VARIABLE,
+        ((K8sNodeLabelDynamicConfigProvider) defaulted).getNodeNameVariable()
+    );
+    Assertions.assertEquals(
+        defaulted,
+        mapper.readValue(mapper.writeValueAsString(defaulted), 
DynamicConfigProvider.class)
+    );
+
+    final DynamicConfigProvider<?> explicit = mapper.readValue(
+        
"{\"type\":\"k8sNodeLabel\",\"labels\":{\"client.rack\":\"topology.kubernetes.io/zone\"},"
+        + "\"nodeNameVariable\":\"NODE_NAME\"}",
+        DynamicConfigProvider.class
+    );
+    Assertions.assertEquals(
+        new K8sNodeLabelDynamicConfigProvider(ImmutableMap.of(CLIENT_RACK, 
ZONE_NAME_LABEL), "NODE_NAME"),
+        explicit
+    );
+    Assertions.assertEquals(
+        explicit,
+        mapper.readValue(mapper.writeValueAsString(explicit), 
DynamicConfigProvider.class)
+    );
+  }
+
+  @Test
+  public void testEqualsAndHashCode()
+  {
+    final K8sNodeLabelDynamicConfigProvider provider =
+        new K8sNodeLabelDynamicConfigProvider(RACK_FROM_ZONE_ID, null);
+    final K8sNodeLabelDynamicConfigProvider same =
+        new K8sNodeLabelDynamicConfigProvider(RACK_FROM_ZONE_ID, 
DEFAULT_VARIABLE);
+    final K8sNodeLabelDynamicConfigProvider otherLabel =
+        new K8sNodeLabelDynamicConfigProvider(ImmutableMap.of(CLIENT_RACK, 
ZONE_NAME_LABEL), null);
+    final K8sNodeLabelDynamicConfigProvider otherVariable =
+        new K8sNodeLabelDynamicConfigProvider(RACK_FROM_ZONE_ID, "NODE_NAME");
+
+    Assertions.assertEquals(provider, same);
+    Assertions.assertEquals(provider.hashCode(), same.hashCode());
+    Assertions.assertNotEquals(provider, otherLabel);
+    Assertions.assertNotEquals(provider, otherVariable);
+    Assertions.assertNotEquals(provider, null);
+  }
+
+  @Test
+  public void testLabelsAreRequired()
+  {
+    Assertions.assertThrows(
+        NullPointerException.class,
+        () -> new K8sNodeLabelDynamicConfigProvider(null, null)
+    );
+  }
+
+  private void serve(int status, String body)
+  {
+    server.createContext("/", exchange -> {
+      requestCount.incrementAndGet();
+      requestPath = exchange.getRequestURI().getPath();
+      authorizationHeader = 
exchange.getRequestHeaders().getFirst("Authorization");
+      respond(exchange, status, body);
+    });
+    server.start();
+  }
+
+  private static void respond(HttpExchange exchange, int status, String body) 
throws IOException
+  {
+    final byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+    exchange.getResponseHeaders().set("Content-Type", "application/json");
+    exchange.sendResponseHeaders(status, bytes.length);
+    try (OutputStream out = exchange.getResponseBody()) {
+      out.write(bytes);
+    }
+  }
+
+  /**
+   * Holds a handler open until the test is over, so a stalling server does 
not also stall the suite.
+   */
+  private void awaitTeardown()
+  {
+    try {
+      teardown.await(30, TimeUnit.SECONDS);
+    }
+    catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+    }
+  }
+
+  private K8sNodeLabelDynamicConfigProvider provider(Map<String, String> 
labels)
+  {
+    return provider(labels, reader(Duration.ofSeconds(5)));
+  }
+
+  private K8sNodeLabelDynamicConfigProvider provider(Map<String, String> 
labels, NodeLabelReader reader)
+  {
+    return provider(labels, DEFAULT_VARIABLE, NODE_NAME, reader);
+  }
+
+  /**
+   * The fake environment answers only for {@code variable}, with {@code 
nodeName}, or null for an unset variable.
+   */
+  private K8sNodeLabelDynamicConfigProvider provider(
+      Map<String, String> labels,
+      String variable,
+      @Nullable String nodeName,
+      NodeLabelReader reader
+  )
+  {
+    return new K8sNodeLabelDynamicConfigProvider(labels, variable, reader, var 
-> variable.equals(var) ? nodeName : null);
+  }
+
+  private NodeLabelReader reader(Duration timeout)
+  {
+    // Production gets its token from an OkHttp interceptor installed by 
ClientBuilder, not from a default header,
+    // so the test uses an interceptor too. A default header would survive the 
client swap either way.
+    final ApiClient apiClient = new 
ApiClient().setBasePath(apiServerUri.toString());
+    apiClient.setHttpClient(
+        apiClient.getHttpClient()
+                 .newBuilder()
+                 .addInterceptor(chain -> chain.proceed(
+                     chain.request().newBuilder().header("Authorization", 
"Bearer " + TOKEN).build()
+                 ))
+                 .build()
+    );
+    apiClients.add(apiClient);
+    return new ApiNodeLabelReader(apiClient, timeout);
+  }
+}
diff --git a/website/.spelling b/website/.spelling
index 8d9fa585d9a..5c7d8ba7d5c 100644
--- a/website/.spelling
+++ b/website/.spelling
@@ -67,6 +67,7 @@ CentralizedDatasourceSchema
 Ceph
 circledR
 CloudWatch
+ClusterRole
 ColumnDescriptor
 Corretto
 CLI
@@ -104,6 +105,7 @@ DynamicConfigProvider
 EC2
 EC2ContainerCredentialsProviderWrapper
 ECS
+EKS
 EMR
 EMRFS
 ETL
@@ -118,6 +120,7 @@ Float.POSITIVE_INFINITY
 Float.POSITIVE_INFINITY.
 ForwardedRequestCustomizer
 GC
+GKE
 GPG
 GoogleCloudStorageInputSource
 GSSAPI
@@ -179,6 +182,7 @@ Joda
 JsonProperty
 Jupyter
 JupyterLab
+KIP-392
 KMS
 Kerberized
 Kerberos
@@ -205,6 +209,7 @@ Marquez
 MiddleManager
 MiddleManagers
 Montréal
+MSK
 MSQ
 Murmur3
 MVCC
@@ -422,6 +427,7 @@ kbps
 kerberos
 keystore
 keytool
+kubelet
 keytab
 kubeconfig
 kubernetes


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to