diff --git a/.copier-answers.image-template.yml b/.copier-answers.image-template.yml new file mode 100644 index 0000000..20297c7 --- /dev/null +++ b/.copier-answers.image-template.yml @@ -0,0 +1,21 @@ +# Changes here will be overwritten by Copier; do NOT edit manually +_commit: v0.1.2 +_src_path: https://github.com/Tecnativa/image-template.git +dockerhub_image: tecnativa/docker-socket-proxy +image_platforms: + - linux/386 + - linux/amd64 + - linux/arm/v6 + - linux/arm/v7 + - linux/arm/v8 + - linux/arm64 + - linux/ppc64le + - linux/s390x +main_branches: + - master +project_name: docker-socket-proxy +project_owner: Tecnativa +push_to_ghcr: true +pytest: true +python_versions: + - "3.8" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..625c269 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,164 @@ +name: Build, Test & Deploy + +"on": + pull_request: + push: + branches: + - master + workflow_dispatch: + inputs: + pytest_addopts: + description: + Extra options for pytest; use -vv for full details; see + https://docs.pytest.org/en/latest/example/simple.html#how-to-change-command-line-options-defaults + required: false + +env: + LANG: "en_US.utf-8" + LC_ALL: "en_US.utf-8" + PIP_CACHE_DIR: ${{ github.workspace }}/.cache.~/pip + PIPX_HOME: ${{ github.workspace }}/.cache.~/pipx + POETRY_CACHE_DIR: ${{ github.workspace }}/.cache.~/pypoetry + POETRY_VIRTUALENVS_IN_PROJECT: "true" + PYTEST_ADDOPTS: ${{ github.event.inputs.pytest_addopts }} + PYTHONIOENCODING: "UTF-8" + +jobs: + build-test: + runs-on: ubuntu-20.04 + strategy: + matrix: + python: + - 3.8 + steps: + # Prepare environment + - uses: actions/checkout@v2 + # Set up and run tests + - name: Install python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: Generate cache key CACHE + run: + echo "CACHE=${{ secrets.CACHE_DATE }} ${{ runner.os }} $(python -VV | + sha256sum | cut -d' ' -f1) ${{ hashFiles('pyproject.toml') }} ${{ + hashFiles('poetry.lock') }}" >> $GITHUB_ENV + - uses: actions/cache@v2 + with: + path: | + .cache.~ + .venv + ~/.local/bin + key: venv ${{ env.CACHE }} + - run: pip install poetry + - name: Patch $PATH + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + - run: poetry install + # Run tests + - run: poetry run pytest --prebuild + build-push: + runs-on: ubuntu-20.04 + services: + registry: + image: registry:2 + ports: + - 5000:5000 + env: + DOCKER_IMAGE_NAME: ${{ github.repository }} + DOCKERHUB_IMAGE_NAME: tecnativa/docker-socket-proxy + PUSH: ${{ toJSON(github.event_name != 'pull_request') }} + steps: + # Set up Docker Environment + - uses: actions/checkout@v2 + - uses: actions/cache@v2 + with: + path: | + /tmp/.buildx-cache + key: buildx|${{ secrets.CACHE_DATE }}|${{ runner.os }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + with: + driver-opts: network=host + install: true + # Build and push + - name: Docker meta for local images + id: docker_meta_local + uses: crazy-max/ghaction-docker-meta@v1 + with: + images: localhost:5000/${{ env.DOCKER_IMAGE_NAME }} + tag-edge: true + tag-semver: | + {{version}} + {{major}} + {{major}}.{{minor}} + - name: Build and push to local (test) registry + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + platforms: | + linux/386 + linux/amd64 + linux/arm/v6 + linux/arm/v7 + linux/arm/v8 + linux/arm64 + linux/ppc64le + linux/s390x + load: false + push: true + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache,mode=max + labels: ${{ steps.docker_meta_local.outputs.labels }} + tags: ${{ steps.docker_meta_local.outputs.tags }} + # Next jobs only happen outside of pull requests and on main branches + - name: Login to DockerHub + if: ${{ fromJSON(env.PUSH) }} + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_LOGIN }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GitHub Container Registry + if: ${{ fromJSON(env.PUSH) }} + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ secrets.BOT_LOGIN }} + password: ${{ secrets.BOT_TOKEN }} + - name: Docker meta for public images + if: ${{ fromJSON(env.PUSH) }} + id: docker_meta_public + uses: crazy-max/ghaction-docker-meta@v1 + with: + images: | + ghcr.io/${{ env.DOCKER_IMAGE_NAME }} + ${{ env.DOCKERHUB_IMAGE_NAME }} + tag-edge: true + tag-semver: | + {{version}} + {{major}} + {{major}}.{{minor}} + - name: Build and push to public registry(s) + if: ${{ fromJSON(env.PUSH) }} + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + platforms: | + linux/386 + linux/amd64 + linux/arm/v6 + linux/arm/v7 + linux/arm/v8 + linux/arm64 + linux/ppc64le + linux/s390x + load: false + push: true + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache,mode=max + labels: ${{ steps.docker_meta_public.outputs.labels }} + tags: ${{ steps.docker_meta_public.outputs.tags }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index ac3ae47..0000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,111 +0,0 @@ -name: test - -on: - pull_request: - push: - branches: - - master - workflow_dispatch: - inputs: - pytest_addopts: - description: - Extra options for pytest; use -vv for full details; see - https://docs.pytest.org/en/latest/example/simple.html#how-to-change-command-line-options-defaults - required: false - -env: - LANG: "en_US.utf-8" - LC_ALL: "en_US.utf-8" - PIP_CACHE_DIR: ${{ github.workspace }}/.cache.~/pip - PIPX_HOME: ${{ github.workspace }}/.cache.~/pipx - POETRY_CACHE_DIR: ${{ github.workspace }}/.cache.~/pypoetry - POETRY_VIRTUALENVS_IN_PROJECT: "true" - PYTEST_ADDOPTS: ${{ github.event.inputs.pytest_addopts }} - PYTHONIOENCODING: "UTF-8" - -jobs: - build-test-push: - runs-on: ubuntu-latest - env: - DOCKER_REPO: tecnativa/docker-socket-proxy - steps: - - name: Get date - run: echo "BUILD_DATE=$(date --rfc-3339 ns)" >> $GITHUB_ENV - # Prepare Docker environment and build - - uses: actions/checkout@v2 - - uses: docker/setup-qemu-action@v1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Build image(s) - uses: docker/build-push-action@v2 - with: - build-args: | - BUILD_DATE=${{ env.BUILD_DATE }} - VCS_REF=${{ github.sha }} - context: . - file: ./Dockerfile - # HACK: Build single platform image for testing. See https://github.com/docker/buildx/issues/59 - load: true - push: false - tags: | - ${{ env.DOCKER_REPO }}:local - # Set up and run tests - - name: Install python - uses: actions/setup-python@v1 - with: - python-version: "3.9" - - name: Generate cache key CACHE - run: - echo "CACHE=${{ secrets.CACHE_DATE }} ${{ runner.os }} $(python -VV | - sha256sum | cut -d' ' -f1) ${{ hashFiles('pyproject.toml') }} ${{ - hashFiles('poetry.lock') }}" >> $GITHUB_ENV - - uses: actions/cache@v2 - with: - path: | - .cache.~ - .venv - ~/.local/bin - key: venv ${{ env.CACHE }} - - run: pip install poetry - - name: Patch $PATH - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - run: poetry install - # Run tests - - run: poetry run pytest - env: - DOCKER_IMAGE_NAME: ${{ env.DOCKER_REPO }}:local - # Build and push - - name: Login to DockerHub - if: - github.repository == 'Tecnativa/docker-socket-proxy' && github.ref == - 'refs/heads/master' - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_LOGIN }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GitHub Container Registry - if: - github.repository == 'Tecnativa/docker-socket-proxy' && github.ref == - 'refs/heads/master' - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ secrets.BOT_LOGIN }} - password: ${{ secrets.BOT_TOKEN }} - - name: Build and push - if: - github.repository == 'Tecnativa/docker-socket-proxy' && github.ref == - 'refs/heads/master' - uses: docker/build-push-action@v2 - with: - build-args: | - BUILD_DATE=${{ env.BUILD_DATE }} - VCS_REF=${{ github.sha }} - context: . - file: ./Dockerfile - platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm/v8,linux/arm64,linux/ppc64le,linux/s390x - load: false - push: true - tags: | - ghcr.io/${{ env.DOCKER_REPO }} - ${{ env.DOCKER_REPO }} diff --git a/README.md b/README.md index 1d8b3dc..b247f4a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +[![Last image-template](https://img.shields.io/badge/last%20template%20update-v0.1.2-informational)](https://github.com/Tecnativa/image-template/tree/v0.1.2) +[![GitHub Container Registry](https://img.shields.io/badge/GitHub%20Container%20Registry-latest-%2324292e)](https://github.com/orgs/Tecnativa/packages/container/package/docker-socket-proxy) +[![Docker Hub](https://img.shields.io/badge/Docker%20Hub-latest-%23099cec)](https://hub.docker.com/r/tecnativa/docker-socket-proxy) + # Docker Socket Proxy [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-docker.io%2Ftecnativa%2Fdocker--socket--proxy-%23099cec)](https://hub.docker.com/r/tecnativa/docker-socket-proxy) diff --git a/tests/conftest.py b/tests/conftest.py index 8ccb095..5190926 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,64 +1,89 @@ import json -import os +import logging from contextlib import contextmanager -from logging import info from pathlib import Path import pytest from plumbum import local from plumbum.cmd import docker -DOCKER_IMAGE_NAME = os.environ.get("DOCKER_IMAGE_NAME", "docker-socket-proxy:local") +_logger = logging.getLogger(__name__) def pytest_addoption(parser): """Allow prebuilding image for local testing.""" - parser.addoption("--prebuild", action="store_const", const=True) + parser.addoption( + "--prebuild", action="store_true", help="Build local image before testing" + ) + parser.addoption( + "--image", + action="store", + default="test:docker-socket-proxy", + help="Specify testing image name", + ) -@pytest.fixture(autouse=True, scope="session") -def prebuild_docker_image(request): - """Build local docker image once before starting test suite.""" +@pytest.fixture(scope="session") +def image(request): + """Get image name. Builds it if needed.""" + image = request.config.getoption("--image") if request.config.getoption("--prebuild"): - info(f"Building {DOCKER_IMAGE_NAME}...") - docker("build", "-t", DOCKER_IMAGE_NAME, Path(__file__).parent.parent) + build = docker["image", "build", "-t", image, Path(__file__).parent.parent] + retcode, stdout, stderr = build.run() + _logger.log( + # Pytest prints warnings if a test fails, so this is a warning if + # the build succeeded, to allow debugging the build logs + logging.ERROR if retcode else logging.WARNING, + "Build logs for COMMAND: %s\nEXIT CODE:%d\nSTDOUT:%s\nSTDERR:%s", + build.bound_command(), + retcode, + stdout, + stderr, + ) + assert not retcode, "Image build failed" + return image -@contextmanager -def proxy(**env_vars): +@pytest.fixture(scope="session") +def proxy_factory(image): """A context manager that starts the proxy with the specified env. While inside the block, `$DOCKER_HOST` will be modified to talk to the proxy instead of the raw docker socket. """ - container_id = None - env_list = [f"--env={key}={value}" for key, value in env_vars.items()] - info(f"Starting {DOCKER_IMAGE_NAME} container with: {env_list}") - try: - container_id = docker( - "container", - "run", - "--detach", - "--privileged", - "--publish=2375", - "--volume=/var/run/docker.sock:/var/run/docker.sock", - *env_list, - DOCKER_IMAGE_NAME, - ).strip() - container_data = json.loads( - docker("container", "inspect", container_id.strip()) - ) - socket_port = container_data[0]["NetworkSettings"]["Ports"]["2375/tcp"][0][ - "HostPort" - ] - with local.env(DOCKER_HOST=f"tcp://localhost:{socket_port}"): - yield container_id - finally: - if container_id: - info(f"Removing {container_id}...") - docker( + + @contextmanager + def _proxy(**env_vars): + container_id = None + env_list = [f"--env={key}={value}" for key, value in env_vars.items()] + _logger.info(f"Starting {image} container with: {env_list}") + try: + container_id = docker( "container", - "rm", - "-f", - container_id, + "run", + "--detach", + "--privileged", + "--publish=2375", + "--volume=/var/run/docker.sock:/var/run/docker.sock", + *env_list, + image, + ).strip() + container_data = json.loads( + docker("container", "inspect", container_id.strip()) ) + socket_port = container_data[0]["NetworkSettings"]["Ports"]["2375/tcp"][0][ + "HostPort" + ] + with local.env(DOCKER_HOST=f"tcp://localhost:{socket_port}"): + yield container_id + finally: + if container_id: + _logger.info(f"Removing {container_id}...") + docker( + "container", + "rm", + "-f", + container_id, + ) + + return _proxy diff --git a/tests/test_service.py b/tests/test_service.py index 3b4d95b..097a906 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,7 +1,6 @@ import logging import pytest -from conftest import proxy from plumbum import ProcessExecutionError from plumbum.cmd import docker @@ -16,8 +15,8 @@ def _check_permissions(allowed_calls, forbidden_calls): docker(*args) -def test_default_permissions(): - with proxy() as test_container: +def test_default_permissions(proxy_factory): + with proxy_factory() as test_container: allowed_calls = (("version",),) forbidden_calls = ( ("pull", "alpine"), @@ -40,8 +39,8 @@ def test_default_permissions(): _check_permissions(allowed_calls, forbidden_calls) -def test_container_permissions(): - with proxy(CONTAINERS=1) as test_container: +def test_container_permissions(proxy_factory): + with proxy_factory(CONTAINERS=1) as test_container: allowed_calls = [ ("logs", test_container), ("inspect", test_container), @@ -55,8 +54,8 @@ def test_container_permissions(): _check_permissions(allowed_calls, forbidden_calls) -def test_post_permissions(): - with proxy(POST=1) as test_container: +def test_post_permissions(proxy_factory): + with proxy_factory(POST=1) as test_container: allowed_calls = [] forbidden_calls = [ ("rm", "-f", test_container), @@ -67,8 +66,8 @@ def test_post_permissions(): _check_permissions(allowed_calls, forbidden_calls) -def test_network_post_permissions(): - with proxy(POST=1, NETWORKS=1): +def test_network_post_permissions(proxy_factory): + with proxy_factory(POST=1, NETWORKS=1): allowed_calls = [ ("network", "ls"), ("network", "create", "foo"),