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
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -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"
69 changes: 69 additions & 0 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -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 }}
4 changes: 2 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]))

Expand Down
1 change: 1 addition & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Ensures the repository root is importable by tests.
2 changes: 1 addition & 1 deletion myservice.py
Original file line number Diff line number Diff line change
@@ -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')

Expand Down
4 changes: 4 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
markers =
e2e: end-to-end tests that boot the Flask app (slower, need full dependencies)
2 changes: 1 addition & 1 deletion registrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
5 changes: 1 addition & 4 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# pip install -r requirements.txt
# pip install --user -r requirements.txt
Flask
pprint
requests
configparser
argparse
waitress
waitress
12 changes: 12 additions & 0 deletions tests/e2e/test_app_boots.py
Original file line number Diff line number Diff line change
@@ -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!"
28 changes: 28 additions & 0 deletions tests/unit/test_app.py
Original file line number Diff line number Diff line change
@@ -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"<html" in resp.data


def test_blueprint_index_get():
resp = client.get("/")
assert resp.status_code == 200
assert resp.data == b"Hello, World!"


def test_blueprint_index_post_returns_json():
resp = client.post("/")
assert resp.status_code == 200
assert resp.get_json() == {"Hello": "World"}
44 changes: 44 additions & 0 deletions tests/unit/test_configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Tests for configuration.Configuration: parsing a config file and validating
that all demanded keys are present."""
import pytest

from configuration import Configuration

CONF = """[ServiceConfiguration]
springbootadminserverurl = http://localhost:8080
servicename = my-service
serviceport = 5000
servicehost = http://127.0.0.1
servicedescription = a test service
"""


def _write(tmp_path, text):
path = tmp_path / "app.conf"
path.write_text(text)
return str(path)


def test_reads_values_as_attributes(tmp_path):
cfg = Configuration(_write(tmp_path, CONF), [])
assert cfg.servicename == "my-service"
assert cfg.serviceport == "5000"
assert cfg.springbootadminserverurl == "http://localhost:8080"


def test_passes_when_all_demanded_keys_present(tmp_path):
cfg = Configuration(_write(tmp_path, CONF), ["servicename", "serviceport"])
# all demanded keys were found, so none remain outstanding
assert cfg.demandedConfigurationKeys == []


def test_raises_when_demanded_key_missing(tmp_path):
with pytest.raises(Exception) as exc:
Configuration(_write(tmp_path, CONF), ["servicename", "absent_key"])
assert "absent_key" in str(exc.value)


def test_raises_when_config_file_missing(tmp_path):
with pytest.raises(Exception) as exc:
Configuration(str(tmp_path / "does-not-exist.conf"), [])
assert "no configuration" in str(exc.value)
25 changes: 25 additions & 0 deletions tests/unit/test_registration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Registration is a simple value object mirroring the Spring Boot Admin class."""
from registration import Registration


def test_stores_all_fields():
reg = Registration(
name="svc",
healthUrl="http://h/health",
serviceUrl="http://h",
managementUrl="http://h/mgmt",
source="http",
metadata={"k": "v"},
)
assert reg.name == "svc"
assert reg.healthUrl == "http://h/health"
assert reg.serviceUrl == "http://h"
assert reg.managementUrl == "http://h/mgmt"
assert reg.metadata == {"k": "v"}


def test_optional_fields_default_to_none():
reg = Registration(name="svc", healthUrl="http://h/health")
assert reg.managementUrl is None
assert reg.serviceUrl is None
assert reg.source is None
Loading
Loading