Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build-and-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,16 @@ jobs:
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4

- name: Login to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/full-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4

- name: Read Photon version from .last_release
id: photon_version
Expand Down
54 changes: 37 additions & 17 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,20 +1,40 @@
FROM eclipse-temurin:21.0.9_10-jre-noble
FROM ubuntu:noble AS builder

ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update \
&& apt-get -y install --no-install-recommends \
python3.12 \
&& rm -rf /var/lib/apt/lists/*

# install astral uv
COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /usr/local/bin/

WORKDIR /build

COPY pyproject.toml uv.lock ./

ENV UV_PYTHON=/usr/bin/python3.12 \
UV_PYTHON_PREFERENCE=only-system \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/photon/.venv

RUN uv sync --locked --no-dev --no-install-project


FROM eclipse-temurin:21.0.9_10-jre-noble

ARG DEBIAN_FRONTEND=noninteractive
ARG PHOTON_VERSION
ARG PUID=9011
ARG PGID=9011

RUN apt-get update \
&& apt-get -y install --no-install-recommends \
lbzip2 \
gosu \
python3.12 \
curl \
&& rm -rf /var/lib/apt/lists/*
&& apt-get -y install --no-install-recommends \
lbzip2 \
gosu \
python3.12 \
curl \
&& rm -rf /var/lib/apt/lists/*

RUN groupadd -g ${PGID} -o photon && \
useradd -l -u ${PUID} -g photon -o -s /bin/false -m -d /photon photon
Expand All @@ -27,24 +47,24 @@ ADD https://github.com/komoot/photon/releases/download/${PHOTON_VERSION}/photon-

COPY src/ ./src/
COPY entrypoint.sh .
COPY pyproject.toml .
COPY uv.lock .
RUN gosu photon uv sync --locked
COPY --from=builder /photon/.venv /photon/.venv

ENV PATH="/photon/.venv/bin:${PATH}" \
VIRTUAL_ENV=/photon/.venv

RUN chmod 644 /photon/photon.jar && \
chown -R photon:photon /photon

LABEL org.opencontainers.image.title="photon-docker" \
org.opencontainers.image.description="Unofficial docker image for the Photon Geocoder" \
org.opencontainers.image.url="https://github.com/rtuszik/photon-docker" \
org.opencontainers.image.source="https://github.com/rtuszik/photon-docker" \
org.opencontainers.image.documentation="https://github.com/rtuszik/photon-docker#readme"
org.opencontainers.image.description="Unofficial docker image for the Photon Geocoder" \
org.opencontainers.image.url="https://github.com/rtuszik/photon-docker" \
org.opencontainers.image.source="https://github.com/rtuszik/photon-docker" \
org.opencontainers.image.documentation="https://github.com/rtuszik/photon-docker#readme"

EXPOSE 2322

HEALTHCHECK --interval=30s --timeout=10s --start-period=240s --retries=3 \
CMD curl -f http://localhost:2322/status || exit 1
CMD curl -f http://localhost:2322/status || exit 1

ENTRYPOINT ["/bin/sh", "entrypoint.sh"]
CMD ["uv", "run", "-m", "src.process_manager"]
CMD ["/photon/.venv/bin/python", "-m", "src.process_manager"]
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ dev = [
requires = ["uv_build>=0.11.7,<0.12.0"]
build-backend = "uv_build"

[tool.coverage.run]
omit = ["tests/*"]

[tool.ruff]
indent-width = 4
line-length = 120
Expand Down
4 changes: 2 additions & 2 deletions src/process_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def handle_shutdown(self, signum, _frame):

def run_initial_setup(self):
logger.info("Running initial setup...")
result = subprocess.run(["uv", "run", "--no-sync", "-m", "src.entrypoint", "setup"], check=False, cwd="/photon") # noqa S603
result = subprocess.run([sys.executable, "-m", "src.entrypoint", "setup"], check=False, cwd="/photon") # noqa S603

if result.returncode != 0:
logger.error("Setup failed!")
Expand Down Expand Up @@ -218,7 +218,7 @@ def run_update(self):
if config.UPDATE_STRATEGY == "SEQUENTIAL":
self.stop_photon()

result = subprocess.run(["uv", "run", "--no-sync", "-m", "src.updater"], check=False, cwd="/photon") # noqa S603
result = subprocess.run([sys.executable, "-m", "src.updater"], check=False, cwd="/photon") # noqa S603

if result.returncode == 0:
logger.info("Update process completed, verifying Photon health...")
Expand Down
209 changes: 209 additions & 0 deletions tests/test_check_remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import datetime
import os
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from requests.exceptions import RequestException

from src import check_remote
from src.utils import config


@pytest.fixture
def fake_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
data_dir = tmp_path / "data"
photon_data_dir = data_dir / "photon_data"
os_node_dir = photon_data_dir / "node_1"
data_dir.mkdir()
photon_data_dir.mkdir()
monkeypatch.setattr(config, "DATA_DIR", str(data_dir))
monkeypatch.setattr(config, "PHOTON_DATA_DIR", str(photon_data_dir))
monkeypatch.setattr(config, "OS_NODE_DIR", str(os_node_dir))
return data_dir


def _mock_response(status_code=200, headers=None):
resp = MagicMock()
resp.status_code = status_code
resp.headers = headers or {}
resp.raise_for_status = MagicMock()
return resp


def test_get_remote_file_size_uses_content_length():
resp = _mock_response(headers={"content-length": "12345"})
with patch("src.check_remote.requests.head", return_value=resp):
assert check_remote.get_remote_file_size("https://example.com/x") == 12345


def test_get_remote_file_size_falls_back_to_range_request():
head_resp = _mock_response(headers={})
range_resp = _mock_response(status_code=206, headers={"content-range": "bytes 0-0/9876"})
with (
patch("src.check_remote.requests.head", return_value=head_resp),
patch("src.check_remote.requests.get", return_value=range_resp),
):
assert check_remote.get_remote_file_size("https://example.com/x") == 9876


def test_get_remote_file_size_raises_when_no_size_returned():
head_resp = _mock_response(headers={})
range_resp = _mock_response(status_code=200, headers={})
with (
patch("src.check_remote.requests.head", return_value=head_resp),
patch("src.check_remote.requests.get", return_value=range_resp),
pytest.raises(check_remote.RemoteFileSizeError, match="did not return file size"),
):
check_remote.get_remote_file_size("https://example.com/x")


def test_get_remote_file_size_wraps_request_errors():
with (
patch("src.check_remote.requests.head", side_effect=RequestException("boom")),
pytest.raises(check_remote.RemoteFileSizeError, match="Could not determine remote file size"),
):
check_remote.get_remote_file_size("https://example.com/x")


def test_get_remote_file_size_ignores_non_digit_range_total():
head_resp = _mock_response(headers={})
range_resp = _mock_response(status_code=206, headers={"content-range": "bytes 0-0/*"})
with (
patch("src.check_remote.requests.head", return_value=head_resp),
patch("src.check_remote.requests.get", return_value=range_resp),
pytest.raises(check_remote.RemoteFileSizeError),
):
check_remote.get_remote_file_size("https://example.com/x")


def test_get_remote_time_returns_parsed_datetime():
resp = _mock_response(headers={"last-modified": "Wed, 21 Oct 2026 07:28:00 GMT"})
with patch("src.check_remote.requests.head", return_value=resp):
result = check_remote.get_remote_time("https://example.com")
assert result is not None
assert result.year == 2026
assert result.month == 10
assert result.day == 21


def test_get_remote_time_returns_none_when_header_missing():
resp = _mock_response(headers={})
with patch("src.check_remote.requests.head", return_value=resp):
assert check_remote.get_remote_time("https://example.com") is None


def test_get_remote_time_returns_none_on_request_error():
with patch("src.check_remote.requests.head", side_effect=RequestException("nope")):
assert check_remote.get_remote_time("https://example.com") is None


def test_get_local_time_returns_marker_mtime_when_present(fake_dirs: Path):
marker = fake_dirs / ".photon-index-updated"
marker.write_text("")
os.utime(marker, (1_000_000, 1_000_000))
assert check_remote.get_local_time(str(fake_dirs / "missing")) == 1_000_000


def test_get_local_time_returns_path_mtime_when_no_marker(fake_dirs: Path):
target = fake_dirs / "node_1"
target.mkdir()
os.utime(target, (2_000_000, 2_000_000))
assert check_remote.get_local_time(str(target)) == 2_000_000


def test_get_local_time_returns_zero_when_path_missing(fake_dirs: Path):
assert check_remote.get_local_time(str(fake_dirs / "missing")) == 0.0


def test_compare_mtime_returns_false_when_remote_time_unknown(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(config, "REGION", None)
monkeypatch.setattr(config, "BASE_URL", "https://example.com")
with patch("src.check_remote.get_remote_time", return_value=None):
assert check_remote.compare_mtime() is False


def test_compare_mtime_returns_false_on_invalid_region(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(config, "REGION", "atlantis")
assert check_remote.compare_mtime() is False


def test_compare_mtime_with_marker_compares_directly(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(config, "REGION", None)
monkeypatch.setattr(config, "BASE_URL", "https://example.com")

marker = fake_dirs / ".photon-index-updated"
marker.write_text("")
local_ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC).timestamp()
os.utime(marker, (local_ts, local_ts))

remote_dt = datetime.datetime(2026, 1, 2, tzinfo=datetime.UTC)
with patch("src.check_remote.get_remote_time", return_value=remote_dt):
assert check_remote.compare_mtime() is True


def test_compare_mtime_with_marker_returns_false_when_remote_older(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(config, "REGION", None)
monkeypatch.setattr(config, "BASE_URL", "https://example.com")

marker = fake_dirs / ".photon-index-updated"
marker.write_text("")
local_ts = datetime.datetime(2026, 1, 10, tzinfo=datetime.UTC).timestamp()
os.utime(marker, (local_ts, local_ts))

remote_dt = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
with patch("src.check_remote.get_remote_time", return_value=remote_dt):
assert check_remote.compare_mtime() is False


def test_compare_mtime_directory_uses_grace_period(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(config, "REGION", None)
monkeypatch.setattr(config, "BASE_URL", "https://example.com")

node_dir = Path(config.OS_NODE_DIR)
node_dir.mkdir()
local_ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC).timestamp()
os.utime(node_dir, (local_ts, local_ts))

remote_within_grace = datetime.datetime(2026, 1, 5, tzinfo=datetime.UTC)
with patch("src.check_remote.get_remote_time", return_value=remote_within_grace):
assert check_remote.compare_mtime() is False

remote_past_grace = datetime.datetime(2026, 1, 20, tzinfo=datetime.UTC)
with patch("src.check_remote.get_remote_time", return_value=remote_past_grace):
assert check_remote.compare_mtime() is True


def test_check_index_age_returns_true_when_min_date_unset(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(config, "MIN_INDEX_DATE", None)
assert check_remote.check_index_age() is True


def test_check_index_age_warns_and_returns_true_on_bad_format(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(config, "MIN_INDEX_DATE", "2026-01-01")
assert check_remote.check_index_age() is True


def test_check_index_age_returns_true_when_no_local_index(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.01.26")
assert check_remote.check_index_age() is True


def test_check_index_age_returns_true_when_update_required_due_to_old_index(
fake_dirs: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.06.26")
node_dir = Path(config.OS_NODE_DIR)
node_dir.mkdir()
local_ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC).timestamp()
os.utime(node_dir, (local_ts, local_ts))
assert check_remote.check_index_age() is True


def test_check_index_age_returns_false_when_local_meets_min(fake_dirs: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(config, "MIN_INDEX_DATE", "01.01.26")
node_dir = Path(config.OS_NODE_DIR)
node_dir.mkdir()
local_ts = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC).timestamp()
os.utime(node_dir, (local_ts, local_ts))
assert check_remote.check_index_age() is False
Loading
Loading