From b2d6db7d82d71c35a66e682b94abd14ac686ac07 Mon Sep 17 00:00:00 2001 From: Arijit Basu Date: Thu, 7 Nov 2019 16:58:46 +0530 Subject: [PATCH] Add some options for more control - Option to choose the checks that needs to be performed. - Option to define a delay for the build and app load stages. - Plus some code improvements and docs update. --- Dockerfile | 5 +- README.md | 52 ++++++++++++++++--- action.yml | 23 +++++++-- review_app_status.py | 119 +++++++++++++++++++++++++++++++++---------- tests.py | 43 ++++++++++++---- 5 files changed, 194 insertions(+), 48 deletions(-) diff --git a/Dockerfile b/Dockerfile index 916450d..0c8baf4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ FROM python:3.7-alpine RUN pip install --upgrade pip RUN pip install requests==2.22.0 -COPY review_app_status.py / +WORKDIR /app +COPY . . -ENTRYPOINT ["python3", "/review_app_status.py"] +CMD ["python3", "/app/review_app_status.py"] diff --git a/README.md b/README.md index 10d202e..8f9690d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Github Action that tests the deployment status of a Heroku Review App. -## Usage: +## Usage * Include the action in the workflow ```yaml name: Review App Test @@ -22,13 +22,16 @@ A Github Action that tests the deployment status of a Heroku Review App. steps: - name: Run review-app test - uses: niteoweb/reviewapps-deploy-status@v1.0.2 + uses: niteoweb/reviewapps-deploy-status@v1.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - interval: 10 # in seconds, optional, default is 10 - accepted_responses: 200 # comma separated status codes, optional, default is 200 - deployments_timeout: 120 # in seconds, optional, default is 120 + checks: build, response # check the build status and if the app is responding properly + build_time_delay: 5 # delay the checks till the app is built, default is 5 seconds + load_time_delay: 5 # delay the checks till the app is loaded after build, default is 5 seconds + interval: 10 # interval to repeat checks, default is 10 seconds + accepted_responses: 200 # comma separated status codes, optional, default is 200 + deployments_timeout: 120 # in seconds, optional, default is 120 ``` > Note: Work flow should include `pull_request` event. @@ -39,10 +42,47 @@ A Github Action that tests the deployment status of a Heroku Review App. | Name | Description | Default | |---|---|---| + | checks | Comma separated list of checks to be performed | All checks: build, response | + | build_time_delay | Delay for the build stage of the review app | 5 | + | load_time_delay | Delay for the app to load and start serving after it is built | 5 | | interval | Wait for this amount of seconds before retrying the build check | 10 | - | accepted_responses | Allow/Accept the specified status codes | 200 | + | accepted_responses | Allow/Accept the specified status codes (comma separated) | 200 | | deployments_timeout | Maximum waiting time (in seconds) to fetch the deployments | 120 | + +## Workflow + +``` +Initialize +├── Build time delay +├── Fetch build data +├── Is `build` check included in the `checks`? +│ ├── Yes +│ │ └── Is the build status a `success`? +│ │ ├── Yes +│ │ │ └── Continue +│ │ └── No +│ │ └── Are we past the `deployments_timeout`? +│ │ ├── Yes +│ │ │ └── Fail +│ │ └── No +│ │ └── Repeat from `Fetch build data` +│ └── No +│ └── Continue +├── Load time delay +├── Is `response` check included in the `checks`? +│ ├── Yes +│ │ ├── Do an HTTP request to the app URL. +│ │ └── Is the HTTP response in the `accepted_responses`? +│ │ ├── Yes +│ │ │ └── Continue +│ │ └── No +│ │ └── Fail +│ └── No +│ └── Continue +└── Done (success) +``` + ## Local Development * Create a Python virtual environment(version > 3.6). * Activate the environment. diff --git a/action.yml b/action.yml index c6c4da1..b50594b 100644 --- a/action.yml +++ b/action.yml @@ -12,15 +12,28 @@ runs: image: 'Dockerfile' inputs: + checks: + description: Comma separated list of checks to perform. + required: false + # Default: Perform all checks + default: build, response + build_time_delay: + description: Delay for the build stage of the review app. + required: false + default: 5 + load_time_delay: + description: Delay for the app to load and start serving after it is built. + required: false + default: 5 interval: - description: Interval to check the status. + description: Interval to repeat the deployment check in seconds. required: false - default: 10 # 10 seconds + default: 10 accepted_responses: - description: Status(es) which can be accepted. Separated by comma. + description: Comma separated status which can be accepted. required: false - default: 200 # All OK status + default: 200 deployments_timeout: description: Maximum waiting time to fetch the deployments. required: false - default: 120 # 120 seconds + default: 120 diff --git a/review_app_status.py b/review_app_status.py index 07d964f..c58cf3e 100644 --- a/review_app_status.py +++ b/review_app_status.py @@ -4,7 +4,9 @@ import logging import os import time -from enum import Enum +import typing as t +from dataclasses import dataclass +from enum import Enum, auto import requests @@ -12,10 +14,46 @@ logger = logging.getLogger("review_app_status") -SUCCESS = "success" # Review App Success Build state +class BuildStates(Enum): + """Expected reviewapp app build states""" + success = "success" -def _make_github_api_request(url): + +class Checks(Enum): + """Available checks""" + + # Check if the build was success + build = auto() + + # Check the HTTP response from app URL + response = auto() + + +@dataclass(frozen=True) +class Args: + """User input arguments""" + + # Checks to be performed + checks: t.List[Checks] + + # Delay for the application to be built in Heroku + build_time_delay: int + + # Delay for the application to load and start serving + load_time_delay: int + + # Interval between the repeating checks + interval: int + + # Acceptable responses for the response check + accepted_responses: t.List[int] + + # Max time spend retrying for the build check. + deployments_timeout: t.List[int] + + +def _make_github_api_request(url: str) -> dict: """Make github API request with `deployment` event specific headers. Input: @@ -33,7 +71,9 @@ def _make_github_api_request(url): return r.json() -def _get_github_deployment_status_url(deployments_url, commit_sha, timeout, interval): +def _get_github_deployment_status_url( + deployments_url: str, commit_sha: str, timeout: int, interval: int +) -> None: """Get deployment_status URL for the head commit. Inputs: deployments_url: This can be obtained from `pull_request` event payload. @@ -60,7 +100,7 @@ def _get_github_deployment_status_url(deployments_url, commit_sha, timeout, inte raise ValueError("No deployment found for the lastest commit.") -def _get_build_data(url, interval): +def _get_build_data(url: str, interval: int) -> dict: """Get Review App build data using Github's `deployment_status` API. Inputs: @@ -92,7 +132,9 @@ def _get_build_data(url, interval): return response[0] -def _check_review_app_deployment_status(review_app_url, accepted_responses): +def _check_review_app_deployment_status( + review_app_url: str, accepted_responses: t.List[int] +): """Check Review App deployment status code against accepted_responses. Inputs: @@ -107,38 +149,63 @@ def _check_review_app_deployment_status(review_app_url, accepted_responses): r.raise_for_status() -def main(): +def main() -> None: """Main workflow. All the inputs are received from workflow as environment variables. """ - interval_arg = int(os.environ["INPUT_INTERVAL"]) - deployments_timeout_arg = int(os.environ["INPUT_DEPLOYMENTS_TIMEOUT"]) - accepted_responses_arg = os.environ["INPUT_ACCEPTED_RESPONSES"] - event_payload_path = os.environ["GITHUB_EVENT_PATH"] - logger.info(f"Statuses being accepted: {accepted_responses_arg}") - accepted_responses = set(map(int, accepted_responses_arg.split(","))) + args = Args( + checks=[Checks[x.strip()] for x in os.environ["INPUT_CHECKS"].split(",")], + build_time_delay=int(os.environ["INPUT_BUILD_TIME_DELAY"]), + load_time_delay=int(os.environ["INPUT_LOAD_TIME_DELAY"]), + interval=int(os.environ["INPUT_INTERVAL"]), + deployments_timeout=int(os.environ["INPUT_DEPLOYMENTS_TIMEOUT"]), + accepted_responses=[ + int(x.strip()) for x in os.environ["INPUT_ACCEPTED_RESPONSES"].split(",") + ], + ) + + # Delay the checks till the app is built + logger.info(f"Build time delay: {args.build_time_delay} seconds") + time.sleep(args.build_time_delay) - with open(event_payload_path) as f: - data = f.read() - pull_request_data = json.loads(data) + logger.info(f"Statuses being accepted: {args.accepted_responses}") + with open(os.environ["GITHUB_EVENT_PATH"]) as f: + pull_request_data = json.load(f) + + # Fetch the GitHub status URL github_deployment_status_url = _get_github_deployment_status_url( - pull_request_data["repository"]["deployments_url"], - pull_request_data["pull_request"]["head"]["sha"], - deployments_timeout_arg, - interval_arg, + deployments_url=pull_request_data["repository"]["deployments_url"], + commit_sha=pull_request_data["pull_request"]["head"]["sha"], + timeout=args.deployments_timeout, + interval=args.interval, ) - reviewapp_build_data = _get_build_data(github_deployment_status_url, interval_arg) - - if reviewapp_build_data["state"] != SUCCESS: - raise ValueError(f"Review App Build state: {reviewapp_build_data['state']}") + # Fetch other build data + reviewapp_build_data = _get_build_data( + url=github_deployment_status_url, interval=args.interval + ) - review_app_url = f"https://{reviewapp_build_data['environment']}.herokuapp.com" + # Perform all the checks now - _check_review_app_deployment_status(review_app_url, accepted_responses) + if Checks.build in args.checks: + # Check if the build was success + build_state = reviewapp_build_data["state"] + if build_state != BuildStates.success.value: + raise ValueError(f"Review App Build state: {build_state}") + + if Checks.response in args.checks: + # Delay the checks till the app is loads + logger.info(f"Load time delay: {args.load_time_delay} seconds") + time.sleep(args.load_time_delay) + + # Check the HTTP response from app URL + review_app_url = f"https://{reviewapp_build_data['environment']}.herokuapp.com" + _check_review_app_deployment_status( + review_app_url=review_app_url, accepted_responses=args.accepted_responses + ) print("Successful") diff --git a/tests.py b/tests.py index 8b64c2b..657697d 100644 --- a/tests.py +++ b/tests.py @@ -60,7 +60,10 @@ def test_get_deployment_status_interval_greater_failure(mock_github_request): with pytest.raises(ValueError) as excinfo: url = _get_github_deployment_status_url( - "https://foo.bar/deployments", "commitsha12345", 3, 4 + deployments_url="https://foo.bar/deployments", + commit_sha="commitsha12345", + timeout=3, + interval=4, ) assert "Interval can't be greater than deployments_timeout." in str(excinfo.value) @@ -77,7 +80,10 @@ def test_get_deployment_status_url_success(mock_github_request): } ] url = _get_github_deployment_status_url( - "https://foo.bar/deployments", "commitsha12345", 2, 1 + deployments_url="https://foo.bar/deployments", + commit_sha="commitsha12345", + timeout=2, + interval=1, ) assert url == "https://foo.bar/deployment/statuses/1" mock_github_request.assert_called_once_with("https://foo.bar/deployments") @@ -93,7 +99,10 @@ def test_get_deployment_status_url_failure(mock_github_request, caplog): ] with pytest.raises(ValueError) as excinfo: url = _get_github_deployment_status_url( - "https://foo.bar/deployments", "commitsha12345", 2, 1 + deployments_url="https://foo.bar/deployments", + commit_sha="commitsha12345", + timeout=2, + interval=1, ) assert ( @@ -154,7 +163,7 @@ def test_get_one_build_data_status(mock_github_request): def test_get_pending_build_data_status(mock_github_request, caplog): from review_app_status import _get_build_data - data = _get_build_data("https://foo.bar/deployments/1/status", 1) + data = _get_build_data(url="https://foo.bar/deployments/1/status", interval=1) assert data == {"id": "1"} assert ( caplog.records[0].message @@ -230,6 +239,9 @@ def test_check_review_app_custom_status_success(caplog): @mock.patch.dict( os.environ, { + "INPUT_CHECKS": "build, response", + "INPUT_BUILD_TIME_DELAY": "5", + "INPUT_LOAD_TIME_DELAY": "5", "INPUT_DEPLOYMENTS_TIMEOUT": "20", "INPUT_INTERVAL": "10", "INPUT_ACCEPTED_RESPONSES": "200, 302", @@ -262,11 +274,16 @@ def test_main_success( mock_file.assert_called_with("./test_path") mock_deployment_status_url.assert_called_once_with( - "http://foo.bar/deployments", "commit12345", 20, 10 + deployments_url="http://foo.bar/deployments", + commit_sha="commit12345", + timeout=20, + interval=10, + ) + mock_build_data.assert_called_once_with( + url="http://foo.bar/deployment_status", interval=10 ) - mock_build_data.assert_called_once_with("http://foo.bar/deployment_status", 10) mock_review_app_deployment.assert_called_once_with( - "https://foo-pr-bar.herokuapp.com", {200, 302} + review_app_url="https://foo-pr-bar.herokuapp.com", accepted_responses=[200, 302] ) out, err = capsys.readouterr() @@ -276,6 +293,9 @@ def test_main_success( @mock.patch.dict( os.environ, { + "INPUT_CHECKS": "build, response", + "INPUT_BUILD_TIME_DELAY": "5", + "INPUT_LOAD_TIME_DELAY": "5", "INPUT_DEPLOYMENTS_TIMEOUT": "20", "INPUT_INTERVAL": "10", "INPUT_ACCEPTED_RESPONSES": "200, 302", @@ -305,9 +325,14 @@ def test_main_failure( mock_file.assert_called_with("./test_path") mock_deployment_status_url.assert_called_once_with( - "http://foo.bar/deployments", "commit12345", 20, 10 + deployments_url="http://foo.bar/deployments", + commit_sha="commit12345", + timeout=20, + interval=10, + ) + mock_build_data.assert_called_once_with( + url="http://foo.bar/deployment_status", interval=10 ) - mock_build_data.assert_called_once_with("http://foo.bar/deployment_status", 10) "Review App Build state: failure" in str(excinfo.value) mock_review_app_deployment.assert_not_called()