Merge "Adding missing numerica validator util."
diff --git a/src/dockerfiles/compose/docker-compose.yaml b/src/dockerfiles/compose/docker-compose.yaml
index c3cd1db..f639621 100644
--- a/src/dockerfiles/compose/docker-compose.yaml
+++ b/src/dockerfiles/compose/docker-compose.yaml
@@ -144,6 +144,9 @@
     depends_on:
     - autotest
 
+    environment:
+    - GOOGLE_APPLICATION_CREDENTIALS=/home/moblab/.service_account.json
+
     extra_hosts:
       - "dockerhost:192.168.100.1"
 
diff --git a/src/dockerfiles/db/Dockerfile.db_init b/src/dockerfiles/db/Dockerfile.db_init
index ffa6548..149fd3c 100644
--- a/src/dockerfiles/db/Dockerfile.db_init
+++ b/src/dockerfiles/db/Dockerfile.db_init
@@ -2,7 +2,7 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
-FROM debian:buster
+FROM debian:buster-slim
 
 COPY init_script.sh /
 
diff --git a/src/dockerfiles/devserver/Dockerfile b/src/dockerfiles/devserver/Dockerfile
index f1bac81..f59fb9b 100644
--- a/src/dockerfiles/devserver/Dockerfile
+++ b/src/dockerfiles/devserver/Dockerfile
@@ -13,16 +13,24 @@
 RUN sed -i 's#python2#python3#g' /devserver-src/dev-util/devserver.py
 ######################################################################
 
-FROM debian:buster as run
+FROM debian:buster-slim as run
 
 COPY --from=source /devserver-src /devserver-src
 COPY --from=source /chromite /chromite
 
 # Install packages
 RUN apt-get update && apt-get install --no-install-recommends -yq \
-    curl python3 sudo gcc python-dev procps \
-    bzip2 unzip sudo ca-certificates python3-distutils python3-dev \
-    openssh-server
+    bzip2 \
+    ca-certificates \
+    curl \
+    gcc \
+    openssh-server \
+    python-dev procps \
+    python3 \
+    python3-dev \
+    python3-distutils \
+    sudo \
+    unzip 
 
 RUN mkdir /run/sshd
 RUN mkdir -p /config/.ssh/
@@ -45,6 +53,9 @@
 RUN python -m pip install setuptools
 RUN python /setup_chromite.py install
 
+RUN apt purge -yq gcc
+RUN apt-get clean
+
 # Devserver requires an apache server to be running for a good health check
 # this is a fake apache script that just sleeps forever.
 # TODO remove requirement from devserver to need an apache process.
@@ -74,8 +85,6 @@
         quick-provision/quick-provision \
         /static_files_to_serve
 
-
-
 EXPOSE 8080
 
 ## Add moblab user
diff --git a/src/dockerfiles/offloader/Dockerfile b/src/dockerfiles/offloader/Dockerfile
index c970a07..e1736cd 100644
--- a/src/dockerfiles/offloader/Dockerfile
+++ b/src/dockerfiles/offloader/Dockerfile
@@ -1,15 +1,22 @@
 # Copyright 2020 The Chromium OS Authors. All rights reserved.
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
+ARG REGISTRY_URI
+
+######################################################################
+
+FROM ${REGISTRY_URI}/protoc:haddowk3 as protoc
+
+######################################################################
 
 FROM python:3.8-buster as proto_build
 
 WORKDIR /
-RUN wget https://github.com/protocolbuffers/protobuf/releases/download/v3.10.0/protoc-3.10.0-linux-x86_64.zip
-RUN unzip protoc-3.10.0-linux-x86_64.zip
-RUN cp bin/protoc /usr/local/bin
+COPY --from=protoc /protoc/* /usr/local/bin
 COPY third_party/autotest/tko/tko.proto /usr/local/autotest/tko/tko.proto
-RUN protoc --proto_path /usr/local/autotest/tko --python_out=/usr/local/autotest/tko /usr/local/autotest/tko/tko.proto
+RUN protoc --proto_path /usr/local/autotest/tko \
+    --python_out=/usr/local/autotest/tko \
+    /usr/local/autotest/tko/tko.proto
 
 ####################################################################
 
@@ -17,7 +24,8 @@
 
 WORKDIR /
 
-RUN addgroup --gid 246 moblab && adduser --ingroup moblab --uid 246 --disabled-password --gecos "" moblab
+RUN addgroup --gid 246 moblab && adduser --ingroup moblab --uid 246 \
+    --disabled-password --gecos "" moblab
 RUN apt-get update && apt-get install -yq sudo
 RUN echo "moblab     ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
 RUN usermod -aG sudo moblab
diff --git a/src/dockerfiles/rpcserver/Dockerfile b/src/dockerfiles/rpcserver/Dockerfile
index 43a97f9..2bd0fae 100644
--- a/src/dockerfiles/rpcserver/Dockerfile
+++ b/src/dockerfiles/rpcserver/Dockerfile
@@ -2,34 +2,22 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
-#############
-### build ###
-#############
+ARG ${REGISTRY_URI}
 
-# base image
+######################################################################
+
+FROM gcr.io/chromeos-partner-moblab/protoc:haddowk3 as protoc
+
+######################################################################
+
 FROM python:3.8-buster as build
 
 RUN addgroup --gid 246 moblab && adduser --ingroup moblab --uid 246 \
     --disabled-password --gecos "" moblab
 
-
-# install protoc
-ENV PROTOBUF_URL https://github.com/protocolbuffers/protobuf/releases/download/v3.10.0/protoc-3.10.0-linux-x86_64.zip
-RUN curl -L -o /tmp/protobuf.zip $PROTOBUF_URL
-WORKDIR /tmp/
-RUN unzip protobuf.zip
-RUN cp bin/protoc /usr/local/bin
-
-# install grpc_python_plugin
-RUN git clone https://github.com/grpc/grpc.git
-WORKDIR /tmp/grpc
-RUN git submodule update --init
-RUN make grpc_python_plugin
-RUN cp /tmp/grpc/bins/opt/grpc_python_plugin /usr/local/bin
-
-# set working directory
 WORKDIR /etc/moblab/moblab-rpcserver
 
+COPY --from=protoc /protoc/* /usr/local/bin/
 COPY src/moblab-rpcserver/ /etc/moblab/moblab-rpcserver
 RUN protoc -I=/etc/moblab/moblab-rpcserver \
     --python_out=:/etc/moblab/moblab-rpcserver \
@@ -37,18 +25,22 @@
     --plugin=protoc-gen-grpc_python=/usr/local/bin/grpc_python_plugin \
     moblabrpc.proto
 
-############
-### prod ###
-############
+######################################################################
 
-# base image
-FROM python:3-buster
+FROM python:3-slim-buster
 
 RUN addgroup --gid 246 moblab && adduser --ingroup moblab --uid 246 \
     --disabled-password --gecos "" moblab
-# copy artifact build from the 'build environment'
-COPY --chown=moblab --from=build /etc/moblab/moblab-rpcserver /etc/moblab/moblab-rpcserver
-RUN apt-get update && apt-get install -yq unzip cmake libssl-dev fping
+
+COPY --chown=moblab --from=build /etc/moblab/moblab-rpcserver \
+    /etc/moblab/moblab-rpcserver
+RUN apt-get update && apt-get install -yq \
+    cmake \
+    curl \
+    fping \
+    libssl-dev \
+    unzip \
+    zlib1g-dev
 
 COPY --chown=moblab src/dockerfiles/rpcserver/requirements.txt ./
 RUN pip install --upgrade pip
@@ -57,18 +49,16 @@
 # moblab_common
 COPY --chown=moblab ./src/moblab_common /moblab/src/moblab_common
 COPY --chown=moblab ./src/tools /moblab/src/tools
-COPY --chown=moblab ./src/dockerfiles /moblab/src/dockerfiles
 COPY --chown=moblab ./src/host_services /moblab/src/host_services
 COPY --chown=moblab ./setup.py /moblab/
 
 WORKDIR /moblab
-RUN python ./setup.py install
+RUN python -m pip install .
+
+RUN rm -rf /moblab
 
 RUN mkdir /var/log/moblab
 
-RUN apt purge -yq unzip cmake libssl-dev
-RUN apt-get clean
-
 # Installs Moblab API client library
 WORKDIR /tmp/moblab_api_client_library
 RUN curl https://storage.googleapis.com/moblab-api-distribution/google-chromeos-moblab.tar.gz -o google-chromeos-moblab.tar.gz
@@ -76,6 +66,9 @@
 RUN python -m pip install .
 RUN rm -rf /tmp/moblab_api_client_library
 
+RUN apt purge -yq unzip cmake libssl-dev zlib1g-dev curl
+RUN apt-get clean
+
 WORKDIR /moblab
 USER moblab
 
diff --git a/src/dockerfiles/servod/Dockerfile b/src/dockerfiles/servod/Dockerfile
index d4df2cf..99e17b3 100644
--- a/src/dockerfiles/servod/Dockerfile
+++ b/src/dockerfiles/servod/Dockerfile
@@ -6,12 +6,25 @@
 
 RUN apt update
 
-RUN apt install -y wget python-pexpect python-numpy python-setuptools python-serial python-pytest tidy libftdi-dev curl gcc make pkg-config libudev-dev usbutils
+RUN apt-get update && apt-get install --no-install-recommends -yq \
+    curl \
+    gcc \
+    libftdi-dev \
+    libudev-dev \
+    make \
+    pkg-config \
+    python-numpy \
+    python-pexpect \
+    python-pytest \
+    python-serial \
+    python-setuptools \
+    tidy \
+    usbutils \
+    wget
 
 RUN wget https://github.com/libusb/libusb/releases/download/v1.0.23/libusb-1.0.23.tar.bz2
 RUN tar xvf libusb-1.0.23.tar.bz2
 WORKDIR /libusb-1.0.23
-
 RUN ./configure && make && make install
 RUN export LD_LIBRARY_PATH=/usr/local/lib
 
diff --git a/src/dockerfiles/ssp/Dockerfile b/src/dockerfiles/ssp/Dockerfile
index 2a6d7bb..2282cde 100644
--- a/src/dockerfiles/ssp/Dockerfile
+++ b/src/dockerfiles/ssp/Dockerfile
@@ -8,19 +8,26 @@
 
 RUN git clone https://chromium.googlesource.com/chromiumos/chromite
 
-
 ###############################################################################
 
-FROM debian:buster as run
+FROM debian:buster-slim as run
 
 WORKDIR /
 
 COPY --from=source /chromite /chromite
 
-# Install packages
 RUN apt-get update && apt-get install --no-install-recommends -yq \
-        bzip2 wget curl python unzip rsync gnupg lsb-release \
-        ca-certificates ssh sudo
+        bzip2 \
+        ca-certificates \
+        curl \
+        gnupg \
+        lsb-release \
+        python \
+        rsync \
+        ssh \
+        sudo \
+        unzip \
+        wget
 
 # Install the google cloud sdk
 RUN echo \
diff --git a/src/dockerfiles/ui/Dockerfile b/src/dockerfiles/ui/Dockerfile
index 44ab3f4..c2224c7 100644
--- a/src/dockerfiles/ui/Dockerfile
+++ b/src/dockerfiles/ui/Dockerfile
@@ -1,58 +1,51 @@
 # Copyright 2019 The Chromium OS Authors. All rights reserved.
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
-#############
-### build ###
-#############
+ARG REGISTRY_URI=gcr.io/chromeos-partner-moblab
+ARG LABEL=release
+######################################################################
 
-# base image
+FROM ${REGISTRY_URI}/protoc:${LABEL} as protoc
+
+######################################################################
+
 FROM node:12 as build
 
 # install chrome for protractor tests
-RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
-RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
-RUN apt-get update && apt-get install -yq google-chrome-stable
+# RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
+# RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
+# RUN apt-get update && apt-get install -yq google-chrome-stable
 
-# install protoc
-RUN apt-get install -yq unzip
-ENV PROTOBUF_URL https://github.com/protocolbuffers/protobuf/releases/download/v3.10.0/protoc-3.10.0-linux-x86_64.zip
-RUN curl -L -o /tmp/protobuf.zip $PROTOBUF_URL
-WORKDIR /tmp/
-RUN unzip protobuf.zip
-RUN cp bin/protoc /usr/local/bin
-
-RUN curl -L -o /usr/local/bin/protoc-gen-grpc-web https://github.com/grpc/grpc-web/releases/download/1.0.7/protoc-gen-grpc-web-1.0.7-linux-x86_64
-RUN chmod uog+x /usr/local/bin/protoc-gen-grpc-web
-
-# set working directory
 WORKDIR /app
 
 # add `/app/node_modules/.bin` to $PATH
 ENV PATH /app/node_modules/.bin:$PATH
 
-
 # install and cache app dependencies
 # add app
 COPY ./moblab-ui /app
 COPY ./moblab-rpcserver/moblabrpc.proto /app
+
+COPY --from=protoc /protoc/* /usr/local/bin/
+RUN protoc -I=/app --js_out=import_style=commonjs:/app/src/app/services \
+    --grpc-web_out=import_style=commonjs+dts,mode=grpcwebtext:/app/src/app/services \
+    /app/moblabrpc.proto
+
 RUN npm install
-RUN protoc -I=/app --js_out=import_style=commonjs:/app/src/app/services --grpc-web_out=import_style=commonjs+dts,mode=grpcwebtext:/app/src/app/services /app/moblabrpc.proto
 
 # run tests
 #RUN ng test --watch=false
 #RUN ng e2e --port 4202
 
-# generate build
 RUN ng build --output-path=dist
 
-############
-### prod ###
-############
+######################################################################
 
 # base image
 FROM nginx:alpine
 
-RUN addgroup --gid 246 moblab && adduser --ingroup moblab --uid 246 --disabled-password --gecos "" moblab
+RUN addgroup --gid 246 moblab && adduser --ingroup moblab --uid 246 \
+     --disabled-password --gecos "" moblab
 
 # copy artifact build from the 'build environment'
 COPY --chown=moblab --from=build /app/dist /etc/nginx/html/
diff --git a/src/dockerfiles/utilities/Dockerfile.protoc b/src/dockerfiles/utilities/Dockerfile.protoc
new file mode 100644
index 0000000..27cd117
--- /dev/null
+++ b/src/dockerfiles/utilities/Dockerfile.protoc
@@ -0,0 +1,39 @@
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+FROM debian:buster-slim as download
+
+RUN apt-get update && apt-get install --no-install-recommends -yq \
+	ca-certificates \
+	curl \
+	dh-autoreconf \
+	g++ \
+	gcc \
+	git \
+	make \
+	unzip
+
+WORKDIR /
+RUN curl -L -o protobuf.zip https://github.com/protocolbuffers/protobuf/releases/download/v3.10.0/protoc-3.10.0-linux-x86_64.zip
+RUN unzip protobuf.zip
+
+RUN curl -L -o /usr/local/bin/protoc-gen-grpc-web https://github.com/grpc/grpc-web/releases/download/1.0.7/protoc-gen-grpc-web-1.0.7-linux-x86_64
+RUN chmod uog+x /usr/local/bin/protoc-gen-grpc-web
+
+# install grpc_python_plugin
+RUN git clone https://github.com/grpc/grpc.git
+WORKDIR /grpc
+RUN git submodule update --init
+RUN make grpc_python_plugin
+
+##############################################################################
+FROM debian:buster-slim
+
+RUN mkdir -p /protoc
+COPY --from=download bin/protoc /protoc
+COPY --from=download /usr/local/bin/protoc-gen-grpc-web /protoc
+COPY --from=download /grpc/bins/opt/grpc_python_plugin /protoc
+
+
+
diff --git a/src/moblab-rpcserver/__init__.py b/src/moblab-rpcserver/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/moblab-rpcserver/__init__.py
diff --git a/src/moblab-rpcserver/moblab_build_connector.py b/src/moblab-rpcserver/moblab_build_connector.py
new file mode 100644
index 0000000..f853845
--- /dev/null
+++ b/src/moblab-rpcserver/moblab_build_connector.py
@@ -0,0 +1,168 @@
+# -*- coding: utf-8 -*-
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""
+Interface for the Moblab RPC server to access build resources by using Moblab
+API.
+"""
+
+import logging
+
+import google.api_core.path_template
+
+from google.auth import exceptions as auth_exceptions
+from google.chromeos.moblab_v1beta1.services.build_service.client import \
+    BuildServiceClient
+from google.chromeos.moblab_v1beta1.types import build_service
+from google.protobuf import field_mask_pb2 as field_mask  # type: ignore
+
+
+class MoblabBuildConnectorException(Exception):
+    """Generic exception for this module."""
+    pass
+
+
+class MoblabBuildConnector(object):
+    """
+    Class that provides RPC server an interface to access build resources by
+    using Moblab API.
+    """
+
+    @classmethod
+    def model_path(cls, build_target, model):
+        """Return a fully-qualified model string."""
+        return google.api_core.path_template.expand(
+            'buildTargets/{build_target}/models/{model}',
+            build_target=build_target,
+            model=model,
+        )
+
+    @classmethod
+    def build_artifact_path(cls, build_target, model, build, artifact):
+        """Return a fully-qualified build_artifact string."""
+        return google.api_core.path_template.expand(
+            'buildTargets/{build_target}/models/{model}/builds/{build}/artifacts/{artifact}',
+            build_target=build_target,
+            model=model,
+            build=build,
+            artifact=artifact,
+        )
+
+    def __init__(self, build_service_client=None):
+        """Instantiates the build connector and sets up the build service
+        client. The service account is set in the global environment.
+        """
+        try:
+            self.build_service_client = build_service_client if \
+                build_service_client is not None else BuildServiceClient()
+        except auth_exceptions.DefaultCredentialsError as e:
+            raise MoblabBuildConnectorException('No credentials loaded')
+
+    def list_available_milestones(self, build_target, model, page_size=50,
+        page_token=None):
+        """Lists the available milestones for the given build target and model.
+
+        Args:
+            build_target (str): Build target name.
+            model (str): Model name. For non-unibuilds, set the model name as
+                the same as build target name.
+            page_size (int): The number of milestones to return in the response.
+                The default size is 50.
+            page_token (str): A page token, received from a previous
+                ``ListBuilds`` call. Provide this to retrieve the subsequent
+                page.
+
+        Returns:
+            build_service.ListBuildsResponse: Response message for listing
+            builds with milestones.
+        """
+        milestone_field = field_mask.FieldMask(paths=["milestone"])
+        request = build_service.ListBuildsRequest(
+            parent=self.model_path(build_target, model),
+            page_size=page_size,
+            page_token=page_token,
+            read_mask=milestone_field,
+            group_by=milestone_field
+        )
+        return self.build_service_client.list_builds(request)
+
+    def list_builds_for_milestone(self, build_target, model, milestone,
+        page_size=50, page_token=None):
+        """Lists the builds for the given build target, model and milestone.
+
+        Args:
+            build_target (str): The build target name.
+            model (str): The model name. For non-unibuilds, set the model name as
+                the same as build target name.
+            milestone (int): The milestone number.
+            page_size (int): The number of milestones to return in the response.
+                The default size is 50.
+            page_token (str): The page token, received from a previous
+                ``ListBuilds`` call. Provide this to retrieve the subsequent
+                page.
+
+        Returns:
+            build_service.ListBuildsResponse: Response message for listing
+            builds.
+        """
+        milestone_filter = "milestone=milestones/" + str(milestone)
+        request = build_service.ListBuildsRequest(
+            parent=self.model_path(build_target, model),
+            page_size=page_size,
+            page_token=page_token,
+            filter=milestone_filter
+        )
+        return self.build_service_client.list_builds(request)
+
+    def check_build_stage_status(self, build_target, model, build, bucket_name):
+        """Checks the stage status for a given build artifact in a partner
+        Google Cloud Storage bucket.
+
+        Args:
+            build_target (str): The build target name.
+            model (str): The model name. For non-unibuilds, set the model name as
+                the same as build target name.
+            build (str): The build version.
+            bucket_name: The destination bucket name .
+
+        Returns:
+            build_service.CheckBuildStageStatusResponse: Response message for
+            check the stage status of a build artifact.
+        """
+        request = build_service.CheckBuildStageStatusRequest(
+            name=self.build_artifact_path(build_target, model, build,
+                                          bucket_name)
+        )
+        return self.build_service_client.check_build_stage_status(request)
+
+    def stage_build(self, build_target, model, build, bucket_name):
+        # TODO(zhihuixie): Long running operation cannot returned correctly in
+        # the python client library.
+        """Stages a given build artifact from a internal Google Cloud Storage
+        bucket to a partner Google Cloud Storage bucket. The stage will be
+        skipped if all the objects in the partner bucket are the same as in the
+        internal bucket.
+
+        Args:
+            build_target (str): The build target name.
+            model (str): The model name. For non-unibuilds, set the model name as
+                the same as build target name.
+            build (str): The build version.
+            bucket_name: The destination bucket name .
+
+        Returns:
+            operation.Operation:
+                An object representing a long-running operation. The result
+                type for the operation will be
+                ``build_service.StageBuildResponse``: Response
+                message for staging a build artifact.
+        """
+        request = build_service.StageBuildRequest(
+            name=self.build_artifact_path(build_target, model, build,
+                                          bucket_name)
+        )
+        return self.build_service_client.stage_build(request)
+
+if __name__ == '__main__':
+    connector = MoblabBuildConnector()
\ No newline at end of file
diff --git a/src/moblab-rpcserver/moblab_build_connector_unittest.py b/src/moblab-rpcserver/moblab_build_connector_unittest.py
new file mode 100644
index 0000000..3e2afbb
--- /dev/null
+++ b/src/moblab-rpcserver/moblab_build_connector_unittest.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Unit tests for the moblab_build_connector"""
+
+import unittest
+from unittest import mock
+
+from moblab_build_connector import MoblabBuildConnector
+
+from google.chromeos.moblab_v1beta1.types import build_service
+from google.chromeos.moblab_v1beta1.types import resources
+
+
+class MoblabBuildConnectorTest(unittest.TestCase):
+    """Testing the MoblabBuildConnector code."""
+
+    BUILD_TARGET = 'octopus'
+    MODEL = 'bobba'
+    MILESTONE = 80
+    BUILD_VERSION = '1234.5.6'
+    SOURCE_BUCKET = 'source_bucket'
+    DESTINATION_BUCKET = 'destination bucket'
+
+    def setUp(self):
+        self.mock_build_service_client = mock.MagicMock()
+        self.moblab_build_connector = MoblabBuildConnector(
+            self.mock_build_service_client)
+
+    def test_list_available_milestones(self):
+        build = resources.Build(
+            name=MoblabBuildConnector.model_path(self.BUILD_TARGET, self.MODEL),
+            milestone=str(self.MILESTONE))
+        expect = self.mock_build_service_client.list_builds.return_value = \
+            build_service.ListBuildsResponse(
+                builds=[build],
+                next_page_token='next_page_token_value',
+                total_size=1,
+            )
+
+        response = self.moblab_build_connector.list_available_milestones(
+            self.BUILD_TARGET, self.MODEL)
+
+        self.assertEqual(response.builds, expect.builds)
+        self.mock_build_service_client.list_builds.assert_called_once()
+
+    def test_list_builds_for_milestone(self):
+        build = resources.Build(
+            name=MoblabBuildConnector.model_path(self.BUILD_TARGET, self.MODEL),
+            milestone=str(self.MILESTONE),
+            build_version=self.BUILD_VERSION)
+        expect = self.mock_build_service_client.list_builds.return_value = \
+            build_service.ListBuildsResponse(
+                builds=[build],
+                next_page_token='next_page_token_value',
+                total_size=1,
+            )
+
+        response = self.moblab_build_connector.list_builds_for_milestone(
+            self.BUILD_TARGET, self.MODEL, self.MILESTONE)
+
+        self.assertEqual(response.builds, expect.builds)
+        self.mock_build_service_client.list_builds.assert_called_once()
+
+    def test_check_build_stage_status(self):
+        staged_build_artifact = resources.BuildArtifact(
+            bucket=self.DESTINATION_BUCKET
+        )
+        source_build_artifact = resources.BuildArtifact(
+            bucket=self.SOURCE_BUCKET)
+        expect = self.mock_build_service_client.check_build_stage_status.return_value = \
+            build_service.CheckBuildStageStatusResponse(
+                is_build_staged=True,
+                staged_build_artifact=staged_build_artifact,
+                source_build_artifact=source_build_artifact
+            )
+
+        response = self.moblab_build_connector.check_build_stage_status(
+            self.BUILD_TARGET, self.MODEL, self.BUILD_VERSION,
+            self.DESTINATION_BUCKET)
+
+        self.assertEqual(response.staged_build_artifact,
+                         expect.staged_build_artifact)
+        self.mock_build_service_client.check_build_stage_status.assert_called_once()
+
+    def test_stage_build(self):
+        staged_build_artifact = resources.BuildArtifact(
+            bucket=self.DESTINATION_BUCKET
+        )
+        expect = self.mock_build_service_client.stage_build.return_value = \
+            build_service.StageBuildResponse(
+                staged_build_artifact=staged_build_artifact
+            )
+
+        response = self.moblab_build_connector.stage_build(
+            self.BUILD_TARGET, self.MODEL, self.BUILD_VERSION,
+            self.DESTINATION_BUCKET)
+
+        self.assertEqual(response.staged_build_artifact,
+                         expect.staged_build_artifact)
+        self.mock_build_service_client.stage_build.assert_called_once()
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/src/moblab-ui/src/app/app-routing.module.ts b/src/moblab-ui/src/app/app-routing.module.ts
index 347789f..c9a5687 100644
--- a/src/moblab-ui/src/app/app-routing.module.ts
+++ b/src/moblab-ui/src/app/app-routing.module.ts
@@ -17,7 +17,7 @@
   {path: 'job_detail', component: JobDetailComponent},
   {path: 'job_detail/:job_id', component: JobDetailComponent},
   {path: 'dut_detail', component: DutDetailComponent},
-  {path: 'dut_detail/:dut_ip', component: DutDetailComponent},
+  {path: 'dut_detail/:dut_hostname', component: DutDetailComponent},
   {
     path: 'run_tests',
     component: RunSuiteComponent,
diff --git a/src/moblab-ui/src/app/dut-detail/dut-detail.component.html b/src/moblab-ui/src/app/dut-detail/dut-detail.component.html
index e6222d1..ecc4229 100644
--- a/src/moblab-ui/src/app/dut-detail/dut-detail.component.html
+++ b/src/moblab-ui/src/app/dut-detail/dut-detail.component.html
@@ -50,6 +50,7 @@
       </mat-expansion-panel-header>
       <app-view-jobs
         manualPopulate=true
+        hideActionBar=true
         hideFilters=false
         hideSelectors=true
         pageSizeOverride=10>
diff --git a/src/moblab-ui/src/app/dut-detail/dut-detail.component.ts b/src/moblab-ui/src/app/dut-detail/dut-detail.component.ts
index 26522cd..78325a0 100644
--- a/src/moblab-ui/src/app/dut-detail/dut-detail.component.ts
+++ b/src/moblab-ui/src/app/dut-detail/dut-detail.component.ts
@@ -28,9 +28,19 @@
 
   constructor(
     private readonly moblabGrpcService: MoblabGrpcService,
+    private route: ActivatedRoute,
     private snackBar: MatSnackBar
   ) {}
 
+  ngOnInit() {
+    this.route.paramMap.subscribe(params => {
+      this.dutQuery.setValue(params.get('dut_hostname'));
+      if (this.dutQuery.value) {
+        this.fetchDutDetails();
+      }
+    });
+  }
+
   setDutDetails(dutInfo: ConnectedDutInfo) {
     this.dutInfoTableRef.loadRows(
       [
diff --git a/src/moblab-ui/src/app/job-detail/job-detail.component.html b/src/moblab-ui/src/app/job-detail/job-detail.component.html
index 11fce7e..657de35 100644
--- a/src/moblab-ui/src/app/job-detail/job-detail.component.html
+++ b/src/moblab-ui/src/app/job-detail/job-detail.component.html
@@ -113,6 +113,7 @@
       </mat-expansion-panel-header>
       <app-view-jobs
         manualPopulate=true
+        hideActionBar=true
         hideFilters=true
         hideSelectors=true>
       </app-view-jobs>