diff --git a/.dockerignore b/.dockerignore index 8a028b9..cccb556 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 !start.py +!healthcheck.py !dsc/ # Keep local artifacts out even though dsc/ is re-included above. diff --git a/Dockerfile b/Dockerfile index df72a15..2f5e1b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,9 +61,10 @@ RUN pip install --no-cache-dir --upgrade "setuptools>=78.1.1" && \ # Copy app COPY dsc ./dsc/ COPY start.py ./start.py +COPY healthcheck.py ./healthcheck.py # 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 && \ usermod -G users seafile && \ mkdir -p /dsc/seafile-data && \ @@ -74,11 +75,19 @@ RUN chmod +x /dsc/start.py && \ RUN seaf-cli --help | grep -q 'download-by-name' && \ seaf-cli sync --help | grep -q '\--token' && \ 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 # runtime; seaf-cli itself has no --version flag. ENV SEAFILE_CLI_VERSION=${SEAFILE_CLI_VERSION} 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"] diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 906f273..6aa04d1 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -208,7 +208,12 @@ Acceptance criteria: - [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` 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 state. - [ ] Document supported CPU architectures instead of implying multi-platform diff --git a/dsc/client.py b/dsc/client.py index 88c150b..07dcd25 100644 --- a/dsc/client.py +++ b/dsc/client.py @@ -165,6 +165,22 @@ class SeafileClient: self.__wait_for_daemon(False, const.DAEMON_STOP_TIMEOUT, "stopping") _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]: """ Resolve a requested name or ID to the one library ID it names. diff --git a/healthcheck.py b/healthcheck.py new file mode 100644 index 0000000..fab5465 --- /dev/null +++ b/healthcheck.py @@ -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()) diff --git a/tests/smoke.sh b/tests/smoke.sh index 906c95d..9ebec88 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -119,6 +119,42 @@ else fi 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" 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)" diff --git a/tests/test_healthcheck.py b/tests/test_healthcheck.py new file mode 100644 index 0000000..e3f13c2 --- /dev/null +++ b/tests/test_healthcheck.py @@ -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