All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.hazelcast.kubernetes.KubernetesClient Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (c) 2008-2024, Hazelcast, Inc. All Rights Reserved.
 *
 * Licensed 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 com.hazelcast.kubernetes;

import com.hazelcast.instance.impl.ClusterTopologyIntentTracker;
import com.hazelcast.internal.json.Json;
import com.hazelcast.internal.json.JsonArray;
import com.hazelcast.internal.json.JsonObject;
import com.hazelcast.internal.json.JsonValue;
import com.hazelcast.internal.util.HostnameUtil;
import com.hazelcast.internal.util.StringUtil;
import com.hazelcast.internal.util.concurrent.BackoffIdleStrategy;
import com.hazelcast.kubernetes.KubernetesConfig.ExposeExternallyMode;
import com.hazelcast.logging.ILogger;
import com.hazelcast.logging.Logger;
import com.hazelcast.spi.exception.RestClientException;
import com.hazelcast.spi.utils.RestClient;
import com.hazelcast.spi.utils.RetryUtils;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import static com.hazelcast.instance.impl.ClusterTopologyIntentTracker.UNKNOWN;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static java.net.HttpURLConnection.HTTP_GONE;
import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
import static java.net.HttpURLConnection.HTTP_FORBIDDEN;

/**
 * Responsible for connecting to the Kubernetes API.
 *
 * @see Kubernetes API
 */
@SuppressWarnings("checkstyle:methodcount")
class KubernetesClient {
    static final String SERVICE_TYPE_LOADBALANCER = "LoadBalancer";
    static final String SERVICE_TYPE_NODEPORT = "NodePort";

    private static final ILogger LOGGER = Logger.getLogger(KubernetesClient.class);

    private static final int CONNECTION_TIMEOUT_SECONDS = 10;
    private static final int READ_TIMEOUT_SECONDS = 10;

    private static final List NON_RETRYABLE_KEYWORDS = asList(
            "\"reason\":\"Forbidden\"",
            "\"reason\":\"NotFound\"",
            "Failure in generating SSLSocketFactory",
            "REST call interrupted");

    private static final int STS_MONITOR_SHUTDOWN_AWAIT_TIMEOUT_MS = 1000;

    @Nullable
    final StsMonitorThread stsMonitorThread;
    private final String stsName;
    private final String namespace;
    private final String kubernetesMaster;
    private final String caCertificate;
    private final int retries;
    private final KubernetesApiProvider apiProvider;
    private final ExposeExternallyMode exposeExternallyMode;
    private final boolean useNodeNameAsExternalAddress;
    private final String servicePerPodLabelName;
    private final String servicePerPodLabelValue;

    private final KubernetesTokenProvider tokenProvider;

    @Nullable
    private final ClusterTopologyIntentTracker clusterTopologyIntentTracker;

    private boolean isNoPublicIpAlreadyLogged;
    private boolean isKnownExceptionAlreadyLogged;
    private boolean isNodePortWarningAlreadyLogged;

    KubernetesClient(String namespace, String kubernetesMaster, KubernetesTokenProvider tokenProvider,
                     String caCertificate, int retries, ExposeExternallyMode exposeExternallyMode,
                     boolean useNodeNameAsExternalAddress, String servicePerPodLabelName,
                     String servicePerPodLabelValue, @Nullable ClusterTopologyIntentTracker clusterTopologyIntentTracker) {
        this.namespace = namespace;
        this.kubernetesMaster = kubernetesMaster;
        this.tokenProvider = tokenProvider;
        this.caCertificate = caCertificate;
        this.retries = retries;
        this.exposeExternallyMode = exposeExternallyMode;
        this.useNodeNameAsExternalAddress = useNodeNameAsExternalAddress;
        this.servicePerPodLabelName = servicePerPodLabelName;
        this.servicePerPodLabelValue = servicePerPodLabelValue;
        this.clusterTopologyIntentTracker = clusterTopologyIntentTracker;
        if (clusterTopologyIntentTracker != null) {
            clusterTopologyIntentTracker.initialize();
        }
        this.apiProvider =  buildKubernetesApiUrlProvider();
        this.stsName = extractStsName();
        this.stsMonitorThread = (clusterTopologyIntentTracker != null && clusterTopologyIntentTracker.isEnabled())
                ? new StsMonitorThread() : null;
    }

    // constructor that allows overriding detected statefulset name for usage in tests
    @SuppressWarnings("checkstyle:parameternumber")
    KubernetesClient(String namespace, String kubernetesMaster, KubernetesTokenProvider tokenProvider,
                     String caCertificate, int retries, ExposeExternallyMode exposeExternallyMode,
                     boolean useNodeNameAsExternalAddress, String servicePerPodLabelName,
                     String servicePerPodLabelValue, @Nullable ClusterTopologyIntentTracker clusterTopologyIntentTracker,
                     String stsName) {
        this.namespace = namespace;
        this.kubernetesMaster = kubernetesMaster;
        this.tokenProvider = tokenProvider;
        this.caCertificate = caCertificate;
        this.retries = retries;
        this.exposeExternallyMode = exposeExternallyMode;
        this.useNodeNameAsExternalAddress = useNodeNameAsExternalAddress;
        this.servicePerPodLabelName = servicePerPodLabelName;
        this.servicePerPodLabelValue = servicePerPodLabelValue;
        this.clusterTopologyIntentTracker = clusterTopologyIntentTracker;
        if (clusterTopologyIntentTracker != null) {
            clusterTopologyIntentTracker.initialize();
        }
        this.apiProvider =  buildKubernetesApiUrlProvider();
        this.stsName = stsName;
        this.stsMonitorThread = (clusterTopologyIntentTracker != null && clusterTopologyIntentTracker.isEnabled())
                ? new StsMonitorThread() : null;
    }

    // test usage only
    KubernetesClient(String namespace, String kubernetesMaster, KubernetesTokenProvider tokenProvider,
                     String caCertificate, int retries, ExposeExternallyMode exposeExternallyMode,
                     boolean useNodeNameAsExternalAddress, String servicePerPodLabelName,
                     String servicePerPodLabelValue, KubernetesApiProvider apiProvider) {
        this.namespace = namespace;
        this.kubernetesMaster = kubernetesMaster;
        this.tokenProvider = tokenProvider;
        this.caCertificate = caCertificate;
        this.retries = retries;
        this.exposeExternallyMode = exposeExternallyMode;
        this.useNodeNameAsExternalAddress = useNodeNameAsExternalAddress;
        this.servicePerPodLabelName = servicePerPodLabelName;
        this.servicePerPodLabelValue = servicePerPodLabelValue;
        this.apiProvider = apiProvider;
        this.stsMonitorThread = null;
        this.stsName = extractStsName();
        this.clusterTopologyIntentTracker = null;
    }

    public void start() {
        if (stsMonitorThread != null) {
            stsMonitorThread.start();
        }
    }

    public void destroy() {
        // It's important we shut down the StsMonitorThread first, as the ClusterTopologyIntentTracker
        // receives messages from this thread, and we want to let it process all available messages
        // before the intent tracker is shutdown
        if (stsMonitorThread != null) {
            LOGGER.info("Shutting down StatefulSet monitor thread");
            stsMonitorThread.shutdown();
        }

        if (clusterTopologyIntentTracker != null) {
            // Join the StsMonitor thread to ensure it has completed processing all messages
            // before shutting down our ClusterTopologyIntentTracker (which processes messages)
            if (stsMonitorThread != null) {
                try {
                    stsMonitorThread.join(STS_MONITOR_SHUTDOWN_AWAIT_TIMEOUT_MS);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }

            clusterTopologyIntentTracker.destroy();
        }
    }

    KubernetesApiProvider buildKubernetesApiUrlProvider() {
        try {
            String endpointSlicesUrlString =
                    String.format("%s/apis/discovery.k8s.io/v1/namespaces/%s/endpointslices", kubernetesMaster, namespace);
            callGet(endpointSlicesUrlString);
            LOGGER.finest("Using EndpointSlices API to discover endpoints.");
        } catch (Exception e) {
            LOGGER.finest("EndpointSlices are not available, using Endpoints API to discover endpoints.");
            return new KubernetesApiEndpointProvider();
        }
        return new KubernetesApiEndpointSlicesProvider();
    }

    /**
     * Retrieves POD addresses in the specified {@code namespace}.
     *
     * @return all POD addresses
     * @see Kubernetes Endpoint API
     */
    List endpoints() {
        try {
            String urlString = String.format("%s/api/v1/namespaces/%s/pods", kubernetesMaster, namespace);
            return enrichWithPublicAddresses(parsePodsList(callGet(urlString)));
        } catch (RestClientException e) {
            return handleKnownException(e);
        }
    }

    /**
     * Retrieves POD addresses for all services in the specified {@code namespace} filtered by {@code serviceLabels}
     * and {@code serviceLabelValues}.
     *
     * @param serviceLabels      comma separated labels used to filter responses
     * @param serviceLabelValues comma separated label values used to filter responses
     * @return all POD addresses from the specified {@code namespace} filtered by the labels
     * @see Kubernetes Endpoint API
     */
    List endpointsByServiceLabel(String serviceLabels, String serviceLabelValues) {
        try {
            String param = getLabelSelectorParameter(serviceLabels, serviceLabelValues);
            String urlString = String.format(apiProvider.getEndpointsByServiceLabelUrlString(),
                    kubernetesMaster, namespace, param);
            return enrichWithPublicAddresses(apiProvider.parseEndpointsList(callGet(urlString)));
        } catch (RestClientException e) {
            return handleKnownException(e);
        }
    }

    /**
     * Retrieves POD addresses from the specified {@code namespace} and the given {@code endpointName}.
     *
     * @param endpointName endpoint name
     * @return all POD addresses from the specified {@code namespace} and the given {@code endpointName}
     * @see Kubernetes Endpoint API
     */
    List endpointsByName(String endpointName) {
        try {
            String urlString = String.format(apiProvider.getEndpointsByNameUrlString(),
                    kubernetesMaster, namespace, endpointName);
            return enrichWithPublicAddresses(apiProvider.parseEndpoints(callGet(urlString)));
        } catch (RestClientException e) {
            return handleKnownException(e);
        }
    }

    /**
     * Retrieves POD addresses for all services in the specified {@code namespace} filtered by {@code podLabels}
     * and {@code podLabelValues}.
     *
     * @param podLabels      comma separated labels used to filter responses
     * @param podLabelValues comma separated label values used to filter responses
     * @return all POD addresses from the specified {@code namespace} filtered by the labels
     * @see Kubernetes Endpoint API
     */
    List endpointsByPodLabel(String podLabels, String podLabelValues) {
        try {
            String param = getLabelSelectorParameter(podLabels, podLabelValues);
            String urlString = String.format("%s/api/v1/namespaces/%s/pods?%s", kubernetesMaster, namespace, param);
            return enrichWithPublicAddresses(parsePodsList(callGet(urlString)));
        } catch (RestClientException e) {
            return handleKnownException(e);
        }
    }

    /**
     * Retrieves zone name for the specified {@code namespace} and the given {@code podName}.
     * 

* Note that the Kubernetes environment provides such information as defined * here. * * @param podName POD name * @return zone name * @see Kubernetes Endpoint API */ String zone(String podName) { String nodeUrlString = String.format("%s/api/v1/nodes/%s", kubernetesMaster, nodeName(podName)); return extractZone(callGet(nodeUrlString)); } /** * Retrieves node name for the specified {@code namespace} and the given {@code podName}. * * @param podName POD name * @return Node name * @see Kubernetes Endpoint API */ String nodeName(String podName) { String podUrlString = String.format("%s/api/v1/namespaces/%s/pods/%s", kubernetesMaster, namespace, podName); return extractNodeName(callGet(podUrlString)); } // For test purpose boolean isNoPublicIpAlreadyLogged() { return isNoPublicIpAlreadyLogged; } // For test purpose boolean isKnownExceptionAlreadyLogged() { return isKnownExceptionAlreadyLogged; } // For test purpose boolean isNodePortWarningAlreadyLogged() { return isNodePortWarningAlreadyLogged; } private String extractStsName() { String stsName = HostnameUtil.getLocalHostname(); int dashIndex = stsName.lastIndexOf('-'); if (dashIndex > 0) { stsName = stsName.substring(0, dashIndex); } return stsName; } private RuntimeContext extractSts(JsonObject jsonObject) { int specReplicas = jsonObject.get("spec").asObject().getInt("replicas", UNKNOWN); int readyReplicas = jsonObject.get("status").asObject().getInt("readyReplicas", UNKNOWN); String resourceVersion = jsonObject.get("metadata").asObject().getString("resourceVersion", null); int replicas = jsonObject.get("status").asObject().getInt("currentReplicas", UNKNOWN); return new RuntimeContext(specReplicas, readyReplicas, replicas, resourceVersion); } @Nullable private String extractNodeName(EndpointAddress endpointAddress, Map nodes) { String nodeName = nodes.get(endpointAddress); if (nodeName == null) { JsonObject podJson = callGet(String.format("%s/api/v1/namespaces/%s/pods/%s", kubernetesMaster, namespace, endpointAddress.getTargetRefName())); return podJson.get("spec").asObject().get("nodeName").asString(); } return nodeName; } /** * Tries to add public addresses to the endpoints. *

* If it's not possible, then returns the input parameter. *

* Assigning public IPs must meet one of the following requirements: *

    *
  • Each POD must be exposed with a separate LoadBalancer service OR
  • *
  • Each POD must be exposed with a separate NodePort service and Kubernetes nodes must have external IPs
  • *
*

* The algorithm to fetch public IPs is as follows: *

    *
  1. Use Kubernetes API (/endpoints) to find dedicated services for each POD
  2. *
  3. For each POD: *
      *
    • If the corresponding service type is LoadBalancer, It extracts the External IP and Service Port
    • *
    • If the service type is NodePort, It uses the Kubernetes API (/nodes) to find the External IP of the Node
    • *
    *
  4. *
*/ private List enrichWithPublicAddresses(List endpoints) { if (exposeExternallyMode == ExposeExternallyMode.DISABLED) { return endpoints; } try { String endpointsUrl = String.format(apiProvider.getEndpointsUrlString(), kubernetesMaster, namespace); if (!StringUtil.isNullOrEmptyAfterTrim(servicePerPodLabelName) && !StringUtil.isNullOrEmptyAfterTrim(servicePerPodLabelValue)) { endpointsUrl += String.format("?labelSelector=%s=%s", servicePerPodLabelName, servicePerPodLabelValue); } JsonObject endpointsJson = callGet(endpointsUrl); List privateAddresses = privateAddresses(endpoints); Map services = apiProvider.extractServices(endpointsJson, privateAddresses); Map nodeAddresses = apiProvider.extractNodes(endpointsJson, privateAddresses); Map publicServiceAddresses = new HashMap<>(); Map cachedNodePublicIps = new HashMap<>(); for (Map.Entry serviceEntry : services.entrySet()) { EndpointAddress privateAddress = serviceEntry.getKey(); String service = serviceEntry.getValue(); String serviceUrl = String.format("%s/api/v1/namespaces/%s/services/%s", kubernetesMaster, namespace, service); JsonObject serviceJson = callGet(serviceUrl); String serviceType = extractServiceType(serviceJson); if (SERVICE_TYPE_LOADBALANCER.equals(serviceType)) { Address loadBalancerServiceAddress = extractLoadBalancerServiceAddress(serviceJson); publicServiceAddresses.put(privateAddress.getIp(), loadBalancerServiceAddress); } else if (SERVICE_TYPE_NODEPORT.equals(serviceType)) { Address nodePortServiceAddress = extractNodePortServiceAddress(serviceJson, serviceEntry.getKey(), nodeAddresses, cachedNodePublicIps); publicServiceAddresses.put(privateAddress.getIp(), nodePortServiceAddress); // Log warning only once. if (!isNodePortWarningAlreadyLogged && exposeExternallyMode == ExposeExternallyMode.ENABLED) { LOGGER.warning("Using NodePort service type for public addresses may lead to connection issues from " + "outside of the Kubernetes cluster. Ensure external accessibility of the NodePort IPs."); isNodePortWarningAlreadyLogged = true; } } else { throw new IllegalStateException(String.format( "Service type '%s' is not supported to discover the public addresses of the members", serviceType)); } } return createEndpoints(endpoints, publicServiceAddresses); } catch (Exception e) { if (exposeExternallyMode == ExposeExternallyMode.ENABLED) { throw e; } // If expose-externally not set (exposeExternallyMode == ExposeExternallyMode.AUTO), silently ignore any exception LOGGER.finest(e); // Log warning only once. if (!isNoPublicIpAlreadyLogged) { LOGGER.warning("Cannot fetch public IPs of Hazelcast Member PODs, you won't be able to use " + "Hazelcast MULTI_MEMBER or ALL_MEMBERS routing Clients from outside of the Kubernetes network"); isNoPublicIpAlreadyLogged = true; } return endpoints; } } private String externalIpAddressForNode(String node) { String nodeExternalAddress; if (useNodeNameAsExternalAddress) { LOGGER.info("Using node name instead of public IP for node, must be available from client: " + node); nodeExternalAddress = node; } else { String nodeUrl = String.format("%s/api/v1/nodes/%s", kubernetesMaster, node); nodeExternalAddress = extractNodePublicIp(callGet(nodeUrl)); } return nodeExternalAddress; } private Address extractNodePortServiceAddress(JsonObject serviceJson, EndpointAddress endpointAddress, Map nodeAddresses, Map cachedNodePublicIps) { Integer nodePort = extractNodePort(serviceJson); String node = extractNodeName(endpointAddress, nodeAddresses); String nodePublicIpAddress; if (cachedNodePublicIps.containsKey(node)) { nodePublicIpAddress = cachedNodePublicIps.get(node); } else { nodePublicIpAddress = externalIpAddressForNode(node); cachedNodePublicIps.put(node, nodePublicIpAddress); } return new Address(nodePublicIpAddress, nodePort); } /** * Makes a REST call to Kubernetes API and returns the result JSON. * * @param urlString Kubernetes API REST endpoint * @return parsed JSON * @throws KubernetesClientException if Kubernetes API didn't respond with 200 and a valid JSON content */ private JsonObject callGet(final String urlString) { return RetryUtils.retry(() -> Json .parse((caCertificate == null ? RestClient.create(urlString, CONNECTION_TIMEOUT_SECONDS) : RestClient.createWithSSL(urlString, caCertificate, CONNECTION_TIMEOUT_SECONDS)) .withHeader("Authorization", String.format("Bearer %s", tokenProvider.getToken())) .withRequestTimeoutSeconds(READ_TIMEOUT_SECONDS) .get() .getBody()) .asObject(), retries, NON_RETRYABLE_KEYWORDS); } private List handleKnownException(RestClientException e) { if (e.getHttpErrorCode() == HTTP_UNAUTHORIZED) { if (!isKnownExceptionAlreadyLogged) { LOGGER.warning("Kubernetes API authorization failure! To use Hazelcast Kubernetes discovery, " + "please check your 'api-token' property. Starting standalone."); isKnownExceptionAlreadyLogged = true; } } else if (e.getHttpErrorCode() == HTTP_FORBIDDEN) { if (!isKnownExceptionAlreadyLogged) { LOGGER.warning("Kubernetes API access is forbidden! Starting standalone. To use Hazelcast Kubernetes discovery, " + "configure the required RBAC. For 'default' service account in 'default' namespace execute " + "`kubectl apply -f https://raw.githubusercontent.com/hazelcast/hazelcast/master/kubernetes-rbac.yaml` " + "If you want to use a different service account and a different namespace, " + "you can update the mentioned rbac.yaml file accordingly and use it. " + "Error Kubernetes API Cause details:", e); isKnownExceptionAlreadyLogged = true; } } else { throw e; } LOGGER.finest(e); return emptyList(); } private static String getLabelSelectorParameter(String labelNames, String labelValues) { List labelNameList = new ArrayList<>(Arrays.asList(labelNames.split(","))); List labelValueList = new ArrayList<>(Arrays.asList(labelValues.split(","))); List selectorList = new ArrayList<>(labelNameList.size()); for (int i = 0; i < labelNameList.size(); i++) { selectorList.add(i, String.format("%s=%s", labelNameList.get(i), labelValueList.get(i))); } return String.format("labelSelector=%s", String.join(",", selectorList)); } private static List parsePodsList(JsonObject podsListJson) { List addresses = new ArrayList<>(); for (JsonValue item : toJsonArray(podsListJson.get("items"))) { String podName = item.asObject().get("metadata").asObject().get("name").asString(); JsonObject status = item.asObject().get("status").asObject(); String ip = toString(status.get("podIP")); if (ip != null) { Integer port = extractContainerPort(item); addresses.add(new Endpoint(new EndpointAddress(ip, port, podName), isReady(status))); } } return addresses; } private static Integer extractContainerPort(JsonValue podItemJson) { JsonArray containers = toJsonArray(podItemJson.asObject().get("spec").asObject().get("containers")); // If multiple containers are in one POD, then use the default Hazelcast port from the configuration. if (containers.size() == 1) { JsonValue container = containers.get(0); return containerPort(container); } else { for (JsonValue container : containers) { if (container.asObject().getString("name", "").equals("hazelcast")) { return containerPort(container); } } } return null; } private static Integer containerPort(JsonValue container) { JsonArray ports = toJsonArray(container.asObject().get("ports")); // If multiple ports are exposed by a container, then use the default Hazelcast port from the configuration. if (ports.size() > 0) { JsonValue port = ports.get(0); JsonValue containerPort = port.asObject().get("containerPort"); if (containerPort != null && containerPort.isNumber()) { return containerPort.asInt(); } } return null; } private static boolean isReady(JsonObject podItemStatusJson) { for (JsonValue containerStatus : toJsonArray(podItemStatusJson.get("containerStatuses"))) { // If multiple containers are in one POD, then each needs to be ready. if (!containerStatus.asObject().get("ready").asBoolean()) { return false; } } return true; } private static String extractNodeName(JsonObject podJson) { return toString(podJson.get("spec") .asObject().get("nodeName")); } private static String extractZone(JsonObject nodeJson) { JsonObject labels = nodeJson.get("metadata").asObject().get("labels").asObject(); List zoneLabels = asList("topology.kubernetes.io/zone", "failure-domain.kubernetes.io/zone", "failure-domain.beta.kubernetes.io/zone"); for (String zoneLabel : zoneLabels) { JsonValue zone = labels.get(zoneLabel); if (zone != null) { return toString(zone); } } return null; } private static String extractServiceType(JsonObject serviceResponse) { return serviceResponse.get("spec").asObject() .get("type").asString(); } private static Address extractLoadBalancerServiceAddress(JsonObject serviceJson) { String loadBalancerIpAddress = extractLoadBalancerIpAddress(serviceJson); Integer servicePort = extractServicePort(serviceJson); return new Address(loadBalancerIpAddress, servicePort); } private static List privateAddresses(List endpoints) { List result = new ArrayList<>(); for (Endpoint endpoint : endpoints) { result.add(endpoint.getPrivateAddress().getIp()); } return result; } private static String extractLoadBalancerIpAddress(JsonObject serviceResponse) { try { JsonObject ingress = serviceResponse .get("status").asObject() .get("loadBalancer").asObject() .get("ingress").asArray().get(0).asObject(); JsonValue address = ingress.get("ip"); if (address == null) { address = ingress.get("hostname"); } return address.asString(); } catch (Exception e) { throw new KubernetesClientException("Unable to extract the public address from the LoadBalancer service", e); } } private static List createEndpoints(List endpoints, Map publicAddresses) { List result = new ArrayList<>(); for (Endpoint endpoint : endpoints) { EndpointAddress privateAddress = endpoint.getPrivateAddress(); Address serviceAddress = publicAddresses.get(privateAddress.getIp()); EndpointAddress publicAddress = new EndpointAddress(serviceAddress.ip, serviceAddress.port, privateAddress.getTargetRefName()); result.add(new Endpoint(privateAddress, publicAddress, endpoint.isReady(), endpoint.getAdditionalProperties())); } return result; } private static Integer extractServicePort(JsonObject serviceJson) { JsonArray ports = toJsonArray(serviceJson.get("spec").asObject().get("ports")); if (ports.size() == 1) { return ports.get(0).asObject().get("port").asInt(); } for (JsonValue port : ports) { JsonValue servicePortName = port.asObject().get("name"); if (servicePortName != null && servicePortName.asString().equals("hazelcast")) { return port.asObject().get("port").asInt(); } } throw new KubernetesClientException(String.format("Cannot expose externally, service %s needs to have " + "either exactly one port defined, or a port with 'hazelcast' name", serviceJson.get("metadata").asObject().get("name"))); } private static Integer extractNodePort(JsonObject serviceJson) { JsonArray ports = toJsonArray(serviceJson.get("spec").asObject().get("ports")); if (ports.size() == 1) { return ports.get(0).asObject().get("nodePort").asInt(); } for (JsonValue port: ports) { JsonValue servicePortName = port.asObject().get("name"); if (servicePortName != null && servicePortName.asString().equals("hazelcast")) { return port.asObject().get("nodePort").asInt(); } } throw new KubernetesClientException(String.format("Cannot expose externally, service %s needs to have " + "either exactly one port defined, or a port with 'hazelcast' name", serviceJson.get("metadata").asObject().get("name"))); } private static String extractNodePublicIp(JsonObject nodeJson) { for (JsonValue address : toJsonArray(nodeJson.get("status").asObject().get("addresses"))) { if ("ExternalIP".equals(address.asObject().get("type").asString())) { return address.asObject().get("address").asString(); } } throw new KubernetesClientException(String.format("Cannot expose externally, node %s does not have ExternalIP" + " assigned", nodeJson.get("metadata").asObject().get("name"))); } private static JsonArray toJsonArray(JsonValue jsonValue) { if (jsonValue == null || jsonValue.isNull()) { return new JsonArray(); } else { return jsonValue.asArray(); } } private static String toString(JsonValue jsonValue) { if (jsonValue == null || jsonValue.isNull()) { return null; } else if (jsonValue.isString()) { return jsonValue.asString(); } else { return jsonValue.toString(); } } /** * Result which stores the information about a single endpoint. */ static final class Endpoint { private final EndpointAddress privateAddress; private final EndpointAddress publicAddress; private final boolean isReady; private final Map additionalProperties; Endpoint(EndpointAddress privateAddress, boolean isReady) { this.privateAddress = privateAddress; this.publicAddress = null; this.isReady = isReady; this.additionalProperties = Collections.emptyMap(); } Endpoint(EndpointAddress privateAddress, boolean isReady, Map additionalProperties) { this.privateAddress = privateAddress; this.publicAddress = null; this.isReady = isReady; this.additionalProperties = additionalProperties; } Endpoint(EndpointAddress privateAddress, EndpointAddress publicAddress, boolean isReady, Map additionalProperties) { this.privateAddress = privateAddress; this.publicAddress = publicAddress; this.isReady = isReady; this.additionalProperties = additionalProperties; } EndpointAddress getPublicAddress() { return publicAddress; } EndpointAddress getPrivateAddress() { return privateAddress; } boolean isReady() { return isReady; } Map getAdditionalProperties() { return additionalProperties; } } static final class EndpointAddress { private final Address address; private String targetRefName; EndpointAddress(Address address) { this.address = address; } EndpointAddress(String ip, Integer port) { this(new Address(ip, port)); } EndpointAddress(String ip, Integer port, String targetRefName) { this(ip, port); this.targetRefName = targetRefName; } String getIp() { return address.ip; } Integer getPort() { return address.port; } String getTargetRefName() { return targetRefName; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } EndpointAddress endpointAddress = (EndpointAddress) o; return Objects.equals(address, endpointAddress.address) && Objects.equals(targetRefName, endpointAddress.targetRefName); } @Override public int hashCode() { return Objects.hash(address, targetRefName); } @Override public String toString() { return address.toString(); } } static final class Address { private final String ip; private final Integer port; Address(String ip, Integer port) { this.ip = ip; this.port = port; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Address address = (Address) o; return Objects.equals(ip, address.ip) && Objects.equals(port, address.port); } @Override public int hashCode() { return Objects.hash(ip, port); } @Override public String toString() { return String.format("%s:%s", ip, port); } } final class StsMonitorThread extends Thread { // backoff properties when retrying private static final int MAX_SPINS = 3; private static final int MAX_YIELDS = 10; private static final int MIN_PARK_PERIOD_MILLIS = 1; private static final int MAX_PARK_PERIOD_SECONDS = 10; // used only for tests volatile boolean running = true; volatile boolean finished; volatile boolean shuttingDown; String latestResourceVersion; RuntimeContext latestRuntimeContext; int idleCount; RestClient.WatchResponse watchResponse; private final String stsUrlString; private final BackoffIdleStrategy backoffIdleStrategy; StsMonitorThread() { super("hz-k8s-sts-monitor"); stsUrlString = formatStsListUrl(); backoffIdleStrategy = new BackoffIdleStrategy(MAX_SPINS, MAX_YIELDS, MILLISECONDS.toNanos(MIN_PARK_PERIOD_MILLIS), SECONDS.toNanos(MAX_PARK_PERIOD_SECONDS)); } /** * Initializes and watches information about the StatefulSet in which Hazelcast is being executed. * See * Efficient detection of changes on Kubernetes API reference. *

* Important: If this thread starves, then timely updates may be stalled and shutdown hook * may not act on the latest cluster information. */ @Override public void run() { String message; while (running) { if (shuttingDown) { break; } try { // read initial statefulset list RuntimeContext previous = latestRuntimeContext; readInitialStsList(); // update tracker updateTracker(previous, latestRuntimeContext); watchResponse = sendWatchRequest(); } catch (RestClientException e) { // interrupts during shutdown will trigger a RestClientException if (shuttingDown) { break; } handleFailure(e); // always retry after a RestClientException continue; } // reset backoff-idle count idleCount = 0; try { while ((message = watchResponse.nextLine()) != null) { onMessage(message); } } catch (IOException e) { // If we're shutting down, the watchResponse is already disconnected, and // the IOException can be disregarded; otherwise continue with logging if (!shuttingDown) { LOGGER.info("Exception while watching for StatefulSet changes", e); try { watchResponse.disconnect(); } catch (Exception t) { LOGGER.fine("Exception while closing connection after an IOException", t); } } } } finished = true; } public void shutdown() { this.shuttingDown = true; try { if (watchResponse != null) { watchResponse.disconnect(); } } catch (IOException e) { LOGGER.fine("Exception while closing connection during shutdown", e); } // Interrupt thread as we may be in the process of making watch requests // or other calls that need to be interrupted for us to shut down promptly stsMonitorThread.interrupt(); } private void handleFailure(RestClientException e) { if (e.getHttpErrorCode() == HTTP_GONE) { // occurs when the resource version we are watching for is stale LOGGER.info("StatefulSet watcher has fallen behind, re-reading sts list and resuming watch: " + e.getMessage()); } else { // watch failed with another HTTP error code, let's log at WARNING level, // backoff and try to resume again LOGGER.warning("Error while attempting to watch kubernetes API for StatefulSets: " + e.getHttpErrorCode() + " " + e.getMessage() + ". Backing off (n: " + idleCount + " ) before retrying."); backoffIdleStrategy.idle(idleCount); idleCount++; } } String formatStsListUrl() { String fieldSelectorValue = String.format("metadata.name=%s", stsName); fieldSelectorValue = URLEncoder.encode(fieldSelectorValue, StandardCharsets.UTF_8); return String.format("%s/apis/apps/v1/namespaces/%s/statefulsets?fieldSelector=%s", kubernetesMaster, namespace, fieldSelectorValue); } // GET statefulsets list and update the latest runtime context void readInitialStsList() { JsonObject jsonObject = callGet(stsUrlString); latestResourceVersion = jsonObject.get("metadata").asObject().getString("resourceVersion", null); latestRuntimeContext = parseStsList(jsonObject); } /** * Send a watch request * @return a {@link com.hazelcast.spi.utils.RestClient.WatchResponse} that can be used to poll for watch events * from Kubernetes API server. */ @Nonnull RestClient.WatchResponse sendWatchRequest() throws RestClientException { RestClient restClient = (caCertificate == null ? RestClient.create(stsUrlString) : RestClient.createWithSSL(stsUrlString, caCertificate)) .withHeader("Authorization", String.format("Bearer %s", tokenProvider.getToken())); return restClient.watch(latestResourceVersion); } @Nullable RuntimeContext parseStsList(JsonObject jsonObject) { String resourceVersion = jsonObject.get("metadata").asObject().getString("resourceVersion", null); // identify stateful set this pod belongs to for (JsonValue item : toJsonArray(jsonObject.get("items"))) { String itemName = item.asObject().get("metadata").asObject().getString("name", null); if (stsName.equals(itemName)) { // identified the stateful set int specReplicas = item.asObject().get("spec").asObject().getInt("replicas", UNKNOWN); int readyReplicas = item.asObject().get("status").asObject().getInt("readyReplicas", UNKNOWN); int replicas = item.asObject().get("status").asObject().getInt("currentReplicas", UNKNOWN); return new RuntimeContext(specReplicas, readyReplicas, replicas, resourceVersion); } } return null; } @SuppressWarnings("checkstyle:cyclomaticcomplexity") void onMessage(String message) { if (LOGGER.isFinestEnabled()) { LOGGER.finest("Complete message from kubernetes API: " + message); } JsonObject jsonObject = Json.parse(message).asObject(); JsonObject sts = jsonObject.get("object").asObject(); String itemName = sts.asObject().get("metadata").asObject().getString("name", null); if (!stsName.equals(itemName)) { return; } String watchType = jsonObject.getString("type", null); RuntimeContext ctx = null; switch (watchType) { case "MODIFIED": ctx = extractSts(sts); latestResourceVersion = ctx.getResourceVersion(); break; case "DELETED": ctx = extractSts(sts); latestResourceVersion = ctx.getResourceVersion(); ctx = new RuntimeContext(0, ctx.getReadyReplicas(), ctx.getCurrentReplicas(), ctx.getResourceVersion()); break; case "ADDED": throw new IllegalStateException("A new sts with same name as this cannot be added"); default: LOGGER.info("Unknown watch type " + watchType + ", complete message:\n" + message); } if (latestRuntimeContext != null && ctx != null) { updateTracker(latestRuntimeContext, ctx); } latestRuntimeContext = ctx; } void updateTracker(RuntimeContext previous, RuntimeContext updated) { if (previous != null) { LOGGER.info("Updating cluster topology tracker with previous: " + previous + ", updated: " + updated); clusterTopologyIntentTracker.update(previous.getSpecifiedReplicaCount(), updated.getSpecifiedReplicaCount(), previous.getReadyReplicas(), updated.getReadyReplicas(), previous.getCurrentReplicas(), updated.getCurrentReplicas()); } else { LOGGER.info("Initializing cluster topology tracker with initial context: " + latestRuntimeContext); clusterTopologyIntentTracker.update(UNKNOWN, updated.getSpecifiedReplicaCount(), UNKNOWN, updated.getReadyReplicas(), UNKNOWN, updated.getCurrentReplicas()); } } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy