Skip to content
Merged
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/first-interaction.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ jobs:
steps:
- uses: actions/first-interaction@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: |
repo_token: ${{ secrets.GITHUB_TOKEN }}
issue_message: |
Welcome to our community :hugs: and thank you for your first contribution.

As a first time contributor please make sure to review our [contribution guidelines](../blob/main/CONTRIBUTING.md) :heart:
pr-message: |
pr_message: |
Welcome to our community :hugs: and thank you for your first contribution.

As a first time contributor please make sure to review our [contribution guidelines](../blob/main/CONTRIBUTING.md) :heart:
18 changes: 9 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_VIRTUALENVS_IN_PROJECT=true \

POETRY_NO_INTERACTION=1 \
POETRY_HOME='/usr/local'

Check warning on line 20 in Dockerfile

View workflow job for this annotation

GitHub Actions / build

Empty continuation lines will become errors in a future release

NoEmptyContinuation: Empty continuation line More info: https://docs.docker.com/go/dockerfile/rule/no-empty-continuation/

RUN apt-get update && apt-get install -y --no-install-recommends \
curl && rm -rf /var/lib/apt/lists/*
Expand Down Expand Up @@ -47,19 +48,18 @@

FROM project-base AS production

RUN groupadd -g 1000 tr && \
adduser --uid 1000 --system --ingroup tr tr

RUN groupadd tr && \
adduser --uid 100 --system --ingroup tr tr

RUN chown tr:tr /app/
RUN chown -R tr:tr /app/

USER tr

COPY --chown=root:root --chmod=755 task_registry /app/task_registry
COPY --chown=root:root --chmod=755 instruments /app/instruments
COPY --chown=root:root --chmod=755 requirements /app/requirements
COPY --chown=root:root --chmod=755 measures /app/measures
COPY --chown=root:root --chmod=755 LICENSE /app/LICENSE
COPY --chown=tr:tr --chmod=755 task_registry /app/task_registry
COPY --chown=tr:tr --chmod=755 instruments /app/instruments
COPY --chown=tr:tr --chmod=755 requirements /app/requirements
COPY --chown=tr:tr --chmod=755 measures /app/measures
COPY --chown=tr:tr --chmod=755 LICENSE /app/LICENSE

ENV PYTHONPATH=/app/
WORKDIR /app/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
This registry is an overarching architecture to group all Tasks that can be performed in the context of algorithm and AI-system management, compliance and governance. The purpose of the register is to bridge policy and running code.
A task could be part of an instrument and correspond to a maatregel/measure and/or vereisten/requirement (see 2. Definitions).

The registry is deployed here: [https://task-registry.apps.digilab.network/](https://task-registry.apps.digilab.network/).
The registry is deployed here: [https://task-registry.rijksapp.nl/](https://task-registry.rijksapp.nl/).

A graphical overview of the task registry and its components is shown below:

Expand Down
166 changes: 134 additions & 32 deletions poetry.lock

Large diffs are not rendered by default.

18 changes: 13 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ deploy = "task_registry.data:main"
python = "^3.12"
jsonschema = "^4.22.0"
pyyaml = "^6.0.1"
fastapi = "^0.115.6"
fastapi = "^0.125.0"
uvicorn = "^0.30.6"
pydantic-settings = "^2.4.0"

setuptools = "^74.1.1"
setuptools = "^78.1.1"
click = "^8.1.7"
prometheus-fastapi-instrumentator = "^7.1.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Expand Down Expand Up @@ -121,15 +122,22 @@ level = "PARANOID"
dependencies = true
authorized_licenses = [
"Apache Software",
"Apache-2.0 AND BSD-2-Clause",
"Artistic",
"BSD",
"BSD-2-Clause",
"BSD-3-Clause",
"GNU General Public License v2 or later (GPLv2+)",
"GNU General Public License v3 or later (GPLv3+)",
"GNU General Public License (GPL)",
"GNU Library or Lesser General Public License (LGPL)",
"MIT",
"The Unlicense (Unlicense)",
"ISC",
"ISC License (ISCL)",
"MIT",
"Mozilla Public License 2.0 (MPL 2.0)",
"Python Software Foundation"
"Python Software Foundation",
"The Unlicense (Unlicense)"
]
# setuptools is MIT licensed but doesn't include license metadata in v78+
[tool.liccheck.authorized_packages]
setuptools = "78.1.1"
2 changes: 1 addition & 1 deletion task_registry/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from task_registry.api.routes import health, instruments, measures, requirements, urns

api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(health.router, prefix="/health", tags=["health"], include_in_schema=False)
api_router.include_router(instruments.router, prefix="/instruments", tags=["instruments"])
api_router.include_router(measures.router, prefix="/measures", tags=["measures"])
api_router.include_router(requirements.router, prefix="/requirements", tags=["requirements"])
Expand Down
1 change: 1 addition & 0 deletions task_registry/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class Settings(BaseSettings):

LOGGING_LEVEL: LoggingLevelType = "INFO"
LOGGING_CONFIG: dict[str, Any] | None = None
LOG_TO_FILE: bool = False


def get_settings() -> Settings:
Expand Down
32 changes: 20 additions & 12 deletions task_registry/core/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
LOGGING_SIZE = 10 * 1024 * 1024
LOGGING_BACKUP_COUNT = 5

LOGGING_CONFIG = {
LOGGING_CONFIG: dict[str, Any] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
Expand All @@ -25,27 +25,35 @@
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
"file": {
"formatter": "generic",
"()": "logging.handlers.RotatingFileHandler",
"filename": "task-registry.log",
"maxBytes": LOGGING_SIZE,
"backupCount": LOGGING_BACKUP_COUNT,
},
},
"loggers": {
"": {"handlers": ["console", "file"], "level": "DEBUG", "propagate": False},
"": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
"httpcore": {
"handlers": ["console", "file"],
"handlers": ["console"],
"level": "ERROR",
"propagate": False,
},
},
}

FILE_HANDLER_CONFIG: dict[str, Any] = {
"formatter": "generic",
"()": "logging.handlers.RotatingFileHandler",
"filename": "task-registry.log",
"maxBytes": LOGGING_SIZE,
"backupCount": LOGGING_BACKUP_COUNT,
}


def configure_logging(
level: LoggingLevelType = "INFO", config: dict[str, Any] | None = None, log_to_file: bool = False
) -> None:
log_config: dict[str, Any] = copy.deepcopy(LOGGING_CONFIG)

def configure_logging(level: LoggingLevelType = "INFO", config: dict[str, Any] | None = None) -> None:
log_config = copy.deepcopy(LOGGING_CONFIG)
if log_to_file:
log_config["handlers"]["file"] = FILE_HANDLER_CONFIG.copy()
for logger_config in log_config["loggers"].values():
logger_config["handlers"].append("file")

if config:
log_config.update(config)
Expand Down
12 changes: 5 additions & 7 deletions task_registry/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ class Index(BaseModel):
size: int = Field(examples=[0])
name: str = Field(examples=["task_collection_name"])
path: str = Field(examples=["task_collection_path"])
download_url: str = Field(examples=["https://task-registry.apps.digilab.network/task_collection"])
links: Link = Field(examples=[{"self": "https://task-registry.apps.digilab.network"}])
download_url: str = Field(examples=["https://task-registry.rijksapp.nl/task_collection"])
links: Link = Field(examples=[{"self": "https://task-registry.rijksapp.nl"}])
entries: list[FileInfo] = Field(
examples=[
{
Expand All @@ -44,10 +44,8 @@ class Index(BaseModel):
"name": "task_name.yaml",
"path": "task_collection_path/task_name.yaml",
"urn": "urn:nl:aivt:tr:xx:xx",
"download_url": "https://task-registry.apps.digilab.network/task_collection/urn/urn:nl:aivt:tr:aiia:1.0",
"links": {
"self": "https://task-registry.apps.digilab.network/task_collection/urn/urn:nl:aivt:tr:aiia:1.0"
},
"download_url": "https://task-registry.rijksapp.nl/task_collection/urn/urn:nl:aivt:tr:aiia:1.0",
"links": {"self": "https://task-registry.rijksapp.nl/task_collection/urn/urn:nl:aivt:tr:aiia:1.0"},
}
]
)
Expand Down Expand Up @@ -83,7 +81,7 @@ def has_urn(self, urn: str, tasks: TaskType) -> bool:

def generate_index(
tasks: TaskType,
base_url: str = "https://task-registry.apps.digilab.network",
base_url: str = "https://task-registry.rijksapp.nl",
) -> Index:
tasks_url = f"{base_url}/{tasks}"
entries: list[FileInfo] = []
Expand Down
2 changes: 1 addition & 1 deletion task_registry/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

CACHED_REGISTRY = CachedRegistry()

configure_logging(get_settings().LOGGING_LEVEL, get_settings().LOGGING_CONFIG)
configure_logging(get_settings().LOGGING_LEVEL, get_settings().LOGGING_CONFIG, get_settings().LOG_TO_FILE)

logger = logging.getLogger(__name__)

Expand Down
14 changes: 14 additions & 0 deletions task_registry/server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from fastapi import FastAPI
from prometheus_client import CONTENT_TYPE_LATEST, REGISTRY, generate_latest
from prometheus_fastapi_instrumentator import Instrumentator
from starlette.requests import Request
from starlette.responses import Response
from task_registry.api.main import api_router
from task_registry.core.config import LICENSE_NAME, LICENSE_URL, PROJECT_NAME, PROJECT_SUMMARY, VERSION
from task_registry.lifespan import lifespan
Expand All @@ -14,6 +18,16 @@ def create_app() -> FastAPI:
docs_url="/",
)
app.include_router(api_router)

Instrumentator(excluded_handlers=["/metrics", "/health"]).instrument(app)

# TODO(security): /metrics and /health endpoints are currently exposed on the main port.
# For production, consider running these on a separate internal port (e.g., 8001) to avoid
# public exposure, or ensure they are blocked at the Ingress level.
@app.get("/metrics", include_in_schema=False)
async def metrics(_request: Request) -> Response: # pyright: ignore[reportUnusedFunction]
return Response(content=generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)

return app


Expand Down
22 changes: 22 additions & 0 deletions tests/api/routes/test_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import pytest
from fastapi.testclient import TestClient


def test_metrics_endpoint(client: TestClient) -> None:
try:
response = client.get("/metrics")
except TypeError as e:
# Known issue: prometheus_client ProcessCollector has a bug on some Linux environments
# (e.g., GitHub Actions runners) where it fails with "must be str or None, not bytes"
# when reading /proc/[pid]/stat. This works correctly in Kubernetes clusters.
# See: https://github.com/prometheus/client_python/issues
if "must be str or None, not bytes" in str(e):
pytest.skip("Skipping due to known ProcessCollector bug on this platform")
raise

assert response.status_code == 200
assert "text/plain" in response.headers["content-type"]
# Check for expected Prometheus metrics
assert "http_requests_total" in response.text
assert "http_request_duration_seconds" in response.text
assert "python_info" in response.text
47 changes: 46 additions & 1 deletion tests/core/test_log.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import logging
import tempfile
from pathlib import Path

import pytest
from task_registry.core.config import Settings
from task_registry.core.log import configure_logging
from task_registry.core.log import FILE_HANDLER_CONFIG, configure_logging


def test_logging_tr_module(caplog: pytest.LogCaptureFixture):
Expand Down Expand Up @@ -84,3 +86,46 @@ def test_logging_loglevel(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.
logger.critical(message)

assert len(caplog.records) == 2


def test_logging_log_to_file(monkeypatch: pytest.MonkeyPatch) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
log_file = Path(tmpdir) / "test.log"
monkeypatch.setitem(FILE_HANDLER_CONFIG, "filename", str(log_file))

configure_logging(log_to_file=True)

# Verify file handler was added to root logger
root_logger = logging.getLogger()
handler_classes = [type(h).__name__ for h in root_logger.handlers]
assert "RotatingFileHandler" in handler_classes

# Log a message and verify it's written to the file
message = "This is a file log message"
root_logger.info(message)

# Flush handlers to ensure log is written
for handler in root_logger.handlers:
handler.flush()

assert log_file.exists()
log_content = log_file.read_text()
assert message in log_content


def test_logging_log_to_file_disabled() -> None:
# When log_to_file is False (default), no file handler should be added
configure_logging(log_to_file=False)

root_logger = logging.getLogger()
handler_classes = [type(h).__name__ for h in root_logger.handlers]

assert "RotatingFileHandler" not in handler_classes


def test_logging_log_to_file_setting(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LOG_TO_FILE", "false")

settings = Settings()

assert settings.LOG_TO_FILE is False
Loading