Add a health check for the daemon and RPC endpoint (#34)
* Add a health check for the daemon and RPC endpoint SeafileClient.is_healthy() checks seaf-cli status and makes a real RPC call (get_repo_list) against the daemon's Unix socket - neither needs the Seafile server, so it works without a token or password. healthcheck.py exposes it as a plain script for Docker's HEALTHCHECK. Each check is a fresh process spawned by the Docker daemon as root, unlike the long-lived start.py process, so it drops to the seafile user with runuser before touching the RPC socket - the one place that mechanism still belongs after the earlier in-process privilege drop. Verified against the real image: healthcheck.py exits 0 once the real daemon is up (through the actual main() entrypoint, not a test fixture) and nonzero when no daemon has been started. * Assert the return value in the daemon-down health check test Copilot review: the test checked the RPC call was skipped but not what is_healthy() actually returned in that case. --------- Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
This commit is contained in:
1 parent
ba5ad177b7
commit
28db575c92
7 files changed
+133
-4
No files matched your search
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
# Only requirements.txt, dsc/ and start.py belong in the build context.
|
# Only requirements.txt, dsc/, start.py, and healthcheck.py belong in the
|
||||||
|
# build context.
|
||||||
*
|
*
|
||||||
!requirements.txt
|
!requirements.txt
|
||||||
!start.py
|
!start.py
|
||||||
|
!healthcheck.py
|
||||||
!dsc/
|
!dsc/
|
||||||
|
|
||||||
# Keep local artifacts out even though dsc/ is re-included above.
|
# Keep local artifacts out even though dsc/ is re-included above.
|
||||||
|
|||||||
+11
-2
@@ -61,9 +61,10 @@ RUN pip install --no-cache-dir --upgrade "setuptools>=78.1.1" && \
|
|||||||
# Copy app
|
# Copy app
|
||||||
COPY dsc ./dsc/
|
COPY dsc ./dsc/
|
||||||
COPY start.py ./start.py
|
COPY start.py ./start.py
|
||||||
|
COPY healthcheck.py ./healthcheck.py
|
||||||
|
|
||||||
# Create seafile user and init seafile client
|
# Create seafile user and init seafile client
|
||||||
RUN chmod +x /dsc/start.py && \
|
RUN chmod +x /dsc/start.py /dsc/healthcheck.py && \
|
||||||
useradd -U -d /dsc -s /bin/bash seafile && \
|
useradd -U -d /dsc -s /bin/bash seafile && \
|
||||||
usermod -G users seafile && \
|
usermod -G users seafile && \
|
||||||
mkdir -p /dsc/seafile-data && \
|
mkdir -p /dsc/seafile-data && \
|
||||||
@@ -74,11 +75,19 @@ RUN chmod +x /dsc/start.py && \
|
|||||||
RUN seaf-cli --help | grep -q 'download-by-name' && \
|
RUN seaf-cli --help | grep -q 'download-by-name' && \
|
||||||
seaf-cli sync --help | grep -q '\--token' && \
|
seaf-cli sync --help | grep -q '\--token' && \
|
||||||
seaf-cli list --help | grep -q '\--json' && \
|
seaf-cli list --help | grep -q '\--json' && \
|
||||||
python3 -c "import seafile, pysearpc, dsc.client, dsc.misc"
|
python3 -c "import seafile, pysearpc, dsc.client, dsc.misc, healthcheck"
|
||||||
|
|
||||||
# Record the pinned client version in the image so it can be checked at
|
# Record the pinned client version in the image so it can be checked at
|
||||||
# runtime; seaf-cli itself has no --version flag.
|
# runtime; seaf-cli itself has no --version flag.
|
||||||
ENV SEAFILE_CLI_VERSION=${SEAFILE_CLI_VERSION}
|
ENV SEAFILE_CLI_VERSION=${SEAFILE_CLI_VERSION}
|
||||||
|
|
||||||
VOLUME /dsc/seafile-data
|
VOLUME /dsc/seafile-data
|
||||||
|
|
||||||
|
# The container's own process already runs as the seafile user (see
|
||||||
|
# dsc.misc.drop_privileges), but a HEALTHCHECK is a fresh process started by
|
||||||
|
# the Docker daemon, which starts it as root. runuser drops it to the same
|
||||||
|
# unprivileged user before it ever touches the RPC socket.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
||||||
|
CMD runuser -u seafile -- python3 /dsc/healthcheck.py
|
||||||
|
|
||||||
CMD ["./start.py"]
|
CMD ["./start.py"]
|
||||||
+6
-1
@@ -208,7 +208,12 @@ Acceptance criteria:
|
|||||||
- [x] Reject UID or GID zero unless a documented use case requires it.
|
- [x] Reject UID or GID zero unless a documented use case requires it.
|
||||||
`main()` now exits with code 2 and a clear message if `--uid`/`SEAFILE_UID`
|
`main()` now exits with code 2 and a clear message if `--uid`/`SEAFILE_UID`
|
||||||
or `--gid`/`SEAFILE_GID` resolve to 0, before `setup_uid()` ever runs.
|
or `--gid`/`SEAFILE_GID` resolve to 0, before `setup_uid()` ever runs.
|
||||||
- [ ] Add a health check for the daemon and RPC endpoint.
|
- [x] Add a health check for the daemon and RPC endpoint.
|
||||||
|
`SeafileClient.is_healthy()` checks `seaf-cli status` and a real RPC call
|
||||||
|
(`get_repo_list`); `healthcheck.py` exposes it to Docker's `HEALTHCHECK`.
|
||||||
|
Each check is a fresh root-spawned process (unlike the long-lived
|
||||||
|
`start.py`), so it drops to the `seafile` user with `runuser` before
|
||||||
|
touching the RPC socket - the one place that mechanism still belongs.
|
||||||
- [ ] Handle temporary RPC failures and daemon restarts without corrupting
|
- [ ] Handle temporary RPC failures and daemon restarts without corrupting
|
||||||
state.
|
state.
|
||||||
- [ ] Document supported CPU architectures instead of implying multi-platform
|
- [ ] Document supported CPU architectures instead of implying multi-platform
|
||||||
|
|||||||
@@ -165,6 +165,22 @@ class SeafileClient:
|
|||||||
self.__wait_for_daemon(False, const.DAEMON_STOP_TIMEOUT, "stopping")
|
self.__wait_for_daemon(False, const.DAEMON_STOP_TIMEOUT, "stopping")
|
||||||
_lg.info("Seafile daemon is stopped")
|
_lg.info("Seafile daemon is stopped")
|
||||||
|
|
||||||
|
def is_healthy(self) -> bool:
|
||||||
|
"""
|
||||||
|
Report whether the daemon process and its RPC socket both respond.
|
||||||
|
|
||||||
|
Used by the container's HEALTHCHECK. Neither check needs the Seafile
|
||||||
|
server, so this works without a token or password.
|
||||||
|
"""
|
||||||
|
if not self.daemon_ready:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.rpc.get_repo_list(-1, -1)
|
||||||
|
except Exception as err:
|
||||||
|
_lg.warning("RPC endpoint is not responding: %s", err)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def get_library_id(self, library) -> Optional[str]:
|
def get_library_id(self, library) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Resolve a requested name or ID to the one library ID it names.
|
Resolve a requested name or ID to the one library ID it names.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Docker HEALTHCHECK entry point.
|
||||||
|
|
||||||
|
Exits 0 if the seafile daemon and its RPC socket both respond, 1 otherwise.
|
||||||
|
Runs as a separate, short-lived process on every check, so it needs no
|
||||||
|
credentials and touches no state the main process owns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dsc import const
|
||||||
|
from dsc.client import SeafileClient
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
client = SeafileClient(host="unused", user="unused", app_dir=const.DEFAULT_APP_DIR)
|
||||||
|
return 0 if client.is_healthy() else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -119,6 +119,42 @@ else
|
|||||||
fi
|
fi
|
||||||
docker rm -f dsc-smoke >/dev/null
|
docker rm -f dsc-smoke >/dev/null
|
||||||
|
|
||||||
|
echo "== the HEALTHCHECK command through the real entrypoint"
|
||||||
|
# Runs through main(), unlike the fixture above: HOME/UID/GID come from the
|
||||||
|
# same drop_privileges() path a real deployment uses, so .ccnet ends up
|
||||||
|
# under /dsc like it does in production, not under root's home directory.
|
||||||
|
docker rm -f dsc-smoke >/dev/null 2>&1 || true
|
||||||
|
docker run -d $PLATFORM_ARG --name dsc-smoke \
|
||||||
|
-e SERVER_HOST=example.invalid -e USERNAME=u -e PASSWORD=p -e LIBRARY_ID=x \
|
||||||
|
"$IMAGE" >/dev/null
|
||||||
|
|
||||||
|
ready=0
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if docker logs dsc-smoke 2>&1 | grep -q "Seafile daemon is ready"; then ready=1; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$ready" -eq 0 ]; then
|
||||||
|
fail "the daemon never became ready, HEALTHCHECK not measured"
|
||||||
|
else
|
||||||
|
if docker exec dsc-smoke runuser -u seafile -- python3 /dsc/healthcheck.py; then
|
||||||
|
pass "HEALTHCHECK command exits 0 while the daemon is up"
|
||||||
|
else
|
||||||
|
fail "HEALTHCHECK command did not exit 0 while the daemon was up"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
docker rm -f dsc-smoke >/dev/null
|
||||||
|
|
||||||
|
echo "== the HEALTHCHECK command reports unhealthy when the daemon is down"
|
||||||
|
docker rm -f dsc-smoke >/dev/null 2>&1 || true
|
||||||
|
docker run -d $PLATFORM_ARG --name dsc-smoke "$IMAGE" sleep 60 >/dev/null
|
||||||
|
if docker exec dsc-smoke runuser -u seafile -- python3 /dsc/healthcheck.py; then
|
||||||
|
fail "HEALTHCHECK command exited 0 with no daemon running"
|
||||||
|
else
|
||||||
|
pass "HEALTHCHECK command exits nonzero with no daemon running"
|
||||||
|
fi
|
||||||
|
docker rm -f dsc-smoke >/dev/null
|
||||||
|
|
||||||
echo "== configuration errors stop the container with a useful message"
|
echo "== configuration errors stop the container with a useful message"
|
||||||
out=$(docker run --rm $PLATFORM_ARG -e SERVER_HOST=h -e USERNAME=u -e LIBRARY_ID=x "$IMAGE" 2>&1; echo "rc=$?")
|
out=$(docker run --rm $PLATFORM_ARG -e SERVER_HOST=h -e USERNAME=u -e LIBRARY_ID=x "$IMAGE" 2>&1; echo "rc=$?")
|
||||||
check "no credentials exits 2" "rc=2" "$(echo "$out" | tail -1)"
|
check "no credentials exits 2" "rc=2" "$(echo "$out" | tail -1)"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""The container HEALTHCHECK must reflect both the daemon and its RPC socket."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from dsc.client import SeafileClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return SeafileClient("seafile.example", "user", "pw", "/dsc", token="tok-en")
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthy_when_daemon_and_rpc_both_respond(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(type(client), "daemon_ready", True)
|
||||||
|
monkeypatch.setattr(client.rpc, "get_repo_list", lambda a, b: [])
|
||||||
|
assert client.is_healthy() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unhealthy_when_the_daemon_is_not_ready(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(type(client), "daemon_ready", False)
|
||||||
|
assert client.is_healthy() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unhealthy_when_the_rpc_socket_raises(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(type(client), "daemon_ready", True)
|
||||||
|
|
||||||
|
def broken(a, b):
|
||||||
|
raise ConnectionError("socket gone")
|
||||||
|
|
||||||
|
monkeypatch.setattr(client.rpc, "get_repo_list", broken)
|
||||||
|
assert client.is_healthy() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_is_not_checked_when_the_daemon_is_already_down(client, monkeypatch):
|
||||||
|
"""A dead daemon means a dead socket too; no need to touch it."""
|
||||||
|
monkeypatch.setattr(type(client), "daemon_ready", False)
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(client.rpc, "get_repo_list", lambda a, b: called.append(1))
|
||||||
|
assert client.is_healthy() is False
|
||||||
|
assert not called
|
||||||
Reference in new issue
Block a user