Commit c310a110 authored by nimrod's avatar nimrod
Browse files

Add a Compose health check script.

This script does a better at checking the health of the Docker Compose
project.
parent 3ea988c8
Loading
Loading
Loading
Loading
Loading
+45 −0
Original line number Diff line number Diff line
@@ -80,3 +80,48 @@ repos:
        args:
          - "--server"
          - https://git.shore.co.il

  - repo: https://github.com/ambv/black.git
    rev: 21.9b0
    hooks:
      - id: black
        args:
          - |
              --line-length=79

  - repo: https://github.com/PyCQA/prospector.git
    rev: 1.5.1
    hooks:
      - id: prospector
        args:
          - |-
            --max-line-length=79
          - |-
            --with-tool=pyroma
          - |-
            --with-tool=bandit
          - |-
            --without-tool=pep257
          - |-
            --doc-warnings
          - |-
            --test-warnings
          - |-
            --full-pep8
          - |-
            --strictness=high
          - |-
            --no-autodetect
        additional_dependencies:
          - bandit
          - pyroma

  - repo: https://gitlab.com/pycqa/flake8.git
    rev: 3.9.2
    hooks:
      - id: flake8
        args:
          - |-
            --doctests
        additional_dependencies:
          - flake8-bugbear
+6 −1
Original line number Diff line number Diff line
FROM docker:20.10
# hadolint ignore=DL3018
RUN apk add --update-cache --no-cache docker-compose
RUN apk add --update-cache --no-cache \
        docker-compose \
        docker-py \
        py3-dotenv \
    && \
    rm -rf /root/.cache /tmp/* /var/tmp/*
+113 −0
Original line number Diff line number Diff line
#!/usr/bin/env python3
# pylint: disable=invalid-name
"""Checks the health of a Docker Compose deployment."""


import argparse
import datetime
import os
import sys
import time
import docker  # pylint: disable=import-error
import dotenv  # pylint: disable=import-error


def get_project_containers_health(client, project):
    """Get a Docker connection and the Docker Compose project name and return a
    dictionary of container objects and their health status (if available, else
    their status)."""
    return {
        i: i.attrs["State"]["Health"]["Status"]
        if "Health" in i.attrs["State"]
        else i.attrs["State"]["Status"]
        for i in client.containers.list(
            filters={"label": f"com.docker.compose.project={project}"}
        )
    }


def check_project_health(project, timeout):
    """Checks if a Docker Compose project is healthy.

    If all of the containers are healthy or running (because there's no
    healthcheck set), return True. If any of the containers is starting (health
    check hasn't run yet or in the grace period), sleep 10 and try again
    (ignoring the timeout). Otherwise, if haven't passed the timeout, sleep 10
    and try again, if over the timeout, return if all the containers are
    healthy or running.
    """
    starttime = datetime.datetime.now()
    deadline = starttime + datetime.timedelta(seconds=timeout)
    docker_client = docker.from_env()
    docker_client.ping()
    while True:
        healths = get_project_containers_health(docker_client, project)
        # pylint: disable=no-else-return
        if all(map(lambda x: x in ["healthy", "running"], healths.values())):
            return True
        elif any(map(lambda x: x == "starting", healths.values())):
            time.sleep(10)
            continue
        elif datetime.datetime.now() > deadline:
            break
        else:
            time.sleep(10)

    if all(map(lambda x: x in ["healthy", "running"], healths.values())):
        return True
    unhealthy = ", ".join(
        [
            f"{x.id}({x.name})"
            for x in healths
            if healths[x] not in ["healthy", "running"]
        ]
    )
    print(f"""Containers {unhealthy} are not healthy.""")
    return False


def main():
    """Main entrypoint."""

    epilog = (
        "The Docker Compose project name is resolved in the following order:"  # noqa: E501
        "\n1. From the command line parameter."
        "\n2. From the COMPOSE_PROJECT_NAME variable in the .env file."
        "\n3. From the COMPOSE_PROJECT_NAME environment variable."
    )
    arg_parser = argparse.ArgumentParser(
        description=__doc__,
        epilog=epilog,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    arg_parser.add_argument(
        "project", help="Name of the Compose Project.", nargs="?"
    )
    arg_parser.add_argument(
        "-t",
        "--timeout",
        help="Number of seconds to wait for the deployment to be healthy, defaults to 300.",  # noqa: E501
        type=int,
        default=300,
    )
    args = arg_parser.parse_args()
    if args.project:
        project = args.project
    else:
        env = dotenv.dotenv_values(dotenv_path=".env")
        if "COMPOSE_PROJECT_NAME" in env:
            project = env["COMPOSE_PROJECT_NAME"]
        elif "COMPOSE_PROJECT_NAME" in os.environ:
            project = os.environ["COMPOSE_PROJECT_NAME"]
        else:
            arg_parser.error(
                "Compose project wasn't specified, the COMPOSE_PROJECT_NAME variable is missing from the environment and from the .env file."  # noqa: E501
            )

    if check_project_health(project, args.timeout):
        return 0
    return 1


if __name__ == "__main__":
    sys.exit(main())