You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@submarine.apache.org by ji...@apache.org on 2021/01/04 15:44:24 UTC

[submarine] branch master updated: SUBMARINE-701. Support Tensorboard in Experiment

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 0750ffc  SUBMARINE-701. Support Tensorboard in Experiment
0750ffc is described below

commit 0750ffc01bac393fd92c18a54559a57710bf860c
Author: Byron <by...@gmail.com>
AuthorDate: Mon Jan 4 15:54:17 2021 +0800

    SUBMARINE-701. Support Tensorboard in Experiment
    
    ### What is this PR for?
    
    Support new feature in 0.6.0: tensorboard integration.
    
    - Usage
        1. Create a job request that uses tensorboard
        2. Write tensorboard log to `/logs/mylog` (The subpath is required due to this issue in tensorflow [https://github.com/kubeflow/tf-operator/issues/1053](https://github.com/kubeflow/tf-operator/issues/1053). We cannot directly write log file to mountPath.)
        3. Link to `http://<host>:<ip>/tfboard-${job-name}`, and you can monitor the tensorboard with ease!
    - Implementation
    
        When creating a new job, the backend will not only create original experiment but also several k8s resources required in tensorboard
    
        The resources can be classified into two categories:
    
        1. Storage
        2. Tensorboard serving
    
        **Storage**
    
        The resources required for storage are **persistent volume** and **persistent volume claim**.
    
        I set the storage path of persistent volume on host path, and mount this path to MLjob (enable job to generate logs to volume) and Tensorboard (enable tfboard to access logs).
    
        **Tensorboard Serving**
    
        The resources required here are **deployments, service, and ingressroute**.
    
        I create the tensorboard apps with deployments and service, and then redirect it to custom path with the help of ingressroute.
    
    - Example
        - tensorboard-example.json
    
            ```bash
            {
              "meta": {
                "name": "tensorflow-dist-mnist-byron-1234",
                "namespace": "default",
                "framework": "TensorFlow",
                "cmd": "python /var/tf_mnist/mnist_with_summaries.py --log_dir=/logs/mylog --learning_rate=0.01 --batch_size=20",
                "envVars": {
                  "ENV_1": "ENV1"
                }
              },
              "environment": {
                "image": "apache/submarine:tf-mnist-with-summaries-1.0"
              },
              "spec": {
                "Worker": {
                  "replicas": 1,
                  "resources": "cpu=1,memory=1024M"
                }
              }
            }
            ```
    
            ![Kapture 2020-12-27 at 18 04 40](https://user-images.githubusercontent.com/24364830/103168607-926b3000-486f-11eb-9f73-ecfcf71625a1.gif)
    
    ### What type of PR is it?
    [Feature]
    
    ### Todos
    - [ ] Frontend support
    - [ ] The logs of job cannot be written directly on the mountPath (As describe in above). We should fix this problem.
    - [ ] Make log path configurable (Currently, it is hard-coded as `/logs` )
    - [ ] Support smb-server for shared storage
    
    ### What is the Jira issue?
    https://issues.apache.org/jira/projects/SUBMARINE/issues/SUBMARINE-701
    
    ### How should this be tested?
    https://travis-ci.org/github/ByronHsu/submarine/jobs/751658488
    
    ### Questions:
    * Does the licenses files need update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    Author: Byron <by...@gmail.com>
    Author: ByronHsu <by...@gmail.com>
    
    Closes #484 from ByronHsu/SUBMARINE-701 and squashes the following commits:
    
    9ecb04f [Byron] add timeout between each testcase
    8168cc2 [Byron] convert to lower camel case
    f6028a4 [Byron] change variable in spec parser to lowercamelcase
    3d668c5 [Byron] update
    2bf9696 [Byron] make error meesage more understandable
    961621b [ByronHsu] Update submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java
    6e4c077 [ByronHsu] Update submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java
    64e5c4e [ByronHsu] Update submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java
    6b2f604 [ByronHsu] Update submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java
    1c1258f [ByronHsu] Update submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java
    29af97a [Byron] update rbac
    b58cccb [Byron] update rbac
    2d8940b [Byron] add license to new files
    8a386d1 [Byron] tensorboard works fine in server
    e4c82fb [Byron] update k8sSubmitterTest
    8014ea5 [Byron] test fine with k8sjobsubmittertest
    8a8080a [Byron] add tensorboardspec parser
    25a694b [Byron] add volumeSpecParser & tfb constants
---
 helm-charts/submarine/templates/rbac.yaml          |   9 +
 .../manifests/submarine-cluster/rbac.yaml          |   8 +-
 .../server/api/experiment/Experiment.java          |   9 +
 .../submarine/server/api/spec/ExperimentMeta.java  |  11 ++
 .../submarine/server/api/spec/ExperimentSpec.java  |  10 ++
 .../apache/submarine/server/SubmarineServer.java   |   2 +-
 .../server/experiment/ExperimentManager.java       |   6 +
 .../server/submitter/k8s/K8sSubmitter.java         | 186 ++++++++++++++++++++-
 .../k8s/model/ingressroute/SpecRoute.java          |  11 ++
 .../k8s/model/middlewares/Middlewares.java         | 117 +++++++++++++
 .../MiddlewaresSpec.java}                          |  42 +----
 .../submitter/k8s/parser/ExperimentSpecParser.java |  21 ++-
 .../k8s/parser/TensorboardSpecParser.java          | 141 ++++++++++++++++
 .../submitter/k8s/parser/VolumeSpecParser.java     |  85 ++++++++++
 .../submitter/k8s/util/TensorboardUtils.java       |  38 +++++
 .../server/submitter/k8s/K8SJobSubmitterTest.java  |  16 +-
 .../server/submitter/k8s/SpecBuilder.java          |   1 +
 .../k8s/parser/TensorboardSpecParserTest.java      |  80 +++++++++
 .../submitter/k8s/parser/VolumeSpecParserTest.java |  64 +++++++
 .../src/test/resources/tf_tfboard_mnist_req.json   |  20 +++
 .../apache/submarine/rest/ExperimentRestApiIT.java |  14 +-
 21 files changed, 845 insertions(+), 46 deletions(-)

diff --git a/helm-charts/submarine/templates/rbac.yaml b/helm-charts/submarine/templates/rbac.yaml
index acad2e1..bed8c19 100644
--- a/helm-charts/submarine/templates/rbac.yaml
+++ b/helm-charts/submarine/templates/rbac.yaml
@@ -56,6 +56,15 @@ rules:
   resources:
   - pods
   - pods/log
+  - services
+  - persistentvolumes
+  - persistentvolumeclaims
+  verbs:
+  - '*'
+- apiGroups:
+  - "apps"
+  resources:
+  - deployments
   verbs:
   - '*'
 ---
diff --git a/submarine-cloud/manifests/submarine-cluster/rbac.yaml b/submarine-cloud/manifests/submarine-cluster/rbac.yaml
index 62931d4..6232d3b 100644
--- a/submarine-cloud/manifests/submarine-cluster/rbac.yaml
+++ b/submarine-cloud/manifests/submarine-cluster/rbac.yaml
@@ -28,7 +28,13 @@ items:
           - services
           - endpoints
           - pods
-        verbs: ["list", "get"]
+          - persistentvolumes
+          - persistentvolumeclaims
+        verbs: ["*"]
+      - apiGroups: ["apps"]
+        resources:
+          - deployments
+        verbs: ["*"]
       - apiGroups: ["kubeflow.org"]
         resources:
           - tfjobs
diff --git a/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/experiment/Experiment.java b/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/experiment/Experiment.java
index 2f14460..932b1a6 100644
--- a/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/experiment/Experiment.java
+++ b/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/experiment/Experiment.java
@@ -34,6 +34,7 @@ public class Experiment {
   private String runningTime;
   private String finishedTime;
   private ExperimentSpec spec;
+  private String tfboardURL;
 
   /**
    * Get the job id which is unique in submarine
@@ -135,6 +136,14 @@ public class Experiment {
     this.spec = spec;
   }
 
+  public String getTfboardURL() {
+    return tfboardURL;
+  }
+
+  public void setTfboardURL(String tfboardURL) {
+    this.tfboardURL = tfboardURL;
+  }
+
   public enum Status {
     STATUS_ACCEPTED("Accepted"),
     STATUS_CREATED("Created"),
diff --git a/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/spec/ExperimentMeta.java b/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/spec/ExperimentMeta.java
index de98fd7..d682740 100644
--- a/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/spec/ExperimentMeta.java
+++ b/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/spec/ExperimentMeta.java
@@ -131,4 +131,15 @@ public class ExperimentMeta {
       return names;
     }
   }
+
+  @Override
+  public String toString() {
+    return "ExperimentMeta{" +
+      "name='" + name + '\'' +
+      ", namespace='" + namespace + '\'' +
+      ", framework='" + framework + '\'' +
+      ", cmd='" + cmd + '\'' +
+      ", envVars=" + envVars +
+      '}';
+  }
 }
diff --git a/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/spec/ExperimentSpec.java b/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/spec/ExperimentSpec.java
index 5e8b23d..b0c283a 100644
--- a/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/spec/ExperimentSpec.java
+++ b/submarine-server/server-api/src/main/java/org/apache/submarine/server/api/spec/ExperimentSpec.java
@@ -63,4 +63,14 @@ public class ExperimentSpec {
   public void setCode(CodeSpec code) {
     this.code = code;
   }
+
+  @Override
+  public String toString() {
+    return "ExperimentSpec{" +
+      "meta=" + meta +
+      ", environment=" + environment +
+      ", spec=" + spec +
+      ", code=" + code +
+      '}';
+  }
 }
diff --git a/submarine-server/server-core/src/main/java/org/apache/submarine/server/SubmarineServer.java b/submarine-server/server-core/src/main/java/org/apache/submarine/server/SubmarineServer.java
index f955eeb..43ccbf9 100644
--- a/submarine-server/server-core/src/main/java/org/apache/submarine/server/SubmarineServer.java
+++ b/submarine-server/server-core/src/main/java/org/apache/submarine/server/SubmarineServer.java
@@ -307,7 +307,7 @@ public class SubmarineServer extends ResourceConfig {
 
     @Override
     protected void doGet(HttpServletRequest request, HttpServletResponse response)
-      throws ServletException, IOException {
+        throws ServletException, IOException {
       response.setContentType("text/html");
       response.encodeRedirectURL("/");
       response.setStatus(HttpServletResponse.SC_OK);
diff --git a/submarine-server/server-core/src/main/java/org/apache/submarine/server/experiment/ExperimentManager.java b/submarine-server/server-core/src/main/java/org/apache/submarine/server/experiment/ExperimentManager.java
index 6e38429..6da31db 100644
--- a/submarine-server/server-core/src/main/java/org/apache/submarine/server/experiment/ExperimentManager.java
+++ b/submarine-server/server-core/src/main/java/org/apache/submarine/server/experiment/ExperimentManager.java
@@ -101,6 +101,12 @@ public class ExperimentManager {
 
     Experiment experiment = submitter.createExperiment(spec);
     experiment.setExperimentId(id);
+    /*
+    TODO(byronhsu) Importing tensorboardUtils will
+    cause a dependency circle. Hard-code it as a temporary solution
+     */
+
+    experiment.setTfboardURL("/tfboard-" + spec.getMeta().getName() + "/");
 
     spec.getMeta().getEnvVars().remove(RestConstants.JOB_ID);
     spec.getMeta().getEnvVars().remove(RestConstants.SUBMARINE_TRACKING_URI);
diff --git a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java
index a97aa63..5521f99 100644
--- a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java
+++ b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java
@@ -23,22 +23,26 @@ import java.io.FileReader;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.List;
-
 import com.google.gson.Gson;
 import com.google.gson.JsonSyntaxException;
 import io.kubernetes.client.ApiClient;
 import io.kubernetes.client.ApiException;
 import io.kubernetes.client.Configuration;
 import io.kubernetes.client.JSON;
+import io.kubernetes.client.apis.AppsV1Api;
 import io.kubernetes.client.apis.CoreV1Api;
 import io.kubernetes.client.apis.CustomObjectsApi;
 import io.kubernetes.client.models.V1DeleteOptionsBuilder;
+import io.kubernetes.client.models.V1Deployment;
 import io.kubernetes.client.models.V1ObjectMeta;
+import io.kubernetes.client.models.V1PersistentVolume;
+import io.kubernetes.client.models.V1PersistentVolumeClaim;
 import io.kubernetes.client.models.V1Pod;
 import io.kubernetes.client.models.V1PodList;
+import io.kubernetes.client.models.V1Service;
 import io.kubernetes.client.models.V1Status;
 import io.kubernetes.client.util.ClientBuilder;
 import io.kubernetes.client.util.KubeConfig;
@@ -59,8 +63,11 @@ import org.apache.submarine.server.submitter.k8s.model.ingressroute.IngressRoute
 import org.apache.submarine.server.submitter.k8s.model.ingressroute.SpecRoute;
 import org.apache.submarine.server.submitter.k8s.parser.ExperimentSpecParser;
 import org.apache.submarine.server.submitter.k8s.parser.NotebookSpecParser;
+import org.apache.submarine.server.submitter.k8s.parser.TensorboardSpecParser;
+import org.apache.submarine.server.submitter.k8s.parser.VolumeSpecParser;
 import org.apache.submarine.server.submitter.k8s.util.MLJobConverter;
 import org.apache.submarine.server.submitter.k8s.util.NotebookUtils;
+import org.apache.submarine.server.submitter.k8s.util.TensorboardUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -81,11 +88,14 @@ public class K8sSubmitter implements Submitter {
 
   private CoreV1Api coreApi;
 
-  public K8sSubmitter() {}
+  private AppsV1Api appsV1Api;
+
+  public K8sSubmitter() { }
 
   @Override
   public void initialize(SubmarineConfiguration conf) {
     ApiClient client = null;
+
     try {
       String path = System.getenv(KUBECONFIG_ENV);
       KubeConfig config = KubeConfig.loadKubeConfig(new FileReader(path));
@@ -108,13 +118,25 @@ public class K8sSubmitter implements Submitter {
     if (coreApi == null) {
       coreApi = new CoreV1Api(client);
     }
+    if (appsV1Api == null) {
+      appsV1Api = new AppsV1Api();
+    }
+
+    client.setDebugging(true);
   }
 
   @Override
   public Experiment createExperiment(ExperimentSpec spec) throws SubmarineRuntimeException {
     Experiment experiment;
+    final String name = spec.getMeta().getName();
+
     try {
       MLJob mlJob = ExperimentSpecParser.parseJob(spec);
+
+      createTFBoardPersistentVolume(name);
+      createTFBoardPersistentVolumeClaim(name, spec.getMeta().getNamespace());
+      createTFBoard(name, spec.getMeta().getNamespace());
+
       Object object = api.createNamespacedCustomObject(mlJob.getGroup(), mlJob.getVersion(),
           mlJob.getMetadata().getNamespace(), mlJob.getPlural(), mlJob, "true");
       experiment = parseExperimentResponseObject(object, ParseOp.PARSE_OP_RESULT);
@@ -165,8 +187,15 @@ public class K8sSubmitter implements Submitter {
   @Override
   public Experiment deleteExperiment(ExperimentSpec spec) throws SubmarineRuntimeException {
     Experiment experiment;
+    final String name = spec.getMeta().getName(); // spec.getMeta().getEnvVars().get(RestConstants.JOB_ID);
+
     try {
       MLJob mlJob = ExperimentSpecParser.parseJob(spec);
+
+      deleteTFBoardPersistentVolume(name);
+      deleteTFBoardPersistentVolumeClaim(name, spec.getMeta().getNamespace());
+      deleteTFBoard(name, spec.getMeta().getNamespace());
+
       Object object = api.deleteNamespacedCustomObject(mlJob.getGroup(), mlJob.getVersion(),
           mlJob.getMetadata().getNamespace(), mlJob.getPlural(), mlJob.getMetadata().getName(),
           MLJobConverter.toDeleteOptionsFromMLJob(mlJob), null, null, null);
@@ -320,6 +349,157 @@ public class K8sSubmitter implements Submitter {
     return notebookList;
   }
 
+  public void createTFBoard(String name, String namespace) throws ApiException {
+    final String deployName = TensorboardUtils.DEPLOY_PREFIX + name;
+    final String podName = TensorboardUtils.POD_PREFIX + name;
+    final String svcName = TensorboardUtils.SVC_PREFIX + name;
+    final String ingressName = TensorboardUtils.INGRESS_PREFIX + name;
+
+    final String image = TensorboardUtils.IMAGE_NAME;
+    final String routePath = TensorboardUtils.PATH_PREFIX + name;
+    final String pvc = TensorboardUtils.PVC_PREFIX + name;
+
+    V1Deployment deployment = TensorboardSpecParser.parseDeployment(deployName, image, routePath, pvc);
+    V1Service svc = TensorboardSpecParser.parseService(svcName, podName);
+    IngressRoute ingressRoute = TensorboardSpecParser.parseIngressRoute(
+        ingressName, namespace, routePath, svcName
+    );
+
+    try {
+      appsV1Api.createNamespacedDeployment(namespace, deployment, "true", null, null);
+      coreApi.createNamespacedService(namespace, svc, "true", null, null);
+      api.createNamespacedCustomObject(
+            ingressRoute.getGroup(), ingressRoute.getVersion(),
+            ingressRoute.getMetadata().getNamespace(),
+            ingressRoute.getPlural(), ingressRoute, "true");
+    } catch (ApiException e) {
+      LOG.error("Exception when creating TensorBoard " + e.getMessage(), e);
+      throw e;
+    }
+  }
+
+  public void deleteTFBoard(String name, String namespace) throws ApiException {
+    final String deployName = TensorboardUtils.DEPLOY_PREFIX + name;
+    final String podName = TensorboardUtils.POD_PREFIX + name;
+    final String svcName = TensorboardUtils.SVC_PREFIX + name;
+    final String ingressName = TensorboardUtils.INGRESS_PREFIX + name;
+
+    final String image = TensorboardUtils.IMAGE_NAME;
+    final String routePath = TensorboardUtils.PATH_PREFIX + name;
+    final String pvc = TensorboardUtils.PVC_PREFIX + name;
+
+    V1Deployment deployment = TensorboardSpecParser.parseDeployment(deployName, image, routePath, pvc);
+    V1Service svc = TensorboardSpecParser.parseService(svcName, podName);
+    IngressRoute ingressRoute = TensorboardSpecParser.parseIngressRoute(
+        ingressName, namespace, routePath, svcName
+    );
+
+    try {
+      appsV1Api.deleteNamespacedDeployment(deployName, namespace, "true",
+          null, null, null, null, null);
+      coreApi.deleteNamespacedService(svcName, namespace, "true",
+          null, null, null, null, null);
+      api.deleteNamespacedCustomObject(
+          ingressRoute.getGroup(), ingressRoute.getVersion(),
+          ingressRoute.getMetadata().getNamespace(), ingressRoute.getPlural(), ingressName,
+          new V1DeleteOptionsBuilder().withApiVersion(ingressRoute.getApiVersion()).build(),
+          null, null, null);
+
+    } catch (ApiException e) {
+      LOG.error("Exception when deleting TensorBoard " + e.getMessage(), e);
+      throw e;
+    }
+  }
+
+  public void createTFBoardPersistentVolume(String name) throws ApiException {
+    final String pvName = TensorboardUtils.PV_PREFIX + name;
+    final String hostPath = TensorboardUtils.HOST_PREFIX + name;
+    final String storage = TensorboardUtils.STORAGE;
+
+    V1PersistentVolume pv = VolumeSpecParser.parsePersistentVolume(pvName, hostPath, storage);
+
+    try {
+      V1PersistentVolume result = coreApi.createPersistentVolume(pv, "true", null, null);
+    } catch (ApiException e) {
+      LOG.error("Exception when creating persistent volume " + e.getMessage(), e);
+      throw e;
+    }
+  }
+
+  public void deleteTFBoardPersistentVolume(String name) throws ApiException {
+    /*
+    This version of Kubernetes-client/java has bug here.
+    It will trigger exception as in https://github.com/kubernetes-client/java/issues/86
+    but it can still work fine and delete the PV.
+    */
+    final String pvName = TensorboardUtils.PV_PREFIX + name;
+
+    try {
+      V1Status result = coreApi.deletePersistentVolume(
+              pvName, "true", null,
+              null, null, null, null
+      );
+    } catch (ApiException e) {
+      LOG.error("Exception when deleting persistent volume " + e.getMessage(), e);
+      throw e;
+    } catch (JsonSyntaxException e) {
+      if (e.getCause() instanceof IllegalStateException) {
+        IllegalStateException ise = (IllegalStateException) e.getCause();
+        if (ise.getMessage() != null && ise.getMessage().contains("Expected a string but was BEGIN_OBJECT"))
+          LOG.debug("Catching exception because of issue " +
+            "https://github.com/kubernetes-client/java/issues/86", e);
+        else throw e;
+      }
+      else throw e;
+    }
+  }
+
+  public void createTFBoardPersistentVolumeClaim(String name, String namespace) throws ApiException {
+    final String pvcName = TensorboardUtils.PVC_PREFIX + name;
+    final String volume = TensorboardUtils.PV_PREFIX + name;
+    final String storage = TensorboardUtils.STORAGE;
+
+    V1PersistentVolumeClaim pvc = VolumeSpecParser.parsePersistentVolumeClaim(pvcName, volume, storage);
+
+    try {
+      V1PersistentVolumeClaim result = coreApi.createNamespacedPersistentVolumeClaim(
+              namespace, pvc, "true", null, null
+      );
+    } catch (ApiException e) {
+      LOG.error("Exception when creating persistent volume claim " + e.getMessage(), e);
+      throw e;
+    }
+  }
+
+  public void deleteTFBoardPersistentVolumeClaim(String name, String namespace) throws ApiException {
+    /*
+    This version of Kubernetes-client/java has bug here.
+    It will trigger exception as in https://github.com/kubernetes-client/java/issues/86
+    but it can still work fine and delete the PVC
+    */
+    final String pvcName = TensorboardUtils.PVC_PREFIX + name;
+
+    try {
+      V1Status result = coreApi.deleteNamespacedPersistentVolumeClaim(
+                pvcName, namespace, "true",
+          null, null, null,
+            null, null
+      );
+    } catch (ApiException e) {
+      LOG.error("Exception when deleting persistent volume claim " + e.getMessage(), e);
+      throw e;
+    } catch (JsonSyntaxException e) {
+      if (e.getCause() instanceof IllegalStateException) {
+        IllegalStateException ise = (IllegalStateException) e.getCause();
+        if (ise.getMessage() != null && ise.getMessage().contains("Expected a string but was BEGIN_OBJECT"))
+          LOG.debug("Catching exception because of issue " +
+            "https://github.com/kubernetes-client/java/issues/86", e);
+        else throw e;
+      }
+      else throw e;
+    }
+  }
+
   private String getJobLabelSelector(ExperimentSpec experimentSpec) {
     // TODO(JohnTing): SELECTOR_KEY should be obtained from individual models in MLJOB
     if (experimentSpec.getMeta().getFramework()
diff --git a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/ingressroute/SpecRoute.java b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/ingressroute/SpecRoute.java
index d6208e7..f00e02e 100644
--- a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/ingressroute/SpecRoute.java
+++ b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/ingressroute/SpecRoute.java
@@ -39,6 +39,9 @@ public class SpecRoute {
   @SerializedName("services")
   private Set<Map<String, Object>> services;
 
+  @SerializedName("middlewares")
+  private Set<Map<String, String>> middlewares;
+
   public String getMatch() {
     return match;
   }
@@ -62,4 +65,12 @@ public class SpecRoute {
   public void setServices(Set<Map<String, Object>> services) {
     this.services = services;
   }
+
+  public Set<Map<String, String>> getMiddlewares() {
+    return middlewares;
+  }
+
+  public void setMiddlewares(Set<Map<String, String>> middlewares) {
+    this.middlewares = middlewares;
+  }
 }
diff --git a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/middlewares/Middlewares.java b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/middlewares/Middlewares.java
new file mode 100644
index 0000000..1736206
--- /dev/null
+++ b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/middlewares/Middlewares.java
@@ -0,0 +1,117 @@
+/*
+ * 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.submarine.server.submitter.k8s.model.middlewares;
+
+import com.google.gson.annotations.SerializedName;
+import io.kubernetes.client.models.V1ObjectMeta;
+
+public class Middlewares {
+  // reference: https://doc.traefik.io/traefik/reference/dynamic-configuration/kubernetes-crd/#definitions
+
+  public static final String CRD_MIDDLEWARES_GROUP_V1 = "traefik.containo.us";
+  public static final String CRD_MIDDLEWARES_VERSION_V1 = "v1alpha1";
+  public static final String CRD_APIVERSION_V1 = CRD_MIDDLEWARES_GROUP_V1 +
+      "/" + CRD_MIDDLEWARES_VERSION_V1;
+  public static final String CRD_MIDDLEWARES_KIND_V1 = "Middleware";
+  public static final String CRD_MIDDLEWARES_PLURAL_V1 = "middlewares";
+
+  @SerializedName("apiVersion")
+  private String apiVersion;
+
+  @SerializedName("kind")
+  private String kind;
+
+  @SerializedName("metadata")
+  private V1ObjectMeta metedata;
+
+  @SerializedName("spec")
+  private MiddlewaresSpec spec;
+
+  // transient to avoid being serialized
+  private transient String group;
+
+  private transient String version;
+
+  private transient String plural;
+
+  public Middlewares() {
+    setApiVersion(CRD_APIVERSION_V1);
+    setKind(CRD_MIDDLEWARES_KIND_V1);
+    setPlural(CRD_MIDDLEWARES_PLURAL_V1);
+    setGroup(CRD_MIDDLEWARES_GROUP_V1);
+    setVersion(CRD_MIDDLEWARES_VERSION_V1);
+  }
+
+  public String getApiVersion() {
+    return apiVersion;
+  }
+
+  public void setApiVersion(String apiVersion) {
+    this.apiVersion = apiVersion;
+  }
+
+  public String getKind() {
+    return kind;
+  }
+
+  public void setKind(String kind) {
+    this.kind = kind;
+  }
+
+  public V1ObjectMeta getMetedata() {
+    return metedata;
+  }
+
+  public void setMetedata(V1ObjectMeta metedata) {
+    this.metedata = metedata;
+  }
+
+  public MiddlewaresSpec getSpec() {
+    return spec;
+  }
+
+  public void setSpec(MiddlewaresSpec spec) {
+    this.spec = spec;
+  }
+
+  public String getGroup() {
+    return group;
+  }
+
+  public void setGroup(String group) {
+    this.group = group;
+  }
+
+  public String getVersion() {
+    return version;
+  }
+
+  public void setVersion(String version) {
+    this.version = version;
+  }
+
+  public String getPlural() {
+    return plural;
+  }
+
+  public void setPlural(String plural) {
+    this.plural = plural;
+  }
+}
diff --git a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/ingressroute/SpecRoute.java b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/middlewares/MiddlewaresSpec.java
similarity index 55%
copy from submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/ingressroute/SpecRoute.java
copy to submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/middlewares/MiddlewaresSpec.java
index d6208e7..4f435ac 100644
--- a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/ingressroute/SpecRoute.java
+++ b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/model/middlewares/MiddlewaresSpec.java
@@ -17,49 +17,25 @@
  * under the License.
  */
 
-package org.apache.submarine.server.submitter.k8s.model.ingressroute;
+package org.apache.submarine.server.submitter.k8s.model.middlewares;
 
 import com.google.gson.annotations.SerializedName;
 
 import java.util.Map;
-import java.util.Set;
 
-public class SpecRoute {
+public class MiddlewaresSpec {
 
-  public SpecRoute() {
+  @SerializedName("replacePathRegex")
+  private Map<String, String> replacePathRegex;
 
+  public MiddlewaresSpec() {
   }
 
-  @SerializedName("match")
-  private String match;
-
-  @SerializedName("kind")
-  private String kind;
-
-  @SerializedName("services")
-  private Set<Map<String, Object>> services;
-
-  public String getMatch() {
-    return match;
-  }
-
-  public void setMatch(String match) {
-    this.match = match;
-  }
-
-  public String getKind() {
-    return kind;
-  }
-
-  public void setKind(String kind) {
-    this.kind = kind;
-  }
-
-  public Set<Map<String, Object>> getServices() {
-    return services;
+  public Map<String, String> getReplacePathRegex() {
+    return replacePathRegex;
   }
 
-  public void setServices(Set<Map<String, Object>> services) {
-    this.services = services;
+  public void setReplacePathRegex(Map<String, String> replacePathRegex) {
+    this.replacePathRegex = replacePathRegex;
   }
 }
diff --git a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/ExperimentSpecParser.java b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/ExperimentSpecParser.java
index e4190b7..29e3b0b 100644
--- a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/ExperimentSpecParser.java
+++ b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/ExperimentSpecParser.java
@@ -20,9 +20,11 @@
 package org.apache.submarine.server.submitter.k8s.parser;
 
 import io.kubernetes.client.custom.Quantity;
+
 import io.kubernetes.client.models.V1Container;
 import io.kubernetes.client.models.V1EnvVar;
 import io.kubernetes.client.models.V1ObjectMeta;
+import io.kubernetes.client.models.V1PersistentVolumeClaimVolumeSource;
 import io.kubernetes.client.models.V1PodSecurityContext;
 import io.kubernetes.client.models.V1PodSpec;
 import io.kubernetes.client.models.V1PodTemplateSpec;
@@ -30,7 +32,6 @@ import io.kubernetes.client.models.V1ResourceRequirements;
 import io.kubernetes.client.models.V1SecretVolumeSource;
 import io.kubernetes.client.models.V1Volume;
 import io.kubernetes.client.models.V1VolumeMount;
-
 import org.apache.submarine.commons.utils.SubmarineConfVars;
 import org.apache.submarine.commons.utils.SubmarineConfiguration;
 import org.apache.submarine.server.api.environment.Environment;
@@ -52,6 +53,7 @@ import org.apache.submarine.server.submitter.k8s.model.pytorchjob.PyTorchJobSpec
 import org.apache.submarine.server.submitter.k8s.model.tfjob.TFJob;
 import org.apache.submarine.server.submitter.k8s.model.tfjob.TFJobReplicaType;
 import org.apache.submarine.server.submitter.k8s.model.tfjob.TFJobSpec;
+import org.apache.submarine.server.submitter.k8s.util.TensorboardUtils;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -156,6 +158,7 @@ public class ExperimentSpecParser {
     V1Container container = new V1Container();
     container.setName(experimentSpec.getMeta().getFramework().toLowerCase());
     // image
+
     if (taskSpec.getImage() != null) {
       container.setImage(taskSpec.getImage());
     } else {
@@ -174,7 +177,17 @@ public class ExperimentSpecParser {
     resources.setLimits(parseResources(taskSpec));
     container.setResources(resources);
     container.setEnv(parseEnvVars(taskSpec, experimentSpec.getMeta().getEnvVars()));
-    
+
+    // volumeMount
+    container.addVolumeMountsItem(new V1VolumeMount().mountPath("/logs").name("volume"));
+
+    // volume
+    final String name = experimentSpec.getMeta().getName();
+    V1Volume podVolume = new V1Volume().name("volume");
+    podVolume.setPersistentVolumeClaim(
+          new V1PersistentVolumeClaimVolumeSource().claimName(TensorboardUtils.PVC_PREFIX + name)
+    );
+    podSpec.addVolumesItem(podVolume);
     /**
      * Init Git localize Container
      */
@@ -221,7 +234,7 @@ public class ExperimentSpecParser {
             podSpec.setSecurityContext(podSecurityContext);
           }
         }
-        
+
         V1EnvVar codeEnvVar = new V1EnvVar();
         codeEnvVar.setName(AbstractCodeLocalizer.CODE_LOCALIZER_PATH_ENV_VAR);
         codeEnvVar.setValue(AbstractCodeLocalizer.CODE_LOCALIZER_PATH);
@@ -233,7 +246,7 @@ public class ExperimentSpecParser {
 
     containers.add(container);
     podSpec.setContainers(containers);
-      
+
     /**
      * Init Containers
      */
diff --git a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/TensorboardSpecParser.java b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/TensorboardSpecParser.java
new file mode 100644
index 0000000..dfba33f
--- /dev/null
+++ b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/TensorboardSpecParser.java
@@ -0,0 +1,141 @@
+/*
+ * 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.submarine.server.submitter.k8s.parser;
+
+import io.kubernetes.client.custom.IntOrString;
+import io.kubernetes.client.models.V1Container;
+import io.kubernetes.client.models.V1ContainerPort;
+import io.kubernetes.client.models.V1Deployment;
+import io.kubernetes.client.models.V1DeploymentSpec;
+import io.kubernetes.client.models.V1LabelSelector;
+import io.kubernetes.client.models.V1ObjectMeta;
+import io.kubernetes.client.models.V1PersistentVolumeClaimVolumeSource;
+import io.kubernetes.client.models.V1PodSpec;
+import io.kubernetes.client.models.V1PodTemplateSpec;
+import io.kubernetes.client.models.V1Service;
+import io.kubernetes.client.models.V1ServicePort;
+import io.kubernetes.client.models.V1ServiceSpec;
+import io.kubernetes.client.models.V1Volume;
+import io.kubernetes.client.models.V1VolumeMount;
+import org.apache.submarine.server.submitter.k8s.model.ingressroute.IngressRoute;
+import org.apache.submarine.server.submitter.k8s.model.ingressroute.IngressRouteSpec;
+import org.apache.submarine.server.submitter.k8s.model.ingressroute.SpecRoute;
+import org.apache.submarine.server.submitter.k8s.util.TensorboardUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+
+public class TensorboardSpecParser {
+  public static V1Deployment parseDeployment(String name, String image, String routePath, String pvc) {
+
+    V1Deployment deployment = new V1Deployment();
+
+    V1ObjectMeta deploymentMetedata = new V1ObjectMeta();
+    deploymentMetedata.setName(name);
+    deployment.setMetadata(deploymentMetedata);
+    V1DeploymentSpec deploymentSpec = new V1DeploymentSpec();
+    deploymentSpec.setSelector(
+        new V1LabelSelector().matchLabels(Collections.singletonMap("app", name)) // match the template
+    );
+
+    V1PodTemplateSpec deploymentTemplateSpec = new V1PodTemplateSpec();
+    deploymentTemplateSpec.setMetadata(
+        new V1ObjectMeta().labels(Collections.singletonMap("app", name)) // bind to replicaset and service
+    );
+
+    V1PodSpec deploymentTemplatePodSpec = new V1PodSpec();
+
+    V1Container container = new V1Container();
+    container.setName(name);
+    container.setImage(image);
+    container.setCommand(Arrays.asList(
+        "tensorboard", "--logdir=/logs",
+        String.format("--path_prefix=%s", routePath)
+    ));
+    container.setImagePullPolicy("IfNotPresent");
+    container.addPortsItem(new V1ContainerPort().containerPort(TensorboardUtils.DEFAULT_TENSORBOARD_PORT));
+    container.addVolumeMountsItem(new V1VolumeMount().mountPath("/logs").name("volume"));
+    deploymentTemplatePodSpec.addContainersItem(container);
+
+    V1Volume volume = new V1Volume().name("volume");
+    volume.setPersistentVolumeClaim(
+        new V1PersistentVolumeClaimVolumeSource().claimName(pvc)
+    );
+    deploymentTemplatePodSpec.addVolumesItem(volume);
+
+    deploymentTemplateSpec.setSpec(deploymentTemplatePodSpec);
+
+    deploymentSpec.setTemplate(deploymentTemplateSpec);
+
+    deployment.setSpec(deploymentSpec);
+
+    return deployment;
+  }
+
+  public static V1Service parseService(String svcName, String podName) {
+    V1Service svc = new V1Service();
+    svc.metadata(new V1ObjectMeta().name(svcName));
+
+    V1ServiceSpec svcSpec = new V1ServiceSpec();
+    svcSpec.setSelector(Collections.singletonMap("app", podName)); // bind to pod
+    svcSpec.addPortsItem(new V1ServicePort().protocol("TCP").targetPort(
+        new IntOrString(TensorboardUtils.DEFAULT_TENSORBOARD_PORT)).port(TensorboardUtils.SERVICE_PORT));
+    svc.setSpec(svcSpec);
+    return svc;
+  }
+
+  public static IngressRoute parseIngressRoute(String ingressName, String namespace,
+                                               String routePath, String svcName) {
+
+    IngressRoute ingressRoute = new IngressRoute();
+    ingressRoute.setMetadata(
+        new V1ObjectMeta().name(ingressName).namespace((namespace))
+    );
+
+    IngressRouteSpec ingressRouteSpec = new IngressRouteSpec();
+    ingressRouteSpec.setEntryPoints(new HashSet<>(Collections.singletonList("web")));
+    SpecRoute specRoute = new SpecRoute();
+    specRoute.setKind("Rule");
+    specRoute.setMatch(String.format("PathPrefix(`%s`)", routePath));
+
+    Map<String, Object> service = new HashMap<String, Object>() {{
+        put("kind", "Service");
+        put("name", svcName);
+        put("port", TensorboardUtils.SERVICE_PORT);
+        put("namespace", namespace);
+      }};
+
+    specRoute.setServices(new HashSet<Map<String, Object>>() {{
+        add(service);
+      }});
+
+    ingressRouteSpec.setRoutes(new HashSet<SpecRoute>() {{
+        add(specRoute);
+      }});
+
+    ingressRoute.setSpec(ingressRouteSpec);
+
+    return ingressRoute;
+  }
+
+}
diff --git a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/VolumeSpecParser.java b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/VolumeSpecParser.java
new file mode 100644
index 0000000..6621c67
--- /dev/null
+++ b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/parser/VolumeSpecParser.java
@@ -0,0 +1,85 @@
+/*
+ * 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.submarine.server.submitter.k8s.parser;
+
+import io.kubernetes.client.custom.Quantity;
+import io.kubernetes.client.models.V1HostPathVolumeSource;
+import io.kubernetes.client.models.V1ObjectMeta;
+import io.kubernetes.client.models.V1PersistentVolume;
+import io.kubernetes.client.models.V1PersistentVolumeClaim;
+import io.kubernetes.client.models.V1PersistentVolumeClaimSpec;
+import io.kubernetes.client.models.V1PersistentVolumeSpec;
+import io.kubernetes.client.models.V1ResourceRequirements;
+
+import java.util.Collections;
+
+public class VolumeSpecParser {
+  public static V1PersistentVolume parsePersistentVolume(String name, String hostPath, String storage) {
+    V1PersistentVolume pv = new V1PersistentVolume();
+    /*
+      Required value
+      1. metadata.name
+      2. spec.accessModes
+      3. spec.capacity
+      4. spec.storageClassName
+      Others are not necessary
+     */
+
+    V1ObjectMeta pvMetadata = new V1ObjectMeta();
+    pvMetadata.setName(name);
+    pv.setMetadata(pvMetadata);
+
+    V1PersistentVolumeSpec pvSpec = new V1PersistentVolumeSpec();
+    pvSpec.setAccessModes(Collections.singletonList("ReadWriteMany"));
+    pvSpec.setCapacity(Collections.singletonMap("storage", new Quantity(storage)));
+    pvSpec.setStorageClassName("standard");
+    pvSpec.setHostPath(new V1HostPathVolumeSource().path(hostPath));
+    pv.setSpec(pvSpec);
+
+    return pv;
+  }
+
+  public static V1PersistentVolumeClaim parsePersistentVolumeClaim(
+      String name, String volume, String storage) {
+    V1PersistentVolumeClaim pvc = new V1PersistentVolumeClaim();
+    /*
+      Required value
+      1. metadata.name
+      2. spec.accessModes
+      3. spec.storageClassName
+      4. spec.resources
+      Others are not necessary
+     */
+
+    V1ObjectMeta pvcMetadata = new V1ObjectMeta();
+    pvcMetadata.setName(name);
+    pvc.setMetadata(pvcMetadata);
+
+    V1PersistentVolumeClaimSpec pvcSpec = new V1PersistentVolumeClaimSpec();
+    pvcSpec.setAccessModes(Collections.singletonList("ReadWriteMany"));
+    pvcSpec.setStorageClassName("standard");
+    pvcSpec.setResources(new V1ResourceRequirements().putRequestsItem("storage", new Quantity(storage)));
+    pvcSpec.setVolumeName(volume); // bind pvc to specific pv
+    pvc.setSpec(pvcSpec);
+
+    return pvc;
+  }
+
+}
diff --git a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/util/TensorboardUtils.java b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/util/TensorboardUtils.java
new file mode 100644
index 0000000..a7d8c9f
--- /dev/null
+++ b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/util/TensorboardUtils.java
@@ -0,0 +1,38 @@
+/*
+ * 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.submarine.server.submitter.k8s.util;
+
+public class TensorboardUtils {
+  /*
+  Prefix constants
+   */
+  public static final String PV_PREFIX = "tfboard-pv-";
+  public static final String HOST_PREFIX = "/tmp/tfboard-logs/";
+  public static final String STORAGE = "1Gi";
+  public static final String PVC_PREFIX = "tfboard-pvc-";
+  public static final String DEPLOY_PREFIX = "tfboard-";
+  public static final String POD_PREFIX = "tfboard-";
+  public static final String IMAGE_NAME = "tensorflow/tensorflow:1.11.0";
+  public static final String SVC_PREFIX = "tfboard-svc-";
+  public static final String INGRESS_PREFIX = "tfboard-ingressroute";
+  public static final String PATH_PREFIX = "/tfboard-";
+  public static final Integer DEFAULT_TENSORBOARD_PORT = 6006;
+  public static final Integer SERVICE_PORT = 8080;
+}
diff --git a/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/K8SJobSubmitterTest.java b/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/K8SJobSubmitterTest.java
index a27f88e..4add1e0 100644
--- a/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/K8SJobSubmitterTest.java
+++ b/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/K8SJobSubmitterTest.java
@@ -29,6 +29,8 @@ import org.apache.submarine.server.api.spec.ExperimentSpec;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * We have two ways to test submitter for K8s cluster, local and travis CI.
@@ -47,7 +49,7 @@ import org.junit.Test;
  * Travis: See '.travis.yml'
  */
 public class K8SJobSubmitterTest extends SpecBuilder {
-
+  private static final Logger LOG = LoggerFactory.getLogger(K8SJobSubmitterTest.class);
   private K8sSubmitter submitter;
 
   @Before
@@ -70,6 +72,18 @@ public class K8SJobSubmitterTest extends SpecBuilder {
     run(spec);
   }
 
+  @Test
+  public void testCreateTFJob() throws IOException, URISyntaxException {
+    ExperimentSpec spec = (ExperimentSpec) buildFromJsonFile(ExperimentSpec.class, tfTfboardJobwReqFile);
+    Experiment experiment = submitter.createExperiment(spec);
+  }
+
+  @Test
+  public void testDeleteTFJob() throws IOException, URISyntaxException {
+    ExperimentSpec spec = (ExperimentSpec) buildFromJsonFile(ExperimentSpec.class, tfTfboardJobwReqFile);
+    Experiment experiment = submitter.deleteExperiment(spec);
+  }
+
   private void run(ExperimentSpec spec) throws SubmarineRuntimeException {
     // create
     Experiment experimentCreated = submitter.createExperiment(spec);
diff --git a/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/SpecBuilder.java b/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/SpecBuilder.java
index d94a161..eb41aca 100644
--- a/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/SpecBuilder.java
+++ b/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/SpecBuilder.java
@@ -44,6 +44,7 @@ public abstract class SpecBuilder {
       "/pytorch_job_req_http_git_code_localizer.json";
   protected final String pytorchJobWithSSHGitCodeLocalizerFile =
       "/pytorch_job_req_ssh_git_code_localizer.json";
+  protected final String tfTfboardJobwReqFile = "/tf_tfboard_mnist_req.json";
 
   protected Object buildFromJsonFile(Object obj, String filePath) throws IOException,
       URISyntaxException {
diff --git a/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/parser/TensorboardSpecParserTest.java b/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/parser/TensorboardSpecParserTest.java
new file mode 100644
index 0000000..2503322
--- /dev/null
+++ b/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/parser/TensorboardSpecParserTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.submarine.server.submitter.k8s.parser;
+
+import io.kubernetes.client.models.V1Deployment;
+import io.kubernetes.client.models.V1Service;
+import junit.framework.TestCase;
+import org.apache.submarine.server.submitter.k8s.model.ingressroute.IngressRoute;
+import org.apache.submarine.server.submitter.k8s.util.TensorboardUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class TensorboardSpecParserTest extends TestCase {
+
+  @Test
+  public void testParseDeployment() {
+    final String id = "123456789";
+
+    final String name = TensorboardUtils.DEPLOY_PREFIX + id;
+    final String image = TensorboardUtils.IMAGE_NAME;
+    final String routePath = TensorboardUtils.PATH_PREFIX + id;
+    final String pvc = TensorboardUtils.PVC_PREFIX + id;
+
+    V1Deployment deployment = TensorboardSpecParser.parseDeployment(name, image, routePath, pvc);
+
+    Assert.assertEquals(name, deployment.getMetadata().getName());
+    Assert.assertEquals(image,
+        deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getImage());
+
+    Assert.assertEquals(pvc,
+        deployment.getSpec().getTemplate().getSpec().getVolumes()
+          .get(0).getPersistentVolumeClaim().getClaimName());
+  }
+
+  @Test
+  public void testParseService() {
+    final String id = "123456789";
+
+    final String svcName = TensorboardUtils.SVC_PREFIX + id;
+    final String podName = TensorboardUtils.DEPLOY_PREFIX + id;
+
+    V1Service svc = TensorboardSpecParser.parseService(svcName, podName);
+
+    Assert.assertEquals(svcName, svc.getMetadata().getName());
+    Assert.assertEquals(podName, svc.getSpec().getSelector().get("app"));
+  }
+
+  @Test
+  public void testParseIngressRoute() {
+    final String id = "123456789";
+    final String namespace = "default";
+
+    final String ingressName = TensorboardUtils.INGRESS_PREFIX + id;
+    final String routePath = TensorboardUtils.PATH_PREFIX + id;
+    final String svcName = TensorboardUtils.SVC_PREFIX + id;
+
+    IngressRoute ingressRoute = TensorboardSpecParser.parseIngressRoute(
+        ingressName, namespace, routePath, svcName
+      );
+
+    Assert.assertEquals(ingressRoute.getMetadata().getName(), ingressName);
+  }
+}
diff --git a/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/parser/VolumeSpecParserTest.java b/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/parser/VolumeSpecParserTest.java
new file mode 100644
index 0000000..afacadb
--- /dev/null
+++ b/submarine-server/server-submitter/submitter-k8s/src/test/java/org/apache/submarine/server/submitter/k8s/parser/VolumeSpecParserTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.submarine.server.submitter.k8s.parser;
+import io.kubernetes.client.custom.Quantity;
+import io.kubernetes.client.models.V1PersistentVolume;
+import io.kubernetes.client.models.V1PersistentVolumeClaim;
+import org.apache.submarine.server.submitter.k8s.util.TensorboardUtils;
+import org.junit.Assert;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class VolumeSpecParserTest  {
+  private static final Logger LOG = LoggerFactory.getLogger(VolumeSpecParserTest.class);
+
+  @Test
+  public void testParsePersistentVolume() {
+    final String id = "123456789";
+
+    final String name = TensorboardUtils.PV_PREFIX + id;
+    final String hostPath = TensorboardUtils.HOST_PREFIX + id;
+    final String storage = TensorboardUtils.STORAGE;
+
+    V1PersistentVolume pv = VolumeSpecParser.parsePersistentVolume(name, hostPath, storage);
+    LOG.info(pv.toString());
+
+    Assert.assertEquals(name, pv.getMetadata().getName());
+    Assert.assertEquals(hostPath, pv.getSpec().getHostPath().getPath());
+    Assert.assertEquals(new Quantity(storage), pv.getSpec().getCapacity().get("storage"));
+  }
+
+  @Test
+  public void testParsePersistentVolumeClaim() {
+    final String id = "123456789";
+
+    final String name = TensorboardUtils.PVC_PREFIX + id;
+    final String volume = TensorboardUtils.PV_PREFIX + id;
+    final String storage = TensorboardUtils.STORAGE;
+
+    V1PersistentVolumeClaim pvc = VolumeSpecParser.parsePersistentVolumeClaim(name, volume, storage);
+
+    LOG.info(pvc.toString());
+    Assert.assertEquals(name, pvc.getMetadata().getName());
+    Assert.assertEquals(volume, pvc.getSpec().getVolumeName());
+    Assert.assertEquals(new Quantity(storage), pvc.getSpec().getResources().getRequests().get("storage"));
+  }
+}
diff --git a/submarine-server/server-submitter/submitter-k8s/src/test/resources/tf_tfboard_mnist_req.json b/submarine-server/server-submitter/submitter-k8s/src/test/resources/tf_tfboard_mnist_req.json
new file mode 100644
index 0000000..0a5889e
--- /dev/null
+++ b/submarine-server/server-submitter/submitter-k8s/src/test/resources/tf_tfboard_mnist_req.json
@@ -0,0 +1,20 @@
+{
+  "meta": {
+    "name": "tensorflow-tensorboard-dist-mnist",
+    "namespace": "default",
+    "framework": "TensorFlow",
+    "cmd": "python /var/tf_mnist/mnist_with_summaries.py --log_dir=/logs/mylog --learning_rate=0.01 --batch_size=20",
+    "envVars": {
+      "ENV_1": "ENV1"
+    }
+  },
+  "environment": {
+    "image": "apache/submarine:tf-mnist-with-summaries-1.0"
+  },
+  "spec": {
+    "Worker": {
+      "replicas": 1,
+      "resources": "cpu=1,memory=1024M"
+    }
+  }
+}
diff --git a/submarine-test/test-k8s/src/test/java/org/apache/submarine/rest/ExperimentRestApiIT.java b/submarine-test/test-k8s/src/test/java/org/apache/submarine/rest/ExperimentRestApiIT.java
index c83e055..736fd64 100644
--- a/submarine-test/test-k8s/src/test/java/org/apache/submarine/rest/ExperimentRestApiIT.java
+++ b/submarine-test/test-k8s/src/test/java/org/apache/submarine/rest/ExperimentRestApiIT.java
@@ -46,6 +46,7 @@ import org.apache.submarine.server.response.JsonResponse;
 import org.apache.submarine.server.rest.RestConstants;
 import org.joda.time.DateTime;
 import org.junit.Assert;
+import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 import org.slf4j.Logger;
@@ -102,6 +103,11 @@ public class ExperimentRestApiIT extends AbstractSubmarineServerTest {
     kfOperatorMap.put("pytorch", new KfOperator("v1", "pytorchjobs"));
   }
 
+  @Before
+  public void setUp() throws Exception {
+    Thread.sleep(5000); // timeout for each case, ensuring k8s-client has enough time to delete resoures
+  }
+
   @Test
   public void testJobServerPing() throws IOException {
     GetMethod response = httpGet("/api/" + RestConstants.V1 + "/"
@@ -186,7 +192,7 @@ public class ExperimentRestApiIT extends AbstractSubmarineServerTest {
     String patchBody = loadContent("tensorflow/tf-mnist-with-ssh-git-code-localizer-req.json");
     run(body, patchBody, "application/json");
   }
-  
+
   private void run(String body, String patchBody, String contentType) throws Exception {
     // create
     LOG.info("Create training job by Job REST API");
@@ -219,7 +225,8 @@ public class ExperimentRestApiIT extends AbstractSubmarineServerTest {
     // https://tools.ietf.org/html/rfc5789
 
     // delete
-    DeleteMethod deleteMethod = httpDelete(BASE_API_PATH + "/" + createdExperiment.getExperimentId().toString());
+    DeleteMethod deleteMethod = httpDelete(
+        BASE_API_PATH + "/" + createdExperiment.getExperimentId().toString());
     Assert.assertEquals(Response.Status.OK.getStatusCode(), deleteMethod.getStatusCode());
 
     json = deleteMethod.getResponseBodyAsString();
@@ -296,7 +303,8 @@ public class ExperimentRestApiIT extends AbstractSubmarineServerTest {
     assertK8sResultEquals(env, createdJob);
   }
 
-  private void verifyGetJobApiResult(Experiment createdExperiment, Experiment foundExperiment) throws Exception {
+  private void verifyGetJobApiResult(
+      Experiment createdExperiment, Experiment foundExperiment) throws Exception {
     Assert.assertEquals(createdExperiment.getExperimentId(), foundExperiment.getExperimentId());
     Assert.assertEquals(createdExperiment.getUid(), foundExperiment.getUid());
     Assert.assertEquals(createdExperiment.getName(), foundExperiment.getName());


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@submarine.apache.org
For additional commands, e-mail: dev-help@submarine.apache.org