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
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,26 @@ staticfiles

# Tests and dev tools (not needed at runtime; run tests locally with: make test)
tests
benchmarks
*_test.py
test_*.py
conftest.py
pytest.ini
setup.cfg
.pre-commit-config.yaml
requirements-dev.in
requirements-dev.lock
.github
.test_artifacts
bench.json
coverage.xml
coverage.json
htmlcov
docker-compose.yml
docker-compose.ci.yml
docker-compose.test.yml
docker-compose.prod.yml
Makefile

# OS
.DS_Store
Expand Down
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ DATABASE_URL=postgres://user:password@localhost:5432/boost_dashboard
# LOG_FILE=app.log
# LOG_MAX_BYTES=5242880
# LOG_BACKUP_COUNT=5
# LOG_FORMAT=json
#
# Readiness GET /health/ (database, Celery workers, collector group freshness):
# HEALTH_CHECK_TOKEN=
# HEALTH_CELERY_MIN_WORKERS=1
# HEALTH_CELERY_INSPECT_TIMEOUT=3.0
# HEALTH_COLLECTOR_STALE_HOURS=26
# HEALTH_ENFORCE_COLLECTOR_FRESHNESS=true
# CELERY_MAX_TASKS_PER_CHILD=50

# ==============================================================================
# Error notifications (Discord / Slack)
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ jobs:
echo "DATABASE_URL=postgres://boost:boost@db:5432/boost_dashboard" >> .env
echo "SECRET_KEY=ci-only-secret-key" >> .env
echo "DEBUG=True" >> .env
echo "HEALTH_ENFORCE_COLLECTOR_FRESHNESS=false" >> .env

- name: Build images
run: make build
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Each Django app that has **models** provides a **`services.py`** module. This is

| App | File | Notes |
| ------------------------- | ------------------------------------- | --------------------------------------------- |
| `boost_collector_runner` | `boost_collector_runner/services.py` | Collector group run status (YAML schedule groups). |
| `cppa_user_tracker` | `cppa_user_tracker/services.py` | Identity, profiles, emails, staging. |
| `github_activity_tracker` | `github_activity_tracker/services.py` | Repos, languages, licenses, issues, PRs. |
| `boost_library_tracker` | `boost_library_tracker/services.py` | Boost libraries, versions, dependencies, categories, roles. |
Expand Down
43 changes: 24 additions & 19 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,45 +1,50 @@
# Boost Data Collector - Docker image
# Same image runs: web (gunicorn), celery worker, celery beat

FROM python:3.11-slim
FROM python:3.13-slim

# Prevent Python from writing .pyc and buffering stdout/stderr
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV DJANGO_SETTINGS_MODULE=config.settings

WORKDIR /app

# Install system deps (PostgreSQL client libs for psycopg, git for github_ops, gosu for entrypoint)
# System deps: PostgreSQL client, git, curl (HEALTHCHECK), gosu (dev entrypoint only).
# Pinned to Debian 13 (trixie) versions from python:3.13-slim at pin time; refresh with:
# docker run --rm python:3.13-slim bash -c 'apt-get update -qq && for p in libpq5 git curl gosu; do echo -n "$p="; apt-cache policy "$p" | awk "/Candidate:/{print \$2; exit}"; done'
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
git \
gosu \
libpq5=17.10-0+deb13u1 \
git=1:2.47.3-0+deb13u1 \
curl=8.14.1-2+deb13u3 \
gosu=1.17-3+b4 \
&& rm -rf /var/lib/apt/lists/*
Comment thread
leostar0412 marked this conversation as resolved.

# Install Python dependencies (fully pinned lockfile; gunicorn included in lock)
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock

# Copy project code
COPY . .

# Create dirs that Django/settings expect (logs, staticfiles, workspace, celerybeat)
RUN mkdir -p logs staticfiles workspace celerybeat

# Entrypoint fixes volume permissions then runs CMD as appuser
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh

# Entrypoint runs as root, chowns mounted dirs, then exec's CMD as appuser via gosu
RUN useradd --create-home appuser && chown -R appuser /app
# Git 2.35+ blocks repos when directory owner != current user; bind mounts often
# disagree (e.g. Docker Desktop on Windows). System config applies to root and appuser
# (e.g. docker exec as root vs gosu appuser in entrypoint).
RUN groupadd --gid 10001 appuser \
&& useradd --uid 10001 --gid 10001 --create-home appuser \
&& chown -R appuser:appuser /app
RUN git config --system --add safe.directory '/app/workspace/*'
ENTRYPOINT ["/app/docker-entrypoint.sh"]
# Container starts as root so entrypoint can chown; CMD runs as appuser via gosu

# Default: run gunicorn (overridden in docker-compose for worker/beat)
USER appuser

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "config.wsgi:application"]

# When HEALTH_CHECK_TOKEN is set (runtime env from compose/.env), send Bearer auth.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD sh -c 'if [ -n "${HEALTH_CHECK_TOKEN:-}" ]; then \
curl -fsS -H "Authorization: Bearer ${HEALTH_CHECK_TOKEN}" http://127.0.0.1:8000/health/; \
else \
curl -fsS http://127.0.0.1:8000/health/; \
fi'

ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["gunicorn", "-c", "docker/gunicorn.conf.py", "config.wsgi:application"]
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,15 @@ ps:
$(COMPOSE) ps

.PHONY: health
# HEALTH_CHECK_TOKEN comes from the web container env (env_file: .env). When set, /health/ requires Bearer auth.
health:
$(COMPOSE) exec -T $(APP) python manage.py check --database default
$(COMPOSE) exec -T $(APP) python manage.py shell -c "from django.conf import settings; import sys; n = len(settings.CELERY_BEAT_SCHEDULE); print('Beat schedule entries:', n); sys.exit(1 if n <= 0 else 0)"
$(COMPOSE) exec -T $(APP) sh -c '\
if [ -n "$${HEALTH_CHECK_TOKEN:-}" ]; then \
curl -fsS -H "Authorization: Bearer $${HEALTH_CHECK_TOKEN}" http://127.0.0.1:8000/health/; \
else \
curl -fsS http://127.0.0.1:8000/health/; \
fi | python -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"status\")); sys.exit(0 if d.get(\"status\")==\"healthy\" else 1)"'
$(COMPOSE) exec -T redis redis-cli ping | grep -q PONG
$(COMPOSE) exec -T selenium curl -sf http://localhost:4444/status | grep -qE '"ready"[[:space:]]*:[[:space:]]*true'
$(COMPOSE) ps --status running celery_worker | grep -q celery_worker
$(COMPOSE) ps --status running celery_beat | grep -q celery_beat

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Authoritative names, examples, and comments live in **[`.env.example`](.env.exam

### Prerequisites

- Python 3.11+
- Python 3.13 (Docker image and CI; minimum declared in `pyproject.toml` is 3.11+)
- Django (version in `requirements.txt`)
- PostgreSQL database access
- **pandoc** — required by `boost_library_docs_tracker` for HTML→Markdown conversion (`pypandoc` calls the `pandoc` binary at runtime):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError

from boost_collector_runner import services as collector_services
from boost_collector_runner.schedule_config import (
ScheduleConfigurationError,
ensure_schedule_yaml_loaded,
Expand Down Expand Up @@ -269,5 +270,16 @@ def handle(self, *args, **options):
else:
logger.warning(summary)

if group_id and results:
if exit_code == 0:
collector_services.record_group_success(group_id)
else:
collector_services.record_group_failure(group_id, exit_code=exit_code)
elif group_id:
logger.info(
"run_scheduled_collectors: no executed tasks for group=%s; skipping status update",
group_id,
)

if exit_code != 0:
sys.exit(exit_code)
32 changes: 32 additions & 0 deletions boost_collector_runner/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated manually for production health tracking

from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="CollectorGroupRunStatus",
fields=[
(
"group_id",
models.CharField(max_length=64, primary_key=True, serialize=False),
),
("last_success_at", models.DateTimeField(blank=True, null=True)),
("last_failure_at", models.DateTimeField(blank=True, null=True)),
("last_run_at", models.DateTimeField(blank=True, null=True)),
("last_exit_code", models.IntegerField(blank=True, null=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "Collector group run status",
"verbose_name_plural": "Collector group run statuses",
"db_table": "boost_collector_runner_collectorgrouprunstatus",
},
),
]
23 changes: 22 additions & 1 deletion boost_collector_runner/models.py
Original file line number Diff line number Diff line change
@@ -1 +1,22 @@
# Boost collector runner has no models; it only runs management commands.
"""Models for scheduled collector group run tracking."""

from django.db import models


class CollectorGroupRunStatus(models.Model):
"""Last run outcome per YAML schedule group (e.g. github, slack)."""

group_id = models.CharField(max_length=64, primary_key=True)
last_success_at = models.DateTimeField(null=True, blank=True)
last_failure_at = models.DateTimeField(null=True, blank=True)
last_run_at = models.DateTimeField(null=True, blank=True)
last_exit_code = models.IntegerField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
db_table = "boost_collector_runner_collectorgrouprunstatus"
verbose_name = "Collector group run status"
verbose_name_plural = "Collector group run statuses"

def __str__(self) -> str:
return f"CollectorGroupRunStatus(group_id={self.group_id})"
60 changes: 60 additions & 0 deletions boost_collector_runner/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""
Service layer for boost_collector_runner.

All creates/updates for this app's models must go through functions in this module.
See CONTRIBUTING.md.
"""

from __future__ import annotations

from datetime import datetime
from typing import Optional

from django.utils import timezone

from .models import CollectorGroupRunStatus


def record_group_success(
group_id: str, *, when: Optional[datetime] = None
) -> CollectorGroupRunStatus:
"""Record a successful group batch run."""
ts = when or timezone.now()
obj, _created = CollectorGroupRunStatus.objects.update_or_create(
group_id=group_id,
defaults={
"last_success_at": ts,
"last_run_at": ts,
"last_exit_code": 0,
},
)
return obj


def record_group_failure(
group_id: str,
*,
exit_code: int = 1,
when: Optional[datetime] = None,
) -> CollectorGroupRunStatus:
"""Record a failed group batch run."""
ts = when or timezone.now()
obj, _created = CollectorGroupRunStatus.objects.update_or_create(
group_id=group_id,
defaults={
"last_failure_at": ts,
"last_run_at": ts,
"last_exit_code": exit_code,
},
)
return obj


def get_group_status(group_id: str) -> Optional[CollectorGroupRunStatus]:
"""Return status row for a group, or None if never run."""
return CollectorGroupRunStatus.objects.filter(group_id=group_id).first()


def list_group_statuses() -> dict[str, CollectorGroupRunStatus]:
"""Return all group statuses keyed by group_id."""
return {row.group_id: row for row in CollectorGroupRunStatus.objects.all()}
47 changes: 47 additions & 0 deletions boost_collector_runner/tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,53 @@ def fail_first(name, *args, **kwargs):
assert any("run_a" in r.getMessage() for r in skip_msgs)


@pytest.mark.django_db
def test_run_scheduled_collectors_skipped_on_release_does_not_record_success(
tmp_path, settings
):
"""When all tasks are skipped (e.g. on_release with no new release), do not record group success."""

yaml_path = tmp_path / "boost_collector_schedule.yaml"
yaml_path.write_text("groups: {}\n", encoding="utf-8")
settings.BOOST_COLLECTOR_SCHEDULE_YAML = str(yaml_path)
fake_tasks = [
("boost", {"command": "run_boost_release", "schedule": "on_release"}),
]
with (
patch(
"boost_collector_runner.schedule_config._get_yaml_path",
return_value=yaml_path,
),
patch(
"boost_collector_runner.management.commands.run_scheduled_collectors.get_tasks_for_schedule",
return_value=fake_tasks,
),
patch(
"boost_library_tracker.release_check.has_new_boost_release",
return_value=False,
),
patch(
"boost_collector_runner.management.commands.run_scheduled_collectors.call_command",
) as mock_call,
patch(
"boost_collector_runner.management.commands.run_scheduled_collectors.collector_services.record_group_success",
) as mock_success,
patch(
"boost_collector_runner.management.commands.run_scheduled_collectors.collector_services.record_group_failure",
) as mock_failure,
):
call_command(
"run_scheduled_collectors",
"--schedule",
"on_release",
"--group",
"boost",
)
mock_call.assert_not_called()
mock_success.assert_not_called()
mock_failure.assert_not_called()


@pytest.mark.django_db
def test_run_scheduled_collectors_strict_missing_yaml_raises(tmp_path, settings):
missing = tmp_path / "missing.yaml"
Expand Down
31 changes: 31 additions & 0 deletions boost_collector_runner/tests/test_services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Tests for boost_collector_runner.services."""

import pytest
from django.utils import timezone

from boost_collector_runner import services
from boost_collector_runner.models import CollectorGroupRunStatus

pytestmark = pytest.mark.django_db


def test_record_group_success_creates_row():
when = timezone.now()
row = services.record_group_success("github", when=when)
assert row.group_id == "github"
assert row.last_success_at == when
assert row.last_exit_code == 0
assert CollectorGroupRunStatus.objects.filter(group_id="github").exists()


def test_record_group_failure_sets_exit_code():
when = timezone.now()
row = services.record_group_failure("slack", exit_code=2, when=when)
assert row.last_failure_at == when
assert row.last_exit_code == 2


def test_list_group_statuses():
services.record_group_success("github")
statuses = services.list_group_statuses()
assert "github" in statuses
Loading
Loading