/*
 * 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.pulsar.functions.runtime.kubernetes;

import static java.net.HttpURLConnection.HTTP_CONFLICT;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.apache.commons.lang3.StringUtils.isNotEmpty;
import static org.apache.commons.lang3.StringUtils.left;
import static org.apache.pulsar.functions.auth.FunctionAuthUtils.getFunctionAuthData;
import static org.apache.pulsar.functions.utils.FunctionCommon.roundDecimal;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.gson.Gson;
import com.google.protobuf.Empty;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.kubernetes.client.custom.Quantity;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.apis.AppsV1Api;
import io.kubernetes.client.openapi.apis.CoreV1Api;
import io.kubernetes.client.openapi.models.V1Container;
import io.kubernetes.client.openapi.models.V1ContainerPort;
import io.kubernetes.client.openapi.models.V1EnvVar;
import io.kubernetes.client.openapi.models.V1EnvVarSource;
import io.kubernetes.client.openapi.models.V1LabelSelector;
import io.kubernetes.client.openapi.models.V1ObjectFieldSelector;
import io.kubernetes.client.openapi.models.V1ObjectMeta;
import io.kubernetes.client.openapi.models.V1PodList;
import io.kubernetes.client.openapi.models.V1PodSpec;
import io.kubernetes.client.openapi.models.V1PodTemplateSpec;
import io.kubernetes.client.openapi.models.V1ResourceRequirements;
import io.kubernetes.client.openapi.models.V1Service;
import io.kubernetes.client.openapi.models.V1ServicePort;
import io.kubernetes.client.openapi.models.V1ServiceSpec;
import io.kubernetes.client.openapi.models.V1StatefulSet;
import io.kubernetes.client.openapi.models.V1StatefulSetSpec;
import io.kubernetes.client.openapi.models.V1Toleration;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.pulsar.functions.auth.KubernetesFunctionAuthProvider;
import org.apache.pulsar.functions.instance.AuthenticationConfig;
import org.apache.pulsar.functions.instance.InstanceConfig;
import org.apache.pulsar.functions.instance.InstanceUtils;
import org.apache.pulsar.functions.proto.Function;
import org.apache.pulsar.functions.proto.InstanceCommunication;
import org.apache.pulsar.functions.proto.InstanceCommunication.FunctionStatus;
import org.apache.pulsar.functions.proto.InstanceControlGrpc;
import org.apache.pulsar.functions.runtime.Runtime;
import org.apache.pulsar.functions.runtime.RuntimeUtils;
import org.apache.pulsar.functions.secretsproviderconfigurator.SecretsProviderConfigurator;
import org.apache.pulsar.functions.utils.Actions;
import org.apache.pulsar.functions.utils.FunctionCommon;

/**
 * Kubernetes based runtime for running functions.
 * This runtime provides the usual methods to start/stop/getfunctionstatus
 * interfaces to control the kubernetes job running function.
 * We first create a headless service and then a statefulset for starting function pods
 * Each function instance runs as a pod itself. The reason using statefulset as opposed
 * to a regular deployment is that functions require a unique instance_id for each instance.
 * The service abstraction is used for getting functionstatus.
 */
@Slf4j
@VisibleForTesting
public class KubernetesRuntime implements Runtime {

    private static final String ENV_SHARD_ID = "SHARD_ID";
    private static final int maxJobNameSize = 53;
    private static final int maxLabelSize = 63;
    public static final Pattern VALID_POD_NAME_REGEX =
            Pattern.compile("[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*",
                    Pattern.CASE_INSENSITIVE);
    private static final String PULSARFUNCTIONS_CONTAINER_NAME = "pulsarfunction";

    private final AppsV1Api appsClient;
    private final CoreV1Api coreClient;
    static final List<String> TOLERATIONS = Collections.unmodifiableList(
            Arrays.asList(
                    "node.kubernetes.io/not-ready",
                    "node.alpha.kubernetes.io/notReady",
                    "node.alpha.kubernetes.io/unreachable"
            )
    );
    private static final long GRPC_TIMEOUT_SECS = 5;
    private final boolean authenticationEnabled;

    // The thread that invokes the function
    @Getter
    private List<String> processArgs;
    @Getter
    private ManagedChannel[] channel;
    private InstanceControlGrpc.InstanceControlFutureStub[] stub;
    private InstanceConfig instanceConfig;
    private final String jobNamespace;
    private final String jobName;
    private final Map<String, String> customLabels;
    private final Map<String, String> functionDockerImages;
    private final String pulsarDockerImageName;
    private final String imagePullPolicy;
    private final String pulsarRootDir;
    private final String configAdminCLI;
    private final String userCodePkgUrl;
    private final String originalCodeFileName;
    private final String originalTransformFunctionFileName;
    private final String pulsarAdminUrl;
    private final SecretsProviderConfigurator secretsProviderConfigurator;
    private int percentMemoryPadding;
    private double cpuOverCommitRatio;
    private double memoryOverCommitRatio;
    private int gracePeriodSeconds;
    private final Optional<KubernetesFunctionAuthProvider> functionAuthDataCacheProvider;
    private final AuthenticationConfig authConfig;
    private Integer grpcPort;
    private Integer metricsPort;
    private String narExtractionDirectory;
    private final Optional<KubernetesManifestCustomizer> manifestCustomizer;
    private String functionInstanceClassPath;
    private String downloadDirectory;

    KubernetesRuntime(AppsV1Api appsClient,
                      CoreV1Api coreClient,
                      String jobNamespace,
                      String jobName,
                      Map<String, String> customLabels,
                      Boolean installUserCodeDependencies,
                      String pythonDependencyRepository,
                      String pythonExtraDependencyRepository,
                      String pulsarDockerImageName,
                      Map<String, String> functionDockerImages,
                      String imagePullPolicy,
                      String pulsarRootDir,
                      InstanceConfig instanceConfig,
                      String instanceFile,
                      String extraDependenciesDir,
                      String logDirectory,
                      String configAdminCLI,
                      String userCodePkgUrl,
                      String originalCodeFileName,
                      String originalTransformFunctionFileName,
                      String pulsarServiceUrl,
                      String pulsarAdminUrl,
                      String stateStorageServiceUrl,
                      AuthenticationConfig authConfig,
                      SecretsProviderConfigurator secretsProviderConfigurator,
                      Integer expectedMetricsCollectionInterval,
                      int percentMemoryPadding,
                      double cpuOverCommitRatio,
                      double memoryOverCommitRatio,
                      int gracePeriodSeconds,
                      Optional<KubernetesFunctionAuthProvider> functionAuthDataCacheProvider,
                      boolean authenticationEnabled,
                      Integer grpcPort,
                      String narExtractionDirectory,
                      Optional<KubernetesManifestCustomizer> manifestCustomizer,
                      String functionInstanceClassPath,
                      String downloadDirectory) throws Exception {
        this.appsClient = appsClient;
        this.coreClient = coreClient;
        this.instanceConfig = instanceConfig;
        this.jobNamespace = jobNamespace;
        this.jobName = jobName;
        this.customLabels = customLabels;
        this.functionDockerImages = functionDockerImages;
        this.pulsarDockerImageName = pulsarDockerImageName;
        this.imagePullPolicy = imagePullPolicy;
        this.pulsarRootDir = pulsarRootDir;
        this.configAdminCLI = configAdminCLI;
        this.userCodePkgUrl = userCodePkgUrl;
        this.downloadDirectory =
                isNotEmpty(downloadDirectory) ? downloadDirectory : this.pulsarRootDir; // for backward comp
        this.originalCodeFileName = this.downloadDirectory + "/" + RuntimeUtils.sanitizeFileName(originalCodeFileName);
        this.originalTransformFunctionFileName = isNotEmpty(originalTransformFunctionFileName)
                ? this.downloadDirectory + "/" + RuntimeUtils.sanitizeFileName(originalTransformFunctionFileName)
                : originalTransformFunctionFileName;
        this.pulsarAdminUrl = pulsarAdminUrl;
        this.secretsProviderConfigurator = secretsProviderConfigurator;
        this.percentMemoryPadding = percentMemoryPadding;
        this.cpuOverCommitRatio = cpuOverCommitRatio;
        this.memoryOverCommitRatio = memoryOverCommitRatio;
        this.gracePeriodSeconds = gracePeriodSeconds;
        this.authenticationEnabled = authenticationEnabled;
        this.manifestCustomizer = manifestCustomizer;
        this.functionInstanceClassPath = functionInstanceClassPath;
        String logConfigFile = null;
        String secretsProviderClassName =
                secretsProviderConfigurator.getSecretsProviderClassName(instanceConfig.getFunctionDetails());
        String secretsProviderConfig = null;
        if (secretsProviderConfigurator.getSecretsProviderConfig(instanceConfig.getFunctionDetails()) != null) {
            secretsProviderConfig = new Gson()
                    .toJson(secretsProviderConfigurator.getSecretsProviderConfig(instanceConfig.getFunctionDetails()));
        }
        switch (instanceConfig.getFunctionDetails().getRuntime()) {
            case JAVA:
                logConfigFile = "kubernetes_instance_log4j2.xml";
                break;
            case PYTHON:
                logConfigFile = pulsarRootDir + "/conf/functions-logging/console_logging_config.ini";
                break;
            case GO:
                break;
        }

        this.authConfig = authConfig;

        this.functionAuthDataCacheProvider = functionAuthDataCacheProvider;

        this.grpcPort = grpcPort;
        this.metricsPort = instanceConfig.hasValidMetricsPort() ? instanceConfig.getMetricsPort() : null;
        this.narExtractionDirectory = narExtractionDirectory;

        this.processArgs = new LinkedList<>();
        this.processArgs.addAll(RuntimeUtils.getArgsBeforeCmd(instanceConfig, extraDependenciesDir));

        if (instanceConfig.getFunctionDetails().getRuntime() == Function.FunctionDetails.Runtime.GO) {
            // before we run the command, make sure the go executable with correct permissions
            this.processArgs.add("chmod");
            this.processArgs.add("777");
            this.processArgs.add(this.originalCodeFileName);
            this.processArgs.add("&&");
        }

        // use exec to to launch function so that it gets launched in the foreground with the same PID as shell
        // so that when we kill the pod, the signal will get propagated to the function code
        this.processArgs.add("exec");

        this.processArgs.addAll(
                RuntimeUtils.getCmd(
                        instanceConfig,
                        instanceFile,
                        extraDependenciesDir,
                        logDirectory,
                        this.originalCodeFileName,
                        this.originalTransformFunctionFileName,
                        pulsarServiceUrl,
                        stateStorageServiceUrl,
                        authConfig,
                        "$" + ENV_SHARD_ID,
                        grpcPort,
                        -1L,
                        logConfigFile,
                        secretsProviderClassName,
                        secretsProviderConfig,
                        installUserCodeDependencies,
                        pythonDependencyRepository,
                        pythonExtraDependencyRepository,
                        narExtractionDirectory,
                        functionInstanceClassPath,
                        true,
                        pulsarAdminUrl));

        doChecks(instanceConfig.getFunctionDetails(), this.jobName);
    }

    /**
     * The core logic that creates a service first followed by statefulset.
     */
    @Override
    public void start() throws Exception {

        try {
            submitService();
            submitStatefulSet();

        } catch (Exception e) {
            log.error("Failed start function {}/{}/{} in Kubernetes",
                    instanceConfig.getFunctionDetails().getTenant(),
                    instanceConfig.getFunctionDetails().getNamespace(),
                    instanceConfig.getFunctionDetails().getName(), e);
            stop();
            throw e;
        }

        setupGrpcChannelIfNeeded();
    }

    @Override
    public void reinitialize() {
        setupGrpcChannelIfNeeded();
    }

    private synchronized void setupGrpcChannelIfNeeded() {
        if (channel == null || stub == null) {
            channel = new ManagedChannel[instanceConfig.getFunctionDetails().getParallelism()];
            stub = new InstanceControlGrpc.InstanceControlFutureStub[instanceConfig.getFunctionDetails()
                    .getParallelism()];

            String jobName = createJobName(instanceConfig.getFunctionDetails(), this.jobName);
            for (int i = 0; i < instanceConfig.getFunctionDetails().getParallelism(); ++i) {
                String address = getServiceUrl(jobName, jobNamespace, i);
                channel[i] = ManagedChannelBuilder.forAddress(address, grpcPort)
                        .usePlaintext()
                        .build();
                stub[i] = InstanceControlGrpc.newFutureStub(channel[i]);
            }
        }
    }

    @Override
    public void join() throws Exception {
        // K8 functions never return
        this.wait();
    }

    @Override
    public void stop() throws Exception {
        // Because we're about to delete the function pods, we don't want the channel to attempt loading DNS, which
        // will cease to exist if the deletion succeeds. However, we don't want to shut down the channel until
        // the StatefulSet and Service are actually deleted.
        if (channel != null) {
            for (ManagedChannel cn : channel) {
                cn.enterIdle();
            }
        }

        deleteStatefulSet();
        deleteService();

        if (channel != null) {
            for (ManagedChannel cn : channel) {
                cn.shutdown();
            }
        }
        channel = null;
        stub = null;
    }

    @Override
    public Throwable getDeathException() {
        return null;
    }

    @Override
    public CompletableFuture<FunctionStatus> getFunctionStatus(int instanceId) {
        CompletableFuture<FunctionStatus> retval = new CompletableFuture<>();
        if (stub == null) {
            retval.completeExceptionally(new RuntimeException("Not alive"));
            return retval;
        }
        if (instanceId < 0 || instanceId >= stub.length) {
            retval.completeExceptionally(new RuntimeException("Invalid InstanceId"));
            return retval;
        }
        ListenableFuture<FunctionStatus> response =
                stub[instanceId].withDeadlineAfter(GRPC_TIMEOUT_SECS, TimeUnit.SECONDS)
                        .getFunctionStatus(Empty.newBuilder().build());
        Futures.addCallback(response, new FutureCallback<FunctionStatus>() {
            @Override
            public void onFailure(Throwable throwable) {
                FunctionStatus.Builder builder = FunctionStatus.newBuilder();
                builder.setRunning(false);
                builder.setFailureException(throwable.getMessage());
                retval.complete(builder.build());
            }

            @Override
            public void onSuccess(FunctionStatus t) {
                retval.complete(t);
            }
        }, MoreExecutors.directExecutor());
        return retval;
    }

    @Override
    public CompletableFuture<InstanceCommunication.MetricsData> getAndResetMetrics() {
        CompletableFuture<InstanceCommunication.MetricsData> retval = new CompletableFuture<>();
        retval.completeExceptionally(
                new RuntimeException("Kubernetes Runtime doesn't support getAndReset metrics via rest"));
        return retval;
    }

    @Override
    public CompletableFuture<Void> resetMetrics() {
        CompletableFuture<Void> retval = new CompletableFuture<>();
        retval.completeExceptionally(
                new RuntimeException("Kubernetes Runtime doesn't support resetting metrics via rest"));
        return retval;
    }

    @Override
    public CompletableFuture<InstanceCommunication.MetricsData> getMetrics(int instanceId) {
        CompletableFuture<InstanceCommunication.MetricsData> retval = new CompletableFuture<>();
        if (stub == null) {
            retval.completeExceptionally(new RuntimeException("Not alive"));
            return retval;
        }

        if (instanceId < 0 || instanceId >= stub.length) {
            retval.completeExceptionally(new RuntimeException("Invalid InstanceId"));
            return retval;
        }

        ListenableFuture<InstanceCommunication.MetricsData> response =
                stub[instanceId].withDeadlineAfter(GRPC_TIMEOUT_SECS, TimeUnit.SECONDS)
                        .getMetrics(Empty.newBuilder().build());
        Futures.addCallback(response, new FutureCallback<InstanceCommunication.MetricsData>() {
            @Override
            public void onFailure(Throwable throwable) {
                InstanceCommunication.MetricsData.Builder builder = InstanceCommunication.MetricsData.newBuilder();
                retval.complete(builder.build());
            }

            @Override
            public void onSuccess(InstanceCommunication.MetricsData t) {
                retval.complete(t);
            }
        }, MoreExecutors.directExecutor());
        return retval;
    }

    @Override
    public String getPrometheusMetrics() throws IOException {
        if (metricsPort != null) {
            return RuntimeUtils.getPrometheusMetrics(metricsPort);
        } else {
            return null;
        }
    }

    @Override
    public boolean isAlive() {
        // No point for kubernetes just return dummy value
        return true;
    }

    private void submitService() throws Exception {
        final V1Service service = createService();
        log.info("Submitting the following service to k8 {}", coreClient.getApiClient().getJSON().serialize(service));

        String fqfn = FunctionCommon.getFullyQualifiedName(instanceConfig.getFunctionDetails());

        Actions.Action createService = Actions.Action.builder()
                .actionName(String.format("Submitting service for function %s", fqfn))
                .numRetries(KubernetesRuntimeFactory.numRetries)
                .sleepBetweenInvocationsMs(KubernetesRuntimeFactory.sleepBetweenRetriesMs)
                .supplier(() -> {
                    try {
                        coreClient.createNamespacedService(jobNamespace, service).execute();
                    } catch (ApiException e) {
                        // already exists
                        if (e.getCode() == HTTP_CONFLICT) {
                            log.warn("Service already present for function {}", fqfn);
                            return Actions.ActionResult.builder().success(true).build();
                        }

                        String errorMsg = e.getResponseBody() != null ? e.getResponseBody() : e.getMessage();
                        return Actions.ActionResult.builder()
                                .success(false)
                                .errorMsg(errorMsg)
                                .build();
                    }

                    return Actions.ActionResult.builder().success(true).build();
                })
                .build();


        AtomicBoolean success = new AtomicBoolean(false);
        Actions.newBuilder()
                .addAction(createService.toBuilder()
                        .onSuccess(ignored -> success.set(true))
                        .build())
                .run();

        if (!success.get()) {
            throw new RuntimeException(String.format("Failed to create service for function %s", fqfn));
        }
    }

    @VisibleForTesting
    V1Service createService() {
        final String jobName = createJobName(instanceConfig.getFunctionDetails(), this.jobName);

        final V1Service service = new V1Service();

        // setup stateful set metadata
        final V1ObjectMeta objectMeta = new V1ObjectMeta();
        objectMeta.name(jobName);
        objectMeta.setLabels(getLabels(instanceConfig.getFunctionDetails()));
        // we don't technically need to set this, but it is useful for testing
        objectMeta.setNamespace(jobNamespace);
        service.metadata(objectMeta);

        // create the stateful set spec
        final V1ServiceSpec serviceSpec = new V1ServiceSpec();

        serviceSpec.clusterIP("None");

        final V1ServicePort servicePort = new V1ServicePort();
        servicePort.name("grpc").port(grpcPort).protocol("TCP");
        serviceSpec.addPortsItem(servicePort);

        serviceSpec.selector(getLabels(instanceConfig.getFunctionDetails()));

        service.spec(serviceSpec);

        // let the customizer run but ensure it doesn't change the name so we can find it again
        final V1Service overridden = manifestCustomizer
                .map((customizer) -> customizer.customizeService(instanceConfig.getFunctionDetails(), service))
                .orElse(service);
        overridden.getMetadata().name(jobName);

        return overridden;
    }

    private void submitStatefulSet() throws Exception {
        final V1StatefulSet statefulSet = createStatefulSet();
        // Configure function authentication if needed
        if (authenticationEnabled) {
            functionAuthDataCacheProvider.ifPresent(
                    kubernetesFunctionAuthProvider -> kubernetesFunctionAuthProvider.configureAuthDataStatefulSet(
                            statefulSet, Optional.ofNullable(getFunctionAuthData(
                                    Optional.ofNullable(instanceConfig.getFunctionAuthenticationSpec())))));
        }

        log.info("Submitting the following spec to k8 {}", appsClient.getApiClient().getJSON().serialize(statefulSet));

        String fqfn = FunctionCommon.getFullyQualifiedName(instanceConfig.getFunctionDetails());

        Actions.Action createStatefulSet = Actions.Action.builder()
                .actionName(String.format("Submitting statefulset for function %s", fqfn))
                .numRetries(KubernetesRuntimeFactory.numRetries)
                .sleepBetweenInvocationsMs(KubernetesRuntimeFactory.sleepBetweenRetriesMs)
                .supplier(() -> {
                    try {
                        appsClient.createNamespacedStatefulSet(jobNamespace, statefulSet).execute();
                    } catch (ApiException e) {
                        // already exists
                        if (e.getCode() == HTTP_CONFLICT) {
                            log.warn("Statefulset already present for function {}", fqfn);
                            return Actions.ActionResult.builder().success(true).build();
                        }

                        String errorMsg = e.getResponseBody() != null ? e.getResponseBody() : e.getMessage();
                        return Actions.ActionResult.builder()
                                .success(false)
                                .errorMsg(errorMsg)
                                .build();
                    }

                    return Actions.ActionResult.builder().success(true).build();
                })
                .build();


        AtomicBoolean success = new AtomicBoolean(false);
        Actions.newBuilder()
                .addAction(createStatefulSet.toBuilder()
                        .onSuccess(ignored -> success.set(true))
                        .build())
                .run();

        if (!success.get()) {
            throw new RuntimeException(String.format("Failed to create statefulset for function %s", fqfn));
        }
    }


    public void deleteStatefulSet() throws InterruptedException {
        String statefulSetName = createJobName(instanceConfig.getFunctionDetails(), this.jobName);

        String fqfn = FunctionCommon.getFullyQualifiedName(instanceConfig.getFunctionDetails());
        Actions.Action deleteStatefulSet = Actions.Action.builder()
                .actionName(String.format("Deleting statefulset for function %s", fqfn))
                .numRetries(KubernetesRuntimeFactory.numRetries)
                .sleepBetweenInvocationsMs(KubernetesRuntimeFactory.sleepBetweenRetriesMs)
                .supplier(() -> {
                    try {
                        appsClient.deleteNamespacedStatefulSet(
                                statefulSetName,
                                jobNamespace)
                                .gracePeriodSeconds(gracePeriodSeconds)
                                .propagationPolicy("Foreground")
                                .execute();
                    } catch (ApiException e) {
                        // if already deleted
                        if (e.getCode() == HTTP_NOT_FOUND) {
                            log.warn("Statefulset for function {} does not exist", fqfn);
                            return Actions.ActionResult.builder().success(true).build();
                        }
                        String errorMsg = e.getResponseBody() != null ? e.getResponseBody() : e.getMessage();
                        return Actions.ActionResult.builder()
                                .success(false)
                                .errorMsg(errorMsg)
                                .build();
                    }
                    return Actions.ActionResult.builder()
                            .success(true)
                            .build();
                })
                .build();


        Actions.Action waitForStatefulSetDeletion = Actions.Action.builder()
                .actionName(String.format("Waiting for StatefulSet deletion to complete deletion of function %s", fqfn))
                // set retry period to be about 2x the graceshutdown time
                .numRetries(KubernetesRuntimeFactory.numRetries * 2)
                .sleepBetweenInvocationsMs(KubernetesRuntimeFactory.sleepBetweenRetriesMs * 2)
                .supplier(() -> {
                    V1StatefulSet response;
                    try {
                        response = appsClient.readNamespacedStatefulSet(statefulSetName, jobNamespace).execute();
                    } catch (ApiException e) {
                        // statefulset is gone
                        if (e.getCode() == HTTP_NOT_FOUND) {
                            return Actions.ActionResult.builder().success(true).build();
                        }

                        String errorMsg = e.getResponseBody() != null ? e.getResponseBody() : e.getMessage();
                        return Actions.ActionResult.builder()
                                .success(false)
                                .errorMsg(errorMsg)
                                .build();
                    }
                    return Actions.ActionResult.builder()
                            .success(false)
                            .errorMsg(response.getStatus().toString())
                            .build();
                })
                .build();

        // Need to wait for all pods to die so we can cleanup subscriptions.
        Actions.Action waitForStatefulPodsToTerminate = Actions.Action.builder()
                .actionName(String.format("Waiting for pods for function %s to terminate", fqfn))
                .numRetries(KubernetesRuntimeFactory.numRetries * 2)
                .sleepBetweenInvocationsMs(KubernetesRuntimeFactory.sleepBetweenRetriesMs * 2)
                .supplier(() -> {
                    Map<String, String> validLabels = getLabels(instanceConfig.getFunctionDetails());
                    String labels = String.format("tenant=%s,namespace=%s,name=%s",
                            validLabels.get("tenant"),
                            validLabels.get("namespace"),
                            validLabels.get("name"));

                    V1PodList response;
                    try {
                        response = coreClient.listNamespacedPod(jobNamespace).labelSelector(labels).execute();
                    } catch (ApiException e) {
                        String errorMsg = e.getResponseBody() != null ? e.getResponseBody() : e.getMessage();
                        return Actions.ActionResult.builder()
                                .success(false)
                                .errorMsg(errorMsg)
                                .build();
                    }

                    if (response.getItems().size() > 0) {
                        return Actions.ActionResult.builder()
                                .success(false)
                                .errorMsg(response.getItems().size() + " pods still alive.")
                                .build();
                    } else {
                        return Actions.ActionResult.builder()
                                .success(true)
                                .build();
                    }
                })
                .build();


        AtomicBoolean success = new AtomicBoolean(false);
        Actions.newBuilder()
                .addAction(deleteStatefulSet.toBuilder()
                        .continueOn(true)
                        .build())
                .addAction(waitForStatefulSetDeletion.toBuilder()
                        .continueOn(false)
                        .onSuccess(ignored -> success.set(true))
                        .build())
                .addAction(deleteStatefulSet.toBuilder()
                        .continueOn(true)
                        .build())
                .addAction(waitForStatefulSetDeletion.toBuilder()
                        .onSuccess(ignored -> success.set(true))
                        .build())
                .run();

        if (!success.get()) {
            throw new RuntimeException(String.format("Failed to delete statefulset for function %s", fqfn));
        } else {
            // wait for pods to terminate
            Actions.newBuilder()
                    .addAction(waitForStatefulPodsToTerminate)
                    .run();
        }
    }

    public void deleteService() throws InterruptedException {
        String fqfn = FunctionCommon.getFullyQualifiedName(instanceConfig.getFunctionDetails());
        String serviceName = createJobName(instanceConfig.getFunctionDetails(), this.jobName);

        Actions.Action deleteService = Actions.Action.builder()
                .actionName(String.format("Deleting service for function %s", fqfn))
                .numRetries(KubernetesRuntimeFactory.numRetries)
                .sleepBetweenInvocationsMs(KubernetesRuntimeFactory.sleepBetweenRetriesMs)
                .supplier(() -> {
                    try {
                        coreClient.deleteNamespacedService(serviceName, jobNamespace)
                                .gracePeriodSeconds(0)
                                .propagationPolicy("Foreground")
                                .execute();
                    } catch (ApiException e) {
                        // if already deleted
                        if (e.getCode() == HTTP_NOT_FOUND) {
                            log.warn("Service for function {} does not exist", fqfn);
                            return Actions.ActionResult.builder().success(true).build();
                        }

                        String errorMsg = e.getResponseBody() != null ? e.getResponseBody() : e.getMessage();
                        return Actions.ActionResult.builder()
                                .success(false)
                                .errorMsg(errorMsg)
                                .build();
                    }

                    return Actions.ActionResult.builder().success(true).build();
                })
                .build();

        Actions.Action waitForServiceDeletion = Actions.Action.builder()
                .actionName(String.format("Waiting for service deletion to complete deletion of function %s", fqfn))
                .numRetries(KubernetesRuntimeFactory.numRetries)
                .sleepBetweenInvocationsMs(KubernetesRuntimeFactory.sleepBetweenRetriesMs)
                .supplier(() -> {
                    V1Service response;
                    try {
                        response = coreClient.readNamespacedService(serviceName, jobNamespace).execute();

                    } catch (ApiException e) {
                        // service is gone
                        if (e.getCode() == HTTP_NOT_FOUND) {
                            return Actions.ActionResult.builder().success(true).build();
                        }
                        String errorMsg = e.getResponseBody() != null ? e.getResponseBody() : e.getMessage();
                        return Actions.ActionResult.builder()
                                .success(false)
                                .errorMsg(errorMsg)
                                .build();
                    }
                    return Actions.ActionResult.builder()
                            .success(false)
                            .errorMsg(response.getStatus().toString())
                            .build();
                })
                .build();

        AtomicBoolean success = new AtomicBoolean(false);
        Actions.newBuilder()
                .addAction(deleteService.toBuilder()
                        .continueOn(true)
                        .build())
                .addAction(waitForServiceDeletion.toBuilder()
                        .continueOn(false)
                        .onSuccess(ignored -> success.set(true))
                        .build())
                .addAction(deleteService.toBuilder()
                        .continueOn(true)
                        .build())
                .addAction(waitForServiceDeletion.toBuilder()
                        .onSuccess(ignored -> success.set(true))
                        .build())
                .run();

        if (!success.get()) {
            throw new RuntimeException(String.format("Failed to delete service for function %s", fqfn));
        }
    }

    protected List<String> getExecutorCommand() {
        List<String> cmds =
                new ArrayList<>(getDownloadCommand(instanceConfig.getFunctionDetails(), originalCodeFileName, false));
        if (isNotEmpty(originalTransformFunctionFileName)) {
            cmds.add("&&");
            cmds.addAll(getDownloadCommand(instanceConfig.getFunctionDetails(),
                originalTransformFunctionFileName, true));
        }
        cmds.add("&&");
        cmds.add(setShardIdEnvironmentVariableCommand());
        cmds.add("&&");
        cmds.addAll(processArgs);
        return Arrays.asList("sh", "-c", String.join(" ", cmds));
    }

    private List<String> getDownloadCommand(Function.FunctionDetails functionDetails, String userCodeFilePath,
                                            boolean transformFunction) {
        return getDownloadCommand(functionDetails.getTenant(), functionDetails.getNamespace(),
                functionDetails.getName(), userCodeFilePath, transformFunction);
    }

    private List<String> getDownloadCommand(String tenant, String namespace, String name, String userCodeFilePath,
                                            boolean transformFunction) {
        ArrayList<String> cmd = new ArrayList<>(Arrays.asList(
                pulsarRootDir + configAdminCLI,
                "--admin-url",
                pulsarAdminUrl));

        // add auth plugin and parameters if necessary
        if (authenticationEnabled && authConfig != null) {
            if (isNotBlank(authConfig.getClientAuthenticationPlugin())
                    && isNotBlank(authConfig.getClientAuthenticationParameters())) {
                cmd.addAll(Arrays.asList(
                        "--auth-plugin",
                        authConfig.getClientAuthenticationPlugin(),
                        "--auth-params",
                        authConfig.getClientAuthenticationParameters()));
            }
            if (authConfig.isTlsAllowInsecureConnection()) {
                cmd.add("--tls-allow-insecure");
            }
            if (authConfig.isTlsHostnameVerificationEnable()) {
                cmd.add("--tls-enable-hostname-verification");
            }
            if (isNotBlank(authConfig.getTlsTrustCertsFilePath())) {
                cmd.addAll(Arrays.asList(
                        "--tls-trust-cert-path",
                        authConfig.getTlsTrustCertsFilePath()));
            }
        }

        cmd.addAll(Arrays.asList(
                "functions",
                "download",
                "--tenant",
                tenant,
                "--namespace",
                namespace,
                "--name",
                name,
                "--destination-file",
                userCodeFilePath));

        if (transformFunction) {
            cmd.add("--transform-function");
        }

        return cmd;
    }

    private static String setShardIdEnvironmentVariableCommand() {
        return String.format("%s=${POD_NAME##*-} && echo shardId=${%s}", ENV_SHARD_ID, ENV_SHARD_ID);
    }

    @VisibleForTesting
    V1StatefulSet createStatefulSet() {
        final String jobName = createJobName(instanceConfig.getFunctionDetails(), this.jobName);

        final V1StatefulSet statefulSet = new V1StatefulSet();

        // setup stateful set metadata
        final V1ObjectMeta objectMeta = new V1ObjectMeta();
        objectMeta.name(jobName);
        objectMeta.setLabels(getLabels(instanceConfig.getFunctionDetails()));
        // we don't technically need to set this, but it is useful for testing
        objectMeta.setNamespace(jobNamespace);
        statefulSet.metadata(objectMeta);

        // create the stateful set spec
        final V1StatefulSetSpec statefulSetSpec = new V1StatefulSetSpec();
        statefulSetSpec.serviceName(jobName);
        statefulSetSpec.setReplicas(instanceConfig.getFunctionDetails().getParallelism());

        // Parallel pod management tells the StatefulSet controller to launch or terminate
        // all Pods in parallel, and not to wait for Pods to become Running and Ready or completely
        // terminated prior to launching or terminating another Pod.
        statefulSetSpec.setPodManagementPolicy("Parallel");

        // add selector match labels
        // so the we know which pods to manage
        final V1LabelSelector selector = new V1LabelSelector();
        selector.matchLabels(getLabels(instanceConfig.getFunctionDetails()));
        statefulSetSpec.selector(selector);

        // create a pod template
        final V1PodTemplateSpec podTemplateSpec = new V1PodTemplateSpec();

        // set up pod meta
        final V1ObjectMeta templateMetaData = new V1ObjectMeta().labels(getLabels(instanceConfig.getFunctionDetails()));
        templateMetaData.annotations(getPrometheusAnnotations());
        podTemplateSpec.setMetadata(templateMetaData);

        final List<String> command = getExecutorCommand();
        podTemplateSpec.spec(getPodSpec(command,
                instanceConfig.getFunctionDetails().hasResources() ? instanceConfig.getFunctionDetails().getResources()
                        : null));

        statefulSetSpec.setTemplate(podTemplateSpec);

        statefulSet.spec(statefulSetSpec);

        // let the customizer run but ensure it doesn't change the name so we can find it again
        final V1StatefulSet overridden = manifestCustomizer
                .map(customizer -> customizer.customizeStatefulSet(instanceConfig.getFunctionDetails(), statefulSet))
                .orElse(statefulSet);
        overridden.getMetadata().name(jobName);

        return statefulSet;
    }

    private Map<String, String> getPrometheusAnnotations() {
        if (metricsPort != null) {
            final Map<String, String> annotations = new HashMap<>();
            annotations.put("prometheus.io/scrape", "true");
            annotations.put("prometheus.io/port", String.valueOf(metricsPort));
            return annotations;
        } else {
            return Collections.emptyMap();
        }
    }

    private Map<String, String> getLabels(Function.FunctionDetails functionDetails) {
        final Map<String, String> labels = new HashMap<>();
        Function.FunctionDetails.ComponentType componentType = InstanceUtils.calculateSubjectType(functionDetails);
        String component;
        switch (componentType) {
            case FUNCTION:
                component = "function";
                break;
            case SOURCE:
                component = "source";
                break;
            case SINK:
                component = "sink";
                break;
            default:
                component = "function";
                break;
        }
        labels.put("component", component);
        labels.put("namespace", toValidLabelName(functionDetails.getNamespace()));
        labels.put("tenant", toValidLabelName(functionDetails.getTenant()));
        labels.put("name", toValidLabelName(functionDetails.getName()));
        if (customLabels != null && !customLabels.isEmpty()) {
            customLabels.replaceAll((k, v) -> toValidLabelName(v));
            labels.putAll(customLabels);
        }
        return labels;
    }

    private V1PodSpec getPodSpec(List<String> instanceCommand, Function.Resources resource) {
        final V1PodSpec podSpec = new V1PodSpec();

        // set the termination period to 0 so pods can be deleted quickly
        podSpec.setTerminationGracePeriodSeconds(0L);

        // set the pod tolerations so pods are rescheduled when nodes go down
        // https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/#taint-based-evictions
        podSpec.setTolerations(getTolerations());

        List<V1Container> containers = new LinkedList<>();
        containers.add(getFunctionContainer(instanceCommand, resource));
        podSpec.containers(containers);

        // Configure secrets
        secretsProviderConfigurator.configureKubernetesRuntimeSecretsProvider(podSpec, PULSARFUNCTIONS_CONTAINER_NAME,
                instanceConfig.getFunctionDetails());

        return podSpec;
    }

    private static List<V1Toleration> getTolerations() {
        final List<V1Toleration> tolerations = new ArrayList<>();
        TOLERATIONS.forEach(t -> {
            final V1Toleration toleration =
                    new V1Toleration()
                            .key(t)
                            .operator("Exists")
                            .effect("NoExecute")
                            .tolerationSeconds(10L);
            tolerations.add(toleration);
        });

        return tolerations;
    }

    @VisibleForTesting
    V1Container getFunctionContainer(List<String> instanceCommand, Function.Resources resource) {
        final V1Container container = new V1Container().name(PULSARFUNCTIONS_CONTAINER_NAME);

        Function.FunctionDetails.Runtime runtime = instanceConfig.getFunctionDetails().getRuntime();

        String imageName = null;
        if (functionDockerImages != null) {
            switch (runtime) {
                case JAVA:
                    if (functionDockerImages.get("JAVA") != null) {
                        imageName = functionDockerImages.get("JAVA");
                    }
                    break;
                case PYTHON:
                    if (functionDockerImages.get("PYTHON") != null) {
                        imageName = functionDockerImages.get("PYTHON");
                    }
                    break;
                case GO:
                    if (functionDockerImages.get("GO") != null) {
                        imageName = functionDockerImages.get("GO");
                    }
                    break;
                default:
                    imageName = pulsarDockerImageName;
                    break;
            }
            container.setImage(imageName);
        } else {
            container.setImage(pulsarDockerImageName);
        }

        container.setImagePullPolicy(imagePullPolicy);

        // set up the container command
        container.setCommand(instanceCommand);

        // setup the environment variables for the container
        final V1EnvVar envVarPodName = new V1EnvVar();
        envVarPodName.name("POD_NAME")
                .valueFrom(new V1EnvVarSource()
                        .fieldRef(new V1ObjectFieldSelector()
                                .fieldPath("metadata.name")));
        container.addEnvItem(envVarPodName);

        // set container resources
        final V1ResourceRequirements resourceRequirements = new V1ResourceRequirements();
        final Map<String, Quantity> resourceLimit = new HashMap<>();
        final Map<String, Quantity> resourceRequest = new HashMap<>();


        long ram = resource != null && resource.getRam() != 0 ? resource.getRam() : 1073741824;

        // add memory padding
        long padding = Math.round(ram * (percentMemoryPadding / 100.0));
        long ramWithPadding = ram + padding;
        long ramRequest = (long) (ramWithPadding / memoryOverCommitRatio);

        // set resource limits
        double cpuLimit = resource != null && resource.getCpu() != 0 ? resource.getCpu() : 1;
        // for cpu overcommiting
        double cpuRequest = cpuLimit / cpuOverCommitRatio;

        // round cpu to 3 decimal places as it is the finest cpu precision allowed
        resourceLimit.put("cpu", Quantity.fromString(Double.toString(roundDecimal(cpuLimit, 3))));
        resourceLimit.put("memory", Quantity.fromString(Long.toString(ramWithPadding)));

        // set resource requests
        // round cpu to 3 decimal places as it is the finest cpu precision allowed
        resourceRequest.put("cpu", Quantity.fromString(Double.toString(roundDecimal(cpuRequest, 3))));
        resourceRequest.put("memory", Quantity.fromString(Long.toString(ramRequest)));

        resourceRequirements.setRequests(resourceRequest);
        resourceRequirements.setLimits(resourceLimit);
        container.setResources(resourceRequirements);

        // set container ports
        container.setPorts(getFunctionContainerPorts());

        return container;
    }

    private List<V1ContainerPort> getFunctionContainerPorts() {
        List<V1ContainerPort> ports = new ArrayList<>();
        ports.add(getGRPCPort());
        ports.add(getPrometheusPort());
        return ports;
    }

    private V1ContainerPort getGRPCPort() {
        final V1ContainerPort port = new V1ContainerPort();
        port.setName("grpc");
        port.setContainerPort(grpcPort);
        return port;
    }

    private V1ContainerPort getPrometheusPort() {
        final V1ContainerPort port = new V1ContainerPort();
        port.setName("prometheus");
        port.setContainerPort(metricsPort);
        return port;
    }

    public static String createJobName(Function.FunctionDetails functionDetails, String jobName) {
        return jobName == null ? createJobName(functionDetails.getTenant(),
                functionDetails.getNamespace(), functionDetails.getName()) :
                createJobName(jobName, functionDetails.getTenant(),
                        functionDetails.getNamespace(), functionDetails.getName());
    }

    private static String toValidPodName(String ori) {
        return ori.toLowerCase().replaceAll("[^a-z0-9-\\.]", "-");
    }

    private static String toValidLabelName(String ori) {
        return left(ori.toLowerCase().replaceAll("[^a-zA-Z0-9-_\\.]", "-").replaceAll("^[^a-zA-Z0-9]", "0")
                .replaceAll("[^a-zA-Z0-9]$", "0"), maxLabelSize);
    }

    private static String createJobName(String jobName, String tenant, String namespace, String functionName) {
        final String convertedJobName = toValidPodName(jobName);
        // use of customRuntimeOptions 'jobName' may cause naming collisions,
        // add a short hash here to avoid it
        final String hashName = String.format("%s-%s-%s-%s", jobName, tenant, namespace, functionName);
        final String shortHash = DigestUtils.sha1Hex(hashName).toLowerCase().substring(0, 8);
        return convertedJobName + "-" + shortHash;
    }

    private static String createJobName(String tenant, String namespace, String functionName) {
        final String jobNameBase = String.format("%s-%s-%s", tenant, namespace, functionName);
        final String jobName = "pf-" + jobNameBase;
        final String convertedJobName = toValidPodName(jobName);
        if (jobName.equals(convertedJobName)) {
            return jobName;
        }
        // toValidPodName may cause naming collisions, add a short hash here to avoid it
        final String shortHash = DigestUtils.sha1Hex(jobNameBase).toLowerCase().substring(0, 8);
        return convertedJobName + "-" + shortHash;
    }

    private static String getServiceUrl(String jobName, String jobNamespace, int instanceId) {
        return String.format("%s-%d.%s.%s.svc.cluster.local", jobName, instanceId, jobName, jobNamespace);
    }

    public static void doChecks(Function.FunctionDetails functionDetails, String overridenJobName) {
        final String jobName = createJobName(functionDetails, overridenJobName);
        if (!jobName.equals(jobName.toLowerCase())) {
            throw new RuntimeException("Kubernetes does not allow upper case jobNames.");
        }
        final Matcher matcher = VALID_POD_NAME_REGEX.matcher(jobName);
        if (!matcher.matches()) {
            throw new RuntimeException("Kubernetes only admits lower case and numbers. "
                    + "(jobName=" + jobName + ")");
        }
        if (jobName.length() > maxJobNameSize) {
            throw new RuntimeException("Kubernetes job name size should be less than " + maxJobNameSize);
        }
    }
}
