Skip to content

🎨 docker-api-proxy always requires authentication (⚠️devops) #7586

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
4 changes: 2 additions & 2 deletions .env-devel
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ DIRECTOR_SERVICES_CUSTOM_CONSTRAINTS=null
DIRECTOR_TRACING=null

DOCKER_API_PROXY_HOST=docker-api-proxy
DOCKER_API_PROXY_PASSWORD=null
DOCKER_API_PROXY_PASSWORD=admin
DOCKER_API_PROXY_PORT=8888
DOCKER_API_PROXY_SECURE=False
DOCKER_API_PROXY_USER=null
DOCKER_API_PROXY_USER=admin

EFS_USER_ID=8006
EFS_USER_NAME=efs
Expand Down
16 changes: 14 additions & 2 deletions packages/pytest-simcore/src/pytest_simcore/docker_api_proxy.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging

import pytest
from aiohttp import ClientSession, ClientTimeout
from aiohttp import BasicAuth, ClientSession, ClientTimeout
from pydantic import TypeAdapter
from settings_library.docker_api_proxy import DockerApiProxysettings
from tenacity import before_sleep_log, retry, stop_after_delay, wait_fixed
Expand All @@ -22,7 +22,13 @@
async def _wait_till_docker_api_proxy_is_responsive(
settings: DockerApiProxysettings,
) -> None:
async with ClientSession(timeout=ClientTimeout(1, 1, 1, 1, 1)) as client:
async with ClientSession(
timeout=ClientTimeout(total=1),
auth=BasicAuth(
settings.DOCKER_API_PROXY_USER,
settings.DOCKER_API_PROXY_PASSWORD.get_secret_value(),
),
) as client:
response = await client.get(f"{settings.base_url}/version")
assert response.status == 200, await response.text()

Expand All @@ -44,6 +50,12 @@ async def docker_api_proxy_settings(
{
"DOCKER_API_PROXY_HOST": get_localhost_ip(),
"DOCKER_API_PROXY_PORT": published_port,
"DOCKER_API_PROXY_USER": env_vars_for_docker_compose[
"DOCKER_API_PROXY_USER"
],
"DOCKER_API_PROXY_PASSWORD": env_vars_for_docker_compose[
"DOCKER_API_PROXY_PASSWORD"
],
}
)

Expand Down
44 changes: 29 additions & 15 deletions packages/pytest-simcore/src/pytest_simcore/simcore_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from collections.abc import Iterator
from dataclasses import dataclass
from io import StringIO
from typing import Final

import aiohttp
import pytest
Expand All @@ -27,7 +28,7 @@
log = logging.getLogger(__name__)


_SERVICES_TO_SKIP = {
_SERVICES_TO_SKIP: Final[set[str]] = {
"agent", # global mode deploy (NO exposed ports, has http API)
"dask-sidecar", # global mode deploy (NO exposed ports, **NO** http API)
"migration",
Expand All @@ -41,9 +42,8 @@
"sto-worker-cpu-bound",
}
# TODO: unify healthcheck policies see https://github.com/ITISFoundation/osparc-simcore/pull/2281
SERVICE_PUBLISHED_PORT = {}
DEFAULT_SERVICE_HEALTHCHECK_ENTRYPOINT = "/v0/"
MAP_SERVICE_HEALTHCHECK_ENTRYPOINT = {
DEFAULT_SERVICE_HEALTHCHECK_ENTRYPOINT: Final[str] = "/v0/"
MAP_SERVICE_HEALTHCHECK_ENTRYPOINT: Final[dict[str, str]] = {
"autoscaling": "/",
"clusters-keeper": "/",
"dask-scheduler": "/health",
Expand All @@ -57,16 +57,23 @@
"resource-usage-tracker": "/",
"docker-api-proxy": "/version",
}
AIOHTTP_BASED_SERVICE_PORT: int = 8080
FASTAPI_BASED_SERVICE_PORT: int = 8000
DASK_SCHEDULER_SERVICE_PORT: int = 8787
DOCKER_API_PROXY_SERVICE_PORT: int = 8888

_SERVICE_NAME_REPLACEMENTS: dict[str, str] = {
# some services require authentication to access their health-check endpoints
_BASE_AUTH_ENV_VARS: Final[dict[str, tuple[str, str]]] = {
"docker-api-proxy": ("DOCKER_API_PROXY_USER", "DOCKER_API_PROXY_PASSWORD"),
}

_SERVICE_NAME_REPLACEMENTS: Final[dict[str, str]] = {
"dynamic-scheduler": "dynamic-schdlr",
}

_ONE_SEC_TIMEOUT = ClientTimeout(total=1) # type: ignore

AIOHTTP_BASED_SERVICE_PORT: Final[int] = 8080
FASTAPI_BASED_SERVICE_PORT: Final[int] = 8000
DASK_SCHEDULER_SERVICE_PORT: Final[int] = 8787
DOCKER_API_PROXY_SERVICE_PORT: Final[int] = 8888

_ONE_SEC_TIMEOUT: Final[ClientTimeout] = ClientTimeout(total=1) # type: ignore


async def wait_till_service_healthy(service_name: str, endpoint: URL):
Expand Down Expand Up @@ -108,13 +115,12 @@ class ServiceHealthcheckEndpoint:
@classmethod
def create(cls, service_name: str, baseurl):
# TODO: unify healthcheck policies see https://github.com/ITISFoundation/osparc-simcore/pull/2281
obj = cls(
return cls(
name=service_name,
url=URL(
f"{baseurl}{MAP_SERVICE_HEALTHCHECK_ENTRYPOINT.get(service_name, DEFAULT_SERVICE_HEALTHCHECK_ENTRYPOINT)}"
),
)
return obj


@pytest.fixture(scope="module")
Expand All @@ -140,9 +146,17 @@ def services_endpoint(
DASK_SCHEDULER_SERVICE_PORT,
DOCKER_API_PROXY_SERVICE_PORT,
]
endpoint = URL(
f"http://{get_localhost_ip()}:{get_service_published_port(full_service_name, target_ports)}"
)
if service in _BASE_AUTH_ENV_VARS:
user_env, password_env = _BASE_AUTH_ENV_VARS[service]
user = env_vars_for_docker_compose[user_env]
password = env_vars_for_docker_compose[password_env]
endpoint = URL(
f"http://{user}:{password}@{get_localhost_ip()}:{get_service_published_port(full_service_name, target_ports)}"
)
else:
endpoint = URL(
f"http://{get_localhost_ip()}:{get_service_published_port(full_service_name, target_ports)}"
)
services_endpoint[service] = endpoint
else:
print(f"Collecting service endpoints: '{service}' skipped")
Expand Down
26 changes: 7 additions & 19 deletions packages/service-library/src/servicelib/fastapi/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,20 @@ async def remote_docker_client_lifespan(
) -> AsyncIterator[State]:
settings: DockerApiProxysettings = state[_DOCKER_API_PROXY_SETTINGS]

session: ClientSession | None = None
if settings.DOCKER_API_PROXY_USER and settings.DOCKER_API_PROXY_PASSWORD:
session = ClientSession(
auth=aiohttp.BasicAuth(
login=settings.DOCKER_API_PROXY_USER,
password=settings.DOCKER_API_PROXY_PASSWORD.get_secret_value(),
)
)

async with AsyncExitStack() as exit_stack:
if settings.DOCKER_API_PROXY_USER and settings.DOCKER_API_PROXY_PASSWORD:
await exit_stack.enter_async_context(
ClientSession(
auth=aiohttp.BasicAuth(
login=settings.DOCKER_API_PROXY_USER,
password=settings.DOCKER_API_PROXY_PASSWORD.get_secret_value(),
)
session = await exit_stack.enter_async_context(
ClientSession(
auth=aiohttp.BasicAuth(
login=settings.DOCKER_API_PROXY_USER,
password=settings.DOCKER_API_PROXY_PASSWORD.get_secret_value(),
)
)
)

client = await exit_stack.enter_async_context(
app.state.remote_docker_client = await exit_stack.enter_async_context(
aiodocker.Docker(url=settings.base_url, session=session)
)

app.state.remote_docker_client = client

await wait_till_docker_api_proxy_is_responsive(app)

# NOTE this has to be inside exit_stack scope
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ class DockerApiProxysettings(BaseCustomSettings):
)
DOCKER_API_PROXY_SECURE: bool = False

DOCKER_API_PROXY_USER: str | None = None
DOCKER_API_PROXY_PASSWORD: SecretStr | None = None
DOCKER_API_PROXY_USER: str
DOCKER_API_PROXY_PASSWORD: SecretStr

@cached_property
def base_url(self) -> str:
Expand Down
7 changes: 4 additions & 3 deletions services/docker-api-proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM alpine:3.21 AS base
FROM caddy:2.10.0-alpine AS base

LABEL maintainer=GitHK

Expand Down Expand Up @@ -29,10 +29,11 @@ HEALTHCHECK \
--start-period=20s \
--start-interval=1s \
--retries=5 \
CMD curl http://localhost:8888/version || exit 1
CMD curl --fail-with-body --user ${DOCKER_API_PROXY_USER}:${DOCKER_API_PROXY_PASSWORD} http://localhost:8888/version

COPY --chown=scu:scu services/docker-api-proxy/docker services/docker-api-proxy/docker
RUN chmod +x services/docker-api-proxy/docker/*.sh
RUN chmod +x services/docker-api-proxy/docker/*.sh && \
mv services/docker-api-proxy/docker/Caddyfile /etc/caddy/Caddyfile

ENTRYPOINT [ "/bin/sh", "services/docker-api-proxy/docker/entrypoint.sh" ]
CMD ["/bin/sh", "services/docker-api-proxy/docker/boot.sh"]
Expand Down
11 changes: 11 additions & 0 deletions services/docker-api-proxy/docker/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
:8888 {
handle {
basicauth {
{$DOCKER_API_PROXY_USER} {$DOCKER_API_PROXY_ENCRYPTED_PASSWORD}
}

reverse_proxy http://localhost:8889 {
health_uri /version
}
}
}
5 changes: 4 additions & 1 deletion services/docker-api-proxy/docker/boot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ echo "$INFO" "User :$(id "$(whoami)")"
#
# RUNNING application
#
socat TCP-LISTEN:8888,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock
socat TCP-LISTEN:8889,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock &

DOCKER_API_PROXY_ENCRYPTED_PASSWORD=$(caddy hash-password --plaintext "$DOCKER_API_PROXY_PASSWORD") \
caddy run --adapter caddyfile --config /etc/caddy/Caddyfile

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
# pylint: disable=unused-argument

import json
import sys
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from pathlib import Path

import aiodocker
import pytest
from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict
from servicelib.aiohttp import status
from settings_library.docker_api_proxy import DockerApiProxysettings

HERE = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent

pytest_simcore_core_services_selection = [
"docker-api-proxy",
]


async def test_unauthenticated_docker_client(
async def test_authenticated_docker_client(
docker_swarm: None,
docker_api_proxy_settings: DockerApiProxysettings,
setup_docker_client: Callable[
Expand All @@ -27,7 +25,37 @@ async def test_unauthenticated_docker_client(
envs = {
"DOCKER_API_PROXY_HOST": "127.0.0.1",
"DOCKER_API_PROXY_PORT": "8014",
"DOCKER_API_PROXY_USER": docker_api_proxy_settings.DOCKER_API_PROXY_USER,
"DOCKER_API_PROXY_PASSWORD": docker_api_proxy_settings.DOCKER_API_PROXY_PASSWORD.get_secret_value(),
}
async with setup_docker_client(envs) as working_docker:
info = await working_docker.system.info()
print(json.dumps(info, indent=2))


@pytest.mark.parametrize(
"user, password",
[
("wrong", "wrong"),
("wrong", "admin"),
],
)
async def test_unauthenticated_docker_client(
docker_swarm: None,
docker_api_proxy_settings: DockerApiProxysettings,
setup_docker_client: Callable[
[EnvVarsDict], AbstractAsyncContextManager[aiodocker.Docker]
],
user: str,
password: str,
):
envs = {
"DOCKER_API_PROXY_HOST": "127.0.0.1",
"DOCKER_API_PROXY_PORT": "8014",
"DOCKER_API_PROXY_USER": user,
"DOCKER_API_PROXY_PASSWORD": password,
}
async with setup_docker_client(envs) as working_docker:
with pytest.raises(aiodocker.exceptions.DockerError) as exc:
await working_docker.system.info()
assert exc.value.status == status.HTTP_401_UNAUTHORIZED
Loading
Loading