From a0661eb1456b03010ed46e7acc2e97d625ab179e Mon Sep 17 00:00:00 2001 From: Troy Dai Date: Fri, 16 Feb 2018 11:44:02 -0800 Subject: [PATCH] Overhaul the A01 test droid image and build process (#5599) The change intends to bring the CLI's test image to the standard which eventually will be shared across different product.The change intends to bring the CLI's test image to the standard which eventually will be shared across different product. 1. Use the new droid engine (written in Go) 2. Overhaul the docker image. 3. Add prepare pod and after test scripts --- scripts/ci/a01/Dockerfile.py36 | 22 ++- scripts/ci/a01/docker_app/after_test | 21 +++ scripts/ci/a01/docker_app/collect_tests.py | 38 ++++- scripts/ci/a01/docker_app/job.py | 185 --------------------- scripts/ci/a01/docker_app/prepare_pod | 25 +++ scripts/ci/a01/docker_app/requirements.txt | 1 - scripts/ci/build_droid.sh | 2 + 7 files changed, 90 insertions(+), 204 deletions(-) create mode 100755 scripts/ci/a01/docker_app/after_test delete mode 100644 scripts/ci/a01/docker_app/job.py create mode 100755 scripts/ci/a01/docker_app/prepare_pod delete mode 100644 scripts/ci/a01/docker_app/requirements.txt diff --git a/scripts/ci/a01/Dockerfile.py36 b/scripts/ci/a01/Dockerfile.py36 index db4654153a5..4528e48825b 100644 --- a/scripts/ci/a01/Dockerfile.py36 +++ b/scripts/ci/a01/Dockerfile.py36 @@ -2,17 +2,21 @@ FROM python:3.6-jessie LABEL a01.product="azurecli" LABEL a01.index.schema="v2" +LABEL a01.env.A01_SP_USERNAME="secret:sp.username" +LABEL a01.env.A01_SP_PASSWORD="secret:sp.password" +LABEL a01.env.A01_SP_TENANT="secret:sp.tenant" +LABEL a01.env.AZURE_TEST_RUN_LIVE="arg-live:True" +LABEL a01.setting.storage="True" -RUN rm /usr/bin/python && ln /usr/local/bin/python3.6 /usr/bin/python +RUN rm /usr/bin/python && ln /usr/local/bin/python3.6 /usr/bin/python && \ + apt-get update && apt-get install jq COPY build /tmp/build -COPY docker_app /app +RUN find /tmp/build -name '*.whl' | xargs pip install && \ + rm -rf /tmp/build -RUN pip install -r /app/requirements.txt && \ - find /tmp/build -name '*.whl' | xargs pip install && \ - rm -rf /tmp/build && \ - python /app/collect_tests.py > /app/test_index && \ - rm /app/collect_tests.py && \ - rm /app/requirements.txt && \ - rm -rf ~/.cache/pip +COPY docker_app /app +RUN python /app/collect_tests.py > /app/test_index && \ + rm /app/collect_tests.py +CMD /app/a01droid diff --git a/scripts/ci/a01/docker_app/after_test b/scripts/ci/a01/docker_app/after_test new file mode 100755 index 00000000000..cd2c3713b5c --- /dev/null +++ b/scripts/ci/a01/docker_app/after_test @@ -0,0 +1,21 @@ +#!/bin/bash + +mount_path=$1 +task=$2 + +recording_path=$(echo $task | jq -r ".settings.execution.recording") +if [ "$recording_path" == "null" ]; then + echo "Skip copying recording file. Code 1." + exit 0 +fi + +if [ -z "$recording_path" ]; then + echo "Skip copying recording file. Code 2." + exit 0 +fi + +run_id=$(echo $task | jq -r ".run_id") +task_id=$(echo $task | jq -r ".id") + +mkdir -p $mount_path/$run_id +cp $recording_path $mount_path/$run_id/recording_$task_id.yaml diff --git a/scripts/ci/a01/docker_app/collect_tests.py b/scripts/ci/a01/docker_app/collect_tests.py index 39265192f53..dfe5df0a52b 100644 --- a/scripts/ci/a01/docker_app/collect_tests.py +++ b/scripts/ci/a01/docker_app/collect_tests.py @@ -5,14 +5,20 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +# pylint: disable=import-error, protected-access +# Justification: +# * The azure.cli packages are not visible while the script is scaned but they are guaranteed +# existence on the droid image +# * The test method names and other properties are protected for unit test framework. + import os.path from pkgutil import iter_modules from unittest import TestLoader from importlib import import_module from json import dumps as json_dumps -import azure.cli # pylint: disable=import-error -from azure.cli.testsdk import ScenarioTest, LiveScenarioTest # pylint: disable=import-error +import azure.cli +from azure.cli.testsdk import ScenarioTest, LiveScenarioTest RECORDS = [] @@ -25,6 +31,13 @@ def get_test_type(test_case): return 'Unit' +def find_recording_file(test_path): + module_path, _, test_method = test_path.rsplit('.', 2) + test_folder = os.path.dirname(import_module(module_path).__file__) + recording_file = os.path.join(test_folder, 'recordings', test_method + '.yaml') + return recording_file if os.path.exists(recording_file) else None + + def search(path, prefix=''): loader = TestLoader() for _, name, is_pkg in iter_modules(path): @@ -37,13 +50,20 @@ def search(path, prefix=''): if not is_pkg and name.startswith('test'): test_module = import_module(full_name) for suite in loader.loadTestsFromModule(test_module): - for test in suite._tests: # pylint: disable=protected-access - RECORDS.append({ - 'module': full_name, - 'class': test.__class__.__name__, - 'method': test._testMethodName, # pylint: disable=protected-access - 'type': get_test_type(test), - 'path': '{}.{}.{}'.format(full_name, test.__class__.__name__, test._testMethodName)}) # pylint: disable=protected-access + for test in suite._tests: + path = '{}.{}.{}'.format(full_name, test.__class__.__name__, test._testMethodName) + rec = { + 'ver': '1.0', + 'execution': { + 'command': 'python -m unittest {}'.format(path), + 'recording': find_recording_file(path) + }, + 'classifier': { + 'identifier': path, + 'type': get_test_type(test), + } + } + RECORDS.append(rec) search(azure.cli.__path__, 'azure.cli') diff --git a/scripts/ci/a01/docker_app/job.py b/scripts/ci/a01/docker_app/job.py deleted file mode 100644 index 3ff691c3dc6..00000000000 --- a/scripts/ci/a01/docker_app/job.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import os -import sys -import shlex -import shutil -from datetime import datetime -from subprocess import check_output, CalledProcessError, STDOUT -from typing import Optional -from importlib import import_module - -import requests - - -class InternalCommunicationAuth(requests.auth.AuthBase): # pylint: disable=too-few-public-methods - def __call__(self, req): - req.headers['Authorization'] = os.environ['A01_INTERNAL_COMKEY'] - return req - - -session = requests.Session() # pylint: disable=invalid-name -session.auth = InternalCommunicationAuth() - - -class Droid(object): - def __init__(self): - try: - self.run_id = os.environ['A01_DROID_RUN_ID'] - except KeyError: - print('The environment variable A01_DROID_RUN_ID is missing.', file=sys.stderr, flush=True) - sys.exit(1) - - try: - self.store_host = os.environ['A01_STORE_NAME'] - except KeyError: - print('The environment variable A01_STORE_NAME is missing. Fallback to a01store.', file=sys.stderr, - flush=True) - self.store_host = 'a01store' - - self.run_live = os.environ.get('A01_RUN_LIVE', 'False') == 'True' - self.username = os.environ.get('A01_SP_USERNAME', None) - self.password = os.environ.get('A01_SP_PASSWORD', None) - self.tenant = os.environ.get('A01_SP_TENANT', None) - - def login_azure_cli(self) -> None: - if not self.run_live: - return - - if self.username and self.password and self.tenant: - try: - login = check_output(shlex.split('az login --service-principal -u {} -p {} -t {}' - .format(self.username, self.password, self.tenant))) - print(login.decode('utf-8'), flush=True) - except CalledProcessError as error: - print('Failed to sign in with the service principal.', file=sys.stderr, flush=True) - print(str(error), file=sys.stderr, flush=True) - sys.exit(1) - else: - print('Missing service principal settings for live test', file=sys.stderr, flush=True) - sys.exit(1) - - def start(self): - print('Store host: {}'.format(self.store_host), flush=True) - print(' Run ID: {}'.format(self.run_id), flush=True) - - # Exit condition: when the /run//checkout endpoint returns 204, the sys.exit(0) will be called therefore - # exits the entire program. - while True: - task = self.checkout_task() - self.run_task(task) - - def checkout_task(self) -> str: - task = self.request_task() - print('Pick up task {}.'.format(task['id']), flush=True) - - # update the running agent first - result_details = task.get('result_detail', dict()) - result_details['agent'] = '{}@{}'.format( - os.environ.get('ENV_POD_NAME', 'N/A'), - os.environ.get('ENV_NODE_NAME', 'N/A')) - result_details['live'] = self.run_live - self.update_task(task['id'], {'result_details': result_details}) - - return task['id'] - - def run_task(self, task_id: str) -> None: - # run the task - task = self.retrieve_task(task_id) - - begin = datetime.now() - try: - test_path = task['settings']['path'] - env_vars = {'AZURE_TEST_RUN_LIVE': 'True'} if self.run_live else None - output = check_output(shlex.split(f'python -m unittest {test_path}'), stderr=STDOUT, env=env_vars) - output = output.decode('utf-8') - result = 'Passed' - except CalledProcessError as error: - output = error.output.decode('utf-8') - result = 'Failed' - elapsed = datetime.now() - begin - - print(f'Task {result}', flush=True) - - storage_mount = '/mnt/storage' - if not os.path.isdir(storage_mount): - print(f'Storage volume is not mount for logging. Print the task output to the stdout instead.', - file=sys.stderr, flush=True) - print(output, file=sys.stdout, flush=True) - else: - os.makedirs(os.path.join(storage_mount, self.run_id), exist_ok=True) - with open(os.path.join(storage_mount, self.run_id, f'task_{task_id}.log'), 'w') as log_file_handle: - log_file_handle.write(output) - - # find the recording file and save it - recording_file = self._find_recording_file(test_path) - if recording_file: - shutil.copyfile(recording_file, - os.path.join(storage_mount, self.run_id, f'recording_{task_id}.yaml')) - - result_details = task.get('result_detail', dict()) - result_details['duration'] = int(elapsed.total_seconds() * 1000) - patch = { - 'result': result, - 'result_details': result_details, - 'status': 'completed', - } - self.update_task(task['id'], patch) - - def request_task(self) -> dict: - try: - resp = session.post('http://{}/run/{}/checkout'.format(self.store_host, self.run_id)) - resp.raise_for_status() - - if resp.status_code == 204: - print("No more task. This Droid's work should be completed.") - sys.exit(0) - - return resp.json() - except requests.HTTPError as error: - print(f'Fail to checkout task from the store. {error}') - sys.exit(1) - except ValueError as error: - print(f'Fail to parse the task as JSON. {error}') - sys.exit(1) - - def update_task(self, task_id: str, patch: dict) -> None: - try: - session.patch('http://{}/task/{}'.format(self.store_host, task_id), json=patch).raise_for_status() - except requests.HTTPError as error: - print(f'Fail to update the task\'s details. {error}') - sys.exit(1) - - def retrieve_task(self, task_id: str) -> dict: - try: - resp = session.get('http://{}/task/{}'.format(self.store_host, task_id)) - resp.raise_for_status() - return resp.json() - except requests.HTTPError as error: - print(f'Fail to update the task\'s details. {error}') - sys.exit(1) - except ValueError as error: - print(f'Fail to parse the task as JSON. {error}') - sys.exit(1) - - @staticmethod - def _find_recording_file(test_path: str) -> Optional[str]: - module_path, _, test_method = test_path.rsplit('.', 2) - test_folder = os.path.dirname(import_module(module_path).__file__) - recording_file = os.path.join(test_folder, 'recordings', test_method + '.yaml') - return recording_file if os.path.exists(recording_file) else None - - -def main(): - droid = Droid() - droid.login_azure_cli() - droid.start() - - -if __name__ == '__main__': - main() diff --git a/scripts/ci/a01/docker_app/prepare_pod b/scripts/ci/a01/docker_app/prepare_pod new file mode 100755 index 00000000000..afa56dda015 --- /dev/null +++ b/scripts/ci/a01/docker_app/prepare_pod @@ -0,0 +1,25 @@ +#!/bin/bash + +if [ "$AZURE_TEST_RUN_LIVE" != "True" ]; then + echo "Environment variable AZURE_TEST_RUN_LIVE is NOT True." + exit 0 +fi + +echo "Environment variable AZURE_TEST_RUN_LIVE is True. Login azure with service principal." + +if [ -z "$A01_SP_USERNAME" ]; then + echo "Missing service principal username." >&2 + exit 1 +fi + +if [ -z "$A01_SP_PASSWORD" ]; then + echo "Missing service principal password." >&2 + exit 1 +fi + +if [ -z "$A01_SP_TENANT" ]; then + echo "Missing service principal tenant." >&2 + exit 1 +fi + +az login --service-principal -u $A01_SP_USERNAME -p $A01_SP_PASSWORD -t $A01_SP_TENANT \ No newline at end of file diff --git a/scripts/ci/a01/docker_app/requirements.txt b/scripts/ci/a01/docker_app/requirements.txt deleted file mode 100644 index d514163bdc8..00000000000 --- a/scripts/ci/a01/docker_app/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests==2.18.4 \ No newline at end of file diff --git a/scripts/ci/build_droid.sh b/scripts/ci/build_droid.sh index 9668bc8c3c1..1d694ae8a37 100755 --- a/scripts/ci/build_droid.sh +++ b/scripts/ci/build_droid.sh @@ -29,6 +29,8 @@ cp $dp0/a01/Dockerfile.py36 artifacts/ ############################################# # Move other scripts for docker cp -R $dp0/a01/* artifacts/ +curl -sL https://a01tools.blob.core.windows.net/droid/linux/a01droid -o artifacts/docker_app/a01droid +chmod +x artifacts/docker_app/a01droid ############################################# # for travis repo slug, remove the suffix to reveal the owner