diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fe590fe --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +# Grouped, weekly dependency updates. Grouping minor+patch into one PR per +# ecosystem fixes the previous pile-up of individual daily PRs; majors still +# arrive separately (those need a human + the CI gate before merging). +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + python-minor-patch: + update-types: ["minor", "patch"] + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + github-actions: + patterns: ["*"] + + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a2a4223 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +# CI for Spring-Boot-Admin-Python-component. +# Unit + e2e tests gate every PR and push (this is a pure-Python service with no +# Dockerfile, so there is no image build step). +name: CI + +on: + push: + branches: [main, master] + tags: ["*"] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: pip install ruff + - run: ruff check app.py configuration.py registration.py registrator.py myservice.py tests/ + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + - name: Unit tests with coverage gate + run: | + pytest tests/unit \ + --cov=app --cov=configuration --cov=registration --cov=registrator --cov=myservice \ + --cov-report=term-missing --cov-report=xml \ + --cov-fail-under=90 --junitxml=pytest-report.xml + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.xml + if-no-files-found: ignore + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + - name: End-to-end app boot test + run: pytest tests/e2e -m e2e -v diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4c62a3c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,26 @@ +name: CodeQL + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + schedule: + - cron: "0 3 * * 1" # weekly, Monday 03:00 UTC + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@v7 + - uses: github/codeql-action/init@v4 + with: + languages: python + build-mode: none + - uses: github/codeql-action/analyze@v4 + with: + category: "/language:python" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..6e3d36d --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,69 @@ +# Auto-merge Dependabot patch/minor PRs — but ONLY when the repository meets the +# organisation's auto-merge bar: +# (1) a significant coverage gate is configured in ci.yml (cov-fail-under >= 70), and +# (2) at least one end-to-end test exists (tests/e2e/). +# The `eligibility` job enforces (1) and (2). The actual merge still waits for the +# required status checks (ci.yml test + e2e) via `gh pr merge --auto`, so a red +# build never merges. +# +# Prerequisites in repo settings: +# * Settings → General → "Allow auto-merge" enabled. +# * Branch protection requires the "test" and "e2e" checks. +name: Dependabot auto-merge + +on: pull_request_target + +permissions: + contents: write + pull-requests: write + +jobs: + eligibility: + if: ${{ github.actor == 'dependabot[bot]' }} + runs-on: ubuntu-latest + outputs: + eligible: ${{ steps.gate.outputs.eligible }} + steps: + - uses: actions/checkout@v7 + - id: gate + name: Require significant coverage + an e2e test + run: | + set -euo pipefail + eligible=true + + # (1) significant coverage gate + threshold=$(grep -ohE 'cov-fail-under=[0-9]+' .github/workflows/ci.yml \ + | grep -oE '[0-9]+' | sort -n | head -1 || true) + if [ -z "${threshold:-}" ] || [ "$threshold" -lt 70 ]; then + echo "::warning::No significant coverage gate (cov-fail-under >= 70) found — auto-merge disabled." + eligible=false + else + echo "Coverage gate: cov-fail-under=$threshold" + fi + + # (2) at least one end-to-end test + if ! compgen -G 'tests/e2e/*.py' > /dev/null; then + echo "::warning::No end-to-end test found under tests/e2e/ — auto-merge disabled." + eligible=false + else + echo "Found end-to-end test(s) under tests/e2e/." + fi + + echo "eligible=$eligible" >> "$GITHUB_OUTPUT" + + auto-merge: + needs: eligibility + if: ${{ needs.eligibility.outputs.eligible == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@v3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Enable auto-merge for patch & minor updates + if: ${{ steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.update-type == 'version-update:semver-minor' }} + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/app.py b/app.py index 79caa6c..f7f49a1 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ import logging import argparse -from flask import Flask, render_template, jsonify, request +from flask import Flask, render_template from datetime import datetime from registration import Registration @@ -39,7 +39,7 @@ def about(): return render_template("about.html", configuration=configuration) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover logging.basicConfig(format='%(asctime)s - %(message)s', level=logging.INFO) # allow configuration of the configfile via command line parameters diff --git a/configuration.py b/configuration.py index 85275eb..3cbbc34 100644 --- a/configuration.py +++ b/configuration.py @@ -32,7 +32,7 @@ def integrateDataFromConfigfile(self) -> None: if configvalues[key]: try: self.demandedConfigurationKeys.remove(key) - except: + except ValueError: pass logging.debug("configuration: %s=%s" % (key, configvalues[key])) diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..f7bff21 --- /dev/null +++ b/conftest.py @@ -0,0 +1 @@ +# Ensures the repository root is importable by tests. diff --git a/myservice.py b/myservice.py index 7e3c274..cb6c90e 100644 --- a/myservice.py +++ b/myservice.py @@ -1,4 +1,4 @@ -from flask import Blueprint, Flask, render_template, jsonify, request +from flask import Blueprint, jsonify, request myservice = Blueprint('myservice', __name__, template_folder='templates') diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2c72f5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +markers = + e2e: end-to-end tests that boot the Flask app (slower, need full dependencies) diff --git a/registrator.py b/registrator.py index 944c8a5..b11437c 100644 --- a/registrator.py +++ b/registrator.py @@ -3,7 +3,7 @@ import requests import json import logging -from requests.auth import AuthBase, HTTPBasicAuth +from requests.auth import HTTPBasicAuth class Registrator(threading.Thread): diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..57ed9ed --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# Tooling for local development and CI (kept separate from the app runtime deps). +pytest==9.1.1 +pytest-cov==7.1.0 +ruff==0.15.20 diff --git a/requirements.txt b/requirements.txt index ed47f5a..ab58ca1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,5 @@ # pip install -r requirements.txt # pip install --user -r requirements.txt Flask -pprint requests -configparser -argparse -waitress \ No newline at end of file +waitress diff --git a/tests/e2e/test_app_boots.py b/tests/e2e/test_app_boots.py new file mode 100644 index 0000000..8031ac0 --- /dev/null +++ b/tests/e2e/test_app_boots.py @@ -0,0 +1,12 @@ +"""End-to-end smoke test: the Flask app boots and serves its health + blueprint.""" +import pytest + +import app as app_module + +pytestmark = pytest.mark.e2e + + +def test_app_boots_and_serves_endpoints(): + client = app_module.app.test_client() + assert client.get("/health").data == b"alive" + assert client.get("/").data == b"Hello, World!" diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py new file mode 100644 index 0000000..b5a1c4b --- /dev/null +++ b/tests/unit/test_app.py @@ -0,0 +1,28 @@ +"""Endpoint tests for the Flask app and the myservice blueprint.""" +import app as app_module + +client = app_module.app.test_client() + + +def test_health_returns_alive(): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.data == b"alive" + + +def test_about_renders_without_configuration(): + resp = client.get("/about") + assert resp.status_code == 200 + assert b"