diff --git a/CHANGELOG.md b/CHANGELOG.md index e17421f..0a33922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,16 @@ # CHANGELOG +## v0.10.0 (2021-03-10) + +* Overhaul the `stages` modules, improved code readability and documentation +* Added unit tests for the `stages` module +* Various bug fixes + ## v0.9.0 (2021-03-09) * Overhauled the Pipeline/Webhook modules and removed lots of duplicate code * Fixed a bug where the pipeline timer wouldn't account for startup time (closes #35) -* Added unit tests for the `pipelines` modules +* Added unit tests for the `pipelines` module * Various other bug fixes ## v0.8.2 (2021-03-06) diff --git a/harvey/__init__.py b/harvey/__init__.py index 44a8fcb..a0c6216 100644 --- a/harvey/__init__.py +++ b/harvey/__init__.py @@ -5,6 +5,7 @@ from harvey.images import Image from harvey.messages import Message from harvey.pipelines import Pipeline -from harvey.stages import Stage +from harvey.stages import (BuildStage, DeployComposeStage, DeployStage, + TestStage) from harvey.utils import Logs, Utils from harvey.webhooks import Webhook diff --git a/harvey/globals.py b/harvey/globals.py index 2c6f309..c48043a 100644 --- a/harvey/globals.py +++ b/harvey/globals.py @@ -6,7 +6,7 @@ class Global(): """ DOCKER_VERSION = 'v1.41' # Docker API version # TODO: Figure out how to sync this version number with the one in `setup.py` - HARVEY_VERSION = '0.9.0' # Harvey release + HARVEY_VERSION = '0.10.0' # Harvey release PROJECTS_PATH = 'projects' PROJECTS_LOG_PATH = 'logs/projects' HARVEY_LOG_PATH = 'logs/harvey' diff --git a/harvey/pipelines.py b/harvey/pipelines.py index f4eb16f..f618e16 100644 --- a/harvey/pipelines.py +++ b/harvey/pipelines.py @@ -5,7 +5,8 @@ from harvey.git import Git from harvey.globals import Global from harvey.messages import Message -from harvey.stages import Stage +from harvey.stages import (BuildStage, DeployComposeStage, DeployStage, + TestStage) from harvey.utils import Utils SLACK = os.getenv('SLACK') @@ -25,17 +26,28 @@ def initialize_pipeline(cls, webhook): f'Harvey has started a `{config["pipeline"]}` pipeline for `{Global.repo_full_name(webhook)}`.' ) - preamble = f'Running Harvey v{Global.HARVEY_VERSION}\n{config["pipeline"].title()} Pipeline Started: {start_time}' # noqa - pipeline_id = f'Pipeline ID: {Global.repo_commit_id(webhook)}\n' + preamble = ( + f'Running Harvey v{Global.HARVEY_VERSION}' + f'\n{config["pipeline"].title()} Pipeline Started: {start_time}' + ) + pipeline_id = f'Pipeline ID: {Global.repo_commit_id(webhook)}' print(preamble) - git_message = (f'New commit by: {Global.repo_commit_author(webhook)}.' - f'\nCommit made on repo: {Global.repo_full_name(webhook)}.') + git_message = ( + f'New commit by: {Global.repo_commit_author(webhook)}.' + f'\nCommit made on repo: {Global.repo_full_name(webhook)}.' + ) git = Git.update_git_repo(webhook) execution_time = f'Startup execution time: {datetime.now() - start_time}\n' - output = (f'{preamble}\n{pipeline_id}Configuration:\n{json.dumps(config, indent=4)}' - f'\n\n{git_message}\n{git}\n{execution_time}') + output = ( + f'{preamble}' + f'\n{pipeline_id}' + f'\nConfiguration:\n{json.dumps(config, indent=4)}' + f'\n\n{git_message}' + f'\n{git}' + f'\n{execution_time}' + ) print(execution_time) return config, output, start_time @@ -58,7 +70,12 @@ def start_pipeline(cls, webhook, use_compose=False): execution_time = f'Pipeline execution time: {end_time - start_time}' pipeline_status = 'Pipeline succeeded!' - final_output = f'{webhook_output}\n{test}\n{execution_time}\n{pipeline_status}' + final_output = ( + f'{webhook_output}' + f'\n{test}' + f'\n{execution_time}' + f'\n{pipeline_status}' + ) if pipeline in ['deploy', 'full']: build, deploy, healthcheck = cls.deploy(webhook_config, webhook, webhook_output, start_time, use_compose) # noqa @@ -68,7 +85,13 @@ def start_pipeline(cls, webhook, use_compose=False): execution_time = f'Pipeline execution time: {end_time - start_time}' pipeline_status = 'Pipeline succeeded!' - final_output = f'{webhook_output}\n{stage_output}\n{execution_time}\n{healthcheck_message}\n{pipeline_status}' # noqa + final_output = ( + f'{webhook_output}' + f'\n{stage_output}' + f'\n{execution_time}' + f'\n{healthcheck_message}' + f'\n{pipeline_status}' + ) Utils.success(final_output, webhook) else: @@ -108,13 +131,18 @@ def open_project_config(cls, webhook): def test(cls, config, webhook, output, start_time): """Run the test stage in a pipeline """ - test = Stage.test(config, webhook, output) + test = TestStage.run(config, webhook, output) if 'Error: the above command exited with code' in test: # TODO: Ensure this works, it may be broken end_time = datetime.now() pipeline_status = 'Pipeline failed!' execution_time = f'Pipeline execution time: {end_time - start_time}' - final_output = f'{output}\n{test}\n{execution_time}\n{pipeline_status}' + final_output = ( + f'{output}' + f'\n{test}' + f'\n{execution_time}' + f'\n{pipeline_status}' + ) Utils.kill(final_output, webhook) return test @@ -125,20 +153,27 @@ def deploy(cls, config, webhook, output, start_time, use_compose): """ if use_compose: build = '' # When using compose, there is no build step - deploy = Stage.build_deploy_compose(config, webhook, output) + deploy = DeployComposeStage.run(config, webhook, output) # healthcheck = Stage.run_container_healthcheck(webhook) # TODO: Correct healthchecks for compose healthcheck = True else: - build = Stage.build(config, webhook, output) - deploy = Stage.deploy(webhook, output) - healthcheck = Stage.run_container_healthcheck(webhook) + build = BuildStage.run(config, webhook, output) + deploy = DeployStage.run(webhook, output) + healthcheck = DeployStage.run_container_healthcheck(webhook) if healthcheck is False: end_time = datetime.now() pipeline_status = 'Pipeline failed due to a bad healthcheck.' execution_time = f'Pipeline execution time: {end_time - start_time}' healthcheck_message = f'Project passed healthcheck: {healthcheck}' - final_output = f'{output}\n{build}\n{deploy}\n{execution_time}\n{healthcheck_message}\n{pipeline_status}' + final_output = ( + f'{output}' + f'\n{build}' + f'\n{deploy}' + f'\n{execution_time}' + f'\n{healthcheck_message}' + f'\n{pipeline_status}' + ) Utils.kill(final_output, webhook) return build, deploy, healthcheck diff --git a/harvey/stages.py b/harvey/stages.py index 7fd9be3..505d994 100644 --- a/harvey/stages.py +++ b/harvey/stages.py @@ -9,11 +9,19 @@ from harvey.utils import Utils -# TODO: Break up each Stage into a separate class, practice DRY -class Stage(): +class TestStage(): @classmethod - def test(cls, config, webhook, output): - """Test Stage + def run(cls, config, webhook, output): + """Test Stage, used in "test" and "full" pipelines + + 1. Build an image + 2. Create a container + 3. Run your tests in a contained environment and wait for it to complete + 4. Grab the logs from the container after exiting + 5. Tear down the test env + + This function is intentionally long as everything it tightly coupled and cascades + requiring that each step receive info from the last step in the process. """ start_time = datetime.now() test_project_name = f'test-{Global.docker_project_name(webhook)}-{config["language"]}-{config["version"]}' @@ -28,9 +36,12 @@ def test(cls, config, webhook, output): final_output = 'Error: Harvey timed out building the Test image.' print(final_output) Utils.kill(final_output, webhook) - except subprocess.CalledProcessError: - # TODO: Figure out how to send docker build output if this fails - final_output = output + '\nError: Harvey could not build the Test image.' + except subprocess.CalledProcessError as error: + final_output = ( + f'{output}' + '\nError: Harvey could not build the Test image.' + f'\n{error.output}' + ) print(final_output) Utils.kill(final_output, webhook) @@ -40,8 +51,12 @@ def test(cls, config, webhook, output): container_output = 'Test container created.' print(container_output) else: - final_output = output + image_output + \ - '\nError: Harvey could not create the Test container.' + final_output = ( + f'{output}' + f'\n{image_output}' + f'\nError: Harvey could not create the Test container.' + ) + # Cleanup items as a result of failure so we don't leave orphaned images/containers Image.remove_image(test_project_name) Utils.kill(final_output, webhook) @@ -51,8 +66,13 @@ def test(cls, config, webhook, output): start_output = 'Test container started.' print(start_output) else: - final_output = output + image_output + container_output + \ + final_output = ( + f'{output}' + f'\n{image_output}' + f'\n{container_output}' '\nError: Harvey could not start the container.' + ) + # Cleanup items as a result of failure so we don't leave orphaned images/containers Image.remove_image(image[0]) Container.remove_container(test_project_name) Utils.kill(final_output, webhook) @@ -63,8 +83,14 @@ def test(cls, config, webhook, output): wait_output = 'Waiting for Test container to exit.' print(wait_output) else: - final_output = output + image_output + container_output + start_output + \ + final_output = ( + f'{output}' + f'\n{image_output}' + f'\n{container_output}' + f'\n{start_output}' '\nError: Harvey could not wait for the container.' + ) + # Cleanup items as a result of failure so we don't leave orphaned images/containers Image.remove_image(image[0]) Container.remove_container(test_project_name) Utils.kill(final_output, webhook) @@ -72,13 +98,23 @@ def test(cls, config, webhook, output): # Return logs logs = Container.inspect_container_logs(test_project_name) if logs: - logs_output = '\nTest logs:\n' + \ - '============================================================\n' \ - + logs + '============================================================\n' + logs_output = ( + '\nTest logs:\n' + '============================================================\n' + f'{logs}' + '============================================================\n' + ) print(logs_output) else: - final_output = output + image_output + container_output + start_output + wait_output + \ - '\nError: Harvey could not create the container logs.' + final_output = ( + f'{output}' + f'\n{image_output}' + f'\n{container_output}' + f'\n{start_output}' + f'\n{wait_output}' + f'\nError: Harvey could not create the container logs.' + ) + # Cleanup items as a result of failure so we don't leave orphaned images/containers Image.remove_image(image[0]) Container.remove_container(test_project_name) Utils.kill(final_output, webhook) @@ -90,23 +126,42 @@ def test(cls, config, webhook, output): remove_output = 'Test container and image removed.' print(remove_output) else: - final_output = output + image_output + container_output + start_output + \ - wait_output + logs_output + \ + final_output = ( + f'{output}' + f'\n{image_output}' + f'\n{container_output}' + f'\n{start_output}' + f'\n{wait_output}' + f'\n{logs_output}' '\nError: Harvey could not remove the container and/or image.' + ) + # Cleanup items as a result of failure so we don't leave orphaned images/containers Image.remove_image(image[0]) Container.remove_container(test_project_name) Utils.kill(final_output, webhook) execution_time = f'\nTest stage execution time: {datetime.now() - start_time}' - final_output = f'{image_output}\n{container_output}\n{start_output}\n{wait_output}\n\ - {logs_output}\n{remove_output}\n{execution_time}\n' + final_output = ( + f'{image_output}' + f'\n{container_output}' + f'\n{start_output}' + f'\n{wait_output}' + f'\n{logs_output}' + f'\n{remove_output}' + f'\n{execution_time}' + ) print(execution_time) return final_output + +class BuildStage(): @classmethod - def build(cls, config, webhook, output): - """Build Stage + def run(cls, config, webhook, output): + """Build Stage, used in "deploy" and "full" pipelines + + 1. Remove the image if it already exists to ensure a clean start + 1. Build the image """ start_time = datetime.now() @@ -115,7 +170,9 @@ def build(cls, config, webhook, output): Image.remove_image(Global.docker_project_name(webhook)) image = Image.build_image(config, webhook) image_output = f'Project image created\n{image}' - print(image_output) + execution_time = f'Build stage execution time: {datetime.now() - start_time}' + final_output = f'{image_output}\n{execution_time}\n' + print(final_output) except subprocess.TimeoutExpired: final_output = 'Error: Harvey timed out during the build stage.' print(final_output) @@ -124,15 +181,19 @@ def build(cls, config, webhook, output): final_output = output + '\nError: Harvey could not finish the build stage.' Utils.kill(final_output, webhook) - execution_time = f'Build stage execution time: {datetime.now() - start_time}' - final_output = f'{image_output}\n{execution_time}\n' - print(execution_time) - return final_output + +class DeployStage(): @classmethod - def deploy(cls, webhook, output): - """Deploy Stage + def run(cls, webhook, output): + """Deploy Stage, used in "deploy" and "full" pipelines + + 1. Stop a container by the same name as the one we want to deploy + 2. Wait for the container to stop + 3. Delete the old container + 4. Create a new container + 5. Start the new container """ start_time = datetime.now() @@ -143,6 +204,8 @@ def deploy(cls, webhook, output): # TODO: Determine what the best method for tearing down old containers is # For instance, what if the user stopped a container explicitly and now it # will be overriden? What about volume persistence? + + # Stop the old container stop_container = Container.stop_container(Global.docker_project_name(webhook)) if stop_container.status_code == 204: stop_output = f'Stopping old {Global.docker_project_name(webhook)} container.' @@ -152,9 +215,10 @@ def deploy(cls, webhook, output): elif stop_container.status_code == 404: stop_output = '' else: - # TODO: Add missing logic here stop_output = 'Error: Harvey could not stop the container.' print(stop_output) + + # Wait for the container to stop wait_container = Container.wait_container(Global.docker_project_name(webhook)) if wait_container.status_code == 200: wait_output = f'Waiting for old {Global.docker_project_name(webhook)} container to exit...' @@ -162,10 +226,11 @@ def deploy(cls, webhook, output): elif wait_container.status_code == 404: wait_output = '' else: - # TODO: Add missing logic here print( f'Error: Harvey could not wait for the {Global.docker_project_name(webhook)} container: {wait_container.json()}' # noqa ) + + # Remove the old container remove_container = Container.remove_container(Global.docker_project_name(webhook)) if remove_container.status_code == 204: remove_output = f'Removing old {Global.docker_project_name(webhook)} container.' @@ -173,7 +238,6 @@ def deploy(cls, webhook, output): elif remove_container.status_code == 404: remove_output = '' else: - # TODO: Add missing logic here print( f'Error: Harvey could not remove the {Global.docker_project_name(webhook)} container: {remove_container.json()}' # noqa ) @@ -184,8 +248,13 @@ def deploy(cls, webhook, output): create_output = f'{Global.docker_project_name(webhook)} container created.' print(create_output) else: - final_output = output + stop_output + wait_output + remove_output + \ + final_output = ( + f'{output}' + f'\n{stop_output}' + f'\n{wait_output}' + f'\n{remove_output}' f'\nError: Harvey could not create the container in the deploy stage: {create_container.json()}' + ) Utils.kill(final_output, webhook) # Start the container @@ -194,72 +263,87 @@ def deploy(cls, webhook, output): start_output = f'{Global.docker_project_name(webhook)} container started.' print(start_output) else: - final_output = output + stop_output + wait_output + remove_output + \ - create_output + \ + final_output = ( + f'{output}' + f'\n{stop_output}' + f'\n{wait_output}' + f'\n{remove_output}' + f'\n{create_output}' f'\nError: Harvey could not start the container in the deploy stage: {start_container.json()}' + ) Utils.kill(final_output, webhook) execution_time = f'\nDeploy stage execution time: {datetime.now() - start_time}' - final_output = f'{stop_output}\n{wait_output}\n{remove_output}\n{create_output}\n' + \ - f'{start_output}\n{execution_time}\n' + final_output = ( + f'{stop_output}' + f'\n{wait_output}' + f'\n{remove_output}' + f'\n{create_output}' + f'\n{start_output}' + f'\n{execution_time}' + ) print(execution_time) return final_output @classmethod - def build_deploy_compose(cls, config, webhook, output): - """Build Stage - USING A DOCKER COMPOSE FILE + def run_container_healthcheck(cls, webhook, retry_attempt=0): + """Run a healthcheck to ensure the container is running and not in a transitory state. + Not to be confused with the Docker Healthcheck functionality which is different. + + If we cannot inspect a container, it may not be up and running yet, we'll retry + a few times before abandoning the healthcheck. + """ + healthcheck = False + container = Container.inspect_container(Global.docker_project_name(webhook)) + container_json = container.json() + state = container_json.get('State') + + if state and state['Running'] is True: + healthcheck = True + return healthcheck + elif retry_attempt <= 4: + retry_attempt += 1 + time.sleep(5) + cls.run_container_healthcheck(webhook, retry_attempt) + + return healthcheck + + +class DeployComposeStage(): + @classmethod + def run(cls, config, webhook, output): + """Build Stage, used for `deploy` pipelines that hit the `compose` endpoint + + This flow doesn't use the standard Docker API and instead shells out to run + `docker-compose` commands, perfect for projects with docker-compose.yml files. """ start_time = datetime.now() - if "compose" in config: - compose = f'-f {config["compose"]}' - else: - compose = '' + compose = f'-f {config["compose"]}' if config.get('compose') else None # Build the image and container from the docker-compose file try: - compose = subprocess.check_output( + compose_command = subprocess.check_output( f'cd {os.path.join(Global.PROJECTS_PATH, Global.repo_full_name(webhook))} \ && docker-compose {compose} up -d --build', stdin=None, stderr=None, shell=True, - timeout=Global.BUILD_TIMEOUT + timeout=Global.BUILD_TIMEOUT, + ) + compose_output = compose_command.decode('UTF-8') + execution_time = f'Build/Deploy stage execution time: {datetime.now() - start_time}' + final_output = ( + f'{compose_output}' + f'\n{execution_time}' ) - compose_output = compose.decode('UTF-8') - print(compose_output) + print(final_output) except subprocess.TimeoutExpired: final_output = 'Error: Harvey timed out during the docker compose build/deploy stage.' print(final_output) Utils.kill(final_output, webhook) except subprocess.CalledProcessError: - final_output = output + '\nError: Harvey could not finish the build/deploy compose stage.' + final_output = f'{output}\nError: Harvey could not finish the build/deploy compose stage.' Utils.kill(final_output, webhook) - execution_time = f'Build/Deploy stage execution time: {datetime.now() - start_time}' - final_output = f'{compose_output}\n{execution_time}\n' - print(execution_time) - return final_output - - @classmethod - def run_container_healthcheck(cls, webhook, retry_attempt=0): - """Run a healthcheck to ensure the container is running and not in a transitory state. - Not to be confused with the Docker Healthcheck functionality which is different - """ - print(f'Running healthcheck for {Global.docker_project_name(webhook)}...') - - # If we cannot inspect a container, it may not be up and running yet, retry - container = Container.inspect_container(Global.docker_project_name(webhook)) - container_json = container.json() - state = container_json.get('State') - if not state and retry_attempt <= 4: - retry_attempt += 1 - time.sleep(5) - cls.run_container_healthcheck(webhook, retry_attempt) - elif state and state['Running'] is True: - healthcheck = True - else: - healthcheck = False - - return healthcheck diff --git a/setup.py b/setup.py index 499672e..7e02bbf 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setuptools.setup( name='harvey-ci', - version='0.9.0', + version='0.10.0', description='Your personal CI/CD and Docker orchestration platform.', long_description=long_description, long_description_content_type="text/markdown", diff --git a/test/unit/conftest.py b/test/unit/conftest.py index f1e3654..75ddbf8 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -69,11 +69,12 @@ def mock_response(status=201, json_data={'mock': 'json'}): # TODO: Make this fixture work and put it in the `test_build_image` test -def mock_config(pipeline='deploy', language='python', version='3.9'): +def mock_config(pipeline='deploy', language='python', version='3.9', compose=None): return { 'pipeline': pipeline, 'language': language, 'version': version, + 'compose': compose if compose else None, } diff --git a/test/unit/test_pipelines.py b/test/unit/test_pipelines.py index 8da354a..663bdb8 100644 --- a/test/unit/test_pipelines.py +++ b/test/unit/test_pipelines.py @@ -103,7 +103,7 @@ def test_start_pipeline_bad_pipeline_name(mock_initialize_pipeline, mock_utils_k ) -@mock.patch('harvey.stages.Stage.test') +@mock.patch('harvey.stages.TestStage.run') def test_test_pipeline_success(mock_test_stage, mock_webhook): _ = Pipeline.test(mock_config('test'), mock_webhook, MOCK_OUTPUT, MOCK_TIME) @@ -111,7 +111,7 @@ def test_test_pipeline_success(mock_test_stage, mock_webhook): @mock.patch('harvey.utils.Utils.kill') -@mock.patch('harvey.stages.Stage.test', return_value='Error: the above command exited with code') +@mock.patch('harvey.stages.TestStage.run', return_value='Error: the above command exited with code') def test_test_pipeline_error(mock_test_stage, mock_utils_kill, mock_webhook): _ = Pipeline.test(mock_config('test'), mock_webhook, MOCK_OUTPUT, MOCK_TIME) @@ -119,16 +119,16 @@ def test_test_pipeline_error(mock_test_stage, mock_utils_kill, mock_webhook): mock_utils_kill.assert_called_once() -@mock.patch('harvey.stages.Stage.build_deploy_compose') +@mock.patch('harvey.stages.DeployComposeStage.run') def test_deploy_pipeline_compose_success(mock_deploy_stage, mock_webhook): _, _, _ = Pipeline.deploy(mock_config('deploy'), mock_webhook, MOCK_OUTPUT, MOCK_TIME, True) mock_deploy_stage.assert_called_once_with(mock_config('deploy'), mock_webhook, MOCK_OUTPUT) -@mock.patch('harvey.stages.Stage.run_container_healthcheck') -@mock.patch('harvey.stages.Stage.build') -@mock.patch('harvey.stages.Stage.deploy') +@mock.patch('harvey.stages.DeployStage.run_container_healthcheck') +@mock.patch('harvey.stages.BuildStage.run') +@mock.patch('harvey.stages.DeployStage.run') def test_deploy_pipeline_no_compose_success(mock_deploy_stage, mock_build_stage, mock_run_container_healthcheck, mock_webhook): _, _, _ = Pipeline.deploy(mock_config('deploy'), mock_webhook, MOCK_OUTPUT, MOCK_TIME, False) @@ -139,9 +139,9 @@ def test_deploy_pipeline_no_compose_success(mock_deploy_stage, mock_build_stage, @mock.patch('harvey.utils.Utils.kill') -@mock.patch('harvey.stages.Stage.run_container_healthcheck', return_value=False) -@mock.patch('harvey.stages.Stage.build') -@mock.patch('harvey.stages.Stage.deploy') +@mock.patch('harvey.stages.DeployStage.run_container_healthcheck', return_value=False) +@mock.patch('harvey.stages.BuildStage.run') +@mock.patch('harvey.stages.DeployStage.run') def test_deploy_pipeline_no_compose_failed_healthcheck(mock_deploy_stage, mock_build_stage, mock_run_container_healthcheck, mock_utils_kill, mock_webhook): _, _, healthcheck = Pipeline.deploy(mock_config('deploy'), mock_webhook, MOCK_OUTPUT, MOCK_TIME, False) diff --git a/test/unit/test_stages.py b/test/unit/test_stages.py index faf1436..6d63c46 100644 --- a/test/unit/test_stages.py +++ b/test/unit/test_stages.py @@ -1,9 +1,42 @@ -from test.unit.conftest import \ - mock_response_container # Remove once fixtures are fixed +import subprocess +from test.unit.conftest import mock_config # Remove once fixtures are fixed +from test.unit.conftest import mock_response_container import mock from harvey.globals import Global -from harvey.stages import Stage +from harvey.stages import BuildStage, DeployComposeStage, DeployStage + +MOCK_OUTPUT = 'mock output' + + +@mock.patch('harvey.images.Image.remove_image') +@mock.patch('subprocess.check_output') +def test_build_stage_success(mock_subprocess, mock_remove_image, mock_webhook): + # TODO: Mock the subprocess better to ensure it does what it's supposed to + _ = BuildStage.run(mock_config('deploy'), mock_webhook, MOCK_OUTPUT) + + mock_remove_image.assert_called_once() + mock_subprocess.assert_called_once() + + +@mock.patch('harvey.images.Image.remove_image') +@mock.patch('harvey.utils.Utils.kill') +@mock.patch('subprocess.check_output', side_effect=subprocess.TimeoutExpired(cmd=subprocess.check_output, timeout=0.1)) # noqa +def test_build_stage_subprocess_timeout(mock_subprocess, mock_utils_kill, mock_remove_image, mock_project_path, mock_webhook): # noqa + _ = BuildStage.run(mock_config('deploy'), mock_webhook, MOCK_OUTPUT) + + mock_remove_image.assert_called_once() + mock_utils_kill.assert_called_once() + + +@mock.patch('harvey.images.Image.remove_image') +@mock.patch('harvey.utils.Utils.kill') +@mock.patch('subprocess.check_output', side_effect=subprocess.CalledProcessError(returncode=1, cmd=subprocess.check_output)) # noqa +def test_build_stage_process_error(mock_subprocess, mock_utils_kill, mock_remove_image, mock_project_path, mock_webhook): # noqa + _ = BuildStage.run(mock_config('deploy'), mock_webhook, MOCK_OUTPUT) + + mock_remove_image.assert_called_once() + mock_utils_kill.assert_called_once() @mock.patch('time.sleep', return_value=None) @@ -11,7 +44,7 @@ return_value=mock_response_container(status=200, dead=False, paused=False, restarting=False, running=True)) def test_run_container_healthcheck_success(mock_container_json, mock_sleep, mock_webhook): - healthcheck = Stage.run_container_healthcheck(mock_webhook) + healthcheck = DeployStage.run_container_healthcheck(mock_webhook) mock_container_json.assert_called_once_with(Global.docker_project_name(mock_webhook)) assert healthcheck is True @@ -22,7 +55,46 @@ def test_run_container_healthcheck_success(mock_container_json, mock_sleep, mock return_value=mock_response_container(status=500, dead=True, paused=False, restarting=False, running=False)) def test_run_container_healthcheck_failed(mock_container_json, mock_sleep, mock_webhook): - healthcheck = Stage.run_container_healthcheck(mock_webhook) + """This test checks that if a healthcheck fails, we properly retry + """ + healthcheck = DeployStage.run_container_healthcheck(mock_webhook) - mock_container_json.assert_called_once_with(Global.docker_project_name(mock_webhook)) + mock_container_json.assert_called_with(Global.docker_project_name(mock_webhook)) + assert mock_container_json.call_count == 6 assert healthcheck is False + + +@mock.patch('subprocess.check_output') +def test_deploy_compose_stage_success(mock_subprocess, mock_webhook): + # TODO: Mock the subprocess better to ensure it does what it's supposed to + _ = DeployComposeStage.run(mock_config('deploy'), mock_webhook, MOCK_OUTPUT) + + mock_subprocess.assert_called_once() + + +@mock.patch('harvey.utils.Utils.kill') +@mock.patch('subprocess.check_output', side_effect=subprocess.TimeoutExpired(cmd=subprocess.check_output, timeout=0.1)) # noqa +def test_deploy_compose_stage_subprocess_timeout(mock_subprocess, mock_utils_kill, mock_project_path, mock_webhook): # noqa + _ = DeployComposeStage.run(mock_config('deploy'), mock_webhook, MOCK_OUTPUT) + + mock_utils_kill.assert_called_once() + + +@mock.patch('harvey.utils.Utils.kill') +@mock.patch('subprocess.check_output', side_effect=subprocess.CalledProcessError(returncode=1, cmd=subprocess.check_output)) # noqa +def test_deploy_compose_stage_process_error(mock_subprocess, mock_utils_kill, mock_project_path, mock_webhook): # noqa + _ = DeployComposeStage.run(mock_config('deploy'), mock_webhook, MOCK_OUTPUT) + + mock_utils_kill.assert_called_once() + + +@mock.patch('subprocess.check_output') +def test_deploy_compose_stage_custom_compose_success(mock_subprocess, mock_webhook): + """This test simulates having a custom compose command set in the Harvey config + file - using two docker-compose files for instance + """ + # TODO: Mock the subprocess better to ensure it does what it's supposed to + config = mock_config('deploy', compose='docker-compose.yml -f docker-compose-prod.yml') + _ = DeployComposeStage.run(config, mock_webhook, MOCK_OUTPUT) + + mock_subprocess.assert_called_once() diff --git a/test/unit/test_webhooks.py b/test/unit/test_webhooks.py index aa69f7e..cc73328 100644 --- a/test/unit/test_webhooks.py +++ b/test/unit/test_webhooks.py @@ -43,6 +43,19 @@ def test_parse_webhook_no_json(mock_start_pipeline): assert webhook[1] == 422 +@mock.patch('harvey.webhooks.Webhook.decode_webhook', return_value=False) +@mock.patch('harvey.globals.Global.APP_MODE', 'prod') +@mock.patch('harvey.webhooks.Pipeline.start_pipeline') +def test_parse_webhook_bad_webhook_secret(mock_start_pipeline, mock_webhook_object): + webhook = Webhook.parse_webhook(mock_webhook_object, False) + + assert webhook[0] == { + 'message': 'The X-Hub-Signature did not match the WEBHOOK_SECRET.', + 'success': False + } + assert webhook[1] == 403 + + @mock.patch('harvey.webhooks.WEBHOOK_SECRET', '123') @mock.patch('harvey.webhooks.APP_MODE', 'prod') @pytest.mark.skip('Security is hard, revisit later - but this logic does work')