Skip to content

Commit f9f5df3

Browse files
GitHKAndrei Neagu
and
Andrei Neagu
authored
✨ adding docker-api-proxy service ⚠️ (#7070)
Co-authored-by: Andrei Neagu <[email protected]>
1 parent 89d6628 commit f9f5df3

File tree

38 files changed

+1309
-12
lines changed

38 files changed

+1309
-12
lines changed

.env-devel

+6
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ DIRECTOR_REGISTRY_CACHING=True
8383
DIRECTOR_SERVICES_CUSTOM_CONSTRAINTS=null
8484
DIRECTOR_TRACING=null
8585

86+
DOCKER_API_PROXY_HOST=docker-api-proxy
87+
DOCKER_API_PROXY_PASSWORD=null
88+
DOCKER_API_PROXY_PORT=8888
89+
DOCKER_API_PROXY_SECURE=False
90+
DOCKER_API_PROXY_USER=null
91+
8692
EFS_USER_ID=8006
8793
EFS_USER_NAME=efs
8894
EFS_GROUP_ID=8106

.github/workflows/ci-testing-deploy.yml

+70
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ jobs:
7777
migration: ${{ steps.filter.outputs.migration }}
7878
payments: ${{ steps.filter.outputs.payments }}
7979
dynamic-scheduler: ${{ steps.filter.outputs.dynamic-scheduler }}
80+
docker-api-proxy: ${{ steps.filter.outputs.docker-api-proxy }}
8081
resource-usage-tracker: ${{ steps.filter.outputs.resource-usage-tracker }}
8182
static-webserver: ${{ steps.filter.outputs.static-webserver }}
8283
storage: ${{ steps.filter.outputs.storage }}
@@ -233,6 +234,9 @@ jobs:
233234
- 'services/docker-compose*'
234235
- 'scripts/mypy/*'
235236
- 'mypy.ini'
237+
docker-api-proxy:
238+
- 'packages/**'
239+
- 'services/docker-api-proxy/**'
236240
resource-usage-tracker:
237241
- 'packages/**'
238242
- 'services/resource-usage-tracker/**'
@@ -2190,6 +2194,71 @@ jobs:
21902194
with:
21912195
flags: integrationtests #optional
21922196

2197+
2198+
integration-test-docker-api-proxy:
2199+
needs: [changes, build-test-images]
2200+
if: ${{ needs.changes.outputs.anything-py == 'true' || needs.changes.outputs.docker-api-proxy == 'true' || github.event_name == 'push'}}
2201+
timeout-minutes: 30 # if this timeout gets too small, then split the tests
2202+
name: "[int] docker-api-proxy"
2203+
runs-on: ${{ matrix.os }}
2204+
strategy:
2205+
matrix:
2206+
python: ["3.11"]
2207+
os: [ubuntu-22.04]
2208+
fail-fast: false
2209+
steps:
2210+
- uses: actions/checkout@v4
2211+
- name: setup docker buildx
2212+
id: buildx
2213+
uses: docker/setup-buildx-action@v3
2214+
with:
2215+
driver: docker-container
2216+
- name: setup python environment
2217+
uses: actions/setup-python@v5
2218+
with:
2219+
python-version: ${{ matrix.python }}
2220+
- name: expose github runtime for buildx
2221+
uses: crazy-max/ghaction-github-runtime@v3
2222+
# FIXME: Workaround for https://github.com/actions/download-artifact/issues/249
2223+
- name: download docker images with retry
2224+
uses: Wandalen/wretry.action@master
2225+
with:
2226+
action: actions/download-artifact@v4
2227+
with: |
2228+
name: docker-buildx-images-${{ runner.os }}-${{ github.sha }}-backend
2229+
path: /${{ runner.temp }}/build
2230+
attempt_limit: 5
2231+
attempt_delay: 1000
2232+
- name: load docker images
2233+
run: make load-images local-src=/${{ runner.temp }}/build
2234+
- name: install uv
2235+
uses: astral-sh/setup-uv@v5
2236+
with:
2237+
version: "0.5.x"
2238+
enable-cache: false
2239+
cache-dependency-glob: "**/docker-api-proxy/requirements/ci.txt"
2240+
- name: show system version
2241+
run: ./ci/helpers/show_system_versions.bash
2242+
- name: install
2243+
run: ./ci/github/integration-testing/docker-api-proxy.bash install
2244+
- name: test
2245+
run: ./ci/github/integration-testing/docker-api-proxy.bash test
2246+
- name: upload failed tests logs
2247+
if: ${{ failure() }}
2248+
uses: actions/upload-artifact@v4
2249+
with:
2250+
name: ${{ github.job }}_docker_logs
2251+
path: ./services/docker-api-proxy/test_failures
2252+
- name: cleanup
2253+
if: ${{ !cancelled() }}
2254+
run: ./ci/github/integration-testing/docker-api-proxy.bash clean_up
2255+
- uses: codecov/codecov-action@v5
2256+
if: ${{ !cancelled() }}
2257+
env:
2258+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
2259+
with:
2260+
flags: integrationtests #optional
2261+
21932262
integration-test-simcore-sdk:
21942263
needs: [changes, build-test-images]
21952264
if: ${{ needs.changes.outputs.anything-py == 'true' || needs.changes.outputs.simcore-sdk == 'true' || github.event_name == 'push' }}
@@ -2262,6 +2331,7 @@ jobs:
22622331
integration-test-director-v2-01,
22632332
integration-test-director-v2-02,
22642333
integration-test-dynamic-sidecar,
2334+
integration-test-docker-api-proxy,
22652335
integration-test-simcore-sdk,
22662336
integration-test-webserver-01,
22672337
integration-test-webserver-02,

Makefile

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ SERVICES_NAMES_TO_BUILD := \
4747
payments \
4848
resource-usage-tracker \
4949
dynamic-scheduler \
50+
docker-api-proxy \
5051
service-integration \
5152
static-webserver \
5253
storage \
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/bin/bash
2+
# http://redsymbol.net/articles/unofficial-bash-strict-mode/
3+
set -o errexit # abort on nonzero exitstatus
4+
set -o nounset # abort on unbound variable
5+
set -o pipefail # don't hide errors within pipes
6+
IFS=$'\n\t'
7+
8+
install() {
9+
make devenv
10+
# shellcheck source=/dev/null
11+
source .venv/bin/activate
12+
pushd services/docker-api-proxy
13+
make install-ci
14+
popd
15+
uv pip list
16+
make info-images
17+
}
18+
19+
test() {
20+
# shellcheck source=/dev/null
21+
source .venv/bin/activate
22+
pushd services/docker-api-proxy
23+
make test-ci-integration
24+
popd
25+
}
26+
27+
clean_up() {
28+
docker images
29+
make down
30+
}
31+
32+
# Check if the function exists (bash specific)
33+
if declare -f "$1" >/dev/null; then
34+
# call arguments verbatim
35+
"$@"
36+
else
37+
# Show a helpful error
38+
echo "'$1' is not a known function name" >&2
39+
exit 1
40+
fi

packages/models-library/src/models_library/errors.py

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class ErrorDict(_ErrorDictRequired, total=False):
3636

3737
RABBITMQ_CLIENT_UNHEALTHY_MSG = "RabbitMQ client is in a bad state!"
3838
REDIS_CLIENT_UNHEALTHY_MSG = "Redis cannot be reached!"
39+
DOCKER_API_PROXY_UNHEALTHY_MSG = "docker-api-proxy service is not reachable!"
3940

4041

4142
# NOTE: Here we do not just import as 'from pydantic.error_wrappers import ErrorDict'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import logging
2+
3+
import pytest
4+
from aiohttp import ClientSession, ClientTimeout
5+
from pydantic import TypeAdapter
6+
from settings_library.docker_api_proxy import DockerApiProxysettings
7+
from tenacity import before_sleep_log, retry, stop_after_delay, wait_fixed
8+
9+
from .helpers.docker import get_service_published_port
10+
from .helpers.host import get_localhost_ip
11+
from .helpers.typing_env import EnvVarsDict
12+
13+
_logger = logging.getLogger(__name__)
14+
15+
16+
@retry(
17+
wait=wait_fixed(1),
18+
stop=stop_after_delay(10),
19+
before_sleep=before_sleep_log(_logger, logging.INFO),
20+
reraise=True,
21+
)
22+
async def _wait_till_docker_api_proxy_is_responsive(
23+
settings: DockerApiProxysettings,
24+
) -> None:
25+
async with ClientSession(timeout=ClientTimeout(1, 1, 1, 1, 1)) as client:
26+
response = await client.get(f"{settings.base_url}/version")
27+
assert response.status == 200, await response.text()
28+
29+
30+
@pytest.fixture
31+
async def docker_api_proxy_settings(
32+
docker_stack: dict, env_vars_for_docker_compose: EnvVarsDict
33+
) -> DockerApiProxysettings:
34+
"""Returns the settings of a redis service that is up and responsive"""
35+
36+
prefix = env_vars_for_docker_compose["SWARM_STACK_NAME"]
37+
assert f"{prefix}_docker-api-proxy" in docker_stack["services"]
38+
39+
published_port = get_service_published_port(
40+
"docker-api-proxy", int(env_vars_for_docker_compose["DOCKER_API_PROXY_PORT"])
41+
)
42+
43+
settings = TypeAdapter(DockerApiProxysettings).validate_python(
44+
{
45+
"DOCKER_API_PROXY_HOST": get_localhost_ip(),
46+
"DOCKER_API_PROXY_PORT": published_port,
47+
}
48+
)
49+
50+
await _wait_till_docker_api_proxy_is_responsive(settings)
51+
52+
return settings

packages/pytest-simcore/src/pytest_simcore/simcore_services.py

+3
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@
5252
"invitations": "/",
5353
"payments": "/",
5454
"resource-usage-tracker": "/",
55+
"docker-api-proxy": "/version",
5556
}
5657
AIOHTTP_BASED_SERVICE_PORT: int = 8080
5758
FASTAPI_BASED_SERVICE_PORT: int = 8000
5859
DASK_SCHEDULER_SERVICE_PORT: int = 8787
60+
DOCKER_API_PROXY_SERVICE_PORT: int = 8888
5961

6062
_SERVICE_NAME_REPLACEMENTS: dict[str, str] = {
6163
"dynamic-scheduler": "dynamic-schdlr",
@@ -133,6 +135,7 @@ def services_endpoint(
133135
AIOHTTP_BASED_SERVICE_PORT,
134136
FASTAPI_BASED_SERVICE_PORT,
135137
DASK_SCHEDULER_SERVICE_PORT,
138+
DOCKER_API_PROXY_SERVICE_PORT,
136139
]
137140
endpoint = URL(
138141
f"http://{get_localhost_ip()}:{get_service_published_port(full_service_name, target_ports)}"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import asyncio
2+
import logging
3+
from collections.abc import AsyncIterator
4+
from contextlib import AsyncExitStack
5+
from typing import Final
6+
7+
import aiodocker
8+
import aiohttp
9+
import tenacity
10+
from aiohttp import ClientSession
11+
from fastapi import FastAPI
12+
from fastapi_lifespan_manager import State
13+
from pydantic import NonNegativeInt
14+
from servicelib.fastapi.lifespan_utils import LifespanGenerator
15+
from settings_library.docker_api_proxy import DockerApiProxysettings
16+
17+
_logger = logging.getLogger(__name__)
18+
19+
_DEFAULT_DOCKER_API_PROXY_HEALTH_TIMEOUT: Final[NonNegativeInt] = 5
20+
21+
22+
def get_lifespan_remote_docker_client(
23+
settings: DockerApiProxysettings,
24+
) -> LifespanGenerator:
25+
async def _(app: FastAPI) -> AsyncIterator[State]:
26+
27+
session: ClientSession | None = None
28+
if settings.DOCKER_API_PROXY_USER and settings.DOCKER_API_PROXY_PASSWORD:
29+
session = ClientSession(
30+
auth=aiohttp.BasicAuth(
31+
login=settings.DOCKER_API_PROXY_USER,
32+
password=settings.DOCKER_API_PROXY_PASSWORD.get_secret_value(),
33+
)
34+
)
35+
36+
async with AsyncExitStack() as exit_stack:
37+
if settings.DOCKER_API_PROXY_USER and settings.DOCKER_API_PROXY_PASSWORD:
38+
await exit_stack.enter_async_context(
39+
ClientSession(
40+
auth=aiohttp.BasicAuth(
41+
login=settings.DOCKER_API_PROXY_USER,
42+
password=settings.DOCKER_API_PROXY_PASSWORD.get_secret_value(),
43+
)
44+
)
45+
)
46+
47+
client = await exit_stack.enter_async_context(
48+
aiodocker.Docker(url=settings.base_url, session=session)
49+
)
50+
51+
app.state.remote_docker_client = client
52+
53+
await wait_till_docker_api_proxy_is_responsive(app)
54+
55+
# NOTE this has to be inside exit_stack scope
56+
yield {}
57+
58+
return _
59+
60+
61+
@tenacity.retry(
62+
wait=tenacity.wait_fixed(5),
63+
stop=tenacity.stop_after_delay(60),
64+
before_sleep=tenacity.before_sleep_log(_logger, logging.WARNING),
65+
reraise=True,
66+
)
67+
async def wait_till_docker_api_proxy_is_responsive(app: FastAPI) -> None:
68+
await is_docker_api_proxy_ready(app)
69+
70+
71+
async def is_docker_api_proxy_ready(
72+
app: FastAPI, *, timeout=_DEFAULT_DOCKER_API_PROXY_HEALTH_TIMEOUT # noqa: ASYNC109
73+
) -> bool:
74+
try:
75+
await asyncio.wait_for(get_remote_docker_client(app).version(), timeout=timeout)
76+
except (aiodocker.DockerError, TimeoutError):
77+
return False
78+
return True
79+
80+
81+
def get_remote_docker_client(app: FastAPI) -> aiodocker.Docker:
82+
assert isinstance(app.state.remote_docker_client, aiodocker.Docker) # nosec
83+
return app.state.remote_docker_client
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from functools import cached_property
2+
3+
from pydantic import Field, SecretStr
4+
5+
from .base import BaseCustomSettings
6+
from .basic_types import PortInt
7+
8+
9+
class DockerApiProxysettings(BaseCustomSettings):
10+
DOCKER_API_PROXY_HOST: str = Field(
11+
description="hostname of the docker-api-proxy service"
12+
)
13+
DOCKER_API_PROXY_PORT: PortInt = Field(
14+
8888, description="port of the docker-api-proxy service"
15+
)
16+
DOCKER_API_PROXY_SECURE: bool = False
17+
18+
DOCKER_API_PROXY_USER: str | None = None
19+
DOCKER_API_PROXY_PASSWORD: SecretStr | None = None
20+
21+
@cached_property
22+
def base_url(self) -> str:
23+
protocl = "https" if self.DOCKER_API_PROXY_SECURE else "http"
24+
return f"{protocl}://{self.DOCKER_API_PROXY_HOST}:{self.DOCKER_API_PROXY_PORT}"

services/docker-api-proxy/Dockerfile

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
FROM alpine:3.21 AS base
2+
3+
LABEL maintainer=GitHK
4+
5+
# simcore-user uid=8004(scu) gid=8004(scu) groups=8004(scu)
6+
ENV SC_USER_ID=8004 \
7+
SC_USER_NAME=scu \
8+
SC_BUILD_TARGET=base \
9+
SC_BOOT_MODE=default
10+
11+
RUN addgroup -g ${SC_USER_ID} ${SC_USER_NAME} && \
12+
adduser -u ${SC_USER_ID} -G ${SC_USER_NAME} \
13+
--disabled-password \
14+
--gecos "" \
15+
--shell /bin/sh \
16+
--home /home/${SC_USER_NAME} \
17+
${SC_USER_NAME}
18+
19+
RUN apk add --no-cache socat curl && \
20+
curl -L -o /usr/local/bin/gosu https://github.com/tianon/gosu/releases/download/1.16/gosu-amd64 && \
21+
chmod +x /usr/local/bin/gosu && \
22+
gosu --version
23+
24+
25+
# Health check to ensure the proxy is running
26+
HEALTHCHECK \
27+
--interval=10s \
28+
--timeout=5s \
29+
--start-period=30s \
30+
--start-interval=1s \
31+
--retries=5 \
32+
CMD curl http://localhost:8888/version || exit 1
33+
34+
COPY --chown=scu:scu services/docker-api-proxy/docker services/docker-api-proxy/docker
35+
RUN chmod +x services/docker-api-proxy/docker/*.sh
36+
37+
ENTRYPOINT [ "/bin/sh", "services/docker-api-proxy/docker/entrypoint.sh" ]
38+
CMD ["/bin/sh", "services/docker-api-proxy/docker/boot.sh"]
39+
40+
FROM base AS development
41+
ENV SC_BUILD_TARGET=development
42+
43+
FROM base AS production
44+
ENV SC_BUILD_TARGET=production

services/docker-api-proxy/Makefile

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
include ../../scripts/common.Makefile
2+
include ../../scripts/common-service.Makefile

0 commit comments

Comments
 (0)