Initial commit

This commit is contained in:
notifier committed 2026-11-11 09:56:43 +00:00
commit 85d261e987
18 files changed
+709

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
name: Build & Publish to GHCR via local build
on:
workflow_dispatch:
env:
GHCR_ORG: gansejunge
IMAGE_NAME: app-notifications-service-GENERIC
IMAGE_PATH: ghcr.io/gansejunge/app-notifications-service-GENERIC
OPS_ORG: notifier
OPS_REPO: ops-deployment
OPS_BRANCH_BASE: main
OPS_PATH: service-GENERIC/deployment.yaml
jobs:
build:
runs-on: local
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GHCR
run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin
- name: Get Latest Tag
id: get_tag
run: |
BASE64_TOKEN=$(echo "${{ secrets.GHCR_TOKEN }}" | base64)
LATEST_TAG=$(curl -s -H "Authorization: Bearer $BASE64_TOKEN" \
https://ghcr.io/v2/${{ env.GHCR_ORG }}/${{ env.IMAGE_NAME }}/tags/list \
| jq -r '.tags | map(select(test("^[0-9]+$"))) | map(tonumber) | max // 0')
NEXT_TAG=$((LATEST_TAG + 1))
echo "latest=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "next=$NEXT_TAG" >> $GITHUB_OUTPUT
- name: Build and Push Docker Image
run: |
docker build \
--build-arg COMMIT_ID=$(echo "${{ github.sha }}" | cut -c1-7) \
-t ghcr.io/gansejunge/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.next }} .
docker push ghcr.io/gansejunge/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.next }}
- name: Clone ops-deployment repo
uses: actions/checkout@v4
with:
repository: notifier/ops-deployment
path: ./ops-deployment
token: ${{ secrets.BOT_TOKEN }}
- name: Update GENERIC deployment
working-directory: ./ops-deployment
run: |
NEW_TAG=${{ steps.get_tag.outputs.next }}
NEW_IMAGE="${{ env.IMAGE_PATH }}:$NEW_TAG"
git config user.name "automation-bot"
git config user.email "dev@gansejunge.com"
sed -i "s|ghcr.io/$GHCR_ORG/$IMAGE_NAME:[0-9]\+|$NEW_IMAGE|g" "$OPS_PATH"
COMMIT_URL="https://git.gansejunge.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}"
git add $OPS_PATH
git commit -m "Update GENERIC image to version $NEW_TAG" -m "Linked build commit: $COMMIT_URL"
git push origin $OPS_BRANCH_BASE
+63
View File
@@ -0,0 +1,63 @@
name: Build & Publish to GHCR
on:
workflow_dispatch:
env:
GHCR_ORG: gansejunge
IMAGE_NAME: app-notifications-service-GENERIC
IMAGE_PATH: ghcr.io/gansejunge/app-notifications-service-GENERIC
OPS_ORG: notifier
OPS_REPO: ops-deployment
OPS_BRANCH_BASE: main
OPS_PATH: service-GENERIC/deployment.yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GHCR
run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin
- name: Get Latest Tag
id: get_tag
run: |
BASE64_TOKEN=$(echo "${{ secrets.GHCR_TOKEN }}" | base64)
LATEST_TAG=$(curl -s -H "Authorization: Bearer $BASE64_TOKEN" \
https://ghcr.io/v2/${{ env.GHCR_ORG }}/${{ env.IMAGE_NAME }}/tags/list \
| jq -r '.tags | map(select(test("^[0-9]+$"))) | map(tonumber) | max // 0')
NEXT_TAG=$((LATEST_TAG + 1))
echo "latest=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "next=$NEXT_TAG" >> $GITHUB_OUTPUT
- name: Build and Push Docker Image
run: |
docker build \
--build-arg COMMIT_ID=$(echo "${{ github.sha }}" | cut -c1-7) \
-t ghcr.io/gansejunge/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.next }} .
docker push ghcr.io/gansejunge/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.next }}
- name: Clone ops-deployment repo
uses: actions/checkout@v4
with:
repository: notifier/ops-deployment
path: ./ops-deployment
token: ${{ secrets.BOT_TOKEN }}
- name: Update GENERIC deployment
working-directory: ./ops-deployment
run: |
NEW_TAG=${{ steps.get_tag.outputs.next }}
NEW_IMAGE="${{ env.IMAGE_PATH }}:$NEW_TAG"
git config user.name "automation-bot"
git config user.email "dev@gansejunge.com"
sed -i "s|ghcr.io/$GHCR_ORG/$IMAGE_NAME:[0-9]\+|$NEW_IMAGE|g" "$OPS_PATH"
COMMIT_URL="https://git.gansejunge.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}"
git add $OPS_PATH
git commit -m "Update GENERIC image to version $NEW_TAG" -m "Linked build commit: $COMMIT_URL"
git push origin $OPS_BRANCH_BASE
+78
View File
@@ -0,0 +1,78 @@
name: pip-audit
on:
schedule:
- cron: "35 1 * * *"
push:
branches: [main]
paths: [requirements.txt, scripts/filter_requirements.py]
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
outputs:
report_b64: ${{ steps.audit.outputs.report_b64 }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Audit dependencies
id: audit
shell: bash
run: |
pip install pip-audit
python3 scripts/filter_requirements.py requirements.txt requirements.audit.txt
# Don't let a non-zero exit abort the step, so the report is saved first
set +e
pip-audit -r requirements.audit.txt --no-deps --disable-pip > audit.txt 2>&1
echo $? > audit.exit
set -e
cat audit.txt
echo "report_b64=$(head -c 100000 audit.txt | base64 -w0)" >> "$GITHUB_OUTPUT"
- name: Fail if the audit failed
shell: bash
run: exit "$(cat audit.exit)"
notify:
needs: audit
if: ${{ always() && needs.audit.result == 'failure' }}
runs-on: host
steps:
- name: Send mail via local postfix
env:
MAIL_FROM: ${{ vars.MAIL_FROM }}
MAIL_TO: ${{ vars.MAIL_TO }}
REPO: ${{ gitea.repository }}
RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }}
REPORT_B64: ${{ needs.audit.outputs.report_b64 }}
run: |
python3 - <<'EOF'
import base64, os, smtplib
from email.message import EmailMessage
b64 = os.environ.get("REPORT_B64", "")
report = (
base64.b64decode(b64).decode("utf-8", errors="replace")
if b64 else
"(no audit output, the job failed before pip-audit finished)"
)
msg = EmailMessage()
msg["Subject"] = f"[pip-audit] Problem in {os.environ['REPO']}"
msg["From"] = os.environ["MAIL_FROM"]
msg["To"] = os.environ["MAIL_TO"]
msg.set_content(
f"pip-audit result for {os.environ['REPO']}:\n\n"
f"{report}\n\n"
f"Run: {os.environ['RUN_URL']}\n"
)
with smtplib.SMTP("127.0.0.1", 25, timeout=30) as s:
s.send_message(msg)
EOF
+176
View File
@@ -0,0 +1,176 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
+19
View File
@@ -0,0 +1,19 @@
# Builder stage
FROM python:3.12-slim AS builder
WORKDIR /install
COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
WORKDIR /app
COPY src/ /app/
# Runtime image
FROM nvcr.io/nvidia/distroless/python:3.12-v4.2.0
COPY --from=builder /install /usr/local
COPY --from=builder /app /app
WORKDIR /app
ARG COMMIT_ID=unknown
ENV COMMIT_ID=$COMMIT_ID
ENTRYPOINT ["python", "main.py"]
+2
View File
@@ -0,0 +1,2 @@
# service-musicbrainz
+29
View File
@@ -0,0 +1,29 @@
[tool.ruff]
line-length = 120
target-version = "py312"
[tool.ruff.lint]
select = [
"E", "F", # pycodestyle errors, pyflakes (unused imports/variables, undefined names)
"I", # import sorting (replaces isort)
"B", # bugbear: likely bugs
"ASYNC", # async mistakes
"RUF006", # un-referenced asyncio.create_task (tasks can be garbage-collected)
]
ignore = ["E501"]
[tool.ruff.lint.isort]
known-first-party = ["db_module", "secret_manager", "service_auth", "shared_models",
"simple_logger_handler", "send_notifications", "send_notifications_webhook",]
[tool.ruff.format]
indent-style = "tab"
[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
"fastapi.Header",
"fastapi.Body",
"fastapi.Security",
]
+1
View File
@@ -0,0 +1 @@
ruff
+11
View File
@@ -0,0 +1,11 @@
--extra-index-url https://git.gansejunge.com/api/packages/notifier/pypi/simple/
fastapi
lib-db-module
lib-secret-manager
lib-send-notifications
lib-service-auth
lib-shared-models
prometheus_client
pydantic
simple-logger-handler
uvicorn
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Write a copy of requirements.txt without the self-hosted packages.
Usage: filter_requirements.py <input> <output>
"""
import re
import sys
SKIP = {
"lib-db-module",
"lib-secret-manager",
"lib-send-notifications",
"send_notifications_webhook",
"lib-service-auth",
"lib-shared-models",
"simple-logger-handler",
}
NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
def main(src: str, dst: str) -> None:
skipped = []
with open(src) as fin, open(dst, "w") as fout:
for line in fin:
match = NAME_RE.match(line)
if match and match.group(1) in SKIP:
skipped.append(match.group(1))
continue
fout.write(line)
print(f"Skipped {len(skipped)} self-hosted package(s): {', '.join(skipped) or '-'}")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2])
+13
View File
@@ -0,0 +1,13 @@
from enum import StrEnum
from service_auth import KeySource, ServiceAuth
class Caller(StrEnum):
GATEWAY = "gateway"
SCHEDULER = "scheduler"
auth = ServiceAuth({
Caller.GATEWAY: KeySource("GATEWAY_KEY", "/etc/secrets/gateway_key"),
Caller.SCHEDULER: KeySource("SCHEDULER_KEY", "/etc/secrets/scheduler_key"),
})
+47
View File
@@ -0,0 +1,47 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from metrics_server import REQUEST_COUNTER
from simple_logger_handler import setup_logger
logger = setup_logger(__name__)
async def custom_http_exception_handler(request,exc):
logger.debug(f"Handling HTTP exception: {exc}")
if exc.status_code == 404:
return JSONResponse(
status_code=403,
content={"detail": "Unauthorized"}
)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail}
)
async def prometheus_middleware(request: Request, call_next):
logger.debug(
"Incoming request: %s %s",
request.method, request.url.path,
)
status = 500
try:
response = await call_next(request)
status = response.status_code
logger.debug("Request processed with status: %s", status)
except Exception as e:
logger.exception("[FastAPI] Exception during request processing: %s", e)
raise
finally:
REQUEST_COUNTER.labels(request.method, request.url.path, status).inc()
logger.debug(
"[FastAPI] Prometheus counter incremented: %s %s %s",
request.method, request.url.path, status,
)
return response
def register_handlers(app: FastAPI) -> None:
"""Attach the error handlers and the metrics middleware to the app."""
app.add_exception_handler(StarletteHTTPException, custom_http_exception_handler)
app.add_middleware(BaseHTTPMiddleware, dispatch=prometheus_middleware)
+78
View File
@@ -0,0 +1,78 @@
import asyncio
import os
from contextlib import asynccontextmanager
import uvicorn
from fastapi import Depends, FastAPI
from auth_config import Caller, auth
from db_module import (
close_connection_pool,
create_connection_pool,
start_healthcheck_thread,
)
from handlers import register_handlers
from metrics_server import metrics_api
from routers.scheduler import scheduler_router
from routers.user import user_router
from secret_manager import cleanup_secret_files
from send_notifications import NotificationClient
from simple_logger_handler import LOG_LEVEL, setup_logger
logger = setup_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("[App] Starting application...")
app.state.notifier = NotificationClient()
logger.info("[DB] Creating MySQL connection pool...")
create_connection_pool("seadexPool")
start_healthcheck_thread()
logger.info("[DB] MySQL healthcheck thread started.")
SECRET_PATHS = frozenset({
"/etc/secrets/api_key",
"/etc/secrets/db_username",
"/etc/secrets/db_password"
})
cleanup_secret_files(SECRET_PATHS)
yield
logger.info("[App] Closing MySQL connection pool...")
close_connection_pool()
logger.info("[App] Shutdown complete.")
api = FastAPI(
title="Service GENERIC",
description="GENERICGENERICGENERICGENERICGENERIC",
version="1.0.0",
lifespan=lifespan,
dependencies=[Depends(auth.authenticate)],
)
register_handlers(api)
api.include_router(user_router)
api.include_router(scheduler_router)
async def start_servers():
logger.info("[Server] Starting main API (port 5000) and metrics server (port 9000)...")
logger.info("[Server] Build commit id=%s", os.getenv("COMMIT_ID", "unknown"))
auth.load_keys(api, Caller.GATEWAY, Caller.SCHEDULER, Caller.ADMIN)
cleanup_secret_files(auth.key_paths(Caller.GATEWAY, Caller.SCHEDULER, Caller.ADMIN))
config_main = uvicorn.Config(api, host="0.0.0.0", port=5000, log_level=LOG_LEVEL.lower(),log_config=None)
config_metrics = uvicorn.Config(metrics_api, host="0.0.0.0", port=9000, log_level=LOG_LEVEL.lower(),log_config=None)
server_main = uvicorn.Server(config_main)
server_metrics = uvicorn.Server(config_metrics)
await asyncio.gather(server_main.serve(), server_metrics.serve())
logger.info("[Server] Both servers have stopped.")
if __name__ == "__main__":
asyncio.run(start_servers())
+10
View File
@@ -0,0 +1,10 @@
from fastapi import FastAPI, Response
from prometheus_client import CONTENT_TYPE_LATEST, Counter, generate_latest
metrics_api = FastAPI(title="Metrics Server", description="Prometheus metrics endpoint")
REQUEST_COUNTER = Counter("http_requests_total", "Total HTTP Requests", ["method", "endpoint", "status"])
@metrics_api.get("/metrics")
async def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
+22
View File
@@ -0,0 +1,22 @@
from datetime import datetime
from pydantic import BaseModel, Field, computed_field
class GenericEntry(BaseModel):
name: str
custom_name: str | None = None
published: datetime | None = None
class Counts(BaseModel):
active: int
inactive: int
class GenericList(BaseModel):
active: list[GenericEntry] = Field(default_factory=list)
inactive: list[GenericEntry] = Field(default_factory=list)
@computed_field
@property
def counts(self) -> Counts:
return Counts(active=len(self.active),inactive=len(self.inactive))
View File
Whitespace-only changes.
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends, Request
from auth_config import Caller, auth
from db_module import (
get_db,
)
from simple_logger_handler import setup_logger
logger = setup_logger(__name__)
scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))])
@scheduler_router.post("/update")
def check_for_updates(request:Request, db=Depends(get_db)):
return
+42
View File
@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends
from auth_config import Caller, auth
from db_module import (
get_db,
)
from models import GenericList
from service_auth import UserId
from simple_logger_handler import setup_logger
logger = setup_logger(__name__)
user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))])
@user_router.post("/entry/add")
def add_entry(
user_id: UserId,
db=Depends(get_db),
):
return
@user_router.post("/entry/delete")
def remove_anime(
user_id: UserId,
db=Depends(get_db)
):
return
@user_router.get("/entry/list", response_model=GenericList)
def list_active_and_inactive_anime(
user_id: UserId,
db=Depends(get_db),
):
return
@user_router.post("/entry/rename")
def rename_entry(
user_id: UserId,
db=Depends(get_db),
):
return